mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-02 12:52:17 +03:00
Compare commits
17 Commits
dependabot
...
fix/v3851-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
705aef5c60 | ||
|
|
713440be0a | ||
|
|
a298dc6b73 | ||
|
|
451dd73870 | ||
|
|
9327990be6 | ||
|
|
af0a9609f4 | ||
|
|
24b784e9bb | ||
|
|
96824288f5 | ||
|
|
678e6077e4 | ||
|
|
5a0a131bc7 | ||
|
|
c702a27eda | ||
|
|
cf53b9220f | ||
|
|
d2a027a156 | ||
|
|
17a53d2eb9 | ||
|
|
7f25d67d07 | ||
|
|
1586476183 | ||
|
|
c9f9b6274e |
33
.env.example
33
.env.example
@@ -1470,17 +1470,15 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# FIRECRAWL_BASE_URL=https://api.firecrawl.dev
|
||||
# FIRECRAWL_TIMEOUT_MS=30000 # Per-request timeout (default: 30000 = 30s)
|
||||
|
||||
# ── Claude TLS sidecar (Chromium-fingerprinted client) ──
|
||||
# Used by: open-sse/services/claudeTlsClient.ts — wire-level timeout for
|
||||
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
|
||||
# layered on top of it when the native library is wedged.
|
||||
# ── Claude TLS transport (Chromium-fingerprinted client) ──
|
||||
# Used by: open-sse/services/claudeTlsClient.ts — native wreq-js request timeout
|
||||
# plus the absolute JS hard-deadline grace when the native request is wedged.
|
||||
# OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS=60000
|
||||
# OMNIROUTE_CLAUDE_TLS_GRACE_MS=10000
|
||||
|
||||
# ── Perplexity TLS sidecar (Firefox-fingerprinted client) ──
|
||||
# Used by: open-sse/services/perplexityTlsClient.ts — wire-level timeout for
|
||||
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
|
||||
# layered on top of it when the native library is wedged.
|
||||
# ── Perplexity TLS transport (Firefox-fingerprinted client) ──
|
||||
# Used by: open-sse/services/perplexityTlsClient.ts — native wreq-js request
|
||||
# timeout plus the absolute JS hard-deadline grace.
|
||||
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
|
||||
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
|
||||
|
||||
@@ -1492,18 +1490,16 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# meta-commentary. Set to 1/true/yes/on to restore the old behavior.
|
||||
# OMNIROUTE_PPLX_SEARCH_HINT=0
|
||||
|
||||
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
|
||||
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
|
||||
# top of it when the native library is wedged.
|
||||
# ── Grok web TLS transport (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/grokTlsClient.ts — native wreq-js request timeout
|
||||
# plus the absolute JS hard-deadline grace.
|
||||
# OMNIROUTE_GROK_TLS_TIMEOUT_MS=60000
|
||||
# OMNIROUTE_GROK_TLS_GRACE_MS=10000
|
||||
|
||||
# ── Notion web TLS sidecar (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/notionTlsClient.ts — wire-level timeout for the
|
||||
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
|
||||
# top of it when the native library is wedged. The notion-web executor raises
|
||||
# the wire timeout per-request to 180000 for long generations.
|
||||
# ── Notion web TLS transport (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/notionTlsClient.ts — native wreq-js request timeout
|
||||
# plus the absolute JS hard-deadline grace. The notion-web executor raises the
|
||||
# native timeout per request to 180000 for long generations.
|
||||
# OMNIROUTE_NOTION_TLS_TIMEOUT_MS=30000
|
||||
# OMNIROUTE_NOTION_TLS_GRACE_MS=10000
|
||||
|
||||
@@ -2398,6 +2394,9 @@ APP_LOG_TO_FILE=true
|
||||
# Bundled Codeium/language-server extension_version, distinct from Desktop.
|
||||
# Must use x.y.z format; invalid/unset values use the bundled default 1.48.2.
|
||||
# DEVIN_DESKTOP_EXTENSION_VERSION=1.48.2
|
||||
# Optional override for the Codeium seat-management API used by Devin CLI quota.
|
||||
# Used by: open-sse/services/usage/devinCli.ts. Default: https://server.codeium.com
|
||||
# DEVIN_SEAT_API_URL=https://server.codeium.com
|
||||
|
||||
# ── Command Code (custom CLI) callback ──
|
||||
# Local port used for OAuth-style callbacks from the Command Code CLI helper.
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -535,7 +535,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4.37.9
|
||||
uses: github/codeql-action/upload-sarif@v4.37.8
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
20
.github/workflows/electron-release.yml
vendored
20
.github/workflows/electron-release.yml
vendored
@@ -187,6 +187,22 @@ jobs:
|
||||
env:
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS: true
|
||||
|
||||
# The Linux leg produces x64 + arm64 installers from one x64 runner. npm
|
||||
# deliberately installs only host-compatible optional dependencies, so
|
||||
# hydrateNativeDeps cannot source the arm64 fork unless we fetch the exact
|
||||
# package pinned in package-lock before either build path runs.
|
||||
- name: Install Linux arm64 wreq binding for cross-package
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install --no-save --ignore-scripts --force --legacy-peer-deps \
|
||||
@wreq-js/binding-linux-arm64-gnu@3.2.0
|
||||
git diff --exit-code -- package.json package-lock.json
|
||||
mkdir -p "$RUNNER_TEMP/omniroute-wreq-verify"
|
||||
DATA_DIR="$RUNNER_TEMP/omniroute-wreq-verify" node --import tsx/esm --test \
|
||||
--test-name-pattern='wreq-js 3.2 manifest pins all nine' \
|
||||
tests/unit/wreq-native-manifest.test.ts
|
||||
|
||||
- name: Sanitize Windows home directory
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
@@ -235,9 +251,9 @@ jobs:
|
||||
# targets, and no unlisted files) byte-for-byte.
|
||||
# hydrate: the bundle was built on ubuntu, so install-machine-forked native
|
||||
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
|
||||
# fsevents) carry linux forks. Replace them with the forks this
|
||||
# @wreq-js/binding-*, fsevents) carry linux forks. Replace them with the forks this
|
||||
# leg's own `npm ci` resolved, then assert every bundled native
|
||||
# (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime)
|
||||
# (better-sqlite3 prebuilds, wreq-js, onnxruntime)
|
||||
# can service this leg's platform/arch before packaging starts.
|
||||
run: |
|
||||
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz
|
||||
|
||||
10
.trivyignore
10
.trivyignore
@@ -18,13 +18,3 @@
|
||||
#
|
||||
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
|
||||
# patched base / bump the dep) over suppressing. Stale entries are debt.
|
||||
#
|
||||
# CVE-2025-68121 — Go stdlib crypto/tls (session-resumption certificate validation)
|
||||
# inside the PREBUILT bogdanfinn/tls-client v1.15.1 .so that tls-client-node's
|
||||
# postinstall downloads (built with go 1.24.1; fixed in 1.24.13). No upstream
|
||||
# rebuild exists (v1.15.1 is still the latest release) and nothing in this repo
|
||||
# can bump it. The binary is only loaded by the browser-TLS web-provider
|
||||
# executors (claude-web / grok-web / lmarena / perplexity-web / notion-web),
|
||||
# whose handshakes go through utls. Tracking issue: #12084. Revisit at the next
|
||||
# tls-client release or base-image bump and BEFORE the v3.8.51 tag (2026-09-15).
|
||||
CVE-2025-68121
|
||||
|
||||
@@ -177,6 +177,13 @@ npm run test:all
|
||||
# Single test file (Node.js native test runner — most tests use this)
|
||||
node --import tsx/esm --test tests/unit/your-file.test.ts
|
||||
|
||||
# Only the unit tests impacted by your change (same TIA selector as the CI gate, #8084)
|
||||
npm run test:scoped # changes in the last commit (or the working tree)
|
||||
npm run test:scoped:staged # staged changes only — pairs well with a pre-commit run
|
||||
npm run test:scoped:full # rebuild the import-graph map first (after adding/moving files)
|
||||
# Exit 1 + "run the full suite" means a hub file (tsconfig, package.json, …) or an
|
||||
# unmapped source changed — the selector fails safe, it never silently skips.
|
||||
|
||||
# Vitest (MCP server, autoCombo, cache)
|
||||
npm run test:vitest
|
||||
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -103,25 +103,12 @@ RUN test -f package-lock.json \
|
||||
# node-gyp comes from npm's own bundled copy (deterministic, already in the image)
|
||||
# instead of `npx --yes`, which would install an arbitrary registry version
|
||||
# on-demand and run its lifecycle scripts (Sonar docker:S6505).
|
||||
#
|
||||
# tls-client-node (claude-web/grok-web/lmarena/perplexity-web TLS
|
||||
# impersonation) hits the same --ignore-scripts wall: its own postinstall.js
|
||||
# fetches a platform .so/.dylib/.dll from the bogdanfinn/tls-client GitHub
|
||||
# Releases API and is never invoked when npm ci skips lifecycle scripts. Unlike
|
||||
# better-sqlite3 above, that script never throws on failure — it only
|
||||
# `console.warn`s and exits 0 — so a rate-limited or offline build would
|
||||
# otherwise succeed silently with an empty bin/ and only fail at first request
|
||||
# in production (TlsClientUnavailableError, #7802). Run it explicitly here so
|
||||
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
|
||||
# broken image.
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
|
||||
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& (cd node_modules/better-sqlite3 \
|
||||
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()" \
|
||||
&& node node_modules/tls-client-node/scripts/postinstall.js \
|
||||
&& (test -n "$(find node_modules/tls-client-node/bin -mindepth 1 -print -quit 2>/dev/null)" \
|
||||
|| (echo "tls-client-node native binary missing after postinstall — GitHub API fetch likely rate-limited or failed (#7802)" >&2 && exit 1))
|
||||
&& node -e "const wreq=require('wreq-js'); if(typeof wreq.createTransport!=='function') process.exit(1)"
|
||||
|
||||
# Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era
|
||||
# TurbopackInternalError panic ("entered unreachable code: there must be a path to a
|
||||
|
||||
@@ -31,10 +31,8 @@ COPY scripts/dev/sync-env.mjs ./scripts/dev/sync-env.mjs
|
||||
# Fast Bun native package install
|
||||
RUN bun install --include=optional --quiet
|
||||
|
||||
# Fetch tls-client-node native binary if script exists
|
||||
RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ] && [ ! -d "node_modules/tls-client-node/bin" ]; then \
|
||||
bun node_modules/tls-client-node/scripts/postinstall.js || true; \
|
||||
fi
|
||||
# Fail the build if wreq-js cannot resolve its current platform binding.
|
||||
RUN bun -e "const wreq = require('wreq-js'); if (typeof wreq.createTransport !== 'function') process.exit(1)"
|
||||
|
||||
# Smoke check native database driver used by Bun (bun:sqlite)
|
||||
RUN bun -e "import { Database } from 'bun:sqlite'; const db = new Database(':memory:'); db.query('SELECT 1 AS ok').get(); db.close(); console.log('bun:sqlite smoke: OK');"
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## wreq-js 3.2.0 native transport
|
||||
|
||||
OmniRoute ships `wreq-js@3.2.0` and its platform-specific native bindings for browser-
|
||||
fingerprinted HTTP transport. The npm package and all nine binding tarballs are tied by npm SLSA
|
||||
attestations to signed tag `v3.2.0` and immutable source commit
|
||||
[`0d52d5fa252841aeef34d4d063b1766a59612bf7`](https://github.com/sqdshguy/wreq-js/commit/0d52d5fa252841aeef34d4d063b1766a59612bf7).
|
||||
|
||||
- Root tarball:
|
||||
<https://registry.npmjs.org/wreq-js/-/wreq-js-3.2.0.tgz>
|
||||
- npm integrity:
|
||||
`sha512-dawhEbhvd5hxivKZSvv/mAQGO3mwZYESyctOvIIZ/H3DvQJzUM2UoFQsij0fg7hIClQ/GEQgg+2259UcFwhpMQ==`
|
||||
- Exact platform, integrity, size, and SHA-256 receipts for all nine native addons:
|
||||
[`config/release/wreq-js-native-manifest.json`](config/release/wreq-js-native-manifest.json)
|
||||
- Locked per-target Cargo closure, with runtime and compile-only packages kept separate:
|
||||
[`config/release/wreq-js-rust-license-inventory.json`](config/release/wreq-js-rust-license-inventory.json)
|
||||
- Deduplicated license texts and attribution notices for the conservative native runtime closure,
|
||||
including patched BoringSSL, Unicode ICU4X components, and Mozilla root-certificate data:
|
||||
[`config/release/wreq-js-rust-notices.md`](config/release/wreq-js-rust-notices.md)
|
||||
|
||||
The native tarballs themselves contain no LICENSE/NOTICE file. The bundled inventory is therefore
|
||||
shipped beside them. It intentionally over-approximates the locked link-eligible Cargo closure;
|
||||
exact post-LTO membership cannot be claimed without an upstream artifact SBOM/link map or a
|
||||
reproducible-build receipt. The Android addon also dynamically requires `libc++_shared.so`, which
|
||||
is not included in its npm tarball; any artifact that supplies that library needs its separate
|
||||
LLVM/Apache-with-LLVM-exception notice.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 will-work-for-meal
|
||||
Copyright (c) 2025 Oleksandr Herasymov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## codex-chatgpt-web
|
||||
|
||||
Parts of `open-sse/vendor/codex-chatgpt-web/` are adapted from
|
||||
|
||||
1
changelog.d/features/11783-react-compiler.md
Normal file
1
changelog.d/features/11783-react-compiler.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(ui):** enable React Compiler (`reactCompiler: true` + `babel-plugin-react-compiler`) for automatic memoization at build time ([#11783](https://github.com/diegosouzapw/OmniRoute/pull/11783)) — thanks @jonlwheat2-gif
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(sse):** treat `max` as a first-class reasoning-effort tier and clamp per model family (GLM 5.1+/DeepSeek V4+/Kimi K3+ keep native `max`; o1/MiniMax/Grok/Muse Spark clamp to their upstream ceiling) ([#11875](https://github.com/diegosouzapw/OmniRoute/pull/11875)) — thanks @Chewji9875
|
||||
@@ -0,0 +1 @@
|
||||
- Add a runtime feature flag to disable universal context handoffs globally without changing the default behavior.
|
||||
1
changelog.d/features/12214-usage-supported-capability.md
Normal file
1
changelog.d/features/12214-usage-supported-capability.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** the provider plugin manifest now also advertises a `usage-supported` capability for the 46 providers whose usage API is accepted by the server and Dashboard routes, so integrators can distinguish "the server will serve quota for this provider" from "a fetcher is wired" without reading TypeScript. Discovery only — no fetcher or quota change. `usage-fetch` resolves on id or alias (the usage dispatcher accepts both); `usage-supported` resolves on id alone, matching the runtime guard `USAGE_SUPPORTED_PROVIDERS.includes(providerId)`. `USAGE_SUPPORTED_PROVIDERS` moved to a zero-dependency leaf (`open-sse/services/usage/supportedProviders.ts`) and is re-exported from `providers.ts`, mirroring the `fetcherProviders` leaf from #11903 and keeping the manifest a light module. ([#12214](https://github.com/diegosouzapw/OmniRoute/pull/12214)) — thanks @maxmad64bis
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(usage):** Devin CLI agentic quota (Codeium seat-management GetUserStatus) and OpenRouter key limits plus account credits now surface in Provider Limits ([#12256](https://github.com/diegosouzapw/OmniRoute/pull/12256) — thanks @Neuron-Mr-White)
|
||||
1
changelog.d/features/12342-radar-optin-access-rules.md
Normal file
1
changelog.d/features/12342-radar-optin-access-rules.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(radar):** explain Community, single-use, contributor, supporter, recovery, abuse, offers, and privacy rules before either Radar activation action, and remove the superseded fixed-PR grant promise from every UI locale ([#12342](https://github.com/diegosouzapw/OmniRoute/pull/12342))
|
||||
11
changelog.d/features/orchestration-canvas-fase2-quickwins.md
Normal file
11
changelog.d/features/orchestration-canvas-fase2-quickwins.md
Normal file
@@ -0,0 +1,11 @@
|
||||
- **feat(dashboard):** Orchestration canvas quick wins — search box plus state/source/provider
|
||||
filter chips with a one-click clear, and per-source collapse/expand, all reflected in the URL
|
||||
so a filtered/collapsed view is shareable and survives a refresh; the detail drawer gained a
|
||||
"copy trace JSON" action and hardened error/empty-state and accessibility handling; the
|
||||
Agents-tab edges now animate traveling particles along active (running) connections; and the
|
||||
canvas node/edge status colors moved off fixed hex values onto theme-aware `--orch-status-*`
|
||||
CSS custom properties, so they adapt correctly to light/dark mode.
|
||||
- **chore(dashboard):** Orchestration UI hardening pass and the missing component/model test
|
||||
coverage it called for — `OrchestratorNode`/`ActivityNode`/`OverflowNode` rendering, the
|
||||
`?node=`/overflow-click page routing, the Agents-tab orchestrator-click no-op and
|
||||
`showCompleted` toggle, and the overview kanban's done-column sort order (#12270, #12271).
|
||||
1
changelog.d/fixes/11844-compression-oom-mitigations.md
Normal file
1
changelog.d/fixes/11844-compression-oom-mitigations.md
Normal file
@@ -0,0 +1 @@
|
||||
- **perf(compression):** OOM mitigations for large payload hashing, memoization, and token estimation ([#11844](https://github.com/diegosouzapw/OmniRoute/pull/11844) — thanks @AndrianBalanescu)
|
||||
5
changelog.d/fixes/12180-embedding-lazy-probe.md
Normal file
5
changelog.d/fixes/12180-embedding-lazy-probe.md
Normal file
@@ -0,0 +1,5 @@
|
||||
- **fix(memory):** self-hosted embedding endpoints now vectorize — the vector width is
|
||||
measured from the first embedding that comes back instead of being read from a registry
|
||||
that cannot describe them, so `vec_memories` is created and memories stop piling up
|
||||
unvectorized behind a green health check
|
||||
([#12180](https://github.com/diegosouzapw/OmniRoute/pull/12180)) — thanks @kanade-hoshino
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(memory):** Embedding Model Quick select, Embedding Source remote dropdown, and Rerank selector now list every configured provider with embedding/rerank support instead of only chat-catalog text matches plus OpenRouter live discovery; a generic OpenAI-compatible `/embeddings` + Cohere-compatible `/rerank` runtime fallback resolves any configured chat provider's embedding/rerank endpoint, so unlisted providers no longer fail with "Unknown embedding provider"; both memory selectors gained a free-text model override
|
||||
1
changelog.d/fixes/v3851-wreq-js-web-cookie-transport.md
Normal file
1
changelog.d/fixes/v3851-wreq-js-web-cookie-transport.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(providers):** Claude, Grok, LMArena, Notion, and Perplexity web-cookie transports now use pooled `wreq-js` 3.2 instead of the native sidecar, with all nine supported bindings pinned and audited, and the applicable platform binding plus native-license evidence included in each release artifact.
|
||||
@@ -74,12 +74,6 @@
|
||||
"justification": "CC-BY-4.0 applies to the caniuse browser-support data (a dataset, not code). The Creative Commons Attribution license requires attribution when distributing — OmniRoute does not distribute caniuse-lite data directly to end users; it is consumed by browserslist/PostCSS at build time to generate CSS compatibility info. This is a widely accepted pattern in the Node.js ecosystem (caniuse-lite is in millions of projects). Attribution is satisfied by keeping the package in node_modules with its original license file.",
|
||||
"risk": "low",
|
||||
"reviewAt": "v4.0.0"
|
||||
},
|
||||
"tls-client-node": {
|
||||
"license": "Custom: LICENSE (Apache-2.0 + Commons Clause)",
|
||||
"justification": "TODO: revisar — tls-client-node uses Apache-2.0 with a 'Commons Clause' addendum that restricts 'Selling' the software (i.e., offering it as a hosted/commercial service whose value derives substantially from tls-client-node). OmniRoute is an open-source proxy; however if deployed as a paid SaaS/hosting service, this restriction could apply. The package is used by grokTlsClient.ts for Grok TLS fingerprinting. RISK: medium — legal review recommended before commercial deployment. Alternatives: consider replacing with a native TLS fingerprinting approach or a truly permissive library.",
|
||||
"risk": "medium",
|
||||
"reviewAt": "v3.9.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"_justifications": {
|
||||
"@testing-library/dom": "Peer dep obrigatoria de @testing-library/react v16 (adicionada no PR #11224); Refs #9985.",
|
||||
"@testing-library/user-event": "Utilitario oficial do ecossistema testing-library para testes de UI (adicionada no PR #11224); Refs #9985.",
|
||||
"babel-plugin-react-compiler": "Official React Compiler Babel plugin (facebook/react, MIT). Required peer of Next.js 16 `reactCompiler: true`; Next declares it optional (`*`) and does not auto-install. Added by PR #11783 / issue #67.",
|
||||
"eslint-plugin-react-hooks": "React Hooks lint rules (set-state-in-effect, immutability, refs, purity) pinned at 7.0.1 by the release/v3.8.51 cycle; the 224 findings it raised are tracked in #11924. Refs #11924."
|
||||
},
|
||||
"allowed": [
|
||||
@@ -44,6 +45,7 @@
|
||||
"ajv",
|
||||
"ajv-formats",
|
||||
"axios",
|
||||
"babel-plugin-react-compiler",
|
||||
"bcryptjs",
|
||||
"better-sqlite3",
|
||||
"bottleneck",
|
||||
@@ -139,7 +141,6 @@
|
||||
"tailwind-merge",
|
||||
"tailwindcss",
|
||||
"tiktoken",
|
||||
"tls-client-node",
|
||||
"tsup",
|
||||
"tsx",
|
||||
"turndown",
|
||||
|
||||
159
config/release/wreq-js-native-manifest.json
Normal file
159
config/release/wreq-js-native-manifest.json
Normal file
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"package": "wreq-js",
|
||||
"version": "3.2.0",
|
||||
"license": "MIT",
|
||||
"source": {
|
||||
"repository": "https://github.com/sqdshguy/wreq-js",
|
||||
"commit": "0d52d5fa252841aeef34d4d063b1766a59612bf7",
|
||||
"signedTag": "v3.2.0",
|
||||
"signedTagObject": "dfb277d51aa03d8c6ada9a0d78ba00bc8568150b",
|
||||
"buildWorkflow": "https://github.com/sqdshguy/wreq-js/actions/runs/32649967431/attempts/1",
|
||||
"attestation": "https://registry.npmjs.org/-/npm/v1/attestations/wreq-js@3.2.0",
|
||||
"licenseUrl": "https://raw.githubusercontent.com/sqdshguy/wreq-js/0d52d5fa252841aeef34d4d063b1766a59612bf7/LICENSE",
|
||||
"licenseSha256": "f5e211eaa1c732f23cae866f00c7a0d9f458cbb6e37051170a3f7bb45c2e5d8e"
|
||||
},
|
||||
"npm": {
|
||||
"tarball": "https://registry.npmjs.org/wreq-js/-/wreq-js-3.2.0.tgz",
|
||||
"integrity": "sha512-dawhEbhvd5hxivKZSvv/mAQGO3mwZYESyctOvIIZ/H3DvQJzUM2UoFQsij0fg7hIClQ/GEQgg+2259UcFwhpMQ=="
|
||||
},
|
||||
"nativeAddons": [
|
||||
{
|
||||
"target": "android-arm64",
|
||||
"package": "@wreq-js/binding-android-arm64",
|
||||
"version": "3.2.0",
|
||||
"platform": "android",
|
||||
"arch": "arm64",
|
||||
"tarball": "https://registry.npmjs.org/@wreq-js/binding-android-arm64/-/binding-android-arm64-3.2.0.tgz",
|
||||
"integrity": "sha512-PRsy18Z+0fftLeDvFTQwpgdepihRk6oVzdQWt92hEdarI7DexhgDJvvZfDsylMp7GDfsys9sFks8nIAi4n7eKQ==",
|
||||
"path": "wreq-js.android-arm64.node",
|
||||
"size": 9746720,
|
||||
"sha256": "10cfed8b7f8ce5767d74188bcc2c249f9b0102e8ae90b381b85ec53fbd84c59f"
|
||||
},
|
||||
{
|
||||
"target": "darwin-arm64",
|
||||
"package": "@wreq-js/binding-darwin-arm64",
|
||||
"version": "3.2.0",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"tarball": "https://registry.npmjs.org/@wreq-js/binding-darwin-arm64/-/binding-darwin-arm64-3.2.0.tgz",
|
||||
"integrity": "sha512-TGbgqj7YKp6m2p79hyLtTBatKgU8SKEVL5e903KGSeSDKkLbgk8knFoZ2MakhJnlqKZhvLPCNLT7A3AStwIoHQ==",
|
||||
"path": "wreq-js.darwin-arm64.node",
|
||||
"size": 7754432,
|
||||
"sha256": "f426855858e4c661361a93440ed5fd5bd1e4f6926b3b1c0bf8449bdfe35d0936"
|
||||
},
|
||||
{
|
||||
"target": "darwin-x64",
|
||||
"package": "@wreq-js/binding-darwin-x64",
|
||||
"version": "3.2.0",
|
||||
"platform": "darwin",
|
||||
"arch": "x64",
|
||||
"tarball": "https://registry.npmjs.org/@wreq-js/binding-darwin-x64/-/binding-darwin-x64-3.2.0.tgz",
|
||||
"integrity": "sha512-89JkGsik49nUcQR7HfO6M+Na3whkhAQBghVFWn+vGmz32RzTX+HVy6q7wThjN+XGT+xvn9ZQpzTie3B292S50g==",
|
||||
"path": "wreq-js.darwin-x64.node",
|
||||
"size": 8249144,
|
||||
"sha256": "ef00da7db372d5a71403a17f8067655f7313ae58816150ec4a00680546b35f27"
|
||||
},
|
||||
{
|
||||
"target": "linux-arm64-gnu",
|
||||
"package": "@wreq-js/binding-linux-arm64-gnu",
|
||||
"version": "3.2.0",
|
||||
"platform": "linux",
|
||||
"arch": "arm64",
|
||||
"libc": "gnu",
|
||||
"tarball": "https://registry.npmjs.org/@wreq-js/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-3.2.0.tgz",
|
||||
"integrity": "sha512-WXqMK7AtOxMJAdwDpnAdDq0NZqf6wuRucKCQWQSLdSTUyTEuGo2anRkUsZwqvHbLrkWCTXNfl1hAfA+wIk/4kw==",
|
||||
"path": "wreq-js.linux-arm64-gnu.node",
|
||||
"size": 8669896,
|
||||
"sha256": "5a515d02c9693f1440aa88da7a6a09332fb93844f66590e6eb1be582284a96e2"
|
||||
},
|
||||
{
|
||||
"target": "linux-arm64-musl",
|
||||
"package": "@wreq-js/binding-linux-arm64-musl",
|
||||
"version": "3.2.0",
|
||||
"platform": "linux",
|
||||
"arch": "arm64",
|
||||
"libc": "musl",
|
||||
"tarball": "https://registry.npmjs.org/@wreq-js/binding-linux-arm64-musl/-/binding-linux-arm64-musl-3.2.0.tgz",
|
||||
"integrity": "sha512-YSMWs3BNBCNhWvIAUHWyp2K/L17qxfaRTl+t97ykOIOIKZSduNYZn/Yn3hTNtpbdfrTmNMG9pltEcbixFKS4xQ==",
|
||||
"path": "wreq-js.linux-arm64-musl.node",
|
||||
"size": 8530208,
|
||||
"sha256": "85dd40b3059b9fb1fc11923e0fca98ab2fff7bfe850aeb4dc18f8812e7125b07"
|
||||
},
|
||||
{
|
||||
"target": "linux-x64-gnu",
|
||||
"package": "@wreq-js/binding-linux-x64-gnu",
|
||||
"version": "3.2.0",
|
||||
"platform": "linux",
|
||||
"arch": "x64",
|
||||
"libc": "gnu",
|
||||
"tarball": "https://registry.npmjs.org/@wreq-js/binding-linux-x64-gnu/-/binding-linux-x64-gnu-3.2.0.tgz",
|
||||
"integrity": "sha512-6N7C1uc1qieM23rdKR5k07hfS50hVFExVHzLhHiWbmk9NyqBj0xyj2Mh5ThIrvz/or/6Pe79P8D7UWbl4aJTkw==",
|
||||
"path": "wreq-js.linux-x64-gnu.node",
|
||||
"size": 9110176,
|
||||
"sha256": "32be0fe79325ee55216ac844130997ae24ff3df15570357194a8e7c6ae262743"
|
||||
},
|
||||
{
|
||||
"target": "linux-x64-musl",
|
||||
"package": "@wreq-js/binding-linux-x64-musl",
|
||||
"version": "3.2.0",
|
||||
"platform": "linux",
|
||||
"arch": "x64",
|
||||
"libc": "musl",
|
||||
"tarball": "https://registry.npmjs.org/@wreq-js/binding-linux-x64-musl/-/binding-linux-x64-musl-3.2.0.tgz",
|
||||
"integrity": "sha512-0h0xJsmhVlmh+vHs9dYMIp5lpkKGNZrSedkl2Mh9XmR5slBahTcHT7oEEclhV+aNcY3V2Afmmqfil5huL+yDpA==",
|
||||
"path": "wreq-js.linux-x64-musl.node",
|
||||
"size": 9036248,
|
||||
"sha256": "34c43f6694dfa5c749771f14bd19a4d4823707d428bc12d7d141ffa3176dccd6"
|
||||
},
|
||||
{
|
||||
"target": "win32-arm64-msvc",
|
||||
"package": "@wreq-js/binding-win32-arm64-msvc",
|
||||
"version": "3.2.0",
|
||||
"platform": "win32",
|
||||
"arch": "arm64",
|
||||
"tarball": "https://registry.npmjs.org/@wreq-js/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-3.2.0.tgz",
|
||||
"integrity": "sha512-6bfaVFfbI61s5YLgQL+43uURk/VuOu7TPlUwY1l0Q9yJdWzD+Jpdokgl5cEiY2a0FEAw8Q4LqPI+XZT3YAMnQA==",
|
||||
"path": "wreq-js.win32-arm64-msvc.node",
|
||||
"size": 6994432,
|
||||
"sha256": "c853e10e272f31d3e5bf3e14cf64a3bfb41ef94d428f895cb73a67f0c58c46fa"
|
||||
},
|
||||
{
|
||||
"target": "win32-x64-msvc",
|
||||
"package": "@wreq-js/binding-win32-x64-msvc",
|
||||
"version": "3.2.0",
|
||||
"platform": "win32",
|
||||
"arch": "x64",
|
||||
"tarball": "https://registry.npmjs.org/@wreq-js/binding-win32-x64-msvc/-/binding-win32-x64-msvc-3.2.0.tgz",
|
||||
"integrity": "sha512-w4aktLElPgBXkWC/v9Ti9np6jBjYOzIyqmzgT41V/zuDq/V+V7s/13f9OzcX5hObH2WOpajo0KeljTJ2aRExNQ==",
|
||||
"path": "wreq-js.win32-x64-msvc.node",
|
||||
"size": 8003584,
|
||||
"sha256": "2659898ee73ab64bb1ec4b4b1dd0c1e1d50f7dc579bad456d8bcad84349b01d4"
|
||||
}
|
||||
],
|
||||
"rust": {
|
||||
"cargoTomlSha256": "9dcc37ee9b254a57722402355ae483aff9eeae8dbb1a28e84a57e008ab05a747",
|
||||
"cargoLockSha256": "b22954960bffe817721539c17c18d2c2fb5084b358ea3e009133b5403b123df3",
|
||||
"cargoLockPackages": 229,
|
||||
"normalClosureUnionPackages": 153,
|
||||
"compileOnlyUnionPackages": 43,
|
||||
"btlsSys": {
|
||||
"version": "0.5.6",
|
||||
"crateChecksum": "9b1b8638a2e1c38a5ae4efa90ae57e643baec35a30d03fc5b399b893adc4954b",
|
||||
"sourceCommit": "4edbf5d716ba014384569ac5c631cea83827abfc",
|
||||
"license": "MIT",
|
||||
"licenseSha256": "2f55c7cce4da9f8334dce14d53e35410f67973510bc9793ac2dafa5e8cddd3c3"
|
||||
},
|
||||
"boringSsl": {
|
||||
"sourceCommit": "91a66a59b6c1435120ff83e245d7719411294386",
|
||||
"license": "Apache-2.0",
|
||||
"licenseSha256": "827c8d8fc207c2392794eef9e00fe246f9f61fdcc132556c275be3dd8c3cd97f",
|
||||
"modified": true,
|
||||
"modificationNote": "btls-sys applies its published BoringSSL patch sets; the upstream wreq-js build workflow also adjusts btls-sys build logic on Windows targets."
|
||||
}
|
||||
},
|
||||
"holds": {
|
||||
"exactPostLtoSbom": "Published addons contain no cargo-auditable section, link map, CycloneDX/SPDX SBOM, or reproducible-build receipt; the Cargo normal closure is a conservative link-eligible superset.",
|
||||
"androidRuntime": "The Android addon dynamically requires libc++_shared.so, which is absent from its npm tarball. Audit LLVM/Apache-with-LLVM-exception notices if a release artifact supplies that library."
|
||||
}
|
||||
}
|
||||
3111
config/release/wreq-js-rust-license-inventory.json
Normal file
3111
config/release/wreq-js-rust-license-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
9709
config/release/wreq-js-rust-notices.md
Normal file
9709
config/release/wreq-js-rust-notices.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: "Radar Free-Model Catalog"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-13
|
||||
version: 3.8.51
|
||||
lastUpdated: 2026-09-01
|
||||
---
|
||||
|
||||
# Radar Free-Model Catalog
|
||||
|
||||
> **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/`
|
||||
> **Last updated:** 2026-08-13 — v3.8.50
|
||||
> **Last updated:** 2026-09-01 — v3.8.51
|
||||
> **Hosted-service evidence boundary:** server-side rules described here were verified on
|
||||
> 2026-09-01 against the intentionally private Radar server at exact revision
|
||||
> `main@dce70f004364912f3f144cdb69f4cbcde16093ed`. That implementation is not distributed in
|
||||
> this OSS repository; hosted availability remains a separate operational state.
|
||||
|
||||
Radar is an **optional add-on** that overlays a signed, freshly-curated free-model
|
||||
catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in
|
||||
@@ -24,7 +28,7 @@ is never mutated on disk — see
|
||||
|
||||
---
|
||||
|
||||
## Delivery status in v3.8.50
|
||||
## Delivery status in v3.8.51
|
||||
|
||||
The following status distinguishes what this OSS release implements from later Radar
|
||||
workstreams. It is a code-level status, not a promise that a particular hosted deployment
|
||||
@@ -109,10 +113,17 @@ When both are on, the sync path is:
|
||||
`Authorization: Bearer <supporter key>` header (see below). Servers default to the separately
|
||||
signed v1 transition artifact when the schema header is absent, so older installed clients keep
|
||||
receiving updates.
|
||||
2. Nothing about the request, the operator, or their traffic is uploaded — it is a
|
||||
plain, unauthenticated-by-default GET. OmniRoute never posts usage data, provider
|
||||
configuration, or model traffic to the feed service.
|
||||
3. The response is verified, validated, and cached locally (see
|
||||
2. This is a download-only application flow, but it is still an HTTPS request. The hosted
|
||||
infrastructure receives ordinary connection metadata such as the source IP. When a supporter
|
||||
key is configured, sync also sends that key in the Bearer header so the service can resolve the
|
||||
entitlement. At the exact private-server revision identified in the evidence boundary above,
|
||||
feed-request accounting uses key hashes, aggregate usage, and a daily rotating truncated HMAC
|
||||
of the IP for manual abuse review; those tables persist neither the key nor the IP in raw form.
|
||||
Infrastructure access logs and the encrypted delivery outbox are separate operational
|
||||
boundaries.
|
||||
3. OmniRoute never sends prompts, responses, conversations, provider credentials, model traffic,
|
||||
uptime, latency, or the local provider configuration to the Radar service.
|
||||
4. The response is verified, validated, and cached locally (see
|
||||
[Security model](#security-model)). Radar has exactly four server-side network paths:
|
||||
`syncRadar()` for the catalog, `syncRadarReferrals()` for referrals, and
|
||||
`syncRadarOffers()` / `syncRadarIntel()` for supporter-only offers and Intel.
|
||||
@@ -133,6 +144,42 @@ that lets the feed service decide which tier to serve (see
|
||||
|
||||
---
|
||||
|
||||
## Access and safety rules shown before opt-in
|
||||
|
||||
The inactive dashboard renders these rules from
|
||||
`src/app/(dashboard)/dashboard/radar/RadarAccessExplainer.tsx` **before** either activation action.
|
||||
The canonical access scale is:
|
||||
|
||||
| Level | Eligibility | Access | Repeat/expiration rule |
|
||||
| --------------------- | --------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Community | Anyone; no key | Complete catalog delayed by about 30 days | Always available; no issuance |
|
||||
| Star + follow | GitHub OAuth verifies both a star on the repository and a follow of the owner | One live catalog read, then Community | One issuance per login; never reissued |
|
||||
| Contributor Top 10 | Positions 1–10 in the latest complete weekly ranking | 365 live days | Claimed on demand; leaving the ranking does not shorten an awarded period |
|
||||
| Contributor Top 100 | Positions 11–100 in that ranking | 90 live days | Same on-demand/idempotent claim rule |
|
||||
| Supporter purchase | One-time 6-month, 1-year, or lifetime purchase | Live catalog, signed live offers, and Intel | No automatic renewal |
|
||||
| Donation/manual grant | Owner-reviewed donation or an owner grant for an explicit number of days/lifetime | Same live entitlement for the granted period | Audited, idempotent grant |
|
||||
|
||||
Merged PRs, commits, and changed lines are **ranking inputs only**. A login outside the Top 100 gets
|
||||
no contributor grant regardless of PR count. Finite purchases, donations, contributor periods, and
|
||||
manual grants accumulate from the current expiration; lifetime dominates. A rank change never
|
||||
retroactively revokes or shortens time already awarded.
|
||||
|
||||
The hosted license is personal and the user-facing rule is one active installation at a time. This
|
||||
release does **not** claim a hardware lock: the OSS sync does not fingerprint hardware or maintain a
|
||||
cryptographic device lease. At the verified private-server revision above, implemented enforcement
|
||||
is entitlement validation plus a manual-review signal when the same live key is seen from a fourth
|
||||
distinct IP within 24 hours. That signal never blocks or revokes a key automatically. Recovery
|
||||
revokes and replaces the lost key while preserving the existing expiration; it does not restart the
|
||||
purchased or granted period.
|
||||
|
||||
Live offers are manually curated and can change or expire. The opt-in screen also names the exact
|
||||
privacy boundary: signed catalog/referral metadata is downloaded; a valid key additionally unlocks
|
||||
signed offers and Intel; the Bearer key and normal connection metadata reach the hosted service;
|
||||
prompts, responses, conversations, provider credentials, model traffic, uptime, latency, and local
|
||||
provider configuration do not.
|
||||
|
||||
---
|
||||
|
||||
## Getting a supporter key
|
||||
|
||||
The activation screen (`/dashboard/radar`) links out to two flows for **obtaining** a
|
||||
@@ -142,11 +189,12 @@ destination pages, not in this repo (spec decision D14).
|
||||
|
||||
- **"I'm a contributor"** — opens `RADAR_CONTRIBUTOR_CLAIM_URL` (default
|
||||
`https://radar.omniroute.online/auth/github`), a GitHub OAuth claim flow hosted on
|
||||
the private radar server. It verifies the visitor's GitHub account and grants a
|
||||
supporter key to anyone with 5+ merged pull requests or a top-100 contributor spot
|
||||
on the repo.
|
||||
the private Radar server. It checks the latest complete weekly ranking: Top 10 receives 365 days
|
||||
and positions 11–100 receive 90 days. Outside the Top 100, PR count never grants access; the flow
|
||||
instead checks the separate star + follow single-use level.
|
||||
- **"Support the project"** — opens `RADAR_SUPPORTER_PLANS_URL` (default
|
||||
`https://radar.omniroute.online/planos`), the payment/plans page.
|
||||
`https://radar.omniroute.online/planos`), the hosted page for the one-time 6-month, 1-year, and
|
||||
lifetime options. The OSS page still displays no monetary value.
|
||||
|
||||
Both URLs are resolved server-side (`src/lib/radar/links.ts`, same env-override
|
||||
pattern as `RADAR_FEED_URL`) and relayed to the dashboard through the existing
|
||||
|
||||
@@ -75,7 +75,9 @@ When you run `npm install -g omniroute`, you may see a wall of warnings like `np
|
||||
The warnings come from stale peer-dependency ranges in third-party packages OmniRoute doesn't control:
|
||||
|
||||
1. **`marked-terminal` wants `marked >=1 <16`, found `marked@18`** — works fine in practice; the upstream peer range is just stale.
|
||||
2. **`deprecated prebuild-install@7.1.3`** — the native-binary fetch helper. Only relevant later if a web-cookie provider reports a missing `tls-client-node` native binary (a separate issue, not caused by this warning).
|
||||
2. **`deprecated prebuild-install@7.1.3`** — a transitive native-binary fetch helper. It is not
|
||||
used to install the pinned `wreq-js` transport binding and does not indicate that web-cookie
|
||||
provider transport setup failed.
|
||||
|
||||
**No action needed** — the warnings cannot be fully silenced without forking upstream packages.
|
||||
|
||||
@@ -148,9 +150,9 @@ desktop app, for example:
|
||||
- `resources/app/.build/next/node_modules/playwright-<hash>/lib/…/agentParser.js` and
|
||||
`workerProcessEntry.js` — [Playwright](https://playwright.dev), the browser-automation
|
||||
library used for in-app provider login and browser-backed chat.
|
||||
- `resources/app/.build/next/node_modules/tls-client-node-<hash>/bin/tls-client-windows-64-<ver>.dll`
|
||||
— the native binary from `tls-client-node`, used for Cloudflare-tolerant HTTP on some web
|
||||
providers.
|
||||
- `resources/app/.build/next/node_modules/@wreq-js/binding-win32-<arch>-msvc-<hash>/wreq-js.win32-<arch>-msvc.node`
|
||||
— the pinned `wreq-js` native binding used for browser-fingerprinted HTTP on web-cookie
|
||||
providers (`<arch>` is `x64` or `arm64`).
|
||||
|
||||
**Why it fires:** the Windows installer is **not yet code-signed**, so an unsigned NSIS
|
||||
installer has zero reputation and behavioral heuristics run at maximum aggression. Combined
|
||||
|
||||
@@ -6199,6 +6199,22 @@ paths:
|
||||
"200":
|
||||
description: Health status
|
||||
|
||||
/api/monitoring/compression:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get compression result-memo statistics
|
||||
description: >-
|
||||
In-process compression result-memo observability snapshot — size, capacity,
|
||||
lifetime hits/misses/hitRate plus 1m/5m/15m/1h windowed rates. Lightweight
|
||||
(no DB, no provider reads) companion to `GET /api/monitoring/health` intended
|
||||
for frequent polling. Sent with `Cache-Control: no-store, no-cache,
|
||||
must-revalidate`. Counters reset on process restart.
|
||||
responses:
|
||||
"200":
|
||||
description: Compression memo stats (`compression.memo` + `timestamp`)
|
||||
"503":
|
||||
description: Compression stats unavailable
|
||||
|
||||
/api/rate-limits:
|
||||
get:
|
||||
tags: [System]
|
||||
|
||||
@@ -428,6 +428,7 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
|
||||
| `DEVIN_BRIDGE_OPUS_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Opus default. |
|
||||
| `DEVIN_BRIDGE_HAIKU_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Haiku default. |
|
||||
| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. |
|
||||
| `DEVIN_SEAT_API_URL` | `https://server.codeium.com` | `open-sse/services/usage/devinCli.ts` | Optional override for the Codeium seat-management API used by Devin CLI quota (`GetUserStatus`). |
|
||||
| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. |
|
||||
| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). |
|
||||
| `ZCODE_BIN` | `zcode` | `open-sse/executors/zcode.ts` | Binary used for the local `zcode` provider's stdio client. Falls back to `zcode` on PATH. |
|
||||
@@ -764,15 +765,15 @@ REQUEST_TIMEOUT_MS (global override)
|
||||
| `OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS` | `8000` | Timeout (ms) for the `validationRead` and `modelsProbe` presets in `src/shared/network/safeOutboundFetch.ts`. Raise for slow endpoints (Cerebras, Cloudflare AI, Groq) to prevent flapping between active/error in the dashboard. Falls back to 8000ms for invalid (<1000) or non-numeric values. |
|
||||
| `OMNIROUTE_RELAY_FETCH_TIMEOUT_MS` | `25000` | Relay-specific fetch timeout in `open-sse/utils/proxyFetch.ts` (#9158). A hung relay must fail before the client/agent timeout (~30s) so callers see a relay-specific failure instead of a generic upstream timeout. Capped at `29000` so it always fires first. |
|
||||
| `OMNIROUTE_RETRY_BACKOFF_MS` | `10` | Shared retry backoff for the direct/relay/proxy retry-once paths in `open-sse/utils/proxyFetch.ts` (#9158). `0` = retry immediately. |
|
||||
| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`claudeTlsClient.ts`). |
|
||||
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
|
||||
| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). |
|
||||
| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
|
||||
| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Native wreq-js request timeout (`claudeTlsClient.ts`). |
|
||||
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | Absolute JS hard-deadline grace added on top of the native timeout. |
|
||||
| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Native wreq-js request timeout (`perplexityTlsClient.ts`). |
|
||||
| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | Absolute JS hard-deadline grace added on top of the native timeout. |
|
||||
| `OMNIROUTE_PPLX_SEARCH_HINT` | `0` (off) | Appends "You have built-in web search. Answer questions directly using search results." to the caller's system message (`perplexity-web/protocol.ts`). Off by default — Perplexity searches anyway, and the sentence leaks into replies as meta-commentary for coding clients. Set `1`/`true`/`yes`/`on` to restore. |
|
||||
| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). |
|
||||
| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
|
||||
| `OMNIROUTE_NOTION_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`notionTlsClient.ts`); the `notion-web` executor raises it per-request to `180000` for long generations. |
|
||||
| `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
|
||||
| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Native wreq-js request timeout (`grokTlsClient.ts`). |
|
||||
| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | Absolute JS hard-deadline grace added on top of the native timeout. |
|
||||
| `OMNIROUTE_NOTION_TLS_TIMEOUT_MS` | `30000` | Native wreq-js request timeout (`notionTlsClient.ts`); `notion-web` raises it per request to `180000` for long generations. |
|
||||
| `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | Absolute JS hard-deadline grace added on top of the native timeout. |
|
||||
| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. |
|
||||
| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. |
|
||||
| `KIMI_WEB_BASE_URL` | `https://www.kimi.ai` | Base URL for the Kimi Web (international kimi.ai Connect-RPC) executor (`kimi-web.ts`); override only for mirror/proxy endpoints. |
|
||||
|
||||
@@ -48,7 +48,7 @@ The manifest contains:
|
||||
- JSON-safe model metadata such as context length, vision/reasoning flags, and
|
||||
unsupported params
|
||||
- capability tags including `apikey`, `oauth`, `custom-executor`,
|
||||
`passthrough-models`, `responses`, `sidecar-candidate`, and `usage-fetch`
|
||||
`passthrough-models`, `responses`, `sidecar-candidate`, `usage-fetch`, and `usage-supported`
|
||||
|
||||
The manifest intentionally excludes:
|
||||
|
||||
@@ -74,6 +74,7 @@ re-reading the TypeScript sources.
|
||||
| `custom-executor` | Runs a non-default executor, so it stays on the TypeScript path. |
|
||||
| `sidecar-candidate` | Mirrors `sidecar.eligible` — safe to consider for sidecar import. |
|
||||
| `usage-fetch` | Has a wired usage or quota fetcher (`getUsageForProvider`). |
|
||||
| `usage-supported` | The usage API accepts this provider (`isSupportedUsageConnection`). |
|
||||
|
||||
`usage-fetch` is discovery only. It reports that OmniRoute knows how to read usage for the
|
||||
provider; it does not activate fetching, change quota semantics, or imply that the
|
||||
@@ -86,6 +87,16 @@ with aliases and is slightly longer than the number of tagged providers: entries
|
||||
not chat providers in the manifest registry (for example the `firecrawl` search provider
|
||||
and the `amazon-q` ACP provider) have no manifest entry to tag.
|
||||
|
||||
`usage-supported` answers whether the server and Dashboard usage routes accept a connection
|
||||
for the provider. It mirrors `isSupportedUsageConnection()` (`src/lib/usage/providerLimits.ts`)
|
||||
and `supportsProviderQuota()` (`src/shared/utils/providerQuotaVisibility.ts`), both gated by
|
||||
`USAGE_SUPPORTED_PROVIDERS` (`open-sse/services/usage/supportedProviders.ts`). Unlike
|
||||
`usage-fetch`, it is emitted on the provider id alone — the runtime guard does
|
||||
`USAGE_SUPPORTED_PROVIDERS.includes(providerId)` with no alias resolution, so the manifest
|
||||
keeps the same rule. The two tags have different perimeters: 3 providers carry only
|
||||
`usage-fetch` (`opencode`, `opencode-zen`, `xai`) and 1 carries only
|
||||
`usage-supported` (`xiaomi-mimo-token-plan`), so one does not imply the other.
|
||||
|
||||
## Sidecar Use
|
||||
|
||||
Sidecars should treat `sidecar.eligible` as a conservative candidate signal, not
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Stealth Guide"
|
||||
version: 3.8.40
|
||||
lastUpdated: 2026-06-28
|
||||
version: 3.8.51
|
||||
lastUpdated: 2026-09-02
|
||||
---
|
||||
|
||||
# Stealth Guide
|
||||
|
||||
> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/`
|
||||
> **Last updated:** 2026-06-28 — v3.8.40
|
||||
> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{tlsClientBase,claudeTlsClient,perplexityTlsClient,grokTlsClient,notionTlsClient,lmarenaTlsClient,claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/`
|
||||
> **Last updated:** 2026-09-02 — v3.8.51
|
||||
> **Audience:** Engineers maintaining provider-specific stealth integrations.
|
||||
|
||||
OmniRoute integrates with providers whose edges actively fingerprint non-official clients (TLS JA3/JA4, header ordering, JSON body shape, integrity tokens). This page documents the stealth surfaces OmniRoute exposes and where they are implemented.
|
||||
@@ -22,13 +22,56 @@ Stealth features exist so OmniRoute can act as a compatibility layer between use
|
||||
|
||||
### `open-sse/utils/tlsClient.ts` — wreq-js (Chrome 124)
|
||||
|
||||
Lazy-loaded `wreq-js` session that impersonates **Chrome 124 on macOS**. Used as a generic JA3/JA4 wrapper for upstreams behind Cloudflare. Falls back to native fetch when `wreq-js` is not installed (`available = false`).
|
||||
Persistent `wreq-js` sessions are created lazily per account scope and resolved proxy. The
|
||||
process-wide `TlsClient` pools at most 128 sessions that impersonate **Chrome 124 on macOS** for
|
||||
upstreams behind Cloudflare. `TlsClient.fetch()` fails closed when the native runtime is
|
||||
unavailable; a caller may explicitly select a fallback outside this wrapper.
|
||||
|
||||
- Singleton session: `browser: "chrome_124", os: "macos"`
|
||||
- Session profile: `browser: "chrome_124", os: "macos"`
|
||||
- Proxy resolution (priority): `HTTPS_PROXY` → `HTTP_PROXY` → `ALL_PROXY` (also lower-case)
|
||||
- Timeout: `TLS_CLIENT_TIMEOUT_MS` (inherits from `FETCH_TIMEOUT_MS`, default 600000)
|
||||
- `wreq-js` Response is fetch-compatible (`headers`, `text()`, `json()`, `clone()`, `body`).
|
||||
|
||||
### Web-cookie provider transport — wreq-js 3.2.0
|
||||
|
||||
`open-sse/services/tlsClientBase.ts` is the shared adapter for the five specialized
|
||||
web-cookie transports below. Each thin provider wrapper selects a browser/OS profile. The adapter
|
||||
uses the single wreq runtime loader and transport pool in `open-sse/utils/tlsClient.ts`, keyed by
|
||||
profile + OS + resolved proxy, while every request uses `cookieMode: "ephemeral"`. Accounts and
|
||||
requests therefore share transport-level connections, but never a wreq session or cookie jar.
|
||||
|
||||
| Provider | Profile | Emulated OS | Stream EOF policy |
|
||||
| ---------- | ------------- | ----------- | -------------------------------- |
|
||||
| Claude | `chrome_146` | Linux | include `[DONE]` |
|
||||
| Perplexity | `firefox_148` | macOS | include `event: end_of_stream` |
|
||||
| Grok | `chrome_146` | Linux | exclude `[DONE]` |
|
||||
| Notion | `chrome_146` | Windows | include `[DONE]` |
|
||||
| LMArena | `chrome_146` | Windows | no sentinel; close on native EOF |
|
||||
|
||||
- Streaming consumes the native response `ReadableStream` directly; no temp file or sidecar is
|
||||
created.
|
||||
- Up to 256 initial bytes are inspected before exposing a stream. SSE providers buffer non-SSE
|
||||
errors; Grok/LMArena map Cloudflare challenges to `403` and HTML interstitials to `502`.
|
||||
- The native request timeout remains wrapped by an absolute JS hard deadline. A hang invalidates
|
||||
and closes only the affected profile/OS/proxy transport before the next request recreates it.
|
||||
- Proxy resolution priority is per-call `proxyUrl` → request-scoped account/dashboard context →
|
||||
`HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY` (including lowercase variants). Resolution errors fail
|
||||
closed instead of leaking a direct connection. LMArena deliberately resolves against `arena.ai`.
|
||||
- `byteResponse` returns a content-typed `data:` URL without UTF-8 corruption.
|
||||
- Errors are `TlsClientUnavailableError` (package/addon unavailable), `TlsClientHangError`
|
||||
(deadline exceeded), and `WreqTransportCapacityError` (the shared session-capacity error code)
|
||||
when all 128 bounded profile/OS/proxy slots are active or closing.
|
||||
|
||||
The generic `TlsClient` session above remains specialized for persistent browser-backed cookie
|
||||
state. Both paths reuse one cached wreq module loader and process lifecycle hook; their pools remain
|
||||
separate because their cookie lifetimes are intentionally different.
|
||||
|
||||
The profiles are supported by the pinned package, but real WAF acceptance can change independently
|
||||
of local contract tests. Validate fingerprint changes against an explicitly authorized live account
|
||||
before claiming parity with an upstream browser.
|
||||
|
||||
---
|
||||
|
||||
## Claude Code Stealth Bundle
|
||||
|
||||
When `cliCompatMode` is on, OmniRoute reshapes outgoing Claude requests so they are indistinguishable from `claude-cli` traffic. Three modules collaborate:
|
||||
|
||||
@@ -223,6 +223,9 @@ const nextConfig = {
|
||||
...(isContributorBuild ? {} : { output: "standalone" }),
|
||||
compress: true,
|
||||
productionBrowserSourceMaps: false,
|
||||
// Issue #67: enable React Compiler — automates memoization, removes manual useCallback/useMemo debt.
|
||||
// See: https://next.dev/blog/react-compiler
|
||||
reactCompiler: true,
|
||||
// OmniRoute is a proxy for AI APIs — request bodies routinely include
|
||||
// multi-MB payloads (vision models, image edits, base64-encoded files,
|
||||
// long chat histories with embedded images). Next.js's Server Action
|
||||
@@ -330,6 +333,13 @@ const nextConfig = {
|
||||
// analysis can't follow _require.resolve("sql.js/package.json") and spams
|
||||
// build warnings. Externalizing silences them without changing behaviour.
|
||||
"sql.js",
|
||||
// tiktoken's node build reads tiktoken_bg.wasm via __dirname-relative
|
||||
// fs.readFileSync at import time. When bundled, the wasm asset is not
|
||||
// traced into the server chunk and page-data collection for any route
|
||||
// importing the vendored ChatGPT Web tokenizer fails with
|
||||
// "Missing tiktoken_bg.wasm". Externalizing keeps the require at runtime
|
||||
// where node_modules/tiktoken/tiktoken_bg.wasm resolves normally.
|
||||
"tiktoken",
|
||||
// sqlite-vec ships a native vec0.so loaded at runtime via createRequire().
|
||||
// Turbopack otherwise tries to bundle the .so and fails with "Unknown module
|
||||
// type"; externalizing it keeps the require at runtime (like better-sqlite3).
|
||||
@@ -339,9 +349,6 @@ const nextConfig = {
|
||||
"keytar",
|
||||
"wreq-js",
|
||||
"zod",
|
||||
"tls-client-node",
|
||||
"koffi",
|
||||
"tough-cookie",
|
||||
"@ngrok/ngrok",
|
||||
"@huggingface/transformers",
|
||||
// The ESM entry imports tiktoken_bg.wasm as a module. Turbopack can compile
|
||||
|
||||
@@ -408,6 +408,7 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
const EMBEDDING_PROVIDER_ALIASES: Record<string, string> = {
|
||||
@@ -470,6 +471,38 @@ export function getEmbeddingProvider(providerId: string): EmbeddingProvider | nu
|
||||
return EMBEDDING_PROVIDERS[resolveEmbeddingProviderId(providerId)] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an OpenAI-compatible embeddings config for a chat provider that has NO
|
||||
* curated EMBEDDING_PROVIDERS entry. Works for any registry provider whose base
|
||||
* URL ends in /chat/completions by swapping that suffix for /embeddings (groq,
|
||||
* mistral, together, upstage, fireworks, nvidia, vercel-ai-gateway, ...).
|
||||
* Dynamic-URL providers (no usable static base) derive to
|
||||
* null — they need bespoke URL handling, not a bogus endpoint.
|
||||
*
|
||||
* This is a FALLBACK only: callers must check getEmbeddingProvider() first so
|
||||
* curated entries keep their specialized configuration.
|
||||
*/
|
||||
export function deriveEmbeddingProviderForChatProvider(
|
||||
providerId: string,
|
||||
chatEntry: { id?: string; baseUrl?: string | string[] } | null | undefined
|
||||
): EmbeddingProvider | null {
|
||||
if (!chatEntry) return null;
|
||||
const rawBase = Array.isArray(chatEntry.baseUrl)
|
||||
? chatEntry.baseUrl[0]
|
||||
: chatEntry.baseUrl;
|
||||
if (!rawBase || typeof rawBase !== "string") return null;
|
||||
// stripTrailingSlashes-equivalent without importing open-sse utils here:
|
||||
const base = rawBase.replace(/\/+$/, "");
|
||||
if (!base.endsWith("/chat/completions")) return null;
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl: `${base.slice(0, -"/chat/completions".length)}/embeddings`,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse embedding model string (format: "provider/model" or just "model")
|
||||
* Returns { provider, model }
|
||||
@@ -485,6 +518,18 @@ export function parseEmbeddingModel(
|
||||
const slashIdx = modelStr.indexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
const rawProvider = modelStr.slice(0, slashIdx);
|
||||
|
||||
// A configured provider_node whose prefix exactly equals the requested
|
||||
// provider segment always wins — even when that segment is also an alias
|
||||
// of a curated provider (a local node must not be hijacked by a registry
|
||||
// alias). Same exact-match precedence documented for
|
||||
// EMBEDDING_MODEL_ALIASES above.
|
||||
const dynamicExact =
|
||||
dynamicProviders && dynamicProviders.find((dp) => dp.id === rawProvider);
|
||||
if (dynamicExact) {
|
||||
return { provider: rawProvider, model: modelStr.slice(slashIdx + 1) };
|
||||
}
|
||||
|
||||
const resolvedProvider = resolveEmbeddingProviderId(rawProvider);
|
||||
|
||||
if (EMBEDDING_PROVIDERS[resolvedProvider]) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RegistryEntry, RegistryModel } from "./providers/shared.ts";
|
||||
import { USAGE_FETCHER_PROVIDERS } from "../services/usage/fetcherProviders.ts";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "../services/usage/supportedProviders.ts";
|
||||
|
||||
export type ProviderPluginCapability =
|
||||
| "apikey"
|
||||
@@ -8,7 +9,8 @@ export type ProviderPluginCapability =
|
||||
| "passthrough-models"
|
||||
| "responses"
|
||||
| "sidecar-candidate"
|
||||
| "usage-fetch";
|
||||
| "usage-fetch"
|
||||
| "usage-supported";
|
||||
|
||||
export interface ProviderPluginModel {
|
||||
id: string;
|
||||
@@ -66,6 +68,15 @@ const SIDECAR_COMPATIBLE_EXECUTORS = new Set(["default"]);
|
||||
*/
|
||||
const USAGE_FETCHER_PROVIDER_SET = new Set<string>(USAGE_FETCHER_PROVIDERS);
|
||||
|
||||
/**
|
||||
* Providers whose usage API is accepted by dashboard/server routes (#10078).
|
||||
* Unlike USAGE_FETCHER_PROVIDERS this gate is checked with a plain
|
||||
* `USAGE_SUPPORTED_PROVIDERS.includes(providerId)` — no alias resolution —
|
||||
* so the manifest must emit on the identifier alone to stay faithful to the
|
||||
* runtime guard.
|
||||
*/
|
||||
const USAGE_SUPPORTED_PROVIDER_SET = new Set<string>(USAGE_SUPPORTED_PROVIDERS);
|
||||
|
||||
function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined)
|
||||
@@ -142,6 +153,9 @@ function capabilitiesFor(entry: RegistryEntry, eligible: boolean): ProviderPlugi
|
||||
) {
|
||||
capabilities.add("usage-fetch");
|
||||
}
|
||||
if (USAGE_SUPPORTED_PROVIDER_SET.has(entry.id)) {
|
||||
capabilities.add("usage-supported");
|
||||
}
|
||||
|
||||
return [...capabilities].sort();
|
||||
}
|
||||
|
||||
@@ -218,3 +218,29 @@ export function getAllRerankModels() {
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a Cohere-compatible rerank config for a chat provider that has NO
|
||||
* curated RERANK_PROVIDERS entry. Works for any registry provider whose base
|
||||
* URL ends in /chat/completions by swapping that suffix for /rerank (groq,
|
||||
* mistral, vercel-ai-gateway, ...). Dynamic-URL providers (no usable static
|
||||
* base, e.g. dynamic account-scoped hosts) derive to null — they need bespoke
|
||||
* URL handling.
|
||||
*
|
||||
* This is a FALLBACK only: callers must check getRerankProvider() first so
|
||||
* curated entries keep their specialized configuration and format adapters.
|
||||
*/
|
||||
export function deriveRerankProviderForChatProvider(providerId, chatEntry) {
|
||||
if (!chatEntry) return null;
|
||||
const rawBase = Array.isArray(chatEntry.baseUrl) ? chatEntry.baseUrl[0] : chatEntry.baseUrl;
|
||||
if (!rawBase || typeof rawBase !== "string") return null;
|
||||
const base = rawBase.replace(/\/+$/, "");
|
||||
if (!base.endsWith("/chat/completions")) return null;
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl: `${base.slice(0, -"/chat/completions".length)}/rerank`,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +50,50 @@ export const GITHUB_REASONING_EFFORT_OPT_IN_PATTERN = /claude[-_.]?(?:opus|sonne
|
||||
export const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i;
|
||||
const NVIDIA_GLM_52_PATTERN = /z-ai\/glm-5\.2\b/i;
|
||||
|
||||
/**
|
||||
* Model families whose top reasoning tier in their native API or upstream gateways
|
||||
* is `max` (rather than `xhigh`):
|
||||
* - GLM 5.1+ / 6.0+ (Z.AI / Zhipu GLM-5.1, GLM-5.2, GLM-5.3, GLM-5.3-flash, GLM-5.4, GLM-6...)
|
||||
* - DeepSeek V4+ (Flash, Pro, Flash-Vision, ...)
|
||||
* - Moonshot Kimi K3+ (Kimi K3, K4...)
|
||||
*/
|
||||
export const MAX_TIER_REASONING_MODEL_PATTERN =
|
||||
/(?:^|\/|\b)(?:glm-(?:5\.[1-9]|5\.\d+|[6-9]|\d{2,})|deepseek-v(?:[4-9]|\d{2,})|kimi-k(?:[3-9]|\d{2,}))/i;
|
||||
|
||||
export const O1_O3_REASONING_MODELS_PATTERN = /(?:^|\/|\b)(?:o1-mini|o1|o3-mini|o3-pro|o3)(?:$|-)/i;
|
||||
export const O1_PREVIEW_PATTERN = /(?:^|\/|\b)o1-preview(?:$|-)/i;
|
||||
export const MUSE_SPARK_PATTERN = /(?:^|\/|\b)muse-spark/i;
|
||||
export const MINIMAX_REASONING_PATTERN = /(?:^|\/|\b)minimax(?:-m3|-m2)/i;
|
||||
export const GROK_45_PATTERN = /(?:^|\/|\b)grok-4\.5/i;
|
||||
export const GROK_46_PATTERN = /(?:^|\/|\b)grok-4\.6/i;
|
||||
export const GLM_53_FAMILY_PATTERN = /(?:^|\/|\b)glm-5\.3(?:$|-)/i;
|
||||
export const GLM_52_FAMILY_PATTERN = /(?:^|\/|\b)glm-5\.2(?:$|-)/i;
|
||||
|
||||
export function isCommandCodeProvider(provider: string): boolean {
|
||||
return (
|
||||
provider === "command-code" ||
|
||||
provider === "cmd" ||
|
||||
provider === "command_code"
|
||||
);
|
||||
}
|
||||
|
||||
export function isOllamaCloudProvider(provider: string): boolean {
|
||||
return (
|
||||
provider === "ollama-cloud" ||
|
||||
provider === "ollamacloud" ||
|
||||
provider === "ollama_cloud"
|
||||
);
|
||||
}
|
||||
|
||||
export function isOpencodeGoProvider(provider: string): boolean {
|
||||
return (
|
||||
provider === "opencode-go" ||
|
||||
provider === "opencode-zen" ||
|
||||
provider === "opencode" ||
|
||||
provider === "opencode_go"
|
||||
);
|
||||
}
|
||||
|
||||
type ReasoningSanitizeLog = {
|
||||
info?: (tag: string, msg: string) => void;
|
||||
};
|
||||
@@ -154,23 +198,21 @@ export function supportsMaxEffortForProvider(provider: string, model: string): b
|
||||
const isClaude =
|
||||
(provider === PROVIDER_CLAUDE || isClaudeCodeCompatible(provider)) &&
|
||||
supportsClaudeMaxEffort(resolvedModelId);
|
||||
// opencode-go proxies DeepSeek with the native DeepSeek API contract, which
|
||||
// accepts {high, max} literally. Without this opt-in, max would be
|
||||
// normalized to xhigh (the OmniRoute-internal top tier) and rejected by the
|
||||
// upstream. Scoped to opencode-go deliberately: OpenRouter's DeepSeek path
|
||||
// (pi#4055) is the documented inverse and expects xhigh, not max.
|
||||
// Ollama Cloud also accepts literal max (for example GLM 5.2 supports
|
||||
// low|medium|high|max|none) and rejects xhigh; xhigh is mapped to max by the
|
||||
// provider guard in sanitizeReasoningEffortForProvider.
|
||||
const isOpencodeGoDeepSeek =
|
||||
(provider === "opencode-go" || provider === "opencode-zen") &&
|
||||
resolvedModelId.toLowerCase().includes("deepseek");
|
||||
const isOllamaCloud = provider === "ollama-cloud";
|
||||
const isOpencodeGo = isOpencodeGoProvider(provider);
|
||||
const isOllamaCloud = isOllamaCloudProvider(provider);
|
||||
const isMoonshotK3 = /^kimi-k3(?:$|-)/i.test(resolvedModelId);
|
||||
// Command Code's upstream API accepts the literal DeepSeek/OpenAI effort value
|
||||
// `max`; do not rewrite it to OmniRoute's internal `xhigh` spelling.
|
||||
const isCommandCode = provider === "command-code";
|
||||
return isClaude || isOpencodeGoDeepSeek || isOllamaCloud || isMoonshotK3 || isCommandCode;
|
||||
const isCommandCode = isCommandCodeProvider(provider);
|
||||
const isMaxTierModel =
|
||||
MAX_TIER_REASONING_MODEL_PATTERN.test(resolvedModelId) ||
|
||||
MAX_TIER_REASONING_MODEL_PATTERN.test(model);
|
||||
return (
|
||||
isClaude ||
|
||||
isOpencodeGo ||
|
||||
isOllamaCloud ||
|
||||
isMoonshotK3 ||
|
||||
isCommandCode ||
|
||||
isMaxTierModel
|
||||
);
|
||||
}
|
||||
|
||||
// ── Effort carrier helpers (#7044) ──────────────────────────────────────────
|
||||
@@ -267,6 +309,15 @@ export function sanitizeReasoningEffortForProvider(
|
||||
const effortStr = typeof c.effort === "string" ? c.effort.toLowerCase() : "";
|
||||
const modelStr = model || "";
|
||||
|
||||
// ── o1-preview: does not accept reasoning_effort parameter at all ─────────
|
||||
if (O1_PREVIEW_PATTERN.test(modelStr)) {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: removed unsupported reasoning_effort for o1-preview`
|
||||
);
|
||||
return stripEffortValue(b, c);
|
||||
}
|
||||
|
||||
const githubOptIn =
|
||||
provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr);
|
||||
const rejecting =
|
||||
@@ -280,6 +331,136 @@ export function sanitizeReasoningEffortForProvider(
|
||||
return stripEffortValue(b, c);
|
||||
}
|
||||
|
||||
// ── GLM-5.3 and GLM-5.3-FLASH specific rules ──────────────────────────────
|
||||
// Supported options: max (default & recommended), high, low.
|
||||
// none/minimal/low → low; medium/high → high; xhigh/max → max.
|
||||
// In addition, GLM-5.3+ forces thinking; thinking.type="disabled" is rejected upstream.
|
||||
if (GLM_53_FAMILY_PATTERN.test(modelStr)) {
|
||||
let mappedGlm53 = "max";
|
||||
if (effortStr === "none" || effortStr === "minimal" || effortStr === "low") {
|
||||
mappedGlm53 = "low";
|
||||
} else if (effortStr === "medium" || effortStr === "high") {
|
||||
mappedGlm53 = "high";
|
||||
} else if (effortStr === "xhigh" || effortStr === "max" || effortStr === "ultra") {
|
||||
mappedGlm53 = "max";
|
||||
}
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort ${effortStr} → ${mappedGlm53} (GLM-5.3 contract)`
|
||||
);
|
||||
let updated = writeEffortValue(b, mappedGlm53, c);
|
||||
const thinkingObj = updated.thinking;
|
||||
if (
|
||||
thinkingObj &&
|
||||
typeof thinkingObj === "object" &&
|
||||
!Array.isArray(thinkingObj) &&
|
||||
(thinkingObj as Record<string, unknown>).type === "disabled"
|
||||
) {
|
||||
updated = {
|
||||
...updated,
|
||||
thinking: {
|
||||
...(thinkingObj as Record<string, unknown>),
|
||||
type: "enabled",
|
||||
},
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── GLM-5.2 specific rules ────────────────────────────────────────────────
|
||||
// none/minimal stop thinking (none); low/medium → high; xhigh/max → max; high → high.
|
||||
if (GLM_52_FAMILY_PATTERN.test(modelStr)) {
|
||||
let mappedGlm52 = "max";
|
||||
if (effortStr === "none" || effortStr === "minimal") {
|
||||
mappedGlm52 = "none";
|
||||
} else if (effortStr === "low" || effortStr === "medium") {
|
||||
mappedGlm52 = "high";
|
||||
} else if (effortStr === "xhigh" || effortStr === "max" || effortStr === "ultra") {
|
||||
mappedGlm52 = "max";
|
||||
} else if (effortStr === "high") {
|
||||
mappedGlm52 = "high";
|
||||
}
|
||||
if (mappedGlm52 !== effortStr) {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort ${effortStr} → ${mappedGlm52} (GLM-5.2 contract)`
|
||||
);
|
||||
return writeEffortValue(b, mappedGlm52, c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── Muse Spark models (muse-spark-1.2, etc.) ─────────────────────────────
|
||||
// Accepts minimal|low|medium|high|xhigh. Rejects none (400) and max.
|
||||
// max/ultra → xhigh; none → minimal.
|
||||
if (MUSE_SPARK_PATTERN.test(modelStr)) {
|
||||
if (effortStr === "max" || effortStr === "ultra") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort ${effortStr} → xhigh (Muse Spark ceiling)`
|
||||
);
|
||||
return writeEffortValue(b, "xhigh", c);
|
||||
}
|
||||
if (effortStr === "none") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort none → minimal (Muse Spark floor)`
|
||||
);
|
||||
return writeEffortValue(b, "minimal", c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── OpenAI o1 / o3-mini models ───────────────────────────────────────────
|
||||
// Accepts only low|medium|high. Clamp xhigh/max/ultra → high.
|
||||
if (O1_O3_REASONING_MODELS_PATTERN.test(modelStr)) {
|
||||
if (effortStr === "xhigh" || effortStr === "max" || effortStr === "ultra") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort ${effortStr} → high (o1/o3-mini ceiling)`
|
||||
);
|
||||
return writeEffortValue(b, "high", c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── MiniMax models ───────────────────────────────────────────────────────
|
||||
// Accepts none|minimal|low|medium|high. Clamp xhigh/max/ultra → high.
|
||||
if (MINIMAX_REASONING_PATTERN.test(modelStr)) {
|
||||
if (effortStr === "xhigh" || effortStr === "max" || effortStr === "ultra") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort ${effortStr} → high (MiniMax ceiling)`
|
||||
);
|
||||
return writeEffortValue(b, "high", c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── xAI Grok models ──────────────────────────────────────────────────────
|
||||
// Grok 4.6 accepts low|medium|high|xhigh (clamp max/ultra → xhigh).
|
||||
// Grok 4.5 accepts low|medium|high (clamp xhigh/max/ultra → high).
|
||||
if (GROK_46_PATTERN.test(modelStr)) {
|
||||
if (effortStr === "max" || effortStr === "ultra") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort ${effortStr} → xhigh (Grok 4.6 ceiling)`
|
||||
);
|
||||
return writeEffortValue(b, "xhigh", c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
if (GROK_45_PATTERN.test(modelStr)) {
|
||||
if (effortStr === "xhigh" || effortStr === "max" || effortStr === "ultra") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort ${effortStr} → high (Grok 4.5 ceiling)`
|
||||
);
|
||||
return writeEffortValue(b, "high", c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// `minimal` is a sub-`low` reasoning tier some catalogs advertise (e.g.
|
||||
// Muse Spark via models.dev) and the Codex provider accepts natively — but
|
||||
// Command Code rejects it outright:
|
||||
@@ -287,7 +468,7 @@ export function sanitizeReasoningEffortForProvider(
|
||||
// "low"|"medium"|"high"|"xhigh"|"max" at "params.reasoning_effort"
|
||||
// Map it to the closest supported value (`low`) for command-code only;
|
||||
// other providers (codex etc.) keep their native `minimal` handling.
|
||||
if (provider === "command-code" && effortStr === "minimal") {
|
||||
if (isCommandCodeProvider(provider) && effortStr === "minimal") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort minimal → low`
|
||||
@@ -295,10 +476,23 @@ export function sanitizeReasoningEffortForProvider(
|
||||
return writeEffortValue(b, "low", c);
|
||||
}
|
||||
|
||||
// Command Code accepts the literal top-tier value `max`, while the shared
|
||||
// standardization stage may have already represented the client's `max` as
|
||||
// OmniRoute's internal `xhigh`. Convert it back before the upstream request.
|
||||
if (provider === "command-code" && effortStr === "xhigh") {
|
||||
// Providers and model families whose top reasoning tier is `max` natively
|
||||
// (or whose gateways expect `max` rather than OmniRoute's internal `xhigh`):
|
||||
// - Command Code (`command-code` / `cmd`)
|
||||
// - Ollama Cloud (`ollama-cloud` / `ollamacloud`)
|
||||
// - OpenCode Go (`opencode-go` / `opencode-zen` / `opencode`)
|
||||
// - GLM 5.1+ / 6.0+ (Z.AI / Zhipu GLM-5.1, GLM-5.2, GLM-5.3, GLM-5.4...)
|
||||
// - DeepSeek V4+ (Flash, Pro, Vision, ...)
|
||||
// - Kimi K3+ (Moonshot AI K3, K4, ...)
|
||||
// OpenRouter (pi#4055) is excluded because OpenRouter's normalized API expects xhigh.
|
||||
const isMaxTierTarget =
|
||||
provider !== "openrouter" &&
|
||||
(isCommandCodeProvider(provider) ||
|
||||
isOllamaCloudProvider(provider) ||
|
||||
isOpencodeGoProvider(provider) ||
|
||||
MAX_TIER_REASONING_MODEL_PATTERN.test(modelStr));
|
||||
|
||||
if (isMaxTierTarget && effortStr === "xhigh") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: normalized reasoning_effort xhigh → max`
|
||||
@@ -306,18 +500,6 @@ export function sanitizeReasoningEffortForProvider(
|
||||
return writeEffortValue(b, "max", c);
|
||||
}
|
||||
|
||||
// Ollama Cloud accepts low|medium|high|max|none and rejects xhigh. Map
|
||||
// xhigh → max (its literal top tier) before the generic xhigh handling so
|
||||
// passthrough (unregistered) models are covered too — the registry opt-out
|
||||
// only covers known models.
|
||||
if (provider === "ollama-cloud" && effortStr === "xhigh") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort xhigh → max`
|
||||
);
|
||||
return writeEffortValue(b, "max", c);
|
||||
}
|
||||
|
||||
// Native DeepSeek (api.deepseek.com) — V4 Pro and Flash use the native
|
||||
// {low, high, max} vocabulary, while other model ids retain the {high, max}
|
||||
// floor. OmniRoute's internal top tier xhigh maps to DeepSeek's literal max,
|
||||
@@ -363,14 +545,6 @@ export function sanitizeReasoningEffortForProvider(
|
||||
// and the requested effort falls outside that vocabulary, remap to the
|
||||
// nearest declared tier: the smallest ranked value ≥ the request, else the
|
||||
// highest declared (a request above the ceiling lands on the ceiling).
|
||||
// Live case: opencode-go/ox-alpha-free (Console Go) only accepts
|
||||
// {low, high, max} — a client's reasoning_effort:"medium" reached the
|
||||
// upstream verbatim and 400'd every turn ("[1210] This model always engages
|
||||
// in thinking and cannot be disabled; please use low, high, or max"). The
|
||||
// learned-caps path can't help here (it only clamps down from xhigh/max,
|
||||
// and this error text isn't a parseable enum), so the declaration is the
|
||||
// only source of truth. Models without an explicit declaration keep
|
||||
// #8057's trust-the-upstream pass-through.
|
||||
const providerModelIdForClamp = modelStr.startsWith(`${provider}/`)
|
||||
? modelStr.slice(provider.length + 1)
|
||||
: modelStr;
|
||||
|
||||
@@ -109,6 +109,7 @@ function parseGlmEffortTier(model: string): GlmEffortTier | null {
|
||||
* https://docs.z.ai/guides/overview/concept-param
|
||||
*/
|
||||
const GLM_THINKING_MODEL_PATTERN = /^glm-5\.(?:[2-9]|\d{2,})/i;
|
||||
const GLM_53_OR_HIGHER_PATTERN = /^glm-5\.(?:[3-9]|\d{2,})/i;
|
||||
|
||||
function isGlmThinkingModel(model: string): boolean {
|
||||
return GLM_THINKING_MODEL_PATTERN.test(model);
|
||||
@@ -348,6 +349,15 @@ export class GlmExecutor extends DefaultExecutor {
|
||||
}
|
||||
|
||||
if (transport === "openai") {
|
||||
// GLM-5.3+ rejects thinking.type "disabled". Ensure thinking is enabled
|
||||
// when targeting GLM-5.3 or higher.
|
||||
if (record && GLM_53_OR_HIGHER_PATTERN.test(effectiveModel)) {
|
||||
const existingThinking = asRecord(record.thinking);
|
||||
if (existingThinking?.type === "disabled") {
|
||||
record.thinking = { ...existingThinking, type: "enabled" };
|
||||
}
|
||||
}
|
||||
|
||||
// GLM-5.3 effort tiers: inject the documented `reasoning_effort` param and
|
||||
// force thinking on — 5.3 rejects thinking.type "disabled", and an effort
|
||||
// tier without thinking would silently drop the selector upstream.
|
||||
|
||||
@@ -939,8 +939,8 @@ export class GrokWebExecutor extends BaseExecutor {
|
||||
|
||||
// Fetch from Grok via TLS-impersonating client (#3180).
|
||||
// Grok sits behind Cloudflare Enterprise which rejects Node's native TLS
|
||||
// fingerprint even with valid sso+sso-rw cookies. We use tls-client-node
|
||||
// to send a Chrome-like handshake instead.
|
||||
// fingerprint even with valid sso+sso-rw cookies. The pinned wreq-js
|
||||
// transport sends a Chrome-like handshake instead.
|
||||
let tlsResult: TlsFetchResult;
|
||||
try {
|
||||
tlsResult = await tlsFetchGrok(GROK_CHAT_API, {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* LMArenaExecutor — Arena (formerly LMArena) web-session provider.
|
||||
*
|
||||
* Routes requests through arena.ai create-evaluation with session cookies.
|
||||
* Upstream sits behind Cloudflare; traffic goes through tls-client-node Chrome
|
||||
* impersonation (see services/lmarenaTlsClient.ts).
|
||||
* Upstream sits behind Cloudflare; traffic goes through wreq-js Chrome
|
||||
* impersonation with isolated ephemeral cookies (see services/lmarenaTlsClient.ts).
|
||||
*
|
||||
* Helpers: open-sse/executors/lmarena/{cookie,models,stream,response}.ts
|
||||
*/
|
||||
@@ -174,7 +174,6 @@ export class LMArenaExecutor extends BaseExecutor {
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal: ctx.signal,
|
||||
stream: ctx.stream,
|
||||
streamEofSymbol: "__OMNIROUTE_LMARENA_EOF_NEVER__",
|
||||
});
|
||||
|
||||
const failed = mapFailedTlsResult({
|
||||
|
||||
@@ -6,9 +6,9 @@ export const LMARENA_API_BASE = "https://arena.ai";
|
||||
export const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream/create-evaluation`;
|
||||
/**
|
||||
* Current Chrome stable UA (header surface).
|
||||
* TLS JA3 profile is separate: tls-client-node tops out at chrome_146 — see
|
||||
* LMARENA_PROFILE in lmarenaTlsClient.ts. Headers track the live browser string;
|
||||
* fingerprint stays at the newest native profile we can actually impersonate.
|
||||
* TLS JA3/JA4 profile is separate: the provider-tested wreq-js profile is pinned
|
||||
* to chrome_146 in lmarenaTlsClient.ts while headers track the live browser string.
|
||||
* Treat that deliberate version skew as a WAF-sensitive compatibility surface.
|
||||
*/
|
||||
export const LMARENA_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
|
||||
|
||||
@@ -114,7 +114,7 @@ export function mapTlsUnavailable(
|
||||
return {
|
||||
response: errorResponse(
|
||||
502,
|
||||
`Arena TLS impersonation unavailable: ${error.message}. Install/repair tls-client-node native binary.`,
|
||||
`Arena TLS impersonation unavailable: ${error.message}. Verify the wreq-js 3.2 native binding.`,
|
||||
"upstream_error",
|
||||
"TLS_CLIENT_UNAVAILABLE"
|
||||
),
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* chunk — safer than assuming unverified incremental-delta semantics.
|
||||
*
|
||||
* Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id, user_id])
|
||||
* Method: Browser-TLS impersonation via tls-client-node (Chrome JA3). Plain
|
||||
* Method: Browser-TLS impersonation via pinned wreq-js (Chrome JA3/JA4). Plain
|
||||
* Node/undici fetch is rejected by Notion's edge with in-band
|
||||
* `temporarily-unavailable` (HTTP 200, empty assistant text) — curl/Schannel
|
||||
* and Chrome work with the same cookie + body. See services/notionTlsClient.ts.
|
||||
@@ -60,10 +60,7 @@ import {
|
||||
messagesForNotionTranscript,
|
||||
type NotionAgentOptions,
|
||||
} from "../services/notionTranscriptBuilder.ts";
|
||||
import {
|
||||
tlsFetchNotion,
|
||||
TlsClientUnavailableError,
|
||||
} from "../services/notionTlsClient.ts";
|
||||
import { tlsFetchNotion } from "../services/notionTlsClient.ts";
|
||||
|
||||
// Re-exported for unit tests that destructure `mod.<name>` on this module.
|
||||
export {
|
||||
@@ -225,7 +222,6 @@ function extractUserIdFromCookie(cookie: string): string {
|
||||
return extractNotionUserIdFromCookie(cookie);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Notion's undocumented inference API does not return token usage.
|
||||
* Emit a cheap char-based estimate so clients don't see a constant
|
||||
@@ -236,9 +232,7 @@ export function estimateNotionUsage(
|
||||
messages: NotionMessage[] | undefined,
|
||||
content: string
|
||||
): { prompt_tokens: number; completion_tokens: number; total_tokens: number; estimated: true } {
|
||||
const promptText = (messages || [])
|
||||
.map((m) => extractNotionMessageText(m?.content))
|
||||
.join("\n");
|
||||
const promptText = (messages || []).map((m) => extractNotionMessageText(m?.content)).join("\n");
|
||||
// ~4 chars/token (English-ish); at least 1 when there is any text.
|
||||
const prompt_tokens = promptText ? Math.max(1, Math.ceil(promptText.length / 4)) : 0;
|
||||
const completion_tokens = content ? Math.max(1, Math.ceil(content.length / 4)) : 0;
|
||||
@@ -393,9 +387,8 @@ function buildNotionExecuteHeaders(opts: {
|
||||
const isCustom = Boolean(opts.agent?.workflowId);
|
||||
// Browser uses /agent/<workflowId without dashes>?wfv=chat for custom agents.
|
||||
const agentPathId = (opts.agent?.workflowId || "").replace(/-/g, "");
|
||||
const referer = isCustom && agentPathId
|
||||
? `${BASE_URL}/agent/${agentPathId}?wfv=chat`
|
||||
: `${BASE_URL}/ai`;
|
||||
const referer =
|
||||
isCustom && agentPathId ? `${BASE_URL}/agent/${agentPathId}?wfv=chat` : `${BASE_URL}/ai`;
|
||||
const reqHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
@@ -453,11 +446,8 @@ export function resolveNotionAgentOptions(
|
||||
"agent_id",
|
||||
]) || "";
|
||||
const pageFromPs =
|
||||
readProviderSpecificString(ps, [
|
||||
"contextPageId",
|
||||
"context_page_id",
|
||||
"notionContextPageId",
|
||||
]) || "";
|
||||
readProviderSpecificString(ps, ["contextPageId", "context_page_id", "notionContextPageId"]) ||
|
||||
"";
|
||||
|
||||
const readCookie = (name: string): string => {
|
||||
const m = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`, "i"));
|
||||
@@ -477,10 +467,7 @@ export function resolveNotionAgentOptions(
|
||||
readCookie("agent_id")
|
||||
);
|
||||
const contextPageId =
|
||||
pageFromPs ||
|
||||
readCookie("context_page_id") ||
|
||||
readCookie("notion_context_page_id") ||
|
||||
"";
|
||||
pageFromPs || readCookie("context_page_id") || readCookie("notion_context_page_id") || "";
|
||||
|
||||
return {
|
||||
workflowId: workflowId || undefined,
|
||||
@@ -510,44 +497,22 @@ async function sendNotionInferenceRequest(opts: {
|
||||
body: JSON.stringify(reqBody),
|
||||
signal: signal ?? undefined,
|
||||
// Inference can take a while (tool-autoload + LLM first token).
|
||||
timeoutMs:
|
||||
Number.parseInt(process.env.OMNIROUTE_NOTION_TLS_TIMEOUT_MS || "", 10) || 180_000,
|
||||
timeoutMs: Number.parseInt(process.env.OMNIROUTE_NOTION_TLS_TIMEOUT_MS || "", 10) || 180_000,
|
||||
});
|
||||
status = tlsRes.status;
|
||||
rawText = tlsRes.text ?? "";
|
||||
} catch (err) {
|
||||
if (err instanceof TlsClientUnavailableError) {
|
||||
// Fall back to plain fetch only when the native TLS sidecar is missing —
|
||||
// better a degraded path than a hard crash on platforms without the binary.
|
||||
try {
|
||||
const upstream = await fetch(NOTION_URL, {
|
||||
method: "POST",
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
status = upstream.status;
|
||||
rawText = await upstream.text().catch(() => "");
|
||||
} catch (fallbackErr) {
|
||||
return {
|
||||
errorResult: makeErrorResult(
|
||||
502,
|
||||
`Notion fetch failed: ${fallbackErr instanceof Error ? fallbackErr.message : "unknown error"}`,
|
||||
reqBody,
|
||||
NOTION_URL
|
||||
),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
errorResult: makeErrorResult(
|
||||
502,
|
||||
`Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
||||
reqBody,
|
||||
NOTION_URL
|
||||
),
|
||||
};
|
||||
}
|
||||
// Fail closed: plain fetch would bypass the resolved proxy and Notion rejects
|
||||
// undici's fingerprint anyway. A missing native binding is a packaging error,
|
||||
// not permission to leak a direct request.
|
||||
return {
|
||||
errorResult: makeErrorResult(
|
||||
502,
|
||||
`Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
||||
reqBody,
|
||||
NOTION_URL
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
@@ -634,8 +599,7 @@ export class NotionWebExecutor extends BaseExecutor {
|
||||
const inboundHeaders =
|
||||
(input.clientHeaders as Record<string, string> | null | undefined) ??
|
||||
((input as { headers?: Record<string, string> }).headers as
|
||||
| Record<string, string>
|
||||
| undefined);
|
||||
Record<string, string> | undefined);
|
||||
const clientThreadId = readClientThreadId(requestBody, inboundHeaders ?? undefined);
|
||||
// Namespace the thread cache PER CALLER (hash of the caller's cookie) AND by custom
|
||||
// agent, so (a) two users of the same Notion space never share a cached thread
|
||||
@@ -738,7 +702,10 @@ export class NotionWebExecutor extends BaseExecutor {
|
||||
|
||||
// One automatic retry for transient Notion faults — same threadId, never create again
|
||||
if (isFailedAttempt(attempt) && attempt.retryable) {
|
||||
const delayMs = process.env.NODE_ENV === "test" || process.env.VITEST ? 20 : 700 + Math.floor(Math.random() * 400);
|
||||
const delayMs =
|
||||
process.env.NODE_ENV === "test" || process.env.VITEST
|
||||
? 20
|
||||
: 700 + Math.floor(Math.random() * 400);
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
attempt = await runOnce({ createThread: false, threadId });
|
||||
}
|
||||
|
||||
@@ -501,7 +501,7 @@ export class PerplexityWebExecutor extends BaseExecutor {
|
||||
if (isCloudflareChallenge(response.text)) {
|
||||
errMsg =
|
||||
"Cloudflare blocked the request — Perplexity's edge rejected this server's TLS fingerprint " +
|
||||
"(common on VPS/datacenter IPs). Ensure tls-client-node is installed with its native binary, " +
|
||||
"(common on VPS/datacenter IPs). Verify the wreq-js 3.2 native binding, " +
|
||||
"or route perplexity-web through a residential proxy.";
|
||||
log?.error?.("PPLX-WEB", "Cloudflare challenge detected — TLS bypass failed");
|
||||
} else {
|
||||
|
||||
@@ -472,6 +472,7 @@ import {
|
||||
isRpmExhausted,
|
||||
} from "../services/geminiRateLimitTracker.ts";
|
||||
import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
|
||||
import { getProactiveCompressionRatio } from "@/lib/db/compression";
|
||||
|
||||
type ChatCoreExecutorResult = ReturnType<typeof normalizeExecutorResult> & {
|
||||
_executionCredentials?: Record<string, unknown>;
|
||||
@@ -1993,7 +1994,7 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
const COMPRESSION_THRESHOLD = 0.7;
|
||||
const COMPRESSION_THRESHOLD = getProactiveCompressionRatio();
|
||||
let reservedTokens = 0;
|
||||
if (Array.isArray(body.tools)) {
|
||||
reservedTokens = estimateTokens(body.tools);
|
||||
|
||||
@@ -201,6 +201,7 @@ export async function handleRerank({
|
||||
connectionId = null,
|
||||
apiKeyId = null,
|
||||
apiKeyName = null,
|
||||
resolvedProvider = null,
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
if (!model) return errorResponse(400, "model is required");
|
||||
@@ -210,7 +211,8 @@ export async function handleRerank({
|
||||
}
|
||||
|
||||
const { provider: providerId, model: modelId } = parseRerankModel(model);
|
||||
const providerConfig = providerId ? getRerankProvider(providerId) : null;
|
||||
const providerConfig =
|
||||
resolvedProvider || (providerId ? getRerankProvider(providerId) : null);
|
||||
|
||||
if (!providerConfig) {
|
||||
const availableProviders = Object.keys(RERANK_PROVIDERS).join(", ");
|
||||
@@ -219,10 +221,13 @@ export async function handleRerank({
|
||||
`No rerank provider found for model "${model}". Available: ${availableProviders}`
|
||||
);
|
||||
}
|
||||
// When a derived/generic provider is injected, its id is authoritative for
|
||||
// logging and cost attribution even though parseRerankModel returned null.
|
||||
const effectiveProviderId = providerConfig.id || providerId;
|
||||
|
||||
const token = credentials?.apiKey || credentials?.accessToken;
|
||||
if (!token) {
|
||||
return errorResponse(401, `No credentials for rerank provider: ${providerId}`);
|
||||
return errorResponse(401, `No credentials for rerank provider: ${effectiveProviderId}`);
|
||||
}
|
||||
|
||||
const requestBody = transformRequestForProvider(providerConfig, {
|
||||
@@ -275,8 +280,8 @@ export async function handleRerank({
|
||||
method: "POST",
|
||||
path: "/v1/rerank",
|
||||
status: res.status,
|
||||
model: `${providerId}/${modelId}`,
|
||||
provider: providerId,
|
||||
model: `${effectiveProviderId}/${modelId}`,
|
||||
provider: effectiveProviderId,
|
||||
connectionId: connectionId || undefined,
|
||||
duration: Date.now() - startTime,
|
||||
requestBody,
|
||||
@@ -296,14 +301,14 @@ export async function handleRerank({
|
||||
});
|
||||
|
||||
const searchUnits = Number(result?.meta?.billed_units?.search_units) || 0;
|
||||
const costUsd = await calculateModalCost("rerank", providerId, modelId, { searchUnits });
|
||||
const costUsd = await calculateModalCost("rerank", effectiveProviderId, modelId, { searchUnits });
|
||||
|
||||
saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/rerank",
|
||||
status: 200,
|
||||
model: `${providerId}/${modelId}`,
|
||||
provider: providerId,
|
||||
model: `${effectiveProviderId}/${modelId}`,
|
||||
provider: effectiveProviderId,
|
||||
connectionId: connectionId || undefined,
|
||||
duration: Date.now() - startTime,
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
@@ -315,7 +320,7 @@ export async function handleRerank({
|
||||
|
||||
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
|
||||
attachOmniRouteMetaHeaders(headers, {
|
||||
provider: providerId,
|
||||
provider: effectiveProviderId,
|
||||
model: modelId,
|
||||
costUsd,
|
||||
latencyMs: Date.now() - startTime,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* Regression tests for the proxy-leak fix in grokTlsClient.
|
||||
*
|
||||
* Bug context (#3180): tlsFetchGrok() built its native tls-client-node
|
||||
* requestOptions without a `proxyUrl` field, so every grok-web call
|
||||
* Bug context (#3180): tlsFetchGrok() built its native transport options
|
||||
* without a `proxyUrl` field, so every grok-web call
|
||||
* egressed with the bare host IP regardless of the dashboard proxy config
|
||||
* or HTTP_PROXY / HTTPS_PROXY env vars (the koffi-loaded Go binary does not
|
||||
* consult Go's `http.ProxyFromEnvironment`).
|
||||
* or HTTP_PROXY / HTTPS_PROXY env vars. Native browser transports require the
|
||||
* resolved proxy to be passed explicitly.
|
||||
*
|
||||
* These tests pin the resolution-order contract:
|
||||
* 1. Per-call `options.proxyUrl` wins.
|
||||
* 2. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants).
|
||||
* 3. Otherwise undefined (no proxy).
|
||||
* 2. Request-scoped dashboard/account proxy context.
|
||||
* 3. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants).
|
||||
* 4. Otherwise undefined (no proxy).
|
||||
*
|
||||
* They also pin that the resolved proxy is actually placed on the
|
||||
* requestOptions object handed to the native binding — the original bug
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Browser-TLS-impersonating HTTP client for claude.ai.
|
||||
*
|
||||
* Thin re-export over the shared `tlsClientBase.ts` factory
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
|
||||
* streaming tail-file, proxy resolution, error classes, SSE detection) lives
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
|
||||
* pooling, direct streaming, proxy resolution, deadlines, SSE detection) lives
|
||||
* in the base module; this file supplies only Claude-specific config and
|
||||
* preserves the original public export surface.
|
||||
*/
|
||||
@@ -24,13 +24,13 @@ const HARD_TIMEOUT_GRACE_MS =
|
||||
export const tlsClientModule = createTlsClientModule({
|
||||
providerName: "Claude",
|
||||
tlsProfile: `chrome_${CLAUDE_TLS_BROWSER_MAJOR_VERSION}`,
|
||||
emulationOs: "linux",
|
||||
domain: "https://claude.ai",
|
||||
tempDirPrefix: "cgpt-stream-",
|
||||
tailFileVariant: "A",
|
||||
streamEofPolicy: "include",
|
||||
responseValidation: "sse",
|
||||
exportCloudflareCheck: false,
|
||||
exposeStreamingForTesting: true,
|
||||
// Claude waits indefinitely for the first SSE byte (original 2-arg waitForContent).
|
||||
// Claude allows the native/hard request deadline to bound a slow first SSE byte.
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
hardTimeoutGraceMs: HARD_TIMEOUT_GRACE_MS,
|
||||
firstByteTimeoutMs: Number.POSITIVE_INFINITY,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 3. Waits for Turnstile challenge to appear
|
||||
* 4. Waits for challenge to be solved (with retry)
|
||||
* 5. Extracts cf_clearance cookie
|
||||
* 6. Returns fresh cookie for tls-client-node
|
||||
* 6. Returns a fresh cookie for the isolated wreq-js request
|
||||
*/
|
||||
|
||||
import type { Browser, Page } from "playwright";
|
||||
|
||||
@@ -11,7 +11,11 @@ import type {
|
||||
EngineValidationResult,
|
||||
} from "../types.ts";
|
||||
import { CODEX_RESPONSE_ITEM_META } from "../../bodyAdapter.ts";
|
||||
import { countTextTokens } from "../../../../../src/shared/utils/tiktokenCounter.ts";
|
||||
import {
|
||||
countTextTokens,
|
||||
MAX_EXACT_TOKEN_COUNT_CHARS,
|
||||
} from "../../../../../src/shared/utils/tiktokenCounter.ts";
|
||||
import { jsonLength, jsonLengthStrippingBase64DataUris } from "../../../../utils/jsonSize.ts";
|
||||
|
||||
const ENGINE_ID = "codex-responses";
|
||||
|
||||
@@ -19,6 +23,23 @@ function countCodexTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return countTextTokens(text, { provider: "codex" });
|
||||
}
|
||||
|
||||
/** Codex-context token count for a whole body, skipping JSON.stringify on oversized
|
||||
* bodies: countTextTokens falls back to a char heuristic above MAX_EXACT_TOKEN_COUNT_CHARS,
|
||||
* so materializing a multi-MB string for the count is a pure OOM-class transient (#7847). */
|
||||
function countCodexTokensForBody(body: unknown): number {
|
||||
if (body === null || body === undefined) return 0;
|
||||
if (typeof body === "string") return countCodexTokens(body);
|
||||
if (jsonLength(body) > MAX_EXACT_TOKEN_COUNT_CHARS) {
|
||||
// Oversized bodies skip countTextTokens (which falls back to a char heuristic above
|
||||
// MAX_EXACT_TOKEN_COUNT_CHARS) to avoid materializing a multi-MB string (#7847). But the
|
||||
// exact path it replaces also stripped base64 data URIs first; the heuristic must too,
|
||||
// otherwise embedded screenshots inflate the reported token count and distort
|
||||
// savingsPercent. (The compression DECISION is unaffected either way.)
|
||||
return Math.ceil(jsonLengthStrippingBase64DataUris(body) / 4);
|
||||
}
|
||||
return countCodexTokens(JSON.stringify(body));
|
||||
}
|
||||
const SUPPORTED_TYPES = new Set([
|
||||
"function_call_output",
|
||||
"local_shell_call_output",
|
||||
@@ -274,8 +295,8 @@ export const codexResponsesEngine: CompressionEngine = {
|
||||
if (!changed) return { body, compressed: false, stats: null };
|
||||
const nextBody = { ...body, messages };
|
||||
const stats = createCompressionStats(body, nextBody, "codex-responses", [ENGINE_ID]);
|
||||
const originalTokens = countCodexTokens(JSON.stringify(body));
|
||||
const compressedTokens = countCodexTokens(JSON.stringify(nextBody));
|
||||
const originalTokens = countCodexTokensForBody(body);
|
||||
const compressedTokens = countCodexTokensForBody(nextBody);
|
||||
stats.originalTokens = originalTokens;
|
||||
stats.compressedTokens = compressedTokens;
|
||||
stats.savingsPercent =
|
||||
|
||||
@@ -162,17 +162,18 @@ export function applyHardBudget(
|
||||
// Distribute the aggregate budget proportionally per message so the SUM stays
|
||||
// ≤ target (passing the full target to each message would let an N-message body
|
||||
// come back N× over budget).
|
||||
let changed = false;
|
||||
const newMessages = messages.map((m) => {
|
||||
if (typeof m.content !== "string") return m;
|
||||
const msgTokens = countTextTokens(m.content, tokenizerContext);
|
||||
const perMsgTarget =
|
||||
totalTokens > 0 ? Math.floor(effectiveTarget * (msgTokens / totalTokens)) : effectiveTarget;
|
||||
const out = compressText(m.content, perMsgTarget, tokenizerContext);
|
||||
return out === m.content ? m : { ...m, content: out };
|
||||
if (out === m.content) return m;
|
||||
changed = true;
|
||||
return { ...m, content: out };
|
||||
});
|
||||
|
||||
const changed = newMessages.some((m, i) => JSON.stringify(m) !== JSON.stringify(messages[i]));
|
||||
|
||||
// Measure the result to detect when preserve-guarded content makes the target
|
||||
// unreachable, so callers are not silently left over budget.
|
||||
const usedMessages = changed ? newMessages : messages;
|
||||
|
||||
@@ -90,6 +90,8 @@ export {
|
||||
applyStackedCompressionAsync,
|
||||
} from "./strategySelector.ts";
|
||||
|
||||
export { getMemoStats, clearMemoStore, makeMemoKey, isDeterministicMode } from "./resultMemo.ts";
|
||||
|
||||
export type {
|
||||
CompressionEngine,
|
||||
CompressionEngineApplyOptions,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { estimateCompressionTokens } from "./stats.ts";
|
||||
import type { CompressionResult, CompressionStats } from "./types.ts";
|
||||
import { jsonSha256 } from "../../utils/jsonHash.ts";
|
||||
|
||||
export interface LiveZoneOptions {
|
||||
principalId?: string;
|
||||
@@ -57,8 +56,15 @@ function serialize(value: unknown): string | null {
|
||||
}
|
||||
|
||||
function digest(value: unknown): string | null {
|
||||
const serialized = serialize(value);
|
||||
return serialized === null ? null : createHash("sha256").update(serialized).digest("hex");
|
||||
// jsonSha256 computes sha256hex(JSON.stringify(value)) WITHOUT materializing the
|
||||
// multi-MB string, avoiding the #7847 OOM-class transient on large tool-message
|
||||
// items (e.g. base64 screenshots). Throws on non-serializable values, matching
|
||||
// the previous JSON.stringify behavior which the caller treats as a miss.
|
||||
try {
|
||||
return jsonSha256(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneItems(items: unknown[]): unknown[] | null {
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { CompressionConfig, CompressionMode, CompressionResult } from "./types.ts";
|
||||
import { jsonSha256 } from "../../utils/jsonHash.ts";
|
||||
|
||||
export const MEMO_CAP = 5_000;
|
||||
|
||||
const memoMap = new Map<string, CompressionResult>();
|
||||
let lookupCountForTests = 0;
|
||||
let memoHits = 0;
|
||||
let memoMisses = 0;
|
||||
|
||||
// ── Windowed hit/miss ring buffer for time-bucketed stats ──────────────
|
||||
// Records each lookup outcome with a ms timestamp. getMemoStats scans the
|
||||
// ring to compute 1m/5m/15m/1h windows (like load average) so operators see
|
||||
// the *current* hit rate during a traffic spike, not a diluted all-time
|
||||
// average. Bounded memory: RING_CAP * ~9 bytes ≈ 90 KB, fixed-size array.
|
||||
const RING_CAP = 10_000;
|
||||
const ring: Array<{ ts: number; hit: boolean } | undefined> = new Array(RING_CAP);
|
||||
let ringHead = 0; // index of the NEXT write slot (wraps)
|
||||
let ringCount = 0; // entries written so far (clamped to RING_CAP)
|
||||
|
||||
function recordLookup(hit: boolean): void {
|
||||
ring[ringHead] = { ts: Date.now(), hit };
|
||||
ringHead = (ringHead + 1) % RING_CAP;
|
||||
if (ringCount < RING_CAP) ringCount++;
|
||||
}
|
||||
|
||||
/** Compute hits/misses/hitRate for lookups within the last `windowMs`. */
|
||||
function windowStats(windowMs: number): { hits: number; misses: number; hitRate: number } {
|
||||
const cutoff = Date.now() - windowMs;
|
||||
let hits = 0;
|
||||
let misses = 0;
|
||||
// Walk newest→oldest. The ring is time-ordered (oldest at head), so once
|
||||
// an entry is older than the cutoff every earlier one is too — early break.
|
||||
for (let k = 0; k < ringCount; k++) {
|
||||
const idx = (ringHead - 1 - k + RING_CAP) % RING_CAP;
|
||||
const e = ring[idx];
|
||||
if (!e) break;
|
||||
if (e.ts < cutoff) break;
|
||||
if (e.hit) hits++;
|
||||
else misses++;
|
||||
}
|
||||
const total = hits + misses;
|
||||
return {
|
||||
hits,
|
||||
misses,
|
||||
hitRate: total > 0 ? Math.round((hits / total) * 10000) / 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Opt-IN whitelist (NOT opt-out): cache only engines proven pure + STATELESS across
|
||||
// requests. Excluded on purpose: `ccr` and `session-dedup` write to the cross-request
|
||||
@@ -41,7 +83,9 @@ export function makeMemoKey(
|
||||
model?: string,
|
||||
supportsVision?: boolean | null
|
||||
): string {
|
||||
const bodyHash = sha256hex(JSON.stringify(body));
|
||||
// Uses streaming jsonSha256 instead of sha256hex(JSON.stringify(body))
|
||||
// to avoid allocating multi-MB string transients on large agent payloads (#7847).
|
||||
const bodyHash = jsonSha256(body);
|
||||
|
||||
// #8137: Only include model + supportsVision in the cache key when the compression
|
||||
// result actually depends on them. The `lite` engine strips data:image URLs only when
|
||||
@@ -97,22 +141,74 @@ function boundedSet(key: string, value: CompressionResult): void {
|
||||
export function memoLookup(key: string): CompressionResult | null {
|
||||
lookupCountForTests++;
|
||||
const hit = memoMap.get(key);
|
||||
if (!hit) return null;
|
||||
if (!hit) {
|
||||
memoMisses++;
|
||||
recordLookup(false);
|
||||
return null;
|
||||
}
|
||||
memoHits++;
|
||||
recordLookup(true);
|
||||
// Return a clone so downstream mutation cannot corrupt the cached value.
|
||||
return JSON.parse(JSON.stringify(hit)) as CompressionResult;
|
||||
const cloned = JSON.parse(JSON.stringify(hit)) as CompressionResult;
|
||||
if (cloned.stats) {
|
||||
cloned.stats.memoHit = true;
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function memoStore(key: string, result: CompressionResult): void {
|
||||
// Clone on STORE too (memoLookup already clones on read). Storing the caller's live
|
||||
// object would let a later mutation of it (e.g. an async engine holding a sub-ref)
|
||||
// corrupt the cached entry. Both ends isolated ⇒ the cache is immutable once stored.
|
||||
boundedSet(key, JSON.parse(JSON.stringify(result)) as CompressionResult);
|
||||
export function memoStore(key: string, result: CompressionResult): CompressionResult {
|
||||
// Clone on STORE (memoLookup also clones on read) so the caller's live object — which
|
||||
// an async engine may still hold a sub-ref to — cannot later corrupt the cached entry.
|
||||
// Returns the stored clone so callers that need a fresh instance (the common
|
||||
// `memoStore(key, result); return memoLookup(key)!` idiom) can avoid a redundant
|
||||
// second multi-MB deep clone of the body on the way out.
|
||||
const stored = JSON.parse(JSON.stringify(result)) as CompressionResult;
|
||||
boundedSet(key, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** For tests only — clears the in-process memo store. */
|
||||
/** Observability stats for the in-process result memo store.
|
||||
* `windows` gives time-bucketed hit/miss/rate (1m/5m/15m/1h) so operators
|
||||
* see the *current* behavior during a spike, not the diluted lifetime rate.
|
||||
* `hits`/`misses`/`hitRate` remain the lifetime cumulative counters. */
|
||||
export function getMemoStats(): {
|
||||
size: number;
|
||||
capacity: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
hitRate: number;
|
||||
windows: {
|
||||
"1m": { hits: number; misses: number; hitRate: number };
|
||||
"5m": { hits: number; misses: number; hitRate: number };
|
||||
"15m": { hits: number; misses: number; hitRate: number };
|
||||
"1h": { hits: number; misses: number; hitRate: number };
|
||||
};
|
||||
} {
|
||||
const total = memoHits + memoMisses;
|
||||
return {
|
||||
size: memoMap.size,
|
||||
capacity: MEMO_CAP,
|
||||
hits: memoHits,
|
||||
misses: memoMisses,
|
||||
hitRate: total > 0 ? Math.round((memoHits / total) * 10000) / 100 : 0,
|
||||
windows: {
|
||||
"1m": windowStats(60_000),
|
||||
"5m": windowStats(5 * 60_000),
|
||||
"15m": windowStats(15 * 60_000),
|
||||
"1h": windowStats(60 * 60_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** For tests only — clears the in-process memo store and resets counters. */
|
||||
export function clearMemoStore(): void {
|
||||
memoMap.clear();
|
||||
lookupCountForTests = 0;
|
||||
memoHits = 0;
|
||||
memoMisses = 0;
|
||||
for (let i = 0; i < RING_CAP; i++) ring[i] = undefined;
|
||||
ringHead = 0;
|
||||
ringCount = 0;
|
||||
}
|
||||
export const resultMemoForTests = {
|
||||
get lookupCount(): number {
|
||||
|
||||
@@ -11,14 +11,22 @@ import {
|
||||
countTextTokens,
|
||||
isCodexTokenizerContext,
|
||||
tokenizerContextFromBody,
|
||||
MAX_EXACT_TOKEN_COUNT_CHARS,
|
||||
} from "../../../src/shared/utils/tiktokenCounter.ts";
|
||||
import {
|
||||
anthropicImageTokens,
|
||||
ANTHROPIC_IMAGE_BLOCK_OVERHEAD_TOKENS,
|
||||
openAIVisionTokens,
|
||||
} from "omniglyph";
|
||||
import { isInlineBase64ImageBlock } from "../contextManager.ts";
|
||||
import {
|
||||
jsonLength,
|
||||
jsonLengthStrippingBase64DataUris,
|
||||
rawLengthStrippingBase64DataUris,
|
||||
} from "../../utils/jsonSize.ts";
|
||||
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
const DEFAULT_IMAGE_TOKEN_ESTIMATE = 1200;
|
||||
|
||||
/**
|
||||
* Anthropic image block shape this estimator recognizes:
|
||||
@@ -112,11 +120,15 @@ function decodePngDimensions(base64: string): { width: number; height: number }
|
||||
}
|
||||
}
|
||||
|
||||
/** Char-count fallback for one value (same accounting as the legacy estimator). */
|
||||
/** Char-count fallback for one value (using jsonLength to avoid allocating multi-MB strings).
|
||||
* Base64 data URIs embedded in arbitrary strings (not just structured image blocks) are
|
||||
* stripped so a tool-output screenshot doesn't inflate the token estimate (#7847 drift). */
|
||||
function charTokensOf(value: unknown): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
const str = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return Math.ceil(str.length / CHARS_PER_TOKEN);
|
||||
if (typeof value === "string") {
|
||||
return Math.ceil(rawLengthStrippingBase64DataUris(value) / CHARS_PER_TOKEN);
|
||||
}
|
||||
return Math.ceil(jsonLengthStrippingBase64DataUris(value) / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,23 +154,42 @@ function blankImageBlocksAndSumImageTokens(body: Record<string, unknown>): {
|
||||
return content.map((block) => {
|
||||
if (isAnthropicPngImageBlock(block)) {
|
||||
const dims = decodePngDimensions(block.source.data);
|
||||
if (!dims) return block; // fall back to char-counting this block as-is
|
||||
if (!dims) {
|
||||
// Recognized image block that can't be decoded: use a bounded estimate rather
|
||||
// than char-counting the raw base64, which would inflate the token estimate
|
||||
// multi-MB (the #7847 OOM/drift class).
|
||||
imageTokens += DEFAULT_IMAGE_TOKEN_ESTIMATE;
|
||||
return { ...block, source: { ...block.source, data: "" } };
|
||||
}
|
||||
imageTokens += anthropicImageTokens(dims.width, dims.height, "standard");
|
||||
imageTokens += ANTHROPIC_IMAGE_BLOCK_OVERHEAD_TOKENS;
|
||||
return { ...block, source: { ...block.source, data: "" } };
|
||||
}
|
||||
if (isOpenAIChatPngImagePart(block)) {
|
||||
const dims = pngDimensionsFromDataUrl(block.image_url.url);
|
||||
if (!dims) return block;
|
||||
if (!dims) {
|
||||
imageTokens += DEFAULT_IMAGE_TOKEN_ESTIMATE;
|
||||
return { ...block, image_url: { ...block.image_url, url: "" } };
|
||||
}
|
||||
imageTokens += openAIVisionTokens(model, dims.width, dims.height);
|
||||
return { ...block, image_url: { ...block.image_url, url: "" } };
|
||||
}
|
||||
if (isOpenAIResponsesPngImagePart(block)) {
|
||||
const dims = pngDimensionsFromDataUrl(block.image_url);
|
||||
if (!dims) return block;
|
||||
if (!dims) {
|
||||
imageTokens += DEFAULT_IMAGE_TOKEN_ESTIMATE;
|
||||
return { ...block, image_url: "" };
|
||||
}
|
||||
imageTokens += openAIVisionTokens(model, dims.width, dims.height);
|
||||
return { ...block, image_url: "" };
|
||||
}
|
||||
if (isInlineBase64ImageBlock(block as Record<string, unknown>)) {
|
||||
// Inline-base64 image content-block shape (AI SDK / Gemini / flat) not
|
||||
// covered by the PNG decoders above. Keep the estimate bounded so a
|
||||
// multi-MB screenshot doesn't inflate the token count (#7847 drift).
|
||||
imageTokens += DEFAULT_IMAGE_TOKEN_ESTIMATE;
|
||||
return { ...block, image: "" };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
};
|
||||
@@ -201,15 +232,19 @@ export function estimateCompressionTokens(text: string | object | null | undefin
|
||||
text as Record<string, unknown>
|
||||
);
|
||||
if (imageTokens === 0) {
|
||||
// Keep the legacy character estimate for generic payloads. Codex payloads use
|
||||
// the model-appropriate tokenizer so their compression stats match hard budgets.
|
||||
return useExactTokenizer
|
||||
? countTextTokens(JSON.stringify(text), tokenizerContext)
|
||||
: charTokensOf(text);
|
||||
// countTextTokens falls back to a char heuristic above MAX_EXACT_TOKEN_COUNT_CHARS,
|
||||
// so materializing JSON.stringify(text) for a large body would only allocate a
|
||||
// multi-MB transient that's immediately discarded (#7847 OOM class). Measure the
|
||||
// serialized length via jsonLength instead and skip the allocation when oversized.
|
||||
if (useExactTokenizer && jsonLength(text) <= MAX_EXACT_TOKEN_COUNT_CHARS) {
|
||||
return countTextTokens(JSON.stringify(text), tokenizerContext);
|
||||
}
|
||||
return charTokensOf(text);
|
||||
}
|
||||
return useExactTokenizer
|
||||
? countTextTokens(JSON.stringify(clone), tokenizerContext) + imageTokens
|
||||
: charTokensOf(clone) + imageTokens;
|
||||
if (useExactTokenizer && jsonLength(clone) <= MAX_EXACT_TOKEN_COUNT_CHARS) {
|
||||
return countTextTokens(JSON.stringify(clone), tokenizerContext) + imageTokens;
|
||||
}
|
||||
return charTokensOf(clone) + imageTokens;
|
||||
} catch {
|
||||
// Non-serializable/unexpected shape → fall back to the legacy char-count,
|
||||
// never throw out of an estimator.
|
||||
|
||||
@@ -331,6 +331,10 @@ function runCompression(
|
||||
...options,
|
||||
config: { ...options.config, memoizeCompressionResults: false },
|
||||
});
|
||||
// memoStore clones internally, so the cache entry stays isolated from the caller's
|
||||
// live object. Return the caller's own `result` (upstream #11727 semantics): handing
|
||||
// back the stored clone would let the caller's later mutations corrupt the cache —
|
||||
// the exact bug the result-memo mutation-isolation test guards.
|
||||
memoStore(key, result);
|
||||
return result;
|
||||
}
|
||||
@@ -564,6 +568,8 @@ async function runCompressionAsync(
|
||||
...options,
|
||||
config: { ...options.config, memoizeCompressionResults: false },
|
||||
});
|
||||
// Same contract as the sync path: store the internal clone; return the caller's own
|
||||
// object so later caller mutations cannot corrupt the cache (#11727 semantics).
|
||||
memoStore(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -326,6 +326,8 @@ export interface CompressionStats {
|
||||
validationWarnings?: string[];
|
||||
validationErrors?: string[];
|
||||
fallbackApplied?: boolean;
|
||||
/** #7847 observability: true when this result was served from the result memo cache. */
|
||||
memoHit?: boolean;
|
||||
/**
|
||||
* Contabilidade física do OmniGlyph, normalizada pelo próprio pacote
|
||||
* (`normalizeAccounting`). Só número e enum — ver `omniglyphTelemetry.ts`
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "../../src/lib/db/contextHandoffs.ts";
|
||||
import { estimateTokens } from "./contextManager.ts";
|
||||
import { stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts";
|
||||
import { isFeatureFlagEnabled } from "../../src/shared/utils/featureFlags.ts";
|
||||
|
||||
export const HANDOFF_WARNING_THRESHOLD = 0.85;
|
||||
export const HANDOFF_EXHAUSTION_THRESHOLD = 0.95;
|
||||
@@ -139,7 +140,9 @@ export function resolveUniversalHandoffConfig(
|
||||
triggerRaw === "always" || triggerRaw === "on-error" ? triggerRaw : "on-switch";
|
||||
|
||||
return {
|
||||
enabled: getBool("enabled", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.enabled),
|
||||
enabled:
|
||||
isFeatureFlagEnabled("UNIVERSAL_CONTEXT_HANDOFF_ENABLED") &&
|
||||
getBool("enabled", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.enabled),
|
||||
trigger,
|
||||
providerAllowlist: getStringArray(
|
||||
"providerAllowlist",
|
||||
|
||||
@@ -363,6 +363,17 @@ export function classifyProviderError(
|
||||
if (recoverableProject403) {
|
||||
return PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR;
|
||||
}
|
||||
// Kiro IDC missing profileArn — AWS returns 403 "User is not authorized to make this call"
|
||||
// when the request is sent without a profileArn or to the wrong Q Developer region.
|
||||
// This is a recoverable configuration issue, not a ban: the account still works in Kiro IDE.
|
||||
// Do NOT classify as FORBIDDEN (which bans permanently). Treat as PROJECT_ROUTE_ERROR
|
||||
// so the connection stays active and can be retried after profile discovery (#10725).
|
||||
const isKiroProfile403 =
|
||||
(p === "kiro" || p === "amazon-q") &&
|
||||
bodyStr.includes("User is not authorized to make this call");
|
||||
if (isKiroProfile403) {
|
||||
return PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR;
|
||||
}
|
||||
// A Cloudflare Sentinel/Turnstile 403 is a TERMINAL block for browser-session
|
||||
// providers: the user's IP/session needs a browser Turnstile challenge, and
|
||||
// retrying the same connection will keep 403ing. Classify as FORBIDDEN so
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Browser-TLS-impersonating HTTP client for grok.com.
|
||||
*
|
||||
* Thin re-export over the shared `tlsClientBase.ts` factory
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
|
||||
* streaming tail-file, proxy resolution, error classes, Cloudflare challenge
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
|
||||
* pooling, direct streaming, proxy resolution, deadlines, Cloudflare challenge
|
||||
* detection) lives in the base module; this file supplies only Grok-specific
|
||||
* config and preserves the original public export surface.
|
||||
*/
|
||||
@@ -22,9 +22,9 @@ const HARD_TIMEOUT_GRACE_MS =
|
||||
export const tlsClientModule = createTlsClientModule({
|
||||
providerName: "Grok",
|
||||
tlsProfile: "chrome_146",
|
||||
emulationOs: "linux",
|
||||
domain: "https://grok.com",
|
||||
tempDirPrefix: "grok-stream-",
|
||||
tailFileVariant: "B1",
|
||||
streamEofPolicy: "exclude",
|
||||
responseValidation: "cf",
|
||||
exportCloudflareCheck: true,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Browser-TLS-impersonating HTTP client for arena.ai.
|
||||
*
|
||||
* Thin re-export over the shared `tlsClientBase.ts` factory
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
|
||||
* streaming tail-file, proxy resolution, error classes, Cloudflare challenge
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
|
||||
* pooling, direct streaming, proxy resolution, deadlines, Cloudflare challenge
|
||||
* detection) lives in the base module; this file supplies only LMArena-specific
|
||||
* config and preserves the original public export surface.
|
||||
*/
|
||||
@@ -20,11 +20,12 @@ const HARD_TIMEOUT_GRACE_MS = 10_000;
|
||||
export const tlsClientModule = createTlsClientModule({
|
||||
providerName: "LMArena",
|
||||
tlsProfile: "chrome_146",
|
||||
emulationOs: "windows",
|
||||
domain: "https://lmarena.ai",
|
||||
// LMArena's proxy resolution domain is hardcoded to arena.ai, not the config domain.
|
||||
proxyDomainOverride: "https://arena.ai",
|
||||
tempDirPrefix: "LMArena-stream-",
|
||||
tailFileVariant: "B2",
|
||||
streamEofPolicy: "none",
|
||||
streamEofSymbol: "",
|
||||
responseValidation: "cf",
|
||||
exportCloudflareCheck: true,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Browser-TLS-impersonating HTTP client for app.notion.com.
|
||||
*
|
||||
* Thin re-export over the shared `tlsClientBase.ts` factory
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
|
||||
* streaming tail-file, proxy resolution, error classes, SSE detection,
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
|
||||
* pooling, direct streaming, proxy resolution, deadlines, SSE detection,
|
||||
* Cloudflare challenge detection) lives in the base module; this file supplies
|
||||
* only Notion-specific config and preserves the original public export surface.
|
||||
*/
|
||||
@@ -22,9 +22,9 @@ const HARD_TIMEOUT_GRACE_MS =
|
||||
export const tlsClientModule = createTlsClientModule({
|
||||
providerName: "Notion",
|
||||
tlsProfile: "chrome_146",
|
||||
emulationOs: "windows",
|
||||
domain: "https://app.notion.com",
|
||||
tempDirPrefix: "pplx-stream-",
|
||||
tailFileVariant: "A",
|
||||
streamEofPolicy: "include",
|
||||
responseValidation: "sse",
|
||||
exportCloudflareCheck: true,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
|
||||
@@ -21,7 +21,15 @@ type JsonRecord = Record<string, unknown>;
|
||||
* Related: services/mimoThinking.ts uses the same pattern for Xiaomi MiMo.
|
||||
*/
|
||||
|
||||
const OPENCODE_GO_PROVIDERS = new Set(["ollama-cloud", "opencode-go", "opencode", "opencode-zen"]);
|
||||
const OPENCODE_GO_PROVIDERS = new Set([
|
||||
"ollama-cloud",
|
||||
"ollamacloud",
|
||||
"ollama_cloud",
|
||||
"opencode-go",
|
||||
"opencode_go",
|
||||
"opencode",
|
||||
"opencode-zen",
|
||||
]);
|
||||
|
||||
/** True when the provider is backed by the opencode-go backend. */
|
||||
export function isOpencodeGoProvider(provider: string): boolean {
|
||||
|
||||
@@ -17,8 +17,13 @@
|
||||
* -> { data: { total_credits, total_usage } }
|
||||
* Account-level totals; upstream caches this endpoint for ~60s already.
|
||||
*
|
||||
* We fetch both (credits is a cheap second call, same auth) and merge into one
|
||||
* QuotaInfo. Graceful "unknown" on any fetch failure — quota tracking must
|
||||
* We fetch both and merge into one QuotaInfo. OpenRouter is credit-based, not
|
||||
* subscription-based: the /credits balance (`total_credits - total_usage`, the
|
||||
* documented "get remaining credits" signal) is authoritative and stands on
|
||||
* its own — a /key failure (rate limit, transient error, unexpected shape)
|
||||
* degrades to a credits-only quota instead of discarding the balance.
|
||||
* Only a double auth-rejection (401/403 on both) means the token is invalid.
|
||||
* Graceful "unknown" on any fetch failure — quota tracking must
|
||||
* never block routing (mirrors deepseekQuotaFetcher.ts / bailianQuotaFetcher.ts).
|
||||
*
|
||||
* Cache: in-memory TTL (45s, inside the 30-60s window OpenRouter's own docs
|
||||
@@ -204,6 +209,38 @@ function buildQuotaFromParts(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits-only quota — built when `/key` is unavailable but `/credits`
|
||||
* succeeded. OpenRouter is credit-based, not subscription-based: the account
|
||||
* balance (`total_credits - total_usage`, the documented "get remaining
|
||||
* credits" signal) stands on its own without any key-level cap data.
|
||||
*/
|
||||
function buildCreditsOnlyQuota(credits: OpenrouterCreditsFields): OpenrouterQuota {
|
||||
const creditBalance =
|
||||
credits.totalCredits !== null && credits.totalUsage !== null
|
||||
? credits.totalCredits - credits.totalUsage
|
||||
: null;
|
||||
return {
|
||||
used: 0,
|
||||
total: 100,
|
||||
percentUsed: 0,
|
||||
resetAt: null,
|
||||
limitReached: false,
|
||||
limit: null,
|
||||
limitRemaining: null,
|
||||
isFreeTier: false,
|
||||
usage: 0,
|
||||
usageDaily: 0,
|
||||
usageWeekly: 0,
|
||||
usageMonthly: 0,
|
||||
byokUsage: null,
|
||||
includeByokInLimit: false,
|
||||
totalCredits: credits.totalCredits,
|
||||
totalUsage: credits.totalUsage,
|
||||
creditBalance,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Free-Window Preflight (#6842) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -265,6 +302,36 @@ async function fetchJson(
|
||||
}
|
||||
}
|
||||
|
||||
type EndpointResult = { status: number; data: unknown } | null;
|
||||
|
||||
function isAuthRejected(result: EndpointResult): boolean {
|
||||
return !result || result.status === 401 || result.status === 403;
|
||||
}
|
||||
|
||||
function rememberQuota(connectionId: string, quota: OpenrouterQuota): OpenrouterQuota {
|
||||
quotaCache.set(connectionId, { quota, fetchedAt: Date.now() });
|
||||
return quota;
|
||||
}
|
||||
|
||||
function mergeOpenrouterResults(
|
||||
keyResult: EndpointResult,
|
||||
creditsResult: EndpointResult
|
||||
): OpenrouterQuota | null {
|
||||
const keyFields =
|
||||
keyResult && keyResult.status === 200 ? parseOpenrouterKeyResponse(keyResult.data) : null;
|
||||
const creditsFields =
|
||||
creditsResult && creditsResult.status === 200
|
||||
? parseOpenrouterCreditsResponse(creditsResult.data)
|
||||
: { totalCredits: null, totalUsage: null };
|
||||
if (keyFields) return buildQuotaFromParts(keyFields, creditsFields);
|
||||
// /key unavailable (rate-limited, transient failure, or unexpected shape).
|
||||
// OpenRouter is credit-based: the /credits balance stands on its own.
|
||||
if (creditsFields.totalCredits !== null || creditsFields.totalUsage !== null) {
|
||||
return buildCreditsOnlyQuota(creditsFields);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current quota for an OpenRouter connection.
|
||||
* Returns quota info based on the /key + /credits API responses.
|
||||
@@ -291,29 +358,24 @@ export async function fetchOpenrouterQuota(
|
||||
try {
|
||||
await throttleQuotaFetch();
|
||||
|
||||
const keyUrl = `${OPENROUTER_CONFIG.baseUrl}${OPENROUTER_CONFIG.keyPath}`;
|
||||
const keyResult = await fetchJson(keyUrl, apiKey);
|
||||
const keyResult = await fetchJson(
|
||||
`${OPENROUTER_CONFIG.baseUrl}${OPENROUTER_CONFIG.keyPath}`,
|
||||
apiKey
|
||||
);
|
||||
const creditsResult = await fetchJson(
|
||||
`${OPENROUTER_CONFIG.baseUrl}${OPENROUTER_CONFIG.creditsPath}`,
|
||||
apiKey
|
||||
);
|
||||
|
||||
// 401/403 on the key endpoint: token invalid — remove from cache, fail open.
|
||||
if (!keyResult || keyResult.status === 401 || keyResult.status === 403) {
|
||||
// Both endpoints auth-rejected: the token itself is invalid — fail open.
|
||||
// A single-endpoint rejection must NOT discard the other endpoint's data.
|
||||
if (isAuthRejected(keyResult) && isAuthRejected(creditsResult)) {
|
||||
quotaCache.delete(connectionId);
|
||||
return null;
|
||||
}
|
||||
if (keyResult.status !== 200) return null;
|
||||
|
||||
const keyFields = parseOpenrouterKeyResponse(keyResult.data);
|
||||
if (!keyFields) return null;
|
||||
|
||||
const creditsUrl = `${OPENROUTER_CONFIG.baseUrl}${OPENROUTER_CONFIG.creditsPath}`;
|
||||
const creditsResult = await fetchJson(creditsUrl, apiKey);
|
||||
const creditsFields =
|
||||
creditsResult && creditsResult.status === 200
|
||||
? parseOpenrouterCreditsResponse(creditsResult.data)
|
||||
: { totalCredits: null, totalUsage: null };
|
||||
|
||||
const quota = buildQuotaFromParts(keyFields, creditsFields);
|
||||
quotaCache.set(connectionId, { quota, fetchedAt: Date.now() });
|
||||
return quota;
|
||||
const quota = mergeOpenrouterResults(keyResult, creditsResult);
|
||||
return quota ? rememberQuota(connectionId, quota) : null;
|
||||
} catch {
|
||||
// Network error, timeout, etc. — fail open (graceful "unknown").
|
||||
return null;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Browser-TLS-impersonating HTTP client for www.perplexity.ai.
|
||||
*
|
||||
* Thin re-export over the shared `tlsClientBase.ts` factory
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
|
||||
* streaming tail-file, proxy resolution, error classes, SSE detection,
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
|
||||
* pooling, direct streaming, proxy resolution, deadlines, SSE detection,
|
||||
* Cloudflare challenge detection) lives in the base module; this file supplies
|
||||
* only Perplexity-specific config and preserves the original public export
|
||||
* surface.
|
||||
@@ -23,9 +23,9 @@ const HARD_TIMEOUT_GRACE_MS =
|
||||
export const tlsClientModule = createTlsClientModule({
|
||||
providerName: "Perplexity",
|
||||
tlsProfile: "firefox_148",
|
||||
emulationOs: "macos",
|
||||
domain: "https://www.perplexity.ai",
|
||||
tempDirPrefix: "pplx-stream-",
|
||||
tailFileVariant: "A",
|
||||
streamEofPolicy: "include",
|
||||
responseValidation: "sse",
|
||||
exportCloudflareCheck: true,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
|
||||
@@ -36,6 +36,10 @@ import {
|
||||
getResolvedModelCapabilities,
|
||||
supportsReasoning,
|
||||
} from "@/lib/modelCapabilities";
|
||||
import {
|
||||
jsonLengthStrippingBase64DataUris,
|
||||
rawLengthStrippingBase64DataUris,
|
||||
} from "../utils/jsonSize.ts";
|
||||
|
||||
// Effort → budget token mapping
|
||||
export const EFFORT_BUDGETS: Record<string, number> = {
|
||||
@@ -350,7 +354,8 @@ function applyAdaptiveBudget(body: unknown, cfg: Partial<ThinkingBudgetConfig>)
|
||||
const tools = Array.isArray(bodyRecord.tools) ? bodyRecord.tools : [];
|
||||
const toolCount = tools.length;
|
||||
|
||||
// Get last user message length
|
||||
// Get last user message length. Strip base64 data URIs so an inline image in the prompt
|
||||
// doesn't inflate lastMsgLength and silently bump the complexity multiplier.
|
||||
let lastMsgLength = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
@@ -358,8 +363,8 @@ function applyAdaptiveBudget(body: unknown, cfg: Partial<ThinkingBudgetConfig>)
|
||||
if (msgRecord.role === "user") {
|
||||
lastMsgLength =
|
||||
typeof msgRecord.content === "string"
|
||||
? msgRecord.content.length
|
||||
: JSON.stringify(msgRecord.content || "").length;
|
||||
? rawLengthStrippingBase64DataUris(msgRecord.content)
|
||||
: jsonLengthStrippingBase64DataUris(msgRecord.content || "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { resolveDataDir } from "@/lib/dataPaths";
|
||||
|
||||
/**
|
||||
* Writable cache directory for tls-client-node's native binary.
|
||||
*
|
||||
* Without an explicit `downloadDir`, the library defaults to its own package
|
||||
* `node_modules/tls-client-node/bin`, which is root-owned on global installs
|
||||
* and fails with EACCES for normal users (#8579).
|
||||
*/
|
||||
export function resolveTlsClientDownloadDir(): string {
|
||||
return join(resolveDataDir(), "tls-client", "bin");
|
||||
}
|
||||
|
||||
export function buildNativeTlsClientOptions(): {
|
||||
runtimeMode: "native";
|
||||
downloadDir: string;
|
||||
} {
|
||||
return {
|
||||
runtimeMode: "native",
|
||||
downloadDir: resolveTlsClientDownloadDir(),
|
||||
};
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import { getQoderUsage, parseQoderUserStatusUsage } from "./usage/qoder.ts";
|
||||
export { parseQoderUserStatusUsage } from "./usage/qoder.ts";
|
||||
import { getOpencodeUsage } from "./usage/opencode.ts";
|
||||
import { getDeepseekUsage } from "./usage/deepseek.ts";
|
||||
import { getDevinCliUsage } from "./usage/devinCli.ts";
|
||||
import { getBailianCodingPlanUsage } from "./usage/bailian.ts";
|
||||
import { getVertexUsage } from "./usage/vertex.ts";
|
||||
import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.ts";
|
||||
@@ -208,6 +209,9 @@ export async function getUsageForProvider(
|
||||
return await getAgentrouterUsage(id, connection);
|
||||
case "kilocode":
|
||||
return await getKilocodeUsage(id, connection);
|
||||
case "devin-cli":
|
||||
// Devin CLI tokens live in `accessToken` (oauth import) or `apiKey`.
|
||||
return await getDevinCliUsage(apiKey || accessToken);
|
||||
default:
|
||||
return { message: `Usage API not implemented for ${provider}` };
|
||||
}
|
||||
|
||||
269
open-sse/services/usage/devinCli.ts
Normal file
269
open-sse/services/usage/devinCli.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* usage/devinCli.ts — Devin CLI (devin-cli / devin-cli-agentic) usage fetcher.
|
||||
*
|
||||
* Devin exposes no REST usage endpoint; the official CLI reads account quota from
|
||||
* the Codeium seat-management Connect API:
|
||||
*
|
||||
* POST {api}/exa.seat_management_pb.SeatManagementService/GetUserStatus
|
||||
* Content-Type: application/proto
|
||||
* Connect-Protocol-Version: 1
|
||||
* Authorization: Basic <token>-<token> (raw, non-base64 — Codeium convention)
|
||||
*
|
||||
* Request body (protobuf):
|
||||
* GetUserStatusRequest { 1: Metadata { 1: ide_name, 2: extension_version,
|
||||
* 3: api_key, 4: locale, 5: platform } }
|
||||
*
|
||||
* Response (protobuf) — the fields surfaced here, read off the live wire format:
|
||||
* GetUserStatusResponse { 1: user_status { 13: plan_status {
|
||||
* 1: plan_info { 2: plan_name } → "Pro" | "Teams" | …
|
||||
* 14: daily_quota_remaining_percent → 0..100
|
||||
* 15: weekly_quota_remaining_percent → 0..100
|
||||
* 17: daily_quota_reset_at_unix → epoch seconds
|
||||
* 18: weekly_quota_reset_at_unix → epoch seconds
|
||||
* } } }
|
||||
*
|
||||
* Surfaces `daily` / `weekly` percent-based quotas (used/total expressed in
|
||||
* percent, matching the percent-quota style used by the Claude family leaves)
|
||||
* for Provider Limits and genericQuotaFetcher preflight. Graceful `{ message }`
|
||||
* on any failure — quota tracking must never block routing.
|
||||
*/
|
||||
|
||||
import { parseResetTime, type UsageQuota } from "./quota.ts";
|
||||
|
||||
const SEAT_MANAGEMENT_API_BASE =
|
||||
process.env.DEVIN_SEAT_API_URL?.trim() || "https://server.codeium.com";
|
||||
const GET_USER_STATUS_PATH = "/exa.seat_management_pb.SeatManagementService/GetUserStatus";
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const CONNECT_PROTOCOL_VERSION = "1";
|
||||
|
||||
// ─── Minimal protobuf wire helpers ───────────────────────────────────────────
|
||||
|
||||
function encodeVarint(value: number): number[] {
|
||||
const bytes: number[] = [];
|
||||
let v = value;
|
||||
while (v > 0x7f) {
|
||||
bytes.push((v & 0x7f) | 0x80);
|
||||
v = Math.floor(v / 128);
|
||||
}
|
||||
bytes.push(v);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function encodeStringField(field: number, text: string): number[] {
|
||||
const bytes = Array.from(new TextEncoder().encode(text));
|
||||
return [(field << 3) | 2, ...encodeVarint(bytes.length), ...bytes];
|
||||
}
|
||||
|
||||
function buildGetUserStatusRequest(token: string): Uint8Array {
|
||||
const metadata = [
|
||||
...encodeStringField(1, "chisel"), // ide_name
|
||||
...encodeStringField(2, "0.0.0-dev"), // extension_version
|
||||
...encodeStringField(3, token), // api_key
|
||||
...encodeStringField(4, "en"), // locale
|
||||
...encodeStringField(5, "linux"), // platform
|
||||
...encodeStringField(7, "0.0.0-dev"), // ide_version — required by the endpoint
|
||||
];
|
||||
return new Uint8Array([
|
||||
...encodeVarint((1 << 3) | 2),
|
||||
...encodeVarint(metadata.length),
|
||||
...metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
interface ProtoField {
|
||||
field: number;
|
||||
varint: number | null;
|
||||
bytes: Uint8Array | null;
|
||||
}
|
||||
|
||||
function readVarint(buf: Uint8Array, start: number): { value: number; next: number } | null {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
let i = start;
|
||||
for (;;) {
|
||||
if (i >= buf.length) return null;
|
||||
const byte = buf[i++];
|
||||
result += (byte & 0x7f) * Math.pow(2, shift);
|
||||
if ((byte & 0x80) === 0) return { value: result, next: i };
|
||||
shift += 7;
|
||||
if (shift > 63) return null;
|
||||
}
|
||||
}
|
||||
|
||||
function advancePastFixed(buf: Uint8Array, i: number, size: number): number | null {
|
||||
return i + size > buf.length ? null : i + size;
|
||||
}
|
||||
|
||||
/** Decode one protobuf field; `{ field: null }` skips fixed64/fixed32 payloads. */
|
||||
function decodeOneField(
|
||||
buf: Uint8Array,
|
||||
start: number
|
||||
): { field: ProtoField | null; next: number } | null {
|
||||
const tag = readVarint(buf, start);
|
||||
if (!tag) return null;
|
||||
const field = tag.value >>> 3;
|
||||
const wire = tag.value & 7;
|
||||
if (wire === 0) {
|
||||
const v = readVarint(buf, tag.next);
|
||||
if (!v) return null;
|
||||
return { field: { field, varint: v.value, bytes: null }, next: v.next };
|
||||
}
|
||||
if (wire === 2) {
|
||||
const len = readVarint(buf, tag.next);
|
||||
if (!len || len.value > buf.length - len.next) return null;
|
||||
return {
|
||||
field: { field, varint: null, bytes: buf.subarray(len.next, len.next + len.value) },
|
||||
next: len.next + len.value,
|
||||
};
|
||||
}
|
||||
if (wire === 1) {
|
||||
const next = advancePastFixed(buf, tag.next, 8);
|
||||
return next === null ? null : { field: null, next };
|
||||
}
|
||||
if (wire === 5) {
|
||||
const next = advancePastFixed(buf, tag.next, 4);
|
||||
return next === null ? null : { field: null, next };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Walk one protobuf message into (field, value) triples; null on malformed input. */
|
||||
export function decodeProtoFields(buf: Uint8Array): ProtoField[] | null {
|
||||
const out: ProtoField[] = [];
|
||||
let i = 0;
|
||||
while (i < buf.length) {
|
||||
const step = decodeOneField(buf, i);
|
||||
if (!step) return null;
|
||||
if (step.field) out.push(step.field);
|
||||
i = step.next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fieldBytes(fields: ProtoField[] | null, field: number): Uint8Array | null {
|
||||
return fields?.find((f) => f.field === field && f.bytes !== null)?.bytes ?? null;
|
||||
}
|
||||
|
||||
function fieldVarint(fields: ProtoField[] | null, field: number): number | null {
|
||||
const hit = fields?.find((f) => f.field === field && f.varint !== null);
|
||||
return hit ? (hit.varint as number) : null;
|
||||
}
|
||||
|
||||
function fieldString(fields: ProtoField[] | null, field: number): string | null {
|
||||
const hit = fields?.find((f) => f.field === field && f.bytes !== null);
|
||||
if (!hit?.bytes) return null;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(hit.bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Response parsing ────────────────────────────────────────────────────────
|
||||
|
||||
export interface DevinQuotaSnapshot {
|
||||
plan: string | null;
|
||||
dailyRemainingPercent: number | null;
|
||||
weeklyRemainingPercent: number | null;
|
||||
dailyResetAtUnix: number | null;
|
||||
weeklyResetAtUnix: number | null;
|
||||
}
|
||||
|
||||
/** Parse a GetUserStatus protobuf response into the quota snapshot. */
|
||||
export function parseDevinUserStatus(buf: Uint8Array): DevinQuotaSnapshot | null {
|
||||
const userStatus = fieldBytes(decodeProtoFields(buf), 1);
|
||||
if (!userStatus) return null;
|
||||
|
||||
const planStatus = fieldBytes(decodeProtoFields(userStatus), 13);
|
||||
if (!planStatus) return null;
|
||||
|
||||
const status = decodeProtoFields(planStatus);
|
||||
if (!status) return null;
|
||||
|
||||
const planInfoBytes = fieldBytes(status, 1);
|
||||
const planName = planInfoBytes ? fieldString(decodeProtoFields(planInfoBytes), 2) : null;
|
||||
|
||||
return {
|
||||
plan: planName,
|
||||
dailyRemainingPercent: fieldVarint(status, 14),
|
||||
weeklyRemainingPercent: fieldVarint(status, 15),
|
||||
dailyResetAtUnix: fieldVarint(status, 17),
|
||||
weeklyResetAtUnix: fieldVarint(status, 18),
|
||||
};
|
||||
}
|
||||
|
||||
function percentQuota(
|
||||
remainingPercent: number,
|
||||
resetAtUnix: number | null,
|
||||
displayName: string
|
||||
): UsageQuota {
|
||||
const clamped = Math.min(Math.max(remainingPercent, 0), 100);
|
||||
return {
|
||||
used: 100 - clamped,
|
||||
total: 100,
|
||||
remaining: clamped,
|
||||
remainingPercentage: clamped,
|
||||
resetAt: parseResetTime(resetAtUnix),
|
||||
unlimited: false,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Fetcher ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getDevinCliUsage(token: string | null | undefined) {
|
||||
if (!token?.trim()) {
|
||||
return { message: "Devin token not available. Import a Devin token to view usage." };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${SEAT_MANAGEMENT_API_BASE}${GET_USER_STATUS_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/proto",
|
||||
"Connect-Protocol-Version": CONNECT_PROTOCOL_VERSION,
|
||||
Authorization: `Basic ${token}-${token}`,
|
||||
},
|
||||
body: new Uint8Array(buildGetUserStatusRequest(token.trim())),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
return { message: `Devin usage error: ${(error as Error).message}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { message: `Devin GetUserStatus failed (${response.status})` };
|
||||
}
|
||||
|
||||
const snapshot = parseDevinUserStatus(new Uint8Array(await response.arrayBuffer()));
|
||||
if (!snapshot) {
|
||||
return { message: "Devin quota response could not be parsed." };
|
||||
}
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
if (snapshot.dailyRemainingPercent !== null) {
|
||||
quotas.daily = percentQuota(
|
||||
snapshot.dailyRemainingPercent,
|
||||
snapshot.dailyResetAtUnix,
|
||||
"Daily Agentic Quota"
|
||||
);
|
||||
}
|
||||
if (snapshot.weeklyRemainingPercent !== null) {
|
||||
quotas.weekly = percentQuota(
|
||||
snapshot.weeklyRemainingPercent,
|
||||
snapshot.weeklyResetAtUnix,
|
||||
"Weekly Agentic Quota"
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length === 0) {
|
||||
return { message: "Devin quota fields not present in GetUserStatus response." };
|
||||
}
|
||||
|
||||
return { plan: snapshot.plan ?? "Devin", quotas };
|
||||
}
|
||||
@@ -82,6 +82,8 @@ export const USAGE_FETCHER_PROVIDERS = [
|
||||
// AgentRouter (New-API) console balance (GET /api/user/self)
|
||||
"agentrouter",
|
||||
"kilocode",
|
||||
// Devin CLI agentic quota (Codeium seat-management GetUserStatus, protobuf)
|
||||
"devin-cli",
|
||||
] as const;
|
||||
|
||||
export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number];
|
||||
|
||||
@@ -70,9 +70,9 @@ export async function getOpenrouterUsage(
|
||||
|
||||
if (!quota) {
|
||||
return {
|
||||
plan: "OpenRouter (usage endpoint unreachable)",
|
||||
plan: "OpenRouter (credits endpoint unreachable)",
|
||||
quotas,
|
||||
message: "OpenRouter connected. Balance/credit-cap data temporarily unavailable.",
|
||||
message: "OpenRouter connected. /key and /credits both unreachable — no balance data.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
83
open-sse/services/usage/supportedProviders.ts
Normal file
83
open-sse/services/usage/supportedProviders.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* usage/supportedProviders.ts — registration list of providers whose usage/quota
|
||||
* API is accepted by the dashboard and server routes.
|
||||
*
|
||||
* Extracted from `src/shared/constants/providers.ts` so that light consumers —
|
||||
* the provider-plugin manifest (`config/providerPluginManifest.ts`) above all —
|
||||
* can read the list without pulling the ~12-module provider registry, and
|
||||
* without an open-sse module reaching across the workspace boundary into
|
||||
* `src/` (the open-sse typecheck gate forbids open-sse → src imports). Same
|
||||
* pattern as `fetcherProviders.ts` (#11903): pure data — no imports, no module
|
||||
* state — so it cannot introduce a cycle. `src/shared/constants/providers.ts`
|
||||
* re-exports the value, so every existing `@/shared/constants/providers`
|
||||
* import path keeps working unchanged.
|
||||
*
|
||||
* Typed `readonly string[]` (not `as const`): the dashboard/server gates call
|
||||
* `USAGE_SUPPORTED_PROVIDERS.includes(providerId)` with a plain `string`, which
|
||||
* a literal-tuple type would reject (TS2345).
|
||||
*/
|
||||
|
||||
// Providers that support usage/quota API
|
||||
export const USAGE_SUPPORTED_PROVIDERS: readonly string[] = [
|
||||
"antigravity",
|
||||
"agy",
|
||||
"kiro",
|
||||
"amazon-q",
|
||||
"github",
|
||||
"codex",
|
||||
"claude",
|
||||
"cursor",
|
||||
"qoder",
|
||||
"kimi-coding",
|
||||
"kimi-coding-apikey",
|
||||
"glm",
|
||||
"glm-cn",
|
||||
"zai",
|
||||
"glmt",
|
||||
"opencode-go",
|
||||
"ollama-cloud",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
"nanogpt",
|
||||
"deepseek",
|
||||
"xiaomi-mimo",
|
||||
"xiaomi-mimo-token-plan",
|
||||
"vertex",
|
||||
"vertex-partner",
|
||||
"codebuddy-cn",
|
||||
// PromptQL playground credits (getCreditSummary → USD micros)
|
||||
"promptql",
|
||||
"pql",
|
||||
// Adobe Firefly web (cookie/JWT as apikey) — GET firefly.adobe.io/v1/credits/balance
|
||||
"adobe-firefly",
|
||||
"firefly",
|
||||
"hyperagent",
|
||||
"ha",
|
||||
// xAI OAuth (Grok) weekly quota (id + public alias, same pattern as ha/agy)
|
||||
"xai-oauth",
|
||||
"xao",
|
||||
// Grok Build subscription, billing credits, and auto top-up status
|
||||
"grok-cli",
|
||||
// Firecrawl team credits (GET /v2/team/credit-usage)
|
||||
"firecrawl",
|
||||
// Volcano Ark Plan subscriptions (agent-plan / coding-plan)
|
||||
"volcengine-agent-plan",
|
||||
"volcengine-coding-plan",
|
||||
// Command Code credits + 5h/weekly rolling windows
|
||||
"command-code",
|
||||
"conol-web",
|
||||
"cnl",
|
||||
// Alibaba Coding Plan triple-window quota (#9603 UI gap — fetcher existed, list entry missing)
|
||||
"bailian-coding-plan",
|
||||
// Qwen Cloud / Model Studio personal Token Plan (cookie-authenticated console gateway)
|
||||
"qwen-cloud-token-plan",
|
||||
// AgentRouter (New-API) console balance quota (consoleApiKey + newApiUserId)
|
||||
"agentrouter",
|
||||
// Kilo Code personal USD balance (GET /api/profile/balance, existing OAuth token)
|
||||
"kilocode",
|
||||
// OpenRouter key limits + account credits (GET /api/v1/key + /api/v1/credits)
|
||||
"openrouter",
|
||||
// Devin CLI agentic quota (Codeium seat-management GetUserStatus, protobuf)
|
||||
"devin-cli",
|
||||
];
|
||||
221
open-sse/utils/jsonHash.ts
Normal file
221
open-sse/utils/jsonHash.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
/**
|
||||
* Streaming JSON hash — computes `sha256hex(JSON.stringify(value))` WITHOUT
|
||||
* materializing the JSON string (#7847 OOM class). Several hot-path call sites
|
||||
* stringify a multi-megabyte request body just to hash it (compression memo
|
||||
* keys, cache keys). On a ~5 MiB agent body (with base64 screenshots) that
|
||||
* allocates a full ~5 MiB string, read once for a hash, then discarded.
|
||||
*
|
||||
* `jsonSha256()` walks the value and feeds the same bytes `JSON.stringify`
|
||||
* would emit directly into a `crypto.createHash("sha256")` stream, so peak
|
||||
* allocation stays bounded to a small rolling buffer.
|
||||
*
|
||||
* Semantics mirror `JSON.stringify` exactly:
|
||||
* - key order = `Object.keys()` order (insertion order)
|
||||
* - `undefined`/function/symbol object values drop the whole entry
|
||||
* - `undefined`/function/symbol array items render as `null`
|
||||
* - non-finite numbers render as `null`
|
||||
* - `BigInt` throws (matches JSON.stringify)
|
||||
* - Date / toJSON / non-plain containers fall back to `JSON.stringify` for
|
||||
* that subtree only (kept rare so big arrays stay on the fast path).
|
||||
*
|
||||
* Deterministic across calls: identical logical bodies always produce the
|
||||
* identical digest, so callers can replace `sha256hex(JSON.stringify(body))`
|
||||
* with `jsonSha256(body)` without changing cache/memo semantics.
|
||||
*/
|
||||
export function jsonSha256(value: unknown): string {
|
||||
const hash = crypto.createHash("sha256");
|
||||
writeValue(hash, value, new Set<object>());
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function isOmitted(value: unknown): boolean {
|
||||
return value === undefined || typeof value === "function" || typeof value === "symbol";
|
||||
}
|
||||
|
||||
function isPlainContainer(value: object): boolean {
|
||||
if (Array.isArray(value)) return true;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
function writeValue(
|
||||
hash: ReturnType<typeof crypto.createHash>,
|
||||
value: unknown,
|
||||
seen: Set<object>
|
||||
): void {
|
||||
if (writePrimitive(hash, value)) return;
|
||||
|
||||
const obj = value as object;
|
||||
// Date, Map, boxed primitives, class instances with toJSON — fall back to
|
||||
// JSON.stringify for THIS SUBTREE only, keeping multi-MB arrays on the
|
||||
// streaming path. JSON.stringify(Date) emits a quoted ISO string, so push
|
||||
// exactly the string form JSON.stringify would have produced.
|
||||
if (writeToJSONFallback(hash, obj)) return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
if (seen.has(obj)) {
|
||||
throw new TypeError("Converting circular structure to JSON");
|
||||
}
|
||||
seen.add(obj);
|
||||
try {
|
||||
writeArray(hash, obj, seen);
|
||||
} finally {
|
||||
seen.delete(obj);
|
||||
}
|
||||
} else {
|
||||
writePlainObject(hash, obj, seen);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* toJSON / non-plain-container fallback: serializes the subtree with
|
||||
* JSON.stringify, exactly as JSON.stringify would have (undefined → the bare
|
||||
* token, e.g. an object-valued key being dropped later is not possible here
|
||||
* — writeValue callers already filter omissions). Returns true when handled.
|
||||
*/
|
||||
function writeToJSONFallback(
|
||||
hash: ReturnType<typeof crypto.createHash>,
|
||||
obj: object
|
||||
): boolean {
|
||||
const hasToJSON = typeof (obj as { toJSON?: unknown }).toJSON === "function";
|
||||
if (hasToJSON || !isPlainContainer(obj)) {
|
||||
const encoded = JSON.stringify(obj);
|
||||
hash.update(encoded === undefined ? "undefined" : encoded);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Writes JSON primitives and omissions. Returns true when `value` is fully handled. */
|
||||
function writePrimitive(hash: ReturnType<typeof crypto.createHash>, value: unknown): boolean {
|
||||
if (value === null) {
|
||||
hash.update("null");
|
||||
return true;
|
||||
}
|
||||
const type = typeof value;
|
||||
if (type === "string") {
|
||||
writeEncodedString(hash, value as string);
|
||||
return true;
|
||||
}
|
||||
if (type === "boolean") {
|
||||
hash.update(value ? "true" : "false");
|
||||
return true;
|
||||
}
|
||||
if (type === "number") {
|
||||
// Non-finite numbers serialize as null (matches JSON.stringify).
|
||||
hash.update(Number.isFinite(value as number) ? String(value) : "null");
|
||||
return true;
|
||||
}
|
||||
if (type === "bigint") {
|
||||
// Matches JSON.stringify, which throws rather than guessing an encoding.
|
||||
throw new TypeError("Do not know how to serialize a BigInt");
|
||||
}
|
||||
if (isOmitted(value) || type !== "object") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function writeArray(
|
||||
hash: ReturnType<typeof crypto.createHash>,
|
||||
obj: unknown[],
|
||||
seen: Set<object>
|
||||
): void {
|
||||
hash.update("[");
|
||||
for (let i = 0; i < obj.length; i++) {
|
||||
if (i > 0) hash.update(",");
|
||||
const item = obj[i];
|
||||
if (isOmitted(item)) {
|
||||
hash.update("null"); // array items render as null
|
||||
} else {
|
||||
writeValue(hash, item, seen);
|
||||
}
|
||||
}
|
||||
hash.update("]");
|
||||
}
|
||||
|
||||
function writePlainObject(
|
||||
hash: ReturnType<typeof crypto.createHash>,
|
||||
obj: object,
|
||||
seen: Set<object>
|
||||
): void {
|
||||
if (seen.has(obj)) {
|
||||
throw new TypeError("Converting circular structure to JSON");
|
||||
}
|
||||
seen.add(obj);
|
||||
try {
|
||||
hash.update("{");
|
||||
let first = true;
|
||||
for (const key of Object.keys(obj)) {
|
||||
const item = (obj as Record<string, unknown>)[key];
|
||||
if (isOmitted(item)) continue; // entry disappears entirely
|
||||
if (!first) hash.update(",");
|
||||
first = false;
|
||||
writeEncodedString(hash, key);
|
||||
hash.update(":");
|
||||
writeValue(hash, item, seen);
|
||||
}
|
||||
hash.update("}");
|
||||
} finally {
|
||||
seen.delete(obj);
|
||||
}
|
||||
}
|
||||
|
||||
// Static escapes for fast paths: quote, backslash, and the short control
|
||||
// escapes JSON.stringify emits. Lookup avoids the escape ladder entirely.
|
||||
const SINGLE_ESCAPES = new Map<number, string>([
|
||||
[0x22, '\\"'],
|
||||
[0x5c, "\\\\"],
|
||||
[0x08, "\\b"],
|
||||
[0x09, "\\t"],
|
||||
[0x0a, "\\n"],
|
||||
[0x0c, "\\f"],
|
||||
[0x0d, "\\r"],
|
||||
]);
|
||||
|
||||
/** Writes one (possibly surrogate-paired) code unit's escaped form. */
|
||||
function appendEscapedChar(out: string[], value: string, i: number, code: number): number {
|
||||
const single = SINGLE_ESCAPES.get(code);
|
||||
if (single !== undefined) {
|
||||
out.push(single);
|
||||
return i;
|
||||
}
|
||||
if (code < 0x20) {
|
||||
out.push("\\u" + code.toString(16).padStart(4, "0"));
|
||||
return i;
|
||||
}
|
||||
if (code >= 0xd800 && code <= 0xdfff) {
|
||||
const next = i + 1 < value.length ? value.charCodeAt(i + 1) : NaN;
|
||||
const isHigh = code >= 0xd800 && code <= 0xdbff;
|
||||
if (isHigh && next >= 0xdc00 && next <= 0xdfff) {
|
||||
out.push(value[i] + value[i + 1]);
|
||||
return i + 1;
|
||||
}
|
||||
out.push("\\u" + code.toString(16).padStart(4, "0"));
|
||||
return i;
|
||||
}
|
||||
out.push(value[i]);
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Writes a JSON-escaped, double-quoted string, flushing in ~8 KiB chunks. */
|
||||
function writeEncodedString(hash: ReturnType<typeof crypto.createHash>, value: string): void {
|
||||
const out: string[] = [];
|
||||
let buffered = 0;
|
||||
let i = 0;
|
||||
out.push('"');
|
||||
while (i < value.length) {
|
||||
const next = appendEscapedChar(out, value, i, value.charCodeAt(i));
|
||||
buffered += next - i + 1;
|
||||
i = next + 1;
|
||||
if (buffered > 8192) {
|
||||
hash.update(out.join(""));
|
||||
out.length = 0;
|
||||
buffered = 0;
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
hash.update(out.join(""));
|
||||
}
|
||||
@@ -18,11 +18,14 @@
|
||||
* message history back onto the allocating path.
|
||||
*/
|
||||
|
||||
const BASE64_DATA_URI_RE = /data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi;
|
||||
|
||||
/** Length of a JSON-encoded string, including the surrounding quotes. */
|
||||
function encodedStringLength(value: string): number {
|
||||
function encodedStringLength(value: string, stripBase64 = false): number {
|
||||
const target = stripBase64 ? value.replace(BASE64_DATA_URI_RE, "") : value;
|
||||
let len = 2; // the quotes
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
for (let i = 0; i < target.length; i++) {
|
||||
const code = target.charCodeAt(i);
|
||||
if (code === 0x22 || code === 0x5c) {
|
||||
len += 2; // \" and \\
|
||||
} else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) {
|
||||
@@ -33,7 +36,7 @@ function encodedStringLength(value: string): number {
|
||||
// Surrogates: a well-formed pair serializes as its two code units (2 chars); a LONE
|
||||
// surrogate is escaped as \uXXXX since ES2019 well-formed JSON.stringify.
|
||||
const isHigh = code <= 0xdbff;
|
||||
const next = isHigh ? value.charCodeAt(i + 1) : NaN;
|
||||
const next = isHigh ? target.charCodeAt(i + 1) : NaN;
|
||||
const paired = isHigh && next >= 0xdc00 && next <= 0xdfff;
|
||||
if (paired) {
|
||||
len += 2;
|
||||
@@ -66,14 +69,34 @@ function isPlainContainer(value: object): boolean {
|
||||
* Throws on circular structures and BigInt, exactly as JSON.stringify does.
|
||||
*/
|
||||
export function jsonLength(value: unknown): number {
|
||||
return lengthOf(value, new Set<object>());
|
||||
return lengthOf(value, new Set<object>(), false);
|
||||
}
|
||||
|
||||
function lengthOf(value: unknown, seen: Set<object>): number {
|
||||
/**
|
||||
* Same as `jsonLength`, but strips `data:image/*;base64,...` data URIs from strings
|
||||
* before counting, matching `countTextTokens(JSON.stringify(body))` semantics for
|
||||
* token heuristics without materializing the multi-megabyte string (#7847).
|
||||
*/
|
||||
export function jsonLengthStrippingBase64DataUris(value: unknown): number {
|
||||
return lengthOf(value, new Set<object>(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw length of a string with `data:image/*;base64,...` data URIs removed. Unlike
|
||||
* `jsonLengthStrippingBase64DataUris`, this returns the plain code-unit count with NO
|
||||
* JSON-encoding overhead (no surrounding quotes/escaping). Use it where a threshold was
|
||||
* previously fed by `string.length` (e.g. thinking-budget complexity) but the value may
|
||||
* embed a base64 image.
|
||||
*/
|
||||
export function rawLengthStrippingBase64DataUris(value: string): number {
|
||||
return value.replace(BASE64_DATA_URI_RE, "").length;
|
||||
}
|
||||
|
||||
function lengthOf(value: unknown, seen: Set<object>, stripBase64: boolean): number {
|
||||
if (value === null) return 4; // "null"
|
||||
const type = typeof value;
|
||||
|
||||
if (type === "string") return encodedStringLength(value as string);
|
||||
if (type === "string") return encodedStringLength(value as string, stripBase64);
|
||||
if (type === "boolean") return value ? 4 : 5;
|
||||
if (type === "number") {
|
||||
// Non-finite numbers serialize as null.
|
||||
@@ -92,7 +115,8 @@ function lengthOf(value: unknown, seen: Set<object>): number {
|
||||
// Map, boxed primitives. Scoped to this subtree so the big arrays stay on the fast path.
|
||||
if (!isPlainContainer(obj) || typeof (obj as { toJSON?: unknown }).toJSON === "function") {
|
||||
const encoded = JSON.stringify(obj);
|
||||
return encoded === undefined ? 0 : encoded.length;
|
||||
if (encoded === undefined) return 0;
|
||||
return stripBase64 ? encoded.replace(BASE64_DATA_URI_RE, "").length : encoded.length;
|
||||
}
|
||||
|
||||
if (seen.has(obj)) {
|
||||
@@ -106,7 +130,7 @@ function lengthOf(value: unknown, seen: Set<object>): number {
|
||||
if (i > 0) len += 1; // comma
|
||||
const item = obj[i];
|
||||
// Omitted values render as null inside arrays rather than disappearing.
|
||||
len += isOmitted(item) ? 4 : lengthOf(item, seen);
|
||||
len += isOmitted(item) ? 4 : lengthOf(item, seen, stripBase64);
|
||||
}
|
||||
return len;
|
||||
}
|
||||
@@ -118,7 +142,7 @@ function lengthOf(value: unknown, seen: Set<object>): number {
|
||||
if (isOmitted(item)) continue; // the whole entry disappears
|
||||
if (!first) len += 1; // comma
|
||||
first = false;
|
||||
len += encodedStringLength(key) + 1 + lengthOf(item, seen); // "key":value
|
||||
len += encodedStringLength(key, false) + 1 + lengthOf(item, seen, stripBase64); // "key":value
|
||||
}
|
||||
return len;
|
||||
} finally {
|
||||
|
||||
@@ -771,6 +771,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const passthroughResponsesOutputItems: unknown[] = [];
|
||||
const passthroughResponsesPendingFunctionCalls = new Map<string, JsonRecord>();
|
||||
let passthroughResponsesId: string | null = null;
|
||||
let passthroughLastChatId: string | null = null;
|
||||
let passthroughResponsesCurrentFunctionCallKey: string | null = null;
|
||||
const passthroughResponsesReasoningSummarySeen = new Set<string>();
|
||||
// #6199 — commentary-phase items announced via `response.output_item.added` are
|
||||
@@ -1955,6 +1956,16 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
|
||||
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
|
||||
|
||||
// Remember the upstream's chat-completion id so synthetic chunks
|
||||
// emitted at flush (e.g. the estimated usage-only chunk) carry the
|
||||
// stream's real string id instead of null on the chat path
|
||||
// (passthroughResponsesId is only ever set on the Responses path).
|
||||
if (typeof parsed.id === "string" && parsed.id) {
|
||||
passthroughLastChatId = parsed.id;
|
||||
} else if (typeof parsed.id === "number") {
|
||||
passthroughLastChatId = String(parsed.id);
|
||||
}
|
||||
|
||||
if (isFinishChunk) {
|
||||
passthroughSawFinishReason = true;
|
||||
}
|
||||
@@ -1973,28 +1984,21 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
parsed.choices[0].finish_reason !== "tool_calls"
|
||||
) {
|
||||
parsed.choices[0].finish_reason = "tool_calls";
|
||||
// If we modify it, we must output the modified object
|
||||
if (!injectedUsage && hasValidUsage(parsed.usage)) {
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
injectedUsage = true;
|
||||
}
|
||||
// If we modify it, we must output the modified object. This used to
|
||||
// piggyback on the estimated-usage rewrite below; with the estimate
|
||||
// moved to flush() (#12151 follow-up) the rewrite must happen here.
|
||||
// injectedUsage doubles as the "output already rewritten" latch —
|
||||
// without it the raw line overwrites this rewrite further down.
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
injectedUsage = true;
|
||||
}
|
||||
if (
|
||||
isFinishChunk &&
|
||||
!passthroughForwardedUsage &&
|
||||
!hasValidUsage(parsed.usage) &&
|
||||
!hasValidUsage(usage) &&
|
||||
totalContentLength > 0
|
||||
) {
|
||||
const estimated = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI);
|
||||
if (hasValidUsage(estimated)) {
|
||||
parsed.usage = filterUsageForFormat(estimated, sourceFormat || FORMATS.OPENAI);
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
usage = estimated;
|
||||
passthroughForwardedUsage = true;
|
||||
injectedUsage = true;
|
||||
}
|
||||
} else if (isFinishChunk && hasValidUsage(usage) && !passthroughForwardedUsage) {
|
||||
// #12151 follow-up: do NOT inject estimated usage into the finish chunk.
|
||||
// A genuine OpenAI upstream sends its usage in a trailing empty-choices
|
||||
// chunk AFTER the finish; estimating here marked passthroughForwardedUsage
|
||||
// and made the real trailing block get dropped in favor of the estimate
|
||||
// (billing regression pinned by tests/unit/stream-utils.test.ts). The
|
||||
// estimate is now emitted in flush(), only when the upstream stayed silent.
|
||||
if (isFinishChunk && hasValidUsage(usage) && !passthroughForwardedUsage) {
|
||||
const buffered = addBufferToUsage(usage);
|
||||
parsed.usage = filterUsageForFormat(buffered, sourceFormat || FORMATS.OPENAI);
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
@@ -2510,6 +2514,30 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
forward(controller, encoder.encode(finishOutput));
|
||||
clientPayloadCollector.push(syntheticFinishChunk);
|
||||
}
|
||||
// #12151: upstream never reported usage — emit the estimate as a
|
||||
// canonical OpenAI trailing usage-only chunk (empty choices) before
|
||||
// [DONE], so metered clients still see token counts. When the
|
||||
// upstream DID send usage (trailing or in-band), it was forwarded
|
||||
// already and passthroughForwardedUsage guards this off.
|
||||
if (
|
||||
shouldEmitDoneTerminator &&
|
||||
!passthroughForwardedUsage &&
|
||||
hasValidUsage(usage)
|
||||
) {
|
||||
const usageOnlyChunk = {
|
||||
id: passthroughLastChatId ?? passthroughResponsesId ?? `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [],
|
||||
usage: filterUsageForFormat(usage, sourceFormat || FORMATS.OPENAI),
|
||||
};
|
||||
const usageOutput = `data: ${JSON.stringify(usageOnlyChunk)}\n\n`;
|
||||
reqLogger?.appendConvertedChunk?.(usageOutput);
|
||||
forward(controller, encoder.encode(usageOutput));
|
||||
clientPayloadCollector.push(usageOnlyChunk);
|
||||
passthroughForwardedUsage = true;
|
||||
}
|
||||
await emitFinalSseMetadata(controller, usage);
|
||||
doneSent = true;
|
||||
if (shouldEmitDoneTerminator) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cloneLogPayload } from "@/lib/logPayloads";
|
||||
import { toNumber } from "@/shared/utils/numeric";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
import { jsonLength } from "./jsonSize.ts";
|
||||
|
||||
type StructuredSSEEvent = {
|
||||
index: number;
|
||||
@@ -914,7 +915,7 @@ export function createStructuredSSECollector(options: CollectorOptions = {}) {
|
||||
event.event = eventName;
|
||||
}
|
||||
|
||||
const serializedSize = JSON.stringify(event).length;
|
||||
const serializedSize = jsonLength(event);
|
||||
if (events.length >= maxEvents || usedBytes + serializedSize > maxBytes) {
|
||||
droppedEvents += 1;
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,367 @@ function loadRuntimeModule(moduleName: string): unknown {
|
||||
return Reflect.apply(runtimeRequire, undefined, [moduleName]);
|
||||
}
|
||||
|
||||
export type WreqTransportLike = {
|
||||
close: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type WreqTransportResponseLike = {
|
||||
status: number;
|
||||
headers:
|
||||
| Record<string, string[]>
|
||||
| (Iterable<[string, string]> & {
|
||||
getSetCookie?: () => string[];
|
||||
});
|
||||
body:
|
||||
| string
|
||||
| (Pick<ReadableStream<Uint8Array>, "getReader"> & {
|
||||
cancel?: (reason?: unknown) => Promise<void>;
|
||||
})
|
||||
| null;
|
||||
text?: () => Promise<string>;
|
||||
bytes?: () => Promise<Uint8Array>;
|
||||
};
|
||||
|
||||
export type WreqTransportRuntime = {
|
||||
createTransport: (options: Record<string, unknown>) => Promise<WreqTransportLike>;
|
||||
fetch: (url: string, options: Record<string, unknown>) => Promise<WreqTransportResponseLike>;
|
||||
};
|
||||
|
||||
export type WreqTransportRuntimeLoader = () => Promise<WreqTransportRuntime>;
|
||||
|
||||
export type WreqTransportRequestPromise = Promise<WreqTransportResponseLike> & {
|
||||
/** Close this request's exact transport generation if it is still current. */
|
||||
invalidateTransport: () => void;
|
||||
/** Mark this request complete so its idle transport may be reused or evicted. */
|
||||
releaseTransport: () => void;
|
||||
};
|
||||
|
||||
export type WreqTransportRequestClient = {
|
||||
request: (url: string, options: Record<string, unknown>) => WreqTransportRequestPromise;
|
||||
};
|
||||
|
||||
export class WreqRuntimeUnavailableError extends Error {
|
||||
override name = "WreqRuntimeUnavailableError";
|
||||
}
|
||||
|
||||
export class WreqTransportCapacityError extends Error {
|
||||
override name = "WreqTransportCapacityError";
|
||||
readonly code = "TLS_SESSION_CAPACITY";
|
||||
}
|
||||
|
||||
type EmulationOs = "windows" | "macos" | "linux" | "android" | "ios";
|
||||
|
||||
let wreqRuntimeModule: Record<string, unknown> | null = null;
|
||||
let wreqRuntimeModuleError: unknown;
|
||||
let wreqRuntimeModuleResolved = false;
|
||||
|
||||
function getWreqRuntimeModule(): Record<string, unknown> {
|
||||
if (!wreqRuntimeModuleResolved) {
|
||||
wreqRuntimeModuleResolved = true;
|
||||
try {
|
||||
wreqRuntimeModule = loadRuntimeModule("wreq-js") as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
wreqRuntimeModuleError = error;
|
||||
}
|
||||
}
|
||||
if (wreqRuntimeModule) return wreqRuntimeModule;
|
||||
throw wreqRuntimeModuleError ?? new Error("wreq-js runtime unavailable");
|
||||
}
|
||||
|
||||
const TRANSPORT_POOL_KEY = Symbol.for("omniroute.wreqTransportPool.instance");
|
||||
const TRANSPORT_POOL_LIFECYCLE_KEY = Symbol.for("omniroute.wreqTransportPool.lifecycle");
|
||||
type WreqLifecycleResource = {
|
||||
closeAll: () => Promise<void> | void;
|
||||
};
|
||||
const transportPoolGlobal = globalThis as typeof globalThis & {
|
||||
[TRANSPORT_POOL_KEY]?: WreqTransportPool;
|
||||
[TRANSPORT_POOL_LIFECYCLE_KEY]?: {
|
||||
pools: Set<WreqLifecycleResource>;
|
||||
exitHookInstalled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function registerWreqLifecycleResource(resource: WreqLifecycleResource): void {
|
||||
const lifecycle = transportPoolGlobal[TRANSPORT_POOL_LIFECYCLE_KEY] ?? {
|
||||
pools: new Set<WreqLifecycleResource>(),
|
||||
exitHookInstalled: false,
|
||||
};
|
||||
transportPoolGlobal[TRANSPORT_POOL_LIFECYCLE_KEY] = lifecycle;
|
||||
lifecycle.pools.add(resource);
|
||||
if (lifecycle.exitHookInstalled) return;
|
||||
lifecycle.exitHookInstalled = true;
|
||||
process.once("exit", () => {
|
||||
for (const registered of lifecycle.pools) {
|
||||
try {
|
||||
void registered.closeAll();
|
||||
} catch {
|
||||
// Process shutdown is best effort; every close has already been initiated.
|
||||
}
|
||||
}
|
||||
lifecycle.pools.clear();
|
||||
});
|
||||
}
|
||||
|
||||
async function closeWreqLifecycleResources(): Promise<void> {
|
||||
const resources = [...(transportPoolGlobal[TRANSPORT_POOL_LIFECYCLE_KEY]?.pools ?? [])];
|
||||
await Promise.allSettled(
|
||||
resources.map((resource) => Promise.resolve().then(() => resource.closeAll()))
|
||||
);
|
||||
}
|
||||
|
||||
/** Focused-test seam for proving the shared process lifecycle without emitting `exit`. */
|
||||
export async function __closeWreqLifecycleResourcesForTesting(): Promise<void> {
|
||||
await closeWreqLifecycleResources();
|
||||
}
|
||||
|
||||
function loadWreqTransportRuntime(): Promise<WreqTransportRuntime> {
|
||||
try {
|
||||
const loaded = getWreqRuntimeModule() as Partial<WreqTransportRuntime>;
|
||||
if (typeof loaded.createTransport !== "function" || typeof loaded.fetch !== "function") {
|
||||
throw new Error("wreq-js runtime is missing createTransport/fetch");
|
||||
}
|
||||
return Promise.resolve(loaded as WreqTransportRuntime);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
type WreqTransportEntry = {
|
||||
pending: Promise<WreqTransportLike>;
|
||||
transport: WreqTransportLike | null;
|
||||
activeRequests: number;
|
||||
lastUsed: number;
|
||||
closed: boolean;
|
||||
closing: Promise<void> | null;
|
||||
};
|
||||
|
||||
type WreqTransportLease = {
|
||||
key: string | null;
|
||||
entry: WreqTransportEntry | null;
|
||||
released: boolean;
|
||||
invalidated: boolean;
|
||||
};
|
||||
|
||||
class WreqTransportPool {
|
||||
private runtimePromise: Promise<WreqTransportRuntime> | null = null;
|
||||
private readonly transports = new Map<string, WreqTransportEntry>();
|
||||
private readonly pendingCloses = new Set<Promise<void>>();
|
||||
private readonly maxTransports: number;
|
||||
private capacityReservations = 0;
|
||||
private accessSequence = 0;
|
||||
|
||||
constructor(
|
||||
private readonly runtimeLoader: WreqTransportRuntimeLoader,
|
||||
maxTransports = 128
|
||||
) {
|
||||
this.maxTransports = Number.isInteger(maxTransports) && maxTransports > 0 ? maxTransports : 128;
|
||||
}
|
||||
|
||||
private getRuntime(): Promise<WreqTransportRuntime> {
|
||||
if (!this.runtimePromise) {
|
||||
const pending = this.runtimeLoader().catch((error: unknown) => {
|
||||
if (this.runtimePromise === pending) this.runtimePromise = null;
|
||||
throw new WreqRuntimeUnavailableError(
|
||||
error instanceof Error && error.message
|
||||
? `wreq-js runtime unavailable: ${error.message}`
|
||||
: "wreq-js runtime unavailable"
|
||||
);
|
||||
});
|
||||
this.runtimePromise = pending;
|
||||
}
|
||||
return this.runtimePromise;
|
||||
}
|
||||
|
||||
private key(browser: string, os: EmulationOs, options: Record<string, unknown>): string {
|
||||
const proxy = typeof options.proxyUrl === "string" ? options.proxyUrl : "";
|
||||
return `${browser}\0${os}\0${proxy}`;
|
||||
}
|
||||
|
||||
private closeEntry(key: string, entry: WreqTransportEntry): Promise<void> {
|
||||
if (this.transports.get(key) !== entry) return entry.closing ?? Promise.resolve();
|
||||
this.transports.delete(key);
|
||||
if (entry.closed) return entry.closing ?? Promise.resolve();
|
||||
entry.closed = true;
|
||||
let closing: Promise<void>;
|
||||
try {
|
||||
closing = entry.transport
|
||||
? Promise.resolve(entry.transport.close()).then(() => undefined)
|
||||
: entry.pending.then((transport) => transport.close()).then(() => undefined);
|
||||
} catch {
|
||||
closing = Promise.resolve();
|
||||
}
|
||||
closing = closing
|
||||
.catch(() => {
|
||||
// Close is best-effort after eviction; capacity is released by the finalizer below.
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingCloses.delete(closing);
|
||||
});
|
||||
entry.closing = closing;
|
||||
this.pendingCloses.add(closing);
|
||||
return closing;
|
||||
}
|
||||
|
||||
private findOldestIdleEntry(): [string, WreqTransportEntry] | undefined {
|
||||
let candidate: [string, WreqTransportEntry] | undefined;
|
||||
for (const pair of this.transports) {
|
||||
const [, entry] = pair;
|
||||
if (entry.activeRequests > 0) continue;
|
||||
if (!candidate || entry.lastUsed < candidate[1].lastUsed) candidate = pair;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private reserveCapacity(): Promise<void> | null {
|
||||
const occupied = this.transports.size + this.pendingCloses.size + this.capacityReservations;
|
||||
this.capacityReservations += 1;
|
||||
if (occupied < this.maxTransports) return null;
|
||||
|
||||
const candidate = this.findOldestIdleEntry();
|
||||
if (!candidate) {
|
||||
this.capacityReservations -= 1;
|
||||
throw new WreqTransportCapacityError(
|
||||
`wreq-js transport capacity exhausted (${this.maxTransports} active proxy/profile keys)`
|
||||
);
|
||||
}
|
||||
return this.closeEntry(candidate[0], candidate[1]);
|
||||
}
|
||||
|
||||
private releaseCapacityReservation(): void {
|
||||
this.capacityReservations = Math.max(0, this.capacityReservations - 1);
|
||||
}
|
||||
|
||||
private releaseLease(lease: WreqTransportLease): void {
|
||||
if (lease.released) return;
|
||||
lease.released = true;
|
||||
const entry = lease.entry;
|
||||
if (!entry) return;
|
||||
entry.activeRequests = Math.max(0, entry.activeRequests - 1);
|
||||
entry.lastUsed = ++this.accessSequence;
|
||||
}
|
||||
|
||||
private invalidateLease(lease: WreqTransportLease): void {
|
||||
if (lease.invalidated) return;
|
||||
lease.invalidated = true;
|
||||
if (lease.key && lease.entry) this.closeEntry(lease.key, lease.entry);
|
||||
this.releaseLease(lease);
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
const closes = [...this.transports].map(([key, entry]) => this.closeEntry(key, entry));
|
||||
await Promise.allSettled([...closes, ...this.pendingCloses]);
|
||||
}
|
||||
|
||||
client(browser: string, os: EmulationOs): WreqTransportRequestClient {
|
||||
registerWreqLifecycleResource(this);
|
||||
return {
|
||||
request: (url, options) => {
|
||||
const lease: WreqTransportLease = {
|
||||
key: null,
|
||||
entry: null,
|
||||
released: false,
|
||||
invalidated: false,
|
||||
};
|
||||
const request = (async () => {
|
||||
const runtime = await this.getRuntime();
|
||||
if (lease.released) throw new Error("wreq-js request lease was released before dispatch");
|
||||
|
||||
const key = this.key(browser, os, options);
|
||||
let entry = this.transports.get(key);
|
||||
if (!entry) {
|
||||
const capacityWait = this.reserveCapacity();
|
||||
try {
|
||||
if (capacityWait) await capacityWait;
|
||||
if (lease.released) {
|
||||
throw new Error("wreq-js request lease was released before dispatch");
|
||||
}
|
||||
entry = this.transports.get(key);
|
||||
if (!entry) {
|
||||
const proxy = typeof options.proxyUrl === "string" ? options.proxyUrl : undefined;
|
||||
const transportOptions: Record<string, unknown> = { browser, os };
|
||||
if (proxy) transportOptions.proxy = proxy;
|
||||
let createdEntry: WreqTransportEntry;
|
||||
const pending = runtime.createTransport(transportOptions).then((transport) => {
|
||||
createdEntry.transport = transport;
|
||||
return transport;
|
||||
});
|
||||
entry = {
|
||||
pending,
|
||||
transport: null,
|
||||
activeRequests: 0,
|
||||
lastUsed: ++this.accessSequence,
|
||||
closed: false,
|
||||
closing: null,
|
||||
};
|
||||
createdEntry = entry;
|
||||
this.transports.set(key, entry);
|
||||
void pending.catch(() => {
|
||||
if (this.transports.get(key) === createdEntry) this.transports.delete(key);
|
||||
createdEntry.closed = true;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.releaseCapacityReservation();
|
||||
}
|
||||
}
|
||||
|
||||
lease.key = key;
|
||||
lease.entry = entry;
|
||||
entry.activeRequests += 1;
|
||||
entry.lastUsed = ++this.accessSequence;
|
||||
|
||||
const transport = await entry.pending;
|
||||
if (lease.released) throw new Error("wreq-js request lease was released before dispatch");
|
||||
return runtime.fetch(url, {
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
body: options.body,
|
||||
redirect: "follow",
|
||||
timeout: options.timeoutMilliseconds,
|
||||
signal: options.signal,
|
||||
transport,
|
||||
cookieMode: "ephemeral",
|
||||
});
|
||||
})() as WreqTransportRequestPromise;
|
||||
|
||||
Object.defineProperties(request, {
|
||||
invalidateTransport: {
|
||||
value: () => this.invalidateLease(lease),
|
||||
},
|
||||
releaseTransport: {
|
||||
value: () => this.releaseLease(lease),
|
||||
},
|
||||
});
|
||||
void request.catch(() => this.releaseLease(lease));
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an ephemeral-cookie wreq client backed by the process-wide transport pool.
|
||||
* Tests that inject a runtime loader receive an isolated pool to avoid cross-test state.
|
||||
*/
|
||||
export function createWreqTransportClient(options: {
|
||||
browser: string;
|
||||
os: EmulationOs;
|
||||
runtimeLoader?: WreqTransportRuntimeLoader;
|
||||
maxTransports?: number;
|
||||
}): WreqTransportRequestClient {
|
||||
if (options.runtimeLoader) {
|
||||
return new WreqTransportPool(options.runtimeLoader, options.maxTransports).client(
|
||||
options.browser,
|
||||
options.os
|
||||
);
|
||||
}
|
||||
const pool =
|
||||
transportPoolGlobal[TRANSPORT_POOL_KEY] ??
|
||||
new WreqTransportPool(loadWreqTransportRuntime, options.maxTransports);
|
||||
transportPoolGlobal[TRANSPORT_POOL_KEY] = pool;
|
||||
return pool.client(options.browser, options.os);
|
||||
}
|
||||
|
||||
export type WreqResponse = {
|
||||
status: number;
|
||||
statusText: string;
|
||||
@@ -29,7 +390,7 @@ export type CreateSessionFn = (options: Record<string, unknown>) => Promise<Wreq
|
||||
|
||||
let createSession: CreateSessionFn | null;
|
||||
try {
|
||||
const loaded = loadRuntimeModule("wreq-js") as { createSession?: CreateSessionFn };
|
||||
const loaded = getWreqRuntimeModule() as { createSession?: CreateSessionFn };
|
||||
createSession = typeof loaded.createSession === "function" ? loaded.createSession : null;
|
||||
} catch {
|
||||
if (process.env.ENABLE_TLS_FINGERPRINT === "true") {
|
||||
@@ -244,10 +605,15 @@ export class TlsClient {
|
||||
private readonly _libraryAvailable: boolean;
|
||||
private readonly maxSessions: number;
|
||||
|
||||
constructor(createSessionFn: CreateSessionFn | null = createSession, maxSessions = 128) {
|
||||
constructor(
|
||||
createSessionFn: CreateSessionFn | null = createSession,
|
||||
maxSessions = 128,
|
||||
registerLifecycle = false
|
||||
) {
|
||||
this.createSessionFn = createSessionFn;
|
||||
this._libraryAvailable = !!createSessionFn;
|
||||
this.maxSessions = Number.isInteger(maxSessions) && maxSessions > 0 ? maxSessions : 128;
|
||||
if (registerLifecycle) registerWreqLifecycleResource(this);
|
||||
}
|
||||
|
||||
/** Library availability only. Per-session circuit state is enforced inside fetch(). */
|
||||
@@ -288,10 +654,17 @@ export class TlsClient {
|
||||
}
|
||||
|
||||
private closeSession(session: WreqSession): Promise<void> {
|
||||
let closeResult: Promise<void>;
|
||||
try {
|
||||
closeResult = Promise.resolve(session.close()).then(() => undefined);
|
||||
} catch {
|
||||
closeResult = Promise.resolve();
|
||||
}
|
||||
let closing: Promise<void>;
|
||||
closing = Promise.resolve()
|
||||
.then(() => session.close())
|
||||
.catch(() => {})
|
||||
closing = closeResult
|
||||
.catch(() => {
|
||||
// A native close failure must not leak the session-capacity slot.
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingCloses.delete(closing);
|
||||
});
|
||||
@@ -408,7 +781,7 @@ export class TlsClient {
|
||||
return session ? this.closeSession(session) : Promise.resolve();
|
||||
}
|
||||
|
||||
private async closeSessions(): Promise<void> {
|
||||
async closeAll(): Promise<void> {
|
||||
const pending = [...this.pendingSessions.values()];
|
||||
this.globalSessionEpoch++;
|
||||
this.pendingSessions.clear();
|
||||
@@ -615,7 +988,7 @@ export class TlsClient {
|
||||
}
|
||||
|
||||
async exit(): Promise<void> {
|
||||
await this.closeSessions();
|
||||
await this.closeAll();
|
||||
}
|
||||
|
||||
resetCircuit(proxy?: string | null, sessionScope?: string): void {
|
||||
@@ -658,5 +1031,6 @@ const scopedGlobal = globalThis as typeof globalThis & {
|
||||
};
|
||||
const tlsClient = scopedGlobal[TLS_CLIENT_KEY] ?? new TlsClient();
|
||||
scopedGlobal[TLS_CLIENT_KEY] = tlsClient;
|
||||
registerWreqLifecycleResource(tlsClient);
|
||||
|
||||
export default tlsClient;
|
||||
|
||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -122,6 +122,7 @@
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"bun": "1.4.0",
|
||||
"c8": "^12.0.0",
|
||||
"concurrently": "^10.0.5",
|
||||
@@ -171,8 +172,7 @@
|
||||
"keytar": "^7.9.0",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"sqlite-vec": "^0.1.9",
|
||||
"tls-client-node": "^0.2.0",
|
||||
"wreq-js": "^3.2.0"
|
||||
"wreq-js": "3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -16173,6 +16173,16 @@
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-react-compiler": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-walk": {
|
||||
"version": "3.0.0-canary-5",
|
||||
"resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz",
|
||||
@@ -26965,17 +26975,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/koffi": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz",
|
||||
"integrity": "sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/kuler": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
||||
@@ -37381,7 +37380,7 @@
|
||||
"version": "7.0.27",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
|
||||
"integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.27"
|
||||
@@ -37394,28 +37393,9 @@
|
||||
"version": "7.0.27",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz",
|
||||
"integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tls-client-node": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tls-client-node/-/tls-client-node-0.2.0.tgz",
|
||||
"integrity": "sha512-0PHJgaGPvMK9ly7xohviOoe8Oxos43IOIdsEhibgku4ce/3/YLhxJTPPKNQZII0PdcOjlfPweB9eRs13mWaWIg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"koffi": "^2.8.9",
|
||||
"tough-cookie": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fatihkabakk"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -37482,7 +37462,7 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
|
||||
10
package.json
10
package.json
@@ -22,7 +22,6 @@
|
||||
"src/types/",
|
||||
".env.example",
|
||||
"scripts/build/postinstall.mjs",
|
||||
"scripts/build/fixTlsClientNodeBinary.mjs",
|
||||
"scripts/build/fixPlaywrightAndroid.mjs",
|
||||
"bin/cli/runtime/",
|
||||
"scripts/postinstall.mjs",
|
||||
@@ -38,6 +37,10 @@
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/wreqJsNative.mjs",
|
||||
"config/release/wreq-js-native-manifest.json",
|
||||
"config/release/wreq-js-rust-license-inventory.json",
|
||||
"config/release/wreq-js-rust-notices.md",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"scripts/packs/optionalPackManifest.mjs",
|
||||
@@ -126,6 +129,7 @@
|
||||
"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",
|
||||
"test:scoped": "bash scripts/quality/test-scoped.sh",
|
||||
"test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged",
|
||||
"test:scoped:full": "bash scripts/quality/test-scoped.sh --full",
|
||||
"test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"",
|
||||
"test:unit:shard:1": "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=10 --test-shard=1/2 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=10 --test-shard=1/2 \"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=1/2 \"tests/unit/serial/**/*.test.ts\"",
|
||||
"test:unit:shard:2": "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=10 --test-shard=2/2 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=10 --test-shard=2/2 \"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=2/2 \"tests/unit/serial/**/*.test.ts\"",
|
||||
@@ -361,8 +365,7 @@
|
||||
"keytar": "^7.9.0",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"sqlite-vec": "^0.1.9",
|
||||
"tls-client-node": "^0.2.0",
|
||||
"wreq-js": "^3.2.0"
|
||||
"wreq-js": "3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.13.0",
|
||||
@@ -385,6 +388,7 @@
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"bun": "1.4.0",
|
||||
"c8": "^12.0.0",
|
||||
"concurrently": "^10.0.5",
|
||||
|
||||
@@ -12,12 +12,10 @@ allowBuilds:
|
||||
core-js: true
|
||||
esbuild: true
|
||||
keytar: true
|
||||
koffi: true
|
||||
libxmljs2: true
|
||||
onnxruntime-node: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
tls-client-node: true
|
||||
unrs-resolver: true
|
||||
onlyBuiltDependencies:
|
||||
- "@parcel/watcher"
|
||||
@@ -26,11 +24,9 @@ onlyBuiltDependencies:
|
||||
- "core-js"
|
||||
- "esbuild"
|
||||
- "keytar"
|
||||
- "koffi"
|
||||
- "libxmljs2"
|
||||
- "onnxruntime-node"
|
||||
- "omniroute"
|
||||
- "protobufjs"
|
||||
- "sharp"
|
||||
- "tls-client-node"
|
||||
- "unrs-resolver"
|
||||
|
||||
@@ -6,13 +6,11 @@
|
||||
"core-js",
|
||||
"esbuild",
|
||||
"keytar",
|
||||
"koffi",
|
||||
"libxmljs2",
|
||||
"omniroute",
|
||||
"onnxruntime-node",
|
||||
"protobufjs",
|
||||
"sharp",
|
||||
"tls-client-node",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
|
||||
172
scripts/ad-hoc/backfill-servicekinds.mjs
Normal file
172
scripts/ad-hoc/backfill-servicekinds.mjs
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* scripts/ad-hoc/backfill-servicekinds.mjs
|
||||
*
|
||||
* PR A (gate hardening, #10513): make `serviceKinds` REQUIRED on every provider
|
||||
* in the catalog, backfilling the ~320 entries that never declared it.
|
||||
*
|
||||
* Design (pacocartones #10267): serviceKinds distinguishes a canonical provider
|
||||
* that legitimately has no REGISTRY entry (search/audio/media/local/cloud-agent)
|
||||
* from a half-removed provider whose catalog entry outlived its registry entry.
|
||||
* Making the field mandatory turns "canonical provider with no REGISTRY entry"
|
||||
* into a checkable invariant for `provider:remove --dry-run`.
|
||||
*
|
||||
* Rule:
|
||||
* - LLM chat providers -> ["llm"]
|
||||
* - Search providers -> ["webSearch"] (+["webFetch"] where known)
|
||||
* - Pure-media providers -> [] (kinds derived from media registries)
|
||||
* - Cloud agents / system / proxy-> [] (no direct chat registry path)
|
||||
*
|
||||
* Media kinds are NOT declared here — open-sse/config/mediaServiceKinds.ts
|
||||
* derives them from the audio/video/music/image/embedding/ocr registries, so
|
||||
* declaring them would duplicate (and drift from) that source of truth.
|
||||
*
|
||||
* USAGE: node --import tsx/esm scripts/ad-hoc/backfill-servicekinds.mjs
|
||||
* Idempotent: only inserts where serviceKinds is absent.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
// ── Section membership from the REAL catalog modules ────────────────────────
|
||||
import { SEARCH_PROVIDERS } from "../../src/shared/constants/providers/search.ts";
|
||||
import { AUDIO_ONLY_PROVIDERS } from "../../src/shared/constants/providers/audio.ts";
|
||||
import { CLOUD_AGENT_PROVIDERS } from "../../src/shared/constants/providers/cloud-agent.ts";
|
||||
import { SYSTEM_PROVIDERS } from "../../src/shared/constants/providers/system.ts";
|
||||
import { UPSTREAM_PROXY_PROVIDERS } from "../../src/shared/constants/providers/upstream-proxy.ts";
|
||||
import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers/oauth.ts";
|
||||
import { WEB_COOKIE_PROVIDERS } from "../../src/shared/constants/providers/web-cookie.ts";
|
||||
import { LOCAL_PROVIDERS } from "../../src/shared/constants/providers/local.ts";
|
||||
import { APIKEY_PROVIDERS_GATEWAYS } from "../../src/shared/constants/providers/apikey/gateways.ts";
|
||||
import { APIKEY_PROVIDERS_FRONTIER } from "../../src/shared/constants/providers/apikey/frontier-labs.ts";
|
||||
import { APIKEY_PROVIDERS_INFERENCE } from "../../src/shared/constants/providers/apikey/inference-hosts.ts";
|
||||
import { APIKEY_PROVIDERS_ENTERPRISE } from "../../src/shared/constants/providers/apikey/enterprise-cloud.ts";
|
||||
import { APIKEY_PROVIDERS_REGIONAL } from "../../src/shared/constants/providers/apikey/regional.ts";
|
||||
import { APIKEY_PROVIDERS_SPECIALTY } from "../../src/shared/constants/providers/apikey/specialty-media.ts";
|
||||
|
||||
const SEARCH_IDS = new Set(Object.keys(SEARCH_PROVIDERS));
|
||||
const AUDIO_IDS = new Set(Object.keys(AUDIO_ONLY_PROVIDERS));
|
||||
const CLOUD_AGENT_IDS = new Set(Object.keys(CLOUD_AGENT_PROVIDERS));
|
||||
const SYSTEM_IDS = new Set(Object.keys(SYSTEM_PROVIDERS));
|
||||
const UPSTREAM_PROXY_IDS = new Set(Object.keys(UPSTREAM_PROXY_PROVIDERS));
|
||||
|
||||
// Search providers that ALSO fetch pages (declared webFetch today).
|
||||
const SEARCH_WEBFETCH = new Set(["exa-search", "tavily-search", "firecrawl"]);
|
||||
|
||||
// Pure-media / no-direct-chat providers -> [] (kinds come from registries).
|
||||
// web-cookie image/video generators + local image runtimes + specialty-media
|
||||
// image/embedding/music/video set members that have no chat facade.
|
||||
const NO_LLM = new Set([
|
||||
// web-cookie image/video generators
|
||||
"microsoft-designer-web",
|
||||
"adobe-firefly",
|
||||
// local image runtimes
|
||||
"sdwebui",
|
||||
"comfyui",
|
||||
// specialty-media pure media (image/embedding/music/video, no chat facade)
|
||||
"runwayml",
|
||||
"ideogram",
|
||||
"freepik",
|
||||
// freepik foi renomeado para magnific na migration 160 — ambos os ids
|
||||
// permanecem aqui para que uma re-execução não volte a marcá-lo como llm.
|
||||
"magnific",
|
||||
"suno",
|
||||
"udio",
|
||||
"voyage-ai",
|
||||
"jina-ai",
|
||||
"fal-ai",
|
||||
"stability-ai",
|
||||
"black-forest-labs",
|
||||
"recraft",
|
||||
"topaz",
|
||||
"segmind",
|
||||
"nomic",
|
||||
"mixedbread",
|
||||
"leonardo",
|
||||
"haiper",
|
||||
"kie",
|
||||
"deepai",
|
||||
]);
|
||||
|
||||
/** Compute declared serviceKinds for a provider id (media kinds NOT included). */
|
||||
export function computeDeclaredServiceKinds(providerId) {
|
||||
if (SEARCH_IDS.has(providerId)) {
|
||||
return SEARCH_WEBFETCH.has(providerId) ? ["webSearch", "webFetch"] : ["webSearch"];
|
||||
}
|
||||
if (NO_LLM.has(providerId)) return [];
|
||||
if (AUDIO_IDS.has(providerId)) return [];
|
||||
if (CLOUD_AGENT_IDS.has(providerId)) return [];
|
||||
if (SYSTEM_IDS.has(providerId)) return [];
|
||||
if (UPSTREAM_PROXY_IDS.has(providerId)) return [];
|
||||
return ["llm"];
|
||||
}
|
||||
|
||||
/** Insert `serviceKinds` after the `id:` line of a provider entry, if absent. */
|
||||
function insertIntoFile(filePath, providerId, kinds) {
|
||||
const abs = path.join(ROOT, filePath);
|
||||
const src = readFileSync(abs, "utf8");
|
||||
|
||||
const escaped = providerId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// Multi-line entry ` \"provider-id\": {\n ... },` — full block capture. The
|
||||
// whole-block capture makes the idempotency check see serviceKinds wherever it
|
||||
// sits (before OR after the id line) without a file-global `includes` that
|
||||
// would short-circuit every later entry after the first insert.
|
||||
const entryRe = new RegExp(`^( {2})"?${escaped}"?(: \\{)([\\s\\S]*?)^( {2})},$`, "m");
|
||||
const match = entryRe.exec(src);
|
||||
if (!match) {
|
||||
console.error(` ✗ could not locate entry for ${providerId} in ${filePath}`);
|
||||
return false;
|
||||
}
|
||||
// Per-entry idempotency: refuse when THIS entry already declares serviceKinds.
|
||||
const block = match[0];
|
||||
if (/serviceKinds\s*:/.test(block)) return null;
|
||||
// Insert after the `id: \"provider-id\",` line (4-space indent inside the block).
|
||||
const idLineRe = new RegExp(`( {4}id: \"${escaped}\",)`);
|
||||
const idMatch = idLineRe.exec(block);
|
||||
if (!idMatch) {
|
||||
console.error(` ✗ entry for ${providerId} in ${filePath} has no id line`);
|
||||
return false;
|
||||
}
|
||||
const idLineEnd = match.index + idMatch.index + idMatch[1].length;
|
||||
const insert = `\n serviceKinds: ${JSON.stringify(kinds)},`;
|
||||
writeFileSync(abs, src.slice(0, idLineEnd) + insert + src.slice(idLineEnd));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Files to process, derived from the section modules themselves ───────────
|
||||
const FILES = [
|
||||
["src/shared/constants/providers/oauth.ts", OAUTH_PROVIDERS],
|
||||
["src/shared/constants/providers/web-cookie.ts", WEB_COOKIE_PROVIDERS],
|
||||
["src/shared/constants/providers/local.ts", LOCAL_PROVIDERS],
|
||||
["src/shared/constants/providers/search.ts", SEARCH_PROVIDERS],
|
||||
["src/shared/constants/providers/audio.ts", AUDIO_ONLY_PROVIDERS],
|
||||
["src/shared/constants/providers/upstream-proxy.ts", UPSTREAM_PROXY_PROVIDERS],
|
||||
["src/shared/constants/providers/cloud-agent.ts", CLOUD_AGENT_PROVIDERS],
|
||||
["src/shared/constants/providers/system.ts", SYSTEM_PROVIDERS],
|
||||
["src/shared/constants/providers/apikey/gateways.ts", APIKEY_PROVIDERS_GATEWAYS],
|
||||
["src/shared/constants/providers/apikey/frontier-labs.ts", APIKEY_PROVIDERS_FRONTIER],
|
||||
["src/shared/constants/providers/apikey/inference-hosts.ts", APIKEY_PROVIDERS_INFERENCE],
|
||||
["src/shared/constants/providers/apikey/enterprise-cloud.ts", APIKEY_PROVIDERS_ENTERPRISE],
|
||||
["src/shared/constants/providers/apikey/regional.ts", APIKEY_PROVIDERS_REGIONAL],
|
||||
["src/shared/constants/providers/apikey/specialty-media.ts", APIKEY_PROVIDERS_SPECIALTY],
|
||||
];
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
for (const [file, sectionMap] of FILES) {
|
||||
for (const id of Object.keys(sectionMap)) {
|
||||
if (sectionMap[id]?.serviceKinds !== undefined) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const kinds = computeDeclaredServiceKinds(id);
|
||||
const result = insertIntoFile(file, id, kinds);
|
||||
if (result === true) inserted += 1;
|
||||
else if (result === false) failed += 1;
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[backfill] inserted=${inserted} skipped(already-declared)=${skipped} failed=${failed}`
|
||||
);
|
||||
@@ -49,6 +49,7 @@ import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import { colocateLlmlinguaOptionals, SEED_PACKAGES } from "./colocateOptionals.mjs";
|
||||
import { WREQ_JS_NATIVE_BINDINGS } from "./wreqJsNative.mjs";
|
||||
|
||||
/**
|
||||
* Check whether a path exists (async).
|
||||
@@ -121,6 +122,31 @@ const EXTRA_MODULE_ENTRIES = [
|
||||
src: ["node_modules", "wreq-js"],
|
||||
dest: ["node_modules", "wreq-js"],
|
||||
},
|
||||
...WREQ_JS_NATIVE_BINDINGS.map((binding) => ({
|
||||
label: `${binding.packageName} native binding`,
|
||||
src: ["node_modules", ...binding.packageName.split("/")],
|
||||
dest: ["node_modules", ...binding.packageName.split("/")],
|
||||
})),
|
||||
{
|
||||
label: "third-party notices",
|
||||
src: ["THIRD_PARTY_NOTICES.md"],
|
||||
dest: ["THIRD_PARTY_NOTICES.md"],
|
||||
},
|
||||
{
|
||||
label: "wreq-js native provenance manifest",
|
||||
src: ["config", "release", "wreq-js-native-manifest.json"],
|
||||
dest: ["config", "release", "wreq-js-native-manifest.json"],
|
||||
},
|
||||
{
|
||||
label: "wreq-js Rust license inventory",
|
||||
src: ["config", "release", "wreq-js-rust-license-inventory.json"],
|
||||
dest: ["config", "release", "wreq-js-rust-license-inventory.json"],
|
||||
},
|
||||
{
|
||||
label: "wreq-js Rust/native notice bundle",
|
||||
src: ["config", "release", "wreq-js-rust-notices.md"],
|
||||
dest: ["config", "release", "wreq-js-rust-notices.md"],
|
||||
},
|
||||
{
|
||||
label: "@swc/helpers",
|
||||
src: ["node_modules", "@swc", "helpers"],
|
||||
@@ -557,9 +583,7 @@ function stampServiceWorkerBuildId(resolvedOutDir) {
|
||||
const swDest = path.join(resolvedOutDir, "public", "sw.js");
|
||||
if (!fsSync.existsSync(swDest)) return;
|
||||
const buildId =
|
||||
process.env.OMNIROUTE_SW_BUILD_ID ||
|
||||
process.env.SOURCE_VERSION ||
|
||||
String(Date.now());
|
||||
process.env.OMNIROUTE_SW_BUILD_ID || process.env.SOURCE_VERSION || String(Date.now());
|
||||
let sw = fsSync.readFileSync(swDest, "utf8");
|
||||
sw = sw.replace(
|
||||
/^const CACHE_NAME = "omniroute-pwa-v2";$/m,
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* tls-client-node postinstall repair (#7802).
|
||||
*
|
||||
* tls-client-node's own postinstall.js fetches a platform-specific native
|
||||
* binary (.so/.dylib/.dll) from the bogdanfinn/tls-client GitHub Releases
|
||||
* API. That script is blocked by `npm ci --ignore-scripts` (the Dockerfile
|
||||
* builder stage runs with scripts disabled for supply-chain hygiene) and,
|
||||
* even when it does run, silently no-ops on a rate-limited/failed GitHub API
|
||||
* call instead of raising — so `node_modules/tls-client-node/bin/` can end
|
||||
* up empty with no visible signal until the first live request throws
|
||||
* TlsClientUnavailableError (claude-web/grok-web/lmarena/
|
||||
* perplexity-web all share this transport).
|
||||
*
|
||||
* This module:
|
||||
* 1. Copies an already-fetched root `bin/` into the standalone
|
||||
* `dist/node_modules/tls-client-node/bin/` bundle (same pattern as
|
||||
* fixWreqJsBinary), so the published npm package works even though its
|
||||
* own `files` allowlist never ships the binary.
|
||||
* 2. When the root `bin/` is empty (--ignore-scripts blocked it, or a
|
||||
* transient GitHub rate-limit ate the first attempt), retries the
|
||||
* module's own postinstall.js with exponential backoff instead of
|
||||
* giving up on the first failure.
|
||||
*
|
||||
* Best-effort throughout: a failure here never throws out of postinstall.mjs
|
||||
* — it only warns, matching the other fix*Binary() steps. The runtime layer
|
||||
* (perplexityTlsClient.ts and its 4 siblings) already surfaces a clear
|
||||
* TlsClientUnavailableError pointing at the missing binary, so an operator
|
||||
* who hits a still-empty bin/ after this repair gets an actionable message
|
||||
* rather than an opaque crash.
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000, 8_000];
|
||||
|
||||
function hasAnyFile(dir) {
|
||||
if (!existsSync(dir)) return false;
|
||||
try {
|
||||
return readdirSync(dir).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyBinDir(sourceDir, destDir) {
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
for (const file of readdirSync(sourceDir)) {
|
||||
copyFileSync(join(sourceDir, file), join(destDir, file));
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run tls-client-node's own postinstall.js in-process, retrying with
|
||||
* backoff when the attempt leaves `bin/` empty (covers transient GitHub API
|
||||
* rate-limiting — the upstream script itself never throws on failure, it
|
||||
* only warns, so "still empty after running it" is the only failure signal
|
||||
* available).
|
||||
*/
|
||||
async function downloadWithRetry(rootTlsClientDir, retryDelaysMs, log) {
|
||||
const postinstallScript = join(rootTlsClientDir, "scripts", "postinstall.js");
|
||||
const binDir = join(rootTlsClientDir, "bin");
|
||||
if (!existsSync(postinstallScript)) return false;
|
||||
|
||||
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
|
||||
if (attempt > 0) {
|
||||
log(
|
||||
` ⏳ tls-client-node native binary still missing — retrying download ` +
|
||||
`(attempt ${attempt + 1}/${retryDelaysMs.length + 1}) after rate-limit/backoff...`
|
||||
);
|
||||
await sleep(retryDelaysMs[attempt - 1]);
|
||||
}
|
||||
|
||||
try {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
execFileSync(process.execPath, [postinstallScript], {
|
||||
cwd: rootTlsClientDir,
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
log(` ⚠️ tls-client-node postinstall attempt failed: ${err.message.split("\n")[0]}`);
|
||||
}
|
||||
|
||||
if (hasAnyFile(binDir)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.rootDir - repo root
|
||||
* @param {(msg: string) => void} [opts.log]
|
||||
* @param {number[]} [opts.retryDelaysMs] - override for tests (avoid real sleeps)
|
||||
*/
|
||||
export async function fixTlsClientNodeBinary({
|
||||
rootDir,
|
||||
log = (m) => console.log(m),
|
||||
retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
|
||||
} = {}) {
|
||||
const rootTlsClientDir = join(rootDir, "node_modules", "tls-client-node");
|
||||
const rootBinDir = join(rootTlsClientDir, "bin");
|
||||
const distTlsClientDir = join(rootDir, "dist", "node_modules", "tls-client-node");
|
||||
|
||||
if (!existsSync(rootTlsClientDir)) return;
|
||||
|
||||
if (!hasAnyFile(rootBinDir)) {
|
||||
log(
|
||||
"\n 🔧 tls-client-node native binary missing (blocked by --ignore-scripts or a " +
|
||||
"failed fetch) — attempting repair...\n"
|
||||
);
|
||||
const recovered = await downloadWithRetry(rootTlsClientDir, retryDelaysMs, log);
|
||||
if (!recovered) {
|
||||
console.warn(
|
||||
"\n ⚠️ Could not fetch tls-client-node's native binary " +
|
||||
"(GitHub API rate-limited or unreachable after retries)."
|
||||
);
|
||||
console.warn(
|
||||
" claude-web/grok-web/lmarena/perplexity-web will raise a clear " +
|
||||
"TlsClientUnavailableError on first use until this is resolved."
|
||||
);
|
||||
console.warn(
|
||||
` Manual fix: node ${join(rootTlsClientDir, "scripts", "postinstall.js")}\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
log(" ✅ tls-client-node native binary fetched successfully!\n");
|
||||
}
|
||||
|
||||
if (!existsSync(distTlsClientDir) || !hasAnyFile(rootBinDir)) return;
|
||||
|
||||
const distBinDir = join(distTlsClientDir, "bin");
|
||||
if (hasAnyFile(distBinDir)) return;
|
||||
|
||||
try {
|
||||
copyBinDir(rootBinDir, distBinDir);
|
||||
log(" ✅ tls-client-node native binary copied to standalone dist/node_modules.\n");
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Could not copy tls-client-node binary into dist/: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -7,21 +7,26 @@
|
||||
* matrix leg. Everything except install-machine-forked optional packages is
|
||||
* platform-independent:
|
||||
*
|
||||
* - Bundled-for-all (verify only): koffi ships every triplet under
|
||||
* `build/koffi/<os>_<arch>`, better-sqlite3 v13 ships Node-API prebuilds for
|
||||
* 8 platforms, wreq-js ships `rust/wreq-js.<plat>-<arch>[-libc].node`, and
|
||||
* onnxruntime-node ships `bin/napi-v6/<os>/<arch>`.
|
||||
* - Bundled-for-all (verify only): better-sqlite3 v13 ships Node-API prebuilds
|
||||
* for 8 platforms, and onnxruntime-node ships `bin/napi-v6/<os>/<arch>`.
|
||||
* - Install-machine-forked (hydrate): `@img/sharp-*`, `@img/sharp-libvips-*`,
|
||||
* `@ngrok/ngrok-*` and macOS-only `fsevents` resolve to whichever platform
|
||||
* ran `npm ci`. The ubuntu-built tree carries the linux forks; each leg
|
||||
* replaces them with the forks from its OWN `npm ci`d node_modules.
|
||||
* `@ngrok/ngrok-*`, `@wreq-js/binding-*`, and macOS-only `fsevents` resolve
|
||||
* to whichever platform ran `npm ci`. The ubuntu-built tree carries the
|
||||
* linux forks; each leg replaces them with the forks from its OWN install.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveWreqJsNativeBinding } from "./wreqJsNative.mjs";
|
||||
|
||||
/** Scope prefixes whose members are install-machine-forked. */
|
||||
export const HYDRATED_SCOPES = ["@img/sharp-", "@img/sharp-libvips-", "@ngrok/ngrok-"];
|
||||
export const HYDRATED_SCOPES = [
|
||||
"@img/sharp-",
|
||||
"@img/sharp-libvips-",
|
||||
"@ngrok/ngrok-",
|
||||
"@wreq-js/binding-",
|
||||
];
|
||||
|
||||
/** Standalone packages that are not forked but must never be platform-forked. */
|
||||
export const HYDRATED_ROOT_PACKAGES = ["fsevents"];
|
||||
@@ -33,8 +38,7 @@ export const HYDRATED_ROOT_PACKAGES = ["fsevents"];
|
||||
export const BUNDLED_EXEMPTIONS = new Set(["onnxruntime-node:darwin-x64"]);
|
||||
|
||||
function platformTriple(platform, arch) {
|
||||
// koffi uses underscore triplets; better-sqlite3/wreq-js/onnx use dashes.
|
||||
return { koffi: `${platform}_${arch}`, dash: `${platform}-${arch}` };
|
||||
return { dash: `${platform}-${arch}` };
|
||||
}
|
||||
|
||||
function rmrf(target) {
|
||||
@@ -106,9 +110,6 @@ export function verifyBundledNatives({ nodeModulesDir, platform, arch }) {
|
||||
const errors = [];
|
||||
const triple = platformTriple(platform, arch);
|
||||
|
||||
const koffiDir = path.join(nodeModulesDir, "koffi", "build", "koffi", triple.koffi);
|
||||
if (!fs.existsSync(koffiDir)) errors.push(`koffi: missing bundled triplet ${triple.koffi}`);
|
||||
|
||||
const sqlitePrebuild = path.join(
|
||||
nodeModulesDir,
|
||||
"better-sqlite3",
|
||||
@@ -118,13 +119,23 @@ export function verifyBundledNatives({ nodeModulesDir, platform, arch }) {
|
||||
if (!fs.existsSync(sqlitePrebuild))
|
||||
errors.push(`better-sqlite3: missing prebuild ${triple.dash}.node`);
|
||||
|
||||
const wreqDir = path.join(nodeModulesDir, "wreq-js", "rust");
|
||||
const wreqNames = fs.existsSync(wreqDir)
|
||||
? fs
|
||||
.readdirSync(wreqDir)
|
||||
.filter((n) => n.startsWith(`wreq-js.${triple.dash}`) && n.endsWith(".node"))
|
||||
: [];
|
||||
if (wreqNames.length === 0) errors.push(`wreq-js: missing rust binary for ${triple.dash}`);
|
||||
const wreqBinding = resolveWreqJsNativeBinding({
|
||||
platform,
|
||||
arch,
|
||||
libc: platform === "linux" ? "gnu" : undefined,
|
||||
});
|
||||
if (!wreqBinding) {
|
||||
errors.push(`wreq-js: unsupported target ${triple.dash}`);
|
||||
} else {
|
||||
const wreqBinary = path.join(
|
||||
nodeModulesDir,
|
||||
...wreqBinding.packageName.split("/"),
|
||||
wreqBinding.fileName
|
||||
);
|
||||
if (!fs.existsSync(wreqBinary)) {
|
||||
errors.push(`wreq-js: missing ${wreqBinding.packageName}/${wreqBinding.fileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const exempt = BUNDLED_EXEMPTIONS.has(`onnxruntime-node:${triple.dash}`);
|
||||
if (!exempt) {
|
||||
|
||||
@@ -94,6 +94,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"config/release/wreq-js-native-manifest.json",
|
||||
"config/release/wreq-js-rust-license-inventory.json",
|
||||
"config/release/wreq-js-rust-notices.md",
|
||||
"bin/aliasResolver.mjs",
|
||||
"bin/chatgpt-web-codex-mcp.mjs",
|
||||
// #7808: ESM loader hook split out of bin/aliasResolver.mjs to silence CodeQL
|
||||
@@ -136,12 +139,10 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/check/check-supported-node-runtime.ts",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/wreqJsNative.mjs",
|
||||
"scripts/build/postinstall.mjs",
|
||||
"scripts/build/postinstallSupport.mjs",
|
||||
"scripts/build/colocateOptionals.mjs",
|
||||
// #7802: imported by scripts/build/postinstall.mjs to repair tls-client-node's
|
||||
// native binary (claude-web/grok-web/lmarena/perplexity-web transport).
|
||||
"scripts/build/fixTlsClientNodeBinary.mjs",
|
||||
// #8859: imported by scripts/build/postinstall.mjs to repair playwright-core's
|
||||
// browser resolution on Termux/Android (no glibc, no bundled browsers).
|
||||
"scripts/build/fixPlaywrightAndroid.mjs",
|
||||
@@ -222,13 +223,16 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
|
||||
// or the CLI fails to boot — list them REQUIRED so a regression is loud.
|
||||
"bin/aliasResolver.mjs",
|
||||
"bin/aliasResolverHook.mjs",
|
||||
"config/release/wreq-js-native-manifest.json",
|
||||
"config/release/wreq-js-rust-license-inventory.json",
|
||||
"config/release/wreq-js-rust-notices.md",
|
||||
"package.json",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/postinstall.mjs",
|
||||
"scripts/build/postinstallSupport.mjs",
|
||||
"scripts/build/colocateOptionals.mjs",
|
||||
"scripts/build/fixTlsClientNodeBinary.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"scripts/build/wreqJsNative.mjs",
|
||||
// #10382: runtime imports of bin/cli/commands/packs.mjs (optional packs CLI) —
|
||||
// listed REQUIRED so their absence from the tarball fails loudly.
|
||||
"scripts/packs/optionalPackInstaller.mjs",
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
*
|
||||
* Modules repaired:
|
||||
* - better-sqlite3 (SQLite bindings)
|
||||
* - wreq-js (TLS client for OAuth providers)
|
||||
* - tls-client-node (TLS client for claude-web/grok-web/lmarena/perplexity-web)
|
||||
* - wreq-js (TLS client for OAuth and web-cookie providers)
|
||||
* - sql.js (WASM SQLite fallback runtime)
|
||||
* - node-machine-id (local CLI machine-token server runtime)
|
||||
*
|
||||
@@ -26,15 +25,7 @@
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/7802
|
||||
*/
|
||||
|
||||
import {
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -42,8 +33,8 @@ import { fileURLToPath } from "node:url";
|
||||
import { PUBLISHED_BUILD_ARCH, PUBLISHED_BUILD_PLATFORM } from "./native-binary-compat.mjs";
|
||||
import { hasStandaloneAppBundle, isTermux } from "./postinstallSupport.mjs";
|
||||
import { colocateLlmlinguaOptionals } from "./colocateOptionals.mjs";
|
||||
import { fixTlsClientNodeBinary } from "./fixTlsClientNodeBinary.mjs";
|
||||
import { fixPlaywrightAndroid } from "./fixPlaywrightAndroid.mjs";
|
||||
import { resolveWreqJsNativeBinding, WREQ_JS_VERSION } from "./wreqJsNative.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -262,105 +253,60 @@ async function fixBetterSqliteBinary() {
|
||||
console.warn("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix wreq-js native binary for the standalone dist directory.
|
||||
*
|
||||
* wreq-js ships platform-specific .node binaries under rust/.
|
||||
* The standalone build may only contain Linux binaries from the CI.
|
||||
* This copies the correct platform binary from the root install.
|
||||
*
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634
|
||||
*/
|
||||
/** Copy the current wreq-js 3.2 optional binding into the standalone dist tree. */
|
||||
async function fixWreqJsBinary() {
|
||||
// wreq-js native module is not loadable in Termux (libgcc path mismatch).
|
||||
// The runtime already falls back gracefully when wreq-js is unavailable.
|
||||
if (process.platform === "android" || isTermux()) {
|
||||
console.log(
|
||||
" [postinstall] wreq-js: skipped on Termux/Android " +
|
||||
"(libgcc not available — OAuth TLS fingerprinting will use the fallback path)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const appWreqDir = join(ROOT, "dist", "node_modules", "wreq-js", "rust");
|
||||
const rootWreqDir = join(ROOT, "node_modules", "wreq-js", "rust");
|
||||
|
||||
if (!existsSync(join(ROOT, "dist", "node_modules", "wreq-js"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const binaryName = `wreq-js.${process.platform}-${process.arch}.node`;
|
||||
const appBinaryPath = join(appWreqDir, binaryName);
|
||||
const rootBinaryPath = join(rootWreqDir, binaryName);
|
||||
const runtimePlatform = isTermux() ? "android" : process.platform;
|
||||
const binding = resolveWreqJsNativeBinding({
|
||||
platform: runtimePlatform,
|
||||
arch: process.arch,
|
||||
});
|
||||
if (!binding) {
|
||||
console.warn(
|
||||
` ⚠️ wreq-js ${WREQ_JS_VERSION} has no native binding for ` +
|
||||
`${runtimePlatform}-${process.arch}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageSegments = binding.packageName.split("/");
|
||||
const rootBindingDir = join(ROOT, "node_modules", ...packageSegments);
|
||||
const appBindingDir = join(ROOT, "dist", "node_modules", ...packageSegments);
|
||||
const rootBinaryPath = join(rootBindingDir, binding.fileName);
|
||||
const appBinaryPath = join(appBindingDir, binding.fileName);
|
||||
|
||||
// Check if the platform binary already exists and loads
|
||||
if (existsSync(appBinaryPath)) {
|
||||
try {
|
||||
process.dlopen({ exports: {} }, appBinaryPath);
|
||||
return; // Already working
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ wreq-js binary exists but failed to load: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n 🔧 Fixing wreq-js binary for ${process.platform}-${process.arch}...`);
|
||||
console.log(`\n 🔧 Fixing ${binding.packageName} for ${runtimePlatform}-${process.arch}...`);
|
||||
|
||||
// Strategy 1: Copy from root node_modules
|
||||
if (existsSync(rootBinaryPath)) {
|
||||
if (existsSync(rootBindingDir) && existsSync(rootBinaryPath)) {
|
||||
try {
|
||||
mkdirSync(appWreqDir, { recursive: true });
|
||||
copyFileSync(rootBinaryPath, appBinaryPath);
|
||||
mkdirSync(dirname(appBindingDir), { recursive: true });
|
||||
cpSync(rootBindingDir, appBindingDir, { recursive: true, force: true });
|
||||
process.dlopen({ exports: {} }, appBinaryPath);
|
||||
console.log(" ✅ wreq-js native module fixed successfully!\n");
|
||||
console.log(` ✅ ${binding.packageName} copied to standalone successfully!\n`);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Copied wreq-js binary failed to load: ${err.message}`);
|
||||
console.warn(` ⚠️ Copied ${binding.packageName} failed to load: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Copy entire rust/ directory from root (gets all platform binaries)
|
||||
if (existsSync(rootWreqDir)) {
|
||||
try {
|
||||
mkdirSync(appWreqDir, { recursive: true });
|
||||
const files = readdirSync(rootWreqDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".node")) {
|
||||
copyFileSync(join(rootWreqDir, file), join(appWreqDir, file));
|
||||
}
|
||||
}
|
||||
if (existsSync(appBinaryPath)) {
|
||||
process.dlopen({ exports: {} }, appBinaryPath);
|
||||
console.log(" ✅ wreq-js native module fixed (full copy) successfully!\n");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ wreq-js full copy failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Rebuild wreq-js inside dist/
|
||||
console.log(" 📥 Attempting npm rebuild wreq-js...");
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
execSync("npm rebuild wreq-js", {
|
||||
cwd: join(ROOT, "dist"),
|
||||
stdio: "inherit",
|
||||
timeout: 120_000,
|
||||
});
|
||||
if (existsSync(appBinaryPath)) {
|
||||
process.dlopen({ exports: {} }, appBinaryPath);
|
||||
console.log(" ✅ wreq-js native module rebuilt successfully!\n");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ wreq-js rebuild failed: ${err.message}`);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`\n ⚠️ Could not fix wreq-js native module for ${process.platform}-${process.arch}.`
|
||||
`\n ⚠️ Could not install ${binding.packageName}@${WREQ_JS_VERSION} for ` +
|
||||
`${runtimePlatform}-${process.arch}.`
|
||||
);
|
||||
console.warn(" OAuth-based providers (Codex, Cursor, etc.) may not work.");
|
||||
console.warn(` Manual fix: cd ${join(ROOT, "dist")} && npm install wreq-js --no-save\n`);
|
||||
console.warn(" Browser-TLS OAuth and web-cookie providers may not work.");
|
||||
console.warn(` Manual fix: npm install --include=optional wreq-js@${WREQ_JS_VERSION}\n`);
|
||||
}
|
||||
|
||||
async function ensureSwcHelpers() {
|
||||
@@ -470,7 +416,6 @@ async function ensureStandaloneRuntimePackages() {
|
||||
await verifyDevNativeModules();
|
||||
await fixBetterSqliteBinary();
|
||||
await fixWreqJsBinary();
|
||||
await fixTlsClientNodeBinary({ rootDir: ROOT });
|
||||
await fixPlaywrightAndroid({ rootDir: ROOT });
|
||||
await ensureSwcHelpers();
|
||||
await ensureStandaloneRuntimePackages();
|
||||
|
||||
131
scripts/build/wreqJsNative.mjs
Normal file
131
scripts/build/wreqJsNative.mjs
Normal file
@@ -0,0 +1,131 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/** Exact native binding set published by wreq-js 3.2.0. */
|
||||
export const WREQ_JS_VERSION = "3.2.0";
|
||||
|
||||
export const WREQ_JS_NATIVE_BINDINGS = Object.freeze([
|
||||
{
|
||||
target: "android-arm64",
|
||||
packageName: "@wreq-js/binding-android-arm64",
|
||||
fileName: "wreq-js.android-arm64.node",
|
||||
platform: "android",
|
||||
arch: "arm64",
|
||||
},
|
||||
{
|
||||
target: "darwin-arm64",
|
||||
packageName: "@wreq-js/binding-darwin-arm64",
|
||||
fileName: "wreq-js.darwin-arm64.node",
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
},
|
||||
{
|
||||
target: "darwin-x64",
|
||||
packageName: "@wreq-js/binding-darwin-x64",
|
||||
fileName: "wreq-js.darwin-x64.node",
|
||||
platform: "darwin",
|
||||
arch: "x64",
|
||||
},
|
||||
{
|
||||
target: "linux-arm64-gnu",
|
||||
packageName: "@wreq-js/binding-linux-arm64-gnu",
|
||||
fileName: "wreq-js.linux-arm64-gnu.node",
|
||||
platform: "linux",
|
||||
arch: "arm64",
|
||||
libc: "gnu",
|
||||
},
|
||||
{
|
||||
target: "linux-arm64-musl",
|
||||
packageName: "@wreq-js/binding-linux-arm64-musl",
|
||||
fileName: "wreq-js.linux-arm64-musl.node",
|
||||
platform: "linux",
|
||||
arch: "arm64",
|
||||
libc: "musl",
|
||||
},
|
||||
{
|
||||
target: "linux-x64-gnu",
|
||||
packageName: "@wreq-js/binding-linux-x64-gnu",
|
||||
fileName: "wreq-js.linux-x64-gnu.node",
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
libc: "gnu",
|
||||
},
|
||||
{
|
||||
target: "linux-x64-musl",
|
||||
packageName: "@wreq-js/binding-linux-x64-musl",
|
||||
fileName: "wreq-js.linux-x64-musl.node",
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
libc: "musl",
|
||||
},
|
||||
{
|
||||
target: "win32-arm64-msvc",
|
||||
packageName: "@wreq-js/binding-win32-arm64-msvc",
|
||||
fileName: "wreq-js.win32-arm64-msvc.node",
|
||||
platform: "win32",
|
||||
arch: "arm64",
|
||||
},
|
||||
{
|
||||
target: "win32-x64-msvc",
|
||||
packageName: "@wreq-js/binding-win32-x64-msvc",
|
||||
fileName: "wreq-js.win32-x64-msvc.node",
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
},
|
||||
]);
|
||||
|
||||
function readSystemLdd() {
|
||||
const failures = [];
|
||||
for (const lddPath of ["/usr/bin/ldd", "/bin/ldd"]) {
|
||||
try {
|
||||
return readFileSync(lddPath, "utf8");
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
throw failures[0] ?? new Error("ldd is unavailable");
|
||||
}
|
||||
|
||||
/** Detect the C library used by the current Linux runtime. */
|
||||
export function detectRuntimeLibc(options = {}) {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform !== "linux") return undefined;
|
||||
const getReport = options.getReport ?? (() => process.report?.getReport());
|
||||
const readLdd = options.readLdd ?? readSystemLdd;
|
||||
let reportError;
|
||||
try {
|
||||
const report = getReport();
|
||||
if (report?.header?.glibcVersionRuntime) return "gnu";
|
||||
if (report?.header) return "musl";
|
||||
} catch (error) {
|
||||
reportError = error;
|
||||
}
|
||||
|
||||
let lddError;
|
||||
try {
|
||||
const ldd = String(readLdd());
|
||||
if (/\bmusl\b/i.test(ldd)) return "musl";
|
||||
if (/\b(?:glibc|gnu libc|gnu c library)\b/i.test(ldd)) return "gnu";
|
||||
lddError = new Error("ldd output did not identify glibc or musl");
|
||||
} catch (error) {
|
||||
lddError = error;
|
||||
}
|
||||
|
||||
const detail = [reportError, lddError]
|
||||
.filter((error) => error instanceof Error)
|
||||
.map((error) => error.message)
|
||||
.join("; ");
|
||||
throw new Error(`Unable to detect Linux libc${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
|
||||
/** Resolve the exact package and addon filename wreq-js 3.2.0 loads. */
|
||||
export function resolveWreqJsNativeBinding({ platform, arch, libc }) {
|
||||
const runtimeLibc = platform === "linux" ? (libc ?? detectRuntimeLibc()) : undefined;
|
||||
return (
|
||||
WREQ_JS_NATIVE_BINDINGS.find(
|
||||
(binding) =>
|
||||
binding.platform === platform &&
|
||||
binding.arch === arch &&
|
||||
(binding.libc === undefined || binding.libc === runtimeLibc)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,13 @@
|
||||
// Catraca: exceções pré-existentes ficam em KNOWN_REGISTRY_ONLY; só NOVOS órfãos falham.
|
||||
// Stale-enforcement (6A.3): entrada em KNOWN_REGISTRY_ONLY que não suprime nenhum órfão
|
||||
// real → gate falha com instrução de remoção (evita furo de regressão silencioso).
|
||||
//
|
||||
// Reverse walk (#10513): providers.ts → REGISTRY. Um provider canônico cujo
|
||||
// serviceKinds inclui "llm" DEVE ter entrada no REGISTRY — a não ser que esteja em
|
||||
// KNOWN_CATALOG_ONLY (providers que roteiam via baseUrl de conexão / executor
|
||||
// especializado sem entrada de registry). Isso torna provider:remove --dry-run
|
||||
// verificável: um provider removido do REGISTRY mas esquecido em providers.ts
|
||||
// aparece como órfão reverso e o gate falha.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { AI_PROVIDERS, getProviderById } from "@/shared/constants/providers.ts";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
@@ -16,6 +23,46 @@ import { assertNoStale } from "./lib/allowlist.mjs";
|
||||
// justificativa. Remover daqui ao registrar o provider em providers.ts.
|
||||
export const KNOWN_REGISTRY_ONLY: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* Providers canônicos com serviceKinds llm que LEGITIMAMENTE não têm entrada no
|
||||
* REGISTRY. Cada um roteia via baseUrl de conexão (providerSpecificData.baseUrl) ou
|
||||
* executor especializado, então a ausência de registro não é órfão.
|
||||
*/
|
||||
export const KNOWN_CATALOG_ONLY: Record<string, string> = {
|
||||
"amazon-q": "OAuth/IDE provider roteado via KiroExecutor sem entrada de registry.",
|
||||
zed: "OAuth/IDE provider (Zed) roteado via executor especializado; sem entrada de registry.",
|
||||
piapi: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
getgoapi: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
laozhang: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
thebai: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
fenayai: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
empower: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
"arcee-ai": "API-key provider roteado via baseUrl de conexão.",
|
||||
"azure-openai": "Azure OpenAI roteado via AzureOpenAIExecutor + baseUrl de conexão.",
|
||||
"azure-ai": "Azure AI Foundry roteado via AzureAiExecutor + baseUrl de conexão.",
|
||||
watsonx: "Enterprise provider roteado via baseUrl de conexão.",
|
||||
oci: "OCI Generative AI roteado via baseUrl de conexão.",
|
||||
sap: "SAP AI Core roteado via baseUrl de conexão.",
|
||||
datarobot: "Enterprise provider roteado via baseUrl de conexão.",
|
||||
clarifai: "Clarifai PAT roteado via baseUrl de conexão.",
|
||||
"360ai": "Regional provider roteado via baseUrl de conexão.",
|
||||
gitlab: "GitLab (non-Duo) roteado via executor especializado + baseUrl de conexão.",
|
||||
"poe-web": "Web/cookie provider roteado via executor especializado (PoeWebExecutor).",
|
||||
"venice-web": "Web/cookie provider roteado via executor especializado (VeniceWeb).",
|
||||
"v0-vercel-web": "Web/cookie provider roteado via executor especializado (V0VercelWeb).",
|
||||
"gemini-business": "Enterprise Gemini roteado via executor especializado + baseUrl de conexão.",
|
||||
"ollama-local": "Local provider (Ollama) roteado via baseUrl de conexão; sem registry.",
|
||||
"lm-studio": "Local provider (LM Studio) roteado via baseUrl de conexão.",
|
||||
vllm: "Local provider (vLLM) roteado via baseUrl de conexão.",
|
||||
lemonade: "Local provider roteado via baseUrl de conexão.",
|
||||
llamafile: "Local provider roteado via baseUrl de conexão.",
|
||||
"llama-cpp": "Local provider roteado via baseUrl de conexão.",
|
||||
triton: "Local provider (Triton) roteado via baseUrl de conexão.",
|
||||
"docker-model-runner": "Local provider roteado via baseUrl de conexão.",
|
||||
xinference: "Local provider (XInference) roteado via baseUrl de conexão.",
|
||||
oobabooga: "Local provider (Oobabooga) roteado via baseUrl de conexão.",
|
||||
};
|
||||
|
||||
/** Ids do REGISTRY que não são providers canônicos e não estão na allowlist. */
|
||||
export function findOrphanRegistryIds(
|
||||
registryIds: string[],
|
||||
@@ -25,6 +72,24 @@ export function findOrphanRegistryIds(
|
||||
return registryIds.filter((id) => !isKnownProvider(id) && !(id in allowlist));
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers canônicos com serviceKinds llm sem entrada no REGISTRY e fora da
|
||||
* allowlist — metade de um provider:remove (registro apagado, catálogo esquecido).
|
||||
*/
|
||||
export function findCatalogOnlyLlmProviders(
|
||||
canonicalProviders: Record<string, { serviceKinds?: string[] }>,
|
||||
registryIds: string[],
|
||||
allowlist: Record<string, string>
|
||||
): string[] {
|
||||
const registry = new Set(registryIds);
|
||||
return Object.entries(canonicalProviders)
|
||||
.filter(([id, p]) => {
|
||||
if (registry.has(id) || id in allowlist) return false;
|
||||
return Array.isArray(p.serviceKinds) && p.serviceKinds.includes("llm");
|
||||
})
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const canonical = new Set(Object.keys(AI_PROVIDERS));
|
||||
const isKnown = (id: string) => canonical.has(id) || Boolean(getProviderById(id));
|
||||
@@ -42,9 +107,24 @@ function main(): void {
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// Reverse walk: llm-kind canonical provider sem REGISTRY = órfão reverso.
|
||||
const catalogOnlyLlm = findCatalogOnlyLlmProviders(
|
||||
AI_PROVIDERS as Record<string, { serviceKinds?: string[] }>,
|
||||
Object.keys(REGISTRY),
|
||||
KNOWN_CATALOG_ONLY
|
||||
);
|
||||
if (catalogOnlyLlm.length) {
|
||||
console.error(
|
||||
`[provider-consistency] ${catalogOnlyLlm.length} provider(s) canônico(s) llm sem entrada no REGISTRY:\n` +
|
||||
catalogOnlyLlm.map((id) => ` ✗ ${id}`).join("\n") +
|
||||
`\n → registre o provider em open-sse/config/providers/registry/<id>/ ou adicione a KNOWN_CATALOG_ONLY (scripts/check/check-provider-consistency.ts) com justificativa — órfão reverso de um provider:remove incompleto?`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (!process.exitCode) {
|
||||
console.log(
|
||||
`[provider-consistency] OK — ${Object.keys(REGISTRY).length} entradas REGISTRY, ${canonical.size} providers canônicos, ${Object.keys(KNOWN_REGISTRY_ONLY).length} exceção(ões) conhecida(s)`
|
||||
`[provider-consistency] OK — ${Object.keys(REGISTRY).length} entradas REGISTRY, ${canonical.size} providers canônicos, ${Object.keys(KNOWN_REGISTRY_ONLY).length} exceção(ões) registry-only, ${Object.keys(KNOWN_CATALOG_ONLY).length} catalog-only`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,18 @@ export function selectImpacted({ changed, map }) {
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
// `--stdin`: read the changed-file list from stdin (one path per line) instead of
|
||||
// diffing git. Used by scripts/quality/test-scoped.sh so `--staged` selects from the
|
||||
// index — the git-diff path here only knows about commits, never the working tree.
|
||||
export function changedFilesFromStdin(text) {
|
||||
return String(text || "")
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function changedFiles() {
|
||||
if (process.argv.includes("--stdin")) return changedFilesFromStdin(fs.readFileSync(0, "utf8"));
|
||||
const baseRef = process.env.GITHUB_BASE_REF;
|
||||
const baseTarget = process.env.GITHUB_BASE_SHA || (baseRef ? `origin/${baseRef}` : "HEAD~1");
|
||||
const stdout = execFileSync(
|
||||
|
||||
@@ -2,25 +2,46 @@
|
||||
# test-scoped — run only unit tests impacted by your changes.
|
||||
#
|
||||
# Usage:
|
||||
# npm run test:scoped # tests for changes vs HEAD~1
|
||||
# npm run test:scoped -- --staged # tests for staged changes only
|
||||
# npm run test:scoped # tests for changes vs HEAD~1 (working tree if no commit)
|
||||
# npm run test:scoped:staged # tests for staged changes only
|
||||
# npm run test:scoped:full # rebuild the import-graph impact map first, then select
|
||||
#
|
||||
# This is the local DX companion to the CI TIA gate (#8084 D1). The CI version
|
||||
# builds a full import-graph impact map; for local dev we use a fast heuristic:
|
||||
# This is the local DX companion to the CI TIA gate (#8084 D1). It uses the SAME
|
||||
# selector as CI (scripts/quality/select-impacted-tests.mjs) against the import-graph
|
||||
# impact map (config/quality/test-impact-map.json, gitignored):
|
||||
# - Changed test files → run those directly
|
||||
# - Changed source files → run tests that share the file's directory/name prefix
|
||||
# - Hub files (tsconfig, package.json, etc.) → suggest full suite
|
||||
# - Changed source files → run every unit test whose import graph reaches them
|
||||
# - Hub files (tsconfig, package.json, …) or unmapped sources → full suite (fail-safe)
|
||||
#
|
||||
# For the full TIA (import-graph based), use: npm run test:scoped:full
|
||||
# (requires a pre-built impact map via: node scripts/quality/build-test-impact-map.mjs)
|
||||
# The map is a snapshot of the import graph: rebuild it (`--full`) after adding tests,
|
||||
# moving files, or pulling a big base update — a stale map falls back to __RUN_ALL__
|
||||
# for unknown sources, never to a silent skip.
|
||||
#
|
||||
# Loader parity with `npm run test:unit` / CI (#6787): tests/unit/dashboard/** runs
|
||||
# under `--import tsx` (CJS transform — required for ESM-only deep imports such as
|
||||
# @lobehub/icons/es/*), tests/unit/serial/** at --test-concurrency=1, everything else
|
||||
# under `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard
|
||||
# test the map selects ("Unexpected token 'export'").
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
MAP_FILE="$REPO_ROOT/config/quality/test-impact-map.json"
|
||||
|
||||
STAGED=false
|
||||
FULL=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--staged) STAGED=true ;;
|
||||
--full) FULL=true ;;
|
||||
-h|--help) sed -n '2,25p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
||||
*) echo "[test:scoped] unknown argument: $arg (use --staged, --full)"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── 1. Determine changed files ───────────────────────────────────────────────
|
||||
if [[ "${1:-}" == "--staged" ]]; then
|
||||
if [ "$STAGED" = true ]; then
|
||||
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR --cached)
|
||||
else
|
||||
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR HEAD~1...HEAD 2>/dev/null || \
|
||||
@@ -32,80 +53,51 @@ if [ -z "$CHANGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 2. Classify changes ──────────────────────────────────────────────────────
|
||||
HUB_RE="(setupPolyfill|tsconfig|package\\.json|package-lock\\.json|\\.env|vitest\\.config|stryker\\.conf)"
|
||||
TEST_FILES=()
|
||||
SRC_FILES=()
|
||||
HIT_HUB=false
|
||||
# ── 2. Impact map (build on --full or when missing) ──────────────────────────
|
||||
if [ "$FULL" = true ] || [ ! -f "$MAP_FILE" ]; then
|
||||
echo "[test:scoped] Building the import-graph impact map (config/quality/test-impact-map.json)…"
|
||||
(cd "$REPO_ROOT" && node scripts/quality/build-test-impact-map.mjs)
|
||||
fi
|
||||
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
if echo "$f" | grep -qE "$HUB_RE"; then
|
||||
HIT_HUB=true
|
||||
elif echo "$f" | grep -qE '^tests/unit/.*\.test\.(ts|mjs)$'; then
|
||||
TEST_FILES+=("$f")
|
||||
elif echo "$f" | grep -qE '^(src|open-sse)/'; then
|
||||
SRC_FILES+=("$f")
|
||||
fi
|
||||
done <<< "$CHANGED"
|
||||
# ── 3. Select impacted tests (same selector as the CI TIA gate) ──────────────
|
||||
SEL=$(printf '%s\n' "$CHANGED" | node "$REPO_ROOT/scripts/quality/select-impacted-tests.mjs" --stdin)
|
||||
|
||||
# ── 3. Hub file changed → full suite ─────────────────────────────────────────
|
||||
if [ "$HIT_HUB" = true ]; then
|
||||
echo "[test:scoped] Hub file changed — run full suite: npm run test:unit"
|
||||
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
||||
echo "[test:scoped] Hub file or unmapped source changed — run the full suite: npm run test:unit"
|
||||
echo "[test:scoped] (if you just added a source file, rebuild the map: npm run test:scoped:full)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 4. Collect tests to run ──────────────────────────────────────────────────
|
||||
RUN_TESTS=()
|
||||
mapfile -t RUN_TESTS < <(printf '%s\n' "$SEL" | grep -v '^$' | sort -u)
|
||||
|
||||
# Direct test file changes always run
|
||||
for tf in "${TEST_FILES[@]}"; do
|
||||
RUN_TESTS+=("$tf")
|
||||
done
|
||||
|
||||
# For source files, try the impact map first; fall back to heuristic
|
||||
MAP_FILE="$REPO_ROOT/config/quality/test-impact-map.json"
|
||||
if [ ${#SRC_FILES[@]} -gt 0 ] && [ -f "$MAP_FILE" ]; then
|
||||
# Use the TIA selection with the impact map
|
||||
SEL=$(printf '%s\n' "${SRC_FILES[@]}" | node "$REPO_ROOT/scripts/quality/select-impacted-tests.mjs" 2>/dev/null || echo "__RUN_ALL__")
|
||||
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
||||
echo "[test:scoped] Unmapped source change — run full suite: npm run test:unit"
|
||||
exit 1
|
||||
fi
|
||||
while IFS= read -r t; do
|
||||
[ -n "$t" ] && RUN_TESTS+=("$t")
|
||||
done <<< "$SEL"
|
||||
elif [ ${#SRC_FILES[@]} -gt 0 ]; then
|
||||
# No impact map — heuristic: suggest building it
|
||||
echo "[test:scoped] No impact map found. Build it with: node scripts/quality/build-test-impact-map.mjs"
|
||||
echo "[test:scoped] Or run the full suite: npm run test:unit"
|
||||
echo ""
|
||||
echo "[test:scoped] Changed source files:"
|
||||
printf ' %s\n' "${SRC_FILES[@]}"
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
echo "[test:scoped] Running changed test files only..."
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deduplicate
|
||||
IFS=$'\n' SORTED=($(printf '%s\n' "${RUN_TESTS[@]}" | sort -u)); unset IFS
|
||||
|
||||
if [ ${#SORTED[@]} -eq 0 ]; then
|
||||
echo "[test:scoped] No impacted tests — source changes don't map to any unit test."
|
||||
if [ ${#RUN_TESTS[@]} -eq 0 ]; then
|
||||
echo "[test:scoped] No impacted unit tests — the change does not reach any node:test file."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[test:scoped] Running ${#SORTED[@]} impacted test(s)..."
|
||||
echo "[test:scoped] Running ${#RUN_TESTS[@]} impacted test(s)..."
|
||||
|
||||
# ── 4. Split by loader (mirror package.json test:unit / quality.yml TIA step) ──
|
||||
DASH=(); SERIAL=(); REST=()
|
||||
for f in "${RUN_TESTS[@]}"; do
|
||||
case "$f" in
|
||||
tests/unit/dashboard/*) DASH+=("$f") ;;
|
||||
tests/unit/serial/*) SERIAL+=("$f") ;;
|
||||
*) REST+=("$f") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── 5. Run selected tests ────────────────────────────────────────────────────
|
||||
cd "$REPO_ROOT"
|
||||
exec 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 \
|
||||
"${SORTED[@]}"
|
||||
NODE_COMMON=(--max-old-space-size=8192 --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit)
|
||||
export DISABLE_SQLITE_AUTO_BACKUP=true
|
||||
RC=0
|
||||
if [ ${#REST[@]} -gt 0 ]; then
|
||||
node --import tsx/esm "${NODE_COMMON[@]}" --test-concurrency=4 "${REST[@]}" || RC=$?
|
||||
fi
|
||||
if [ ${#DASH[@]} -gt 0 ]; then
|
||||
node --import tsx "${NODE_COMMON[@]}" --test-concurrency=4 "${DASH[@]}" || RC=$?
|
||||
fi
|
||||
if [ ${#SERIAL[@]} -gt 0 ]; then
|
||||
node --import tsx/esm "${NODE_COMMON[@]}" --test-concurrency=1 "${SERIAL[@]}" || RC=$?
|
||||
fi
|
||||
exit $RC
|
||||
|
||||
@@ -25,6 +25,17 @@ curl https://localhost:20128/api/monitoring/health \
|
||||
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
|
||||
```
|
||||
|
||||
### GET /api/monitoring/compression
|
||||
|
||||
Get compression result-memo statistics
|
||||
|
||||
In-process compression result-memo observability snapshot — size, capacity, lifetime hits/misses/hitRate plus 1m/5m/15m/1h windowed rates. Lightweight (no DB, no provider reads) companion to `GET /api/monitoring/health` intended for frequent polling. Sent with `Cache-Control: no-store, no-cache, must-revalidate`. Counters reset on process restart.
|
||||
|
||||
```bash
|
||||
curl https://localhost:20128/api/monitoring/compression \
|
||||
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
|
||||
```
|
||||
|
||||
### GET /api/provider-metrics
|
||||
|
||||
GET provider metrics
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useSyncExternalStore } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
|
||||
// Branded short link through our own link.omniroute.online shortener, so the
|
||||
// click lands in our Kutt metrics. Points at cheaperinference.com?utm_source=omniroute
|
||||
// (the URL in README.md's Open Source Friends section). Keep in sync with the
|
||||
// `cheaper` slug on the shortener.
|
||||
const CHEAPER_INFERENCE_URL = "https://link.omniroute.online/cheaper";
|
||||
// The URL in README.md's Open Source Friends section. This used to go through
|
||||
// our own link.omniroute.online shortener for click metrics, but that domain no
|
||||
// longer resolves (every slug 404s) after the move to omniskill.online, so the
|
||||
// CTA points straight at the destination again.
|
||||
const CHEAPER_INFERENCE_URL = "https://cheaperinference.com/?utm_source=omniroute";
|
||||
|
||||
// Cheaper Inference brand green (#31f889). White text on it fails contrast, so
|
||||
// the CTA pairs it with the dark ink from the provider's color token (colors.ts:
|
||||
|
||||
@@ -6,9 +6,10 @@ import { useTranslations } from "next-intl";
|
||||
// Marketplace listing is the primary CTA; Open VSX (Cursor/Windsurf/VSCodium/etc.)
|
||||
// is called out via secondaryNote instead of a second button, to keep this banner
|
||||
// the same size as KimiSponsorBanner.
|
||||
// Branded short link through our own link.omniroute.online shortener (the `vsx`
|
||||
// slug), so the click lands in our Kutt metrics.
|
||||
const MARKETPLACE_URL = "https://link.omniroute.online/vsx";
|
||||
// This used to go through our own link.omniroute.online shortener for click
|
||||
// metrics, but that domain no longer resolves after the move to omniskill.online.
|
||||
const MARKETPLACE_URL =
|
||||
"https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot";
|
||||
|
||||
const DISMISS_STORAGE_KEY = "omniroute-vscode-copilot-banner-dismissed-v1";
|
||||
// Same-tab signal for the dismiss button, since writing localStorage doesn't
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, memo, Suspense } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -741,7 +741,7 @@ function formatComboEntryDisplay(
|
||||
return `${providerLabel}/${modelLabel}`;
|
||||
}
|
||||
|
||||
export default function CombosPage() {
|
||||
function CombosPageContent() {
|
||||
const t = useTranslations("combos");
|
||||
const tc = useTranslations("common");
|
||||
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
|
||||
@@ -1373,6 +1373,14 @@ export default function CombosPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function CombosPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CombosPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const COMBO_WIZARD_STEPS = [
|
||||
{
|
||||
step: 1,
|
||||
|
||||
@@ -89,22 +89,46 @@ export default function EmbeddingSourceSelector({ settings, providers, onSave, s
|
||||
{t("embedding.noRemoteProviders")}
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={currentProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="embedding-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("embedding.selectProviderModel")}</option>
|
||||
{remoteProviders.map((p) =>
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.dimensions ? `${m.dimensions}d` : "?"})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<>
|
||||
<select
|
||||
value={
|
||||
remoteProviders.some((p) => p.models.some((m) => m.id === currentProviderModel))
|
||||
? currentProviderModel
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="embedding-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("embedding.selectProviderModel")}</option>
|
||||
{remoteProviders.map((p) =>
|
||||
p.models.length > 0 ? (
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.dimensions ? `${m.dimensions}d` : "?"})
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<optgroup key={p.provider} label={p.provider}>
|
||||
<option value="">{`— ${p.provider} (no curated models)`}</option>
|
||||
</optgroup>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
{/* Free-text override: the runtime accepts any configured provider's
|
||||
OpenAI-compatible model id, including ones without a curated
|
||||
registry entry (e.g. groq/, mistral/, cf/...). */}
|
||||
<input
|
||||
type="text"
|
||||
value={currentProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder="provider/model — e.g. mistral/mistral-embed"
|
||||
data-testid="embedding-provider-model-input"
|
||||
className="w-full mt-2 px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<CustomEmbeddingEndpointFields settings={settings} onSave={onSave} saving={saving} />
|
||||
</div>
|
||||
|
||||
@@ -43,11 +43,7 @@ export default function RerankConfigCard({ settings, providers, onSave, saving }
|
||||
}}
|
||||
disabled={saving || (!rerankEnabled && !hasProvider)}
|
||||
aria-disabled={saving || (!rerankEnabled && !hasProvider)}
|
||||
title={
|
||||
!rerankEnabled && !hasProvider
|
||||
? t("rerank.noProviderWithKey")
|
||||
: undefined
|
||||
}
|
||||
title={!rerankEnabled && !hasProvider ? t("rerank.noProviderWithKey") : undefined}
|
||||
role="switch"
|
||||
aria-checked={rerankEnabled}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
@@ -83,22 +79,45 @@ export default function RerankConfigCard({ settings, providers, onSave, saving }
|
||||
{t("rerank.noProviderWithKey")}
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={rerankProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="rerank-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("rerank.selectProviderModel")}</option>
|
||||
{rerankProviders.map((p) =>
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
)),
|
||||
)}
|
||||
</select>
|
||||
<>
|
||||
<select
|
||||
value={
|
||||
rerankProviders.some((p) => p.models.some((m) => m.id === rerankProviderModel))
|
||||
? rerankProviderModel
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="rerank-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("rerank.selectProviderModel")}</option>
|
||||
{rerankProviders.map((p) =>
|
||||
p.models.length > 0 ? (
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<optgroup key={p.provider} label={p.provider}>
|
||||
<option value="">{`— ${p.provider} (no curated models)`}</option>
|
||||
</optgroup>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
{/* Free-text override: any configured provider's Cohere-compatible
|
||||
model id is accepted by the runtime even without a curated entry. */}
|
||||
<input
|
||||
type="text"
|
||||
value={rerankProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder="provider/model — e.g. groq/my-reranker"
|
||||
data-testid="rerank-provider-model-input"
|
||||
className="w-full mt-2 px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function EngineTab() {
|
||||
const { status, isLoading: statusLoading } = useEngineStatus();
|
||||
const { settings, save: saveSettings, isLoading: settingsLoading } = useMemorySettings();
|
||||
const [providers, setProviders] = useState<EmbeddingProviderListing[]>([]);
|
||||
const [rerankProviders, setRerankProviders] = useState<EmbeddingProviderListing[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reindexing, setReindexing] = useState(false);
|
||||
const [reindexMsg, setReindexMsg] = useState("");
|
||||
@@ -32,6 +33,14 @@ export default function EngineTab() {
|
||||
if (!cancelled && data?.providers) setProviders(data.providers);
|
||||
})
|
||||
.catch(() => {});
|
||||
// Rerank has its own curated registry — the embedding listing does not
|
||||
// include rerank-only providers (cohere rerank SKUs, siliconflow, ...).
|
||||
fetch("/api/memory/rerank-providers")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (!cancelled && data?.providers) setRerankProviders(data.providers);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -139,7 +148,7 @@ export default function EngineTab() {
|
||||
</h3>
|
||||
<RerankConfigCard
|
||||
settings={settings}
|
||||
providers={providers}
|
||||
providers={rerankProviders}
|
||||
onSave={handleSaveSettings}
|
||||
saving={saving}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLiveComboStatus } from "@/hooks/useLiveDashboard";
|
||||
@@ -9,12 +9,49 @@ import { AgentsTab } from "./tabs/AgentsTab";
|
||||
import { RoutingTab } from "./tabs/RoutingTab";
|
||||
import { OverviewTab } from "./tabs/OverviewTab";
|
||||
import { OrchestrationDrawer } from "./drawer/OrchestrationDrawer";
|
||||
import { OrchestrationToolbar } from "./OrchestrationToolbar";
|
||||
import { collectProviderKeys, filterSnapshot } from "./model/filterSnapshot";
|
||||
import type { OrchFilter } from "./model/filterSnapshot";
|
||||
import { ORCH_STATES } from "./model/orchestrationTypes";
|
||||
import type { OrchSource, OrchState } from "./model/orchestrationTypes";
|
||||
|
||||
const TABS = ["agents", "routing", "overview"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
export default function OrchestrationPageClient() {
|
||||
const t = useTranslations("orchestration");
|
||||
const VALID_STATES: ReadonlySet<OrchState> = new Set(ORCH_STATES);
|
||||
const VALID_SOURCES: ReadonlySet<OrchSource> = new Set(["cloud-agent", "a2a", "conductor"]);
|
||||
|
||||
/** CSV → Set, dropping empty/invalid entries (`valid` omitted accepts any non-empty token). */
|
||||
function parseCsvSet<T extends string>(raw: string | null, valid?: ReadonlySet<T>): Set<T> {
|
||||
const out = new Set<T>();
|
||||
if (!raw) return out;
|
||||
for (const v of raw.split(",")) {
|
||||
if (!v) continue;
|
||||
if (!valid || valid.has(v as T)) out.add(v as T);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Toggle `value` in `current`, returning the next CSV (or `null` to drop the param). */
|
||||
function toggleCsv<T extends string>(current: ReadonlySet<T>, value: T): string | null {
|
||||
const next = new Set(current);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next.size > 0 ? [...next].sort().join(",") : null;
|
||||
}
|
||||
|
||||
const TAB_KEY: Record<Tab, string> = {
|
||||
agents: "tabAgents",
|
||||
routing: "tabRouting",
|
||||
overview: "tabOverview",
|
||||
};
|
||||
|
||||
/**
|
||||
* The page's entire URL state (tab / selected node / filters / collapsed groups) plus the
|
||||
* writer that patches it back into the query string. Pure derivation over
|
||||
* `useSearchParams` — no state of its own, so the URL stays the single source of truth.
|
||||
*/
|
||||
function useOrchUrlState() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useSearchParams();
|
||||
@@ -22,7 +59,11 @@ export default function OrchestrationPageClient() {
|
||||
const tab: Tab = (TABS as readonly string[]).includes(params.get("tab") ?? "")
|
||||
? (params.get("tab") as Tab)
|
||||
: "agents";
|
||||
const nodeId = params.get("node");
|
||||
const qParam = params.get("q") ?? "";
|
||||
const stateParam = params.get("state");
|
||||
const sourceParam = params.get("source");
|
||||
const providerParam = params.get("provider");
|
||||
const collapsedParam = params.get("collapsed");
|
||||
|
||||
const setParams = useCallback(
|
||||
(patch: Record<string, string | null>) => {
|
||||
@@ -33,69 +74,108 @@ export default function OrchestrationPageClient() {
|
||||
[params, pathname, router]
|
||||
);
|
||||
|
||||
const filter: OrchFilter = useMemo(
|
||||
() => ({
|
||||
q: qParam,
|
||||
states: parseCsvSet(stateParam, VALID_STATES),
|
||||
sources: parseCsvSet(sourceParam, VALID_SOURCES),
|
||||
providers: parseCsvSet<string>(providerParam),
|
||||
}),
|
||||
[qParam, stateParam, sourceParam, providerParam]
|
||||
);
|
||||
const collapsed = useMemo(() => parseCsvSet(collapsedParam, VALID_SOURCES), [collapsedParam]);
|
||||
|
||||
return { tab, nodeId: params.get("node"), filter, collapsed, setParams };
|
||||
}
|
||||
|
||||
/** The tab strip. Presentation only — selecting a tab writes it back to the URL. */
|
||||
function TabList({
|
||||
tab,
|
||||
t,
|
||||
onSelect,
|
||||
}: {
|
||||
tab: Tab;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
onSelect: (tab: Tab) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="tablist" className="flex gap-1 border-b border-border">
|
||||
{TABS.map((tb) => (
|
||||
<button
|
||||
key={tb}
|
||||
role="tab"
|
||||
aria-selected={tab === tb}
|
||||
className={`px-3 py-1.5 text-sm rounded-t ${tab === tb ? "border border-b-0 border-border bg-surface font-medium" : "text-muted"}`}
|
||||
onClick={() => onSelect(tb)}
|
||||
>
|
||||
{t(TAB_KEY[tb])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrchestrationPageClient() {
|
||||
const t = useTranslations("orchestration");
|
||||
const { tab, nodeId, filter, collapsed, setParams } = useOrchUrlState();
|
||||
|
||||
const { snapshot, showCompleted, setShowCompleted, refetch } = useOrchestrationSnapshot();
|
||||
const { comboEvents, activeCombos, isConnected } = useLiveComboStatus();
|
||||
const { providerHealth, connectionHealth } = useProviderBreakerHealth();
|
||||
|
||||
const filtered = useMemo(() => filterSnapshot(snapshot, filter), [snapshot, filter]);
|
||||
const providerKeys = useMemo(() => collectProviderKeys(snapshot), [snapshot]);
|
||||
|
||||
const onToggleCollapse = useCallback(
|
||||
(s: OrchSource) => setParams({ collapsed: toggleCsv(collapsed, s) }),
|
||||
[collapsed, setParams]
|
||||
);
|
||||
const closeDrawer = useCallback(() => setParams({ node: null }), [setParams]);
|
||||
|
||||
const selectedNode = nodeId ? (snapshot.nodes.find((n) => n.id === nodeId) ?? null) : null;
|
||||
const onNodeClick = (id: string) =>
|
||||
id.startsWith("overflow:")
|
||||
? setParams({ tab: "overview", node: null })
|
||||
: setParams({ node: id });
|
||||
|
||||
const TAB_KEY: Record<Tab, string> = {
|
||||
agents: "tabAgents",
|
||||
routing: "tabRouting",
|
||||
overview: "tabOverview",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100dvh-6rem)] min-h-[480px] p-4 gap-3">
|
||||
<div role="tablist" className="flex gap-1 border-b border-border">
|
||||
{TABS.map((tb) => (
|
||||
<button
|
||||
key={tb}
|
||||
role="tab"
|
||||
aria-selected={tab === tb}
|
||||
className={`px-3 py-1.5 text-sm rounded-t ${tab === tb ? "border border-b-0 border-border bg-surface font-medium" : "text-muted"}`}
|
||||
onClick={() => setParams({ tab: tb })}
|
||||
>
|
||||
{t(TAB_KEY[tb])}
|
||||
</button>
|
||||
))}
|
||||
<TabList tab={tab} t={t} onSelect={(tb) => setParams({ tab: tb })} />
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2">
|
||||
{(tab === "agents" || tab === "overview") && (
|
||||
<OrchestrationToolbar filter={filter} providerKeys={providerKeys} setParams={setParams} />
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{tab === "agents" && (
|
||||
<AgentsTab
|
||||
snapshot={filtered}
|
||||
onNodeClick={onNodeClick}
|
||||
showCompleted={showCompleted}
|
||||
onToggleCompleted={setShowCompleted}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
/>
|
||||
)}
|
||||
{tab === "routing" && (
|
||||
<RoutingTab
|
||||
comboEvents={comboEvents}
|
||||
combos={[...activeCombos]}
|
||||
isConnected={isConnected}
|
||||
providerHealth={providerHealth}
|
||||
connectionHealth={connectionHealth}
|
||||
/>
|
||||
)}
|
||||
{tab === "overview" && (
|
||||
<OverviewTab
|
||||
snapshot={filtered}
|
||||
comboEvents={comboEvents}
|
||||
onCardClick={(id) => setParams({ node: id })}
|
||||
onSeeInGraph={(id) => setParams({ tab: "agents", node: id })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
{tab === "agents" && (
|
||||
<AgentsTab
|
||||
snapshot={snapshot}
|
||||
onNodeClick={onNodeClick}
|
||||
showCompleted={showCompleted}
|
||||
onToggleCompleted={setShowCompleted}
|
||||
/>
|
||||
)}
|
||||
{tab === "routing" && (
|
||||
<RoutingTab
|
||||
comboEvents={comboEvents}
|
||||
combos={[...activeCombos]}
|
||||
isConnected={isConnected}
|
||||
providerHealth={providerHealth}
|
||||
connectionHealth={connectionHealth}
|
||||
/>
|
||||
)}
|
||||
{tab === "overview" && (
|
||||
<OverviewTab
|
||||
snapshot={snapshot}
|
||||
comboEvents={comboEvents}
|
||||
onCardClick={(id) => setParams({ node: id })}
|
||||
onSeeInGraph={(id) => setParams({ tab: "agents", node: id })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<OrchestrationDrawer
|
||||
node={selectedNode}
|
||||
onClose={() => setParams({ node: null })}
|
||||
onActionDone={refetch}
|
||||
/>
|
||||
<OrchestrationDrawer node={selectedNode} onClose={closeDrawer} onActionDone={refetch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
/**
|
||||
* Search input + filter chips for the Agents/Overview tabs — pure presentation over the URL
|
||||
* params owned by OrchestrationPageClient (`q`/`state`/`source`/`provider`). No filtering logic
|
||||
* lives here; it renders `filter` (an `OrchFilter` already parsed from the URL) and calls
|
||||
* `setParams` to mutate it. Spec: task-a6-brief.md (2.3+2.4).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { isEmptyFilter } from "./model/filterSnapshot";
|
||||
import type { OrchFilter } from "./model/filterSnapshot";
|
||||
import { ORCH_STATES } from "./model/orchestrationTypes";
|
||||
import type { OrchSource, OrchState } from "./model/orchestrationTypes";
|
||||
|
||||
const SOURCES = ["cloud-agent", "a2a", "conductor"] as const satisfies readonly OrchSource[];
|
||||
|
||||
const STATE_KEY: Record<OrchState, string> = {
|
||||
queued: "stateQueued",
|
||||
running: "stateRunning",
|
||||
waiting_approval: "stateWaitingApproval",
|
||||
succeeded: "stateSucceeded",
|
||||
failed: "stateFailed",
|
||||
cancelled: "stateCancelled",
|
||||
};
|
||||
const SOURCE_KEY: Record<(typeof SOURCES)[number], string> = {
|
||||
"cloud-agent": "sourceCloudAgent",
|
||||
a2a: "sourceA2A",
|
||||
conductor: "sourceConductor",
|
||||
};
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Toggle `value` in `current`, returning the next CSV (or `null` to drop the param). */
|
||||
function toggleCsv<T extends string>(current: ReadonlySet<T>, value: T): string | null {
|
||||
const next = new Set(current);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next.size > 0 ? [...next].sort().join(",") : null;
|
||||
}
|
||||
|
||||
const chipClass = (active: boolean) =>
|
||||
`text-[10px] px-2 py-0.5 rounded-full border whitespace-nowrap ${
|
||||
active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted"
|
||||
}`;
|
||||
|
||||
/**
|
||||
* One labeled row of toggle chips (states / sources / providers). Pure presentation:
|
||||
* `active` drives the pressed style + `aria-pressed`, `onToggle` writes the URL param
|
||||
* upstream. Extracted so the toolbar itself stays under the max-lines ratchet.
|
||||
*/
|
||||
function ChipGroup<T extends string>({
|
||||
label,
|
||||
values,
|
||||
active,
|
||||
renderLabel,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
values: readonly T[];
|
||||
active: ReadonlySet<T>;
|
||||
renderLabel: (value: T) => string;
|
||||
onToggle: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-[10px] text-muted">{label}</span>
|
||||
{values.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={chipClass(active.has(v))}
|
||||
aria-pressed={active.has(v)}
|
||||
onClick={() => onToggle(v)}
|
||||
>
|
||||
{renderLabel(v)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrchestrationToolbar({
|
||||
filter,
|
||||
providerKeys,
|
||||
setParams,
|
||||
}: {
|
||||
filter: OrchFilter;
|
||||
providerKeys: string[];
|
||||
setParams: (patch: Record<string, string | null>) => void;
|
||||
}) {
|
||||
const t = useTranslations("orchestration");
|
||||
const [text, setText] = useState(filter.q);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (v: string) => {
|
||||
setText(v);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setParams({ q: v || null }), SEARCH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setText("");
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setParams({ q: null, state: null, source: null, provider: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-surface px-3 py-2">
|
||||
<input
|
||||
type="search"
|
||||
value={text}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="text-xs px-2 py-1 rounded border border-border bg-transparent min-w-[160px]"
|
||||
/>
|
||||
<ChipGroup
|
||||
label={t("filterStates")}
|
||||
values={ORCH_STATES}
|
||||
active={filter.states}
|
||||
renderLabel={(s) => t(STATE_KEY[s])}
|
||||
onToggle={(s) => setParams({ state: toggleCsv(filter.states, s) })}
|
||||
/>
|
||||
<ChipGroup
|
||||
label={t("filterSources")}
|
||||
values={SOURCES}
|
||||
active={filter.sources}
|
||||
renderLabel={(s) => t(SOURCE_KEY[s])}
|
||||
onToggle={(s) => setParams({ source: toggleCsv(filter.sources, s) })}
|
||||
/>
|
||||
{providerKeys.length > 0 && (
|
||||
<ChipGroup
|
||||
label={t("filterProviders")}
|
||||
values={providerKeys}
|
||||
active={filter.providers}
|
||||
renderLabel={(p) => p}
|
||||
onToggle={(p) => setParams({ provider: toggleCsv(filter.providers, p) })}
|
||||
/>
|
||||
)}
|
||||
{!isEmptyFilter(filter) && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] underline text-muted ml-auto"
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t("clearFilters")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,35 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { StatusDot } from "@/shared/components/flow/StatusDot";
|
||||
import { orchStateColor, type OrchNode, type OrchState } from "../model/orchestrationTypes";
|
||||
import { useDrawerDetail } from "./useDrawerDetail";
|
||||
import type { DrawerError } from "./useDrawerDetail";
|
||||
import type { CloudAgentTask } from "@/lib/cloudAgent/types";
|
||||
import type { A2ATask } from "@/lib/a2a/taskManager";
|
||||
|
||||
const TOAST_MS = 2500;
|
||||
|
||||
/** Timeline normalized by source — the same data the Timeline component displays. */
|
||||
function normalizedTimeline(node: OrchNode, detail: unknown): unknown {
|
||||
if (node.source === "cloud-agent") return (detail as CloudAgentTask | null)?.activities ?? [];
|
||||
if (node.source === "a2a") return (detail as A2ATask | null)?.events ?? [];
|
||||
return null; // conductor/overflow: the raw payload already is the trace
|
||||
}
|
||||
|
||||
/** Builds the copy-to-clipboard JSON payload for the drawer's "copy trace" action. */
|
||||
export function buildTraceJson(node: OrchNode, detail: unknown): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
node: { id: node.id, source: node.source, state: node.state, label: node.label },
|
||||
timeline: normalizedTimeline(node, detail),
|
||||
raw: detail ?? node.raw ?? null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>;
|
||||
|
||||
const STATE_KEY: Record<OrchState, string> = {
|
||||
@@ -72,18 +95,30 @@ function Timeline({ node, detail }: { node: OrchNode; detail: unknown }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Header row: status dot, label/source/state, close button. */
|
||||
/** Header row: status dot, label/source/state, copy-trace + close buttons. */
|
||||
function DrawerHeader({
|
||||
node,
|
||||
detail,
|
||||
state,
|
||||
t,
|
||||
onClose,
|
||||
onToast,
|
||||
}: {
|
||||
node: OrchNode;
|
||||
detail: unknown;
|
||||
state: OrchState;
|
||||
t: Translate;
|
||||
onClose: () => void;
|
||||
onToast: (text: string) => void;
|
||||
}) {
|
||||
const copyTrace = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildTraceJson(node, detail));
|
||||
onToast(t("actionDone"));
|
||||
} catch {
|
||||
onToast(t("actionFailed", { error: "clipboard" }));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<StatusDot
|
||||
@@ -97,13 +132,78 @@ function DrawerHeader({
|
||||
{node.source} · {t(STATE_KEY[state])}
|
||||
</div>
|
||||
</div>
|
||||
<button className="ml-auto text-muted" onClick={onClose} aria-label="close">
|
||||
<button className="ml-auto text-muted" onClick={copyTrace} aria-label={t("copyTrace")}>
|
||||
⧉
|
||||
</button>
|
||||
<button className="text-muted" onClick={onClose} aria-label={t("drawerClose")}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows the loaded detail payload to the typed shape of the node's source — the
|
||||
* non-matching one is always `null`, so each section can read its own shape safely.
|
||||
*/
|
||||
function narrowDetail(
|
||||
node: OrchNode,
|
||||
detail: unknown
|
||||
): { ca: CloudAgentTask | null; a2a: A2ATask | null } {
|
||||
return {
|
||||
ca: node.source === "cloud-agent" ? (detail as CloudAgentTask | null) : null,
|
||||
a2a: node.source === "a2a" ? (detail as A2ATask | null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Objective section: the agent prompt / first A2A message, falling back to the node labels. */
|
||||
function DrawerObjective({
|
||||
node,
|
||||
ca,
|
||||
a2a,
|
||||
t,
|
||||
}: {
|
||||
node: OrchNode;
|
||||
ca: CloudAgentTask | null;
|
||||
a2a: A2ATask | null;
|
||||
t: Translate;
|
||||
}) {
|
||||
return (
|
||||
<Section title={t("drawerObjective")}>
|
||||
<p className="text-xs break-words">
|
||||
{ca?.prompt ?? a2a?.input?.messages[0]?.content ?? node.sublabel ?? node.label}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Transient banners above the sections: toast, load/action error, loading placeholder. */
|
||||
function DrawerBanners({
|
||||
toast,
|
||||
error,
|
||||
errorKind,
|
||||
isLoading,
|
||||
t,
|
||||
}: {
|
||||
toast: string | null;
|
||||
error: string | null;
|
||||
errorKind: DrawerError["kind"] | null;
|
||||
isLoading: boolean;
|
||||
t: Translate;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{toast && <div className="text-xs text-success mb-3">{toast}</div>}
|
||||
{error && (
|
||||
<div className="text-xs text-error mb-3">
|
||||
{t(errorKind === "detail" ? "detailFailed" : "actionFailed", { error })}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <div className="text-xs text-muted mb-3">…</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cost/duration metrics section — omitted entirely when neither value is present. */
|
||||
function DrawerMetrics({
|
||||
node,
|
||||
@@ -160,37 +260,46 @@ function DrawerResult({
|
||||
function DrawerActions({
|
||||
canApprove,
|
||||
canCancel,
|
||||
busy,
|
||||
approve,
|
||||
cancel,
|
||||
onActionDone,
|
||||
onToast,
|
||||
t,
|
||||
}: {
|
||||
canApprove: boolean;
|
||||
canCancel: boolean;
|
||||
busy: boolean;
|
||||
approve: () => Promise<boolean>;
|
||||
cancel: () => Promise<boolean>;
|
||||
onActionDone: () => void;
|
||||
onToast: (text: string) => void;
|
||||
t: Translate;
|
||||
}) {
|
||||
if (!canApprove && !canCancel) return null;
|
||||
const run = async (fn: () => Promise<boolean>) => {
|
||||
if (await fn()) onActionDone();
|
||||
if (await fn()) {
|
||||
onActionDone();
|
||||
onToast(t("actionDone"));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Section title={t("drawerActions")}>
|
||||
<div className="flex gap-2">
|
||||
{canApprove && (
|
||||
<button
|
||||
className="text-xs rounded border border-success px-2 py-1"
|
||||
className="text-xs rounded border border-success px-2 py-1 disabled:opacity-50"
|
||||
onClick={() => run(approve)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("actionApprove")}
|
||||
</button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button
|
||||
className="text-xs rounded border border-error px-2 py-1"
|
||||
className="text-xs rounded border border-error px-2 py-1 disabled:opacity-50"
|
||||
onClick={() => run(cancel)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("actionCancel")}
|
||||
</button>
|
||||
@@ -200,14 +309,42 @@ function DrawerActions({
|
||||
);
|
||||
}
|
||||
|
||||
/** Closes the drawer on Escape while `node` is set. */
|
||||
/** Closes the drawer on Escape while `node` is set. Rebinds by id, not by object
|
||||
* identity, so a fresh `node` reference for the same task (e.g. a refetch) does not
|
||||
* tear down and re-add the listener. */
|
||||
function useCloseOnEscape(node: OrchNode | null, onClose: () => void) {
|
||||
const nodeId = node?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
if (!nodeId) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [node, onClose]);
|
||||
}, [nodeId, onClose]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local, self-clearing toast state. `showToast` starts the timer synchronously in the
|
||||
* same handler that sets the message (button onClick / async action callback) — never
|
||||
* inside an effect body — so the only thing the unmount effect does is clear a pending
|
||||
* timer, with no setState call of its own (keeps `react-hooks/set-state-in-effect` clean).
|
||||
*/
|
||||
function useDrawerToast() {
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const showToast = (text: string) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setToast(text);
|
||||
timerRef.current = setTimeout(() => setToast(null), TOAST_MS);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { toast, showToast };
|
||||
}
|
||||
|
||||
export function OrchestrationDrawer({
|
||||
@@ -220,14 +357,14 @@ export function OrchestrationDrawer({
|
||||
onActionDone: () => void;
|
||||
}) {
|
||||
const t = useTranslations("orchestration");
|
||||
const { detail, isLoading, error, canApprove, canCancel, approve, cancel } =
|
||||
const { detail, isLoading, busy, error, errorKind, canApprove, canCancel, approve, cancel } =
|
||||
useDrawerDetail(node);
|
||||
useCloseOnEscape(node, onClose);
|
||||
const { toast, showToast } = useDrawerToast();
|
||||
|
||||
if (!node) return null;
|
||||
const state = node.state ?? "queued";
|
||||
const ca = node.source === "cloud-agent" ? (detail as CloudAgentTask | null) : null;
|
||||
const a2a = node.source === "a2a" ? (detail as A2ATask | null) : null;
|
||||
const { ca, a2a } = narrowDetail(node, detail);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -237,16 +374,24 @@ export function OrchestrationDrawer({
|
||||
role="dialog"
|
||||
aria-label={node.label}
|
||||
>
|
||||
<DrawerHeader node={node} state={state} t={t} onClose={onClose} />
|
||||
<DrawerHeader
|
||||
node={node}
|
||||
detail={detail}
|
||||
state={state}
|
||||
t={t}
|
||||
onClose={onClose}
|
||||
onToast={showToast}
|
||||
/>
|
||||
|
||||
{error && <div className="text-xs text-error mb-3">{t("actionFailed", { error })}</div>}
|
||||
{isLoading && <div className="text-xs text-muted mb-3">…</div>}
|
||||
<DrawerBanners
|
||||
toast={toast}
|
||||
error={error}
|
||||
errorKind={errorKind}
|
||||
isLoading={isLoading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<Section title={t("drawerObjective")}>
|
||||
<p className="text-xs break-words">
|
||||
{ca?.prompt ?? a2a?.input?.messages[0]?.content ?? node.sublabel ?? node.label}
|
||||
</p>
|
||||
</Section>
|
||||
<DrawerObjective node={node} ca={ca} a2a={a2a} t={t} />
|
||||
<Section title={t("drawerTimeline")}>
|
||||
<Timeline node={node} detail={detail} />
|
||||
</Section>
|
||||
@@ -255,9 +400,11 @@ export function OrchestrationDrawer({
|
||||
<DrawerActions
|
||||
canApprove={canApprove}
|
||||
canCancel={canCancel}
|
||||
busy={busy}
|
||||
approve={approve}
|
||||
cancel={cancel}
|
||||
onActionDone={onActionDone}
|
||||
onToast={showToast}
|
||||
t={t}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user