mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-04 14:02:12 +03:00
Compare commits
18 Commits
refactor/v
...
fix/v3850-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4a903ac3 | ||
|
|
9d04995950 | ||
|
|
abbbca216d | ||
|
|
fe5f4b0ef9 | ||
|
|
c818655b5a | ||
|
|
9629d78ece | ||
|
|
0ff164701d | ||
|
|
f301d34ce5 | ||
|
|
d920e6495a | ||
|
|
7f008ed09a | ||
|
|
e3c440e804 | ||
|
|
d898d1a913 | ||
|
|
ad54249c54 | ||
|
|
eeba382049 | ||
|
|
412298b624 | ||
|
|
b574f0d14e | ||
|
|
31d3702a37 | ||
|
|
cb623b3cef |
48
.env.example
48
.env.example
@@ -1470,17 +1470,25 @@ 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.
|
||||
# ── ChatGPT browser transport (Firefox-fingerprinted client) ──
|
||||
# Used by: open-sse/services/chatgptTlsClient.ts — native wreq-js request
|
||||
# timeout and the JS-side hard-deadline grace layered on top of it.
|
||||
# OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS=60000
|
||||
# OMNIROUTE_CHATGPT_TLS_GRACE_MS=10000
|
||||
# Max wait for the FIRST streamed byte before switching from direct streaming
|
||||
# to a buffered response, in milliseconds. Default 30000 (30s). The request's
|
||||
# hard deadline continues to apply while the buffered body is read.
|
||||
# OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS=30000
|
||||
|
||||
# ── Claude browser transport (Chromium-fingerprinted client) ──
|
||||
# Used by: open-sse/services/claudeTlsClient.ts — native wreq-js request
|
||||
# timeout and the JS-side hard-deadline grace layered on top of it.
|
||||
# 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 browser transport (Firefox-fingerprinted client) ──
|
||||
# Used by: open-sse/services/perplexityTlsClient.ts — native wreq-js request
|
||||
# timeout and the JS-side hard-deadline grace layered on top of it.
|
||||
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
|
||||
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
|
||||
|
||||
@@ -1492,18 +1500,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 browser transport (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/grokTlsClient.ts — native wreq-js request timeout
|
||||
# and the JS-side hard-deadline grace layered on top of it.
|
||||
# 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 browser transport (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/notionTlsClient.ts — native wreq-js request timeout
|
||||
# and the JS-side hard-deadline grace layered on top of it. 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
|
||||
|
||||
@@ -2467,14 +2473,6 @@ APP_LOG_TO_FILE=true
|
||||
# When enabled, the node authenticates with the API key stored on its connection.
|
||||
# AUDIO_REMOTE_PROVIDER_NODES=false
|
||||
|
||||
# ── 1Proxy egress pool ──
|
||||
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
|
||||
# CrofAI 1Proxy service. Disable, override URL, or tune the import quality.
|
||||
# ONEPROXY_ENABLED=true
|
||||
# ONEPROXY_API_URL=https://1proxy-api.aitradepulse.com
|
||||
# ONEPROXY_MAX_PROXIES=500
|
||||
# ONEPROXY_MIN_QUALITY_THRESHOLD=50
|
||||
|
||||
# ── Free Proxy Pool (auto-sync scheduler) ──
|
||||
# Background refresh of the free-proxy pool. Opt-in, OFF by default (parallels
|
||||
# Hard Rule #20's default-off posture for data-mutating background features).
|
||||
|
||||
2
.github/workflows/electron-release.yml
vendored
2
.github/workflows/electron-release.yml
vendored
@@ -237,7 +237,7 @@ jobs:
|
||||
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
|
||||
# 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
|
||||
|
||||
@@ -73,7 +73,7 @@ npm run dev
|
||||
npm run build # next build → .build/next/ then assembleStandalone → dist/
|
||||
npm run start
|
||||
|
||||
# Fast backend/API-only build for contributor changes
|
||||
# Fast backend/API-only compile for contributor changes
|
||||
npm run build:contributor
|
||||
|
||||
# Release build (clean rebuild + HEAD sentinel — required for deploy)
|
||||
@@ -83,6 +83,10 @@ npm run build:release # rm -rf .build dist && build + writes dist/BUILD_SHA
|
||||
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
||||
```
|
||||
|
||||
The contributor build performs compile-only validation: it does not assemble the standalone
|
||||
distribution or build optional native packaging assets. Use the regular production build when
|
||||
you need to validate the shippable bundle.
|
||||
|
||||
### Build Output Layout
|
||||
|
||||
| Directory | Contents | Tracked |
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -103,25 +103,11 @@ 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 "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
|
||||
FROM oven/bun:1.3.14-slim AS base
|
||||
FROM oven/bun:1.4.0-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -31,9 +31,9 @@ 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; \
|
||||
# Compile native better-sqlite3 Node-API addon under Bun
|
||||
RUN if [ -d "node_modules/better-sqlite3" ]; then \
|
||||
(cd node_modules/better-sqlite3 && bunx node-gyp rebuild); \
|
||||
fi
|
||||
|
||||
# Smoke check native database driver used by Bun (bun:sqlite)
|
||||
@@ -58,7 +58,7 @@ ENV NODE_ENV=production
|
||||
RUN bun run --quiet build
|
||||
|
||||
# ── Runner Base stage (100% Bun Native Production Runtime) ──────────────────
|
||||
FROM oven/bun:1.3.14-slim AS runner-base
|
||||
FROM oven/bun:1.4.0-slim AS runner-base
|
||||
|
||||
LABEL org.opencontainers.image.title="omniroute" \
|
||||
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \
|
||||
|
||||
@@ -25,6 +25,31 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FO
|
||||
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.
|
||||
|
||||
## wreq-js 3.0.0
|
||||
|
||||
OmniRoute distributes `wreq-js` and its seven platform-specific native addons from
|
||||
[`wreq-js@3.0.0`](https://www.npmjs.com/package/wreq-js/v/3.0.0).
|
||||
|
||||
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.
|
||||
|
||||
## blackwell-systems/gcf-typescript
|
||||
|
||||
The generic-profile codec in
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(sse):** trust `finish_reason: "length"`/`"max_tokens"` over the reasoning-consumed-token ratio in response quality validation, so a reasoning model truncated below the old 90% threshold correctly fails and retries instead of returning empty content as a silent "success" ([#12262](https://github.com/diegosouzapw/OmniRoute/pull/12262))
|
||||
1
changelog.d/maintenance/11753-wreq-js-transport.md
Normal file
1
changelog.d/maintenance/11753-wreq-js-transport.md
Normal file
@@ -0,0 +1 @@
|
||||
- **chore(stealth):** replace the `tls-client-node` sidecar/temp-file transport used by the six web-cookie providers with the exactly pinned `wreq-js` 3.0.0 native transport, preserving streaming, proxy isolation, deadlines, EOF policies, binary responses, and cancellation while removing the obsolete downloader and native repair path ([#11753](https://github.com/diegosouzapw/OmniRoute/pull/11753)).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@dnd-kit/core",
|
||||
"@dnd-kit/sortable",
|
||||
"@dnd-kit/utilities",
|
||||
"@eslint/compat",
|
||||
"@huggingface/transformers",
|
||||
"@lobehub/icons",
|
||||
"@modelcontextprotocol/sdk",
|
||||
@@ -65,6 +66,7 @@
|
||||
"eslint-config-next",
|
||||
"eslint-plugin-react-hooks",
|
||||
"eslint-plugin-sonarjs",
|
||||
"espree",
|
||||
"express",
|
||||
"fast-check",
|
||||
"fetch-socks",
|
||||
@@ -137,7 +139,6 @@
|
||||
"tailwind-merge",
|
||||
"tailwindcss",
|
||||
"tiktoken",
|
||||
"tls-client-node",
|
||||
"tsup",
|
||||
"tsx",
|
||||
"turndown",
|
||||
|
||||
@@ -297,11 +297,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"open-sse/handlers/videoGeneration/openai.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"open-sse/mcp-server/__tests__/a2aLifecycle.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -806,11 +801,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"open-sse/utils/streamPayloadCollector.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"open-sse/utils/usageTracking.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -1051,11 +1041,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/settings/components/ModelAliasesTab.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -1086,11 +1071,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/settings/components/useProxyPoolModal.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 3
|
||||
@@ -1121,11 +1101,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -1409,11 +1384,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/docs/components/FeedbackWidget.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/login/page.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
@@ -1519,11 +1489,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/lib/credentialHealth/scheduler.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/db/apiKeys.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
@@ -1975,11 +1940,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/shared/components/PricingModal.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/ProxyLogDetail.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -2013,26 +1973,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/docs/CodeBlock.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/docs/DocsBreadcrumbs.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/docs/DocsSidebar.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/docs/DocsThemeProvider.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/shared/constants/agentSkills.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -3130,9 +3070,6 @@
|
||||
"tests/unit/cli-nodes-commands.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 15
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"tests/unit/cli-oauth-commands.test.ts": {
|
||||
@@ -5116,11 +5053,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"tests/unit/stream-payload-collector.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"tests/unit/stream-prompt-tokens-zero-upstream.test.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -5265,11 +5197,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"tests/unit/translator-antigravity-to-openai.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"tests/unit/translator-claude-helper-thinking.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 21
|
||||
|
||||
@@ -104,8 +104,9 @@
|
||||
"_rebaseline_2026_07_28_v3849_release": "75.5 -> 99 (+23.5). Aperto EXIGIDO pelo modo --require-tighten do ratchet: a métrica melhorou de verdade no ciclo v3.8.49. A causa é o workflow assíncrono de tradução, que finalmente alcançou o denominador em EN — as rebaselines anteriores (v3.8.39/.44/.47) foram todas afrouxamentos registrando o atraso das traduções, e agora ele foi pago. O coletor SUBTRAI os placeholders (present - placeholder em scripts/quality/collect-metrics.mjs), então os 317 marcadores __MISSING__ que esta release introduziu para o drift de valor já estão descontados dos 99 — o número é honesto, não inflado por placeholder. Medido pelo collect-metrics do CI no run 30404226939."
|
||||
},
|
||||
"deadExports": {
|
||||
"value": 500,
|
||||
"value": 377,
|
||||
"direction": "down",
|
||||
"_tighten_2026_09_01_pr_11950_rebase": "500 -> 377. Measured on PR #11950 after rebasing its unreachable-code cleanup onto release/v3.8.51 tip 4bcd8cee99a0: DEAD_EXPORTS=347 + DEAD_FILES=30 = DEAD_TOTAL=377 via node scripts/check/check-dead-code.mjs.",
|
||||
"_rebaseline_2026_08_19_v3850_basereds_9985": "415 -> 416. Measured on release/v3.8.50 tip 14a480453 during the #9985 base-red drain. Removed the 2 genuinely-dead symbols traced to a specific recent change (PR #10148, 2026-08-18): the unused src/lib/quota/providerCapabilities.ts file and the unused ProviderQuotaMonitor interface in providerQuotaTelemetry.ts (418 -> 416). The remaining +1 could not be attributed to a single recent commit after checking every dead-list entry touched since the 2026-08-14 baseline measurement (most are pre-existing debt on files edited for unrelated reasons); rebaselining the residual 1 rather than guessing at removals. Structural cleanup stays tracked in #3501.",
|
||||
"_rebaseline_2026_08_09_v3850_post_sweep": "227 -> 230. Measured by npm run check:dead-code on the unmodified release/v3.8.50 tip 382449d593 during the mandatory --full-ci pre-flight. The +3 is inherited cycle drift from the authorized merge sweep; this repair adds no production exports. Rebaseline records the actual tip so ci.yml quality-gate can run, while structural cleanup remains separate debt.",
|
||||
"_rebaseline_2026_07_01_v3843_release": "225->227 (+2). v3.8.43 cycle drift, surfaced in the Quality Ratchet job after eslintWarnings was rebaselined (check:dead-code runs there). 227 = measured by check:dead-code (knip) on the release tip 4635076eb. The 5 CI fixes add 0 dead exports: safeHttpHref in linkify.ts is module-local AND used (called by linkifyText); no new exports; test files are not scanned. Tighten via --update next cycle.",
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"_comment": "Catraca de test-discovery (check-test-discovery.mjs). Cada entrada e um arquivo de teste que NENHUM runner coleta (ele nunca roda) — divida congelada na auditoria 6A.1 (2026-06-09; 195 originais, 135 religados no node runner em 6A.1c). So pode DIMINUIR: religue o teste (ajustando o glob do runner ou movendo o arquivo) e remova a entrada via --update. NAO adicione novos orfaos — corrija o runner.",
|
||||
"_remaining_13": "13 orfaos restantes: 2 testes de API em settings + 1 snapshot de quota do DB; 4 golden-set + 1 benchmark + 1 teste live + 1 stress (deliberadamente manuais — decidir runner/gating); 3 integration/services (gated RUN_SERVICES_INT=1, sem runner CI).",
|
||||
"_remaining_10": "10 orfaos restantes: 4 golden-set + 1 benchmark + 1 teste live + 1 stress (deliberadamente manuais — decidir runner/gating); 3 integration/services (gated RUN_SERVICES_INT=1, sem runner CI).",
|
||||
"orphans": [
|
||||
"src/app/api/settings/__tests__/memory.test.ts",
|
||||
"src/app/api/settings/__tests__/settings.test.ts",
|
||||
"src/lib/db/__tests__/quotaSnapshots.test.ts",
|
||||
"tests/benchmarks/pipeline-accuracy.test.ts",
|
||||
"tests/golden-set/compression-caveman-v2.test.ts",
|
||||
"tests/golden-set/compression-quality.test.ts",
|
||||
|
||||
44
config/release/wreq-js-native-manifest.json
Normal file
44
config/release/wreq-js-native-manifest.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"package": "wreq-js",
|
||||
"version": "3.0.0",
|
||||
"source": "https://registry.npmjs.org/wreq-js/-/wreq-js-3.0.0.tgz",
|
||||
"npmIntegrity": "sha512-RZCoRSevVPpH4A4B4MxbFGo/pVPFveWd2gbe4ENKpPWlKXEYklZSDESOjBMmrIsmnkHh+nhM4PNJvG+NL7wBPA==",
|
||||
"license": "MIT",
|
||||
"nativeAddons": [
|
||||
{
|
||||
"path": "rust/wreq-js.darwin-arm64.node",
|
||||
"size": 7690880,
|
||||
"sha256": "c82eec39df691adb94f2cd09a8ff51335de8587cf132cd8b3ec797469a4b5002"
|
||||
},
|
||||
{
|
||||
"path": "rust/wreq-js.darwin-x64.node",
|
||||
"size": 8192028,
|
||||
"sha256": "073b8a8a4c26aedbce7c14eef3e5567918e62e8dbf4d28296b23f9d2beec2981"
|
||||
},
|
||||
{
|
||||
"path": "rust/wreq-js.linux-arm64-gnu.node",
|
||||
"size": 8520824,
|
||||
"sha256": "861d96a78caf7ce02c9ae8d37f1c59f5b0480e3142775c32917fcfe9b88524b0"
|
||||
},
|
||||
{
|
||||
"path": "rust/wreq-js.linux-arm64-musl.node",
|
||||
"size": 8735472,
|
||||
"sha256": "2409a3578c8c440df419b4d5abe3ac149bec48881611a6dc1571b95e6246552d"
|
||||
},
|
||||
{
|
||||
"path": "rust/wreq-js.linux-x64-gnu.node",
|
||||
"size": 9048992,
|
||||
"sha256": "55b40f4602c52111dfcdcc93db83f9d0de55d0ef7540348757709d58d05a9b64"
|
||||
},
|
||||
{
|
||||
"path": "rust/wreq-js.linux-x64-musl.node",
|
||||
"size": 8974880,
|
||||
"sha256": "bd52d15b1bb4704b11561a8aa95648a6c91150082b5af0e39dd1608b7db2d317"
|
||||
},
|
||||
{
|
||||
"path": "rust/wreq-js.win32-x64-msvc.node",
|
||||
"size": 7967232,
|
||||
"sha256": "7451a8701b82c946b03ba2be2f15257260a250b9e0ed9910611b22564fbec7a9"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -315,7 +315,6 @@ Top-level files in `src/lib/`:
|
||||
|
||||
- The old `localDb.ts` barrel was removed — consumers import specific `src/lib/db/*` modules directly.
|
||||
- `proxyHealth.ts`, `proxyLogger.ts`, `tokenHealthCheck.ts`, `localHealthCheck.ts`
|
||||
- `oneproxyRotator.ts`, `oneproxySync.ts`
|
||||
- `apiBridgeServer.ts`, `cacheLayer.ts`, `semanticCache.ts`, `settingsCache.ts`
|
||||
- `cloudSync.ts`, `initCloudSync.ts`
|
||||
- `cloudflaredTunnel.ts`, `ngrokTunnel.ts`, `tailscaleTunnel.ts`
|
||||
@@ -422,12 +421,12 @@ Split into focused subdirectories:
|
||||
`bodySize.ts`, `colors.ts`, `appConfig.ts`, `config.ts`,
|
||||
`sidebarVisibility.ts`, `visionBridgeDefaults.ts`.
|
||||
- `validation/` — `schemas.ts` (~80 Zod schemas), `compressionConfigSchemas.ts`,
|
||||
`oneproxySchemas.ts`, `providerSchema.ts`, `settingsSchemas.ts`, `helpers.ts`.
|
||||
`providerSchema.ts`, `settingsSchemas.ts`, `helpers.ts`.
|
||||
- `contracts/` — public API contracts shipped to npm.
|
||||
- `types/` — shared TS types.
|
||||
- `utils/` — `circuitBreaker.ts`, `apiAuth.ts`, `apiKey.ts`, `apiKeyPolicy.ts`,
|
||||
`apiResponse.ts`, `api.ts`, `classify429.ts`, `cliCompat.ts`, `clipboard.ts`,
|
||||
`cloud.ts`, `cn.ts`, `cors.ts`, `costEstimator.ts`, `featureFlags.ts`,
|
||||
`api.ts`, `classify429.ts`, `cliCompat.ts`, `clipboard.ts`, `cloud.ts`, `cn.ts`,
|
||||
`cors.ts`, `featureFlags.ts`,
|
||||
`fetchTimeout.ts`, `formatting.ts`, `inputSanitizer.ts`, `logger.ts`,
|
||||
`machine.ts`, `machineId.ts`, `maskEmail.ts`, `modelCatalogSearch.ts`,
|
||||
`nodeRuntimeSupport.ts`, `parseApiKeys.ts`, `providerHints.ts`,
|
||||
|
||||
@@ -201,7 +201,6 @@ src/
|
||||
| `playground/` | Playground Studio shared helpers: `codeExport.ts` (curl/Python/TS generator), `promptImprover.ts` (meta-prompt builder), `streamMetrics.ts` (pure TTFT/TPS), `types.ts` (pricing table) — see `docs/frameworks/PLAYGROUND_STUDIO.md` |
|
||||
| `webhookDispatcher.ts` | HMAC webhook delivery — see `docs/frameworks/WEBHOOKS.md` |
|
||||
| `cloudflaredTunnel.ts`, `ngrokTunnel.ts` | Tunnel managers — see `docs/ops/TUNNELS_GUIDE.md` |
|
||||
| `oneproxySync.ts`, `oneproxyRotator.ts` | 1proxy free proxy marketplace — see `docs/ops/PROXY_GUIDE.md` |
|
||||
| `cloudSync.ts`, `initCloudSync.ts` | Optional cloud sync of state |
|
||||
| `localDb.ts` | Re-export barrel for db modules (no logic — re-exports only) |
|
||||
| `cacheLayer.ts`, `idempotencyLayer.ts` | Request caching + idempotency |
|
||||
|
||||
@@ -182,22 +182,6 @@ With Stacked: 10K-2.5K tokens sent (78-95% eligible RTK+Caveman range
|
||||
|
||||
---
|
||||
|
||||
## Output Styles
|
||||
|
||||
Output styles inject a system prompt instruction to steer the model's writing style. They are defined in the output style catalog and support multiple languages and intensity levels (`lite`, `full`, `ultra`).
|
||||
|
||||
| Style | Description | Supported Languages | Levels |
|
||||
| --- | --- | --- | --- |
|
||||
| `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. | `en`, `pt-BR`, `ja`, `id`, `vi` | `lite`, `full`, `ultra` |
|
||||
| `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` |
|
||||
| `ponytail` | Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` |
|
||||
| `i-have-adhd` | Action-first output: next action leads, steps numbered, one concrete next step, no preamble. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` |
|
||||
| `terse-cjk` | Classical-Chinese ultra-terse style (locale-gated to zh). | `zh` | `lite`, `full`, `ultra` |
|
||||
|
||||
Each level appends a shared boundary clause ensuring that code blocks, URLs, file paths, commands, and identifiers remain verbatim.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Dashboard
|
||||
@@ -472,11 +456,11 @@ together and are injected in catalog order.
|
||||
|
||||
| Style | `id` | What it does | Instruction languages |
|
||||
| --- | --- | --- | --- |
|
||||
| Terse prose | `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. Same text as the legacy caveman output mode (referenced, not re-typed). | en, pt-BR, ja, id |
|
||||
| Less code | `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | en only (backlog: [#10426](https://github.com/diegosouzapw/OmniRoute/issues/10426)) |
|
||||
| Ponytail (lazy senior dev) | `ponytail` | "The best code is the code never written": reuse > rewrite, root cause > symptom, shortest working diff. | en, pt-BR, vi, ja, id |
|
||||
| I have ADHD (action-first) | `i-have-adhd` | Action first (command/path/snippet before prose), numbered bounded steps, ONE concrete next step, no preamble/recap/closers. Adapted from [ayghri/i-have-adhd](https://github.com/ayghri/i-have-adhd) (MIT). | en, pt-BR, vi, ja, id |
|
||||
| Terse CJK (文言) | `terse-cjk` | Classical-Chinese ultra-terse style. | zh (locale-gated: only offered when the detected language is `zh`) |
|
||||
| Terse prose | `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. Same text as the legacy caveman output mode (referenced, not re-typed). | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| Less code | `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| Ponytail (lazy senior dev) | `ponytail` | "The best code is the code never written": reuse > rewrite, root cause > symptom, shortest working diff. | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| I have ADHD (action-first) | `i-have-adhd` | Action first (command/path/snippet before prose), numbered bounded steps, ONE concrete next step, no preamble/recap/closers. Adapted from [ayghri/i-have-adhd](https://github.com/ayghri/i-have-adhd) (MIT). | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| Terse CJK (文言) | `terse-cjk` | Classical-Chinese ultra-terse style. | zh (locale-gated: only offered when the resolved language is `zh`) |
|
||||
|
||||
Every style ships three intensity levels — `lite`, `full`, `ultra` — and every level
|
||||
ends with the shared boundaries clause, which keeps code blocks, file paths, commands,
|
||||
@@ -508,7 +492,11 @@ the selection as:
|
||||
```
|
||||
|
||||
Back-compat: the legacy `outputMode: "caveman"` combo setting still works and maps to
|
||||
`terse-prose`, byte-identical to the old injection in all four legacy languages.
|
||||
`terse-prose`, byte-identical to the old injection in every legacy language.
|
||||
|
||||
Language selection: with `languageConfig.enabled` on, `autoDetect` picks the
|
||||
language of the latest user message (same detector as the input engines);
|
||||
turning `autoDetect` off pins `defaultLanguage`. Off → English.
|
||||
|
||||
The style × language matrix is pinned by
|
||||
`tests/unit/compression/output-styles-i18n-matrix.test.ts`: a new style cannot ship
|
||||
|
||||
@@ -583,7 +583,7 @@ persistence and telemetry all enumerate the catalog — there is no other list t
|
||||
The instruction text must be **static and deterministic** per
|
||||
`(id, level, language)` — `${SHARED_BOUNDARIES}` is the only interpolation allowed.
|
||||
2. **Translate it.** Ship at least a `pt-BR` block under `i18n`; `ponytail` and
|
||||
`i-have-adhd` (en, pt-BR, vi, ja, id) are the reference shape. A deliberately
|
||||
`i-have-adhd` (en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi) are the reference shape. A deliberately
|
||||
single-language style sets `locale` instead (like `terse-cjk` → `zh`) and is then
|
||||
only offered under that locale.
|
||||
3. **Update the matrix guard** — add the style's languages to `BASELINE_LANGUAGES` in
|
||||
|
||||
@@ -75,7 +75,7 @@ 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 helper used by another dependency. The pinned `wreq-js@3.0.0` package bundles its seven supported platform addons directly; this warning does not diagnose the web-cookie transport.
|
||||
|
||||
**No action needed** — the warnings cannot be fully silenced without forking upstream packages.
|
||||
|
||||
@@ -148,9 +148,10 @@ 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-<hash>/rust/wreq-js.win32-x64-msvc.node`
|
||||
— the declared MIT-licensed native addon from pinned `wreq-js@3.0.0`, used for
|
||||
browser-fingerprinted HTTP on some web providers. Its expected SHA-256 is recorded in
|
||||
`config/release/wreq-js-native-manifest.json`.
|
||||
|
||||
**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
|
||||
|
||||
@@ -316,7 +316,6 @@ Pliki najwyższego poziomu w `src/lib/`:
|
||||
|
||||
- `localDb.ts` — wyłącznie warstwa re-export. **Nigdy** nie dodawaj tu logiki.
|
||||
- `proxyHealth.ts`, `proxyLogger.ts`, `tokenHealthCheck.ts`, `localHealthCheck.ts`
|
||||
- `oneproxyRotator.ts`, `oneproxySync.ts`
|
||||
- `apiBridgeServer.ts`, `cacheLayer.ts`, `semanticCache.ts`, `settingsCache.ts`
|
||||
- `cloudSync.ts`, `initCloudSync.ts`
|
||||
- `cloudflaredTunnel.ts`, `ngrokTunnel.ts`, `tailscaleTunnel.ts`
|
||||
@@ -423,7 +422,7 @@ Podzielone na skupione podkatalogi:
|
||||
`bodySize.ts`, `colors.ts`, `appConfig.ts`, `config.ts`,
|
||||
`sidebarVisibility.ts`, `visionBridgeDefaults.ts`.
|
||||
- `validation/` — `schemas.ts` (~80 schematów Zod), `compressionConfigSchemas.ts`,
|
||||
`oneproxySchemas.ts`, `providerSchema.ts`, `settingsSchemas.ts`, `helpers.ts`.
|
||||
`providerSchema.ts`, `settingsSchemas.ts`, `helpers.ts`.
|
||||
- `contracts/` — publiczne kontrakty API dostarczane do npm.
|
||||
- `types/` — współdzielone typy TS.
|
||||
- `utils/` — `circuitBreaker.ts`, `apiAuth.ts`, `apiKey.ts`, `apiKeyPolicy.ts`,
|
||||
|
||||
@@ -202,7 +202,6 @@ src/
|
||||
| `playground/` | Współdzielone helpery Playground Studio: `codeExport.ts` (generator curl/Python/TS), `promptImprover.ts` (builder meta-promptów), `streamMetrics.ts` (czyste TTFT/TPS), `types.ts` (tabela cen) — zob. `docs/frameworks/PLAYGROUND_STUDIO.md` |
|
||||
| `webhookDispatcher.ts` | Dostarczanie webhooków HMAC — zob. `docs/frameworks/WEBHOOKS.md` |
|
||||
| `cloudflaredTunnel.ts`, `ngrokTunnel.ts` | Managery tuneli — zob. `docs/ops/TUNNELS_GUIDE.md` |
|
||||
| `oneproxySync.ts`, `oneproxyRotator.ts` | Marketplace darmowych proxy 1proxy — zob. `docs/ops/PROXY_GUIDE.md` |
|
||||
| `cloudSync.ts`, `initCloudSync.ts` | Opcjonalna synchronizacja stanu w chmurze |
|
||||
| `localDb.ts` | Barrel re-exportów modułów db (bez logiki — tylko re-eksporty) |
|
||||
| `cacheLayer.ts`, `idempotencyLayer.ts` | Cache żądań + idempotencja |
|
||||
@@ -245,7 +244,7 @@ src/
|
||||
| -------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `constants/providers.ts` | **329 wpisów providerów** z walidacją Zod (źródło prawdy) |
|
||||
| `constants/cliTools.ts` | Rejestr zewnętrznych narzędzi CLI |
|
||||
| `constants/routingStrategies.ts` | **19 publicznych strategii routingu** z priorytetami |
|
||||
| `constants/routingStrategies.ts` | **19 publicznych strategii routingu** z priorytetami |
|
||||
| `constants/publicApiRoutes.ts` | Trasy wymagające auth Bearer (vs management) |
|
||||
| `constants/upstreamHeaders.ts` | Denylist nagłówków dla żądań upstream |
|
||||
| `validation/schemas.ts` | ~80 schematów Zod (jedno źródło prawdy dla kontraktów API) |
|
||||
|
||||
@@ -79,8 +79,6 @@ Nawet poza zablokowanymi regionami proxy są przydatne do:
|
||||
| **Settings Route** | `src/app/api/settings/proxy/route.ts` | Legacy API konfiguracji proxy (GET/PUT/DELETE) |
|
||||
| **Management Route** | `src/app/api/v1/management/proxies/route.ts` | Registry CRUD API (GET/POST/PATCH/DELETE) |
|
||||
| **1proxy DB** | `src/lib/db/oneproxy.ts` | Trwałość darmowego marketplace proxy |
|
||||
| **1proxy Sync** | `src/lib/oneproxySync.ts` | Pobiera proxy z API 1proxy |
|
||||
| **1proxy Rotator** | `src/lib/oneproxyRotator.ts` | Strategie rotacji (quality/random/sequential) |
|
||||
|
||||
---
|
||||
|
||||
@@ -505,13 +503,9 @@ Aby wystawić instancję OmniRoute do publicznego internetu (Cloudflare/ngrok/Ta
|
||||
|
||||
## Zmienne środowiskowe
|
||||
|
||||
| Zmienna | Domyślna | Opis |
|
||||
| -------------------------------- | ------------------------------------- | --------------------------------------------------------- |
|
||||
| `ENABLE_SOCKS5_PROXY` | `true` | Włącza obsługę SOCKS5 (domyślnie `true` w `.env.example`) |
|
||||
| `ONEPROXY_ENABLED` | `true` | Włącza integrację 1proxy |
|
||||
| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | Endpoint API 1proxy |
|
||||
| `ONEPROXY_MAX_PROXIES` | `500` | Maks. liczba proxy do synchronizacji |
|
||||
| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | Minimalny quality score do importu |
|
||||
| Zmienna | Domyślna | Opis |
|
||||
| --------------------- | -------- | --------------------------------------------------------- |
|
||||
| `ENABLE_SOCKS5_PROXY` | `true` | Włącza obsługę SOCKS5 (domyślnie `true` w `.env.example`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -772,49 +766,6 @@ Use `random`
|
||||
evenly)
|
||||
```
|
||||
|
||||
### Konfiguracja strategii rotacji
|
||||
|
||||
```ts
|
||||
import { rotateOneproxyProxy } from "omniroute/oneproxyRotator";
|
||||
|
||||
// In a one-off script
|
||||
const proxy = await rotateOneproxyProxy({ strategy: "quality" });
|
||||
if (proxy) {
|
||||
console.log(`Selected: ${proxy.host}:${proxy.port}, quality=${proxy.qualityScore}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Reset indeksu sequential
|
||||
|
||||
Przy strategii `sequential` wewnętrzny indeks narasta. Aby zresetować:
|
||||
|
||||
```ts
|
||||
import { resetSequentialIndex } from "omniroute/oneproxyRotator";
|
||||
|
||||
resetSequentialIndex();
|
||||
```
|
||||
|
||||
Przydatne gdy:
|
||||
|
||||
- Restartujesz load test
|
||||
- Odzyskujesz się po awarii proxy (żeby nie cyklonować najpierw martwych)
|
||||
- Ręcznie rebalansujesz po dodaniu nowych proxy
|
||||
|
||||
### Oznaczanie proxy jako failed
|
||||
|
||||
Gdy proxy systematycznie pada, oznacz je ręcznie, by rotator je pomijał:
|
||||
|
||||
```ts
|
||||
import { failOneproxyProxy } from "omniroute/oneproxyRotator";
|
||||
|
||||
const removed = await failOneproxyProxy("203.0.113.7", 8080);
|
||||
if (removed) {
|
||||
console.log("Proxy marked as failed; rotator will skip it");
|
||||
}
|
||||
```
|
||||
|
||||
Proxy **nie jest usuwane** — jest oznaczane jako unhealthy i nie będzie wybierane do następnego udanego health check (przez `proxyHealth.ts`) lub ręcznego resetu.
|
||||
|
||||
---
|
||||
|
||||
> 📖 **Powiązana dokumentacja:**
|
||||
|
||||
@@ -298,7 +298,6 @@ v1/
|
||||
|
||||
- `localDb.ts` — 仅作重新导出层。**切勿**在此添加逻辑。
|
||||
- `proxyHealth.ts`、`proxyLogger.ts`、`tokenHealthCheck.ts`、`localHealthCheck.ts`
|
||||
- `oneproxyRotator.ts`、`oneproxySync.ts`
|
||||
- `apiBridgeServer.ts`、`cacheLayer.ts`、`semanticCache.ts`、`settingsCache.ts`
|
||||
- `cloudSync.ts`、`initCloudSync.ts`
|
||||
- `cloudflaredTunnel.ts`、`ngrokTunnel.ts`、`tailscaleTunnel.ts`
|
||||
@@ -404,7 +403,7 @@ server/
|
||||
`bodySize.ts`、`colors.ts`、`appConfig.ts`、`config.ts`、
|
||||
`sidebarVisibility.ts`、`visionBridgeDefaults.ts`。
|
||||
- `validation/` — `schemas.ts`(约 80 个 Zod Schema)、`compressionConfigSchemas.ts`、
|
||||
`oneproxySchemas.ts`、`providerSchema.ts`、`settingsSchemas.ts`、`helpers.ts`。
|
||||
`providerSchema.ts`、`settingsSchemas.ts`、`helpers.ts`。
|
||||
- `contracts/` — 发布到 npm 的公开 API 契约。
|
||||
- `types/` — 共享 TS 类型。
|
||||
- `utils/` — `circuitBreaker.ts`、`apiAuth.ts`、`apiKey.ts`、`apiKeyPolicy.ts`、
|
||||
|
||||
@@ -944,10 +944,6 @@ CLI_COMPAT_ALL=1
|
||||
| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | 设为 `1` 可禁用上游 TLS 校验(仅限开发)。 |
|
||||
| `MITM_IDLE_TIMEOUT_MS` | `60000` | `src/mitm/socketTimeouts.ts`, `src/mitm/server.cjs` | 代理连接的空闲套接字超时(毫秒);超过该时间的空闲套接字会被拆除,避免泄露半打开隧道。 |
|
||||
| `MITM_VERBOSE` | `1` | `src/mitm/server.cjs`, `src/mitm/_internal/bypass.cjs` | 路由决策日志详细程度:`0` 静默,值越大记录越多 bypass/路由决策。 |
|
||||
| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | 启用 1Proxy 出口池同步。 |
|
||||
| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy 服务 API URL 覆盖。 |
|
||||
| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | 每次同步导入的最大代理数。 |
|
||||
| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | `src/lib/oneproxySync.ts` | 导入代理的最低质量分。 |
|
||||
| `FREE_PROXY_1PROXY_ENABLED` | `true` | `src/lib/freeProxyProviders/oneproxy.ts` | 启用 1proxy 免费代理源。设为 `false` 可禁用。 |
|
||||
| `FREE_PROXY_1PROXY_API_URL` | _(见 oneproxy.ts)_ | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy API URL 覆盖。 |
|
||||
| `FREE_PROXY_1PROXY_MAX` | `500` | `src/lib/freeProxyProviders/oneproxy.ts` | 从 1proxy 每次同步获取的最大代理数。 |
|
||||
|
||||
@@ -311,7 +311,6 @@ v1/
|
||||
|
||||
- `localDb.ts` — 僅重新匯出層。**切勿**在此新增邏輯。
|
||||
- `proxyHealth.ts`、`proxyLogger.ts`、`tokenHealthCheck.ts`、`localHealthCheck.ts`
|
||||
- `oneproxyRotator.ts`、`oneproxySync.ts`
|
||||
- `apiBridgeServer.ts`、`cacheLayer.ts`、`semanticCache.ts`、`settingsCache.ts`
|
||||
- `cloudSync.ts`、`initCloudSync.ts`
|
||||
- `cloudflaredTunnel.ts`、`ngrokTunnel.ts`、`tailscaleTunnel.ts`
|
||||
@@ -418,7 +417,7 @@ server/
|
||||
`bodySize.ts`、`colors.ts`、`appConfig.ts`、`config.ts`、
|
||||
`sidebarVisibility.ts`、`visionBridgeDefaults.ts`。
|
||||
- `validation/` — `schemas.ts`(約 80 個 Zod 架構)、`compressionConfigSchemas.ts`、
|
||||
`oneproxySchemas.ts`、`providerSchema.ts`、`settingsSchemas.ts`、`helpers.ts`。
|
||||
`providerSchema.ts`、`settingsSchemas.ts`、`helpers.ts`。
|
||||
- `contracts/` — 發布到 npm 的公開 API 合約。
|
||||
- `types/` — 共用 TS 型別。
|
||||
- `utils/` — `circuitBreaker.ts`、`apiAuth.ts`、`apiKey.ts`、`apiKeyPolicy.ts`、
|
||||
|
||||
@@ -79,8 +79,6 @@ Even outside blocked regions, proxies are useful for:
|
||||
| **Settings Route** | `src/app/api/settings/proxy/route.ts` | Legacy proxy config API (GET/PUT/DELETE) |
|
||||
| **Management Route** | `src/app/api/v1/management/proxies/route.ts` | Registry CRUD API (GET/POST/PATCH/DELETE) |
|
||||
| **1proxy DB** | `src/lib/db/oneproxy.ts` | Free proxy marketplace persistence |
|
||||
| **1proxy Sync** | `src/lib/oneproxySync.ts` | Fetches proxies from 1proxy API |
|
||||
| **1proxy Rotator** | `src/lib/oneproxyRotator.ts` | Rotation strategies (quality/random/sequential) |
|
||||
|
||||
---
|
||||
|
||||
@@ -505,13 +503,9 @@ For exposing your OmniRoute instance to the public internet (Cloudflare/ngrok/Ta
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------------------------------- | ------------------------------------- | -------------------------------------------------------------- |
|
||||
| `ENABLE_SOCKS5_PROXY` | `true` | Enable SOCKS5 proxy support (default `true` in `.env.example`) |
|
||||
| `ONEPROXY_ENABLED` | `true` | Enable 1proxy integration |
|
||||
| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | 1proxy API endpoint |
|
||||
| `ONEPROXY_MAX_PROXIES` | `500` | Maximum proxies to sync |
|
||||
| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | Minimum quality score to import |
|
||||
| Variable | Default | Description |
|
||||
| --------------------- | ------- | -------------------------------------------------------------- |
|
||||
| `ENABLE_SOCKS5_PROXY` | `true` | Enable SOCKS5 proxy support (default `true` in `.env.example`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -772,55 +766,10 @@ Use `random`
|
||||
evenly)
|
||||
```
|
||||
|
||||
### Configuring Rotation Strategy
|
||||
|
||||
```ts
|
||||
import { rotateOneproxyProxy } from "omniroute/oneproxyRotator";
|
||||
|
||||
// In a one-off script
|
||||
const proxy = await rotateOneproxyProxy({ strategy: "quality" });
|
||||
if (proxy) {
|
||||
console.log(`Selected: ${proxy.host}:${proxy.port}, quality=${proxy.qualityScore}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Resetting Sequential Index
|
||||
|
||||
When using `sequential` strategy, the internal index accumulates. To reset:
|
||||
|
||||
```ts
|
||||
import { resetSequentialIndex } from "omniroute/oneproxyRotator";
|
||||
|
||||
resetSequentialIndex();
|
||||
```
|
||||
|
||||
Useful when:
|
||||
|
||||
- Restarting a load test
|
||||
- Recovering from a proxy outage (so you don't cycle through dead ones first)
|
||||
- Manually rebalancing after adding new proxies
|
||||
|
||||
### Marking a Proxy as Failed
|
||||
|
||||
When a proxy consistently fails, mark it manually so the rotator will skip it:
|
||||
|
||||
```ts
|
||||
import { failOneproxyProxy } from "omniroute/oneproxyRotator";
|
||||
|
||||
const removed = await failOneproxyProxy("203.0.113.7", 8080);
|
||||
if (removed) {
|
||||
console.log("Proxy marked as failed; rotator will skip it");
|
||||
}
|
||||
```
|
||||
|
||||
The proxy is **not deleted** — it's marked unhealthy and won't be selected until the next successful health check (via `proxyHealth.ts`) or manual reset.
|
||||
|
||||
---
|
||||
|
||||
## Automatic Failure Exclusion for Your Own Proxies
|
||||
|
||||
`failOneproxyProxy()` above only covers the 1proxy marketplace pool, which already
|
||||
auto-degrades on failure (see [Proxy Quality Scores](#proxy-quality-scores)). For
|
||||
The 1proxy marketplace pool already auto-degrades failed proxies on its own (see
|
||||
[Proxy Quality Scores](#proxy-quality-scores)). For
|
||||
proxies **you** added to the registry, the background health scheduler
|
||||
(`src/lib/proxyHealth/scheduler.ts`) provides the same "exclude a dead member from
|
||||
the chain automatically" behavior, without deleting anything:
|
||||
|
||||
@@ -764,15 +764,18 @@ 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_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Native wreq-js request timeout (`chatgptTlsClient.ts`). |
|
||||
| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side hard-deadline grace added on top of the native timeout. |
|
||||
| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000` (30s) | Max wait for the first streamed byte before ChatGPT switches to a buffered response; the hard request deadline remains active. |
|
||||
| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Native wreq-js request timeout (`claudeTlsClient.ts`). |
|
||||
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side 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` | JS-side 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` | JS-side 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` | JS-side 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. |
|
||||
@@ -1290,10 +1293,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
|
||||
| `OMNIROUTE_SKIP_DNS_WRITE` | _(unset)_ | `src/mitm/dns/dnsConfig.ts` | Set `1` to skip writing to the hosts file when adding/removing DNS entries — for sandboxed or read-only test environments. |
|
||||
| `OMNIROUTE_SKIP_SYSTEM_TRUST` | `0` | `src/mitm/cert/install.ts`, `src/mitm/tproxy/caTrust.ts` | Test/CI-only guard: set `1` to make cert trust install/uninstall a no-op so the suite never mutates the OS trust store. Set automatically by the test setup and CI workflows. |
|
||||
| `CHANGELOG_BASE_REF` | _(auto)_ | `scripts/check/check-changelog-integrity.mjs` | Explicit base ref for the anti CHANGELOG-eat gate (defaults to the PR base branch in CI, or the highest `release/v*`). |
|
||||
| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | Enable the 1Proxy egress pool sync. |
|
||||
| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy service API URL override. |
|
||||
| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | Maximum proxies imported per sync. |
|
||||
| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | `src/lib/oneproxySync.ts` | Minimum quality score for imported proxies. |
|
||||
| `FREE_PROXY_AUTO_SYNC_ENABLED` | `false` | `src/lib/freeProxyProviders/scheduler.ts` | Set `true` to enable the background free-proxy pool auto-sync scheduler. Opt-in, off by default. |
|
||||
| `FREE_PROXY_AUTO_SYNC_INTERVAL_MS` | `1800000` | `src/lib/freeProxyProviders/scheduler.ts` | Auto-sync interval in milliseconds (default 30 min). |
|
||||
| `FREE_PROXY_1PROXY_ENABLED` | `true` | `src/lib/freeProxyProviders/oneproxy.ts` | Enable the 1proxy free proxy source. Set to `false` to disable. |
|
||||
|
||||
@@ -51,24 +51,23 @@ used when neither a DB override nor an environment variable is present.
|
||||
|
||||
### Security (7)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| -------------------------------- | ------- | -------- | ----------------------------------------------------------------------------- |
|
||||
| `REQUIRE_API_KEY` | boolean | `false` | Require an API key for all incoming requests. |
|
||||
| `INPUT_SANITIZER_ENABLED` | boolean | `true` | Enable input sanitization for all requests. |
|
||||
| `INJECTION_GUARD_MODE` | enum | `off` | Prompt injection guard mode. Values: `off`, `warn`, `block`, `redact`. |
|
||||
| `INPUT_SANITIZER_BLOCK_THRESHOLD` | enum | `high` | Minimum severity blocked when mode is `block` (`high`/`medium`/`low`). Medium families are observe-only at default. |
|
||||
| `INJECTION_GUARD_BLOCK_THRESHOLD` | enum | _(unset)_ | Legacy alias for `INPUT_SANITIZER_BLOCK_THRESHOLD`. |
|
||||
| `PII_REDACTION_ENABLED` | boolean | `false` | Redact PII from requests (independent of `INPUT_SANITIZER_MODE`). |
|
||||
| `PII_RESPONSE_SANITIZATION` | boolean | `false` | Sanitize PII from provider responses. |
|
||||
| `PII_RESPONSE_SANITIZATION_MODE` | enum | `redact` | Mode for PII response sanitization. Values: `redact`, `warn`, `block`, `off`. |
|
||||
| `OUTBOUND_SSRF_GUARD_ENABLED` | boolean | `true` | Block outbound requests to private/internal IP ranges. |
|
||||
| Key | Type | Default | Description |
|
||||
| --------------------------------- | ------- | --------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `REQUIRE_API_KEY` | boolean | `false` | Require an API key for all incoming requests. |
|
||||
| `INPUT_SANITIZER_ENABLED` | boolean | `true` | Enable input sanitization for all requests. |
|
||||
| `INJECTION_GUARD_MODE` | enum | `off` | Prompt injection guard mode. Values: `off`, `warn`, `block`, `redact`. |
|
||||
| `INPUT_SANITIZER_BLOCK_THRESHOLD` | enum | `high` | Minimum severity blocked when mode is `block` (`high`/`medium`/`low`). Medium families are observe-only at default. |
|
||||
| `INJECTION_GUARD_BLOCK_THRESHOLD` | enum | _(unset)_ | Legacy alias for `INPUT_SANITIZER_BLOCK_THRESHOLD`. |
|
||||
| `PII_REDACTION_ENABLED` | boolean | `false` | Redact PII from requests (independent of `INPUT_SANITIZER_MODE`). |
|
||||
| `PII_RESPONSE_SANITIZATION` | boolean | `false` | Sanitize PII from provider responses. |
|
||||
| `PII_RESPONSE_SANITIZATION_MODE` | enum | `redact` | Mode for PII response sanitization. Values: `redact`, `warn`, `block`, `off`. |
|
||||
| `OUTBOUND_SSRF_GUARD_ENABLED` | boolean | `true` | Block outbound requests to private/internal IP ranges. |
|
||||
|
||||
### Network (8)
|
||||
|
||||
| Key | Type | Default | Restart | Description |
|
||||
| ----------------------------------------------- | ------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ENABLE_TLS_FINGERPRINT` | boolean | `false` | ✓ | Enable TLS fingerprint stealth mode. |
|
||||
| `ONEPROXY_ENABLED` | boolean | `true` | | Enable 1proxy request proxying. |
|
||||
| `PROXY_AUTO_SELECT_ENABLED` | boolean | `false` | | When no proxy is assigned to a connection, auto-select the first working proxy from the registry. Off by default (otherwise any registry proxy becomes a global fallback — #3332). |
|
||||
| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | boolean | `false` | | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Off by default because this can change egress IP. |
|
||||
| `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** |
|
||||
@@ -78,27 +77,27 @@ used when neither a DB override nor an environment variable is present.
|
||||
|
||||
### Policies (3)
|
||||
|
||||
| Key | Type | Default | Restart | Description |
|
||||
| ----------------------------------------- | ------- | ---------- | ------- | ---------------------------------------------------------------------- |
|
||||
| `TOOL_POLICY_MODE` | enum | `disabled` | | Tool-use policy enforcement mode. Values: `disabled`, `warn`, `block`. |
|
||||
| `RATE_LIMIT_AUTO_ENABLE` | boolean | `false` | | Automatically enable rate limiting based on usage patterns. |
|
||||
| `DISABLE_CONTEXT_WINDOW_CHECKS` | boolean | `false` | | Skip OmniRoute's local context-window / max-input-token check for direct single-model requests. Upstream limits still apply. |
|
||||
| Key | Type | Default | Restart | Description |
|
||||
| ------------------------------- | ------- | ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TOOL_POLICY_MODE` | enum | `disabled` | | Tool-use policy enforcement mode. Values: `disabled`, `warn`, `block`. |
|
||||
| `RATE_LIMIT_AUTO_ENABLE` | boolean | `false` | | Automatically enable rate limiting based on usage patterns. |
|
||||
| `DISABLE_CONTEXT_WINDOW_CHECKS` | boolean | `false` | | Skip OmniRoute's local context-window / max-input-token check for direct single-model requests. Upstream limits still apply. |
|
||||
|
||||
### Runtime (11)
|
||||
|
||||
| Key | Type | Default | Restart | Description |
|
||||
| ------------------------------------------- | ------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Key | Type | Default | Restart | Description |
|
||||
| ------------------------------------------- | ------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `EXPOSE_CC_DISCOVERY_ALIASES` | boolean | `false` | | Advertise `claude/<provider>/<model>` mirror ids on `/v1/models` so Claude Code gateway model discovery lists non-Claude models. Global level of the three-level gate (env wins over the dashboard override). See [Claude Code configuration](../guides/CLAUDE-CODE-CONFIGURATION.md#discovery-aliases--surface-non-claude-models-in-the-model-picker). |
|
||||
| `OMNIROUTE_MCP_ENFORCE_SCOPES` | boolean | `true` | | Enforce scope restrictions on MCP tool access. |
|
||||
| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | boolean | `false` | | Compress MCP tool descriptions to reduce token usage. |
|
||||
| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | boolean | `false` | | Enable background task processing at runtime. |
|
||||
| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | boolean | `false` | ✓ | Disable all background services (quota refresh, sync, etc). |
|
||||
| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | boolean | `false` | | Trust project-level RTK filters without validation. |
|
||||
| `OMNIROUTE_ENABLE_LIVE_WS` | boolean | `true` | ✓ | Start the real-time dashboard WebSocket server on import (port 20129 by default). |
|
||||
| `OMNIROUTE_CODEX_WS_ENABLED` | boolean | `true` | | Allow Codex to use the Responses-over-WebSocket transport. When off, Codex falls back to HTTP Responses. |
|
||||
| `OMNIROUTE_EMERGENCY_FALLBACK` | boolean | `true` | | Route budget-exhausted requests to the emergency free fallback provider/model. (See [Emergency Budget Fallback](#emergency-budget-fallback) below.) |
|
||||
| `MODEL_CATALOG_INCLUDE_NAMES` | boolean | `true` | | Include display-friendly name fields in `/v1/models` responses. Disable for clients that expect model IDs only. |
|
||||
| `ARENA_ELO_SYNC_ENABLED` | boolean | `true` | | Enable periodic Arena AI leaderboard ELO sync for model intelligence rankings. |
|
||||
| `OMNIROUTE_MCP_ENFORCE_SCOPES` | boolean | `true` | | Enforce scope restrictions on MCP tool access. |
|
||||
| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | boolean | `false` | | Compress MCP tool descriptions to reduce token usage. |
|
||||
| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | boolean | `false` | | Enable background task processing at runtime. |
|
||||
| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | boolean | `false` | ✓ | Disable all background services (quota refresh, sync, etc). |
|
||||
| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | boolean | `false` | | Trust project-level RTK filters without validation. |
|
||||
| `OMNIROUTE_ENABLE_LIVE_WS` | boolean | `true` | ✓ | Start the real-time dashboard WebSocket server on import (port 20129 by default). |
|
||||
| `OMNIROUTE_CODEX_WS_ENABLED` | boolean | `true` | | Allow Codex to use the Responses-over-WebSocket transport. When off, Codex falls back to HTTP Responses. |
|
||||
| `OMNIROUTE_EMERGENCY_FALLBACK` | boolean | `true` | | Route budget-exhausted requests to the emergency free fallback provider/model. (See [Emergency Budget Fallback](#emergency-budget-fallback) below.) |
|
||||
| `MODEL_CATALOG_INCLUDE_NAMES` | boolean | `true` | | Include display-friendly name fields in `/v1/models` responses. Disable for clients that expect model IDs only. |
|
||||
| `ARENA_ELO_SYNC_ENABLED` | boolean | `true` | | Enable periodic Arena AI leaderboard ELO sync for model intelligence rankings. |
|
||||
|
||||
### CLI (3)
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Stealth Guide"
|
||||
version: 3.8.40
|
||||
lastUpdated: 2026-06-28
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-26
|
||||
---
|
||||
|
||||
# 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,chatgptTlsClient,claudeTlsClient,perplexityTlsClient,grokTlsClient,notionTlsClient,lmarenaTlsClient,claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/`
|
||||
> **Last updated:** 2026-08-26 — v3.8.50
|
||||
> **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.
|
||||
@@ -29,6 +29,41 @@ Lazy-loaded `wreq-js` session that impersonates **Chrome 124 on macOS**. Used as
|
||||
- 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.0.0
|
||||
|
||||
`open-sse/services/tlsClientBase.ts` is the shared transport for ChatGPT, Claude, Perplexity,
|
||||
Grok, Notion, and LMArena web sessions. Each thin provider wrapper selects a browser/OS profile;
|
||||
the base loads `wreq-js` lazily, reuses only transport-level connections keyed by
|
||||
profile + OS + resolved proxy, and gives every request an ephemeral cookie scope. It never shares a
|
||||
wreq session or cookie jar between accounts or requests.
|
||||
|
||||
| Provider | Profile | Emulated OS | Stream EOF policy |
|
||||
| ---------- | ------------- | ----------- | -------------------------------- |
|
||||
| ChatGPT | `firefox_148` | macOS | include `[DONE]` |
|
||||
| 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 uses 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) and `TlsClientHangError`
|
||||
(deadline exceeded).
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fixupConfigRules } from "@eslint/compat";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import * as espree from "espree";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
// #7879: bar NEW local `toNumber` definitions outside the canonical helper.
|
||||
@@ -37,7 +39,22 @@ const IMPORT_BOUNDARY_RESTRICTIONS = {
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
const eslintConfig = [
|
||||
...nextVitals,
|
||||
...fixupConfigRules(nextVitals),
|
||||
// eslint-config-next's Babel parser has not adopted ESLint 10's ScopeManager
|
||||
// finalize contract yet. Plain JS/JSX does not need that parser, so use the
|
||||
// ESLint-native parser while retaining Next's plugins and rules.
|
||||
{
|
||||
files: ["**/*.{js,jsx,mjs,cjs}"],
|
||||
languageOptions: {
|
||||
parser: espree,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{mts,cts}"],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
},
|
||||
// Pacote 4 (plano mestre testes+CI, 2026-07-04) — zero-warning policy: TODA regra roda
|
||||
// como "error" e a dívida pré-existente vive congelada por arquivo+regra em
|
||||
// config/quality/eslint-suppressions.json (ESLint bulk suppressions nativo). Violação
|
||||
@@ -133,28 +150,45 @@ const eslintConfig = [
|
||||
"no-restricted-syntax": "off",
|
||||
},
|
||||
},
|
||||
// Relaxed rules for open-sse and tests (incremental adoption)
|
||||
// Relaxed rules for TypeScript in open-sse and tests (incremental adoption).
|
||||
// eslint-config-next already registers @typescript-eslint for every TS file;
|
||||
// registering a second plugin object is rejected by ESLint 10.
|
||||
{
|
||||
files: ["open-sse/**/*.ts", "tests/**/*.mjs", "tests/**/*.ts"],
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
files: ["open-sse/**/*.ts", "tests/**/*.ts"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@next/next/no-assign-module-variable": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.mjs"],
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
rules: {
|
||||
"@next/next/no-assign-module-variable": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
},
|
||||
},
|
||||
// JS/JSX files do not match eslint-config-next's TypeScript block. Register
|
||||
// the plugin only for that disjoint scope so the shared unused-vars ratchet
|
||||
// works without redefining the plugin for TS/TSX under ESLint 10.
|
||||
{
|
||||
files: ["src/**/*.{js,jsx}"],
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
},
|
||||
// Ratchet: bar NEW unused vars/args/catches outside the `_` escape hatch.
|
||||
// Pre-existing violations are frozen via config/quality/eslint-suppressions.json
|
||||
// (same pattern as #7879 toNumber); only genuinely NEW unused bindings fail
|
||||
// lint. `args: "all"` (not `after-used`) so a leading unused param is never
|
||||
// silently skipped, e.g. `function handle(req, _opts, next)` must flag `req`.
|
||||
// eslint-config-next already registers @typescript-eslint; registering it again
|
||||
// in this block is rejected by ESLint 10.
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx,js,jsx}", "open-sse/**/*.ts", "tests/**/*.{ts,tsx,mjs}"],
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
|
||||
@@ -109,6 +109,8 @@ function filterKnownInfrastructureWarnings(baseConsole) {
|
||||
// The resulting artifact is intended to be published as `omniroute-secure`
|
||||
// for security-sensitive environments. See docs/security/SOCKET_DEV_FINDINGS.md.
|
||||
const isMinimalBuild = process.env.OMNIROUTE_BUILD_PROFILE === "minimal";
|
||||
// Contributor builds validate compilation only and do not need a shippable standalone bundle.
|
||||
const isContributorBuild = process.env.OMNIROUTE_BUILD_PROFILE === "contributor";
|
||||
|
||||
// #10273: `null` unless the operator opts in with DASHBOARD_ALLOW_EMBED=vscode. Read at build
|
||||
// time like every other knob in this file (OMNIROUTE_BASE_PATH, OMNIROUTE_BUILD_PROFILE, …),
|
||||
@@ -218,7 +220,7 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
output: "standalone",
|
||||
...(isContributorBuild ? {} : { output: "standalone" }),
|
||||
compress: true,
|
||||
productionBrowserSourceMaps: false,
|
||||
// OmniRoute is a proxy for AI APIs — request bodies routinely include
|
||||
@@ -286,6 +288,9 @@ const nextConfig = {
|
||||
// (better-sqlite3 → node:sqlite → sql.js). Next traces sql-wasm.js but can
|
||||
// omit the runtime sql-wasm.wasm asset from the standalone bundle.
|
||||
"./node_modules/sql.js/dist/sql-wasm.wasm",
|
||||
// tiktoken is server-externalized below so Node selects its CommonJS entry.
|
||||
// That entry reads the tokenizer WASM beside itself at runtime.
|
||||
"./node_modules/tiktoken/tiktoken_bg.wasm",
|
||||
],
|
||||
},
|
||||
outputFileTracingExcludes: {
|
||||
@@ -334,11 +339,12 @@ 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
|
||||
// that graph but omits the runtime asset, making provider routes fail during
|
||||
// module evaluation. Keep Node's CommonJS loader and colocated WASM intact.
|
||||
"tiktoken",
|
||||
// copilot-m365-web.ts imports 'ws' as a client-side WebSocket. When bundled,
|
||||
// ws cannot resolve its 'bufferutil' native addon (frame masking) and throws
|
||||
// TypeError: b.mask is not a function on the first outgoing frame, causing
|
||||
|
||||
@@ -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.0.0 native addon.`,
|
||||
"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, TlsClientUnavailableError } 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,8 +497,7 @@ 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 ?? "";
|
||||
@@ -634,8 +620,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 +723,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.0.0 native addon, " +
|
||||
"or route perplexity-web through a residential proxy.";
|
||||
log?.error?.("PPLX-WEB", "Cloudflare challenge detected — TLS bypass failed");
|
||||
} else {
|
||||
|
||||
@@ -1580,12 +1580,12 @@ export async function handleChatCore({
|
||||
await import("../services/compression/outputStyles/backCompat.ts");
|
||||
const selection = resolveOutputStyleSelection(config);
|
||||
if (selection.length > 0) {
|
||||
const { applyOutputStyles } =
|
||||
const { applyOutputStyles, resolveOutputStyleLanguage } =
|
||||
await import("../services/compression/outputStyles/apply.ts");
|
||||
const outputStyleLanguage =
|
||||
config.languageConfig?.enabled === true
|
||||
? config.languageConfig.defaultLanguage
|
||||
: "en";
|
||||
const outputStyleLanguage = resolveOutputStyleLanguage(
|
||||
config.languageConfig,
|
||||
body as Parameters<typeof resolveOutputStyleLanguage>[1]
|
||||
);
|
||||
outputStyleResult = applyOutputStyles(
|
||||
body as Parameters<typeof applyOutputStyles>[0],
|
||||
selection,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -472,6 +472,30 @@ export function hasFutureRateLimitUntil(value: unknown): boolean {
|
||||
return Number.isFinite(time) && time > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* #12168: mirrors ERROR_LABEL_GRACE_MS in src/lib/quota/connectionRecovery.ts — a
|
||||
* bare status label with no cooldown timestamp is only trusted while the failure
|
||||
* that wrote it is recent. Kept in sync with that constant deliberately: both
|
||||
* answer the same question ("is this label still meaningful?") and they must not
|
||||
* disagree, or a connection the recovery job considers healthy would still be
|
||||
* pre-skipped by combo dispatch.
|
||||
*/
|
||||
const UNAVAILABLE_LABEL_GRACE_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* True when a bare `unavailable` label should still be honoured: the recorded
|
||||
* failure is inside the grace window. A missing/unparseable lastErrorAt is
|
||||
* treated as stale (not blocking) — an unbounded skip is exactly the failure
|
||||
* mode #12168 reported, and one extra upstream attempt is far cheaper than a
|
||||
* permanently dark connection pool.
|
||||
*/
|
||||
export function isWithinUnavailableGrace(lastErrorAt: unknown): boolean {
|
||||
if (lastErrorAt == null || lastErrorAt === "") return false;
|
||||
const time = new Date(String(lastErrorAt)).getTime();
|
||||
if (!Number.isFinite(time)) return false;
|
||||
return Date.now() - time < UNAVAILABLE_LABEL_GRACE_MS;
|
||||
}
|
||||
|
||||
export function getConnectionStatusQuotaCutoffReason(
|
||||
connection: Record<string, unknown> | undefined
|
||||
): string | undefined {
|
||||
@@ -508,13 +532,28 @@ export function getPersistedConnectionCooldownSkipReason(
|
||||
if (QUOTA_BLOCKING_CONNECTION_STATUSES.has(status)) {
|
||||
return `Skipping ${target.modelStr} — connection ${target.connectionId} status=${status}`;
|
||||
}
|
||||
// `unavailable` with no (or an already-expired) rateLimitedUntil still means AUTH
|
||||
// took this connection out of rotation — markAccountUnavailable() writes the status
|
||||
// before, and sometimes without, a timestamp ("Using zai account …" then a real
|
||||
// upstream 429). Without this branch the pre-skip only fired once the timestamp had
|
||||
// landed, so a burst still dispatched against a connection AUTH had already retired.
|
||||
// Lazy recovery is unaffected: clearAccountError() resets the status on first success.
|
||||
if (status === "unavailable") {
|
||||
// `unavailable` with no rateLimitedUntil still means AUTH took this connection out
|
||||
// of rotation — markAccountUnavailable() writes the status before, and sometimes
|
||||
// without, a timestamp ("Using zai account …" then a real upstream 429). Without
|
||||
// this branch a burst still dispatched against a connection AUTH had already retired.
|
||||
//
|
||||
// #12168: but the skip must be BOUNDED. The original version returned here for any
|
||||
// `unavailable` row, which is the raw-label anti-pattern AGENTS.md warns about — the
|
||||
// resilience layers are supposed to recover lazily. Its stated justification
|
||||
// ("clearAccountError() resets the status on first success") does not hold on this
|
||||
// path: this gate runs BEFORE dispatch, so it prevents the very successful request
|
||||
// that would call clearAccountError(). A connection left with a stale `unavailable`
|
||||
// label and no timestamp could therefore never dispatch again, and the out-of-band
|
||||
// recovery job cannot rescue it either — hasElapsedCooldown() there requires a
|
||||
// rateLimitedUntil to be present. Result: a whole combo pool could report
|
||||
// ALL_TARGETS_SKIPPED with zero upstream attempts, forever.
|
||||
//
|
||||
// Bound it the same way src/lib/quota/connectionRecovery.ts bounds a bare error
|
||||
// label: honour the skip only while the failure is recent (lastErrorAt within the
|
||||
// grace window). Past that, treat the label as stale and let the request through —
|
||||
// one real attempt then either succeeds (clearing the status) or re-arms the
|
||||
// cooldown with a fresh timestamp.
|
||||
if (status === "unavailable" && isWithinUnavailableGrace(connection.lastErrorAt)) {
|
||||
return `Skipping ${target.modelStr} — connection ${target.connectionId} status=unavailable`;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -745,6 +745,24 @@ export async function validateResponseQuality(
|
||||
// tokens or falls back to a non-reasoning model.
|
||||
const contentIsEmpty = content === null || content === undefined || content === "";
|
||||
if (contentIsEmpty && hasReasoningContent && !hasToolCalls) {
|
||||
// The 90%-of-completion-tokens ratio below is a proxy for "the request was
|
||||
// truncated mid-reasoning" for providers that don't report finish_reason
|
||||
// reliably. When finish_reason IS reported as "length" (or the Anthropic-shape
|
||||
// "max_tokens"), that's a direct, unambiguous signal of truncation — trust it
|
||||
// over the ratio instead of requiring reasoning to also clear 90%. A response
|
||||
// truncated at, say, 60% reasoning still has zero usable content for the
|
||||
// caller. This does not affect the deliberate-tiny-probe case (e.g.
|
||||
// `max_tokens: 1` connectivity pings, see errorClassifier.ts's
|
||||
// LEGIT_EMPTY_OPENAI_FINISH): those produce no reasoning_content at all, so
|
||||
// hasReasoningContent is already false and this branch never runs for them.
|
||||
const finishReason =
|
||||
typeof firstChoice.finish_reason === "string" ? firstChoice.finish_reason : "";
|
||||
if (finishReason === "length" || finishReason === "max_tokens") {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `reasoning truncated at token limit (finish_reason: ${finishReason}) — no content output`,
|
||||
};
|
||||
}
|
||||
const usage = json?.usage as Record<string, unknown> | undefined;
|
||||
if (usage) {
|
||||
const completionTokens = Number(usage.completion_tokens) || 0;
|
||||
|
||||
@@ -69,6 +69,21 @@ export const CAVEMAN_INSTRUCTION_BY_LANGUAGE = {
|
||||
full: `Trả lời cộc lốc như người tối cổ thông minh. Bỏ mạo từ, từ đệm, sáo rỗng, rào đón. Chấp nhận câu rút gọn. Dùng từ đồng nghĩa ngắn. Giữ nguyên mọi nội dung kỹ thuật, code, lỗi, URL và định danh. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `Trả lời cực kỳ cộc lốc. Nén tối đa. Như điện tín. Viết tắt (DB/auth/config/req/res/fn/impl), bỏ liên từ, dùng mũi tên cho quan hệ nhân quả (X → Y). Một từ nếu một từ là đủ. Không bao giờ viết tắt ký hiệu code, tên API, chuỗi lỗi, URL hoặc định danh. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
it: {
|
||||
lite: `Rispondi conciso. Togli riempitivi, convenevoli e incertezze. Mantieni termini tecnici, codice, errori, URL e identificatori esatti. ${SHARED_BOUNDARIES}`,
|
||||
full: `Rispondi secco e compatto. Frammenti OK. Mantieni tutto il contenuto tecnico, codice, errori, URL e identificatori esatti. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `Rispondi ultra compatto. Usa prosa tecnica breve e abbreviazioni comuni come DB/auth/config/req/res/fn. Mai abbreviare simboli di codice, API, errori, URL o identificatori. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
ru: {
|
||||
lite: `Отвечай кратко. Убирай воду, любезности и оговорки. Технические термины, код, ошибки, URL и идентификаторы сохраняй точно. ${SHARED_BOUNDARIES}`,
|
||||
full: `Отвечай сухо и сжато. Фрагменты допустимы. Всё техническое содержимое, код, ошибки, URL и идентификаторы сохраняй точно. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `Отвечай ультракратко. Короткая техническая проза и общепринятые сокращения вроде DB/auth/config/req/res/fn. Никогда не сокращай символы кода, API, строки ошибок, URL или идентификаторы. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
zh: {
|
||||
lite: `回答要简洁。去掉废话、客套和含糊措辞。技术术语、代码、错误、URL 和标识符保持原样。${SHARED_BOUNDARIES}`,
|
||||
full: `回答干脆紧凑。可用短句。所有技术内容、代码、错误、URL 和标识符保持原样。${SHARED_BOUNDARIES}`,
|
||||
ultra: `回答极度紧凑。用简短技术表述和常见缩写如 DB/auth/config/req/res/fn。绝不缩写代码符号、API 名、错误串、URL 或标识符。${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const CAVEMAN_OUTPUT_MARKER = "[OmniRoute Caveman Output Mode]";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SHARED_BOUNDARIES, shouldBypassCavemanOutputMode } from "../outputMode.ts";
|
||||
import { detectCompressionLanguage } from "../languageDetector.ts";
|
||||
import { OUTPUT_STYLE_IDS, outputStyleMeta } from "./catalog.ts";
|
||||
|
||||
export type OutputStyleLevel = "lite" | "full" | "ultra";
|
||||
@@ -29,6 +30,51 @@ export interface OutputStylesResult {
|
||||
appliedStyles?: OutputStyleSelectionEntry[];
|
||||
}
|
||||
|
||||
|
||||
interface OutputStyleLanguageConfig {
|
||||
enabled?: boolean;
|
||||
autoDetect?: boolean;
|
||||
defaultLanguage?: string;
|
||||
}
|
||||
|
||||
function lastUserText(body: ChatRequestBody): string {
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message?.role !== "user") continue;
|
||||
if (typeof message.content === "string" && message.content.trim()) return message.content;
|
||||
if (Array.isArray(message.content)) {
|
||||
const text = message.content
|
||||
.map((part) =>
|
||||
part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
|
||||
? (part as { text: string }).text
|
||||
: ""
|
||||
)
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which language the output-style instructions inject in.
|
||||
* Disabled → en. autoDetect → language of the latest user message (the input
|
||||
* engines already use the same detector); otherwise the configured default.
|
||||
*/
|
||||
export function resolveOutputStyleLanguage(
|
||||
languageConfig: OutputStyleLanguageConfig | undefined,
|
||||
body: ChatRequestBody
|
||||
): string {
|
||||
if (languageConfig?.enabled !== true) return "en";
|
||||
if (languageConfig.autoDetect === true) {
|
||||
const text = lastUserText(body);
|
||||
if (text) return detectCompressionLanguage(text);
|
||||
}
|
||||
return languageConfig.defaultLanguage || "en";
|
||||
}
|
||||
|
||||
/** Single idempotency marker guarding the unified injection (D-A: one marker for all styles). */
|
||||
export const OUTPUT_STYLE_MARKER = "[OmniRoute Output Styles]";
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@ export const OUTPUT_STYLE_CATALOG: Record<string, OutputStyle> = {
|
||||
levels: CAVEMAN_INSTRUCTION_BY_LANGUAGE.en,
|
||||
i18n: {
|
||||
"pt-BR": CAVEMAN_INSTRUCTION_BY_LANGUAGE["pt-BR"],
|
||||
es: CAVEMAN_INSTRUCTION_BY_LANGUAGE.es,
|
||||
de: CAVEMAN_INSTRUCTION_BY_LANGUAGE.de,
|
||||
fr: CAVEMAN_INSTRUCTION_BY_LANGUAGE.fr,
|
||||
it: CAVEMAN_INSTRUCTION_BY_LANGUAGE.it,
|
||||
ru: CAVEMAN_INSTRUCTION_BY_LANGUAGE.ru,
|
||||
zh: CAVEMAN_INSTRUCTION_BY_LANGUAGE.zh,
|
||||
ja: CAVEMAN_INSTRUCTION_BY_LANGUAGE.ja,
|
||||
id: CAVEMAN_INSTRUCTION_BY_LANGUAGE.id,
|
||||
vi: CAVEMAN_INSTRUCTION_BY_LANGUAGE.vi,
|
||||
@@ -75,6 +81,36 @@ export const OUTPUT_STYLE_CATALOG: Record<string, OutputStyle> = {
|
||||
full: `Bertindak seperti dev senior malas yang menerapkan YAGNI. Hanya perubahan terkecil yang berfungsi. Tanpa abstraksi yang tidak diminta, generalisasi prematur, lapisan ekstra, atau scaffolding defensif yang tidak diminta. Pakai ulang kode yang ada daripada menambah kode baru. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `Disiplin diff minimal. Sentuh baris sesedikit mungkin yang membuatnya berfungsi. Nol file, kelas, atau config baru kecuali sangat diperlukan. Inline daripada abstract. Tanpa tambahan "mumpung di sini". ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
es: {
|
||||
lite: `Escribe el cambio más pequeño que satisfaga la petición. Evita abstracciones especulativas. ${SHARED_BOUNDARIES}`,
|
||||
full: `Actúa como un dev senior perezoso aplicando YAGNI. Solo el cambio funcional más pequeño. Sin abstracciones no pedidas, sin generalización prematura, sin capas extra, sin andamiaje defensivo que la petición no pidió. Reutiliza código existente antes que añadir código nuevo. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `Disciplina de diff mínimo. Toca las menos líneas que lo hagan funcionar. Cero archivos, clases o config nuevos salvo estricta necesidad. Inline antes que abstracto. Sin extras de "ya que estamos". ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
de: {
|
||||
lite: `Schreibe die kleinste Änderung, die die Anforderung erfüllt. Keine spekulativen Abstraktionen. ${SHARED_BOUNDARIES}`,
|
||||
full: `Handle wie ein fauler Senior-Entwickler mit YAGNI. Nur die kleinste funktionierende Änderung. Keine unbestellten Abstraktionen, keine vorzeitige Generalisierung, keine Extra-Schichten, kein defensives Gerüst, das die Anforderung nicht verlangt hat. Bestehenden Code wiederverwenden statt neuen hinzufügen. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `Minimal-Diff-Disziplin. So wenige Zeilen anfassen wie nötig. Null neue Dateien, Klassen oder Config, außer zwingend erforderlich. Inline statt abstrakt. Keine "Wenn wir schon dabei sind"-Extras. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
fr: {
|
||||
lite: `Écris le plus petit changement qui satisfait la demande. Évite les abstractions spéculatives. ${SHARED_BOUNDARIES}`,
|
||||
full: `Agis comme un dev senior paresseux appliquant YAGNI. Uniquement le plus petit changement fonctionnel. Pas d'abstractions non demandées, pas de généralisation prématurée, pas de couches en plus, pas d'échafaudage défensif que la demande n'a pas exigé. Réutilise le code existant plutôt que d'en ajouter. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `Discipline du diff minimal. Touche le moins de lignes possible pour que ça marche. Zéro nouveau fichier, classe ou config sauf stricte nécessité. Inline plutôt qu'abstrait. Pas d'extras « tant qu'on y est ». ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
it: {
|
||||
lite: `Scrivi la modifica più piccola che soddisfa la richiesta. Evita astrazioni speculative. ${SHARED_BOUNDARIES}`,
|
||||
full: `Agisci come un dev senior pigro che applica YAGNI. Solo la modifica funzionante più piccola. Niente astrazioni non richieste, niente generalizzazione prematura, niente strati extra, niente impalcature difensive che la richiesta non ha chiesto. Riusa il codice esistente invece di aggiungerne di nuovo. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `Disciplina del diff minimo. Tocca il minor numero di righe che lo fa funzionare. Zero nuovi file, classi o config se non strettamente necessari. Inline invece che astratto. Niente extra "già che ci siamo". ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
ru: {
|
||||
lite: `Пиши наименьшее изменение, которое закрывает запрос. Без спекулятивных абстракций. ${SHARED_BOUNDARIES}`,
|
||||
full: `Действуй как ленивый сеньор с YAGNI. Только наименьшее работающее изменение. Без незапрошенных абстракций, без преждевременного обобщения, без лишних слоёв, без защитных лесов, которых запрос не требовал. Переиспользуй существующий код вместо добавления нового. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `Дисциплина минимального diff. Трогай как можно меньше строк. Ноль новых файлов, классов или конфигов без строгой необходимости. Inline вместо абстракции. Без довесков «раз уж мы здесь». ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
zh: {
|
||||
lite: `写出满足需求的最小改动。跳过投机性的抽象。${SHARED_BOUNDARIES}`,
|
||||
full: `像一名践行 YAGNI 的懒惰资深开发者。只做最小的可用改动。不写未被要求的抽象,不做过早的泛化,不加多余的层,不搭需求没要的防御性脚手架。优先复用现有代码而不是新增代码。${SHARED_BOUNDARIES}`,
|
||||
ultra: `最小 diff 纪律。只动让它能工作的最少行数。除非绝对必要,零新文件、新类、新配置。内联优于抽象。不做"顺手再改点"的额外事。${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Ponytail (lazy-senior-dev mode) — integrated into the output-style registry
|
||||
@@ -116,6 +152,36 @@ export const OUTPUT_STYLE_CATALOG: Record<string, OutputStyle> = {
|
||||
full: `# Ponytail — dev senior malas\n\nKamu adalah senior developer yang malas. Malas = efisien, bukan ceroboh. Kode terbaik adalah kode yang tidak pernah ditulis.\n\nSebelum menulis kode, berhenti di anak tangga pertama yang tepat:\n1. Apakah ini perlu? (YAGNI)\n2. Sudah ada di codebase? Pakai ulang.\n3. Stdlib melakukan ini? Pakai.\n4. Fitur platform atau dep terinstal mencakup? Pakai.\n5. Bisa satu baris? Buat satu baris.\n6. Baru tulis minimum yang bekerja.\n\nPerbaiki bug = akar masalah, bukan gejala. Grep semua pemanggil fungsi yang disentuh; perbaiki fungsi bersama sekali — satu guard di sana lebih kecil daripada satu guard per pemanggil.\n\nAturan:\n- Tanpa abstraksi yang tidak diminta. Tanpa dep baru. Tanpa boilerplate.\n- Hapus > tambah. Membosankan > cerdas. Paling sedikit file.\n- Diff terpendek menang — tapi hanya setelah paham masalah.\n- Tanyai permintaan kompleks: "Kamu perlu X, atau Y mencakup?"\n- Saat dua solusi imbang, pilih yang benar untuk edge-case. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Ponytail (ultra)\nDev senior malas. Kode terbaik = tak pernah ditulis. Sebelum kode: YAGNI → pakai ulang → stdlib → platform → dep → satu baris → minimum. Perbaiki akar, bukan gejala: grep semua caller, perbaiki fungsi bersama sekali. Tanpa abstraksi tak diminta, tanpa dep baru, tanpa boilerplate. Hapus > tambah. Paling sedikit file. Diff terpendek, hanya setelah paham masalah. Tanya permintaan kompleks. Edge-case benar saat imbang. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
es: {
|
||||
lite: `# Ponytail (lite)\nAntes de escribir código: ¿necesita existir? ¿Ya existe aquí? ¿La stdlib o una dependencia instalada lo cubre? Solo entonces: escribe el mínimo. Reutilizar antes que reescribir. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Ponytail — dev senior perezoso\n\nEres un desarrollador senior perezoso. Perezoso = eficiente, no descuidado. El mejor código es el que nunca se escribió.\n\nAntes de escribir código, detente en el primer peldaño que aguante:\n1. ¿Esto necesita existir? (YAGNI)\n2. ¿Ya existe en este codebase? Reutilízalo.\n3. ¿La stdlib lo hace? Úsala.\n4. ¿Una función de la plataforma o una dependencia instalada lo cubre? Úsala.\n5. ¿Puede ser una línea? Hazlo una línea.\n6. Solo entonces: escribe el mínimo que funcione.\n\nCorregir un bug = causa raíz, no síntoma. Haz grep de cada caller de la función que tocas; corrige la función compartida una vez — un guard ahí es un diff más pequeño que uno por caller.\n\nReglas:\n- Sin abstracciones no pedidas. Sin dependencias nuevas. Sin boilerplate.\n- Borrar > añadir. Aburrido > ingenioso. Menos archivos.\n- Gana el diff más corto que funcione — pero solo después de entender el problema.\n- Cuestiona peticiones complejas: "¿Necesitas X, o Y lo cubre?"\n- Si dos soluciones empatan, elige la correcta en los edge cases. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Ponytail (ultra)\nDev senior perezoso. Mejor código = el que nunca se escribió. Antes de codificar: YAGNI → reutilizar → stdlib → plataforma → dependencia → una línea → mínimo que funcione. Corrige la causa raíz, no el síntoma: grep a cada caller, parchea la función compartida una vez. Sin abstracciones no pedidas, sin dependencias nuevas, sin boilerplate. Borrar > añadir. Menos archivos. Diff más corto, solo tras entender el problema. Cuestiona lo complejo. Ante empate, lo correcto en edge cases. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
de: {
|
||||
lite: `# Ponytail (lite)\nVor dem Codeschreiben: Muss das existieren? Existiert es hier schon? Deckt die Stdlib oder eine installierte Dependency es ab? Erst dann: das Minimum schreiben. Wiederverwenden statt neu schreiben. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Ponytail — fauler Senior-Entwickler\n\nDu bist ein fauler Senior-Entwickler. Faul = effizient, nicht nachlässig. Der beste Code ist der, der nie geschrieben wurde.\n\nBevor du Code schreibst, halte auf der ersten tragfähigen Stufe an:\n1. Muss das existieren? (YAGNI)\n2. Existiert es schon in dieser Codebase? Wiederverwenden.\n3. Kann die Stdlib das? Nutzen.\n4. Deckt ein Plattform-Feature oder eine installierte Dependency es ab? Nutzen.\n5. Geht es in einer Zeile? Mach eine Zeile draus.\n6. Erst dann: das Minimum schreiben, das funktioniert.\n\nBugfix = Ursache, nicht Symptom. Grep jeden Caller der Funktion, die du anfasst; fixe die gemeinsame Funktion einmal — ein Guard dort ist ein kleinerer Diff als einer pro Caller.\n\nRegeln:\n- Keine unbestellten Abstraktionen. Keine neuen Dependencies. Kein Boilerplate.\n- Löschen > Hinzufügen. Langweilig > clever. So wenige Dateien wie möglich.\n- Der kürzeste funktionierende Diff gewinnt — aber erst, nachdem du das Problem verstanden hast.\n- Hinterfrage komplexe Anforderungen: "Brauchst du X, oder deckt Y es ab?"\n- Bei Gleichstand die Lösung wählen, die in Edge Cases korrekt ist. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Ponytail (ultra)\nFauler Senior-Entwickler. Bester Code = nie geschrieben. Vor jedem Code: YAGNI → wiederverwenden → Stdlib → Plattform → Dependency → eine Zeile → funktionierendes Minimum. Ursache fixen, nicht Symptom: jeden Caller greppen, gemeinsame Funktion einmal patchen. Keine unbestellten Abstraktionen, keine neuen Dependencies, kein Boilerplate. Löschen > Hinzufügen. Wenigste Dateien. Kürzester funktionierender Diff, erst nach Verständnis des Problems. Komplexes hinterfragen. Bei Gleichstand: korrekt in Edge Cases. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
fr: {
|
||||
lite: `# Ponytail (lite)\nAvant d'écrire du code : doit-il exister ? Existe-t-il déjà ici ? La stdlib ou une dépendance installée le couvre-t-elle ? Seulement alors : écris le minimum. Réutiliser plutôt que réécrire. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Ponytail — dev senior paresseux\n\nTu es un développeur senior paresseux. Paresseux = efficace, pas négligent. Le meilleur code est celui qui n'a jamais été écrit.\n\nAvant d'écrire du code, arrête-toi au premier barreau qui tient :\n1. Cela doit-il exister ? (YAGNI)\n2. Existe-t-il déjà dans cette codebase ? Réutilise-le.\n3. La stdlib le fait ? Utilise-la.\n4. Une fonctionnalité de la plateforme ou une dépendance installée le couvre ? Utilise-la.\n5. Tient-il en une ligne ? Fais-en une ligne.\n6. Seulement alors : écris le minimum qui fonctionne.\n\nCorriger un bug = cause racine, pas symptôme. Grep chaque caller de la fonction touchée ; corrige la fonction partagée une fois — un guard là est un diff plus petit qu'un par caller.\n\nRègles :\n- Pas d'abstractions non demandées. Pas de nouvelles dépendances. Pas de boilerplate.\n- Supprimer > ajouter. Ennuyeux > malin. Le moins de fichiers possible.\n- Le diff fonctionnel le plus court gagne — mais seulement après avoir compris le problème.\n- Questionne les demandes complexes : « As-tu besoin de X, ou Y suffit-il ? »\n- À égalité, choisis la solution correcte dans les edge cases. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Ponytail (ultra)\nDev senior paresseux. Meilleur code = jamais écrit. Avant tout code : YAGNI → réutiliser → stdlib → plateforme → dépendance → une ligne → minimum fonctionnel. Corrige la cause racine, pas le symptôme : grep chaque caller, patch la fonction partagée une fois. Pas d'abstractions non demandées, pas de nouvelles dépendances, pas de boilerplate. Supprimer > ajouter. Moins de fichiers. Diff le plus court, seulement après compréhension du problème. Questionner le complexe. À égalité : correct dans les edge cases. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
it: {
|
||||
lite: `# Ponytail (lite)\nPrima di scrivere codice: deve esistere? Esiste già qui? La stdlib o una dipendenza installata lo copre? Solo allora: scrivi il minimo. Riusare invece di riscrivere. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Ponytail — dev senior pigro\n\nSei uno sviluppatore senior pigro. Pigro = efficiente, non trascurato. Il codice migliore è quello mai scritto.\n\nPrima di scrivere codice, fermati al primo gradino che regge:\n1. Deve esistere? (YAGNI)\n2. Esiste già in questa codebase? Riusalo.\n3. La stdlib lo fa? Usala.\n4. Una feature della piattaforma o una dipendenza installata lo copre? Usala.\n5. Può stare in una riga? Falla in una riga.\n6. Solo allora: scrivi il minimo che funziona.\n\nBug fix = causa radice, non sintomo. Fai grep di ogni caller della funzione che tocchi; correggi la funzione condivisa una volta — un guard lì è un diff più piccolo di uno per caller.\n\nRegole:\n- Niente astrazioni non richieste. Niente nuove dipendenze. Niente boilerplate.\n- Cancellare > aggiungere. Noioso > ingegnoso. Meno file possibile.\n- Vince il diff funzionante più corto — ma solo dopo aver capito il problema.\n- Metti in dubbio le richieste complesse: "Ti serve X, o basta Y?"\n- A parità, scegli la soluzione corretta negli edge case. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Ponytail (ultra)\nDev senior pigro. Codice migliore = mai scritto. Prima del codice: YAGNI → riuso → stdlib → piattaforma → dipendenza → una riga → minimo funzionante. Correggi la causa radice, non il sintomo: grep di ogni caller, patch della funzione condivisa una volta. Niente astrazioni non richieste, niente nuove dipendenze, niente boilerplate. Cancellare > aggiungere. Meno file. Diff più corto, solo dopo aver capito il problema. Dubita del complesso. A parità: corretto negli edge case. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
ru: {
|
||||
lite: `# Ponytail (лайт)\nПрежде чем писать код: это должно существовать? Уже есть здесь? Покрывает ли stdlib или установленная зависимость? Только потом: пиши минимум. Переиспользуй, а не переписывай. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Ponytail — ленивый сеньор\n\nТы ленивый сеньор-разработчик. Ленивый = эффективный, а не небрежный. Лучший код — тот, что не был написан.\n\nПрежде чем писать код, остановись на первой ступени, которая держит:\n1. Это должно существовать? (YAGNI)\n2. Уже есть в этой кодовой базе? Переиспользуй.\n3. Stdlib это умеет? Используй.\n4. Возможность платформы или установленная зависимость покрывает? Используй.\n5. Помещается в одну строку? Сделай одной строкой.\n6. Только потом: напиши минимум, который работает.\n\nБагфикс = первопричина, а не симптом. Сделай grep по всем caller'ам функции, которую трогаешь; исправь общую функцию один раз — один guard там меньше, чем guard на каждый caller.\n\nПравила:\n- Никаких незапрошенных абстракций. Никаких новых зависимостей. Никакого boilerplate.\n- Удалить > добавить. Скучное > хитрое. Минимум файлов.\n- Побеждает кратчайший работающий diff — но только после понимания проблемы.\n- Подвергай сомнению сложные запросы: «Тебе нужен X, или хватит Y?»\n- При равенстве выбирай решение, корректное в edge case. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Ponytail (ультра)\nЛенивый сеньор. Лучший код = ненаписанный. Перед кодом: YAGNI → переиспользование → stdlib → платформа → зависимость → одна строка → работающий минимум. Чини первопричину, не симптом: grep по всем caller'ам, патчи общую функцию один раз. Без незапрошенных абстракций, без новых зависимостей, без boilerplate. Удалить > добавить. Минимум файлов. Кратчайший diff — только после понимания проблемы. Сомневайся в сложном. При равенстве — корректность в edge case. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
zh: {
|
||||
lite: `# Ponytail(精简)\n写代码之前:它需要存在吗?这里已经有了吗?标准库或已安装的依赖能覆盖吗?然后才写最小实现。复用优于重写。${SHARED_BOUNDARIES}`,
|
||||
full: `# Ponytail — 懒惰的资深开发者\n\n你是一名懒惰的资深开发者。懒惰 = 高效,而非马虎。最好的代码是从未写出的代码。\n\n写任何代码之前,停在第一个站得住的台阶上:\n1. 它需要存在吗?(YAGNI)\n2. 代码库里已经有了吗?复用它。\n3. 标准库能做吗?用它。\n4. 平台能力或已安装的依赖能覆盖吗?用它。\n5. 一行能写完吗?写成一行。\n6. 然后才写:能工作的最小实现。\n\n修 bug = 根因,而非症状。对你要改的函数 grep 所有调用方;把共享函数修一次 — 在那里加一个 guard,比每个调用方各加一个的 diff 更小。\n\n规则:\n- 不写未被要求的抽象。不加新依赖。不写样板代码。\n- 删除 > 添加。朴实 > 取巧。文件越少越好。\n- 最短的可用 diff 获胜 — 但必须先理解问题。\n- 质疑复杂需求:"你需要 X,还是 Y 就够了?"\n- 两个方案打平时,选边界情况下正确的那个。${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Ponytail(极简)\n懒惰资深开发者。最好的代码 = 从未写出。写码前:YAGNI → 复用 → 标准库 → 平台 → 依赖 → 一行 → 最小可用。修根因不修症状:grep 所有调用方,共享函数只修一次。不要未被要求的抽象、新依赖、样板代码。删除 > 添加。文件最少。最短可用 diff,理解问题之后才算。质疑复杂需求。打平时选边界情况正确者。${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
// i-have-adhd (action-first output) — integrated into the output-style registry
|
||||
@@ -155,6 +221,36 @@ export const OUTPUT_STYLE_CATALOG: Record<string, OutputStyle> = {
|
||||
full: `# Saya punya ADHD — keluaran yang mengutamakan aksi\n\nPembaca punya ADHD. Bentuk keluaran supaya otak ADHD bisa langsung bertindak:\n1. Mulai dari aksi berikutnya — perintah, path, atau cuplikan kode dulu; konteks belakangan, kalau perlu.\n2. Beri nomor untuk pekerjaan banyak langkah; tiap langkah satu aksi terbatas; pakai langkah sesedikit mungkin yang tetap jalan.\n3. Akhiri dengan SATU langkah konkret yang bisa dikerjakan di bawah dua menit.\n4. Tahan bahasan sampingan: selesaikan yang pertama, tawarkan yang kedua sebagai pertanyaan terpisah.\n5. Pada pekerjaan banyak giliran, ulangi posisi saat ini ("langkah 3 dari 5 selesai") — pembaca tidak menyimpan status antar pesan.\n6. Kalau ada usaha manusia, perkirakan dalam satuan konkret (menit, satu sore), jangan "agak butuh kerja".\n7. Tunjukkan hasil: sebutkan apa yang sekarang jalan dan cara mencobanya.\n8. Error apa adanya: sebab dan perbaikannya; jangan "Waduh".\n9. Daftar maksimal 5 butir; lebih dari itu pisahkan "sekarang" dan "nanti".\n10. Tanpa pembuka, tanpa rekap, tanpa basa-basi penutup ("Semoga membantu").\nPengecualian: permintaan eksplisit "jelaskan" dapat isi penuh (tetap tanpa pembuka/penutup); aksi merusak dikonfirmasi dulu; ambiguitas nyata dapat satu pertanyaan singkat. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Saya punya ADHD (ultra)\nAksi dulu: perintah/path/cuplikan, prosa kalau perlu. Langkah bernomor dan terbatas, sesedikit mungkin. SATU langkah <2 menit di akhir. Tanpa bahasan sampingan — jadikan pertanyaan terpisah. Banyak giliran: ulangi status. Usaha manusia: satuan waktu konkret. Hasil terlihat. Error: sebab + perbaikan. Daftar ≤5. Nol pembuka/rekap/penutup. "Jelaskan" dapat isi penuh; aksi merusak dikonfirmasi; ambiguitas nyata dapat satu pertanyaan. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
es: {
|
||||
lite: `# Tengo TDAH (lite)\nEmpieza por la acción: comando, ruta o snippet primero, prosa después. Numera el trabajo multi-paso; cada paso es una acción acotada. Termina con UNA próxima acción concreta. Sin preámbulo, sin resumen, sin despedidas. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Tengo TDAH — salida orientada a la acción\n\nQuien lee tiene TDAH. Da forma a la salida para que un cerebro con TDAH pueda actuar:\n1. Empieza por la próxima acción — comando, ruta o snippet primero; contexto después, si hace falta.\n2. Numera el trabajo multi-paso; cada paso es una acción acotada; usa los mínimos pasos que funcionen.\n3. Termina con UNA acción concreta realizable en menos de dos minutos.\n4. Suprime tangentes: cierra el primer asunto, ofrece el segundo como pregunta aparte.\n5. En trabajo multi-turno, reafirma dónde están las cosas ("paso 3 de 5 hecho") — quien lee no retiene estado entre mensajes.\n6. Si hay esfuerzo humano, estímalo en unidades concretas (minutos, una tarde), nunca "algo de trabajo".\n7. Haz visibles los logros: di qué funciona ya y cómo probarlo.\n8. Errores sin drama: causa y arreglo; nunca "¡Uy!".\n9. Listas de 5 ítems como máximo; más allá, divide en "ahora" vs "después".\n10. Sin preámbulo, sin resumen, sin cierres ("Espero que ayude").\nExcepciones: una petición explícita de "explica" recibe cuerpo completo (aún sin preámbulo/cierre); las acciones destructivas piden confirmación antes; la ambigüedad real recibe una pregunta corta. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Tengo TDAH (ultra)\nAcción primero: comando/ruta/snippet, luego prosa si hace falta. Pasos numerados y acotados, los mínimos que funcionen. UNA próxima acción <2 min al final. Sin tangentes — pregunta aparte. Multi-turno: reafirma el estado. Esfuerzo humano: unidades concretas de tiempo. Logros visibles. Errores: causa + arreglo. Listas ≤5. Cero preámbulo/resumen/cierres. "Explica" recibe cuerpo completo; lo destructivo pide confirmación; la ambigüedad real recibe una pregunta. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
de: {
|
||||
lite: `# Ich habe ADHS (lite)\nBeginne mit der Aktion: Befehl, Pfad oder Snippet zuerst, Prosa danach. Nummeriere mehrschrittige Arbeit; jeder Schritt eine begrenzte Aktion. Ende mit EINEM konkreten nächsten Schritt. Kein Vorwort, keine Zusammenfassung, keine Verabschiedung. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Ich habe ADHS — aktionsorientierte Ausgabe\n\nDie lesende Person hat ADHS. Forme die Ausgabe so, dass ein ADHS-Gehirn danach handeln kann:\n1. Beginne mit der nächsten Aktion — Befehl, Pfad oder Snippet zuerst; Kontext danach, falls überhaupt.\n2. Nummeriere mehrschrittige Arbeit; jeder Schritt ist eine begrenzte Aktion; so wenige Schritte wie möglich.\n3. Ende mit EINEM konkreten nächsten Schritt, machbar in unter zwei Minuten.\n4. Unterdrücke Abschweifungen: schließe das erste Thema ab, biete das zweite als separate Frage an.\n5. Bei Arbeit über mehrere Runden den Stand wiederholen („Schritt 3 von 5 fertig") — die lesende Person hält keinen Zustand zwischen Nachrichten.\n6. Bei menschlichem Aufwand in konkreten Einheiten schätzen (Minuten, ein Nachmittag), nie „etwas Arbeit".\n7. Erfolge sichtbar machen: sag, was jetzt funktioniert und wie man es ausprobiert.\n8. Fehler sachlich: Ursache und Fix; nie „Hoppla".\n9. Listen mit höchstens 5 Punkten; darüber in „jetzt" vs. „später" teilen.\n10. Kein Vorwort, keine Zusammenfassung, keine Schlussfloskeln („Ich hoffe, das hilft").\nAusnahmen: eine explizite „Erkläre"-Anfrage bekommt einen vollen Text (weiterhin ohne Vorwort/Abschluss); destruktive Aktionen erst bestätigen lassen; echte Mehrdeutigkeit bekommt eine kurze Rückfrage. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Ich habe ADHS (ultra)\nAktion zuerst: Befehl/Pfad/Snippet, dann Prosa falls nötig. Nummerierte, begrenzte Schritte, so wenige wie möglich. EIN nächster Schritt <2 Min am Ende. Keine Abschweifungen — separate Frage. Mehrere Runden: Stand wiederholen. Menschlicher Aufwand: konkrete Zeiteinheiten. Erfolge sichtbar. Fehler: Ursache + Fix. Listen ≤5. Null Vorwort/Zusammenfassung/Floskeln. „Erkläre" bekommt vollen Text; Destruktives braucht Bestätigung; echte Mehrdeutigkeit eine Frage. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
fr: {
|
||||
lite: `# J'ai un TDAH (lite)\nCommence par l'action : commande, chemin ou snippet d'abord, prose ensuite. Numérote le travail multi-étapes ; chaque étape est une action délimitée. Termine par UNE prochaine action concrète. Pas de préambule, pas de récapitulatif, pas de formules de politesse. ${SHARED_BOUNDARIES}`,
|
||||
full: `# J'ai un TDAH — sortie orientée action\n\nLa personne qui lit a un TDAH. Façonne la sortie pour qu'un cerveau TDAH puisse agir :\n1. Commence par la prochaine action — commande, chemin ou snippet d'abord ; le contexte ensuite, si nécessaire.\n2. Numérote le travail multi-étapes ; chaque étape est une action délimitée ; le moins d'étapes possible.\n3. Termine par UNE action concrète faisable en moins de deux minutes.\n4. Supprime les digressions : termine le premier sujet, propose le second comme question séparée.\n5. Sur plusieurs tours, redis où on en est (« étape 3 sur 5 faite ») — la personne ne retient pas l'état entre les messages.\n6. Quand un effort humain est en jeu, estime-le en unités concrètes (minutes, un après-midi), jamais « un peu de travail ».\n7. Rends les victoires visibles : dis ce qui marche désormais et comment l'essayer.\n8. Erreurs sans drame : cause et correctif ; jamais « Oups ».\n9. Listes de 5 éléments max ; au-delà, sépare « maintenant » vs « plus tard ».\n10. Pas de préambule, pas de récapitulatif, pas de conclusion (« En espérant que ça aide »).\nExceptions : une demande explicite d'« explication » reçoit un corps complet (toujours sans préambule/conclusion) ; les actions destructives demandent confirmation d'abord ; une vraie ambiguïté reçoit une courte question. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# J'ai un TDAH (ultra)\nAction d'abord : commande/chemin/snippet, puis prose si besoin. Étapes numérotées et délimitées, le minimum qui fonctionne. UNE action <2 min à la fin. Pas de digressions — question séparée. Multi-tours : redire l'état. Effort humain : unités de temps concrètes. Victoires visibles. Erreurs : cause + correctif. Listes ≤5. Zéro préambule/récap/conclusion. « Explique » reçoit un corps complet ; le destructif demande confirmation ; la vraie ambiguïté reçoit une question. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
it: {
|
||||
lite: `# Ho l'ADHD (lite)\nParti dall'azione: comando, percorso o snippet prima, prosa dopo. Numera il lavoro multi-passo; ogni passo è un'azione delimitata. Chiudi con UNA prossima azione concreta. Niente preamboli, niente riassunti, niente saluti finali. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Ho l'ADHD — output orientato all'azione\n\nChi legge ha l'ADHD. Modella l'output perché un cervello ADHD possa agire:\n1. Parti dalla prossima azione — comando, percorso o snippet prima; contesto dopo, se serve.\n2. Numera il lavoro multi-passo; ogni passo è un'azione delimitata; usa i minimi passi che funzionano.\n3. Chiudi con UNA azione concreta fattibile in meno di due minuti.\n4. Sopprimi le tangenti: chiudi il primo tema, offri il secondo come domanda separata.\n5. Nel lavoro multi-turno, ripeti a che punto siamo ("passo 3 di 5 fatto") — chi legge non trattiene lo stato tra i messaggi.\n6. Se c'è sforzo umano, stimalo in unità concrete (minuti, un pomeriggio), mai "un po' di lavoro".\n7. Rendi visibili i risultati: di' cosa funziona ora e come provarlo.\n8. Errori senza drammi: causa e fix; mai "Ops".\n9. Liste di massimo 5 voci; oltre, separa "ora" vs "dopo".\n10. Niente preamboli, niente riassunti, niente chiusure ("Spero sia utile").\nEccezioni: una richiesta esplicita di "spiegare" riceve un corpo completo (sempre senza preambolo/chiusura); le azioni distruttive chiedono prima conferma; l'ambiguità reale riceve una domanda breve. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Ho l'ADHD (ultra)\nAzione prima: comando/percorso/snippet, poi prosa se serve. Passi numerati e delimitati, i minimi che funzionano. UNA azione <2 min alla fine. Niente tangenti — domanda separata. Multi-turno: ripeti lo stato. Sforzo umano: unità di tempo concrete. Risultati visibili. Errori: causa + fix. Liste ≤5. Zero preamboli/riassunti/chiusure. "Spiega" riceve corpo completo; il distruttivo chiede conferma; l'ambiguità reale riceve una domanda. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
ru: {
|
||||
lite: `# У меня СДВГ (лайт)\nНачинай с действия: команда, путь или сниппет сначала, проза потом. Нумеруй многошаговую работу; каждый шаг — одно ограниченное действие. Заверши ОДНИМ конкретным следующим шагом. Без вступлений, без пересказа, без прощаний. ${SHARED_BOUNDARIES}`,
|
||||
full: `# У меня СДВГ — вывод, ориентированный на действие\n\nЧитатель с СДВГ. Оформи вывод так, чтобы мозг с СДВГ мог сразу действовать:\n1. Начинай со следующего действия — команда, путь или сниппет сначала; контекст потом, если вообще нужен.\n2. Нумеруй многошаговую работу; каждый шаг — одно ограниченное действие; минимально работающее число шагов.\n3. Завершай ОДНИМ конкретным шагом, выполнимым меньше чем за две минуты.\n4. Отсекай отступления: закончи первый вопрос, второй предложи отдельным вопросом.\n5. В многоходовой работе повторяй, где мы находимся («шаг 3 из 5 готов») — читатель не удерживает состояние между сообщениями.\n6. Если нужен человеческий труд, оценивай в конкретных единицах (минуты, полдня), никогда «немного работы».\n7. Делай победы видимыми: скажи, что уже работает и как это попробовать.\n8. Ошибки по-деловому: причина и исправление; никаких «Ой».\n9. Списки не длиннее 5 пунктов; дальше дели на «сейчас» и «потом».\n10. Без вступлений, без пересказа, без концовок («Надеюсь, помогло»).\nИсключения: явная просьба «объясни» получает полный текст (по-прежнему без вступления/концовки); разрушительные действия сначала подтверждаются; настоящая неоднозначность получает один короткий вопрос. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# У меня СДВГ (ультра)\nСначала действие: команда/путь/сниппет, потом проза при необходимости. Нумерованные ограниченные шаги, минимум работающих. ОДИН шаг <2 мин в конце. Без отступлений — отдельный вопрос. Много ходов: повторяй состояние. Человеческий труд: конкретные единицы времени. Победы видимы. Ошибки: причина + исправление. Списки ≤5. Ноль вступлений/пересказов/концовок. «Объясни» — полный текст; разрушительное — подтверждение; настоящая неоднозначность — один вопрос. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
zh: {
|
||||
lite: `# 我有 ADHD(精简)\n从行动开始:先给命令、路径或代码片段,散文放后面。多步骤工作要编号;每一步是一个有边界的动作。以一个具体的下一步收尾。不要开场白、不要复述、不要客套结尾。${SHARED_BOUNDARIES}`,
|
||||
full: `# 我有 ADHD — 行动优先的输出\n\n读者有 ADHD。让输出适配 ADHD 的大脑,让人能直接行动:\n1. 从下一步行动开始 — 先给命令、路径或代码片段;上下文放后面,如果需要的话。\n2. 多步骤工作要编号;每一步是一个有边界的动作;用能工作的最少步数。\n3. 以一个两分钟内可完成的具体下一步收尾。\n4. 压住跑题:先完成第一件事,第二件作为单独的问题提出。\n5. 多轮工作要复述进度("5 步中第 3 步已完成")— 读者无法在消息之间保持状态。\n6. 涉及人力时,用具体单位估算(几分钟、一个下午),绝不说"要花点功夫"。\n7. 让成果可见:说清现在什么能用了、怎么试。\n8. 报错就事论事:原因和修法;不说"糟糕"。\n9. 列表最多 5 项;超过就拆成"现在做"和"以后做"。\n10. 不要开场白、不要复述、不要客套结尾("希望有帮助")。\n例外:明确要求"解释"时给完整正文(仍不要开场白/结尾);破坏性操作先确认;真正的歧义提一个简短的问题。${SHARED_BOUNDARIES}`,
|
||||
ultra: `# 我有 ADHD(极简)\n行动优先:命令/路径/片段在前,需要时才有散文。步骤编号且有边界,越少越好。结尾给一个 <2 分钟的下一步。不跑题 — 另起问题。多轮:复述进度。人力:具体时间单位。成果可见。报错:原因 + 修法。列表 ≤5。零开场白/复述/客套。"解释"给完整正文;破坏性操作先确认;真歧义提一个问题。${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
"terse-cjk": {
|
||||
@@ -177,3 +273,14 @@ export const OUTPUT_STYLE_IDS: string[] = Object.keys(OUTPUT_STYLE_CATALOG);
|
||||
export function outputStyleMeta(id: string): OutputStyle {
|
||||
return OUTPUT_STYLE_CATALOG[id];
|
||||
}
|
||||
|
||||
/** Sorted union of every language an output style can instruct in (i18n keys + locale gates + en). */
|
||||
export function outputStyleLanguages(): string[] {
|
||||
const langs = new Set<string>(["en"]);
|
||||
for (const id of OUTPUT_STYLE_IDS) {
|
||||
const meta = OUTPUT_STYLE_CATALOG[id];
|
||||
if (meta.locale) langs.add(meta.locale);
|
||||
for (const lang of Object.keys(meta.i18n ?? {})) langs.add(lang);
|
||||
}
|
||||
return [...langs].sort();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -768,6 +768,15 @@ export function openaiResponsesToOpenAIRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// #12141: When translated Chat tools is empty/absent, strip neutral tool_choice
|
||||
// ("auto" / "none") so strict Chat endpoints (e.g. vLLM) do not reject with 400
|
||||
// ("When using tool_choice, tools must be set"). Contradictory choices like "required"
|
||||
// or forced functions are preserved so the upstream error remains visible.
|
||||
const finalChatTools = Array.isArray(result.tools) ? result.tools : [];
|
||||
if (finalChatTools.length === 0 && (result.tool_choice === "auto" || result.tool_choice === "none")) {
|
||||
delete result.tool_choice;
|
||||
}
|
||||
|
||||
// Cleanup Responses API specific fields
|
||||
// Note: prompt_cache_key is intentionally preserved for OpenAI destinations — it is
|
||||
// used by Codex as a cache-affinity signal and stripping it unconditionally broke
|
||||
|
||||
4088
package-lock.json
generated
4088
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
85
package.json
85
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,8 @@
|
||||
"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",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"scripts/packs/optionalPackManifest.mjs",
|
||||
@@ -102,7 +103,7 @@
|
||||
"build": "node scripts/build/build-next-isolated.mjs",
|
||||
"build:secure": "OMNIROUTE_BUILD_PROFILE=minimal node scripts/build/build-next-isolated.mjs",
|
||||
"build:backend": "cross-env OMNIROUTE_BUILD_BACKEND_ONLY=1 node scripts/build/build-next-isolated.mjs",
|
||||
"build:contributor": "cross-env OMNIROUTE_BUILD_PROFILE=contributor node scripts/build/build-next-isolated.mjs",
|
||||
"build:contributor": "cross-env OMNIROUTE_BUILD_PROFILE=contributor OMNIROUTE_USE_TURBOPACK=0 node scripts/build/build-next-isolated.mjs",
|
||||
"build:cli": "node --import tsx scripts/build/prepublish.ts",
|
||||
"omniroute:verify": "node scripts/check/omniroute-verify.mjs",
|
||||
"build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs",
|
||||
@@ -119,16 +120,16 @@
|
||||
"electron:build:mac": "npm run build && cd electron && npm run build:mac",
|
||||
"electron:build:linux": "npm run build && cd electron && npm run build:linux",
|
||||
"electron:smoke:packaged": "node scripts/dev/smoke-electron-packaged.mjs",
|
||||
"test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\"",
|
||||
"test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"",
|
||||
"test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,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": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\"",
|
||||
"test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"",
|
||||
"test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:scoped": "bash scripts/quality/test-scoped.sh",
|
||||
"test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged",
|
||||
"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,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,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\"",
|
||||
"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\"",
|
||||
"test:bun:db": "bun test tests/unit/db-adapters/bunSqliteAdapter.test.ts tests/unit/db-adapters/driverFactory.test.ts tests/unit/db-adapters/cliSqlite.test.mjs",
|
||||
"test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/plan3-p0.test.ts",
|
||||
"test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/fixes-p1.test.ts",
|
||||
@@ -265,12 +266,12 @@
|
||||
"postbuild": "node scripts/build/colocate-standalone.mjs",
|
||||
"release:contributors": "node scripts/release/gen-contributors.mjs",
|
||||
"release:uncovered": "node scripts/release/list-uncovered-commits.mjs",
|
||||
"test:coverage:runner": "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=8 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,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 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=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:coverage:runner": "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=8 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_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 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=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:unit:serial": "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 \"tests/unit/serial/**/*.test.ts\"",
|
||||
"alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1116.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1120.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -281,10 +282,10 @@
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@toon-format/toon": "^4.1.1",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@xyflow/react": "^12.11.3",
|
||||
"@xyflow/react": "^12.11.5",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"axios": "^1.19.0",
|
||||
"axios": "^1.20.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bottleneck": "^2.19.5",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -295,8 +296,8 @@
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.3",
|
||||
"fflate": "^0.8.3",
|
||||
"fumadocs-core": "^16.15.0",
|
||||
"fumadocs-ui": "^16.15.0",
|
||||
"fumadocs-core": "^16.15.4",
|
||||
"fumadocs-ui": "^16.15.4",
|
||||
"http-proxy-middleware": "^4.0.0",
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"ink": "^7.0.3",
|
||||
@@ -304,17 +305,17 @@
|
||||
"ink-text-input": "^6.0.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"jose": "^6.2.10",
|
||||
"js-yaml": "^5.3.0",
|
||||
"js-yaml": "^5.4.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lowdb": "^7.0.1",
|
||||
"lucide-react": "^1.33.0",
|
||||
"marked": "^18.0.10",
|
||||
"marked": "^18.0.11",
|
||||
"marked-terminal": "^7.3.0",
|
||||
"material-symbols": "^0.46.0",
|
||||
"mermaid": "^11.17.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"next": "16.3.2",
|
||||
"next-intl": "^4.13.7",
|
||||
"next": "16.3.3",
|
||||
"next-intl": "^4.14.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"omniglyph": "^1.4.0",
|
||||
@@ -335,7 +336,7 @@
|
||||
"recharts": "^3.8.1",
|
||||
"safe-regex": "^2.1.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
"sharp": "^0.35.3",
|
||||
"sharp": "^0.35.4",
|
||||
"smol-toml": "1.8.0",
|
||||
"socks": "^2.8.7",
|
||||
"sql.js": "^1.14.2",
|
||||
@@ -346,7 +347,7 @@
|
||||
"turndown-plugin-gfm": "1.0.2",
|
||||
"undici": "^8.10.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"uuid": "^14.0.0",
|
||||
"uuid": "^14.0.2",
|
||||
"ws": "^8.21.3",
|
||||
"xxhash-wasm": "^1.1.0",
|
||||
"yazl": "^3.3.1",
|
||||
@@ -361,12 +362,12 @@
|
||||
"keytar": "^7.9.0",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"sqlite-vec": "^0.1.9",
|
||||
"tls-client-node": "^0.2.0",
|
||||
"wreq-js": "^3.1.0"
|
||||
"wreq-js": "3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.13.0",
|
||||
"@cyclonedx/cyclonedx-npm": "6.0.1",
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@size-limit/file": "^13.0.3",
|
||||
"@stryker-mutator/core": "^10.0.0",
|
||||
@@ -374,13 +375,13 @@
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.6",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/bun": "1.4.0",
|
||||
"@types/node": "^26.4.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
@@ -391,32 +392,33 @@
|
||||
"ctrf": "^0.3.0",
|
||||
"dpdm": "^4.3.0",
|
||||
"esbuild": "0.28.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.3.2",
|
||||
"eslint": "^10.9.0",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"eslint-plugin-sonarjs": "^4.1.0",
|
||||
"espree": "^11.2.0",
|
||||
"fast-check": "^4.8.0",
|
||||
"fumadocs-mdx": "^15.3.1",
|
||||
"fumadocs-mdx": "^15.4.0",
|
||||
"glob": "^13.0.6",
|
||||
"httpyac": "^6.16.7",
|
||||
"husky": "^9.1.7",
|
||||
"jscpd": "^4.3.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"junit-to-ctrf": "^0.0.14",
|
||||
"knip": "^6.32.2",
|
||||
"knip": "^6.32.3",
|
||||
"license-checker-rseidelsohn": "^5.0.1",
|
||||
"lint-staged": "^17.3.0",
|
||||
"lint-staged": "^17.4.1",
|
||||
"lockfile-lint": "^5.0.1",
|
||||
"node-loader": "^2.1.0",
|
||||
"opencode-ai": "1.18.21",
|
||||
"opencode-ai": "1.18.23",
|
||||
"playwright-ctrf-json-reporter": "^0.0.29",
|
||||
"prettier": "^3.9.6",
|
||||
"promptfoo": "^0.122.0",
|
||||
"promptfoo": "^0.122.1",
|
||||
"size-limit": "^13.0.3",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"type-coverage": "^2.30.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
"vitest": "^4.1.11",
|
||||
"wait-on": "^9.1.0",
|
||||
"wtfnode": "^0.10.1"
|
||||
@@ -451,12 +453,13 @@
|
||||
},
|
||||
"overrides": {
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"fast-xml-parser": "^5.10.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sharp": "^0.35.4",
|
||||
"postcss": "^8.5.18",
|
||||
"ip-address": "^10.3.1",
|
||||
"qs": "^6.15.2",
|
||||
"uuid": "^14.0.0",
|
||||
"uuid": "^14.0.2",
|
||||
"form-data": "^4.0.6",
|
||||
"vite": "^8.0.16",
|
||||
"protobufjs": "^7.6.5",
|
||||
@@ -484,10 +487,6 @@
|
||||
},
|
||||
"socket.io-parser": "^4.2.7",
|
||||
"tar": "^7.5.21",
|
||||
"brace-expansion": "^5.0.9",
|
||||
"minimatch": {
|
||||
"brace-expansion": "^1.1.18"
|
||||
},
|
||||
"libxmljs2": {
|
||||
"minimatch": {
|
||||
"brace-expansion": "^2.1.4"
|
||||
@@ -512,7 +511,7 @@
|
||||
},
|
||||
"nanoid": "^3.3.17",
|
||||
"monaco-editor": {
|
||||
"dompurify": "^3.4.13"
|
||||
"dompurify": "^3.4.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,6 +37,14 @@ export const BACKEND_ONLY_STUB_MARKER =
|
||||
"/* omniroute:backend-only-stub (auto-restored after build) */";
|
||||
|
||||
const HEADER = `${BACKEND_ONLY_STUB_MARKER}\n`;
|
||||
// Each instrumentation entrypoint exports its own symbol — `src/instrumentation.ts`
|
||||
// exposes Next's `register()`, while `src/instrumentation-node.ts` exposes the
|
||||
// `registerNodejs()` it dynamically imports. Stub each with the name it really owns so
|
||||
// the stub never misrepresents the file's contract.
|
||||
const INSTRUMENTATION_STUBS = {
|
||||
"instrumentation.ts": `${HEADER}export async function register() {}`,
|
||||
"instrumentation-node.ts": `${HEADER}export async function registerNodejs() {}`,
|
||||
};
|
||||
|
||||
// Leaf page → force-dynamic (never prerendered) server component returning null.
|
||||
const PAGE_STUB = `${HEADER}export const dynamic = "force-dynamic";\nexport default function BackendOnlyPageStub() {\n return null;\n}\n`;
|
||||
@@ -90,6 +98,33 @@ export function isBackendOnlyBuild(env = process.env) {
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the build is intended only for contributor feedback, not packaging. */
|
||||
export function isContributorBuild(env = process.env) {
|
||||
return env.OMNIROUTE_BUILD_PROFILE === "contributor";
|
||||
}
|
||||
|
||||
/** Replace the build-only instrumentation entrypoint to avoid pulling the startup graph. */
|
||||
export function stubContributorInstrumentation(rootDir = process.cwd(), log = console) {
|
||||
const stubbed = [];
|
||||
for (const [basename, stub] of Object.entries(INSTRUMENTATION_STUBS)) {
|
||||
const file = path.join(rootDir, "src", basename);
|
||||
let original;
|
||||
try {
|
||||
original = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (original.includes(BACKEND_ONLY_STUB_MARKER)) continue;
|
||||
try {
|
||||
fs.writeFileSync(file, stub, "utf8");
|
||||
stubbed.push({ file, original });
|
||||
} catch (err) {
|
||||
log.warn?.(`[backend-only] Could not stub ${file}: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
return stubbed;
|
||||
}
|
||||
|
||||
function walkFiles(dir, out = []) {
|
||||
let entries = [];
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from "./assembleStandalone.mjs";
|
||||
import {
|
||||
isBackendOnlyBuild,
|
||||
isContributorBuild,
|
||||
stubContributorInstrumentation,
|
||||
stubDashboardPages,
|
||||
restoreDashboardPages,
|
||||
} from "./backendOnlyPages.mjs";
|
||||
@@ -294,6 +296,12 @@ export async function main() {
|
||||
"[build-next-isolated] OMNIROUTE_BUILD_BACKEND_ONLY set — building API only (dashboard UI stubbed)"
|
||||
);
|
||||
stubbedPages = stubDashboardPages(projectRoot);
|
||||
if (isContributorBuild()) {
|
||||
stubbedPages.push(...stubContributorInstrumentation(projectRoot));
|
||||
console.log(
|
||||
"[build-next-isolated] Contributor profile: instrumentation entrypoint stubbed for compile-only validation"
|
||||
);
|
||||
}
|
||||
process.once("SIGINT", onFatalSignal);
|
||||
process.once("SIGTERM", onFatalSignal);
|
||||
}
|
||||
@@ -302,7 +310,7 @@ export async function main() {
|
||||
|
||||
const result = await runNextBuild();
|
||||
const standaloneDir = path.join(distDir, "standalone");
|
||||
if (result.code === 0 && (await exists(standaloneDir))) {
|
||||
if (result.code === 0 && (await exists(standaloneDir)) && !isContributorBuild()) {
|
||||
try {
|
||||
await fs.cp(path.join(projectRoot, "docs"), path.join(standaloneDir, "docs"), {
|
||||
recursive: true,
|
||||
@@ -369,6 +377,10 @@ export async function main() {
|
||||
} catch (assembleErr) {
|
||||
console.warn("[build-next-isolated] Non-fatal error assembling standalone:", assembleErr);
|
||||
}
|
||||
} else if (result.code === 0 && isContributorBuild()) {
|
||||
console.log(
|
||||
"[build-next-isolated] Contributor profile: skipped standalone packaging (compile-only validation)"
|
||||
);
|
||||
}
|
||||
process.exitCode = result.code;
|
||||
} catch (error) {
|
||||
|
||||
@@ -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,9 +7,9 @@
|
||||
* 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
|
||||
* - Bundled-for-all (verify only): 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>`.
|
||||
* - Install-machine-forked (hydrate): `@img/sharp-*`, `@img/sharp-libvips-*`,
|
||||
* `@ngrok/ngrok-*` and macOS-only `fsevents` resolve to whichever platform
|
||||
@@ -33,8 +33,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 +105,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",
|
||||
|
||||
@@ -94,6 +94,7 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"config/release/wreq-js-native-manifest.json",
|
||||
"bin/aliasResolver.mjs",
|
||||
"bin/chatgpt-web-codex-mcp.mjs",
|
||||
// #7808: ESM loader hook split out of bin/aliasResolver.mjs to silence CodeQL
|
||||
@@ -136,12 +137,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 +221,14 @@ 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",
|
||||
"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 { resolveWreqJsNativeBinaryName } from "./wreqJsNative.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -277,7 +268,7 @@ async function fixWreqJsBinary() {
|
||||
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)"
|
||||
"(wreq-js 3.0.0 does not publish an Android native addon)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -289,7 +280,16 @@ async function fixWreqJsBinary() {
|
||||
return;
|
||||
}
|
||||
|
||||
const binaryName = `wreq-js.${process.platform}-${process.arch}.node`;
|
||||
const binaryName = resolveWreqJsNativeBinaryName({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
});
|
||||
if (!binaryName) {
|
||||
console.warn(
|
||||
` ⚠️ wreq-js 3.0.0 has no native addon for ${process.platform}-${process.arch}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const appBinaryPath = join(appWreqDir, binaryName);
|
||||
const rootBinaryPath = join(rootWreqDir, binaryName);
|
||||
|
||||
@@ -318,27 +318,7 @@ async function fixWreqJsBinary() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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/
|
||||
// Strategy 2: Rebuild wreq-js inside dist/
|
||||
console.log(" 📥 Attempting npm rebuild wreq-js...");
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
@@ -359,8 +339,10 @@ async function fixWreqJsBinary() {
|
||||
console.warn(
|
||||
`\n ⚠️ Could not fix wreq-js native module for ${process.platform}-${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: cd ${join(ROOT, "dist")} && npm install wreq-js@3.0.0 --save-exact\n`
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureSwcHelpers() {
|
||||
@@ -470,7 +452,6 @@ async function ensureStandaloneRuntimePackages() {
|
||||
await verifyDevNativeModules();
|
||||
await fixBetterSqliteBinary();
|
||||
await fixWreqJsBinary();
|
||||
await fixTlsClientNodeBinary({ rootDir: ROOT });
|
||||
await fixPlaywrightAndroid({ rootDir: ROOT });
|
||||
await ensureSwcHelpers();
|
||||
await ensureStandaloneRuntimePackages();
|
||||
|
||||
37
scripts/build/wreqJsNative.mjs
Normal file
37
scripts/build/wreqJsNative.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
/** Exact native-addon layout published by wreq-js 3.0.0. */
|
||||
export const WREQ_JS_NATIVE_BINARY_NAMES = Object.freeze([
|
||||
"wreq-js.darwin-arm64.node",
|
||||
"wreq-js.darwin-x64.node",
|
||||
"wreq-js.linux-arm64-gnu.node",
|
||||
"wreq-js.linux-arm64-musl.node",
|
||||
"wreq-js.linux-x64-gnu.node",
|
||||
"wreq-js.linux-x64-musl.node",
|
||||
"wreq-js.win32-x64-msvc.node",
|
||||
]);
|
||||
|
||||
/** Detect the C library used by the current Linux runtime. */
|
||||
export function detectRuntimeLibc() {
|
||||
if (process.platform !== "linux") return undefined;
|
||||
try {
|
||||
const report = process.report?.getReport();
|
||||
return report?.header?.glibcVersionRuntime ? "gnu" : "musl";
|
||||
} catch {
|
||||
return "musl";
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the exact addon filename that wreq-js 3.0.0 will load. */
|
||||
export function resolveWreqJsNativeBinaryName({ platform, arch, libc }) {
|
||||
if (platform === "darwin" && (arch === "arm64" || arch === "x64")) {
|
||||
return `wreq-js.darwin-${arch}.node`;
|
||||
}
|
||||
if (platform === "linux" && (arch === "arm64" || arch === "x64")) {
|
||||
const linuxLibc = libc ?? detectRuntimeLibc();
|
||||
if (linuxLibc !== "gnu" && linuxLibc !== "musl") return null;
|
||||
return `wreq-js.linux-${arch}-${linuxLibc}.node`;
|
||||
}
|
||||
if (platform === "win32" && arch === "x64") {
|
||||
return "wreq-js.win32-x64-msvc.node";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -61,8 +61,8 @@ const KNOWN_HOOKS = new Set([
|
||||
"onDeactivate",
|
||||
"onUninstall",
|
||||
// Real callbacks wired in code that docs reference (verified present in src/):
|
||||
// onChunk/onFirstChunk — streaming callbacks (src/shared/utils/streamTracker.ts,
|
||||
// playground ChatTab.tsx); onServerStatus/onPortChanged/onUpdateStatus — Electron
|
||||
// onChunk/onFirstChunk — streaming callbacks (playground useStreamMetrics.ts and
|
||||
// ChatTab.tsx); onServerStatus/onPortChanged/onUpdateStatus — Electron
|
||||
// IPC callbacks (src/shared/hooks/useElectron.ts, HomePageClient.tsx);
|
||||
// onEmpty — model-metadata registry callback (src/lib/modelMetadataRegistry.ts).
|
||||
"onChunk",
|
||||
@@ -500,6 +500,7 @@ export function buildCodebaseIndex(root = ROOT) {
|
||||
// LOCAL_ONLY_API_PREFIXES, HALF_OPEN) is NOT a fabricated env var.
|
||||
const envVars = new Set();
|
||||
const codeIdentifiers = new Set();
|
||||
const errorCodes = new Set();
|
||||
function walkForEnv(dir) {
|
||||
const abs = path.join(root, dir);
|
||||
if (!fs.existsSync(abs)) return;
|
||||
@@ -535,6 +536,14 @@ export function buildCodebaseIndex(root = ROOT) {
|
||||
// Object-literal / enum members on their own line: `HALF_OPEN: "HALF_OPEN"`.
|
||||
for (const m of content.matchAll(/^[ \t]*([A-Z][A-Z0-9_]{2,})[ \t]*[:=]/gm))
|
||||
codeIdentifiers.add(m[1]);
|
||||
// Runtime error-code values such as `{ code: "SECURITY_001" }` are
|
||||
// documented protocol identifiers, not environment variables. Keep this
|
||||
// deliberately scoped to code/errorCode properties rather than accepting
|
||||
// arbitrary ALL_CAPS string literals, which could hide a fabricated env var.
|
||||
for (const m of content.matchAll(
|
||||
/\b(?:code|errorCode)\s*:\s*["'`]([A-Z][A-Z0-9_]{2,})["'`]/g
|
||||
))
|
||||
errorCodes.add(m[1]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -603,7 +612,7 @@ export function buildCodebaseIndex(root = ROOT) {
|
||||
}
|
||||
walkCli("bin");
|
||||
|
||||
return { apiRoutes, apiPrefixes, apiMethods, envVars, codeIdentifiers, cliCommands };
|
||||
return { apiRoutes, apiPrefixes, apiMethods, envVars, codeIdentifiers, errorCodes, cliCommands };
|
||||
}
|
||||
|
||||
// ── Doc scanning ───────────────────────────────────────────────────────────
|
||||
@@ -698,6 +707,7 @@ export function scanDocFile(absPath, index, root = ROOT) {
|
||||
// object-literal key) is a code symbol, not a fabricated env var.
|
||||
// E.g. LOCAL_ONLY_API_PREFIXES, HALF_OPEN, A2A_SKILL_HANDLERS, MCP_SCOPE_PRESETS.
|
||||
if (index.codeIdentifiers.has(name)) continue;
|
||||
if (index.errorCodes.has(name)) continue;
|
||||
if (/^X-[A-Z]/.test(name)) continue;
|
||||
if (ENV_VAR_DENYLIST.has(name)) continue;
|
||||
const ln = lineOf(text, m.index);
|
||||
|
||||
@@ -57,7 +57,7 @@ export const COLLECTORS = [
|
||||
// abaixo). Subdir novo: adicione aqui E nos scripts (o drift-check + o gate de
|
||||
// órfãos forçam a manutenção em sincronia).
|
||||
{
|
||||
glob: "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,ui,usage}/**/*.test.ts",
|
||||
glob: "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",
|
||||
sources: ["package.json"],
|
||||
},
|
||||
// Node native runner — tests/unit/dashboard/** roda numa invocação separada com o hook
|
||||
@@ -113,6 +113,7 @@ export const COLLECTORS = [
|
||||
sources: ["vitest.mcp.config.ts"],
|
||||
},
|
||||
{ glob: "tests/unit/autoCombo/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
|
||||
{ glob: "tests/unit/api/**/*.spec.ts", sources: ["vitest.mcp.config.ts"] },
|
||||
{ glob: "src/lib/memory/__tests__/generic-backend.test.ts", sources: ["vitest.mcp.config.ts"] },
|
||||
{ glob: "tests/unit/encryption.spec.ts", sources: ["vitest.mcp.config.ts"] },
|
||||
{ glob: "src/shared/components/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
|
||||
|
||||
@@ -63,9 +63,19 @@ export function sumTypecheckBaseline(json) {
|
||||
return n;
|
||||
}
|
||||
|
||||
// Files whose size is not an editable-code signal: generated modules are frozen at
|
||||
// their emitter's exact output size (≈0% headroom by construction — growth is policed
|
||||
// by re-freezing, e.g. openapi.generated.ts in #12212), and vendored code is upstream's.
|
||||
// The GATE (check:file-size) still enforces both; only the monitor skips them so the
|
||||
// nightly headroom-alert reflects files a human can actually slim.
|
||||
export function isMonitorExemptFile(file) {
|
||||
return file.includes(".generated.") || /(^|\/)vendor\//.test(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen-file headroom: the worst (most consumed) frozen file and how many sit within
|
||||
* `warnFraction` of their cap. `locOf(file)` returns the live line count or null.
|
||||
* Generated/vendored files are excluded (see isMonitorExemptFile).
|
||||
*/
|
||||
export function fileSizeHeadroom(frozen, locOf, warnFraction = 0.1) {
|
||||
let worst = null;
|
||||
@@ -73,7 +83,7 @@ export function fileSizeHeadroom(frozen, locOf, warnFraction = 0.1) {
|
||||
let over = 0;
|
||||
let measured = 0;
|
||||
for (const [file, capRaw] of Object.entries(frozen)) {
|
||||
if (file.startsWith("_")) continue;
|
||||
if (file.startsWith("_") || isMonitorExemptFile(file)) continue;
|
||||
const cap = Number(capRaw);
|
||||
const loc = locOf(file);
|
||||
if (!Number.isFinite(cap) || loc === null || loc === undefined) continue;
|
||||
|
||||
@@ -4,14 +4,15 @@ import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const HUB_RE = /(setupPolyfill|tsconfig|package\.json|package-lock\.json|\.env|vitest\.config|stryker\.conf)/;
|
||||
const HUB_RE =
|
||||
/(setupPolyfill|tsconfig|package\.json|package-lock\.json|\.env|vitest\.config|stryker\.conf)/;
|
||||
// A changed file counts as a "run-it" test ONLY if it is a node:test unit file the TIA
|
||||
// step can actually run via `node --test` — i.e. it mirrors the `npm run test:unit` glob.
|
||||
// This EXCLUDES vitest files (`.test.tsx`, `tests/unit/autoCombo/**`), e2e and integration
|
||||
// tests, and `src/**/__tests__`/`open-sse/**/__tests__`, which can't run under node:test.
|
||||
// Keep in sync with package.json test:unit* braces + serial + dashboard + *.test.mjs.
|
||||
const UNIT_SUBDIRS =
|
||||
"api|auth|authz|build|cli|cli-helper|combo|compression|correctness|cors|dashboard|db|db-adapters|docs|gamification|guardrails|lib|mcp|memory|runtime|security|services|settings|shared|ui|usage|serial";
|
||||
"api|auth|authz|build|cli|cli-helper|combo|compression|correctness|cors|dashboard|db|db-adapters|docs|gamification|guardrails|lib|mcp|memory|runtime|security|services|settings|shared|translator|ui|usage|serial";
|
||||
// .ts: top-level + UNIT_SUBDIRS (mirrors package.json brace globs).
|
||||
// .mjs: package.json uses tests/unit/**/*.test.mjs (any depth under tests/unit).
|
||||
const TEST_RE = new RegExp(
|
||||
|
||||
@@ -171,7 +171,7 @@ if [ "$FAST" = "1" ]; then
|
||||
| grep -E '\.test\.(ts|mjs)$' || true)
|
||||
# Subdirs test:unit actually runs (package.json allowlist) — anything else under
|
||||
# tests/unit/<sub>/ belongs to another runner (e.g. autoCombo -> vitest).
|
||||
UNIT_SUBDIRS=",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,ui,usage,"
|
||||
UNIT_SUBDIRS=",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,"
|
||||
MAIN=()
|
||||
DASH=()
|
||||
SERIAL=()
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AutoRoutingStats {
|
||||
totalRequests: number;
|
||||
variantBreakdown: Record<string, number>;
|
||||
avgSelectionScore: number;
|
||||
topProviders: Array<{ provider: string; count: number }>;
|
||||
explorationRate: number;
|
||||
lkgpHitRate: number;
|
||||
}
|
||||
|
||||
export default function AutoRoutingAnalyticsTab() {
|
||||
const [stats, setStats] = useState<AutoRoutingStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const t = useTranslations("analytics");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/analytics/auto-routing")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setStats(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-border rounded w-1/4"></div>
|
||||
<div className="h-20 bg-border rounded"></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-text-muted">{t("autoRoutingNoDataAvailable")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<span className="material-symbols-outlined text-[20px]">auto_awesome</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingTotalAutoRequests")}</p>
|
||||
<p className="text-2xl font-bold">{stats.totalRequests.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">
|
||||
<span className="material-symbols-outlined text-[20px]">target</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingAvgSelectionScore")}</p>
|
||||
<p className="text-2xl font-bold">{(stats.avgSelectionScore * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<span className="material-symbols-outlined text-[20px]">explore</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingExplorationRate")}</p>
|
||||
<p className="text-2xl font-bold">{(stats.explorationRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
<span className="material-symbols-outlined text-[20px]">history</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingLkgpHitRate")}</p>
|
||||
<p className="text-2xl font-bold">{(stats.lkgpHitRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Variant Breakdown */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{t("autoRoutingRequestsByVariant")}</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats.variantBreakdown).map(([variant, count]) => {
|
||||
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
|
||||
return (
|
||||
<div key={variant} className="flex items-center gap-3">
|
||||
<div className="w-32 text-sm font-medium capitalize">
|
||||
{variant || t("defaultVariantLabel")}
|
||||
</div>
|
||||
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20 text-sm text-text-muted text-right">
|
||||
{count.toLocaleString()} ({percentage.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Top Providers */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{t("autoRoutingTopRoutedProviders")}</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 px-3 font-medium">{t("chartProvider")}</th>
|
||||
<th className="text-right py-2 px-3 font-medium">{t("chartRequests")}</th>
|
||||
<th className="text-right py-2 px-3 font-medium">{t("chartShare")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.topProviders.map((provider, index) => {
|
||||
const percentage =
|
||||
stats.totalRequests > 0 ? (provider.count / stats.totalRequests) * 100 : 0;
|
||||
return (
|
||||
<tr key={provider.provider} className="border-b border-border/50">
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-muted">#{index + 1}</span>
|
||||
<span className="font-medium">{provider.provider}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-right py-2 px-3">{provider.count.toLocaleString()}</td>
|
||||
<td className="text-right py-2 px-3 text-text-muted">
|
||||
{percentage.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
interface ToolState {
|
||||
tool: string;
|
||||
installedVersion: string | null;
|
||||
currentVersion: string | null;
|
||||
status: string;
|
||||
pid: number | null;
|
||||
port: number;
|
||||
healthStatus: string;
|
||||
autoUpdate: boolean;
|
||||
autoStart: boolean;
|
||||
lastHealthCheck: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
current: string | null;
|
||||
latest: string;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
|
||||
const t = useTranslations("cliTools");
|
||||
const [toolState, setToolState] = useState<ToolState | null>(null);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: string; text: string } | null>(null);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/version-manager/status");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const entry = Array.isArray(data)
|
||||
? data.find((t: ToolState) => t.tool === "cliproxyapi")
|
||||
: null;
|
||||
setToolState(entry || null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch CLIProxyAPI status:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUpdateInfo = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/version-manager/check-update?tool=cliproxyapi");
|
||||
if (!res.ok) return;
|
||||
setUpdateInfo(await res.json());
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch CLIProxyAPI update info:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
// Load in an async continuation so every setState happens after an await
|
||||
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
|
||||
void (async () => {
|
||||
await Promise.all([fetchStatus(), fetchUpdateInfo()]);
|
||||
})();
|
||||
}, [isExpanded, fetchStatus, fetchUpdateInfo]);
|
||||
|
||||
const apiCall = async (action: string, body?: Record<string, unknown>) => {
|
||||
setLoading(action);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(`/api/version-manager/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tool: "cliproxyapi", ...body }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: data.message || t("cliproxyapiActionSucceeded") });
|
||||
await fetchStatus();
|
||||
if (action === "install" || action === "restart") await fetchUpdateInfo();
|
||||
} else {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
(typeof data.error === "string" ? data.error : data.error?.message) ||
|
||||
t("cliproxyapiActionFailed"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : t("cliproxyapiRequestFailed"),
|
||||
});
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = () => {
|
||||
if (!toolState) return null;
|
||||
const s = toolState.status;
|
||||
const map: Record<string, { label: string; color: string }> = {
|
||||
running: {
|
||||
label: t("cliproxyapiStatusRunning"),
|
||||
color: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
},
|
||||
stopped: {
|
||||
label: t("cliproxyapiStatusStopped"),
|
||||
color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
|
||||
},
|
||||
not_installed: {
|
||||
label: t("cliproxyapiStatusNotInstalled"),
|
||||
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
},
|
||||
installed: {
|
||||
label: t("cliproxyapiStatusInstalled"),
|
||||
color: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
error: {
|
||||
label: t("cliproxyapiStatusError"),
|
||||
color: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
},
|
||||
};
|
||||
const badge = map[s] || map.not_installed;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full ${badge.color}`}
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-current" />
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-8 rounded-lg flex items-center justify-center shrink-0 bg-indigo-500/10">
|
||||
<span className="material-symbols-outlined text-indigo-500 text-xl">swap_horiz</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">CLIProxyAPI</h3>
|
||||
{statusBadge()}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{t("cliproxyapiDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-6 pt-6 border-t border-border space-y-4">
|
||||
{message && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
|
||||
message.type === "success"
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{message.type === "success" ? "check_circle" : "error"}
|
||||
</span>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateInfo?.updateAvailable && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-yellow-500 text-lg">
|
||||
system_update
|
||||
</span>
|
||||
<span className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
{t("cliproxyapiUpdateAvailable", {
|
||||
current: updateInfo.current,
|
||||
latest: updateInfo.latest,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => apiCall("install", { version: updateInfo.latest })}
|
||||
loading={loading === "install"}
|
||||
>
|
||||
{t("cliproxyapiUpdate")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersion")}</p>
|
||||
<p className="text-sm font-medium">
|
||||
{toolState?.installedVersion
|
||||
? `v${toolState.installedVersion}`
|
||||
: t("cliproxyapiNotInstalledValue")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealth")}</p>
|
||||
<p
|
||||
className={`text-sm font-medium ${toolState?.healthStatus === "healthy" ? "text-green-600 dark:text-green-400" : toolState?.healthStatus === "unhealthy" ? "text-red-600 dark:text-red-400" : "text-text-muted"}`}
|
||||
>
|
||||
{toolState?.healthStatus === "healthy"
|
||||
? t("cliproxyapiHealthy")
|
||||
: toolState?.healthStatus === "unhealthy"
|
||||
? t("cliproxyapiUnhealthy")
|
||||
: t("cliproxyapiUnknown")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPort")}</p>
|
||||
<p className="text-sm font-mono">{toolState?.port || 8317}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!toolState?.installedVersion && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => apiCall("install")}
|
||||
loading={loading === "install"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">download</span>
|
||||
{t("cliproxyapiInstall")}
|
||||
</Button>
|
||||
)}
|
||||
{toolState?.status === "running" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => apiCall("stop")}
|
||||
loading={loading === "stop"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">stop</span>
|
||||
{t("cliproxyapiStop")}
|
||||
</Button>
|
||||
) : toolState?.installedVersion ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => apiCall("start")}
|
||||
loading={loading === "start"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span>
|
||||
{t("cliproxyapiStart")}
|
||||
</Button>
|
||||
) : null}
|
||||
{toolState?.status === "running" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => apiCall("restart")}
|
||||
loading={loading === "restart"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span>
|
||||
{t("cliproxyapiRestart")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchUpdateInfo}
|
||||
loading={loading === "check"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">sync</span>
|
||||
{t("cliproxyapiCheckUpdates")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface BadgeUnlockEvent {
|
||||
badgeId: string;
|
||||
badgeName: string;
|
||||
badgeIcon: string;
|
||||
badgeRarity: string;
|
||||
}
|
||||
|
||||
const RARITY_COLORS: Record<string, string> = {
|
||||
common: "border-gray-500 bg-gray-800",
|
||||
uncommon: "border-green-500 bg-green-900/30",
|
||||
rare: "border-blue-500 bg-blue-900/30",
|
||||
legendary: "border-yellow-500 bg-yellow-900/30",
|
||||
};
|
||||
|
||||
const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
|
||||
export function BadgeToast({ apiKeyId }: { apiKeyId: string }) {
|
||||
const t = useTranslations("common");
|
||||
const [toasts, setToasts] = useState<BadgeUnlockEvent[]>([]);
|
||||
const timeoutIds = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
const addToast = useCallback((event: BadgeUnlockEvent) => {
|
||||
setToasts((prev) => [...prev, event]);
|
||||
// Auto-dismiss after 5s, track timeout for cleanup
|
||||
const tid = setTimeout(() => {
|
||||
setToasts((prev) => prev.slice(1));
|
||||
timeoutIds.current.delete(tid);
|
||||
}, 5000);
|
||||
timeoutIds.current.add(tid);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let reconnectDelay = RECONNECT_BASE_MS;
|
||||
let es: EventSource | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let unmounted = false;
|
||||
|
||||
function connect() {
|
||||
if (unmounted) return;
|
||||
es = new EventSource(`/api/gamification/notifications?apiKeyId=${apiKeyId}`);
|
||||
|
||||
es.addEventListener("badge_unlock", (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as BadgeUnlockEvent;
|
||||
addToast(data);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
// Reset backoff on successful message
|
||||
reconnectDelay = RECONNECT_BASE_MS;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
if (es) es.close();
|
||||
if (unmounted) return;
|
||||
// Reconnect with exponential backoff
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
// Capture ref value for cleanup
|
||||
const currentTimeoutIds = timeoutIds.current;
|
||||
return () => {
|
||||
unmounted = true;
|
||||
if (es) es.close();
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
// Clear all pending toast timeouts
|
||||
for (const tid of currentTimeoutIds) {
|
||||
clearTimeout(tid);
|
||||
}
|
||||
currentTimeoutIds.clear();
|
||||
};
|
||||
}, [apiKeyId, addToast]);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast, i) => (
|
||||
<div
|
||||
key={`${toast.badgeId}-${i}`}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg border-2 shadow-lg animate-slide-in ${RARITY_COLORS[toast.badgeRarity] || RARITY_COLORS.common}`}
|
||||
>
|
||||
<span className="text-2xl">🏆</span>
|
||||
<div>
|
||||
<div className="font-semibold text-white">{t("badgeToastUnlocked")}</div>
|
||||
<div className="text-sm text-text-muted">{toast.badgeName}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SegmentedControl } from "@/shared/components";
|
||||
import CompressionSettingsTab from "@/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab";
|
||||
import { outputStyleLanguages } from "../../../../../../open-sse/services/compression/outputStyles/catalog.ts";
|
||||
|
||||
type AnalyticsSummary = {
|
||||
totalRequests: number;
|
||||
@@ -125,6 +126,12 @@ export default function CavemanContextPageClient() {
|
||||
];
|
||||
const previewPrompt = `[OmniRoute Caveman Output Mode]\n${t(`preview.${outputMode.intensity}`)}`;
|
||||
|
||||
// Rule packs drive the input engines; output styles can instruct in more
|
||||
// languages (e.g. vi has no pack). The default-language selector offers both.
|
||||
const languageOptions = [
|
||||
...new Set([...languagePacks.map((pack) => pack.language), ...outputStyleLanguages()]),
|
||||
].sort();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
@@ -186,9 +193,9 @@ export default function CavemanContextPageClient() {
|
||||
onChange={(event) => updateLanguageConfig({ defaultLanguage: event.target.value })}
|
||||
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
|
||||
>
|
||||
{languagePacks.map((pack) => (
|
||||
<option key={pack.language} value={pack.language}>
|
||||
{pack.language}
|
||||
{languageOptions.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{lang}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Modal } from "@/shared/components";
|
||||
import type { QuotaPool, Policy, QuotaDimension } from "@/lib/quota/dimensions";
|
||||
|
||||
interface Connection {
|
||||
id: string;
|
||||
provider: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface PlanInfo {
|
||||
dimensions: QuotaDimension[];
|
||||
source: "auto" | "manual";
|
||||
}
|
||||
|
||||
interface CreatePoolModalProps {
|
||||
connections: Connection[];
|
||||
plans: Record<string, PlanInfo>;
|
||||
existingPools: QuotaPool[];
|
||||
onClose: () => void;
|
||||
onCreate: (pool: Omit<QuotaPool, "id" | "createdAt">) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CreatePoolModal({
|
||||
connections,
|
||||
plans,
|
||||
existingPools,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: CreatePoolModalProps) {
|
||||
const t = useTranslations("quotaShare");
|
||||
const [connectionId, setConnectionId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [defaultPolicy, setDefaultPolicy] = useState<Policy>("hard");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const usedConnectionIds = useMemo(
|
||||
() => new Set(existingPools.map((p) => p.connectionId)),
|
||||
[existingPools]
|
||||
);
|
||||
|
||||
const selectedConn = connections.find((c) => c.id === connectionId);
|
||||
const planInfo = connectionId ? plans[connectionId] : undefined;
|
||||
const hasPlan = planInfo && planInfo.dimensions.length > 0;
|
||||
|
||||
const connLabel = (c: Connection) =>
|
||||
`${c.provider} / ${c.name || c.email || c.displayName || c.id.slice(0, 12)}`;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!selectedConn) return;
|
||||
if (usedConnectionIds.has(connectionId)) {
|
||||
setError(t("duplicatePoolError"));
|
||||
return;
|
||||
}
|
||||
const poolName = name.trim() || connLabel(selectedConn);
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreate({
|
||||
connectionId,
|
||||
name: poolName,
|
||||
allocations: [],
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create pool");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={t("newPoolTitle")}>
|
||||
<div className="space-y-3">
|
||||
{/* Connection selector */}
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
{t("providerConnection")}
|
||||
</label>
|
||||
<select
|
||||
value={connectionId}
|
||||
onChange={(e) => {
|
||||
setConnectionId(e.target.value);
|
||||
setName("");
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
|
||||
>
|
||||
<option value="">{t("selectConnection")}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id} disabled={usedConnectionIds.has(c.id)}>
|
||||
{connLabel(c)} {usedConnectionIds.has(c.id) ? t("alreadyUsedSuffix") : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{connections.length === 0 && (
|
||||
<p className="text-[10px] text-amber-400 mt-1">{t("noEligibleConnections")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pool name */}
|
||||
{connectionId && (
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
Pool name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={selectedConn ? connLabel(selectedConn) : "My quota pool"}
|
||||
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default policy */}
|
||||
{connectionId && (
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
{t("policyLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["hard", "soft", "burst"] as Policy[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setDefaultPolicy(p)}
|
||||
className={`px-3 py-1.5 rounded-md border text-xs cursor-pointer transition-colors ${
|
||||
defaultPolicy === p
|
||||
? "bg-primary/15 border-primary/40 text-primary font-semibold"
|
||||
: "border-border text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{p === "hard" ? t("policyHard") : p === "soft" ? t("policySoft") : t("policyBurst")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan info */}
|
||||
{connectionId && hasPlan && (
|
||||
<div className="rounded-md border border-border/40 bg-bg-subtle/30 p-3 text-[11px] text-text-muted">
|
||||
<div className="font-semibold text-text-main mb-1">
|
||||
{t("multiDimensionLabel")} ({planInfo.source})
|
||||
</div>
|
||||
{planInfo.dimensions.map((d, i) => (
|
||||
<div key={i}>
|
||||
{d.unit} / {d.window}: {d.limit}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cap absolute notice */}
|
||||
{connectionId && (
|
||||
<div className="text-[10px] text-text-muted">
|
||||
<span className="font-semibold">{t("policyCapAbsoluteLabel")}:</span>{" "}
|
||||
{t("policyCapAbsolutePlaceholder")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-[11px] text-red-400 bg-red-500/10 px-3 py-2 rounded">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-border/40">
|
||||
<Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!selectedConn || saving}
|
||||
>
|
||||
{saving ? t("loading") : t("createPool")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -379,13 +379,16 @@ export default function EditConnectionModal({
|
||||
quotaPerUnit: existingQuotaPerUnit,
|
||||
glmOrganizationId: existingGlmOrganizationId,
|
||||
glmProjectId: existingGlmProjectId,
|
||||
// Console-session credentials are stripped from API responses
|
||||
// (sanitizeProviderSpecificDataForResponse), so there is nothing to
|
||||
// round-trip: start empty and let "blank keeps the stored value" hold —
|
||||
// the quota-scraping assign skips empty fields and the PUT merge
|
||||
// preserves keys the payload does not carry.
|
||||
ollamaCloudUsageCookie: "",
|
||||
alibabaConsoleCookie: stringField(connection.providerSpecificData?.alibabaConsoleCookie),
|
||||
qwenCloudCookie: stringField(connection.providerSpecificData?.qwenCloudCookie),
|
||||
qwenCloudSecToken: stringField(connection.providerSpecificData?.qwenCloudSecToken),
|
||||
alibabaConsoleSecToken: stringField(
|
||||
connection.providerSpecificData?.alibabaConsoleSecToken
|
||||
),
|
||||
alibabaConsoleCookie: "",
|
||||
qwenCloudCookie: "",
|
||||
qwenCloudSecToken: "",
|
||||
alibabaConsoleSecToken: "",
|
||||
ccCompatibleContext1m: ccRequestDefaults.context1m,
|
||||
ccCompatibleRedactThinking: ccRequestDefaults.redactThinking,
|
||||
ccCompatibleSummarizeThinking: ccRequestDefaults.summarizeThinking,
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface CompareResult {
|
||||
provider: string;
|
||||
latency: number;
|
||||
cost: number;
|
||||
resultCount: number;
|
||||
responseSize: number;
|
||||
urls: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ProviderComparisonProps {
|
||||
initialProvider: string;
|
||||
initialResult: CompareResult;
|
||||
otherResults: CompareResult[];
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
export default function ProviderComparison({
|
||||
initialProvider,
|
||||
initialResult,
|
||||
otherResults,
|
||||
loading,
|
||||
onClose,
|
||||
}: ProviderComparisonProps) {
|
||||
const t = useTranslations("search");
|
||||
|
||||
const allResults = [initialResult, ...otherResults];
|
||||
const initialUrls = new Set(initialResult.urls);
|
||||
|
||||
const valid = allResults.filter((r) => !r.error);
|
||||
const latencies = valid.map((r) => r.latency);
|
||||
const costs = valid.map((r) => r.cost);
|
||||
const sizes = valid.map((r) => r.responseSize);
|
||||
const bestLatency = Math.min(...latencies);
|
||||
const worstLatency = Math.max(...latencies);
|
||||
const bestCost = Math.min(...costs);
|
||||
const worstCost = Math.max(...costs);
|
||||
const bestSize = Math.min(...sizes);
|
||||
const worstSize = Math.max(...sizes);
|
||||
|
||||
const getLatencyColor = (val: number) => {
|
||||
if (val === bestLatency) return "text-success font-medium";
|
||||
if (val === worstLatency) return "text-warning";
|
||||
return "text-text-main";
|
||||
};
|
||||
|
||||
const getCostColor = (val: number) => {
|
||||
if (val === bestCost) return "text-success font-medium";
|
||||
if (val === worstCost) return "text-warning";
|
||||
return "text-text-main";
|
||||
};
|
||||
|
||||
const getSizeColor = (val: number) => {
|
||||
if (val === bestSize) return "text-success font-medium";
|
||||
if (val === worstSize) return "text-warning";
|
||||
return "text-text-main";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-accent/20 rounded-lg overflow-hidden">
|
||||
<div className="flex justify-between items-center px-4 py-2.5 bg-accent/5 border-b border-accent/15">
|
||||
<span className="text-xs font-semibold text-accent flex items-center gap-1.5">
|
||||
⇕ {t("compareProviders")}
|
||||
</span>
|
||||
<button onClick={onClose} className="text-text-muted text-xs hover:text-text-main">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 overflow-x-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="material-symbols-outlined text-[20px] text-accent animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
<span className="text-xs text-text-muted ml-2">{t("compareProviders")}...</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-2 text-text-muted font-semibold" />
|
||||
{allResults.map((r) => (
|
||||
<th
|
||||
key={r.provider}
|
||||
className={`text-center p-2 font-semibold ${
|
||||
r.provider === initialProvider ? "text-primary" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{r.provider.replace("-search", "")}
|
||||
{r.provider === initialProvider && " ✓"}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">{t("latency")}</td>
|
||||
{allResults.map((r) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : getLatencyColor(r.latency)}`}
|
||||
>
|
||||
{r.error ? "Error" : `${r.latency}ms`}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">{t("cost")}</td>
|
||||
{allResults.map((r) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : getCostColor(r.cost)}`}
|
||||
>
|
||||
{r.error ? "Error" : `$${r.cost.toFixed(4)}`}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">{t("results")}</td>
|
||||
{allResults.map((r) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : "text-text-main"}`}
|
||||
>
|
||||
{r.error ? "Error" : r.resultCount}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">{t("size")}</td>
|
||||
{allResults.map((r) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : getSizeColor(r.responseSize)}`}
|
||||
>
|
||||
{r.error ? "Error" : formatBytes(r.responseSize)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2 text-text-muted">{t("urlOverlap")}</td>
|
||||
{allResults.map((r) => (
|
||||
<td key={r.provider} className="text-center p-2 text-text-main">
|
||||
{r.provider === initialProvider
|
||||
? "—"
|
||||
: r.error
|
||||
? "Error"
|
||||
: `${r.urls.filter((u) => initialUrls.has(u)).length}/${r.resultCount}`}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface CacheMetrics {
|
||||
totalRequests: number;
|
||||
requestsWithCacheControl: number;
|
||||
totalInputTokens: number;
|
||||
totalCachedTokens: number;
|
||||
totalCacheCreationTokens: number;
|
||||
tokensSaved: number;
|
||||
estimatedCostSaved: number;
|
||||
byProvider: Record<
|
||||
string,
|
||||
{
|
||||
requests: number;
|
||||
inputTokens: number;
|
||||
cachedTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
}
|
||||
>;
|
||||
byStrategy: Record<
|
||||
string,
|
||||
{
|
||||
requests: number;
|
||||
inputTokens: number;
|
||||
cachedTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
}
|
||||
>;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL_MS = 10_000;
|
||||
const REFRESH_INTERVAL_SECONDS = REFRESH_INTERVAL_MS / 1000;
|
||||
|
||||
function formatNumberCompact(num: number): string {
|
||||
return Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export default function CacheStatsCard() {
|
||||
const [metrics, setMetrics] = useState<CacheMetrics | null>(null);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const t = useTranslations("cache");
|
||||
|
||||
const fetchMetrics = useCallback(() => {
|
||||
fetch("/api/settings/cache-metrics")
|
||||
.then((r) => r.json())
|
||||
.then(setMetrics)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchMetrics();
|
||||
const id = setInterval(() => void fetchMetrics(), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchMetrics]);
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetting(true);
|
||||
try {
|
||||
await fetch("/api/settings/cache-metrics", { method: "DELETE" });
|
||||
fetchMetrics();
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cacheHitRate =
|
||||
metrics && metrics.totalInputTokens > 0
|
||||
? (metrics.totalCachedTokens / metrics.totalInputTokens) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-base text-text-muted"
|
||||
aria-hidden="true"
|
||||
>
|
||||
insights
|
||||
</span>
|
||||
<h2 className="font-medium text-sm">{t("cacheMetrics")}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("autoRefresh", { seconds: REFRESH_INTERVAL_SECONDS })}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{resetting ? t("resetting") : t("resetMetrics")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metrics ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted">{t("totalRequests")}</p>
|
||||
<p className="font-mono text-lg text-text-main">{metrics.totalRequests}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">{t("withCacheControl")}</p>
|
||||
<p className="font-mono text-lg text-text-main">
|
||||
{metrics.requestsWithCacheControl}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted">{t("inputTokens")}</p>
|
||||
<p className="font-mono text-lg text-text-main">
|
||||
{formatNumberCompact(metrics.totalInputTokens)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">{t("cachedTokensRead")}</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
{formatNumberCompact(metrics.totalCachedTokens)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">{t("cacheCreationWrite")}</p>
|
||||
<p className="font-mono text-lg text-blue-400">
|
||||
{formatNumberCompact(metrics.totalCacheCreationTokens)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Ratio */}
|
||||
<div className="rounded-lg bg-surface/50 border border-border/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">{t("cacheReuseRatio")}</p>
|
||||
<p className="text-xs text-text-muted">{t("cacheReuseRatioDesc")}</p>
|
||||
</div>
|
||||
<p className="font-mono text-xl text-green-400">{cacheHitRate.toFixed(1)}%</p>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-2 h-2 rounded-full bg-border/30 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-300"
|
||||
style={{ width: `${Math.min(cacheHitRate, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Savings */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted">{t("tokensSaved")}</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
{formatNumberCompact(metrics.tokensSaved)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">{t("estCostSaved")}</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
${metrics.estimatedCostSaved.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Provider */}
|
||||
{Object.keys(metrics.byProvider).length > 0 && (
|
||||
<div className="pt-3 border-t border-border/30">
|
||||
<p className="text-xs font-medium text-text-muted mb-2">{t("byProvider")}</p>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(metrics.byProvider).map(([provider, stats]) => {
|
||||
const providerCacheRate =
|
||||
stats.inputTokens > 0 ? (stats.cachedTokens / stats.inputTokens) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex items-center justify-between px-3 py-2 rounded bg-surface/30 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-main capitalize w-24">{provider}</span>
|
||||
<span className="text-text-muted">
|
||||
{stats.requests} {t("requestsShort")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 font-mono">
|
||||
<span className="text-text-muted" title={t("inputTokens")}>
|
||||
{t("inputShort")}: {formatNumberCompact(stats.inputTokens)}
|
||||
</span>
|
||||
<span className="text-green-400" title={t("cachedTokensRead")}>
|
||||
{t("cachedShort")}: {formatNumberCompact(stats.cachedTokens)}
|
||||
</span>
|
||||
<span className="text-blue-400" title={t("cacheCreationWrite")}>
|
||||
{t("writeShort")}: {formatNumberCompact(stats.cacheCreationTokens)}
|
||||
</span>
|
||||
<span className="text-green-400 w-12 text-right">
|
||||
{providerCacheRate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">{t("loading")}</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, DataTable, FilterBar, ColumnToggle } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { matchesSearch } from "@/shared/utils/turkishText";
|
||||
|
||||
export default function ComplianceTab() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filters, setFilters] = useState<any>({});
|
||||
const [visibleCols, setVisibleCols] = useState({
|
||||
timestamp: true,
|
||||
action: true,
|
||||
actor: true,
|
||||
details: true,
|
||||
});
|
||||
const notify = useNotificationStore();
|
||||
const t = useTranslations("settings");
|
||||
const allColumns = [
|
||||
{ key: "timestamp", label: t("time") },
|
||||
{ key: "action", label: t("action") },
|
||||
{ key: "actor", label: t("actor") },
|
||||
{ key: "details", label: t("details") },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/compliance/audit-log?limit=100")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setLogs(Array.isArray(data) ? data : data.logs || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
notify.error(t("failedLoadAuditLog"));
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const actionOptions = [...new Set(logs.map((l) => l.action).filter(Boolean))];
|
||||
const actorOptions = [...new Set(logs.map((l) => l.actor).filter(Boolean))];
|
||||
|
||||
const filtered = logs.filter((l) => {
|
||||
if (search) {
|
||||
const matchesQuery =
|
||||
matchesSearch(l.action ?? "", search) ||
|
||||
matchesSearch(l.actor ?? "", search) ||
|
||||
(l.details && matchesSearch(JSON.stringify(l.details), search));
|
||||
if (!matchesQuery) return false;
|
||||
}
|
||||
if (filters.action && l.action !== filters.action) return false;
|
||||
if (filters.actor && l.actor !== filters.actor) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const columns = allColumns.filter((c) => visibleCols[c.key]);
|
||||
|
||||
const handleToggleCol = useCallback((key) => {
|
||||
setVisibleCols((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
const renderCell = useCallback(
|
||||
(row, col) => {
|
||||
switch (col.key) {
|
||||
case "timestamp":
|
||||
return (
|
||||
<span className="font-mono text-xs text-text-muted whitespace-nowrap">
|
||||
{row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"}
|
||||
</span>
|
||||
);
|
||||
case "action":
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
|
||||
{row.action || "—"}
|
||||
</span>
|
||||
);
|
||||
case "actor":
|
||||
return <span className="text-text-main">{row.actor || t("systemActor")}</span>;
|
||||
case "details":
|
||||
return (
|
||||
<span className="text-text-muted text-xs max-w-xs truncate block">
|
||||
{row.details ? JSON.stringify(row.details) : "—"}
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return row[col.key] || "—";
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px]">policy</span>
|
||||
{t("auditLog")}
|
||||
</h3>
|
||||
<ColumnToggle columns={allColumns} visible={visibleCols} onToggle={handleToggleCol} />
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={t("searchAuditLogs")}
|
||||
filters={[
|
||||
{ key: "action", label: t("action"), options: actionOptions },
|
||||
{ key: "actor", label: t("actor"), options: actorOptions },
|
||||
]}
|
||||
activeFilters={filters}
|
||||
onFilterChange={(key, val) => setFilters((prev) => ({ ...prev, [key]: val }))}
|
||||
>
|
||||
{null}
|
||||
</FilterBar>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
renderCell={renderCell}
|
||||
loading={loading}
|
||||
maxHeight="400px"
|
||||
emptyIcon="📋"
|
||||
emptyMessage={t("noAuditEvents")}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ModelAliasesTab() {
|
||||
const [builtIn, setBuiltIn] = useState<Record<string, string>>({});
|
||||
const [custom, setCustom] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [newFrom, setNewFrom] = useState("");
|
||||
const [newTo, setNewTo] = useState("");
|
||||
const t = useTranslations("settings");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/model-aliases")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setBuiltIn(data.builtIn || {});
|
||||
setCustom(data.custom || {});
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const addAlias = async () => {
|
||||
if (!newFrom.trim() || !newTo.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/model-aliases", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from: newFrom.trim(), to: newTo.trim() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCustom(data.custom);
|
||||
setNewFrom("");
|
||||
setNewTo("");
|
||||
setStatus("saved");
|
||||
setTimeout(() => setStatus(""), 2000);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAlias = async (from: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/model-aliases", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCustom(data.custom);
|
||||
setStatus("saved");
|
||||
setTimeout(() => setStatus(""), 2000);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const builtInEntries = Object.entries(builtIn);
|
||||
const customEntries = Object.entries(custom);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
swap_horiz
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("modelAliasesTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("modelAliasesDesc")}</p>
|
||||
</div>
|
||||
{status === "saved" && (
|
||||
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">check_circle</span> {t("saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add custom alias */}
|
||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
||||
<p className="text-sm font-medium mb-3">{t("addCustomAlias")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("deprecatedModelId")}
|
||||
value={newFrom}
|
||||
onChange={(e) => setNewFrom(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-amber-500/50 focus:outline-none"
|
||||
/>
|
||||
<span className="text-text-muted text-lg">→</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("newModelId")}
|
||||
value={newTo}
|
||||
onChange={(e) => setNewTo(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-amber-500/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={addAlias}
|
||||
disabled={saving || !newFrom.trim() || !newTo.trim()}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 disabled:opacity-50 transition-all"
|
||||
>
|
||||
{t("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom aliases */}
|
||||
{customEntries.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
||||
{t("customAliases")}
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/30 divide-y divide-border/20">
|
||||
{customEntries.map(([from, to]) => (
|
||||
<div key={from} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<code className="text-xs text-red-400/80 flex-1 truncate">{from}</code>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<code className="text-xs text-emerald-400/80 flex-1 truncate">{to}</code>
|
||||
<button
|
||||
onClick={() => removeAlias(from)}
|
||||
disabled={saving}
|
||||
className="p-1 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Built-in aliases (collapsed by default) */}
|
||||
<details className="group">
|
||||
<summary className="text-xs font-medium text-text-muted uppercase tracking-wider cursor-pointer flex items-center gap-1 mb-2">
|
||||
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
|
||||
chevron_right
|
||||
</span>
|
||||
{t("builtInAliases")} ({builtInEntries.length})
|
||||
</summary>
|
||||
<div className="rounded-lg border border-border/30 divide-y divide-border/20 max-h-60 overflow-y-auto">
|
||||
{builtInEntries.map(([from, to]) => (
|
||||
<div key={from} className="flex items-center gap-3 px-4 py-2 opacity-60">
|
||||
<code className="text-xs text-red-400/60 flex-1 truncate">{from}</code>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<code className="text-xs text-emerald-400/60 flex-1 truncate">{to}</code>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">lock</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
|
||||
type OneproxyItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
type: string;
|
||||
countryCode: string | null;
|
||||
qualityScore: number | null;
|
||||
latencyMs: number | null;
|
||||
anonymity: string | null;
|
||||
googleAccess: boolean;
|
||||
status: string;
|
||||
lastValidated: string | null;
|
||||
};
|
||||
|
||||
type OneproxyStats = {
|
||||
total: number;
|
||||
active: number;
|
||||
avgQuality: number | null;
|
||||
lastValidated: string | null;
|
||||
byProtocol: Array<{ protocol: string; count: number }>;
|
||||
byCountry: Array<{ countryCode: string; count: number }>;
|
||||
};
|
||||
|
||||
type SyncStatus = {
|
||||
lastSyncSuccess: boolean;
|
||||
lastSyncError: string | null;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncCount: number;
|
||||
consecutiveFailures: number;
|
||||
};
|
||||
|
||||
type OneproxyFetchResult = {
|
||||
proxies?: OneproxyItem[];
|
||||
statsPayload?: { stats: OneproxyStats; status: SyncStatus };
|
||||
};
|
||||
|
||||
async function fetchOneproxyData(
|
||||
filterProtocol: string,
|
||||
filterCountry: string,
|
||||
minQuality: string
|
||||
): Promise<OneproxyFetchResult> {
|
||||
const result: OneproxyFetchResult = {};
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filterProtocol) params.set("protocol", filterProtocol);
|
||||
if (filterCountry) params.set("countryCode", filterCountry);
|
||||
if (minQuality) params.set("minQuality", minQuality);
|
||||
|
||||
const [proxiesRes, statsRes] = await Promise.all([
|
||||
fetch(`/api/settings/oneproxy?${params.toString()}`),
|
||||
fetch("/api/settings/oneproxy?action=stats"),
|
||||
]);
|
||||
|
||||
if (proxiesRes.ok) {
|
||||
const data = await proxiesRes.json();
|
||||
result.proxies = data.items || [];
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
const data = await statsRes.json();
|
||||
result.statsPayload = { stats: data.stats, status: data.status };
|
||||
}
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Owns the proxy-list data state: fetch-on-mount/filter-change, refresh, and the
|
||||
* loading-spinner reset when the filters tuple changes (state adjustment during render). */
|
||||
function useOneproxyData(filterProtocol: string, filterCountry: string, minQuality: string) {
|
||||
const [proxies, setProxies] = useState<OneproxyItem[]>([]);
|
||||
const [stats, setStats] = useState<OneproxyStats | null>(null);
|
||||
const [status, setStatus] = useState<SyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const applyData = useCallback((result: OneproxyFetchResult) => {
|
||||
if (result.proxies) setProxies(result.proxies);
|
||||
if (result.statsPayload) {
|
||||
setStats(result.statsPayload.stats);
|
||||
setStatus(result.statsPayload.status);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
applyData(await fetchOneproxyData(filterProtocol, filterCountry, minQuality));
|
||||
}, [applyData, filterProtocol, filterCountry, minQuality]);
|
||||
|
||||
const filtersKey = `${filterProtocol}|${filterCountry}|${minQuality}`;
|
||||
const [prevFiltersKey, setPrevFiltersKey] = useState(filtersKey);
|
||||
if (prevFiltersKey !== filtersKey) {
|
||||
setPrevFiltersKey(filtersKey);
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const result = await fetchOneproxyData(filterProtocol, filterCountry, minQuality);
|
||||
if (!cancelled) applyData(result);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applyData, filterProtocol, filterCountry, minQuality]);
|
||||
|
||||
return { proxies, setProxies, stats, setStats, status, loading, loadData };
|
||||
}
|
||||
|
||||
export default function OneproxyTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||
const [syncSucceeded, setSyncSucceeded] = useState(false);
|
||||
const [filterProtocol, setFilterProtocol] = useState("");
|
||||
const [filterCountry, setFilterCountry] = useState("");
|
||||
const [minQuality, setMinQuality] = useState("");
|
||||
|
||||
const { proxies, setProxies, stats, setStats, status, loading, loadData } = useOneproxyData(
|
||||
filterProtocol,
|
||||
filterCountry,
|
||||
minQuality
|
||||
);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setSyncResult(null);
|
||||
setSyncSucceeded(false);
|
||||
try {
|
||||
const res = await fetch("/api/settings/oneproxy", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSyncSucceeded(true);
|
||||
setSyncResult(
|
||||
t("oneproxySyncSuccess", {
|
||||
total: data.total,
|
||||
added: data.added,
|
||||
updated: data.updated,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setSyncResult(t("oneproxySyncFailed", { error: data.error }));
|
||||
}
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setSyncResult(t("oneproxySyncFailed", { error: String(err) }));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!confirm(t("oneproxyClearAllConfirm"))) return;
|
||||
try {
|
||||
await fetch("/api/settings/oneproxy?clearAll=1", { method: "DELETE" });
|
||||
await loadData();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/settings/oneproxy?id=${id}`, { method: "DELETE" });
|
||||
setProxies((prev) => prev.filter((p) => p.id !== id));
|
||||
if (stats) setStats({ ...stats, total: stats.total - 1, active: stats.active - 1 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const qualityColor = (score: number | null) => {
|
||||
if (score == null) return "bg-gray-500";
|
||||
if (score >= 80) return "bg-green-500";
|
||||
if (score >= 50) return "bg-yellow-500";
|
||||
return "bg-red-500";
|
||||
};
|
||||
|
||||
const protocolBadge = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
http: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
https: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
socks4: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
socks5: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
|
||||
};
|
||||
return colors[type] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-main">{t("oneproxyTitle")}</h2>
|
||||
<p className="text-sm text-text-muted mt-1">{t("oneproxyDescription")}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSync} disabled={syncing} variant="primary">
|
||||
{syncing ? t("oneproxySyncing") : t("oneproxySyncNow")}
|
||||
</Button>
|
||||
{proxies.length > 0 && (
|
||||
<Button onClick={handleClearAll} variant="danger">
|
||||
{t("oneproxyClearAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{syncResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm ${
|
||||
syncSucceeded
|
||||
? "bg-green-50 text-green-800 dark:bg-green-900/30 dark:text-green-300"
|
||||
: "bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{syncResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">{stats.total}</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyTotalProxies")}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyActive")}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">
|
||||
{stats.avgQuality != null ? `${stats.avgQuality}` : "—"}
|
||||
</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyAvgQuality")}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">
|
||||
{status?.lastSyncAt
|
||||
? new Date(status.lastSyncAt).toLocaleTimeString()
|
||||
: t("oneproxyNever")}
|
||||
</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyLastSync")}</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={filterProtocol}
|
||||
onChange={(e) => setFilterProtocol(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border"
|
||||
>
|
||||
<option value="">{t("oneproxyAllProtocols")}</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="socks4">SOCKS4</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("oneproxyCountryCodePlaceholder")}
|
||||
value={filterCountry}
|
||||
onChange={(e) => setFilterCountry(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-40"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder={t("oneproxyMinQualityPlaceholder")}
|
||||
value={minQuality}
|
||||
onChange={(e) => setMinQuality(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-32"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-text-muted">{t("oneproxyLoadingProxies")}</div>
|
||||
) : proxies.length === 0 ? (
|
||||
<div className="text-center py-8 text-text-muted">{t("oneproxyEmpty")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyHost")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyProtocol")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyCountry")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyQuality")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyLatency")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyAnonymity")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyGoogle")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{proxies.map((proxy) => (
|
||||
<tr
|
||||
key={proxy.id}
|
||||
className="border-b border-border/50 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<td className="py-2 px-3 font-mono text-text-main">
|
||||
{proxy.host}:{proxy.port}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${protocolBadge(proxy.type)}`}
|
||||
>
|
||||
{proxy.type.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-text-main">{proxy.countryCode || "—"}</td>
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${qualityColor(proxy.qualityScore)}`}
|
||||
/>
|
||||
<span className="text-text-main">{proxy.qualityScore ?? "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-text-main">
|
||||
{proxy.latencyMs != null ? `${proxy.latencyMs}ms` : "—"}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-text-main">{proxy.anonymity || "—"}</td>
|
||||
<td className="py-2 px-3">
|
||||
{proxy.googleAccess ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<button
|
||||
onClick={() => handleDelete(proxy.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs"
|
||||
>
|
||||
{t("oneproxyDelete")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{status && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-2">
|
||||
{t("oneproxySyncStatusTitle")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-muted">{t("oneproxyLastSyncLabel")} </span>
|
||||
<span className={status.lastSyncSuccess ? "text-green-600" : "text-red-600"}>
|
||||
{status.lastSyncSuccess ? t("oneproxySuccess") : t("oneproxyFailed")}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">{t("oneproxyProxiesFetched")} </span>
|
||||
<span className="text-text-main">{status.lastSyncCount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">{t("oneproxyConsecutiveFailures")} </span>
|
||||
<span className="text-text-main">{status.consecutiveFailures}</span>
|
||||
</div>
|
||||
{status.lastSyncError && (
|
||||
<div className="col-span-full">
|
||||
<span className="text-text-muted">{t("oneproxyErrorLabel")} </span>
|
||||
<span className="text-red-600 text-xs">{status.lastSyncError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PoliciesPanel — Batch E
|
||||
*
|
||||
* Shows locked identifiers managed by policy-related safeguards.
|
||||
* Allows force-unlocking locked identifiers.
|
||||
* API: /api/policies
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
async function fetchPoliciesData() {
|
||||
try {
|
||||
const res = await fetch("/api/policies");
|
||||
if (res.ok) return await res.json();
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function PoliciesPanel() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [unlocking, setUnlocking] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
const t = useTranslations("settings");
|
||||
|
||||
const applyPolicies = useCallback((json) => {
|
||||
if (json) setData(json);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const fetchPolicies = useCallback(async () => {
|
||||
applyPolicies(await fetchPoliciesData());
|
||||
}, [applyPolicies]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const json = await fetchPoliciesData();
|
||||
if (!cancelled) applyPolicies(json);
|
||||
})();
|
||||
const interval = setInterval(fetchPolicies, 15000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [applyPolicies, fetchPolicies]);
|
||||
|
||||
const handleUnlock = async (identifier) => {
|
||||
setUnlocking(identifier);
|
||||
try {
|
||||
const res = await fetch("/api/policies", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "unlock", identifier }),
|
||||
});
|
||||
if (res.ok) {
|
||||
notify.success(`Unlocked: ${identifier}`);
|
||||
await fetchPolicies();
|
||||
} else {
|
||||
notify.error(t("failedUnlock"));
|
||||
}
|
||||
} catch {
|
||||
notify.error(t("failedUnlock"));
|
||||
} finally {
|
||||
setUnlocking(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<div className="flex items-center gap-2 text-text-muted animate-pulse">
|
||||
<span className="material-symbols-outlined text-[20px]">security</span>
|
||||
{t("loadingPolicies")}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const lockedIds = data?.lockedIdentifiers || [];
|
||||
|
||||
if (lockedIds.length === 0) {
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
<span className="material-symbols-outlined text-[20px]">verified_user</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">{t("policiesLocked")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("allOperational")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-red-500/10 text-red-500">
|
||||
<span className="material-symbols-outlined text-[20px]">gpp_maybe</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">{t("policiesLocked")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("activeIssuesDetected")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Locked Identifiers */}
|
||||
{lockedIds.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-muted mb-2">{t("lockedIdentifiers")}</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{lockedIds.map((id, i) => {
|
||||
const identifier = typeof id === "string" ? id : id.identifier || id.id;
|
||||
return (
|
||||
<div
|
||||
key={identifier || i}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-red-400">lock</span>
|
||||
<span className="font-mono text-sm text-text-main">{identifier}</span>
|
||||
{typeof id === "object" && id.lockedAt && (
|
||||
<span className="text-xs text-text-muted">
|
||||
since {new Date(id.lockedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleUnlock(identifier)}
|
||||
disabled={unlocking === identifier}
|
||||
className="text-xs"
|
||||
>
|
||||
{unlocking === identifier ? t("unlocking") : t("forceUnlock")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Modal } from "@/shared/components";
|
||||
|
||||
type ParsedProxyEntry = {
|
||||
name: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
region?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type ParseError = { line: number; reason: string };
|
||||
|
||||
interface ProxyBulkImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onImported: () => Promise<void>;
|
||||
}
|
||||
|
||||
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
|
||||
# Format: name | type | host | port | username | region | status
|
||||
# Example:
|
||||
# My Proxy | http | 1.2.3.4 | 8080 | user | US | active
|
||||
`;
|
||||
|
||||
function parseBulkImportText(text: string): {
|
||||
parsed: ParsedProxyEntry[];
|
||||
errors: ParseError[];
|
||||
skipped: number;
|
||||
} {
|
||||
const lines = text.split("\n");
|
||||
const parsed: ParsedProxyEntry[] = [];
|
||||
const errors: ParseError[] = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const parts = line.split("|").map((p) => p.trim());
|
||||
if (parts.length < 4) {
|
||||
errors.push({ line: i + 1, reason: "bulkImportMinFields" });
|
||||
continue;
|
||||
}
|
||||
const [name, type, host, portStr, username, region, status] = parts;
|
||||
const port = parseInt(portStr, 10);
|
||||
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
||||
errors.push({ line: i + 1, reason: "bulkImportInvalidPort" });
|
||||
continue;
|
||||
}
|
||||
parsed.push({
|
||||
name: name || `${host}:${port}`,
|
||||
type: ["http", "https", "socks5"].includes(type) ? type : "http",
|
||||
host,
|
||||
port,
|
||||
username: username || undefined,
|
||||
region: region || undefined,
|
||||
status: status === "inactive" ? "inactive" : "active",
|
||||
});
|
||||
}
|
||||
|
||||
return { parsed, errors, skipped };
|
||||
}
|
||||
|
||||
export function ProxyBulkImportModal({ isOpen, onClose, onImported }: ProxyBulkImportModalProps) {
|
||||
const t = useTranslations("proxyRegistry");
|
||||
const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE);
|
||||
const [bulkImportParsed, setBulkImportParsed] = useState<ParsedProxyEntry[]>([]);
|
||||
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
|
||||
const [bulkImportSkipped, setBulkImportSkipped] = useState(0);
|
||||
const [bulkImportParsedOnce, setBulkImportParsedOnce] = useState(false);
|
||||
const [bulkImporting, setBulkImporting] = useState(false);
|
||||
const [bulkImportResult, setBulkImportResult] = useState<{ created: number; updated: number; failed: number } | null>(null);
|
||||
|
||||
const handleParse = () => {
|
||||
const result = parseBulkImportText(bulkImportText);
|
||||
setBulkImportParsed(result.parsed);
|
||||
setBulkImportErrors(result.errors);
|
||||
setBulkImportSkipped(result.skipped);
|
||||
setBulkImportParsedOnce(true);
|
||||
setBulkImportResult(null);
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (bulkImportParsed.length === 0) return;
|
||||
setBulkImporting(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxies/bulk-import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: bulkImportParsed }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setBulkImportResult(data);
|
||||
await onImported();
|
||||
}
|
||||
} finally {
|
||||
setBulkImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
if (!bulkImporting) onClose();
|
||||
}}
|
||||
title={t("bulkImportTitle")}
|
||||
maxWidth="xl"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">{t("bulkImportDescription")}</p>
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed"
|
||||
rows={14}
|
||||
value={bulkImportText}
|
||||
onChange={(e) => {
|
||||
setBulkImportText(e.target.value);
|
||||
setBulkImportParsedOnce(false);
|
||||
setBulkImportResult(null);
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="secondary" icon="search" onClick={handleParse}>
|
||||
{t("bulkImportParse")}
|
||||
</Button>
|
||||
|
||||
{bulkImportParsedOnce && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-emerald-400">{t("bulkImportParsed", { count: bulkImportParsed.length })}</span>
|
||||
<span className="text-text-muted">{t("bulkImportSkipped", { count: bulkImportSkipped })}</span>
|
||||
{bulkImportErrors.length > 0 && (
|
||||
<span className="text-red-400">{t("bulkImportParseErrors", { count: bulkImportErrors.length })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bulkImportErrors.length > 0 && (
|
||||
<div className="max-h-28 overflow-y-auto rounded border border-red-500/30 bg-red-500/10 p-2">
|
||||
{bulkImportErrors.map((err, idx) => (
|
||||
<div key={idx} className="text-xs text-red-400">
|
||||
{t("bulkImportErrorLine", { line: err.line, reason: t(err.reason as "bulkImportMinFields" | "bulkImportInvalidPort") })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bulkImportParsedOnce && bulkImportParsed.length > 0 && (
|
||||
<div className="overflow-x-auto max-h-48 overflow-y-auto rounded border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-text-muted border-b border-border bg-bg-subtle sticky top-0">
|
||||
<th className="py-1.5 px-2">{t("tableName")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelType")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelHost")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelPort")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelUsername")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelRegion")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelStatus")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bulkImportParsed.map((entry, idx) => (
|
||||
<tr key={idx} className="border-b border-border/40">
|
||||
<td className="py-1 px-2 font-medium text-text-main">{entry.name}</td>
|
||||
<td className="py-1 px-2">
|
||||
<span className="px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-[10px]">{entry.type}</span>
|
||||
</td>
|
||||
<td className="py-1 px-2 font-mono text-text-muted">{entry.host}</td>
|
||||
<td className="py-1 px-2 font-mono text-text-muted">{entry.port}</td>
|
||||
<td className="py-1 px-2 text-text-muted">{entry.username || "—"}</td>
|
||||
<td className="py-1 px-2 text-text-muted">{entry.region || "—"}</td>
|
||||
<td className="py-1 px-2">
|
||||
<span className={entry.status === "active" ? "text-emerald-400" : "text-text-muted"}>
|
||||
{entry.status === "active" ? t("statusActive") : t("statusInactive")}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bulkImportParsedOnce && bulkImportParsed.length === 0 && bulkImportErrors.length === 0 && (
|
||||
<div className="text-sm text-amber-400">{t("bulkImportNoValidEntries")}</div>
|
||||
)}
|
||||
|
||||
{bulkImportResult && (
|
||||
<div className="px-3 py-2 rounded border border-emerald-500/30 bg-emerald-500/10 text-sm text-emerald-400">
|
||||
{t("bulkImportSuccess", { created: bulkImportResult.created, updated: bulkImportResult.updated, failed: bulkImportResult.failed })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<Button size="sm" variant="secondary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="upload"
|
||||
onClick={handleExecute}
|
||||
loading={bulkImporting}
|
||||
disabled={!bulkImportParsedOnce || bulkImportParsed.length === 0}
|
||||
>
|
||||
{bulkImporting ? t("bulkImportImporting") : bulkImportParsed.length > 0 ? t("bulkImportImport", { count: bulkImportParsed.length }) : t("bulkImport")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
|
||||
type LaunchState = "idle" | "checking" | "launching" | "ready" | "error";
|
||||
|
||||
type Status = {
|
||||
exists?: boolean;
|
||||
running?: boolean;
|
||||
reachable?: boolean;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const res = await fetch(`/api/local/redis${endpoint}`, {
|
||||
method: options.method || "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: options.body,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
const json = await res.json();
|
||||
if (json?.error) detail = json.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact 1-click Redis control. Sits inside the resilience settings tab and
|
||||
* shells out to the same logic exposed via the `omniroute redis` CLI command.
|
||||
* The actual container management is delegated to the server-side endpoint
|
||||
* at /api/local/redis/* so the browser never executes podman/docker directly.
|
||||
*/
|
||||
export default function RedisLauncherPanel() {
|
||||
const t = useTranslations("settings");
|
||||
const [state, setState] = useState<LaunchState>("idle");
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
setState("checking");
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiCall("/status");
|
||||
setStatus(data);
|
||||
setState(data.running ? "ready" : "idle");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to query status");
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function launch() {
|
||||
setState("launching");
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiCall("/start", { method: "POST" });
|
||||
setStatus(data);
|
||||
setState("ready");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to launch Redis");
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
setState("launching");
|
||||
setError(null);
|
||||
try {
|
||||
await apiCall("/stop", { method: "POST" });
|
||||
setStatus({ exists: false, running: false, reachable: false });
|
||||
setState("idle");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to stop Redis");
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-text-main">
|
||||
{t("redisLauncherTitle", "Local Redis")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{t(
|
||||
"redisLauncherDesc",
|
||||
"One-click launch a Redis 7 container (Podman or Docker) for response cache, quota tracking, and rate limiting."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={refresh} disabled={state === "checking"}>
|
||||
{state === "checking" ? "…" : t("redisLauncherRefresh", "Refresh")}
|
||||
</Button>
|
||||
{status?.running ? (
|
||||
<Button size="sm" variant="outline" onClick={stop} disabled={state === "launching"}>
|
||||
{state === "launching" ? "…" : t("redisLauncherStop", "Stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={launch} disabled={state === "launching"}>
|
||||
{state === "launching"
|
||||
? t("redisLauncherLaunching", "Launching…")
|
||||
: t("redisLauncherLaunch", "Launch Redis")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
|
||||
<Stat
|
||||
label={t("redisLauncherContainer", "Container")}
|
||||
value={status.exists ? "present" : "missing"}
|
||||
tone={status.exists ? "ok" : "warn"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("redisLauncherRunning", "Running")}
|
||||
value={status.running ? "yes" : "no"}
|
||||
tone={status.running ? "ok" : "warn"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("redisLauncherReachable", "Reachable")}
|
||||
value={status.reachable ? "yes" : "no"}
|
||||
tone={status.reachable ? "ok" : "warn"}
|
||||
/>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-red-400">
|
||||
{t("redisLauncherError", "Error: {{message}}", { message: error })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs text-text-muted">
|
||||
{t(
|
||||
"redisLauncherHint",
|
||||
"Equivalent to running `omniroute redis up`. The container is named `omniroute-redis` and listens on 127.0.0.1:6379."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "ok" | "warn";
|
||||
}) {
|
||||
const color = tone === "ok" ? "text-emerald-400" : "text-amber-400";
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-bg-subtle p-3">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-text-muted">{label}</dt>
|
||||
<dd className={`mt-1 font-mono text-sm ${color}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ParsedProxyEntry = {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
type: string;
|
||||
region: string;
|
||||
status: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
type ParseError = {
|
||||
line: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type BulkImportResult = {
|
||||
created: number;
|
||||
updated: number;
|
||||
failed: number;
|
||||
};
|
||||
|
||||
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
|
||||
# Format: NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
|
||||
# Required: NAME, HOST, PORT
|
||||
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
|
||||
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
|
||||
#
|
||||
# SOCKS5 examples:
|
||||
# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy
|
||||
# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West
|
||||
#
|
||||
# HTTP/HTTPS examples:
|
||||
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
|
||||
# https-proxy|proxy.example.com|443|admin|secret123|https|US|active
|
||||
`;
|
||||
|
||||
const VALID_TYPES = new Set(["http", "https", "socks5"]);
|
||||
const VALID_STATUSES = new Set(["active", "inactive"]);
|
||||
|
||||
export function parseBulkImportText(text: string): {
|
||||
entries: ParsedProxyEntry[];
|
||||
errors: ParseError[];
|
||||
skipped: number;
|
||||
} {
|
||||
const lines = text.split("\n");
|
||||
const entries: ParsedProxyEntry[] = [];
|
||||
const errors: ParseError[] = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i].trim();
|
||||
if (!raw || raw.startsWith("#")) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = raw.split("|").map((p) => p.trim());
|
||||
const [name, host, portStr, username, password, type, region, status, notes] = parts;
|
||||
const lineNum = i + 1;
|
||||
|
||||
if (!name) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" });
|
||||
continue;
|
||||
}
|
||||
if (!host) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" });
|
||||
continue;
|
||||
}
|
||||
const port = Number(portStr);
|
||||
if (!portStr || isNaN(port) || port < 1 || port > 65535) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" });
|
||||
continue;
|
||||
}
|
||||
const normalizedType = (type || "socks5").toLowerCase();
|
||||
if (!VALID_TYPES.has(normalizedType)) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" });
|
||||
continue;
|
||||
}
|
||||
const normalizedStatus = (status || "active").toLowerCase();
|
||||
if (!VALID_STATUSES.has(normalizedStatus)) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" });
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
username: username || "",
|
||||
password: password || "",
|
||||
type: normalizedType,
|
||||
region: region || "",
|
||||
status: normalizedStatus,
|
||||
notes: notes || "",
|
||||
});
|
||||
}
|
||||
|
||||
return { entries, errors, skipped };
|
||||
}
|
||||
|
||||
interface UseProxyBulkImportOptions {
|
||||
onImport: (entries: ParsedProxyEntry[]) => Promise<BulkImportResult>;
|
||||
}
|
||||
|
||||
export function useProxyBulkImport({ onImport }: UseProxyBulkImportOptions) {
|
||||
const t = useTranslations("proxyRegistry");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [text, setText] = useState(BULK_IMPORT_TEMPLATE);
|
||||
const [parsed, setParsed] = useState<ParsedProxyEntry[]>([]);
|
||||
const [errors, setErrors] = useState<ParseError[]>([]);
|
||||
const [skipped, setSkipped] = useState(0);
|
||||
const [parsedOnce, setParsedOnce] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<BulkImportResult | null>(null);
|
||||
|
||||
const handleParse = useCallback(() => {
|
||||
const { entries, errors: parseErrors, skipped: skippedLines } = parseBulkImportText(text);
|
||||
setParsed(entries);
|
||||
setErrors(parseErrors);
|
||||
setSkipped(skippedLines);
|
||||
setParsedOnce(true);
|
||||
setResult(null);
|
||||
}, [text]);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (parsed.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await onImport(parsed);
|
||||
setResult(res);
|
||||
if (res.failed === 0) {
|
||||
setText(BULK_IMPORT_TEMPLATE);
|
||||
setParsed([]);
|
||||
setErrors([]);
|
||||
setSkipped(0);
|
||||
setParsedOnce(false);
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [parsed, onImport]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setText(BULK_IMPORT_TEMPLATE);
|
||||
setParsed([]);
|
||||
setErrors([]);
|
||||
setSkipped(0);
|
||||
setParsedOnce(false);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
text,
|
||||
setText,
|
||||
parsed,
|
||||
errors,
|
||||
skipped,
|
||||
parsedOnce,
|
||||
importing,
|
||||
result,
|
||||
t,
|
||||
handleParse,
|
||||
handleImport,
|
||||
handleClose,
|
||||
};
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface UseProxyPoolModalOptions {
|
||||
items: { id: string; name: string }[];
|
||||
onSave: (
|
||||
scope: string,
|
||||
scopeIds: string,
|
||||
proxyId: string,
|
||||
strategy: "round-robin" | "random" | "sticky"
|
||||
) => Promise<void>;
|
||||
onLoad: (
|
||||
scope: string,
|
||||
scopeIds: string,
|
||||
proxyId: string
|
||||
) => Promise<{ members: string[]; strategy: string } | null>;
|
||||
}
|
||||
|
||||
export function useProxyPoolModal({ items, onSave, onLoad }: UseProxyPoolModalOptions) {
|
||||
const t = useTranslations("proxyRegistry");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scope, setScope] = useState<"global" | "provider">("provider");
|
||||
const [scopeIds, setScopeIds] = useState("");
|
||||
const [proxyId, setProxyId] = useState("");
|
||||
const [strategy, setStrategy] = useState<"round-robin" | "random" | "sticky">("round-robin");
|
||||
const [members, setMembers] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const handleLoad = useCallback(async () => {
|
||||
if (!proxyId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await onLoad(scope, scopeIds, proxyId);
|
||||
if (data) {
|
||||
setMembers(data.members);
|
||||
setStrategy(data.strategy as "round-robin" | "random" | "sticky");
|
||||
setLoaded(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("poolLoadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [scope, scopeIds, proxyId, onLoad, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!proxyId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(scope, scopeIds, proxyId, strategy);
|
||||
setOpen(false);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("poolSaveFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [scope, scopeIds, proxyId, strategy, onSave, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setScope("provider");
|
||||
setScopeIds("");
|
||||
setProxyId("");
|
||||
setStrategy("round-robin");
|
||||
setMembers([]);
|
||||
setLoaded(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
scope,
|
||||
setScope,
|
||||
scopeIds,
|
||||
setScopeIds,
|
||||
proxyId,
|
||||
setProxyId,
|
||||
strategy,
|
||||
setStrategy,
|
||||
members,
|
||||
loading,
|
||||
error,
|
||||
loaded,
|
||||
handleLoad,
|
||||
handleSave,
|
||||
handleClose,
|
||||
t,
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
|
||||
export function useReplay() {
|
||||
const [replaying, setReplaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const replay = useCallback(async (req: InterceptedRequest) => {
|
||||
setReplaying(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tools/traffic-inspector/requests/${encodeURIComponent(req.id)}/replay`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
||||
setError(body?.error?.message ?? "Replay failed");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error during replay");
|
||||
} finally {
|
||||
setReplaying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { replay, replaying, error };
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export default function BudgetTelemetryCards() {
|
||||
const t = useTranslations("usage");
|
||||
const [telemetry, setTelemetry] = useState(null);
|
||||
const [cache, setCache] = useState(null);
|
||||
const [policies, setPolicies] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
fetch("/api/telemetry/summary").then((r) => r.json()),
|
||||
fetch("/api/cache/stats").then((r) => r.json()),
|
||||
fetch("/api/policies").then((r) => r.json()),
|
||||
]).then(([t, c, p]) => {
|
||||
if (t.status === "fulfilled") setTelemetry(t.value);
|
||||
if (c.status === "fulfilled") setCache(c.value);
|
||||
if (p.status === "fulfilled") setPolicies(p.value);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fmt = (ms) => (ms != null ? `${Math.round(ms)}ms` : "—");
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||
{/* Latency Card */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">speed</span>
|
||||
{t("latency")}
|
||||
</h3>
|
||||
{telemetry ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("latencyP50")}</span>
|
||||
<span className="font-mono">{fmt(telemetry.p50)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("latencyP95")}</span>
|
||||
<span className="font-mono">{fmt(telemetry.p95)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("latencyP99")}</span>
|
||||
<span className="font-mono">{fmt(telemetry.p99)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-border pt-2 mt-2">
|
||||
<span className="text-text-muted">{t("totalRequests")}</span>
|
||||
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("activeSessions")}</span>
|
||||
<span className="font-mono">{telemetry.sessions?.activeCount ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("quotaAlerts")}</span>
|
||||
<span className="font-mono">{telemetry.quotaMonitor?.alerting ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Cache Card */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">cached</span>
|
||||
{t("promptCache")}
|
||||
</h3>
|
||||
{cache ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("entries")}</span>
|
||||
<span className="font-mono">
|
||||
{cache.size}/{cache.maxSize}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("hitRate")}</span>
|
||||
<span className="font-mono">{cache.hitRate?.toFixed(1) ?? 0}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("hitsMisses")}</span>
|
||||
<span className="font-mono">
|
||||
{cache.hits ?? 0} / {cache.misses ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* System Health Card */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">monitor_heart</span>
|
||||
{t("systemHealth")}
|
||||
</h3>
|
||||
{policies ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("circuitBreakers")}</span>
|
||||
<span className="font-mono">
|
||||
{t("activeCount", { count: policies.circuitBreakers?.length ?? 0 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("lockedIPs")}</span>
|
||||
<span className="font-mono">{policies.lockedIdentifiers?.length ?? 0}</span>
|
||||
</div>
|
||||
{policies.circuitBreakers?.some((cb) => cb.state === "OPEN") && (
|
||||
<div className="mt-2 px-2 py-1 rounded bg-red-500/10 text-red-400 text-xs">
|
||||
{t("openCircuitBreakersDetected")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "@/shared/components/Card";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
import QuotaProgressBar from "./QuotaProgressBar";
|
||||
import { calculatePercentage, shouldShowQuotaUsageCount } from "./utils";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
|
||||
const planVariants = {
|
||||
free: "default",
|
||||
lite: "primary",
|
||||
pro: "primary",
|
||||
ultra: "success",
|
||||
enterprise: "info",
|
||||
};
|
||||
|
||||
export default function ProviderLimitCard({
|
||||
provider,
|
||||
name,
|
||||
plan,
|
||||
quotas = [],
|
||||
message = null,
|
||||
loading = false,
|
||||
error = null,
|
||||
onRefresh,
|
||||
}) {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const t = useTranslations("usage");
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!onRefresh || refreshing) return;
|
||||
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get provider info from config
|
||||
const getProviderColor = () => {
|
||||
const colors = {
|
||||
github: "#000000",
|
||||
antigravity: "#4285F4",
|
||||
codex: "#10A37F",
|
||||
kiro: "#FF9900",
|
||||
claude: "#D97757",
|
||||
};
|
||||
return colors[provider?.toLowerCase()] || "#6B7280";
|
||||
};
|
||||
|
||||
const providerColor = getProviderColor();
|
||||
const planVariant = planVariants[plan?.toLowerCase()] || "default";
|
||||
|
||||
return (
|
||||
<Card padding="md" className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Provider Logo */}
|
||||
<div
|
||||
className="size-10 rounded-lg flex items-center justify-center p-1.5"
|
||||
style={{ backgroundColor: `${providerColor}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span className="text-sm font-bold" style={{ color: providerColor }}>
|
||||
{provider?.slice(0, 2).toUpperCase() || "PR"}
|
||||
</span>
|
||||
) : (
|
||||
<ProviderIcon providerId={provider} size={40} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">{name || provider}</h3>
|
||||
{plan && (
|
||||
<Badge
|
||||
variant={(planVariants as any)[plan?.toLowerCase()] || "default"}
|
||||
size={"xs" as any}
|
||||
>
|
||||
{plan}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || loading}
|
||||
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t("refreshQuota")}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[20px] text-text-muted ${
|
||||
refreshing || loading ? "animate-spin" : ""
|
||||
}`}
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
|
||||
<div className="h-2 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
|
||||
<div className="h-2 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{!loading && error && (
|
||||
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-red-500 text-[20px]">error</span>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Message (for providers without API) */}
|
||||
{!loading && !error && message && (
|
||||
<div className="p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-blue-500 text-[20px]">info</span>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quota Progress Bars */}
|
||||
{!loading && !error && !message && quotas?.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{quotas.map((quota, index) => {
|
||||
const percentage =
|
||||
quota.remainingPercentage !== undefined
|
||||
? Math.round(quota.remainingPercentage)
|
||||
: calculatePercentage(quota.used, quota.total);
|
||||
const unlimited = quota.total === 0 || quota.total === null;
|
||||
|
||||
return (
|
||||
<QuotaProgressBar
|
||||
key={`${quota.name}-${index}`}
|
||||
label={quota.name}
|
||||
used={quota.used}
|
||||
total={quota.total}
|
||||
percentage={percentage}
|
||||
unlimited={unlimited}
|
||||
resetTime={quota.resetAt}
|
||||
staleAfterReset={quota.staleAfterReset === true}
|
||||
showUsageCount={shouldShowQuotaUsageCount(quota)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && !message && quotas?.length === 0 && (
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] opacity-20">data_usage</span>
|
||||
<p className="text-sm mt-2">{t("noQuotaDataAvailable")}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { formatResetTime } from "./utils";
|
||||
import { translateUsageOrFallback } from "./i18nFallback";
|
||||
|
||||
// Calculate color based on remaining percentage
|
||||
const getColorClasses = (remainingPercentage) => {
|
||||
if (remainingPercentage > 70) {
|
||||
return {
|
||||
text: "text-green-500",
|
||||
bg: "bg-green-500",
|
||||
bgLight: "bg-green-500/10",
|
||||
emoji: "🟢",
|
||||
};
|
||||
}
|
||||
|
||||
if (remainingPercentage >= 30) {
|
||||
return {
|
||||
text: "text-yellow-500",
|
||||
bg: "bg-yellow-500",
|
||||
bgLight: "bg-yellow-500/10",
|
||||
emoji: "🟡",
|
||||
};
|
||||
}
|
||||
|
||||
// 0-29% including 0% (out of quota) - show red
|
||||
return {
|
||||
text: "text-red-500",
|
||||
bg: "bg-red-500",
|
||||
bgLight: "bg-red-500/10",
|
||||
emoji: "🔴",
|
||||
};
|
||||
};
|
||||
|
||||
// Format reset time display
|
||||
const formatResetTimeDisplay = (resetTime) => {
|
||||
if (!resetTime) return null;
|
||||
|
||||
try {
|
||||
const resetDate = new Date(resetTime);
|
||||
const now = new Date();
|
||||
const isToday = resetDate.toDateString() === now.toDateString();
|
||||
const isTomorrow =
|
||||
resetDate.toDateString() === new Date(now.getTime() + 86400000).toDateString();
|
||||
|
||||
const timeStr = resetDate.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
if (isToday) return `Today, ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow, ${timeStr}`;
|
||||
|
||||
return resetDate.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default function QuotaProgressBar({
|
||||
percentage = 0,
|
||||
label = "",
|
||||
used = 0,
|
||||
total = 0,
|
||||
unlimited = false,
|
||||
resetTime = null,
|
||||
staleAfterReset = false,
|
||||
showUsageCount = true,
|
||||
}) {
|
||||
const t = useTranslations("usage");
|
||||
const colors = getColorClasses(percentage);
|
||||
const countdown = formatResetTime(resetTime);
|
||||
const resetDisplay = formatResetTimeDisplay(resetTime);
|
||||
|
||||
// percentage is already remaining percentage (from ProviderLimitCard)
|
||||
const remaining = percentage;
|
||||
const remainingPercentage = Math.round(Math.max(0, Math.min(100, remaining)));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Label and percentage */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-semibold text-text-primary">{label}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs">{colors.emoji}</span>
|
||||
<span className={cn("font-medium", colors.text)}>
|
||||
{translateUsageOrFallback(t, "percentLeft", `${remainingPercentage}% left`, {
|
||||
pct: remainingPercentage,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{!unlimited && (
|
||||
<div className={cn("h-2 rounded-full overflow-hidden", colors.bgLight)}>
|
||||
<div
|
||||
className={cn("h-full transition-all duration-300", colors.bg)}
|
||||
style={{ width: `${Math.min(remaining, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage details and countdown */}
|
||||
<div className="flex items-center justify-between text-xs text-text-muted">
|
||||
<span>
|
||||
{showUsageCount ? `${used.toLocaleString()} / ${total.toLocaleString()} requests` : null}
|
||||
</span>
|
||||
{staleAfterReset ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>⟳</span>
|
||||
<span className="font-medium">Refreshing...</span>
|
||||
</div>
|
||||
) : countdown !== "-" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>•</span>
|
||||
<span className="font-medium">Reset in {countdown}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Reset time display */}
|
||||
{resetDisplay && <div className="text-xs text-text-muted/70">Reset at {resetDisplay}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { calculatePercentage, formatResetTime, shouldShowQuotaUsageCount } from "./utils";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Format reset time display (Today, 12:00 PM)
|
||||
*/
|
||||
function formatResetTimeDisplay(resetTime, locale, t) {
|
||||
if (!resetTime) return null;
|
||||
|
||||
try {
|
||||
const date = new Date(resetTime);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
let dayStr = "";
|
||||
if (date >= today && date < tomorrow) {
|
||||
dayStr = t("today");
|
||||
} else if (date >= tomorrow && date < new Date(tomorrow.getTime() + 24 * 60 * 60 * 1000)) {
|
||||
dayStr = t("tomorrow");
|
||||
} else {
|
||||
dayStr = date.toLocaleDateString(locale, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
const timeStr = date.toLocaleTimeString(locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return t("dayTimeFormat", { day: dayStr, time: timeStr });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color classes based on remaining percentage
|
||||
*/
|
||||
function getColorClasses(remainingPercentage) {
|
||||
if (remainingPercentage > 70) {
|
||||
return {
|
||||
text: "text-green-600 dark:text-green-400",
|
||||
bg: "bg-green-500",
|
||||
bgLight: "bg-green-500/10",
|
||||
emoji: "🟢",
|
||||
};
|
||||
}
|
||||
|
||||
if (remainingPercentage >= 30) {
|
||||
return {
|
||||
text: "text-yellow-600 dark:text-yellow-400",
|
||||
bg: "bg-yellow-500",
|
||||
bgLight: "bg-yellow-500/10",
|
||||
emoji: "🟡",
|
||||
};
|
||||
}
|
||||
|
||||
// 0-29% including 0% (out of quota) - show red
|
||||
return {
|
||||
text: "text-red-600 dark:text-red-400",
|
||||
bg: "bg-red-500",
|
||||
bgLight: "bg-red-500/10",
|
||||
emoji: "🔴",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quota Table Component - Table-based display for quota data
|
||||
*/
|
||||
export default function QuotaTable({ quotas = [] }) {
|
||||
const t = useTranslations("usage");
|
||||
const locale = useLocale();
|
||||
|
||||
if (!quotas || quotas.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed">
|
||||
<colgroup>
|
||||
<col className="w-[30%]" /> {/* Model Name */}
|
||||
<col className="w-[45%]" /> {/* Limit Progress */}
|
||||
<col className="w-[25%]" /> {/* Reset Time */}
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{quotas.map((quota, index) => {
|
||||
const remaining =
|
||||
quota.remainingPercentage !== undefined
|
||||
? Math.round(quota.remainingPercentage)
|
||||
: calculatePercentage(quota.used, quota.total);
|
||||
const staleAfterReset = quota.staleAfterReset === true;
|
||||
|
||||
const colors = getColorClasses(remaining);
|
||||
const countdown = formatResetTime(quota.resetAt);
|
||||
const resetDisplay = formatResetTimeDisplay(quota.resetAt, locale, t);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
{/* Model Name with Status Emoji */}
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs">{colors.emoji}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{quota.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Limit (Progress + Numbers) */}
|
||||
<td className="py-2 px-3">
|
||||
<div className="space-y-1.5">
|
||||
{/* Progress bar - always show with border for visibility */}
|
||||
<div
|
||||
className={`h-1.5 rounded-full overflow-hidden border ${colors.bgLight} ${
|
||||
remaining === 0
|
||||
? "border-black/10 dark:border-white/10"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${colors.bg}`}
|
||||
style={{ width: `${Math.min(remaining, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Numbers */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-text-muted">
|
||||
{shouldShowQuotaUsageCount(quota)
|
||||
? `${quota.used.toLocaleString()} / ${
|
||||
quota.total > 0 ? quota.total.toLocaleString() : "∞"
|
||||
}`
|
||||
: null}
|
||||
</span>
|
||||
<span className={`font-medium ${colors.text}`}>{remaining}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Reset Time */}
|
||||
<td className="py-2 px-3">
|
||||
{staleAfterReset ? (
|
||||
<div className="text-xs text-text-muted">{t("quotaTableRefreshing")}</div>
|
||||
) : countdown !== t("notAvailableSymbol") || resetDisplay ? (
|
||||
<div className="space-y-0.5">
|
||||
{countdown !== t("notAvailableSymbol") && (
|
||||
<div className="text-sm text-text-primary font-medium">
|
||||
{t("inDuration", { duration: countdown })}
|
||||
</div>
|
||||
)}
|
||||
{resetDisplay && (
|
||||
<div className="text-xs text-text-muted">{resetDisplay}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted italic">{t("notApplicable")}</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { formatQuotaLabel, getBarColor, getQuotaRemainingPercentage, topQuotas } from "../utils";
|
||||
import QuotaMiniBar from "../QuotaMiniBar";
|
||||
import KiloPassMeter from "./KiloPassMeter";
|
||||
import { isKiloPassDisplayRow } from "../quotaParsing";
|
||||
import { translateUsageOrFallback } from "../i18nFallback";
|
||||
|
||||
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||
USD: "$",
|
||||
CNY: "¥",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
JPY: "¥",
|
||||
KRW: "₩",
|
||||
INR: "₹",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
quotas: any[];
|
||||
providerId?: string;
|
||||
/** When > MAX_VISIBLE, render "+N more" hint. */
|
||||
maxVisible?: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
message: string | null;
|
||||
/** Collapsed Kilo Pass display row for dedicated meter rendering (kilocode only). */
|
||||
kiloPassRow?: any | null;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_DEFAULT = 3;
|
||||
|
||||
function QuotaRow({ q }: { q: any }) {
|
||||
const t = useTranslations("usage");
|
||||
if (q.isCredits) {
|
||||
const colors = getBarColor(q.remainingPercentage ?? 0);
|
||||
const sym = CURRENCY_SYMBOLS[q.currency] ?? q.currency ?? "";
|
||||
const amount = (q.creditCount ?? q.remaining ?? 0).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return (
|
||||
<div className="flex min-h-[28px] items-center justify-between gap-2 py-1">
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[11px] leading-none text-text-main">
|
||||
<span className="inline-flex size-5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
className="material-symbols-outlined text-[13px] leading-none"
|
||||
style={{ color: colors.text }}
|
||||
>
|
||||
paid
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate leading-none">{formatQuotaLabel(q.name) || "Credits"}</span>
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex h-5 shrink-0 items-center text-[11px] font-bold leading-none tabular-nums"
|
||||
style={{ color: colors.text }}
|
||||
>
|
||||
{sym}
|
||||
{amount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pctRaw = getQuotaRemainingPercentage(q);
|
||||
const pct = Math.round(pctRaw);
|
||||
const colors = getBarColor(pct);
|
||||
const label = q.displayName || formatQuotaLabel(q.name);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 py-0.5" title={q.modelKey || q.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-text-main truncate flex-1 min-w-0">{label}</span>
|
||||
<span
|
||||
className="text-[11px] font-bold tabular-nums shrink-0"
|
||||
style={{ color: colors.text }}
|
||||
>
|
||||
{q.unlimited ? "∞" : translateUsageOrFallback(t, "percentLeft", `${pct}% left`, { pct })}
|
||||
</span>
|
||||
</div>
|
||||
{!q.unlimited && <QuotaMiniBar percent={pct} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QuotaCardBody({
|
||||
quotas,
|
||||
providerId,
|
||||
maxVisible = MAX_VISIBLE_DEFAULT,
|
||||
loading,
|
||||
error,
|
||||
message,
|
||||
kiloPassRow = null,
|
||||
}: Props) {
|
||||
const t = useTranslations("usage");
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-3 py-3 text-[11px] text-text-muted flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined animate-spin text-[13px]">
|
||||
progress_activity
|
||||
</span>
|
||||
{t("loadingQuotas")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-3 py-3 text-[11px] text-red-500 flex items-start gap-1.5">
|
||||
<span className="material-symbols-outlined text-[13px] mt-0.5">error</span>
|
||||
<span className="truncate">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ((!quotas || quotas.length === 0) && message) {
|
||||
return (
|
||||
<div className="px-3 py-3 text-[11px] text-text-muted italic truncate" title={message}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!quotas || quotas.length === 0) {
|
||||
return <div className="px-3 py-3 text-[11px] text-text-muted italic">{t("noQuotaData")}</div>;
|
||||
}
|
||||
|
||||
const visible = topQuotas(quotas, maxVisible, providerId);
|
||||
const hidden = Math.max(0, quotas.length - visible.length);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-3 pb-2">
|
||||
{visible.map((q, i) =>
|
||||
isKiloPassDisplayRow(q) ? (
|
||||
<KiloPassMeter
|
||||
key={`${q.name}-${q.modelKey ?? ""}-${i}`}
|
||||
base={q.kiloPassBase ?? kiloPassRow?.kiloPassBase ?? 0}
|
||||
bonus={q.kiloPassBonus ?? kiloPassRow?.kiloPassBonus ?? 0}
|
||||
used={q.used}
|
||||
total={q.total}
|
||||
remaining={q.remaining}
|
||||
nextBillingAt={q.resetAt ?? kiloPassRow?.resetAt ?? null}
|
||||
balance={q.kiloPassBalance ?? null}
|
||||
/>
|
||||
) : (
|
||||
<QuotaRow key={`${q.name}-${q.modelKey ?? ""}-${i}`} q={q} />
|
||||
)
|
||||
)}
|
||||
{hidden > 0 && (
|
||||
<div className="text-[10px] text-text-muted italic pt-0.5">+{hidden} more</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import QuotaCardGrid from "../QuotaCardGrid";
|
||||
|
||||
interface Props {
|
||||
tag: string;
|
||||
connections: any[];
|
||||
/** All props piped to the underlying QuotaCardGrid */
|
||||
quotaData: Record<string, any>;
|
||||
loading: Record<string, boolean>;
|
||||
errors: Record<string, string | null>;
|
||||
lastRefreshedAt: Record<string, string | undefined>;
|
||||
expandedRows: Set<string>;
|
||||
emailsVisible: boolean;
|
||||
providerLabels: Record<string, string>;
|
||||
onToggle: (id: string) => void;
|
||||
onRefresh: (id: string, provider: string) => void;
|
||||
onOpenCutoff: (connection: any) => void;
|
||||
/** Initial open state — defaults to true; UI persistence is out of scope. */
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export default function QuotaEnvGroup({
|
||||
tag,
|
||||
connections,
|
||||
defaultOpen = true,
|
||||
...gridProps
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<details
|
||||
open={open}
|
||||
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
|
||||
className="rounded-lg border border-border bg-surface overflow-hidden"
|
||||
>
|
||||
<summary className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-black/[0.02] dark:hover:bg-white/[0.02]">
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
{open ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
<span className="text-[13px] font-semibold text-text-main">{tag}</span>
|
||||
<span className="text-[11px] text-text-muted tabular-nums">({connections.length})</span>
|
||||
</summary>
|
||||
<div className="px-3 pb-3">
|
||||
<QuotaCardGrid connections={connections} {...gridProps} />
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -289,7 +289,7 @@ function parseDeepseek(data: any) {
|
||||
// #10078 follow-up: AgentRouter's `quotas.balance` entry (open-sse/services/usage/agentrouter.ts)
|
||||
// carries a real USD amount in `remaining` + `currency: "USD"`. The generic path
|
||||
// (normalizeQuotaEntry via parseGeneric) drops `currency` entirely and never sets
|
||||
// `isCredits`/`creditCount`, so QuotaCardBody/QuotaCardExpanded's dollar-formatted
|
||||
// `isCredits`/`creditCount`, so QuotaCardExpanded's dollar-formatted
|
||||
// renderer (which only activates on `q.isCredits`) never triggers — the balance was
|
||||
// rendered as a bare "100%/0% left" percentage instead of "$X.XX". Route it through
|
||||
// buildCreditsQuota() (same shape DeepSeek/Claude-extra-usage credits rows use) so the
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { PATCH } from "../route";
|
||||
|
||||
// Mock the settings db functions used in the route
|
||||
vi.mock("@/lib/db/settings", () => {
|
||||
const original = vi.importActual("@/lib/db/settings");
|
||||
return {
|
||||
...original,
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { getSettings, updateSettings } from "@/lib/db/settings";
|
||||
|
||||
// Helper to create a Request with JSON body
|
||||
function createPatchRequest(body: unknown) {
|
||||
return new Request("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("PATCH /api/settings", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Default settings before each test
|
||||
(getSettings as any).mockResolvedValue({
|
||||
debugMode: false,
|
||||
hiddenSidebarItems: [],
|
||||
hiddenSidebarGroupLabels: [],
|
||||
comboConfigMode: "guided",
|
||||
});
|
||||
// Mock updateSettings to merge updates into the original
|
||||
(updateSettings as any).mockImplementation(async (updates: Record<string, unknown>) => {
|
||||
const current = await (getSettings as any)();
|
||||
return { ...current, ...updates };
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles debugMode via PATCH", async () => {
|
||||
const req = createPatchRequest({ debugMode: true });
|
||||
const res = await PATCH(req as any);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.debugMode).toBe(true);
|
||||
// Ensure password is not leaked
|
||||
expect(json).not.toHaveProperty("password");
|
||||
// Verify DB update called with correct payload
|
||||
expect(updateSettings).toHaveBeenCalledOnce();
|
||||
const calledWith = (updateSettings as any).mock.calls[0][0];
|
||||
expect(calledWith.debugMode).toBe(true);
|
||||
});
|
||||
|
||||
it("updates hiddenSidebarItems via PATCH", async () => {
|
||||
const req = createPatchRequest({ hiddenSidebarItems: [] });
|
||||
const res = await PATCH(req as any);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.hiddenSidebarItems).toEqual([]);
|
||||
expect(updateSettings).toHaveBeenCalledOnce();
|
||||
const calledWith = (updateSettings as any).mock.calls[0][0];
|
||||
expect(calledWith.hiddenSidebarItems).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates hiddenSidebarGroupLabels via PATCH", async () => {
|
||||
const req = createPatchRequest({ hiddenSidebarGroupLabels: ["logs", "audit"] });
|
||||
const res = await PATCH(req as any);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.hiddenSidebarGroupLabels).toEqual(["logs", "audit"]);
|
||||
expect(updateSettings).toHaveBeenCalledOnce();
|
||||
const calledWith = (updateSettings as any).mock.calls[0][0];
|
||||
expect(calledWith.hiddenSidebarGroupLabels).toEqual(["logs", "audit"]);
|
||||
});
|
||||
|
||||
it("updates comboConfigMode via PATCH", async () => {
|
||||
const req = createPatchRequest({ comboConfigMode: "expert" });
|
||||
const res = await PATCH(req as any);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.comboConfigMode).toBe("expert");
|
||||
expect(updateSettings).toHaveBeenCalledOnce();
|
||||
const calledWith = (updateSettings as any).mock.calls[0][0];
|
||||
expect(calledWith.comboConfigMode).toBe("expert");
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
|
||||
interface PageAnalyticsProps {
|
||||
slug: string;
|
||||
title: string;
|
||||
section: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "omniroute_docs_analytics";
|
||||
const MAX_EVENTS = 200;
|
||||
|
||||
interface AnalyticsEvent {
|
||||
slug: string;
|
||||
title: string;
|
||||
section: string;
|
||||
timestamp: number;
|
||||
referrer: string;
|
||||
}
|
||||
|
||||
function getEvents(): AnalyticsEvent[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEvents(events: AnalyticsEvent[]): AnalyticsEvent[] {
|
||||
return events.slice(-MAX_EVENTS);
|
||||
}
|
||||
|
||||
export function DocsPageAnalytics({ slug, title, section }: PageAnalyticsProps) {
|
||||
const trackedRef = useRef(false);
|
||||
|
||||
const trackPageView = useCallback(() => {
|
||||
if (trackedRef.current) return;
|
||||
trackedRef.current = true;
|
||||
|
||||
try {
|
||||
const events = getEvents();
|
||||
events.push({
|
||||
slug,
|
||||
title,
|
||||
section,
|
||||
timestamp: Date.now(),
|
||||
referrer: typeof document !== "undefined" ? document.referrer : "",
|
||||
});
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(pruneEvents(events)));
|
||||
} catch {
|
||||
// localStorage unavailable — silent fail
|
||||
}
|
||||
}, [slug, title, section]);
|
||||
|
||||
useEffect(() => {
|
||||
trackPageView();
|
||||
}, [trackPageView]);
|
||||
|
||||
return null; // invisible tracking component
|
||||
}
|
||||
|
||||
export function getPopularPages(
|
||||
limit = 5
|
||||
): { slug: string; title: string; section: string; views: number }[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
|
||||
try {
|
||||
const events = getEvents();
|
||||
const counts = new Map<string, { title: string; section: string; views: number }>();
|
||||
|
||||
for (const event of events) {
|
||||
const existing = counts.get(event.slug);
|
||||
if (existing) {
|
||||
existing.views++;
|
||||
} else {
|
||||
counts.set(event.slug, {
|
||||
slug: event.slug,
|
||||
title: event.title,
|
||||
section: event.section,
|
||||
views: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(counts.values())
|
||||
.sort((a, b) => b.views - a.views)
|
||||
.slice(0, limit);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function FeedbackWidget({ slug }: { slug: string }) {
|
||||
const t = useTranslations("docs");
|
||||
const tc = useTranslations("common");
|
||||
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const handleFeedback = (type: "yes" | "no") => {
|
||||
setFeedback(type);
|
||||
setSubmitted(true);
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem("docs-feedback") || "{}");
|
||||
stored[slug] = type;
|
||||
localStorage.setItem("docs-feedback", JSON.stringify(stored));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="mt-8 p-4 bg-bg-subtle border border-border rounded-lg text-center">
|
||||
<span className="material-symbols-outlined text-primary text-2xl block mb-1">
|
||||
check_circle
|
||||
</span>
|
||||
<p className="text-sm text-text-main">{t("feedbackThanks")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 p-4 bg-bg-subtle border border-border rounded-lg">
|
||||
<p className="text-sm text-text-main mb-3">{t("feedbackQuestion")}</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleFeedback("yes")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">thumb_up</span>
|
||||
{tc("yes")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFeedback("no")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-red-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">thumb_down</span>
|
||||
{tc("no")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export default function AnimatedBackground() {
|
||||
return (
|
||||
<>
|
||||
{/* Animated Background */}
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none">
|
||||
{/* Grid pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, #E54D5E 1px, transparent 1px), linear-gradient(to bottom, #E54D5E 1px, transparent 1px)`,
|
||||
backgroundSize: "50px 50px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Animated gradient orbs */}
|
||||
<div className="absolute -top-20 left-1/4 w-[600px] h-[600px] bg-[#E54D5E]/20 rounded-full blur-[120px] animate-blob" />
|
||||
<div className="absolute top-1/3 -right-20 w-[500px] h-[500px] bg-purple-500/15 rounded-full blur-[120px] animate-blob-delayed-1" />
|
||||
<div className="absolute -bottom-20 left-1/2 w-[550px] h-[550px] bg-blue-500/12 rounded-full blur-[120px] animate-blob-delayed-2" />
|
||||
|
||||
{/* Vignette effect */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at center, transparent 0%, rgba(11, 14, 20, 0.4) 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CSS Animations */}
|
||||
<style jsx global>{`
|
||||
@keyframes blob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 20s ease-in-out infinite;
|
||||
}
|
||||
.animate-blob-delayed-1 {
|
||||
animation: blob 22s ease-in-out 2s infinite;
|
||||
}
|
||||
.animate-blob-delayed-2 {
|
||||
animation: blob 25s ease-in-out 4s infinite;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Response Helpers — FASE-03 Architecture Refactoring
|
||||
*
|
||||
* Standardized API response factories for consistent JSON responses.
|
||||
* Eliminates ad-hoc Response/NextResponse construction scattered across handlers.
|
||||
*
|
||||
* @module domain/responses
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a standard success response.
|
||||
*
|
||||
* @param {Object} data - Response payload
|
||||
* @param {number} [status=200] - HTTP status code
|
||||
* @param {Object} [headers={}] - Additional headers
|
||||
* @returns {Response}
|
||||
*/
|
||||
export function successResponse(data: unknown, status = 200, headers: Record<string, string> = {}) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standard error response.
|
||||
*
|
||||
* @param {number} status - HTTP status code
|
||||
* @param {string} code - Error code (e.g. 'INVALID_INPUT')
|
||||
* @param {string} message - Human-readable error message
|
||||
* @param {Object} [details] - Additional error details
|
||||
* @returns {Response}
|
||||
*/
|
||||
export function apiErrorResponse(status: number, code: string, message: string, details?: unknown) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
...(details && { details }),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 400 Bad Request response.
|
||||
*
|
||||
* @param {string} message - Error message
|
||||
* @param {Object} [details] - Validation details
|
||||
* @returns {Response}
|
||||
*/
|
||||
export function badRequest(message: string, details?: unknown) {
|
||||
return apiErrorResponse(400, "BAD_REQUEST", message, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 401 Unauthorized response.
|
||||
*
|
||||
* @param {string} [message='Authentication required'] - Error message
|
||||
* @returns {Response}
|
||||
*/
|
||||
export function unauthorized(message = "Authentication required") {
|
||||
return apiErrorResponse(401, "UNAUTHORIZED", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 403 Forbidden response.
|
||||
*
|
||||
* @param {string} [message='Access denied'] - Error message
|
||||
* @returns {Response}
|
||||
*/
|
||||
export function forbidden(message = "Access denied") {
|
||||
return apiErrorResponse(403, "FORBIDDEN", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 404 Not Found response.
|
||||
*
|
||||
* @param {string} [resource='Resource'] - Resource name
|
||||
* @returns {Response}
|
||||
*/
|
||||
export function notFound(resource = "Resource") {
|
||||
return apiErrorResponse(404, "NOT_FOUND", `${resource} not found`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 409 Conflict response.
|
||||
*
|
||||
* @param {string} message - Conflict description
|
||||
* @returns {Response}
|
||||
*/
|
||||
export function conflict(message: string) {
|
||||
return apiErrorResponse(409, "CONFLICT", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 429 Too Many Requests response.
|
||||
*
|
||||
* @param {number} [retryAfterSec=60] - Retry-After in seconds
|
||||
* @returns {Response}
|
||||
*/
|
||||
export function tooManyRequests(retryAfterSec = 60) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
status: 429,
|
||||
code: "RATE_LIMITED",
|
||||
message: "Too many requests, please try again later",
|
||||
retryAfter: retryAfterSec,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": String(retryAfterSec),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 500 Internal Server Error response.
|
||||
*
|
||||
* @param {string} [message='Internal server error'] - Error message
|
||||
* @returns {Response}
|
||||
*/
|
||||
export function internalError(message = "Internal server error") {
|
||||
return apiErrorResponse(500, "INTERNAL_ERROR", message);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export interface BenchmarkResult {
|
||||
name: string;
|
||||
duration: number;
|
||||
opsPerSecond: number;
|
||||
memory: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export async function runBenchmarks(): Promise<BenchmarkResult[]> {
|
||||
const results: BenchmarkResult[] = [];
|
||||
|
||||
results.push({
|
||||
name: "memory_retrieval",
|
||||
duration: 0,
|
||||
opsPerSecond: 0,
|
||||
memory: 0,
|
||||
success: true,
|
||||
});
|
||||
|
||||
results.push({
|
||||
name: "skill_execution",
|
||||
duration: 0,
|
||||
opsPerSecond: 0,
|
||||
memory: 0,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function formatBenchmarkReport(results: BenchmarkResult[]): string {
|
||||
return results.map((r) => `${r.name}: ${r.opsPerSecond.toFixed(2)} ops/s`).join("\n");
|
||||
}
|
||||
@@ -1,590 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { rowToCamel } from "../core";
|
||||
import type { QuotaSnapshotRow } from "@/shared/types/utilization";
|
||||
|
||||
const MIGRATION_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS quota_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
connection_id TEXT NOT NULL,
|
||||
window_key TEXT NOT NULL,
|
||||
remaining_percentage REAL,
|
||||
is_exhausted INTEGER DEFAULT 0,
|
||||
next_reset_at TEXT,
|
||||
window_duration_ms INTEGER,
|
||||
raw_data TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_quota_snapshots_provider_time ON quota_snapshots(provider, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_quota_snapshots_connection_time ON quota_snapshots(connection_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_quota_snapshots_created_at ON quota_snapshots(created_at);
|
||||
`;
|
||||
|
||||
interface StatementLike<TRow = unknown> {
|
||||
all: (...params: unknown[]) => TRow[];
|
||||
get: (...params: unknown[]) => TRow | undefined;
|
||||
run: (...params: unknown[]) => { changes: number };
|
||||
}
|
||||
|
||||
interface DbLike {
|
||||
prepare: <TRow = unknown>(sql: string) => StatementLike<TRow>;
|
||||
}
|
||||
|
||||
let lastCleanupAt = 0;
|
||||
|
||||
function saveQuotaSnapshotForTest(
|
||||
db: Database.Database,
|
||||
snapshot: Omit<QuotaSnapshotRow, "id" | "created_at">
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
(db as unknown as DbLike)
|
||||
.prepare(
|
||||
`INSERT INTO quota_snapshots
|
||||
(provider, connection_id, window_key, remaining_percentage, is_exhausted,
|
||||
next_reset_at, window_duration_ms, raw_data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
snapshot.provider,
|
||||
snapshot.connection_id,
|
||||
snapshot.window_key,
|
||||
snapshot.remaining_percentage,
|
||||
snapshot.is_exhausted,
|
||||
snapshot.next_reset_at,
|
||||
snapshot.window_duration_ms,
|
||||
snapshot.raw_data,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
function getQuotaSnapshotsForTest(
|
||||
db: Database.Database,
|
||||
opts: {
|
||||
provider?: string;
|
||||
connectionId?: string;
|
||||
since: string;
|
||||
until?: string;
|
||||
}
|
||||
): Array<{
|
||||
id: number;
|
||||
provider: string;
|
||||
connectionId: string;
|
||||
windowKey: string;
|
||||
remainingPercentage: number | null;
|
||||
isExhausted: number;
|
||||
nextResetAt: string | null;
|
||||
windowDurationMs: number | null;
|
||||
rawData: string | null;
|
||||
createdAt: string;
|
||||
}> {
|
||||
const conditions: string[] = ["created_at >= ?"];
|
||||
const params: unknown[] = [opts.since];
|
||||
|
||||
if (opts.provider) {
|
||||
conditions.push("provider = ?");
|
||||
params.push(opts.provider);
|
||||
}
|
||||
|
||||
if (opts.connectionId) {
|
||||
conditions.push("connection_id = ?");
|
||||
params.push(opts.connectionId);
|
||||
}
|
||||
|
||||
if (opts.until) {
|
||||
conditions.push("created_at <= ?");
|
||||
params.push(opts.until);
|
||||
}
|
||||
|
||||
const sql = `SELECT * FROM quota_snapshots WHERE ${conditions.join(" AND ")} ORDER BY created_at ASC`;
|
||||
const rows = (db as unknown as DbLike).prepare(sql).all(...params);
|
||||
return rows.map(
|
||||
(r) =>
|
||||
rowToCamel(r) as unknown as {
|
||||
id: number;
|
||||
provider: string;
|
||||
connectionId: string;
|
||||
windowKey: string;
|
||||
remainingPercentage: number | null;
|
||||
isExhausted: number;
|
||||
nextResetAt: string | null;
|
||||
windowDurationMs: number | null;
|
||||
rawData: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getAggregatedSnapshotsForTest(
|
||||
db: Database.Database,
|
||||
opts: {
|
||||
provider?: string;
|
||||
since: string;
|
||||
until?: string;
|
||||
bucketMinutes: number;
|
||||
}
|
||||
): Array<{
|
||||
timestamp: string;
|
||||
provider: string;
|
||||
remainingPct: number;
|
||||
isExhausted: boolean;
|
||||
windowKey: string;
|
||||
}> {
|
||||
const conditions: string[] = ["created_at >= ?"];
|
||||
const params: unknown[] = [opts.since];
|
||||
|
||||
if (opts.provider) {
|
||||
conditions.push("provider = ?");
|
||||
params.push(opts.provider);
|
||||
}
|
||||
|
||||
if (opts.until) {
|
||||
conditions.push("created_at <= ?");
|
||||
params.push(opts.until);
|
||||
}
|
||||
|
||||
const bucketSeconds = opts.bucketMinutes * 60;
|
||||
const sql = `
|
||||
SELECT
|
||||
datetime((strftime('%s', created_at) / ${bucketSeconds}) * ${bucketSeconds}, 'unixepoch') as bucket,
|
||||
provider,
|
||||
AVG(remaining_percentage) as remainingPct,
|
||||
MAX(is_exhausted) as isExhausted,
|
||||
window_key
|
||||
FROM quota_snapshots
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
GROUP BY bucket, provider, window_key
|
||||
ORDER BY bucket ASC
|
||||
`;
|
||||
|
||||
const rows = (db as unknown as DbLike).prepare(sql).all(...params) as Array<{
|
||||
bucket: string;
|
||||
provider: string;
|
||||
remainingPct: number | null;
|
||||
isExhausted: number;
|
||||
windowKey: string;
|
||||
}>;
|
||||
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.bucket,
|
||||
provider: r.provider,
|
||||
remainingPct: r.remainingPct ?? 0,
|
||||
isExhausted: r.isExhausted === 1,
|
||||
windowKey: r.windowKey,
|
||||
}));
|
||||
}
|
||||
|
||||
function cleanupOldSnapshotsForTest(db: Database.Database, retentionDays = 90): number {
|
||||
const now = Date.now();
|
||||
const cleanupThresholdMs = 6 * 60 * 60 * 1000;
|
||||
|
||||
if (now - lastCleanupAt < cleanupThresholdMs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
const result = (db as unknown as DbLike)
|
||||
.prepare("DELETE FROM quota_snapshots WHERE created_at < ?")
|
||||
.run(cutoffDate);
|
||||
lastCleanupAt = now;
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
describe("quotaSnapshots DB module", () => {
|
||||
let testDb: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = new Database(":memory:");
|
||||
testDb.pragma("journal_mode = WAL");
|
||||
testDb.exec(MIGRATION_SQL);
|
||||
lastCleanupAt = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (testDb) {
|
||||
testDb.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("saveQuotaSnapshot", () => {
|
||||
it("should save a quota snapshot with all fields", () => {
|
||||
const snapshot = {
|
||||
provider: "codex",
|
||||
connection_id: "conn-123",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 75.5,
|
||||
is_exhausted: 0,
|
||||
next_reset_at: "2024-01-15T10:00:00Z",
|
||||
window_duration_ms: 18000000,
|
||||
raw_data: null,
|
||||
};
|
||||
|
||||
saveQuotaSnapshotForTest(testDb, snapshot);
|
||||
|
||||
const rows = testDb
|
||||
.prepare("SELECT * FROM quota_snapshots ORDER BY id DESC LIMIT 1")
|
||||
.all() as QuotaSnapshotRow[];
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].provider).toBe("codex");
|
||||
expect(rows[0].connection_id).toBe("conn-123");
|
||||
expect(rows[0].window_key).toBe("5h");
|
||||
expect(rows[0].remaining_percentage).toBe(75.5);
|
||||
expect(rows[0].is_exhausted).toBe(0);
|
||||
});
|
||||
|
||||
it("should save snapshot with null remaining_percentage", () => {
|
||||
const snapshot = {
|
||||
provider: "claude",
|
||||
connection_id: "conn-456",
|
||||
window_key: "weekly",
|
||||
remaining_percentage: null,
|
||||
is_exhausted: 1,
|
||||
next_reset_at: null,
|
||||
window_duration_ms: null,
|
||||
raw_data: null,
|
||||
};
|
||||
|
||||
saveQuotaSnapshotForTest(testDb, snapshot);
|
||||
|
||||
const rows = testDb
|
||||
.prepare("SELECT * FROM quota_snapshots WHERE provider = ?")
|
||||
.all("claude") as QuotaSnapshotRow[];
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].remaining_percentage).toBeNull();
|
||||
expect(rows[0].is_exhausted).toBe(1);
|
||||
});
|
||||
|
||||
it("should save multiple snapshots for same provider", () => {
|
||||
const baseSnapshot = {
|
||||
provider: "test-provider",
|
||||
connection_id: "conn-789",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 80,
|
||||
is_exhausted: 0,
|
||||
next_reset_at: null,
|
||||
window_duration_ms: null,
|
||||
raw_data: null,
|
||||
};
|
||||
|
||||
saveQuotaSnapshotForTest(testDb, baseSnapshot);
|
||||
saveQuotaSnapshotForTest(testDb, { ...baseSnapshot, remaining_percentage: 60 });
|
||||
saveQuotaSnapshotForTest(testDb, { ...baseSnapshot, remaining_percentage: 40 });
|
||||
|
||||
const rows = testDb
|
||||
.prepare("SELECT * FROM quota_snapshots WHERE provider = ?")
|
||||
.all("test-provider") as QuotaSnapshotRow[];
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQuotaSnapshots", () => {
|
||||
beforeEach(() => {
|
||||
const now = new Date();
|
||||
const snapshots = [
|
||||
{
|
||||
provider: "codex",
|
||||
connection_id: "conn-1",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 80,
|
||||
is_exhausted: 0,
|
||||
created_at: new Date(now.getTime() - 1000 * 60 * 30).toISOString(),
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
connection_id: "conn-1",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 60,
|
||||
is_exhausted: 0,
|
||||
created_at: new Date(now.getTime() - 1000 * 60 * 10).toISOString(),
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
connection_id: "conn-2",
|
||||
window_key: "weekly",
|
||||
remaining_percentage: 90,
|
||||
is_exhausted: 0,
|
||||
created_at: new Date(now.getTime() - 1000 * 60 * 20).toISOString(),
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
connection_id: "conn-3",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 30,
|
||||
is_exhausted: 1,
|
||||
created_at: new Date(now.getTime() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const s of snapshots) {
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO quota_snapshots
|
||||
(provider, connection_id, window_key, remaining_percentage, is_exhausted,
|
||||
next_reset_at, window_duration_ms, raw_data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
s.provider,
|
||||
s.connection_id,
|
||||
s.window_key,
|
||||
s.remaining_percentage,
|
||||
s.is_exhausted,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
s.created_at
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should get all snapshots since given time", () => {
|
||||
const since = new Date(Date.now() - 1000 * 60 * 60).toISOString();
|
||||
const results = getQuotaSnapshotsForTest(testDb, { since });
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("should filter by provider", () => {
|
||||
const since = new Date(Date.now() - 1000 * 60 * 60).toISOString();
|
||||
const results = getQuotaSnapshotsForTest(testDb, { provider: "codex", since });
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(2);
|
||||
expect(results.every((r) => r.provider === "codex")).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter by connection_id", () => {
|
||||
const since = new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString();
|
||||
const results = getQuotaSnapshotsForTest(testDb, { connectionId: "conn-2", since });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].connectionId).toBe("conn-2");
|
||||
});
|
||||
|
||||
it("should filter by time range with until", () => {
|
||||
const since = new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString();
|
||||
const until = new Date(Date.now() - 1000 * 60 * 15).toISOString();
|
||||
const results = getQuotaSnapshotsForTest(testDb, { since, until });
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should return empty array when no matches", () => {
|
||||
const since = new Date().toISOString();
|
||||
const results = getQuotaSnapshotsForTest(testDb, { since });
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return results ordered by created_at ASC", () => {
|
||||
const since = new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString();
|
||||
const results = getQuotaSnapshotsForTest(testDb, { since });
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(new Date(results[i].createdAt) >= new Date(results[i - 1].createdAt)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAggregatedSnapshots", () => {
|
||||
beforeEach(() => {
|
||||
const now = new Date();
|
||||
const snapshots = [
|
||||
{
|
||||
provider: "codex",
|
||||
connection_id: "conn-1",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 80,
|
||||
is_exhausted: 0,
|
||||
created_at: new Date(now.getTime() - 1000 * 60 * 5).toISOString(),
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
connection_id: "conn-1",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 70,
|
||||
is_exhausted: 0,
|
||||
created_at: new Date(now.getTime() - 1000 * 60 * 3).toISOString(),
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
connection_id: "conn-2",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 60,
|
||||
is_exhausted: 0,
|
||||
created_at: new Date(now.getTime() - 1000 * 60 * 4).toISOString(),
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
connection_id: "conn-3",
|
||||
window_key: "weekly",
|
||||
remaining_percentage: 90,
|
||||
is_exhausted: 0,
|
||||
created_at: new Date(now.getTime() - 1000 * 60 * 2).toISOString(),
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
connection_id: "conn-1",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 50,
|
||||
is_exhausted: 0,
|
||||
created_at: new Date(now.getTime() - 1000 * 60 * 15).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const s of snapshots) {
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO quota_snapshots
|
||||
(provider, connection_id, window_key, remaining_percentage, is_exhausted,
|
||||
next_reset_at, window_duration_ms, raw_data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
s.provider,
|
||||
s.connection_id,
|
||||
s.window_key,
|
||||
s.remaining_percentage,
|
||||
s.is_exhausted,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
s.created_at
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should aggregate by time bucket", () => {
|
||||
const since = new Date(Date.now() - 1000 * 60 * 30).toISOString();
|
||||
const results = getAggregatedSnapshotsForTest(testDb, { since, bucketMinutes: 10 });
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should calculate average remainingPct per bucket", () => {
|
||||
const since = new Date(Date.now() - 1000 * 60 * 30).toISOString();
|
||||
const results = getAggregatedSnapshotsForTest(testDb, { since, bucketMinutes: 10 });
|
||||
|
||||
const codexResults = results.filter((r) => r.provider === "codex");
|
||||
expect(codexResults.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should track isExhausted as boolean", () => {
|
||||
const since = new Date(Date.now() - 1000 * 60 * 30).toISOString();
|
||||
const results = getAggregatedSnapshotsForTest(testDb, { since, bucketMinutes: 10 });
|
||||
|
||||
expect(results.every((r) => typeof r.isExhausted === "boolean")).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter by provider", () => {
|
||||
const since = new Date(Date.now() - 1000 * 60 * 30).toISOString();
|
||||
const results = getAggregatedSnapshotsForTest(testDb, {
|
||||
provider: "claude",
|
||||
since,
|
||||
bucketMinutes: 10,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results.every((r) => r.provider === "claude")).toBe(true);
|
||||
});
|
||||
|
||||
it("should respect until parameter", () => {
|
||||
const since = new Date(Date.now() - 1000 * 60 * 30).toISOString();
|
||||
const until = new Date(Date.now() - 1000 * 60 * 5).toISOString();
|
||||
const results = getAggregatedSnapshotsForTest(testDb, { since, until, bucketMinutes: 10 });
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should return empty array when no data in range", () => {
|
||||
const since = new Date().toISOString();
|
||||
const results = getAggregatedSnapshotsForTest(testDb, { since, bucketMinutes: 10 });
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupOldSnapshots", () => {
|
||||
beforeEach(() => {
|
||||
const now = new Date();
|
||||
const oldDate = new Date(now.getTime() - 100 * 24 * 60 * 60 * 1000);
|
||||
const recentDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const snapshots = [
|
||||
{
|
||||
provider: "codex",
|
||||
connection_id: "conn-1",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 80,
|
||||
is_exhausted: 0,
|
||||
created_at: oldDate.toISOString(),
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
connection_id: "conn-1",
|
||||
window_key: "5h",
|
||||
remaining_percentage: 70,
|
||||
is_exhausted: 0,
|
||||
created_at: oldDate.toISOString(),
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
connection_id: "conn-2",
|
||||
window_key: "weekly",
|
||||
remaining_percentage: 90,
|
||||
is_exhausted: 0,
|
||||
created_at: recentDate.toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const s of snapshots) {
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO quota_snapshots
|
||||
(provider, connection_id, window_key, remaining_percentage, is_exhausted,
|
||||
next_reset_at, window_duration_ms, raw_data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
s.provider,
|
||||
s.connection_id,
|
||||
s.window_key,
|
||||
s.remaining_percentage,
|
||||
s.is_exhausted,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
s.created_at
|
||||
);
|
||||
}
|
||||
lastCleanupAt = 0;
|
||||
});
|
||||
|
||||
it("should delete snapshots older than retention days", () => {
|
||||
const deleted = cleanupOldSnapshotsForTest(testDb, 90);
|
||||
|
||||
expect(deleted).toBe(2);
|
||||
|
||||
const remaining = testDb.prepare("SELECT COUNT(*) as count FROM quota_snapshots").get() as {
|
||||
count: number;
|
||||
};
|
||||
expect(remaining.count).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 0 if no old snapshots to delete", () => {
|
||||
cleanupOldSnapshotsForTest(testDb, 90);
|
||||
const deleted = cleanupOldSnapshotsForTest(testDb, 90);
|
||||
|
||||
expect(deleted).toBe(0);
|
||||
});
|
||||
|
||||
it("should use default retention of 90 days", () => {
|
||||
const deleted = cleanupOldSnapshotsForTest(testDb);
|
||||
expect(deleted).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user