mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
Merge remote-tracking branch 'origin/release/v3.8.43' into fix/port-2267-kiro-claude-sonnet-5
This commit is contained in:
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -624,7 +624,7 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts"
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{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}/**/*.test.ts"
|
||||
|
||||
test-vitest:
|
||||
name: Vitest (MCP / autoCombo / UI components)
|
||||
@@ -676,7 +676,7 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts"
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{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}/**/*.test.ts"
|
||||
|
||||
node-26-compat-build:
|
||||
name: Node 26 Compatibility Build
|
||||
@@ -729,7 +729,7 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts"
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{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}/**/*.test.ts"
|
||||
|
||||
test-coverage-shard:
|
||||
name: Coverage Shard (${{ matrix.shard }}/8)
|
||||
@@ -770,7 +770,7 @@ jobs:
|
||||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \
|
||||
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts"
|
||||
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{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}/**/*.test.ts"
|
||||
- name: Upload raw shard coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -335,7 +335,7 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
- **🗜️ Compression hardening** — a default-on **inflation guard** (discard the stacked result and send the verbatim original whenever compression would _grow_ the prompt), completed **Caveman rule packs** for German / French / Japanese (dedup + ultra) plus a new **Chinese (文言 / wényán) input pack** with zh-vs-ja auto-detection, and **RTK filters for Gradle & .NET (`dotnet`)** build output. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||
- **💸 Honest flat-rate cost** — subscription / coding-plan providers (ChatGPT Web, grok-web, the Minimax / Kimi / GLM / Alibaba Coding plans, Xiaomi MiMo…) now read **$0** in cost analytics instead of an inflated per-token estimate, while budget / quota / routing keep estimating unchanged. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **⚖️ Quota-Share routing** — a dedicated combo strategy that spreads load across accounts by _available quota_: Deficit-Round-Robin scheduling, per-connection `max_concurrent` with cooldown-wait queueing, multi-window usage buckets (5h / 7d / per-model), per-(key,model) caps, session stickiness for prompt-cache integrity, and proactive saturation from upstream token-usage headers. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
||||
- **⚖️ Quota-Share routing** — a dedicated combo strategy that spreads load across accounts by _available quota_: Deficit-Round-Robin scheduling, per-connection `max_concurrent` with cooldown-wait queueing, multi-window usage buckets (5h / 7d / per-model), per-(key, model) caps, session stickiness for prompt-cache integrity, and proactive saturation from upstream token-usage headers. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
||||
- **🤖 One-command CLI/agent setup** — a dedicated `setup-*` command configures each coding tool to route through OmniRoute (Claude Code, Codex, Cline, Continue, Cursor, Roo Code, Kilo Code, Crush, Goose, Qwen Code, Aider, OpenCode); `omniroute launch` / `omniroute launch-codex` are zero-config launchers. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
- **🛰️ Remote mode** — drive a remote OmniRoute from any machine with scoped access tokens (`omniroute connect` / `omniroute contexts` / `omniroute tokens`), plus an `omniroute login antigravity` helper that runs Google "native/desktop" OAuth on your own machine and pastes a credential blob into a remote/VPS install (where the loopback redirect is unreachable). → [Remote Mode](docs/guides/REMOTE-MODE.md)
|
||||
- **🧭 Smarter auto-routing** — OpenRouter-style `auto/<category>:<tier>` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), a **Fusion** strategy (fan out to a panel of models in parallel, then synthesize via a judge), **task-aware routing** (best-fit connection per task type), per-request `X-Route-Model` override, live Arena-ELO + models.dev model intelligence, per-step account allowlists, provider-wildcard combo steps, nested combo-ref execution, sticky weighted selection, and `web_search`-aware routing. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
@@ -344,7 +344,7 @@ Result: 4 layers of fallback = zero downtime
|
||||
- **💸 Cost telemetry everywhere** — `X-OmniRoute-*` cost/usage headers on every endpoint (including media), a non-token cost engine, a cache-HIT `X-OmniRoute-Cost-Saved` header, and per-key USD spend quotas. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🧠 Memory you control** — opt-in int8 vector quantization (Qdrant + sqlite-vec), memory off by default, and a per-request `x-omniroute-no-memory` header. → [Memory](docs/frameworks/MEMORY.md)
|
||||
- **🛡️ Security** — a prompt-injection guard across every LLM route (backed by a red-team suite), plus a free DuckDuckGo last-resort web search. → [Guardrails](docs/security/GUARDRAILS.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, new gateways **DGrid** and **Pioneer AI** (Fastino Labs), inbound **xAI Grok** translators plus **Grok Build (xAI)** with an OAuth import-token flow, GPT-4 / GPT-4o-mini on the GitHub Copilot provider, multi-model **Factory Droid**, **ZenMux Free** (session-cookie free tier), **Alibaba DashScope** text-to-video (`wan2.7-t2v`), a refreshed 237-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech / transcription / music / video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, new gateways **DGrid** and **Pioneer AI** (Fastino Labs), inbound **xAI Grok** translators plus **Grok Build (xAI)** with an OAuth import-token flow, GPT-4 / GPT-4o-mini on the GitHub Copilot provider, multi-model **Factory Droid**, **ZenMux Free** (session-cookie free tier), **Alibaba DashScope** text-to-video (`wan2.7-t2v`), a refreshed 237-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech/transcription/music/video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **⚡ Local performance & infra** — a one-click local Redis launcher (`omniroute redis up`, plus a dashboard Redis panel), one-click **Cloudflare Workers** and **Deno Deploy** relay deployers wired into the proxy pool, and an optional Bifrost Go sidecar that offloads the hottest relay path (`BIFROST_BASE_URL`, with automatic fallback to the TypeScript path on timeout) — now with a relay-backend selector (`OMNIROUTE_RELAY_BACKEND=ts|bifrost|auto`) so the `/v1/relay` endpoint stays the stable surface while choosing the fastest backend internally. → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
|
||||
<br/>
|
||||
@@ -555,7 +555,7 @@ claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp
|
||||
|
||||
</div>
|
||||
|
||||
> **Why use many token when few token do trick?** Every request passes through OmniRoute's compression pipeline **transparently** — no client changes. It's now a **stack of 10 composable engines** that run in order and mix & match per routing combo — building on ideas from [RTK](https://github.com/rtk-ai/rtk), [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 78K+), [LLMLingua-2](https://github.com/microsoft/LLMLingua), and [Troglodita](https://github.com/leninejunior/troglodita) (PT-BR).
|
||||
> **Why use many tokens when few tokens do the trick?** Every request passes through OmniRoute's compression pipeline **transparently** — no client changes. It's now a **stack of 10 composable engines** that run in order and mix & match per routing combo — building on ideas from [RTK](https://github.com/rtk-ai/rtk), [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 78K+), [LLMLingua-2](https://github.com/microsoft/LLMLingua), and [Troglodita](https://github.com/leninejunior/troglodita) (PT-BR).
|
||||
|
||||
### 🧱 The 10-engine stack
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
|
||||
"count": 1981,
|
||||
"count": 1982,
|
||||
"_rebaseline_2026_07_01_v3843_release": "1981->1982 (+1). v3.8.43 cycle drift, surfaced after check:mutation-test-coverage was fixed (it was masked behind that earlier step in the Fast Quality Gates chain). 1982 = the value measured by check:complexity on BOTH fce85136c (release tip) and 6d7060e21 (release + the 5 CI fixes) — identical, so all +1 is inherited cycle drift; the fixes touch only test files + linkify.ts safeHttpHref (cyclomatic ~4, well under the >=15 threshold, 0 new violations) + config JSON. Tighten via --update next cycle.",
|
||||
"_rebaseline_2026_06_28_v3840_5237_reconcile": "1980->1981 (+1). Inherited release/v3.8.40 drift surfaced while merging PR #5237 (impersonation-UA refresh) — the +1 is present on the pristine release tip (d8a392a47) WITHOUT #5237's changes, so it is #5222 (antigravity fallback-LRU retry) / #5221 (command-code) growth that merged via --admin without ratcheting complexity (the PR->release fast-gates do not run check:complexity). #5237 itself is complexity-net-zero: its only edits are a single UA constant, a regenerated golden snapshot, and baseline JSONs. Structural reduction tracked in #3501.",
|
||||
"_rebaseline_2026_06_27_v3838_release": "1978->1980 (+2). v3.8.38 cycle-close drift surfaced by the release-green pre-flight (check:complexity does NOT run on PR->release fast-gates). +2 from late-cycle feature/fix merges (compression fidelity-gate steps #5143, SSE hardening). Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + the 2 baseline JSONs — 0 production-code change. Structural reduction tracked in #3501.",
|
||||
"_rebaseline_2026_06_26_v3838_ownerprs_batch": "1972->1978 (+6). Drift do lote de merges de PRs do dono + contribuidores em release/v3.8.38 (sessao /review-prs): #4845 (antigravity convertGeminiToOpenAI), #5105 (executor zenmux-free), #5020 (executor grok-cli), #4940 (usage dedupe guard), #5093 (resilience: quota cutoff/gemini mime/model-lockout cooldown), #5015 (quota hydration + auto-combo scoping). Cada um e crescimento de feature/fix legitimo recem-TDD'd, nao regressao; o gate check:complexity NAO roda no fast-path PR->release, entao o ramo acumula sem rebaselinar (mesma familia dos rebaselines anteriores). #5121 cookie-dedup foi mantido complexity-NEUTRO via extracao do helper findExistingCookieConnection. Reducao estrutural fica como debt (#3501).",
|
||||
|
||||
@@ -158,14 +158,14 @@
|
||||
"open-sse/mcp-server/tools/advancedTools.ts": 1118,
|
||||
"_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.",
|
||||
"open-sse/services/accountFallback.ts": 1783,
|
||||
"open-sse/services/batchProcessor.ts": 828,
|
||||
"open-sse/services/batchProcessor.ts": 915,
|
||||
"open-sse/services/browserBackedChat.ts": 850,
|
||||
"open-sse/services/claudeCodeCompatible.ts": 1202,
|
||||
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
|
||||
"_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, <cap) and the async orderer orderTargetsByHeadroom is appended to the existing open-sse/services/combo/quotaStrategies.ts (<cap) next to its sibling reset-aware/reset-window orderers (reuses their connection-expansion machinery). headroom = 1 - max(util_5h, util_7d) from getSaturation (src/lib/quota/saturationSignals.ts), prefers the connection with the most free capacity. Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. fill-first stays default; all existing strategies untouched. Covered by tests/unit/combo-headroom-ranking.test.ts (pure helper) + tests/unit/combo-headroom-strategy.test.ts (orderer, saturation injected). Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_06_24_quota_share_strategy": "Dedicated quota-share strategy (Phase 3 #9): combo.ts 3180->3190 (+10 = one new `else if (strategy === \"quota-share\")` dispatch branch in handleComboChat that delegates 100% to selectQuotaShareTarget + its log line, plus the import). All the new logic lives OUT of the god-file in two new leaves under open-sse/services/combo/: quotaShareInflight.ts (in-flight counter with TTL/lease, ~150 LOC <cap) and quotaShareStrategy.ts (per-model bucket gating via isBucketSaturated + DRR proportional to weight + P2C over in-flight, ~240 LOC <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the headroom/reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. ZERO existing strategy cases were modified — only this branch was added, and the qtSd/ combos switched from fill-first to quota-share in src/lib/quota/quotaCombos.ts. Covered by tests/unit/quota-share-strategy.test.ts (gating, DRR fairness, P2C in-flight, fail-open, activation). Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_06_24_task_aware_routing": "Task-aware routing strategy (port PR #2045, OmniRoute #4945): combo.ts 3190->3225 (+35) = one new `else if (strategy === \"task-aware\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors quota-share/headroom/reset-aware branches). ZERO existing strategy cases modified. Covered by tests/unit/combo-task-aware.test.ts (35 tests). Structural shrink of combo.ts tracked in #3501.",
|
||||
"open-sse/services/combo.ts": 3368,
|
||||
"open-sse/services/combo.ts": 3387,
|
||||
"_rebaseline_2026_06_26_fidelity_gate_extraction": "Milestone-B fidelity-gate wiring residual: bodyToText+gateAdvance extracted to fidelityGateStep.ts (889->854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).",
|
||||
"_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (<cap); the residual growth is the duplicated thin-wrapper signatures + the extracted bodies' dispatch boundary, guarded by a byte-identical parity test (riskGateIntegration). Default off (DEFAULT_COMPRESSION_CONFIG unchanged). Not extractable without hiding the dispatch boundary, mirroring prior compression rebaselines. Structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_06_29_5286_memoization": "PR #5286 own growth: strategySelector.ts 899->960 (+61 = the opt-in result-memoization branches in applyCompression/applyCompressionAsync — principal+determinism gate, makeMemoKey lookup/store with model+supportsVision folded into the key, recompute-with-memo-off). Default off (memoizeCompressionResults), so zero behavior change. The memo helpers live in the leaf resultMemo.ts (<cap); the chokepoint wiring here is not extractable. Structural shrink of this hot-path file tracked in #3501.",
|
||||
@@ -181,12 +181,12 @@
|
||||
"open-sse/utils/stream.ts": 2710,
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385,
|
||||
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1028,
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 2983,
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3017,
|
||||
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105,
|
||||
"src/app/(dashboard)/dashboard/cache/page.tsx": 845,
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 900,
|
||||
"src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 922,
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": 4594,
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": 4608,
|
||||
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495,
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007,
|
||||
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2612,
|
||||
@@ -194,12 +194,12 @@
|
||||
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 784,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 942,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 868,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 869,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1204,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 974,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 996,
|
||||
"src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906,
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": 1927,
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201,
|
||||
@@ -249,15 +249,17 @@
|
||||
"src/shared/services/cliRuntime.ts": 1090,
|
||||
"src/shared/validation/schemas.ts": 2523,
|
||||
"_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791<cap pre-feature). Cohesive request/logging chokepoint wiring; structural shrink of chat.ts tracked in #3501.",
|
||||
"src/sse/handlers/chat.ts": 1635,
|
||||
"src/sse/handlers/chat.ts": 1647,
|
||||
"src/sse/handlers/chatHelpers.ts": 842,
|
||||
"src/sse/services/auth.ts": 2401,
|
||||
"src/sse/services/auth.ts": 2403,
|
||||
"open-sse/executors/default.ts": 877,
|
||||
"open-sse/translator/request/openai-responses.ts": 902,
|
||||
"open-sse/executors/kiro.ts": 944,
|
||||
"open-sse/translator/request/openai-to-claude.ts": 823,
|
||||
"tests/unit/account-fallback-service.test.ts": 1572,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2843
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2843,
|
||||
"open-sse/executors/huggingchat.ts": 813,
|
||||
"_rebaseline_2026_07_01_v3843_release_5609": "Rebaseline v3.8.43 (PR #5609 release reconciliation). DRIFT dos 109 commits do ciclo: 8 god-files existentes cresceram (ApiManagerPageClient 2983->3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui."
|
||||
},
|
||||
"testCap": 800,
|
||||
"testFrozen": {
|
||||
@@ -266,11 +268,11 @@
|
||||
"tests/integration/skills-pipeline.test.ts": 918,
|
||||
"tests/unit/account-fallback-service.test.ts": 1572,
|
||||
"tests/unit/arena-elo-sync.test.ts": 830,
|
||||
"tests/unit/batch_api.test.ts": 1303,
|
||||
"tests/unit/batch_api.test.ts": 1324,
|
||||
"tests/unit/cc-compatible-provider.test.ts": 1179,
|
||||
"tests/unit/chatcore-sanitization.test.ts": 829,
|
||||
"tests/unit/chatcore-translation-paths.test.ts": 2810,
|
||||
"tests/unit/chatgpt-web.test.ts": 3159,
|
||||
"tests/unit/chatgpt-web.test.ts": 3170,
|
||||
"tests/unit/combo-routing-engine.test.ts": 3213,
|
||||
"tests/unit/combo-strategy-fallbacks.test.ts": 880,
|
||||
"tests/unit/db-core-init.test.ts": 877,
|
||||
@@ -289,8 +291,8 @@
|
||||
"_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).",
|
||||
"tests/unit/oauth-providers-config.test.ts": 873,
|
||||
"tests/unit/perplexity-web.test.ts": 959,
|
||||
"tests/unit/provider-models-route.test.ts": 1618,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2843,
|
||||
"tests/unit/provider-models-route.test.ts": 1628,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2874,
|
||||
"_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.",
|
||||
"tests/unit/providers-page-utils.test.ts": 1052,
|
||||
"tests/unit/reasoning-cache.test.ts": 980,
|
||||
@@ -307,7 +309,8 @@
|
||||
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1234,
|
||||
"tests/unit/usage-service-hardening.test.ts": 1633,
|
||||
"tests/unit/vscode-token-routes.test.ts": 1212,
|
||||
"tests/unit/combo-config.test.ts": 881
|
||||
"tests/unit/combo-config.test.ts": 881,
|
||||
"tests/unit/web-cookie-providers-new.test.ts": 827
|
||||
},
|
||||
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
|
||||
"_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.",
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
"_comment": "Catraca de qualidade. 'down' = nao pode aumentar; 'up' = nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (somente quando melhora). Cada valor e um numero REAL medido, nunca um chute. Cobertura entra na Fase 4 a partir de um run de cobertura mergeada no CI.",
|
||||
"metrics": {
|
||||
"eslintWarnings": {
|
||||
"value": 4121,
|
||||
"value": 4158,
|
||||
"direction": "down",
|
||||
"_rebaseline_2026_07_01_v3843_release": "4121->4158 (+37). v3.8.43 cycle drift surfaced by the release-green pre-flight; the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle. 4158 = the value measured by the CI Quality Ratchet on the release tip fce85136c (release PR #5609). Trust-but-verify: the fix/release-v3843-ci-reds branch touches only test files (rtk-mcp-tools de-flake, compression-studio e2e anchor, oauth-error-linkify hardening test) + src/shared/utils/linkify.ts (eslint-clean, 0 warnings) + stryker.conf.json + this baseline -> 0 new warnings, so all +37 is inherited cycle drift (any warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
|
||||
"_rebaseline_2026_06_30_v3842_release": "4116->4121 (+5). v3.8.42 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's 90 commits — chatgpt-web PoW sha3-512 BoringSSL fix #5540, provider baseUrl/i18n umbrella #5511, proxy union proxyUrlMap+acct.proxy #5521, dead-code + duplication waves #5468-#5495, tls-options packaging #5503, release-freeze + .npmrc fetch-retries #5506, dast-smoke spawn-prefix client-safe extraction #5546, plus ~30 SSE/translator/combo/dashboard fixes). Trust-but-verify: measured 4121 via `npm run check:release-green` on the working tree INCLUDING my reconciliation (CHANGELOG/i18n/golden snapshot + file-size baseline) — those touch only config JSON + a provider snapshot (eslint-ignored) and contribute 0 warnings; all +5 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
|
||||
"_rebaseline_2026_06_29_v3841_release": "4103->4116 (+13). v3.8.41 cycle drift surfaced by the release-green collect (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's 52 commits — relay backend #5315, gemini catalog #5337, services dashboard #5299, empty-Claude-messages guard #5342, thinking-budget/redacted-replay + marker opt-out #5312/#5352/#5367, opencode proxy-pool + observability #5217/#5370/#5351, cors + HTTPS-serve #5242/#5360/#5361, grok cf_clearance #5350/#5358, oauth/chatgpt-web/routing/cli/dashboard/rerank #5326/#5240/#5239/#5238/#5264/#5332, partially offset by the dead-code sweep #5321-#5371). Trust-but-verify: measured 4116 via `npm run quality:collect` on the working tree INCLUDING my reconciliation (CHANGELOG/i18n/README/env docs + baselines) AND the lint-fix in useServiceLogs.ts — that fix REMOVES a setState-in-effect ERROR (eslintErrors stays 0) and adds an `open` listener with no `any`/unused, contributing 0 warnings; all +13 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
|
||||
"_rebaseline_2026_06_29_v3840_release": "4090->4103 (+13). v3.8.40 cycle drift surfaced by the release-green pre-flight + the release PR Quality Ratchet (the ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's ~57 commits — compression roadmap relevance/hard-budget/memoization/transparency/saliency/splitter/tool_search/RTK/QuantumLock #5289/#5288/#5286/#5284/#5285/#5283/#5269/#5268/#5260, ~20 SSE/translator/combo fixes #5248/#5250/#5254/#5261/#5255/#5273/#5258, M365 Copilot provider #5302, public-origin centralization #5278). Trust-but-verify: measured 4103 locally via `npm run quality:collect` on the release tip INCLUDING my reconciliation commits (CHANGELOG + main merge + the 2 regression test fixes 165c823f5) — the test fixes add 0 `any`/warnings (health-autopilot added a NextRequest import + asserts; chat-pipeline changed one Accept string + a comment), so all +13 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
|
||||
@@ -103,16 +104,18 @@
|
||||
"_rebaseline_2026_06_28_v3839_release": "78.4 -> 77.5 (-0.9, beyond the 0.5 eps). v3.8.39 cycle drift surfaced ONLY on the release PR (i18n-ui-coverage does NOT run on PR->release fast-gates). The cycle added new UI strings (compression studio TOON A/B table, antigravity remote-login dashboard field, amber warning icon) to the en denominator faster than the 37 non-en locales were translated; those locales need `npm run i18n:run` with OMNIROUTE_TRANSLATION_API_KEY (unavailable locally) — same precedent as _rebaseline_2026_06_18_v3828_cycle_close + _quality_rebaseline_2026_06_20_ci_ratchet. Measured by CI collect-metrics (run 28317145160) = 77.5. My release-finalize tree changes no src/i18n/messages/*.json. Tightening is tracked as follow-up (run i18n:run with creds)."
|
||||
},
|
||||
"deadExports": {
|
||||
"value": 225,
|
||||
"value": 227,
|
||||
"direction": "down",
|
||||
"_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.",
|
||||
"dedicatedGate": true,
|
||||
"_rebaseline_2026_06_30_v3842_deadcode_wave": "310 -> 225. Measured by `node scripts/check/check-dead-code.mjs` on the v3.8.42 tip after the JxnLexn dead-code (#5463/#5464/#5466) + duplication (#5471..#5500) wave landed: DEAD_EXPORTS=133 + DEAD_FILES=92 = 225. The stale 310 was the v3.8.38 release snapshot never ratcheted on PR->release fast-gates (check:dead-code runs only on ci.yml PR->main, not quality.yml). Tightening to the true measured value; release-time captain rebaselines up if parallel cycle merges add dead exports.",
|
||||
"_rebaseline_2026_06_27_v3838_release": "345->346 (+1). v3.8.38 cycle drift surfaced by the release-green pre-flight (Quality Ratchet does NOT run on PR->release fast-gates). Net +1 inherited from this cycle's feature/fix merges (new executors/providers, compression fidelity-gate module) minus #5138's removal of dead legacy store modules. Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + README + baselines — 0 production-code change. Structural cleanup tracked as debt.",
|
||||
"_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle."
|
||||
},
|
||||
"cognitiveComplexity": {
|
||||
"value": 842,
|
||||
"value": 845,
|
||||
"direction": "down",
|
||||
"_rebaseline_2026_07_01_v3843_release": "842->845 (+3). v3.8.43 cycle drift, surfaced after check:mutation-test-coverage was fixed (masked behind it in the Fast Quality Gates chain). 845 = measured by check:cognitive-complexity on BOTH fce85136c and 6d7060e21 (identical) — all +3 is inherited cycle drift; the 5 CI fixes add 0 (safeHttpHref cognitive ~2, under the 15 threshold). Tighten via --update next cycle.",
|
||||
"dedicatedGate": true,
|
||||
"_rebaseline_2026_06_29_v3841_release": "841->842 (+1). v3.8.41 cycle drift surfaced by the release-PR Quality Ratchet (cognitive-complexity does NOT run on PR->release fast-gates). The Phase-0 pre-flight measured 840 on the pre-campaign tip; the campaign's +34 later commits (thinking-budget/marker #5312/#5352/#5367, opencode proxy-pool/observability #5217/#5370/#5351, cors/HTTPS #5242/#5360/#5361, grok #5350/#5358, oauth/routing/cli #5326/#5239/#5238) added +2 net. My release-finalize changes are docs/baselines + a lint-fix (useServiceLogs open-listener), a test alignment (encryption.spec) and a pack-allowlist entry — all complexity-neutral. Structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_06_27_v3838_release": "833->841 (+8). v3.8.38 cycle drift surfaced by the release-green pre-flight (cognitive-complexity does NOT run on PR->release fast-gates). Inherited drift from this cycle's ~78 feature/fix merges (compression fidelity-gate #5143, SSE/streaming hardening #5124/#5108/#5085, resilience #5093, quota keepalive #5102, contributor provider/translator branches). god-file decomposition #3501 is complexity-neutral. Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + README + baselines — 0 production-code change. Structural shrink tracked in #3501.",
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"tests/unit/compression/session-dedup.test.ts": "v3.8.29 #4226: the vestigial SessionDedup round-trip helper was removed from source; its 2 asserts were removed accordingly (32→30). Helper no longer exists. Verified legitimate, not masking. Prune after v3.8.29 merges to main.",
|
||||
"tests/unit/compression/ultra.test.ts": "v3.8.29 #4253: the vestigial SLM seam + dead deprecated alias were removed from the ultra compression engine; 6 asserts covering the removed seam were removed accordingly (49→43). Verified legitimate, not masking. Prune after v3.8.29 merges to main.",
|
||||
"tests/unit/db-backup-extended.test.ts": "v3.8.29 #4132: db-backup de-flake — 1 timing-sensitive assertion on fire-and-forget backup completion was removed in favor of awaiting actual completion (44→43). Verified legitimate, not masking. Prune after v3.8.29 merges to main.",
|
||||
"@omniroute/opencode-plugin/tests/combos.test.ts": "v3.8.31 #4384: the plugin now prefixes every catalog key with the `omniroute` provider id and drops the legacy `combo/` namespace; the test asserting raw-deletion + a `combo/<slug>` key (a namespace that no longer exists) was removed and the remaining asserts switched to `omniroute/<slug>` keys (82→81). Asserts updated to the new key contract, not weakened. Verified legitimate. Prune after v3.8.31 merges to main."
|
||||
"@omniroute/opencode-plugin/tests/combos.test.ts": "v3.8.31 #4384: the plugin now prefixes every catalog key with the `omniroute` provider id and drops the legacy `combo/` namespace; the test asserting raw-deletion + a `combo/<slug>` key (a namespace that no longer exists) was removed and the remaining asserts switched to `omniroute/<slug>` keys (82→81). Asserts updated to the new key contract, not weakened. Verified legitimate. Prune after v3.8.31 merges to main.",
|
||||
"tests/unit/chatgpt-web.test.ts": "v3.8.43 #5549: fix(chatgpt-web) restore dot-form Pro model ids — dois assert.equal separados (base Pro slug + pass-through slug) consolidados num único assert.equal(body.model, expectedSlugById[omniId], ...) orientado por tabela de mapeamento (281→280). Asserts consolidados, não enfraquecidos. Verificado legítimo. Prune após v3.8.43 mergear para main."
|
||||
}
|
||||
|
||||
@@ -60,20 +60,40 @@ const BUILT_IN_ALIASES: Record<string, string> = {
|
||||
};
|
||||
|
||||
// ── Custom Aliases (persisted via Settings API) ─────────────────────────────
|
||||
let _customAliases: Record<string, string> = {};
|
||||
//
|
||||
// Backed by globalThis so the singleton store is shared across the SEPARATE webpack
|
||||
// module graphs Next.js builds for `instrumentation.ts` (boot-time hydration via
|
||||
// applyRuntimeSettings → setCustomAliases) and the app-route `GET /api/settings/model-aliases`.
|
||||
// A plain module-level `let` is DUPLICATED per graph, so startup hydration lands on the
|
||||
// instrumentation graph's copy while the API route reads an empty copy — the exact
|
||||
// symptom #5777 patched at the route layer. Migrating the store to globalThis fixes the
|
||||
// root cause (both instances read/write one store), mirroring the #5312 pattern already
|
||||
// applied to thinkingBudget.ts and backgroundTaskDetector.ts (and systemPrompt.ts #2470).
|
||||
const CUSTOM_ALIASES_GLOBAL_KEY = "__omniroute_customAliases__";
|
||||
const _aliasStore = globalThis as unknown as Record<
|
||||
string,
|
||||
Record<string, string> | undefined
|
||||
>;
|
||||
|
||||
function customAliases(): Record<string, string> {
|
||||
if (!_aliasStore[CUSTOM_ALIASES_GLOBAL_KEY]) {
|
||||
_aliasStore[CUSTOM_ALIASES_GLOBAL_KEY] = {};
|
||||
}
|
||||
return _aliasStore[CUSTOM_ALIASES_GLOBAL_KEY]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom aliases (called from settings API or startup).
|
||||
*/
|
||||
export function setCustomAliases(aliases: Record<string, string>): void {
|
||||
_customAliases = { ...aliases };
|
||||
_aliasStore[CUSTOM_ALIASES_GLOBAL_KEY] = { ...aliases };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current custom aliases.
|
||||
*/
|
||||
export function getCustomAliases(): Record<string, string> {
|
||||
return { ..._customAliases };
|
||||
return { ...customAliases() };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +101,7 @@ export function getCustomAliases(): Record<string, string> {
|
||||
* Custom aliases take precedence over built-in.
|
||||
*/
|
||||
export function getAllAliases(): Record<string, string> {
|
||||
return { ...BUILT_IN_ALIASES, ..._customAliases };
|
||||
return { ...BUILT_IN_ALIASES, ...customAliases() };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,7 +115,8 @@ export function resolveModelAlias(modelId: string): string {
|
||||
if (!modelId) return modelId;
|
||||
|
||||
// Check custom aliases first (higher priority)
|
||||
if (_customAliases[modelId]) return _customAliases[modelId];
|
||||
const custom = customAliases();
|
||||
if (custom[modelId]) return custom[modelId];
|
||||
|
||||
// Then check built-in
|
||||
if (BUILT_IN_ALIASES[modelId]) return BUILT_IN_ALIASES[modelId];
|
||||
@@ -129,15 +150,16 @@ export function isDeprecated(modelId: string): boolean {
|
||||
* Add a custom alias.
|
||||
*/
|
||||
export function addCustomAlias(from: string, to: string): void {
|
||||
_customAliases[from] = to;
|
||||
customAliases()[from] = to;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a custom alias.
|
||||
*/
|
||||
export function removeCustomAlias(from: string): boolean {
|
||||
if (_customAliases[from]) {
|
||||
delete _customAliases[from];
|
||||
const custom = customAliases();
|
||||
if (custom[from]) {
|
||||
delete custom[from];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -24,6 +24,20 @@ import {
|
||||
cleanJSONSchemaForAntigravity,
|
||||
} from "../helpers/geminiHelper.ts";
|
||||
import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts";
|
||||
import {
|
||||
type GeminiGenerationConfig,
|
||||
isVertexGeminiProvider,
|
||||
buildChangedToolNameMap,
|
||||
extractClientThoughtSignature,
|
||||
deepCleanUndefined,
|
||||
applyAntigravityGenerationDefaults,
|
||||
stringifyHistoricalToolArguments,
|
||||
buildInertHistoricalToolCallText,
|
||||
buildInertHistoricalToolResponseText,
|
||||
escapeHistoricalContextAttribute,
|
||||
escapeHistoricalContextContent,
|
||||
buildHistoricalToolResultContext,
|
||||
} from "./openai-to-gemini/helpers.ts";
|
||||
|
||||
// Observed Antigravity wrapper output cap, not an underlying model capability.
|
||||
// Keep this bridge-local: Antigravity currently caps visible output around 16K.
|
||||
@@ -43,20 +57,6 @@ const GEMINI_BUILTIN_TOOL_NAMES = new Set<string>([
|
||||
type GeminiPart = Record<string, unknown>;
|
||||
type GeminiContent = { role: string; parts: GeminiPart[] };
|
||||
|
||||
type GeminiGenerationConfig = {
|
||||
temperature?: unknown;
|
||||
topP?: unknown;
|
||||
topK?: unknown;
|
||||
maxOutputTokens?: unknown;
|
||||
thinkingConfig?: {
|
||||
thinkingBudget: number;
|
||||
includeThoughts: boolean;
|
||||
};
|
||||
responseMimeType?: string;
|
||||
responseSchema?: unknown;
|
||||
stopSequences?: string[] | unknown[];
|
||||
};
|
||||
|
||||
type GeminiFunctionDeclaration = {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -118,126 +118,6 @@ type GeminiToolNameOptions = {
|
||||
supportsSignatureBypass?: boolean;
|
||||
};
|
||||
|
||||
// Vertex AI (and Vertex Partner models) reject the OpenAI-style `id` field inside
|
||||
// function_call / function_response parts. Detect these by the routed provider id.
|
||||
function isVertexGeminiProvider(provider: unknown): boolean {
|
||||
return provider === "vertex" || provider === "vertex-partner";
|
||||
}
|
||||
|
||||
type OpenAIToolCallLike = {
|
||||
thoughtSignature?: unknown;
|
||||
thought_signature?: unknown;
|
||||
function?: {
|
||||
thoughtSignature?: unknown;
|
||||
thought_signature?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function buildChangedToolNameMap(toolNameMap: Map<string, string>): Map<string, string> | null {
|
||||
const changedEntries = [...toolNameMap.entries()].filter(
|
||||
([sanitizedName, originalName]) => sanitizedName !== originalName
|
||||
);
|
||||
return changedEntries.length > 0 ? new Map(changedEntries) : null;
|
||||
}
|
||||
|
||||
function extractClientThoughtSignature(toolCall: unknown): string | null {
|
||||
if (!toolCall || typeof toolCall !== "object") return null;
|
||||
const candidate = toolCall as OpenAIToolCallLike;
|
||||
|
||||
const signature =
|
||||
candidate.thoughtSignature ||
|
||||
candidate.thought_signature ||
|
||||
candidate.function?.thoughtSignature ||
|
||||
candidate.function?.thought_signature ||
|
||||
null;
|
||||
return typeof signature === "string" && signature.length > 0 ? signature : null;
|
||||
}
|
||||
|
||||
function deepCleanUndefined(value: unknown, depth = 0): void {
|
||||
if (depth > 10 || !value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
deepCleanUndefined(item, depth + 1);
|
||||
}
|
||||
} else {
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
const val = obj[key];
|
||||
if (typeof val === "string" && val === "[undefined]") {
|
||||
delete obj[key];
|
||||
} else {
|
||||
deepCleanUndefined(val, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyAntigravityGenerationDefaults(generationConfig: GeminiGenerationConfig) {
|
||||
const config = { ...generationConfig };
|
||||
if (config.topK === undefined) {
|
||||
config.topK = 40;
|
||||
}
|
||||
if (config.topP === undefined) {
|
||||
config.topP = 1;
|
||||
}
|
||||
|
||||
const thinkingBudget = Number(config.thinkingConfig?.thinkingBudget);
|
||||
const maxOutputTokens = Number(config.maxOutputTokens);
|
||||
if (
|
||||
Number.isFinite(thinkingBudget) &&
|
||||
thinkingBudget > 0 &&
|
||||
(!Number.isFinite(maxOutputTokens) || maxOutputTokens <= thinkingBudget)
|
||||
) {
|
||||
config.maxOutputTokens = Math.floor(thinkingBudget) + 1;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function stringifyHistoricalToolArguments(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value ?? {});
|
||||
} catch {
|
||||
return String(value ?? "{}");
|
||||
}
|
||||
}
|
||||
|
||||
function buildInertHistoricalToolCallText(name: string | undefined, args: unknown): string {
|
||||
const toolName = name || "unknown";
|
||||
return `[tool_history_call: ${toolName}] ${stringifyHistoricalToolArguments(args || "{}")}`;
|
||||
}
|
||||
|
||||
function buildInertHistoricalToolResponseText(name: string, response: unknown): string {
|
||||
return `[tool_history_result: ${name || "unknown"}] ${typeof response === "string" ? response : stringifyHistoricalToolArguments(response)}`;
|
||||
}
|
||||
|
||||
function escapeHistoricalContextAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function escapeHistoricalContextContent(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function buildHistoricalToolResultContext(name: string, response: unknown): string {
|
||||
const source = escapeHistoricalContextAttribute(name || "unknown");
|
||||
const rawResult =
|
||||
typeof response === "string" ? response : stringifyHistoricalToolArguments(response);
|
||||
const result = escapeHistoricalContextContent(rawResult);
|
||||
return [
|
||||
`<previous_tool_result_context source="${source}">`,
|
||||
result,
|
||||
"</previous_tool_result_context>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Gemini-family APIs (incl. Antigravity / Vertex) reject a `contents[]` array that
|
||||
// has two adjacent entries with the same role:
|
||||
// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role".
|
||||
|
||||
142
open-sse/translator/request/openai-to-gemini/helpers.ts
Normal file
142
open-sse/translator/request/openai-to-gemini/helpers.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// Pure, self-contained helpers extracted verbatim from ../openai-to-gemini.ts
|
||||
// (god-file decomposition): historical-tool-context string builders, undefined-
|
||||
// pruning, thought-signature extraction, tool-name remapping, and the Vertex
|
||||
// provider check + Antigravity generation-config defaults. No I/O or module state;
|
||||
// the host imports them back internally (these were module-private — no public API
|
||||
// change). The GeminiGenerationConfig shape lives here with its only mutator.
|
||||
|
||||
export type GeminiGenerationConfig = {
|
||||
temperature?: unknown;
|
||||
topP?: unknown;
|
||||
topK?: unknown;
|
||||
maxOutputTokens?: unknown;
|
||||
thinkingConfig?: {
|
||||
thinkingBudget: number;
|
||||
includeThoughts: boolean;
|
||||
};
|
||||
responseMimeType?: string;
|
||||
responseSchema?: unknown;
|
||||
stopSequences?: string[] | unknown[];
|
||||
};
|
||||
|
||||
// Vertex AI (and Vertex Partner models) reject the OpenAI-style `id` field inside
|
||||
// function_call / function_response parts. Detect these by the routed provider id.
|
||||
export function isVertexGeminiProvider(provider: unknown): boolean {
|
||||
return provider === "vertex" || provider === "vertex-partner";
|
||||
}
|
||||
|
||||
type OpenAIToolCallLike = {
|
||||
thoughtSignature?: unknown;
|
||||
thought_signature?: unknown;
|
||||
function?: {
|
||||
thoughtSignature?: unknown;
|
||||
thought_signature?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export function buildChangedToolNameMap(
|
||||
toolNameMap: Map<string, string>
|
||||
): Map<string, string> | null {
|
||||
const changedEntries = [...toolNameMap.entries()].filter(
|
||||
([sanitizedName, originalName]) => sanitizedName !== originalName
|
||||
);
|
||||
return changedEntries.length > 0 ? new Map(changedEntries) : null;
|
||||
}
|
||||
|
||||
export function extractClientThoughtSignature(toolCall: unknown): string | null {
|
||||
if (!toolCall || typeof toolCall !== "object") return null;
|
||||
const candidate = toolCall as OpenAIToolCallLike;
|
||||
|
||||
const signature =
|
||||
candidate.thoughtSignature ||
|
||||
candidate.thought_signature ||
|
||||
candidate.function?.thoughtSignature ||
|
||||
candidate.function?.thought_signature ||
|
||||
null;
|
||||
return typeof signature === "string" && signature.length > 0 ? signature : null;
|
||||
}
|
||||
|
||||
export function deepCleanUndefined(value: unknown, depth = 0): void {
|
||||
if (depth > 10 || !value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
deepCleanUndefined(item, depth + 1);
|
||||
}
|
||||
} else {
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
const val = obj[key];
|
||||
if (typeof val === "string" && val === "[undefined]") {
|
||||
delete obj[key];
|
||||
} else {
|
||||
deepCleanUndefined(val, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAntigravityGenerationDefaults(generationConfig: GeminiGenerationConfig) {
|
||||
const config = { ...generationConfig };
|
||||
if (config.topK === undefined) {
|
||||
config.topK = 40;
|
||||
}
|
||||
if (config.topP === undefined) {
|
||||
config.topP = 1;
|
||||
}
|
||||
|
||||
const thinkingBudget = Number(config.thinkingConfig?.thinkingBudget);
|
||||
const maxOutputTokens = Number(config.maxOutputTokens);
|
||||
if (
|
||||
Number.isFinite(thinkingBudget) &&
|
||||
thinkingBudget > 0 &&
|
||||
(!Number.isFinite(maxOutputTokens) || maxOutputTokens <= thinkingBudget)
|
||||
) {
|
||||
config.maxOutputTokens = Math.floor(thinkingBudget) + 1;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function stringifyHistoricalToolArguments(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value ?? {});
|
||||
} catch {
|
||||
return String(value ?? "{}");
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInertHistoricalToolCallText(name: string | undefined, args: unknown): string {
|
||||
const toolName = name || "unknown";
|
||||
return `[tool_history_call: ${toolName}] ${stringifyHistoricalToolArguments(args || "{}")}`;
|
||||
}
|
||||
|
||||
export function buildInertHistoricalToolResponseText(name: string, response: unknown): string {
|
||||
return `[tool_history_result: ${name || "unknown"}] ${typeof response === "string" ? response : stringifyHistoricalToolArguments(response)}`;
|
||||
}
|
||||
|
||||
export function escapeHistoricalContextAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export function escapeHistoricalContextContent(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export function buildHistoricalToolResultContext(name: string, response: unknown): string {
|
||||
const source = escapeHistoricalContextAttribute(name || "unknown");
|
||||
const rawResult =
|
||||
typeof response === "string" ? response : stringifyHistoricalToolArguments(response);
|
||||
const result = escapeHistoricalContextContent(rawResult);
|
||||
return [
|
||||
`<previous_tool_result_context source="${source}">`,
|
||||
result,
|
||||
"</previous_tool_result_context>",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -18,6 +18,25 @@
|
||||
|
||||
import zlib from "node:zlib";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
WT_VARINT,
|
||||
WT_LEN,
|
||||
encodeVarint,
|
||||
encodeTag,
|
||||
encodeBytes,
|
||||
encodeString,
|
||||
encodeMessage,
|
||||
encodeUInt32Field,
|
||||
encodeBoolField,
|
||||
encodeDoubleField,
|
||||
decodeVarint,
|
||||
checkedLen,
|
||||
decodeFields,
|
||||
findField,
|
||||
decodeStringField,
|
||||
decodeVarintField,
|
||||
type Field,
|
||||
} from "./cursorAgentProtobuf/wire.ts";
|
||||
|
||||
// ─── Field numbers (from agent.proto descriptor) ───────────────────────────
|
||||
|
||||
@@ -225,125 +244,6 @@ const LIST_VALUES = 1; // ListValue.values = repeated Value
|
||||
const MAP_KEY = 1;
|
||||
const MAP_VALUE = 2;
|
||||
|
||||
// ─── Wire-type constants ───────────────────────────────────────────────────
|
||||
|
||||
const WT_VARINT = 0;
|
||||
const WT_LEN = 2;
|
||||
|
||||
// ─── Primitive encoders ────────────────────────────────────────────────────
|
||||
|
||||
function encodeVarint(value: number | bigint): Buffer {
|
||||
let v = typeof value === "bigint" ? value : BigInt(value);
|
||||
const bytes: number[] = [];
|
||||
while (v > 0x7fn) {
|
||||
bytes.push(Number(v & 0x7fn) | 0x80);
|
||||
v >>= 7n;
|
||||
}
|
||||
bytes.push(Number(v));
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function encodeTag(fieldNumber: number, wireType: number): Buffer {
|
||||
return encodeVarint((fieldNumber << 3) | wireType);
|
||||
}
|
||||
|
||||
function encodeBytes(fieldNumber: number, value: Buffer | Uint8Array): Buffer {
|
||||
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
||||
return Buffer.concat([encodeTag(fieldNumber, WT_LEN), encodeVarint(buf.length), buf]);
|
||||
}
|
||||
|
||||
function encodeString(fieldNumber: number, value: string): Buffer {
|
||||
return encodeBytes(fieldNumber, Buffer.from(value, "utf8"));
|
||||
}
|
||||
|
||||
function encodeMessage(fieldNumber: number, parts: Buffer[]): Buffer {
|
||||
const inner = Buffer.concat(parts);
|
||||
return Buffer.concat([encodeTag(fieldNumber, WT_LEN), encodeVarint(inner.length), inner]);
|
||||
}
|
||||
|
||||
function encodeUInt32Field(fieldNumber: number, value: number): Buffer {
|
||||
return Buffer.concat([encodeTag(fieldNumber, WT_VARINT), encodeVarint(value)]);
|
||||
}
|
||||
|
||||
function encodeBoolField(fieldNumber: number, value: boolean): Buffer {
|
||||
return Buffer.concat([encodeTag(fieldNumber, WT_VARINT), encodeVarint(value ? 1 : 0)]);
|
||||
}
|
||||
|
||||
function encodeDoubleField(fieldNumber: number, value: number): Buffer {
|
||||
// wire type 1 = 64-bit fixed (double)
|
||||
const buf = Buffer.alloc(8);
|
||||
buf.writeDoubleLE(value, 0);
|
||||
return Buffer.concat([encodeTag(fieldNumber, 1), buf]);
|
||||
}
|
||||
|
||||
// ─── Primitive decoders ────────────────────────────────────────────────────
|
||||
|
||||
function decodeVarint(buf: Buffer, offset: number): [bigint, number] {
|
||||
let result = 0n;
|
||||
let shift = 0n;
|
||||
let pos = offset;
|
||||
while (pos < buf.length) {
|
||||
const byte = buf[pos++];
|
||||
result |= BigInt(byte & 0x7f) << shift;
|
||||
if ((byte & 0x80) === 0) return [result, pos];
|
||||
shift += 7n;
|
||||
}
|
||||
throw new Error("varint truncated");
|
||||
}
|
||||
|
||||
type Field =
|
||||
| { fieldNumber: number; wireType: 0; varint: bigint }
|
||||
| { fieldNumber: number; wireType: 2; bytes: Buffer };
|
||||
|
||||
/**
|
||||
* Validate a length-delimited field's declared length against the bytes that
|
||||
* actually remain in the buffer. Cursor's frames are well-formed, but a
|
||||
* corrupted or hostile upstream could declare a length that overruns the
|
||||
* buffer; without this guard `Buffer.subarray` silently clamps to EOF and a
|
||||
* truncated tool argument (or any nested message) is decoded as empty/partial
|
||||
* data instead of being recognized as malformed. Throwing lets the caller —
|
||||
* `processFrame`, wrapped in driveH2's per-frame try/catch — skip the bad
|
||||
* frame rather than act on corrupted fields. Also rejects absurd lengths that
|
||||
* would not fit a JS safe integer.
|
||||
*/
|
||||
function checkedLen(len: bigint, pos: number, buf: Buffer): number {
|
||||
if (len < 0n || len > BigInt(buf.length - pos)) {
|
||||
throw new Error(
|
||||
`length-delimited field overruns buffer (len=${len}, remaining=${buf.length - pos})`
|
||||
);
|
||||
}
|
||||
return Number(len);
|
||||
}
|
||||
|
||||
function decodeFields(buf: Buffer): Field[] {
|
||||
const fields: Field[] = [];
|
||||
let pos = 0;
|
||||
while (pos < buf.length) {
|
||||
const [tag, np] = decodeVarint(buf, pos);
|
||||
pos = np;
|
||||
const fieldNumber = Number(tag >> 3n);
|
||||
const wireType = Number(tag & 0x7n);
|
||||
if (wireType === WT_VARINT) {
|
||||
const [v, np2] = decodeVarint(buf, pos);
|
||||
pos = np2;
|
||||
fields.push({ fieldNumber, wireType: 0, varint: v });
|
||||
} else if (wireType === WT_LEN) {
|
||||
const [len, np2] = decodeVarint(buf, pos);
|
||||
pos = np2;
|
||||
const lenN = checkedLen(len, pos, buf);
|
||||
fields.push({ fieldNumber, wireType: 2, bytes: buf.subarray(pos, pos + lenN) });
|
||||
pos += lenN;
|
||||
} else if (wireType === 5) {
|
||||
pos += 4;
|
||||
} else if (wireType === 1) {
|
||||
pos += 8;
|
||||
} else {
|
||||
throw new Error(`unsupported wireType ${wireType}`);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ─── Connect-RPC framing ───────────────────────────────────────────────────
|
||||
|
||||
const FLAG_NONE = 0x00;
|
||||
@@ -547,9 +447,7 @@ export function encodeAgentRunRequest(input: AgentRunInput): Buffer {
|
||||
const selectedContextParts: Buffer[] = [];
|
||||
if (input.images && input.images.length > 0) {
|
||||
for (const img of input.images) {
|
||||
selectedContextParts.push(
|
||||
encodeMessage(SC_SELECTED_IMAGES, [encodeSelectedImageBody(img)])
|
||||
);
|
||||
selectedContextParts.push(encodeMessage(SC_SELECTED_IMAGES, [encodeSelectedImageBody(img)]));
|
||||
}
|
||||
}
|
||||
// The empty selected_context placeholder and mode=1 match cursor-agent's
|
||||
@@ -652,24 +550,6 @@ export type DecodedDelta =
|
||||
| { kind: "kv_server_message" }
|
||||
| { kind: "unknown"; field: number };
|
||||
|
||||
function findField(fields: Field[], fieldNumber: number): Field | undefined {
|
||||
return fields.find((f) => f.fieldNumber === fieldNumber);
|
||||
}
|
||||
|
||||
function decodeStringField(buf: Buffer, fieldNumber: number): string {
|
||||
const fields = decodeFields(buf);
|
||||
const f = findField(fields, fieldNumber);
|
||||
if (f && f.wireType === 2) return f.bytes.toString("utf8");
|
||||
return "";
|
||||
}
|
||||
|
||||
function decodeVarintField(buf: Buffer, fieldNumber: number): number {
|
||||
const fields = decodeFields(buf);
|
||||
const f = findField(fields, fieldNumber);
|
||||
if (f && f.wireType === 0) return Number(f.varint);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function decodeAgentServerMessage(payload: Buffer): DecodedDelta[] {
|
||||
const out: DecodedDelta[] = [];
|
||||
for (const top of decodeFields(payload)) {
|
||||
|
||||
143
open-sse/utils/cursorAgentProtobuf/wire.ts
Normal file
143
open-sse/utils/cursorAgentProtobuf/wire.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// Low-level protobuf wire-format primitives for the Cursor Agent codec, extracted
|
||||
// verbatim from ../cursorAgentProtobuf.ts (god-file decomposition). Pure and
|
||||
// dependency-free (Buffer only): varint/tag/length-delimited encode+decode and the
|
||||
// generic field walker. Framing, the value codec, and the message encoders/decoders
|
||||
// all build on this layer. Nothing here was part of the module's public API, so the
|
||||
// host imports these back internally (no re-export).
|
||||
|
||||
// ─── Wire-type constants ───────────────────────────────────────────────────
|
||||
|
||||
export const WT_VARINT = 0;
|
||||
export const WT_LEN = 2;
|
||||
|
||||
// ─── Primitive encoders ────────────────────────────────────────────────────
|
||||
|
||||
export function encodeVarint(value: number | bigint): Buffer {
|
||||
let v = typeof value === "bigint" ? value : BigInt(value);
|
||||
const bytes: number[] = [];
|
||||
while (v > 0x7fn) {
|
||||
bytes.push(Number(v & 0x7fn) | 0x80);
|
||||
v >>= 7n;
|
||||
}
|
||||
bytes.push(Number(v));
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
export function encodeTag(fieldNumber: number, wireType: number): Buffer {
|
||||
return encodeVarint((fieldNumber << 3) | wireType);
|
||||
}
|
||||
|
||||
export function encodeBytes(fieldNumber: number, value: Buffer | Uint8Array): Buffer {
|
||||
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
||||
return Buffer.concat([encodeTag(fieldNumber, WT_LEN), encodeVarint(buf.length), buf]);
|
||||
}
|
||||
|
||||
export function encodeString(fieldNumber: number, value: string): Buffer {
|
||||
return encodeBytes(fieldNumber, Buffer.from(value, "utf8"));
|
||||
}
|
||||
|
||||
export function encodeMessage(fieldNumber: number, parts: Buffer[]): Buffer {
|
||||
const inner = Buffer.concat(parts);
|
||||
return Buffer.concat([encodeTag(fieldNumber, WT_LEN), encodeVarint(inner.length), inner]);
|
||||
}
|
||||
|
||||
export function encodeUInt32Field(fieldNumber: number, value: number): Buffer {
|
||||
return Buffer.concat([encodeTag(fieldNumber, WT_VARINT), encodeVarint(value)]);
|
||||
}
|
||||
|
||||
export function encodeBoolField(fieldNumber: number, value: boolean): Buffer {
|
||||
return Buffer.concat([encodeTag(fieldNumber, WT_VARINT), encodeVarint(value ? 1 : 0)]);
|
||||
}
|
||||
|
||||
export function encodeDoubleField(fieldNumber: number, value: number): Buffer {
|
||||
// wire type 1 = 64-bit fixed (double)
|
||||
const buf = Buffer.alloc(8);
|
||||
buf.writeDoubleLE(value, 0);
|
||||
return Buffer.concat([encodeTag(fieldNumber, 1), buf]);
|
||||
}
|
||||
|
||||
// ─── Primitive decoders ────────────────────────────────────────────────────
|
||||
|
||||
export function decodeVarint(buf: Buffer, offset: number): [bigint, number] {
|
||||
let result = 0n;
|
||||
let shift = 0n;
|
||||
let pos = offset;
|
||||
while (pos < buf.length) {
|
||||
const byte = buf[pos++];
|
||||
result |= BigInt(byte & 0x7f) << shift;
|
||||
if ((byte & 0x80) === 0) return [result, pos];
|
||||
shift += 7n;
|
||||
}
|
||||
throw new Error("varint truncated");
|
||||
}
|
||||
|
||||
export type Field =
|
||||
| { fieldNumber: number; wireType: 0; varint: bigint }
|
||||
| { fieldNumber: number; wireType: 2; bytes: Buffer };
|
||||
|
||||
/**
|
||||
* Validate a length-delimited field's declared length against the bytes that
|
||||
* actually remain in the buffer. Cursor's frames are well-formed, but a
|
||||
* corrupted or hostile upstream could declare a length that overruns the
|
||||
* buffer; without this guard `Buffer.subarray` silently clamps to EOF and a
|
||||
* truncated tool argument (or any nested message) is decoded as empty/partial
|
||||
* data instead of being recognized as malformed. Throwing lets the caller —
|
||||
* `processFrame`, wrapped in driveH2's per-frame try/catch — skip the bad
|
||||
* frame rather than act on corrupted fields. Also rejects absurd lengths that
|
||||
* would not fit a JS safe integer.
|
||||
*/
|
||||
export function checkedLen(len: bigint, pos: number, buf: Buffer): number {
|
||||
if (len < 0n || len > BigInt(buf.length - pos)) {
|
||||
throw new Error(
|
||||
`length-delimited field overruns buffer (len=${len}, remaining=${buf.length - pos})`
|
||||
);
|
||||
}
|
||||
return Number(len);
|
||||
}
|
||||
|
||||
export function decodeFields(buf: Buffer): Field[] {
|
||||
const fields: Field[] = [];
|
||||
let pos = 0;
|
||||
while (pos < buf.length) {
|
||||
const [tag, np] = decodeVarint(buf, pos);
|
||||
pos = np;
|
||||
const fieldNumber = Number(tag >> 3n);
|
||||
const wireType = Number(tag & 0x7n);
|
||||
if (wireType === WT_VARINT) {
|
||||
const [v, np2] = decodeVarint(buf, pos);
|
||||
pos = np2;
|
||||
fields.push({ fieldNumber, wireType: 0, varint: v });
|
||||
} else if (wireType === WT_LEN) {
|
||||
const [len, np2] = decodeVarint(buf, pos);
|
||||
pos = np2;
|
||||
const lenN = checkedLen(len, pos, buf);
|
||||
fields.push({ fieldNumber, wireType: 2, bytes: buf.subarray(pos, pos + lenN) });
|
||||
pos += lenN;
|
||||
} else if (wireType === 5) {
|
||||
pos += 4;
|
||||
} else if (wireType === 1) {
|
||||
pos += 8;
|
||||
} else {
|
||||
throw new Error(`unsupported wireType ${wireType}`);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function findField(fields: Field[], fieldNumber: number): Field | undefined {
|
||||
return fields.find((f) => f.fieldNumber === fieldNumber);
|
||||
}
|
||||
|
||||
export function decodeStringField(buf: Buffer, fieldNumber: number): string {
|
||||
const fields = decodeFields(buf);
|
||||
const f = findField(fields, fieldNumber);
|
||||
if (f && f.wireType === 2) return f.bytes.toString("utf8");
|
||||
return "";
|
||||
}
|
||||
|
||||
export function decodeVarintField(buf: Buffer, fieldNumber: number): number {
|
||||
const fields = decodeFields(buf);
|
||||
const f = findField(fields, fieldNumber);
|
||||
if (f && f.wireType === 0) return Number(f.varint);
|
||||
return 0;
|
||||
}
|
||||
16
package.json
16
package.json
@@ -95,13 +95,13 @@
|
||||
"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 --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,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"",
|
||||
"test:unit": "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/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"",
|
||||
"test:unit:ci": "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/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"",
|
||||
"test:unit:fast": "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/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"",
|
||||
"test": "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/*.test.ts \"tests/unit/{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}/**/*.test.ts\"",
|
||||
"test:unit": "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/*.test.ts \"tests/unit/{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}/**/*.test.ts\"",
|
||||
"test:unit:ci": "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/*.test.ts \"tests/unit/{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}/**/*.test.ts\"",
|
||||
"test:unit:fast": "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/*.test.ts \"tests/unit/{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}/**/*.test.ts\"",
|
||||
"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 --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,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"",
|
||||
"test:unit:shard:2": "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/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"",
|
||||
"test:unit:shard:1": "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/*.test.ts \"tests/unit/{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}/**/*.test.ts\"",
|
||||
"test:unit:shard:2": "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/*.test.ts \"tests/unit/{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}/**/*.test.ts\"",
|
||||
"test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --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 --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/fixes-p1.test.ts",
|
||||
"test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/security-fase01.test.ts",
|
||||
@@ -191,7 +191,7 @@
|
||||
"test:mutation": "stryker run",
|
||||
"test:ecosystem": "node scripts/dev/run-ecosystem-tests.mjs",
|
||||
"test:system": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/e2e/system-failover.test.ts",
|
||||
"test:coverage": "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/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"",
|
||||
"test:coverage": "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/*.test.ts \"tests/unit/{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}/**/*.test.ts\"",
|
||||
"test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx --test tests/unit/*.test.ts",
|
||||
"coverage:report": "cross-env NODE_OPTIONS=--max-old-space-size=8192 c8 report --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov",
|
||||
"coverage:summary": "node scripts/check/test-report-summary.mjs --input coverage/coverage-summary.json --output coverage/coverage-report.md",
|
||||
@@ -217,6 +217,7 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@ngrok/ngrok": "^1.7.0",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@toon-format/toon": "^2.3.0",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@xyflow/react": "^12.11.1",
|
||||
"axios": "^1.16.1",
|
||||
@@ -265,6 +266,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"recharts": "^3.8.1",
|
||||
"safe-regex": "^2.1.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
"socks": "^2.8.7",
|
||||
"sql.js": "^1.14.1",
|
||||
|
||||
@@ -53,7 +53,7 @@ export const COLLECTORS = [
|
||||
// "vitest" e explodem no node runner). 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,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts",
|
||||
glob: "tests/unit/{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}/**/*.test.ts",
|
||||
sources: ["package.json", ".github/workflows/ci.yml"],
|
||||
},
|
||||
// Node native runner — test:integration (top-level only; tests/integration/services/ NÃO roda)
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
normalizeAndValidateHttpBaseUrl,
|
||||
extractCommandCodeCredentialInput,
|
||||
providerText,
|
||||
validationBadgeProps,
|
||||
type CommandCodeAuthFlowState,
|
||||
} from "../../providerPageHelpers";
|
||||
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
|
||||
@@ -202,7 +203,7 @@ export default function AddApiKeyModal({
|
||||
});
|
||||
const data = await res.json();
|
||||
const ok = !!data.valid;
|
||||
setValidationResult(ok ? "success" : "failed");
|
||||
setValidationResult(ok ? "success" : data.unsupported ? "unsupported" : "failed");
|
||||
// #5088: surface the detailed reason the backend returns (e.g. a TLS/EACCES
|
||||
// environment error for claude-web/chatgpt-web) instead of only a bare
|
||||
// "invalid" badge — otherwise the real cause is hidden and users are stuck.
|
||||
@@ -276,7 +277,7 @@ export default function AddApiKeyModal({
|
||||
if (!isValid && data.error) {
|
||||
validationError = data.error;
|
||||
}
|
||||
setValidationResult(isValid ? "success" : "failed");
|
||||
setValidationResult(isValid ? "success" : isUnsupported ? "unsupported" : "failed");
|
||||
} catch {
|
||||
setValidationResult("failed");
|
||||
} finally {
|
||||
@@ -674,8 +675,8 @@ export default function AddApiKeyModal({
|
||||
/>
|
||||
)}
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? t("valid") : t("invalid")}
|
||||
<Badge variant={validationBadgeProps(validationResult).variant}>
|
||||
{t(validationBadgeProps(validationResult).labelKey)}
|
||||
</Badge>
|
||||
)}
|
||||
{saveError && (
|
||||
|
||||
@@ -118,6 +118,22 @@ export function providerText(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* #5442 — Badge variant + i18n label key for an add-credential validation result.
|
||||
* A provider with no live validator returns `unsupported` (Save still succeeds);
|
||||
* previously the modal only had success/failed states, so it rendered a red
|
||||
* "Invalid" badge for those providers even though saving worked (LMArena, PiAPI…).
|
||||
* "unsupported" now maps to a neutral `info` badge ("N/A"), not "Invalid".
|
||||
*/
|
||||
export function validationBadgeProps(result: string): {
|
||||
variant: "success" | "error" | "info";
|
||||
labelKey: string;
|
||||
} {
|
||||
if (result === "success") return { variant: "success", labelKey: "valid" };
|
||||
if (result === "unsupported") return { variant: "info", labelKey: "notApplicable" };
|
||||
return { variant: "error", labelKey: "invalid" };
|
||||
}
|
||||
|
||||
/** A single model's outcome from a `/api/models/test-all` response. */
|
||||
export interface TestAllModelOutcome {
|
||||
status: "ok" | "error";
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes";
|
||||
import { isNewer, resolveLatestVersion } from "@/lib/system/versionCheck";
|
||||
import { resolveGlobalOmniroutePath } from "@/lib/system/globalPackagePath";
|
||||
// #5542 — On Windows npm is `npm.cmd`; Node ≥24 refuses to execFile a `.cmd` without
|
||||
// a shell (nodejs/node#52554 → "spawn npm ENOENT"). buildNpmExecOptions enables the
|
||||
// shell on win32 only; SERVICE_VERSION_PATTERN keeps the shell-joined version safe.
|
||||
import { buildNpmExecOptions, SERVICE_VERSION_PATTERN } from "@/lib/services/installers/utils";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -208,10 +212,11 @@ export async function POST(req: NextRequest) {
|
||||
status: "running",
|
||||
message: "Installing dependencies...",
|
||||
});
|
||||
await execFileAsync("npm", ["install", "--legacy-peer-deps"], {
|
||||
timeout: 300_000,
|
||||
cwd: PROJECT_ROOT,
|
||||
});
|
||||
await execFileAsync(
|
||||
"npm",
|
||||
["install", "--legacy-peer-deps"],
|
||||
buildNpmExecOptions(process.platform, { cwd: PROJECT_ROOT, timeoutMs: 300_000 })
|
||||
);
|
||||
send({ step: "rebuild", status: "done", message: "Dependencies installed" });
|
||||
|
||||
try {
|
||||
@@ -228,10 +233,11 @@ export async function POST(req: NextRequest) {
|
||||
status: "running",
|
||||
message: "Building application...",
|
||||
});
|
||||
await execFileAsync("npm", ["run", "build"], {
|
||||
timeout: 600_000,
|
||||
cwd: PROJECT_ROOT,
|
||||
});
|
||||
await execFileAsync(
|
||||
"npm",
|
||||
["run", "build"],
|
||||
buildNpmExecOptions(process.platform, { cwd: PROJECT_ROOT, timeoutMs: 600_000 })
|
||||
);
|
||||
send({ step: "rebuild", status: "done", message: "Build complete" });
|
||||
|
||||
send({ step: "restart", status: "running", message: "Restarting service..." });
|
||||
@@ -286,14 +292,19 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
// Step 1: Install
|
||||
// #5542 — buildNpmExecOptions enables the shell on win32 (npm.cmd), which
|
||||
// shell-joins argv, so the version spec must be metacharacter-free before it
|
||||
// reaches the command line (Hard Rule #13).
|
||||
if (!SERVICE_VERSION_PATTERN.test(latest)) {
|
||||
send({ step: "install", status: "error", message: "Invalid version format" });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
send({ step: "install", status: "running", message: `Installing omniroute@${latest}...` });
|
||||
await execFileAsync(
|
||||
"npm",
|
||||
["install", "-g", `omniroute@${latest}`, "--ignore-scripts", "--legacy-peer-deps"],
|
||||
{
|
||||
timeout: 300000,
|
||||
cwd: PROJECT_ROOT,
|
||||
}
|
||||
buildNpmExecOptions(process.platform, { cwd: PROJECT_ROOT, timeoutMs: 300_000 })
|
||||
);
|
||||
send({ step: "install", status: "done", message: `Installed omniroute@${latest}` });
|
||||
|
||||
@@ -307,10 +318,7 @@ export async function POST(req: NextRequest) {
|
||||
await execFileAsync(
|
||||
"npm",
|
||||
["rebuild", "better-sqlite3"],
|
||||
{
|
||||
cwd: omniPath,
|
||||
timeout: 120000,
|
||||
}
|
||||
buildNpmExecOptions(process.platform, { cwd: omniPath, timeoutMs: 120_000 })
|
||||
);
|
||||
send({ step: "rebuild", status: "done", message: "Native modules rebuilt" });
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ export {
|
||||
export * from "./db/compressionCacheStats";
|
||||
export * from "./db/compressionCombos";
|
||||
export * from "./db/compressionRunTelemetry";
|
||||
export * from "./db/modelContextOverrides";
|
||||
|
||||
export {
|
||||
// API Keys
|
||||
|
||||
@@ -131,6 +131,13 @@ export const grokCli = {
|
||||
expiresIn = exp - currentSec;
|
||||
}
|
||||
|
||||
// #5775 follow-up: guard against an already-expired token yielding a negative
|
||||
// expiresIn. A negative value is truthy downstream (import-token route) and maps
|
||||
// to a PAST expiresAt, which AutoCombo reads as "already expired" and excludes the
|
||||
// connection instead of refreshing it. Clamp to a tiny positive TTL so the token is
|
||||
// treated as due-for-refresh.
|
||||
expiresIn = Math.max(1, expiresIn);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { createLogger } from "@/shared/utils/logger";
|
||||
import { buildNpmExecOptions } from "@/lib/services/installers/utils";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const log = createLogger("system/versionCheck");
|
||||
@@ -71,9 +72,13 @@ export function isNewer(latest: string | null | undefined, current: string): boo
|
||||
/** Latest published version via the `npm` CLI (fast when npm is on PATH, e.g. source installs). */
|
||||
export async function getLatestVersionFromNpmCli(): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("npm", ["info", "omniroute", "version", "--json"], {
|
||||
timeout: LOOKUP_TIMEOUT_MS,
|
||||
});
|
||||
// #5542 — win32 npm is npm.cmd; execFile without a shell throws "spawn npm ENOENT"
|
||||
// on Node ≥24 (nodejs/node#52554). buildNpmExecOptions enables the shell on win32.
|
||||
const { stdout } = await execFileAsync(
|
||||
"npm",
|
||||
["info", "omniroute", "version", "--json"],
|
||||
buildNpmExecOptions(process.platform, { timeoutMs: LOOKUP_TIMEOUT_MS })
|
||||
);
|
||||
const parsed = JSON.parse(String(stdout).trim());
|
||||
return typeof parsed === "string" && parsed ? parsed : null;
|
||||
} catch {
|
||||
|
||||
30
src/shared/components/LinkifiedText.tsx
Normal file
30
src/shared/components/LinkifiedText.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Fragment } from "react";
|
||||
import { linkifyText } from "@/shared/utils/linkify";
|
||||
|
||||
/**
|
||||
* #5486 — Render a string with any embedded http(s) URLs as clickable links.
|
||||
* Used by the OAuth error step so setup instructions (e.g. GitLab Duo's
|
||||
* "register an OAuth application at https://gitlab.com/-/profile/applications …")
|
||||
* are actionable instead of dead text. Links open in a new tab with a safe rel.
|
||||
*/
|
||||
export default function LinkifiedText({ text }: { text: string | null | undefined }) {
|
||||
return (
|
||||
<>
|
||||
{linkifyText(text || "").map((seg, i) =>
|
||||
seg.href ? (
|
||||
<a
|
||||
key={i}
|
||||
href={seg.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline break-all"
|
||||
>
|
||||
{seg.text}
|
||||
</a>
|
||||
) : (
|
||||
<Fragment key={i}>{seg.text}</Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
import Input from "./Input";
|
||||
import LinkifiedText from "./LinkifiedText";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { parseResponseBody, getErrorMessage } from "@/shared/utils/api";
|
||||
import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oauthBlobSubmit";
|
||||
@@ -948,7 +949,9 @@ export default function OAuthModal({
|
||||
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("error")}</h3>
|
||||
<p className="text-sm text-red-600 mb-4">{error}</p>
|
||||
<p className="text-sm text-red-600 mb-4">
|
||||
<LinkifiedText text={error} />
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={startOAuthFlow} variant="secondary" fullWidth>
|
||||
{t("tryAgain")}
|
||||
|
||||
54
src/shared/utils/linkify.ts
Normal file
54
src/shared/utils/linkify.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// #5486 — Split a plain string into text + URL segments so messages that embed a
|
||||
// URL (e.g. the GitLab Duo OAuth setup instructions pointing at
|
||||
// https://gitlab.com/-/profile/applications) can render clickable links instead of
|
||||
// dead red text in the OAuth error step. Pure (no React) so it is unit-testable.
|
||||
//
|
||||
// The matcher is a single unbounded repetition over a NEGATED character class
|
||||
// (`[^\s...]+`) — linear time, no catastrophic backtracking (ReDoS-safe per the
|
||||
// bounded-regex rule). A trailing sentence punctuation char is peeled back out of
|
||||
// the URL so "…restart." keeps its period as text and the link stays valid.
|
||||
|
||||
export type TextSegment = { text: string; href?: string };
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s"'<>)]+/g;
|
||||
|
||||
// Defense-in-depth scheme allowlist. URL_RE already requires an http(s):// prefix, but
|
||||
// validate the scheme EXPLICITLY before exposing `href` so the http(s)-only guarantee is
|
||||
// enforced on the value itself — not merely implied by the regex. A non-parseable or
|
||||
// non-http(s) match degrades to plain text (no `href`). This also makes the sink safe to
|
||||
// static analysis: `href` provably can never carry a javascript:/data:/vbscript: scheme
|
||||
// (CodeQL js/xss + js/client-side-unvalidated-url-redirection).
|
||||
function safeHttpHref(url: string): string | undefined {
|
||||
let protocol: string;
|
||||
try {
|
||||
protocol = new URL(url).protocol;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return protocol === "http:" || protocol === "https:" ? url : undefined;
|
||||
}
|
||||
|
||||
export function linkifyText(input: string): TextSegment[] {
|
||||
if (!input) return [];
|
||||
const segments: TextSegment[] = [];
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
URL_RE.lastIndex = 0;
|
||||
while ((match = URL_RE.exec(input)) !== null) {
|
||||
if (match.index > last) {
|
||||
segments.push({ text: input.slice(last, match.index) });
|
||||
}
|
||||
let url = match[0];
|
||||
let trailing = "";
|
||||
while (url.length > 0 && /[.,;:!?]$/.test(url)) {
|
||||
trailing = url.slice(-1) + trailing;
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
const href = safeHttpHref(url);
|
||||
segments.push(href ? { text: url, href } : { text: url });
|
||||
if (trailing) segments.push({ text: trailing });
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
if (last < input.length) segments.push({ text: input.slice(last) });
|
||||
return segments;
|
||||
}
|
||||
@@ -209,6 +209,7 @@
|
||||
"tests/unit/headroom-proxy-lifecycle.test.ts",
|
||||
"tests/unit/livews-forward-backoff-4604.test.ts",
|
||||
"tests/unit/management-auth-hardening.test.ts",
|
||||
"tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts",
|
||||
"tests/unit/model-lockout-max-cooldown.test.ts",
|
||||
"tests/unit/no-memory-header.test.ts",
|
||||
"tests/unit/non-streaming-sse-terminal-typescan-4459.test.ts",
|
||||
|
||||
@@ -29,8 +29,13 @@ test.describe("Compression Studio (Tela A)", () => {
|
||||
|
||||
const playTab = page.locator('[data-testid="tab-play"]');
|
||||
await expect(playTab).toBeVisible({ timeout: 30_000 });
|
||||
// Play view is the default tab → its lane proves the studio body mounted.
|
||||
await expect(page.locator('[data-testid="play-lane"]').first()).toBeVisible({
|
||||
// Play view is the default tab → its playground input proves the studio body mounted.
|
||||
// NOTE: the per-lane `play-lane` buttons only render AFTER a preview-compression run
|
||||
// populates `batch.lanes` (usePreviewCompression keeps `batch` null until `run()` is
|
||||
// called — there is no mount auto-run). This smoke test intentionally does not drive a
|
||||
// compression cascade (see the "Out of scope" note above), so asserting `play-lane`
|
||||
// here can never become visible. Anchor on the always-present input panel instead.
|
||||
await expect(page.locator('[data-testid="play-input"]')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,7 +168,20 @@ test("callLogs.ts wires no-log and PII sanitization before persistence", () => {
|
||||
content.includes('from "../compliance"') || content.includes('from "../compliance/noLog"'),
|
||||
"callLogs.ts should import compliance module"
|
||||
);
|
||||
assert.ok(content.includes('from "../piiSanitizer"'), "callLogs.ts should import piiSanitizer");
|
||||
// PII sanitization for error strings was extracted to callLogs/format.ts by #5725
|
||||
// (sanitizeErrorForLog); callLogs.ts still wires it in before persistence, and the
|
||||
// extracted helper keeps the piiSanitizer dependency — so the "sanitize before
|
||||
// persist" invariant holds post-refactor (verified on both the helper and the file).
|
||||
assert.ok(
|
||||
content.includes("sanitizeErrorForLog") && content.includes('from "./callLogs/format"'),
|
||||
"callLogs.ts should wire the extracted PII-sanitizing error helper (sanitizeErrorForLog)"
|
||||
);
|
||||
const formatHelperContent = readIfExists("src/lib/usage/callLogs/format.ts");
|
||||
assert.ok(formatHelperContent, "src/lib/usage/callLogs/format.ts should exist");
|
||||
assert.ok(
|
||||
formatHelperContent.includes('from "../../piiSanitizer"'),
|
||||
"callLogs/format.ts should import piiSanitizer (PII sanitization still wired post-#5725)"
|
||||
);
|
||||
assert.ok(content.includes("isNoLog("), "callLogs.ts should check no-log policy");
|
||||
|
||||
const payloadHelperContent = readIfExists("src/lib/logPayloads.ts");
|
||||
|
||||
39
tests/unit/_fixtures/tsx-runtime-modern-syntax.ts
Normal file
39
tests/unit/_fixtures/tsx-runtime-modern-syntax.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* #5757 guard fixture — NOT a test file (no `.test.` in the name, so the runner
|
||||
* skips it). Exercised by `tests/unit/tsx-runtime-transform-5757.test.ts`.
|
||||
*
|
||||
* It concentrates the modern JS/TS syntax that the published CLI's runtime
|
||||
* `tsx/esm` loader (`bin/omniroute.mjs` → `await import("tsx/esm")`) must
|
||||
* transform through esbuild at startup: object/array destructuring + rest,
|
||||
* object/array spread, class + private fields, optional chaining, nullish
|
||||
* coalescing, logical assignment, async/await and top-level await.
|
||||
*
|
||||
* If a future esbuild (pulled transitively via `tsx`) cannot transform this on a
|
||||
* supported Node runtime, running this file fails — which is the whole point.
|
||||
*/
|
||||
class Box {
|
||||
value = 41; // public class field
|
||||
#secret = 1; // private field
|
||||
|
||||
bump(): number {
|
||||
this.value += this.#secret;
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { a, b, ...rest } = { a: 1, b: 2, c: 3, d: 4 }; // object destructuring + rest
|
||||
const [first, ...tail] = [10, 20, 30]; // array destructuring + rest
|
||||
const merged = { ...rest, first }; // object spread
|
||||
const arr = [...tail, first]; // array spread
|
||||
const bumped = new Box().bump(); // class + private field
|
||||
const maybe: { x?: { y?: number } } = {};
|
||||
const opt = maybe?.x?.y ?? 99; // optional chaining + nullish coalescing
|
||||
let acc = 0;
|
||||
acc ||= bumped; // logical assignment
|
||||
const total = await Promise.resolve(a + b + first + opt + acc); // async/await
|
||||
return { a, b, rest, first, tail, merged, arr, bumped, opt, total };
|
||||
}
|
||||
|
||||
const result = await main(); // top-level await
|
||||
console.log("TSX_TRANSFORM_OK " + JSON.stringify(result));
|
||||
@@ -61,13 +61,14 @@ test("permissions modal switch buttons declare button type", () => {
|
||||
selfServiceBlock.match(/<button\s+type="button"\s+role="switch"/g) ?? []
|
||||
).length;
|
||||
|
||||
// Self-service Visibility block has 4 switches: own-usage visibility,
|
||||
// shared-account quota visibility, disable-non-public-models (#3041), and the
|
||||
// per-key local usage command allowance (#4034).
|
||||
// Self-service Visibility block has 5 switches: own-usage visibility,
|
||||
// shared-account quota visibility, disable-non-public-models (#3041), the
|
||||
// per-key local usage command allowance (#4034), and the API-key provider
|
||||
// quota-policy bypass scope (#5731).
|
||||
// The invariant is that every switch declares type="button"
|
||||
// (typedSwitchButtonCount === switchButtonCount) to avoid implicit submit.
|
||||
assert.equal(switchButtonCount, 4);
|
||||
assert.equal(typedSwitchButtonCount, 4);
|
||||
assert.equal(switchButtonCount, 5);
|
||||
assert.equal(typedSwitchButtonCount, 5);
|
||||
});
|
||||
|
||||
test("permissions modal exposes Claude Code default wildcard model", () => {
|
||||
|
||||
57
tests/unit/autoupdate-npm-win32-5542.test.ts
Normal file
57
tests/unit/autoupdate-npm-win32-5542.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
|
||||
// #5542 — On Windows, npm is `npm.cmd`; Node ≥24 refuses to execFile a `.cmd`
|
||||
// without a shell (nodejs/node#52554), so the in-app auto-update flow threw
|
||||
// "spawn npm ENOENT" for the version lookup, dependency install, global install,
|
||||
// and native rebuild. Those npm calls now go through buildNpmExecOptions (the
|
||||
// same win32-shell helper the embedded-services installer uses, fix #5379).
|
||||
const { buildNpmExecOptions, SERVICE_VERSION_PATTERN } = await import(
|
||||
"../../src/lib/services/installers/utils.ts"
|
||||
);
|
||||
|
||||
test("#5542 npm exec options enable the shell on win32 (resolves npm.cmd → no ENOENT)", () => {
|
||||
const win = buildNpmExecOptions("win32", { cwd: "/x", timeoutMs: 1000 });
|
||||
assert.equal(win.shell, true, "win32 must enable the shell so npm.cmd resolves");
|
||||
assert.equal(win.timeout, 1000);
|
||||
assert.equal(win.cwd, "/x");
|
||||
|
||||
const linux = buildNpmExecOptions("linux", { cwd: "/x", timeoutMs: 1000 });
|
||||
assert.notEqual(linux.shell, true, "non-win32 must not enable the shell");
|
||||
});
|
||||
|
||||
test("#5542 the update version spec is validated before it is shell-joined (Hard Rule #13)", () => {
|
||||
assert.ok(SERVICE_VERSION_PATTERN.test("3.8.43"));
|
||||
assert.ok(SERVICE_VERSION_PATTERN.test("3.8.43-beta.1"));
|
||||
assert.ok(!SERVICE_VERSION_PATTERN.test("1.0.0; rm -rf /"));
|
||||
assert.ok(!SERVICE_VERSION_PATTERN.test("$(whoami)"));
|
||||
assert.ok(!SERVICE_VERSION_PATTERN.test("1.0 && curl evil"));
|
||||
});
|
||||
|
||||
test("#5542 the auto-update npm call sites route through buildNpmExecOptions", () => {
|
||||
const routeSrc = fs.readFileSync(
|
||||
new URL("../../src/app/api/system/version/route.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const checkSrc = fs.readFileSync(
|
||||
new URL("../../src/lib/system/versionCheck.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
assert.ok(routeSrc.includes("buildNpmExecOptions"), "version route must use the win32-shell helper");
|
||||
assert.ok(checkSrc.includes("buildNpmExecOptions"), "versionCheck must use the win32-shell helper");
|
||||
// The global install spec must be guarded before it reaches the shell.
|
||||
assert.ok(
|
||||
routeSrc.includes("SERVICE_VERSION_PATTERN.test(latest)"),
|
||||
"version route must validate the version spec before shell-joining it"
|
||||
);
|
||||
// Every npm invocation in the route must pass buildNpmExecOptions (not a bare
|
||||
// inline options object that would lack the win32 shell).
|
||||
const npmCalls = routeSrc.match(/execFileAsync\(\s*\n?\s*"npm",/g) || [];
|
||||
const npmViaHelper = routeSrc.match(/"npm",[\s\S]{0,120}?buildNpmExecOptions\(/g) || [];
|
||||
assert.equal(
|
||||
npmViaHelper.length,
|
||||
npmCalls.length,
|
||||
`all ${npmCalls.length} npm calls must route through buildNpmExecOptions`
|
||||
);
|
||||
});
|
||||
@@ -27,8 +27,17 @@ const NOISE = [
|
||||
|
||||
function seedSamples() {
|
||||
// retention "always" forces a capture even on a successful (non-failure) output.
|
||||
// The two captures MUST differ in content: the raw-output filename is keyed on
|
||||
// Date.now() (ms) + a hash of the content (see rawOutput.ts), so two BYTE-IDENTICAL
|
||||
// captures that land in the same millisecond collapse to the same filename — the 2nd
|
||||
// write overwrites the 1st, leaving only 1 sample. That made this test ~25% flaky on
|
||||
// fast CI runners ("expected 2, got 1"). Distinct content = 2 files regardless of
|
||||
// timing; two real build runs never emit byte-identical output anyway.
|
||||
maybePersistRtkRawOutput(NOISE, { retention: "always", command: "gradle build" });
|
||||
maybePersistRtkRawOutput(NOISE, { retention: "always", command: "gradle build" });
|
||||
maybePersistRtkRawOutput(`${NOISE}\n> Task :app:test UP-TO-DATE`, {
|
||||
retention: "always",
|
||||
command: "gradle build",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
83
tests/unit/cursor-protobuf-wire-split.test.ts
Normal file
83
tests/unit/cursor-protobuf-wire-split.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Split-guard for the cursorAgentProtobuf wire-primitive extraction (god-file
|
||||
// decomposition): the low-level protobuf wire codec (varint/tag/length-delimited
|
||||
// encode+decode + the generic field walker) moved verbatim from cursorAgentProtobuf.ts
|
||||
// into cursorAgentProtobuf/wire.ts. These primitives were module-private, so the host's
|
||||
// public API is unchanged; the host imports them back internally. The locks pin the
|
||||
// leaf's surface, the encode↔decode round-trip invariants, the overrun guard, and that
|
||||
// the host now imports the wire leaf instead of defining the primitives inline.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import * as wire from "../../open-sse/utils/cursorAgentProtobuf/wire.ts";
|
||||
|
||||
test("wire leaf exposes the primitives and wire-type constants", () => {
|
||||
assert.equal(wire.WT_VARINT, 0);
|
||||
assert.equal(wire.WT_LEN, 2);
|
||||
for (const fn of [
|
||||
"encodeVarint",
|
||||
"encodeTag",
|
||||
"encodeBytes",
|
||||
"encodeString",
|
||||
"encodeMessage",
|
||||
"encodeUInt32Field",
|
||||
"encodeBoolField",
|
||||
"encodeDoubleField",
|
||||
"decodeVarint",
|
||||
"checkedLen",
|
||||
"decodeFields",
|
||||
"findField",
|
||||
"decodeStringField",
|
||||
"decodeVarintField",
|
||||
]) {
|
||||
assert.equal(
|
||||
typeof (wire as Record<string, unknown>)[fn],
|
||||
"function",
|
||||
`${fn} must be exported`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("varint round-trips across byte-boundaries and bigints", () => {
|
||||
for (const n of [0, 1, 127, 128, 300, 16384, 2 ** 31]) {
|
||||
const [decoded, next] = wire.decodeVarint(wire.encodeVarint(n), 0);
|
||||
assert.equal(decoded, BigInt(n), `varint ${n}`);
|
||||
assert.equal(next, wire.encodeVarint(n).length);
|
||||
}
|
||||
const big = 9007199254740993n; // > Number.MAX_SAFE_INTEGER
|
||||
assert.equal(wire.decodeVarint(wire.encodeVarint(big), 0)[0], big);
|
||||
});
|
||||
|
||||
test("string / uint32 / bool fields round-trip through decodeFields", () => {
|
||||
assert.equal(wire.decodeStringField(wire.encodeString(1, "héllo"), 1), "héllo");
|
||||
assert.equal(wire.decodeStringField(wire.encodeString(3, ""), 3), "");
|
||||
assert.equal(wire.decodeVarintField(wire.encodeUInt32Field(2, 42), 2), 42);
|
||||
assert.equal(wire.decodeVarintField(wire.encodeBoolField(4, true), 4), 1);
|
||||
assert.equal(wire.decodeVarintField(wire.encodeBoolField(4, false), 4), 0);
|
||||
});
|
||||
|
||||
test("decodeFields tags length-delimited vs varint fields and encodeMessage nests", () => {
|
||||
const nested = wire.encodeMessage(5, [wire.encodeString(1, "x"), wire.encodeUInt32Field(2, 7)]);
|
||||
const [outer] = wire.decodeFields(nested);
|
||||
assert.equal(outer.fieldNumber, 5);
|
||||
assert.equal(outer.wireType, 2);
|
||||
if (outer.wireType === 2) {
|
||||
const inner = wire.decodeFields(outer.bytes);
|
||||
assert.equal(inner.length, 2);
|
||||
assert.equal(wire.decodeStringField(outer.bytes, 1), "x");
|
||||
assert.equal(wire.decodeVarintField(outer.bytes, 2), 7);
|
||||
}
|
||||
});
|
||||
|
||||
test("checkedLen rejects a length that overruns the buffer", () => {
|
||||
assert.throws(() => wire.checkedLen(5n, 0, Buffer.alloc(3)), /overruns buffer/);
|
||||
assert.equal(wire.checkedLen(3n, 0, Buffer.alloc(3)), 3);
|
||||
});
|
||||
|
||||
test("host imports the wire leaf and no longer defines the primitives inline", () => {
|
||||
const host = fs.readFileSync(path.join("open-sse", "utils", "cursorAgentProtobuf.ts"), "utf-8");
|
||||
assert.match(host, /from "\.\/cursorAgentProtobuf\/wire\.ts"/);
|
||||
assert.doesNotMatch(host, /^function encodeVarint\(/m, "encodeVarint must live in the wire leaf");
|
||||
assert.doesNotMatch(host, /^function decodeFields\(/m, "decodeFields must live in the wire leaf");
|
||||
});
|
||||
@@ -152,3 +152,32 @@ test("Grok Build OAuth Provider - mapTokens falls back to 21600 if no exp or exp
|
||||
|
||||
assert.equal(result.expiresIn, 21600);
|
||||
});
|
||||
|
||||
// #5775 follow-up: an already-expired token must NOT produce a negative expiresIn.
|
||||
// A negative value is truthy in the import-token route (route.ts), yielding a PAST
|
||||
// expiresAt that AutoCombo (virtualFactory.ts) reads as "already expired" and excludes
|
||||
// the connection immediately — instead of clamping to a tiny positive TTL so the token
|
||||
// is treated as due-for-refresh. Clamp with Math.max(1, …).
|
||||
test("Grok Build OAuth Provider - mapTokens clamps expired JWT exp to a positive expiresIn", () => {
|
||||
const pastSec = Math.floor(Date.now() / 1000) - 3600; // expired 1h ago
|
||||
const payload = { sub: "12345", email: "test@example.com", exp: pastSec };
|
||||
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const mockJwt = `eyJhbGciOiJFUzI1NiJ9.${payloadBase64}.signature`;
|
||||
const result = grokCli.mapTokens(mockJwt, null);
|
||||
|
||||
assert.ok(result.expiresIn >= 1, `expected expiresIn >= 1, got ${result.expiresIn}`);
|
||||
});
|
||||
|
||||
test("Grok Build OAuth Provider - mapTokens clamps expired JSON expires_at to a positive expiresIn", () => {
|
||||
const pastDateStr = new Date(Date.now() - 3600 * 1000).toISOString(); // expired 1h ago
|
||||
const authJson = {
|
||||
"https://auth.x.ai::clientId": {
|
||||
key: "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
|
||||
refresh_token: "test-refresh-token",
|
||||
expires_at: pastDateStr,
|
||||
},
|
||||
};
|
||||
const result = grokCli.mapTokens(authJson, null);
|
||||
|
||||
assert.ok(result.expiresIn >= 1, `expected expiresIn >= 1, got ${result.expiresIn}`);
|
||||
});
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("injectMemory — GLM providers use user role (#1701)", () => {
|
||||
// ── normalizeSystemRole — GLM model names ──────────────────────────────────────
|
||||
|
||||
describe("normalizeSystemRole — GLM model names (#1701)", () => {
|
||||
it("should convert system→user for glm-5.1", () => {
|
||||
it("should preserve system role for glm-5.1 (GLM 5.1+ accepts system, #5610)", () => {
|
||||
const messages = [
|
||||
{ role: "system", content: "Memory context: test" },
|
||||
{ role: "user", content: "Hello" },
|
||||
@@ -130,7 +130,10 @@ describe("normalizeSystemRole — GLM model names (#1701)", () => {
|
||||
const result = normalizeSystemRole(messages, "glm", "glm-5.1");
|
||||
assert.ok(Array.isArray(result));
|
||||
const roles = (result as { role: string }[]).map((m) => m.role);
|
||||
assert.ok(!roles.includes("system"), "system role should be converted");
|
||||
// GLM 5.1 / 5.2 (and newer) accept the `system` role per z.ai docs (#5610), so it
|
||||
// must NOT be folded into the first user turn — unlike bare "glm" and the 4.x / 5.0
|
||||
// families (covered below), which still reject system and get converted.
|
||||
assert.ok(roles.includes("system"), "system role should be preserved for glm-5.1");
|
||||
assert.ok(roles.includes("user"), "should have user role");
|
||||
});
|
||||
|
||||
|
||||
71
tests/unit/model-aliases-globalthis-5777.test.ts
Normal file
71
tests/unit/model-aliases-globalthis-5777.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* #5777 follow-up — root-cause regression guard for the custom-alias store.
|
||||
*
|
||||
* The standalone production build makes webpack emit TWO copies of
|
||||
* `open-sse/services/modelDeprecation.ts`: one hydrated at startup
|
||||
* (`applyRuntimeSettings` → `setCustomAliases`) for request routing, and one used by
|
||||
* `GET /api/settings/model-aliases`. When the store was a plain module-level
|
||||
* `let _customAliases`, each copy had its own state, so the API route read an empty
|
||||
* map after restart (#5777). The store is now backed by `globalThis` so BOTH module
|
||||
* instances share one object — the same #5312 pattern used by thinkingBudget.ts /
|
||||
* backgroundTaskDetector.ts.
|
||||
*
|
||||
* These tests fail on the old plain-`let` implementation: it never touches globalThis
|
||||
* (test 1) and never reads a value another instance wrote to globalThis (test 2).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const GLOBAL_KEY = "__omniroute_customAliases__";
|
||||
const g = globalThis as unknown as Record<string, Record<string, string> | undefined>;
|
||||
|
||||
const modelDeprecation = await import("../../open-sse/services/modelDeprecation.ts");
|
||||
|
||||
test.beforeEach(() => {
|
||||
delete g[GLOBAL_KEY];
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete g[GLOBAL_KEY];
|
||||
});
|
||||
|
||||
test("#5777: setCustomAliases writes through globalThis (store is not a per-module let)", () => {
|
||||
modelDeprecation.setCustomAliases({ "old-model": "new-model" });
|
||||
|
||||
// A plain module-level `let` would never populate globalThis; the globalThis-backed
|
||||
// store does. This is what lets a second webpack module instance see the write.
|
||||
assert.deepEqual(
|
||||
g[GLOBAL_KEY],
|
||||
{ "old-model": "new-model" },
|
||||
"custom aliases must live on globalThis so both webpack module graphs share them"
|
||||
);
|
||||
});
|
||||
|
||||
test("#5777: reads reflect a value written by another module instance via globalThis", () => {
|
||||
// Simulate the OTHER webpack module instance (the startup/instrumentation graph)
|
||||
// hydrating the shared store — this module instance did NOT call setCustomAliases.
|
||||
g[GLOBAL_KEY] = { "claude-opus-4-8": "mimo/mimo-v2.5-pro" };
|
||||
|
||||
// With the plain-`let` store this module's own copy would still be empty (the #5777
|
||||
// bug). With the globalThis backing the read reflects the other instance's write.
|
||||
assert.deepEqual(modelDeprecation.getCustomAliases(), {
|
||||
"claude-opus-4-8": "mimo/mimo-v2.5-pro",
|
||||
});
|
||||
assert.equal(modelDeprecation.resolveModelAlias("claude-opus-4-8"), "mimo/mimo-v2.5-pro");
|
||||
assert.equal(
|
||||
modelDeprecation.getAllAliases()["claude-opus-4-8"],
|
||||
"mimo/mimo-v2.5-pro",
|
||||
"getAllAliases must merge the globalThis-backed custom aliases over built-ins"
|
||||
);
|
||||
});
|
||||
|
||||
test("#5777: addCustomAlias / removeCustomAlias mutate the shared globalThis store", () => {
|
||||
modelDeprecation.setCustomAliases({});
|
||||
modelDeprecation.addCustomAlias("foo", "bar");
|
||||
assert.equal(g[GLOBAL_KEY]?.foo, "bar", "addCustomAlias must mutate the globalThis store");
|
||||
assert.equal(modelDeprecation.resolveModelAlias("foo"), "bar");
|
||||
|
||||
assert.equal(modelDeprecation.removeCustomAlias("foo"), true);
|
||||
assert.equal(g[GLOBAL_KEY]?.foo, undefined, "removeCustomAlias must clear it from globalThis");
|
||||
assert.equal(modelDeprecation.removeCustomAlias("missing"), false);
|
||||
});
|
||||
50
tests/unit/oauth-error-linkify-5486.test.ts
Normal file
50
tests/unit/oauth-error-linkify-5486.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #5486 — GitLab Duo's OAuth setup error embeds a registration URL
|
||||
// (https://gitlab.com/-/profile/applications) but the OAuth error step rendered
|
||||
// it as dead red text. linkifyText splits the message so the URL becomes a
|
||||
// clickable <a> while the surrounding instructions stay plain text.
|
||||
const { linkifyText } = await import("../../src/shared/utils/linkify.ts");
|
||||
|
||||
test("#5486 linkifies the GitLab Duo applications URL in the setup error", () => {
|
||||
const msg =
|
||||
"GitLab Duo OAuth is not configured. Register an OAuth application at " +
|
||||
"https://gitlab.com/-/profile/applications with redirect URI " +
|
||||
"http://localhost:20128/callback and scopes then restart.";
|
||||
const segs = linkifyText(msg);
|
||||
const links = segs.filter((s) => s.href);
|
||||
assert.equal(links.length, 2, "both URLs must become links");
|
||||
assert.equal(links[0].href, "https://gitlab.com/-/profile/applications");
|
||||
assert.equal(links[1].href, "http://localhost:20128/callback");
|
||||
// Reassembling the segment texts must reproduce the original message verbatim.
|
||||
assert.equal(segs.map((s) => s.text).join(""), msg);
|
||||
});
|
||||
|
||||
test("#5486 trailing sentence punctuation is peeled out of the URL", () => {
|
||||
const segs = linkifyText("See https://gitlab.com/-/profile/applications.");
|
||||
const link = segs.find((s) => s.href);
|
||||
assert.equal(link?.href, "https://gitlab.com/-/profile/applications", "period not part of href");
|
||||
assert.equal(segs[segs.length - 1].text, ".", "period kept as trailing text");
|
||||
});
|
||||
|
||||
test("#5486 plain text with no URL is a single text segment", () => {
|
||||
assert.deepEqual(linkifyText("no links here"), [{ text: "no links here" }]);
|
||||
assert.deepEqual(linkifyText(""), []);
|
||||
assert.deepEqual(linkifyText(null as unknown as string), []);
|
||||
});
|
||||
|
||||
test("#5486 (hardening) only ever exposes http(s) hrefs — no javascript:/data: scheme", () => {
|
||||
// The matcher requires an http(s):// prefix AND safeHttpHref validates the scheme, so a
|
||||
// javascript:/data: "URL" embedded in text never becomes a clickable href — it stays
|
||||
// plain text. Regression guard for the CodeQL js/xss + url-redirection sink on the <a href>.
|
||||
const segs = linkifyText(
|
||||
"run javascript:alert(document.cookie) or data:text/html,alert(1) " +
|
||||
"but https://safe.example.com/ok is fine"
|
||||
);
|
||||
const hrefs = segs.filter((s) => s.href).map((s) => s.href as string);
|
||||
assert.deepEqual(hrefs, ["https://safe.example.com/ok"], "only the http(s) URL is a link");
|
||||
for (const h of hrefs) {
|
||||
assert.ok(/^https?:\/\//.test(h), `href must be http(s): ${h}`);
|
||||
}
|
||||
});
|
||||
88
tests/unit/openai-to-gemini-helpers-split.test.ts
Normal file
88
tests/unit/openai-to-gemini-helpers-split.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Split-guard for the openai-to-gemini helpers extraction (god-file decomposition):
|
||||
// the pure historical-tool-context builders, undefined-pruning, thought-signature
|
||||
// extraction, tool-name remapping, the Vertex provider check, and the Antigravity
|
||||
// generation-config defaults moved verbatim from openai-to-gemini.ts into
|
||||
// openai-to-gemini/helpers.ts. These were module-private, so the translator's public
|
||||
// API is unchanged; the host imports them back internally. The locks pin the leaf's
|
||||
// pure behaviour and that the host now imports the leaf.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import * as h from "../../open-sse/translator/request/openai-to-gemini/helpers.ts";
|
||||
|
||||
test("isVertexGeminiProvider matches only the vertex provider ids", () => {
|
||||
assert.equal(h.isVertexGeminiProvider("vertex"), true);
|
||||
assert.equal(h.isVertexGeminiProvider("vertex-partner"), true);
|
||||
assert.equal(h.isVertexGeminiProvider("openai"), false);
|
||||
assert.equal(h.isVertexGeminiProvider(undefined), false);
|
||||
});
|
||||
|
||||
test("buildChangedToolNameMap keeps only renamed entries, else null", () => {
|
||||
const changed = h.buildChangedToolNameMap(
|
||||
new Map([
|
||||
["a", "a"],
|
||||
["b_sanitized", "b"],
|
||||
])
|
||||
);
|
||||
assert.deepEqual([...(changed ?? new Map()).entries()], [["b_sanitized", "b"]]);
|
||||
assert.equal(h.buildChangedToolNameMap(new Map([["a", "a"]])), null);
|
||||
});
|
||||
|
||||
test("extractClientThoughtSignature reads the first non-empty signature field", () => {
|
||||
assert.equal(h.extractClientThoughtSignature({ thoughtSignature: "sig" }), "sig");
|
||||
assert.equal(h.extractClientThoughtSignature({ function: { thought_signature: "s2" } }), "s2");
|
||||
assert.equal(h.extractClientThoughtSignature({ thoughtSignature: "" }), null);
|
||||
assert.equal(h.extractClientThoughtSignature({}), null);
|
||||
assert.equal(h.extractClientThoughtSignature(null), null);
|
||||
});
|
||||
|
||||
test("deepCleanUndefined deletes '[undefined]' string values in place, recursively", () => {
|
||||
const obj = { a: 1, b: "[undefined]", c: { d: "[undefined]", e: 2 }, f: ["[undefined]"] };
|
||||
h.deepCleanUndefined(obj);
|
||||
assert.deepEqual(obj, { a: 1, c: { e: 2 }, f: ["[undefined]"] });
|
||||
});
|
||||
|
||||
test("applyAntigravityGenerationDefaults fills topK/topP and bumps maxOutputTokens past the budget", () => {
|
||||
assert.deepEqual(h.applyAntigravityGenerationDefaults({}), { topK: 40, topP: 1 });
|
||||
assert.deepEqual(h.applyAntigravityGenerationDefaults({ topK: 5 }), { topK: 5, topP: 1 });
|
||||
const withBudget = h.applyAntigravityGenerationDefaults({
|
||||
thinkingConfig: { thinkingBudget: 100, includeThoughts: true },
|
||||
});
|
||||
assert.equal(withBudget.maxOutputTokens, 101);
|
||||
});
|
||||
|
||||
test("historical-tool-context builders stringify and escape as expected", () => {
|
||||
assert.equal(h.stringifyHistoricalToolArguments("raw"), "raw");
|
||||
assert.equal(h.stringifyHistoricalToolArguments({ a: 1 }), '{"a":1}');
|
||||
assert.equal(h.stringifyHistoricalToolArguments(undefined), "{}");
|
||||
assert.equal(
|
||||
h.buildInertHistoricalToolCallText("foo", { a: 1 }),
|
||||
'[tool_history_call: foo] {"a":1}'
|
||||
);
|
||||
assert.equal(
|
||||
h.buildInertHistoricalToolResponseText("bar", "ok"),
|
||||
"[tool_history_result: bar] ok"
|
||||
);
|
||||
// Attribute escaping includes quotes; content escaping does not.
|
||||
assert.equal(h.escapeHistoricalContextAttribute('<t>"&'), "<t>"&");
|
||||
assert.equal(h.escapeHistoricalContextContent('<t>"&'), '<t>"&');
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultContext wraps escaped source + result in the context tag", () => {
|
||||
assert.equal(
|
||||
h.buildHistoricalToolResultContext("myTool", { r: 1 }),
|
||||
'<previous_tool_result_context source="myTool">\n{"r":1}\n</previous_tool_result_context>'
|
||||
);
|
||||
});
|
||||
|
||||
test("host imports the helpers leaf and no longer defines them inline", () => {
|
||||
const host = fs.readFileSync(
|
||||
path.join("open-sse", "translator", "request", "openai-to-gemini.ts"),
|
||||
"utf-8"
|
||||
);
|
||||
assert.match(host, /from "\.\/openai-to-gemini\/helpers\.ts"/);
|
||||
assert.doesNotMatch(host, /^function deepCleanUndefined\(/m);
|
||||
assert.doesNotMatch(host, /^type GeminiGenerationConfig =/m);
|
||||
});
|
||||
80
tests/unit/tsx-runtime-transform-5757.test.ts
Normal file
80
tests/unit/tsx-runtime-transform-5757.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* #5757 — regression guard for the runtime `tsx/esm` → esbuild transform path.
|
||||
*
|
||||
* Background: `tsx` is a runtime `dependency` (not dev), and the published CLI
|
||||
* registers it at boot (`bin/omniroute.mjs` → `await import("tsx/esm")`) to load
|
||||
* OmniRoute's own `.ts` sources. A fresh `npm install omniroute` therefore pulls
|
||||
* `esbuild` transitively via `tsx`. #5757 worried a broken esbuild could make a
|
||||
* fresh install "build-fragile", and proposed forcing `esbuild@0.27.4`.
|
||||
*
|
||||
* That override is unsafe: `tsx` declares `esbuild@~0.28.0` and `fumadocs-mdx`
|
||||
* (also a runtime dep) declares `esbuild@^0.28.0`; forcing 0.27.x pushes esbuild
|
||||
* below both. So instead of pinning a version, these two tests guard the actual
|
||||
* invariants:
|
||||
* 1. the runtime tsx/esm loader still transforms modern syntax correctly, and
|
||||
* 2. the resolved esbuild stays inside tsx's declared range (so nobody
|
||||
* reintroduces the out-of-range override this issue proposed).
|
||||
*
|
||||
* Run: node --import tsx/esm --test tests/unit/tsx-runtime-transform-5757.test.ts
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, "..", "..");
|
||||
const FIXTURE = join(__dirname, "_fixtures", "tsx-runtime-modern-syntax.ts");
|
||||
|
||||
test("#5757: runtime tsx/esm loader transforms modern syntax (esbuild functional guard)", () => {
|
||||
// Exactly the mechanism bin/omniroute.mjs uses. cwd = package root so `tsx`
|
||||
// resolves regardless of where the process is launched from (see #4055).
|
||||
const res = spawnSync(process.execPath, ["--import", "tsx/esm", FIXTURE], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 0, `tsx/esm failed to run the fixture:\n${res.stderr}`);
|
||||
const line = (res.stdout || "").split("\n").find((l) => l.startsWith("TSX_TRANSFORM_OK"));
|
||||
assert.ok(
|
||||
line,
|
||||
`missing TSX_TRANSFORM_OK marker.\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`
|
||||
);
|
||||
|
||||
const payload = JSON.parse(line!.slice("TSX_TRANSFORM_OK ".length)) as {
|
||||
rest: Record<string, number>;
|
||||
arr: number[];
|
||||
bumped: number;
|
||||
opt: number;
|
||||
total: number;
|
||||
};
|
||||
assert.equal(payload.bumped, 42); // class field + private field lowering
|
||||
assert.equal(payload.opt, 99); // optional chaining + nullish coalescing
|
||||
assert.equal(payload.total, 154); // destructuring/spread/async/logical-assign
|
||||
assert.deepEqual(payload.rest, { c: 3, d: 4 });
|
||||
assert.deepEqual(payload.arr, [20, 30, 10]);
|
||||
});
|
||||
|
||||
test("#5757: resolved esbuild stays within tsx's declared range (blocks the out-of-range override)", () => {
|
||||
const tsxPkg = JSON.parse(
|
||||
readFileSync(join(REPO_ROOT, "node_modules", "tsx", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const esbuildPkg = JSON.parse(
|
||||
readFileSync(join(REPO_ROOT, "node_modules", "esbuild", "package.json"), "utf8")
|
||||
) as { version: string };
|
||||
|
||||
const declared = tsxPkg.dependencies?.esbuild ?? "";
|
||||
// e.g. "~0.28.0" → base minor "0.28". Self-maintaining: when tsx bumps its
|
||||
// esbuild range, this guard follows it automatically.
|
||||
const baseMinor = declared.replace(/^[~^]/, "").split(".").slice(0, 2).join(".");
|
||||
assert.ok(baseMinor, `could not parse tsx's declared esbuild range: "${declared}"`);
|
||||
assert.ok(
|
||||
esbuildPkg.version.startsWith(baseMinor + "."),
|
||||
`esbuild ${esbuildPkg.version} is outside tsx's declared range "${declared}". ` +
|
||||
`A global esbuild override (such as the esbuild@0.27.4 workaround proposed in #5757) ` +
|
||||
`breaks tsx and fumadocs-mdx (both require esbuild@^0.28) — do not add one.`
|
||||
);
|
||||
});
|
||||
25
tests/unit/validation-badge-unsupported-5442.test.ts
Normal file
25
tests/unit/validation-badge-unsupported-5442.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #5442 — LMArena (and any provider with no live validator) returns
|
||||
// `{ unsupported: true }` from /api/providers/validate; Save still succeeds.
|
||||
// The Add-API-Key modal only had success/failed states, so it rendered a red
|
||||
// "Invalid" badge for those providers even though the key was saved fine. The
|
||||
// "unsupported" result now maps to a neutral info "N/A" badge, not "Invalid".
|
||||
const { validationBadgeProps } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"
|
||||
);
|
||||
|
||||
test("#5442 unsupported validation → neutral N/A badge, not red Invalid", () => {
|
||||
assert.deepEqual(validationBadgeProps("unsupported"), {
|
||||
variant: "info",
|
||||
labelKey: "notApplicable",
|
||||
});
|
||||
});
|
||||
|
||||
test("#5442 success and failed badges are unchanged", () => {
|
||||
assert.deepEqual(validationBadgeProps("success"), { variant: "success", labelKey: "valid" });
|
||||
assert.deepEqual(validationBadgeProps("failed"), { variant: "error", labelKey: "invalid" });
|
||||
// Any other/unknown result defaults to the error badge (fail-safe).
|
||||
assert.deepEqual(validationBadgeProps("whatever"), { variant: "error", labelKey: "invalid" });
|
||||
});
|
||||
@@ -69,6 +69,8 @@ test("web session credential metadata identifies cookie, token, and no-auth prov
|
||||
placeholder: "convex-session-id=abc123...; Cookie: ...",
|
||||
acceptsFullCookieHeader: true,
|
||||
storageKeys: ["cookie", "convex-session-id", "convexSessionId"],
|
||||
// #5465 — t3.chat ships a step-by-step DevTools copy hint (localStorage + Cookie header).
|
||||
hintKey: "t3ChatWebCookieHint",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user