mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-30 20:05:40 +03:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd593c6e96 | ||
|
|
dc40911583 | ||
|
|
7b139fdb5e | ||
|
|
b3207ab010 | ||
|
|
555b21d296 | ||
|
|
b5c789a3b4 | ||
|
|
cba636b9f0 | ||
|
|
c863966966 | ||
|
|
a7ae9550bd | ||
|
|
cadc3f10b7 | ||
|
|
19d91d82e2 | ||
|
|
ee24eb52d4 | ||
|
|
bfaf459f3c | ||
|
|
d0396c200d | ||
|
|
3b2a2f02a9 | ||
|
|
db362b0126 | ||
|
|
ab8096071c | ||
|
|
3c9883bb73 | ||
|
|
dd5a3db55e | ||
|
|
f165efcd0b | ||
|
|
8842414d8a | ||
|
|
fa367dd99e | ||
|
|
c9b5b1a892 | ||
|
|
7509a32e9d | ||
|
|
ca1e17f740 | ||
|
|
d59cd14391 | ||
|
|
4d21044ba5 | ||
|
|
81a37b67ed | ||
|
|
1f87a9589c | ||
|
|
f5706a6528 | ||
|
|
4066a2ca31 | ||
|
|
35dbf0eea1 | ||
|
|
b4180145e6 | ||
|
|
36baf77ad5 | ||
|
|
f42e8fa751 | ||
|
|
337cd18932 | ||
|
|
e068a63530 | ||
|
|
9847684f0d | ||
|
|
78a1fb40a0 | ||
|
|
cbb332d355 | ||
|
|
c4f2af70f0 | ||
|
|
931afe3482 | ||
|
|
cf5898205a | ||
|
|
aa8fc4157d | ||
|
|
d728bfbb1e | ||
|
|
d3146a1751 | ||
|
|
4ffc55cfe4 | ||
|
|
c8b9544d54 | ||
|
|
7c080941d1 | ||
|
|
2670a0a819 | ||
|
|
948cf1f92c | ||
|
|
ed0638c0f1 | ||
|
|
2a26fea530 | ||
|
|
058946bd04 | ||
|
|
bcb7ed00c7 | ||
|
|
315ac98b49 | ||
|
|
f2f909bd7f | ||
|
|
ef07a19de6 | ||
|
|
5ace548bc5 | ||
|
|
772e6ba493 | ||
|
|
70319eb831 | ||
|
|
31e4e46ef9 | ||
|
|
ebf06b5e6c | ||
|
|
9f2d062083 | ||
|
|
e2d171c63e | ||
|
|
5875c7993f | ||
|
|
c9e24ae48c | ||
|
|
3e79d92744 | ||
|
|
01c8f2d3dd | ||
|
|
c29e83a0ba | ||
|
|
01e6cabeff | ||
|
|
5b71b05a5e | ||
|
|
826c533a59 | ||
|
|
ef324cd00e | ||
|
|
e952ae1406 | ||
|
|
6781a843f2 | ||
|
|
cccb087011 | ||
|
|
e38d225124 | ||
|
|
1a1f236b7d | ||
|
|
b7ac403c81 | ||
|
|
54b89e5c5b | ||
|
|
c5e1102989 | ||
|
|
24c785a3ba | ||
|
|
c8dee8c5f7 | ||
|
|
76a07cf7a5 | ||
|
|
420d62b420 | ||
|
|
ec78fe3d2c |
@@ -77,6 +77,8 @@ bun.lock
|
||||
# Agent config
|
||||
.agents
|
||||
.gemini
|
||||
.claude
|
||||
.source
|
||||
|
||||
# Misc
|
||||
llm.txt
|
||||
|
||||
303
.env.example
303
.env.example
@@ -56,10 +56,12 @@ STORAGE_ENCRYPTION_KEY_VERSION=v1
|
||||
DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
|
||||
# ── Redis (Rate Limiting) ──
|
||||
# Redis connection URL for the rate limiter backend.
|
||||
# Redis connection URL for the rate limiter backend. OPT-IN: leave this
|
||||
# commented out to use the built-in in-memory rate limiter. Setting it to a
|
||||
# non-running localhost (#4878) makes ioredis flood "[REDIS] Error:" logs.
|
||||
# Used by: src/shared/utils/rateLimiter.ts
|
||||
# Default: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 3. NETWORK & PORTS
|
||||
@@ -109,8 +111,18 @@ PORT=20128
|
||||
# Used by: src/app/api/v1/relay/chat/completions/route.ts
|
||||
# RELAY_IP_PER_MINUTE=30
|
||||
|
||||
# Use Turbopack in local dev. Next 16.2.4 can fail to compile next/font/google
|
||||
# through the custom dev runner without this on Windows.
|
||||
# Bundler selection for `npm run dev`. Set to 0 to fall back to webpack.
|
||||
# Default is 1 (Turbopack). PR #4092 had forced webpack because earlier
|
||||
# Turbopack 16.2.x panicked on the OmniRoute module graph with "internal error:
|
||||
# entered unreachable code: there must be a path to a root"
|
||||
# (turbopack-core/module_graph/mod.rs:662). That panic no longer reproduces on
|
||||
# the pinned Next 16.2.9 — verified across a broad cold-compile sweep (36
|
||||
# dashboard routes + open-sse-heavy API routes incl. /api/v1/chat/completions,
|
||||
# /api/v1/models, /api/mcp) and repeated HMR rebuilds: zero panics. Turbopack
|
||||
# also keeps dev memory far lower on the edit→rebuild loop (HMR rebuild RSS stays
|
||||
# ~flat vs webpack's monotonic growth), which mitigates the dev-server OOM on
|
||||
# this 60+ route app. The production build still uses webpack (build pipeline is
|
||||
# unaffected by this dev-only flag).
|
||||
OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
# Skip the SQLite integrity health check on startup (faster boot on large DBs).
|
||||
@@ -189,7 +201,8 @@ AUTH_COOKIE_SECURE=false
|
||||
REQUIRE_API_KEY=false
|
||||
|
||||
# Allow revealing full API key values in the Dashboard UI.
|
||||
# Used by: Dashboard providers page — controls show/hide of key values.
|
||||
# Used by: src/shared/constants/featureFlagDefinitions.ts — controls show/hide of key values.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
# Default: false | Security risk if enabled on shared instances.
|
||||
ALLOW_API_KEY_REVEAL=false
|
||||
|
||||
@@ -237,6 +250,12 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# Default: false (blocked) | Set true to enable local providers.
|
||||
# OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true
|
||||
|
||||
# Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN).
|
||||
# Used by: src/shared/network/outboundUrlGuard.ts — scopes to the provider validation path and
|
||||
# still blocks cloud-metadata (169.254.169.254, metadata.google.internal). Default: true
|
||||
# (OmniRoute is local-first). Set false to enforce strict public-only blocking.
|
||||
# OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS=false
|
||||
|
||||
# Legacy alias toggling the SSRF guard. Used by: src/shared/network/outboundUrlGuard.ts
|
||||
# When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable.
|
||||
# OUTBOUND_SSRF_GUARD_ENABLED=true
|
||||
@@ -273,6 +292,15 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# PII_RESPONSE_SANITIZATION=false
|
||||
# PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response
|
||||
|
||||
# ── VS Code Tokenized-Route Context Sanitizer ──
|
||||
# Strips implicit active-editor context (editorContext/activeEditor/currentFile/
|
||||
# selection/openTabs…) from requests on the /v1/vscode/[token]/* routes before
|
||||
# forwarding upstream, and redacts the content of explicitly-attached sensitive
|
||||
# files (.env, private keys, kubeconfig, credentials/secrets). Explicit
|
||||
# attachments otherwise pass through. Secure-by-default: ON unless set to 0.
|
||||
# Used by: src/app/api/v1/vscode/contextSanitizer.ts
|
||||
# OMNIROUTE_VSCODE_SANITIZE_CONTEXT=1 # set to 0 to disable
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 6. TOOL & ROUTING POLICIES
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -362,6 +390,12 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
#OMNIROUTE_KIE_CALLBACK_URL=
|
||||
#OMNIROUTE_PUBLIC_URL=
|
||||
|
||||
# Headroom token-saver proxy URL. The dashboard lifecycle (api/headroom/*) spawns
|
||||
# a local headroom-ai CLI on loopback by default; override only to point at an
|
||||
# external Docker sidecar proxy. Defaults to http://localhost:8787 when unset.
|
||||
# Used by: src/lib/headroom/detect.ts.
|
||||
#HEADROOM_URL=http://localhost:8787
|
||||
|
||||
# Upstream quota endpoints used by the Usage page. Override only for
|
||||
# debugging or when routing through a corporate mirror. Used by:
|
||||
# open-sse/services/usage.ts.
|
||||
@@ -370,6 +404,22 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
|
||||
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
|
||||
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
|
||||
#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace
|
||||
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings
|
||||
|
||||
# OpenCode Go dashboard quota scraping. Prefer configuring these per connection
|
||||
# in Dashboard → Providers → OpenCode Go. Env vars are useful for headless
|
||||
# deployments or shared server defaults. The cookie is sensitive.
|
||||
#OPENCODE_GO_WORKSPACE_ID=wrk_...
|
||||
#OMNIROUTE_OPENCODE_GO_WORKSPACE_ID=wrk_...
|
||||
#OPENCODE_GO_AUTH_COOKIE=auth=...
|
||||
#OMNIROUTE_OPENCODE_GO_AUTH_COOKIE=auth=...
|
||||
|
||||
# Ollama Cloud quota scraping. Prefer configuring this per connection in
|
||||
# Dashboard → Providers → Ollama Cloud. The cookie is sensitive.
|
||||
#OLLAMA_USAGE_COOKIE=__Secure-session=...
|
||||
#OLLAMA_CLOUD_USAGE_COOKIE=__Secure-session=...
|
||||
#OMNIROUTE_OLLAMA_USAGE_COOKIE=__Secure-session=...
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 8. OUTBOUND PROXY (Upstream Provider Calls)
|
||||
@@ -388,6 +438,26 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# ALL_PROXY=socks5://127.0.0.1:7890
|
||||
# NO_PROXY=localhost,127.0.0.1
|
||||
|
||||
# Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher.
|
||||
# Long-lived SSE streams such as Codex /v1/responses need more than one
|
||||
# connection when multiple requests share the same account-level proxy.
|
||||
# Set to 1 only for legacy diagnostics. Values above 256 are capped.
|
||||
# OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS=32
|
||||
|
||||
# SOCKS5 handshake (connect) timeout in ms (default 10000, capped at 120000).
|
||||
# Raise it when a single residential gateway host is hit by high concurrency
|
||||
# (e.g. 100 simultaneous requests): the real SOCKS5 handshake can exceed 10s
|
||||
# under a saturated pool even though the proxy is reachable, which otherwise
|
||||
# surfaces as a false "[Proxy Fast-Fail] Proxy unreachable".
|
||||
# SOCKS_HANDSHAKE_TIMEOUT_MS=10000
|
||||
|
||||
# Proxy fail-open mode (default: false = fail-closed).
|
||||
# When false, a request whose assigned proxy fails to resolve is REFUSED rather than
|
||||
# falling back to a direct connection — prevents real-IP leaks in egress-controlled
|
||||
# deployments. Set true to restore the legacy DIRECT fallback (legacy behaviour).
|
||||
# Used by: src/sse/handlers/chatHelpers.ts
|
||||
# PROXY_FAIL_OPEN=false
|
||||
|
||||
# TLS fingerprint spoofing (opt-in) — mimics Chrome 124 TLS handshake via wreq-js.
|
||||
# Reduces risk of JA3/JA4 fingerprint-based blocking by providers (e.g., Google).
|
||||
# Used by: open-sse/executors — replaces Node.js default TLS fingerprint.
|
||||
@@ -453,6 +523,11 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Legacy alias for OMNIROUTE_API_KEY.
|
||||
# ROUTER_API_KEY=
|
||||
|
||||
# CLI remote-mode context/profile for `omniroute` commands (overrides the active
|
||||
# context in the local contexts store). Equivalent to the `--context <name>` flag.
|
||||
# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode).
|
||||
# OMNIROUTE_CONTEXT=
|
||||
|
||||
# Enforce scope-based access control on MCP tool calls.
|
||||
# Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes.
|
||||
# OMNIROUTE_MCP_ENFORCE_SCOPES=false
|
||||
@@ -503,6 +578,16 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# heuristic in instrumentation-node.ts. Default: unset (tests skip background).
|
||||
#OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS=1
|
||||
|
||||
# Proactive connection-cooldown recovery (#8): re-validates connections whose
|
||||
# transient `rate_limited_until` window has elapsed OUTSIDE the request hot path,
|
||||
# so the first request after a cooldown does not pay the probe latency. Lazy
|
||||
# recovery in getProviderCredentials still applies regardless. Used by:
|
||||
# src/lib/quota/connectionRecovery.ts.
|
||||
# Tick cadence (ms). Default 60000, floor 5000.
|
||||
# OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS=60000
|
||||
# Disable the proactive recovery scheduler entirely (default: false).
|
||||
# OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false
|
||||
|
||||
# Background job interval for budget reset checks (ms). Default: 600000 (10m).
|
||||
# Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000.
|
||||
#OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000
|
||||
@@ -536,6 +621,12 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Default: <repo>/src/lib/db/migrations.
|
||||
#OMNIROUTE_MIGRATIONS_DIR=
|
||||
|
||||
# Mass-pending-migrations safety threshold (#3416). If more than this many
|
||||
# migrations are pending on an existing DB, startup aborts (a wiped tracking
|
||||
# table could cause data loss). Raise it to restore an older backup; set to 0
|
||||
# to disable the check. Used by: src/lib/db/migrationRunner.ts. Default: 50.
|
||||
#OMNIROUTE_MAX_PENDING_MIGRATIONS=50
|
||||
|
||||
# Trust user-managed RTK project filter rules without strict signature checks.
|
||||
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
|
||||
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
|
||||
@@ -544,6 +635,11 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: scripts/postinstall.mjs.
|
||||
#OMNIROUTE_SKIP_POSTINSTALL=0
|
||||
|
||||
# Operator-supplied JSON credentials for the offline compression-eval CLI
|
||||
# (parsed with JSON.parse; leave unset for a dry run). Developer tooling only.
|
||||
# Used by: scripts/compression-eval/index.ts. Default: {} (empty).
|
||||
#OMNIROUTE_EVAL_CREDENTIALS={}
|
||||
|
||||
# Skip the DB healthcheck entirely on startup (useful for short-lived tasks / tests).
|
||||
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to disable. Default: 0.
|
||||
#OMNIROUTE_SKIP_DB_HEALTHCHECK=0
|
||||
@@ -738,7 +834,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
|
||||
# Update these when providers release new CLI versions to avoid blocks.
|
||||
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)"
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.187 (external, cli)"
|
||||
|
||||
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
|
||||
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
|
||||
@@ -747,15 +843,21 @@ CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)"
|
||||
# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to
|
||||
# forward the original names verbatim (debugging only).
|
||||
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
|
||||
CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)"
|
||||
CODEX_USER_AGENT="codex-cli/0.142.0 (Windows 10.0.26200; x64)"
|
||||
GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1"
|
||||
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
|
||||
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
|
||||
# KIRO_VERIFY_FULL_CRC=false # opt-in: full per-frame message CRC validation on the Kiro event stream (debug corrupted streams; prelude CRC + TLS already protect framing)
|
||||
# Optional override for the Kiro social device-code OAuth clientId. Kiro's
|
||||
# device endpoint accepts any non-empty string and behaves like a User-Agent
|
||||
# rather than a secret. Only override if AWS ever starts enforcing this field.
|
||||
# Used by: src/lib/oauth/constants/oauth.ts (KIRO_CONFIG.socialClientId).
|
||||
# KIRO_OAUTH_CLIENT_ID=kiro-cli
|
||||
# Enable full per-frame message CRC validation for Kiro streams. Off by default
|
||||
# because it is O(frame bytes) on the main thread; use only for debugging
|
||||
# suspected corrupted-stream issues.
|
||||
# Used by: open-sse/executors/kiro.ts
|
||||
# KIRO_VERIFY_FULL_CRC=false
|
||||
QODER_USER_AGENT="Qoder-Cli"
|
||||
QWEN_USER_AGENT="QwenCode/0.15.11 (linux; x64)"
|
||||
CURSOR_USER_AGENT="Cursor/3.4"
|
||||
@@ -763,7 +865,13 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
|
||||
# Override Codex client version sent in headers independently of the
|
||||
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
|
||||
# CODEX_CLIENT_VERSION=0.132.0
|
||||
# CODEX_CLIENT_VERSION=0.142.0
|
||||
|
||||
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
|
||||
# from the Codex Responses stream. These frames break the OpenAI SDK's
|
||||
# responses.stream() with a 502 "Controller is already closed". Off by default;
|
||||
# set to true/1/yes to enable. Used by: open-sse/executors/codex.ts.
|
||||
# OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS=true
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection)
|
||||
@@ -822,7 +930,8 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
# Used by: src/shared/utils/runtimeTimeouts.ts — centralized timeout resolution.
|
||||
#
|
||||
# Hierarchy: REQUEST_TIMEOUT_MS acts as a global override.
|
||||
# If set, it becomes the default for FETCH_TIMEOUT_MS and STREAM_IDLE_TIMEOUT_MS.
|
||||
# If set, it becomes the default for FETCH_TIMEOUT_MS, STREAM_IDLE_TIMEOUT_MS,
|
||||
# and STREAM_READINESS_TIMEOUT_MS.
|
||||
# The fine-grained variables below override their respective defaults only when set.
|
||||
|
||||
# ── Global shortcut ──
|
||||
@@ -889,9 +998,19 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD=2
|
||||
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS=15000
|
||||
|
||||
# ── Context-cache pin health gate ──
|
||||
# Used by: open-sse/services/combo.ts. When a context-cache pin points at a
|
||||
# provider that is durably unhealthy, the pin is dropped to allow failover.
|
||||
# PIN_DROP_BACKOFF_LEVEL gates how deep a connection's backoff must be before the
|
||||
# pin is considered durably unhealthy; PIN_DROP_GRACE_MS is the anti-flap window
|
||||
# that tolerates brief transient cooldowns before dropping the pin.
|
||||
# PIN_DROP_BACKOFF_LEVEL=2
|
||||
# PIN_DROP_GRACE_MS=20000
|
||||
|
||||
# ── Stream idle detection ──
|
||||
# STREAM_IDLE_TIMEOUT_MS=600000 # Max silence between SSE chunks (default: 600000)
|
||||
# # Extended-thinking models rarely pause >90s.
|
||||
# STREAM_READINESS_TIMEOUT_MS=80000 # Time to receive the first non-ping SSE event
|
||||
|
||||
# ── TLS client (wreq-js fingerprint proxy) ──
|
||||
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
|
||||
@@ -960,6 +1079,11 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 100000
|
||||
# CALL_LOGS_TABLE_MAX_ROWS=100000
|
||||
|
||||
# Maximum age for orphaned active request log entries before the in-memory
|
||||
# pending-request reaper removes them. Accepts milliseconds.
|
||||
# Default: 3600000 (1 hour)
|
||||
# MAX_PENDING_REQUEST_AGE_MS=3600000
|
||||
|
||||
# Whether call log pipeline capture stores stream chunks when enabled in settings.
|
||||
# Only applies when call_log_pipeline_enabled=true.
|
||||
# Default: true
|
||||
@@ -1079,9 +1203,12 @@ APP_LOG_TO_FILE=true
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 18b. ARENA ELO SYNC
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Enable auto-updating model intelligence from Arena AI leaderboard ELO scores.
|
||||
# Used by: src/lib/arenaEloSync.ts
|
||||
# ARENA_ELO_SYNC_ENABLED=false
|
||||
# Auto-update model intelligence from Arena AI leaderboard ELO scores (powers the
|
||||
# Free Provider Rankings page). ON by default — fetches from api.wulong.dev on startup
|
||||
# (non-blocking, never fatal). Set to false to opt out of the outbound sync.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
# Used by: src/shared/constants/featureFlagDefinitions.ts, src/lib/arenaEloSync.ts
|
||||
# ARENA_ELO_SYNC_ENABLED=true
|
||||
|
||||
# Sync interval in seconds. Default: 86400 (24 hours).
|
||||
# ARENA_ELO_SYNC_INTERVAL=86400
|
||||
@@ -1130,6 +1257,34 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/executors/cloudflare-ai.ts
|
||||
# CLOUDFLARE_ACCOUNT_ID=
|
||||
|
||||
# ── Deno Deploy proxy relay (#4643 / 9router#1437) ──
|
||||
# Override the Deno Deploy REST API base used by the proxy-pool relay deployer.
|
||||
# Default: https://api.deno.com/v2 (omit unless mocking).
|
||||
# Used by: src/app/api/settings/proxy/deno-deploy/route.ts
|
||||
# DENO_DEPLOY_API_BASE=https://api.deno.com/v2
|
||||
|
||||
# Default Deno Deploy app name suggested in the "Deploy Relay" modal.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx
|
||||
# NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT=omniroute-deno-relay
|
||||
|
||||
# Set to "false" to hide the Deno Deploy relay option from the Proxy Pool tab.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx
|
||||
# NEXT_PUBLIC_DENO_RELAY_ENABLED=true
|
||||
|
||||
# ── Cloudflare Workers proxy relay (#4640 / 9router#1360) ──
|
||||
# Override the Cloudflare REST API base used by the proxy-pool relay deployer.
|
||||
# Default: https://api.cloudflare.com/client/v4 (omit unless mocking).
|
||||
# Used by: src/app/api/settings/proxy/cloudflare-deploy/route.ts
|
||||
# CLOUDFLARE_API_BASE=https://api.cloudflare.com/client/v4
|
||||
|
||||
# Default worker project name suggested in the "Deploy Relay" modal.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx
|
||||
# NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT=omniroute-relay
|
||||
|
||||
# Set to "false" to hide the Cloudflare Workers relay option from the Proxy Pool tab.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx
|
||||
# NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED=true
|
||||
|
||||
# ── Cloudflare Tunnel (cloudflared) ──
|
||||
# Custom path to cloudflared binary for tunnel management.
|
||||
# Used by: src/lib/cloudflaredTunnel.ts
|
||||
@@ -1194,6 +1349,11 @@ APP_LOG_TO_FILE=true
|
||||
# Health check result cache TTL (ms). Default: 30000 (30s)
|
||||
# PROXY_HEALTH_CACHE_TTL_MS=30000
|
||||
|
||||
# Allow OAuth and provider validation flows to bypass a pinned proxy and connect
|
||||
# directly when proxy reachability pre-checks fail. Default: false.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
# OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK=false
|
||||
|
||||
# Rate limit maximum wait time before failing a request (ms). Default: 120000 (2 min)
|
||||
# Used by: open-sse/services/rateLimitManager.ts
|
||||
# RATE_LIMIT_MAX_WAIT_MS=120000
|
||||
@@ -1222,6 +1382,25 @@ APP_LOG_TO_FILE=true
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# PROVIDER_COOLDOWN_ENABLED=true
|
||||
|
||||
# Transparent stream recovery (free-claude-code port). When enabled, the opening SSE
|
||||
# window is briefly held (up to STREAM_RECOVERY.HOLDBACK_MS) so an upstream truncation
|
||||
# before any byte reaches the client can be retried invisibly. Opt-in: holding the
|
||||
# window adds up to that much time-to-first-token latency on every stream, so it is
|
||||
# OFF by default. Seeds ResilienceSettings.streamRecovery.enabled.
|
||||
# Used by: open-sse/services/streamRecovery.ts, open-sse/handlers/chatCore.ts
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# STREAM_RECOVERY_ENABLED=true
|
||||
|
||||
# Mid-stream continuation (Fase 4.4): when an upstream stream truncates AFTER bytes
|
||||
# already reached the client, re-request with the partial text as an assistant prefill
|
||||
# and stitch the missing suffix (plain-text OpenAI-compatible streams only; never with a
|
||||
# tool call in flight). OFF by default — the recovered tail arrives as one burst, not
|
||||
# token-by-token. Independent of STREAM_RECOVERY_ENABLED (different risk profile).
|
||||
# Seeds ResilienceSettings.streamRecovery.continueMidStream.
|
||||
# Used by: open-sse/services/streamRecovery.ts, open-sse/handlers/chatCore.ts
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# STREAM_RECOVERY_MIDSTREAM_ENABLED=true
|
||||
|
||||
# Stagger interval (ms) between provider token healthchecks at startup.
|
||||
# Used by: src/lib/tokenHealthCheck.ts. Default: 3000.
|
||||
# HEALTHCHECK_STAGGER_MS=3000
|
||||
@@ -1345,6 +1524,12 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: src/mitm/server.cjs — captures upstream traffic for inspection.
|
||||
# MITM_LOCAL_PORT=443
|
||||
# MITM_DISABLE_TLS_VERIFY=0
|
||||
# Idle socket timeout (ms) for proxied connections; sockets idle past this are torn
|
||||
# down to avoid leaking half-open tunnels (src/mitm/socketTimeouts.ts, server.cjs).
|
||||
# MITM_IDLE_TIMEOUT_MS=60000
|
||||
# Routing-decision log verbosity: 0 silences, higher values log more bypass/route
|
||||
# decisions (src/mitm/server.cjs, _internal/bypass.cjs).
|
||||
# MITM_VERBOSE=1
|
||||
|
||||
# ── 1Proxy egress pool ──
|
||||
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
|
||||
@@ -1388,6 +1573,10 @@ APP_LOG_TO_FILE=true
|
||||
# dashboard's tunnel manager. Used by: src/lib/tailscaleTunnel.ts.
|
||||
# TAILSCALE_BIN=/usr/local/bin/tailscale
|
||||
# TAILSCALED_BIN=/usr/local/bin/tailscaled
|
||||
# Pre-shared Tailscale auth key for non-interactive / headless `tailscale up`
|
||||
# (passed via --auth-key=). When unset, login falls back to the interactive
|
||||
# browser auth URL. Used by: src/lib/tailscaleTunnel.ts.
|
||||
# TAILSCALE_AUTHKEY=
|
||||
|
||||
# ── Ngrok tunnel ──
|
||||
# Used by: src/lib/ngrokTunnel.ts — authenticates outbound tunnels.
|
||||
@@ -1533,7 +1722,17 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
|
||||
# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
|
||||
# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
|
||||
# STATUS_SOFT_DEPRIORITIZE_FACTOR=0.5 # 0..1; multiplicador do score p/ provider esgotado (credits_exhausted/rate_limited) quando preflight cutoff OFF (#4540)
|
||||
# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos
|
||||
# QUOTA_PREFLIGHT_CUTOFF_ENABLED=false # opt-in (default OFF): hard quota cutoff drops low-quota candidates before auto-routing scoring
|
||||
|
||||
# ─── Auto-Combo tier filter (#4517) ───────────────────────────────────────
|
||||
# When an `auto/<category>:free` (or any `:<tier>`) request matches NO connected
|
||||
# candidates, OmniRoute returns an EMPTY pool by default — so `:free` really means
|
||||
# "free tier only" and a paid model is never picked just because no free provider is
|
||||
# connected. Set this to `true`/`1` to restore the legacy behavior of falling back to
|
||||
# the full (unfiltered) pool with a warning. Source: open-sse/services/autoCombo/virtualFactory.ts
|
||||
# OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=false
|
||||
|
||||
# ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ───
|
||||
# Base URL of the OmniRoute instance to query for /v1/models when regenerating
|
||||
@@ -1546,3 +1745,81 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by:
|
||||
# scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY.
|
||||
# OPENCODE_API_KEY=
|
||||
|
||||
# ─── Bifrost Go sidecar (PR-4 in #3932) ──────────────────────────────────────
|
||||
# Master kill switch for the bifrost sidecar proxy. When set to 0, the
|
||||
# /api/v1/relay/chat/completions/bifrost route returns 503 with the
|
||||
# X-Bifrost-Killswitch header and the operator is bounced to the TS path.
|
||||
# Use this to disable the sidecar without redeploying (e.g. during a
|
||||
# tier-1 router incident or a key rotation). Default: 1 (sidecar active).
|
||||
# BIFROST_ENABLED=1
|
||||
# When BIFROST_BASE_URL is set, /api/v1/relay/chat/completions/bifrost routes
|
||||
# traffic to the Go gateway instead of the TS relay handler, removing TS from
|
||||
# the hot path. Auth/rate-limit/injection-guard stay in the route (security not
|
||||
# duplicated). Falls back to TS path via X-Bifrost-Fallback header on
|
||||
# timeout/failure. See bin/omniroute for the local-redis companion.
|
||||
# BIFROST_BASE_URL=
|
||||
# API key for the Bifrost gateway (sent as Authorization: Bearer ...). If
|
||||
# unset, the route expects the request to carry a valid OmniRoute API key;
|
||||
# this key is for gateway-side auth only.
|
||||
# BIFROST_API_KEY=
|
||||
# When true, the Bifrost sidecar route streams responses back via SSE through
|
||||
# the gateway rather than the TS streaming executor. Default: true (when
|
||||
# BIFROST_BASE_URL is set).
|
||||
# BIFROST_STREAMING_ENABLED=
|
||||
# Per-request timeout when proxying to the Bifrost gateway. Default: 30000 (30s).
|
||||
# BIFROST_TIMEOUT_MS=
|
||||
# Alias for BIFROST_API_KEY (used by scripts that read the env via
|
||||
# OMNIROUTE_*). Falls back to BIFROST_API_KEY when unset.
|
||||
# OMNIROUTE_BIFROST_KEY=
|
||||
|
||||
# ─── 1-click local service launchers (PR-3 in #3932) ────────────────────────
|
||||
# Master switch for /api/local/* routes. When unset or "0", all /api/local/*
|
||||
# routes return 503 in production. Default: 0. Must be "1" in non-loopback
|
||||
# deploys to enable the Redis launcher and similar 1-click local service
|
||||
# starters. Belt-and-suspenders with the isLocalOnlyPath() route-guard
|
||||
# classification (LOCAL_ONLY_API_PREFIXES in src/server/authz/routeGuard.ts).
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=
|
||||
# Bearer token for /api/local/* callers that aren't on loopback (e.g. the
|
||||
# desktop app). When set, requests from non-loopback IPs must carry
|
||||
# Authorization: Bearer <token>. Required when
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1 in non-loopback deployments. Default:
|
||||
# unset (loopback-only).
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_TOKEN=
|
||||
# Container name for the 1-click Redis launcher (`omniroute redis up`).
|
||||
# Default: omniroute-redis. Used by bin/cli/commands/redis.mjs and the
|
||||
# RedisLauncherPanel.
|
||||
# OMNIROUTE_REDIS_CONTAINER_NAME=
|
||||
# Host port for the 1-click Redis launcher. Default: 6379. Bump if the host
|
||||
# already binds 6379. The container's internal port stays 6379.
|
||||
# OMNIROUTE_REDIS_HOST_PORT=
|
||||
# Redis image used by the 1-click Redis launcher. Default: redis:7-alpine.
|
||||
# Override to redis:8-alpine or a private registry mirror as needed.
|
||||
# OMNIROUTE_REDIS_IMAGE=
|
||||
|
||||
# ── Cluster Profile: Qdrant Vector Memory (opt-in via `docker compose --profile memory up`) ──
|
||||
# Qdrant is an OPTIONAL sidecar for deployments that need cosine-distance vector
|
||||
# search at >1M embeddings. The default vector store is sqlite-vec
|
||||
# (src/lib/memory/vectorStore.ts:108); flip this profile on only if you hit the
|
||||
# sqlite-vec ceiling or want persistent cross-replica vector state. See
|
||||
# docs/architecture/cluster-decisions.md § "Qdrant (memory profile)".
|
||||
# QDRANT_HOST=qdrant
|
||||
# QDRANT_PORT=6333
|
||||
# QDRANT_GRPC_PORT=6334
|
||||
# QDRANT_API_KEY=
|
||||
# QDRANT_COLLECTION=omniroute-memory
|
||||
# QDRANT_EMBEDDING_MODEL=text-embedding-3-small
|
||||
# QDRANT_VECTOR_SIZE=1536
|
||||
# QDRANT_HNSW_EF_CONSTRUCT=128
|
||||
|
||||
# ── Cluster Profile: Bifrost Tier-1 Router (opt-in via `docker compose --profile bifrost up`) ──
|
||||
# Bifrost is an OPTIONAL Go-based Tier-1 router that handles the upstream-provider
|
||||
# multiplexing layer. Default: OmniRoute's open-sse/executors/bifrost.ts in-process
|
||||
# executor handles routing directly. Flip this profile on only if you want the
|
||||
# gateway as a separate sidecar (helps in 3+ replica deployments where you want
|
||||
# provider rotation centralised). See docs/architecture/cluster-decisions.md §
|
||||
# "Bifrost (bifrost profile)".
|
||||
# BIFROST_BASE_URL=http://bifrost:8080
|
||||
# BIFROST_API_KEY=
|
||||
# BIFROST_STREAMING_ENABLED=true
|
||||
# BIFROST_TIMEOUT_MS=30000
|
||||
|
||||
29
.github/actions/npm-ci-retry/action.yml
vendored
Normal file
29
.github/actions/npm-ci-retry/action.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: npm ci with retry
|
||||
description: Run npm ci with retries for transient registry/network failures.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
max_attempts=3
|
||||
delay_seconds=20
|
||||
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "npm ci attempt $attempt/$max_attempts after transient failure"
|
||||
fi
|
||||
|
||||
if npm ci; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit_code=$?
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
exit "$exit_code"
|
||||
fi
|
||||
|
||||
sleep "$delay_seconds"
|
||||
delay_seconds=$((delay_seconds * 2))
|
||||
done
|
||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -24,6 +24,20 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "eslint-config-next"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API).
|
||||
# scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it
|
||||
# parses jscpd-report.json against a frozen baseline). A v5 major would break
|
||||
# the duplication gate — migrate the gate intentionally, not via dependabot.
|
||||
- dependency-name: "jscpd"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
|
||||
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
|
||||
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
|
||||
# and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts),
|
||||
# and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must
|
||||
# be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore
|
||||
# every version). Migrate it intentionally, not via dependabot (#4050).
|
||||
- dependency-name: "@huggingface/transformers"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
10
.github/workflows/build-fork.yml
vendored
10
.github/workflows/build-fork.yml
vendored
@@ -7,9 +7,10 @@ on:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
# Least-privilege default: read-only at the top level; the build job that pushes to
|
||||
# GHCR grants packages: write itself (Scorecard TokenPermissions).
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/kang-heewon/omniroute
|
||||
@@ -19,9 +20,14 @@ jobs:
|
||||
name: Build and Push Fork Image
|
||||
if: github.repository == 'kang-heewon/OmniRoute'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
544
.github/workflows/ci.yml
vendored
544
.github/workflows/ci.yml
vendored
@@ -21,6 +21,74 @@ env:
|
||||
CI_NODE_26_VERSION: "26"
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Change Classification
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code: ${{ steps.classify.outputs.code }}
|
||||
docs: ${{ steps.classify.outputs.docs }}
|
||||
i18n: ${{ steps.classify.outputs.i18n }}
|
||||
workflow: ${{ steps.classify.outputs.workflow }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||
{
|
||||
echo "code=true"
|
||||
echo "docs=true"
|
||||
echo "i18n=true"
|
||||
echo "workflow=true"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
code=false
|
||||
docs=false
|
||||
i18n=false
|
||||
workflow=false
|
||||
|
||||
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
|
||||
|
||||
while IFS= read -r file; do
|
||||
case "$file" in
|
||||
.github/workflows/*|.zizmor.yml)
|
||||
workflow=true
|
||||
code=true
|
||||
;;
|
||||
docs/*|*.md)
|
||||
docs=true
|
||||
;;
|
||||
src/i18n/*|src/i18n/messages/*|scripts/i18n/*|config/i18n.json)
|
||||
i18n=true
|
||||
code=true
|
||||
;;
|
||||
src/*|open-sse/*|bin/*|electron/*|tests/*|scripts/*|package.json|package-lock.json|tsconfig*.json|next.config.*|vitest*.config.*|playwright.config.*)
|
||||
code=true
|
||||
;;
|
||||
db/*|config/*)
|
||||
code=true
|
||||
;;
|
||||
*)
|
||||
code=true
|
||||
;;
|
||||
esac
|
||||
done < changed-files.txt
|
||||
|
||||
{
|
||||
echo "code=$code"
|
||||
echo "docs=$docs"
|
||||
echo "i18n=$i18n"
|
||||
echo "workflow=$workflow"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,12 +99,14 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run audit:deps
|
||||
- run: npm run lint
|
||||
@@ -54,7 +124,11 @@ jobs:
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:docs-sync
|
||||
- run: npm run check:tracked-artifacts
|
||||
- run: npm run check:lockfile
|
||||
- run: npm run check:licenses
|
||||
# check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the
|
||||
# husky pre-commit hook; the standalone copy here was redundant (ROI dedup).
|
||||
- run: npm run typecheck:core
|
||||
# typecheck:noimplicit:core is a forward-looking gate (noImplicitAny).
|
||||
# Run informationally for now — many pre-existing call sites still need
|
||||
@@ -66,16 +140,36 @@ jobs:
|
||||
name: Quality Ratchet
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-coverage
|
||||
if: ${{ always() && needs.test-coverage.result == 'success' }}
|
||||
# Run even when test-coverage was SKIPPED/FAILED (e.g. a single flaky Coverage
|
||||
# Shard breaks the shard→coverage→ratchet chain). The DETERMINISTIC ratchets
|
||||
# (eslint / complexity / cognitive-complexity / duplication / codeql) do NOT need
|
||||
# the coverage artifact and MUST still run so cycle drift is measured on the
|
||||
# release PR — otherwise a flake silently skips the whole gate (incident: v3.8.36
|
||||
# release PR #4854, where the drift cascade only surfaced post-merge in #5029).
|
||||
# The coverage.* metrics degrade gracefully: the download is continue-on-error and
|
||||
# the ratchet runs with --allow-missing, so absent coverage is skipped, not failed.
|
||||
if: ${{ !cancelled() }}
|
||||
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
|
||||
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
|
||||
# continue-on-error: o artifact pode não existir se a job test-coverage foi
|
||||
# SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem
|
||||
# erro) e o ratchet roda com --allow-missing — as métricas determinísticas
|
||||
# (eslint/complexity/cognitive) seguem BLOQUEANTES.
|
||||
- uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
@@ -83,14 +177,44 @@ jobs:
|
||||
# Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado).
|
||||
# Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do
|
||||
# coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados.
|
||||
# --allow-missing: pula métricas do baseline ausentes do collect (coverage.* quando
|
||||
# o artifact não veio) em vez de falhar — mantém os gates determinísticos ativos.
|
||||
- name: Ratchet check
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --summary .artifacts/quality-ratchet.md
|
||||
# Fase 6A.5: require-tighten — BLOQUEANTE (promovido de advisory no fim do ciclo
|
||||
# v3.8.27). Falha quando uma métrica MELHOROU sem o baseline ter sido apertado no
|
||||
# mesmo PR (força capturar ganhos permanentes). As métricas coverage.* carregam
|
||||
# tightenSlack para o gap anti-flake (CI mergeado > baseline) não disparar falso-
|
||||
# positivo. Verificado limpo (exit 0) no tip de release/v3.8.27 com a cobertura
|
||||
# mergeada == baseline (ver a nota _require_tighten_flip_blocking em
|
||||
# config/quality/quality-baseline.json).
|
||||
- name: Require-tighten (blocking)
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --require-tighten
|
||||
# Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo)
|
||||
# para não pesar no caminho crítico do lint.
|
||||
- name: Duplication ratchet
|
||||
run: npm run check:duplication
|
||||
- name: Complexity ratchet
|
||||
run: npm run check:complexity
|
||||
# Fase 7 INT: dead-code, cognitive-complexity, type-coverage promovidos de
|
||||
# advisory (quality-extended) para BLOQUEANTES aqui. Os 3 leem seus baseline
|
||||
# de quality-baseline.json e saem 1 em regressão.
|
||||
- name: Dead-code ratchet (knip)
|
||||
run: npm run check:dead-code
|
||||
- name: Cognitive complexity ratchet (sonarjs)
|
||||
run: npm run check:cognitive-complexity
|
||||
- name: Type coverage ratchet
|
||||
run: npm run check:type-coverage
|
||||
- name: Compression budget ratchet (F2.4 / N4)
|
||||
run: npm run check:compression-budget
|
||||
# CodeQL alerts ratchet — BLOQUEANTE (promovido de advisory na v3.8.26).
|
||||
# Lê metrics.codeqlAlerts.value de quality-baseline.json e sai 1 SOMENTE numa
|
||||
# regressão real (alertas abertos > baseline). Falha de medição (gh/auth/api)
|
||||
# é skip gracioso com exit 0 — security-events:read no job-level permissions.
|
||||
- name: CodeQL alerts ratchet (blocking)
|
||||
run: npm run check:codeql-ratchet
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Append summary
|
||||
if: always()
|
||||
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -102,16 +226,136 @@ jobs:
|
||||
path: .artifacts/quality-ratchet.md
|
||||
if-no-files-found: warn
|
||||
|
||||
docs-sync-strict:
|
||||
name: Docs Sync (Strict)
|
||||
# Phase 7/8 extended quality gates — MIXED (Etapa 2, v3.8.26). The job no longer
|
||||
# carries a job-level continue-on-error: the three ratchet-blocking steps below
|
||||
# (Secret scan / Workflow lint / Bundle size, all passing --ratchet) FAIL the job
|
||||
# on a measured regression vs config/quality/quality-baseline.json. The remaining
|
||||
# steps stay ADVISORY via step-level continue-on-error (scanner install,
|
||||
# vuln/osv ratchet, OpenAPI/oasdiff breaking-change, circular-deps/dpdm): they
|
||||
# depend on external binaries/state that may legitimately self-skip, so they must
|
||||
# never block. The blocking gates themselves SKIP (exit 0) when their binary/plugin
|
||||
# is absent — only a measured regression on the SAME metric the baseline froze
|
||||
# blocks. The CodeQL ratchet was PROMOTED to the quality-gate job (v3.8.26).
|
||||
# SonarQube needs SONAR_TOKEN/SONAR_HOST_URL secrets.
|
||||
quality-extended:
|
||||
name: Quality Gates (Extended)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
||||
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
|
||||
# would lack the base ref and the gate would self-skip (base-unresolved).
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# Dead-code, cognitive-complexity, type-coverage foram promovidos ao job
|
||||
# quality-gate (bloqueante) na Fase 7 INT — não rodam aqui para evitar duplo custo.
|
||||
- name: Circular deps (dpdm; advisory)
|
||||
continue-on-error: true
|
||||
run: npm run check:circular-deps
|
||||
# BLOCKING ratchet (Etapa 2): bundleSize must not regress vs the baseline
|
||||
# (gzip via @size-limit/file, installed by `npm ci`). --ratchet exits 1 on a
|
||||
# measured regression; it SKIPs (exit 0) when the size-limit plugin/build is
|
||||
# absent (a non-comparable measurement never blocks).
|
||||
- name: Bundle size (ratchet, blocking)
|
||||
run: npm run check:bundle-size -- --ratchet
|
||||
# CodeQL ratchet foi PROMOVIDO a BLOQUEANTE no job quality-gate (v3.8.26) —
|
||||
# não roda aqui para evitar duplo run/duplo report.
|
||||
# Install the advisory security scanners so the gates below actually run
|
||||
# (they self-skip when the binaries are absent). Robustness lessons baked in:
|
||||
# • `go install …/gitleaks/v8@latest` produces a binary WITHOUT the version
|
||||
# ldflags gitleaks needs (and often fails) — avoided.
|
||||
# • `curl …api.github.com/…/releases/latest` is UNAUTHENTICATED and
|
||||
# rate-limited to 60 req/hr/IP; when throttled it returns an empty body,
|
||||
# so the asset URL resolves to nothing and the install silently no-ops —
|
||||
# every gate then self-skips and the metric is never produced. We instead
|
||||
# use `gh release download`, which is preinstalled on GitHub runners and
|
||||
# authenticated via GITHUB_TOKEN (5000 req/hr) — robust under load.
|
||||
# • actionlint keeps its official download script; zizmor stays on pipx.
|
||||
# We `set +e` (no single failure aborts the step), ALWAYS export $GITHUB_PATH
|
||||
# at the end, and print diagnostics so the next CI run proves exactly what
|
||||
# installed. The job is continue-on-error too, so an install hiccup never
|
||||
# blocks the build.
|
||||
- name: Install advisory security scanners (gitleaks/osv/actionlint/zizmor)
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
# gitleaks — download latest linux x64 tarball via gh (authed), extract binary
|
||||
rm -rf /tmp/gl && mkdir -p /tmp/gl
|
||||
gh release download --repo gitleaks/gitleaks --pattern '*linux_x64.tar.gz' --dir /tmp/gl
|
||||
tar -xzf /tmp/gl/*linux_x64.tar.gz -C "$HOME/.local/bin" gitleaks
|
||||
# osv-scanner — download latest linux amd64 bare binary via gh (authed)
|
||||
rm -rf /tmp/osv && mkdir -p /tmp/osv
|
||||
gh release download --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv
|
||||
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner"
|
||||
# actionlint — official download script
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) latest "$HOME/.local/bin"
|
||||
# zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin
|
||||
pipx install zizmor || pip install --user zizmor
|
||||
# oasdiff — download latest linux amd64 tarball via gh (authed), extract binary
|
||||
rm -rf /tmp/oasd && mkdir -p /tmp/oasd
|
||||
gh release download --repo oasdiff/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
|
||||
tar -xzf /tmp/oasd/*linux_amd64.tar.gz -C "$HOME/.local/bin" oasdiff
|
||||
# ALWAYS export the bin dir (even if any step above failed)
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
# diagnostics — prove what installed on the next CI run
|
||||
ls -la "$HOME/.local/bin"
|
||||
"$HOME/.local/bin/gitleaks" version || true
|
||||
"$HOME/.local/bin/actionlint" -version || true
|
||||
"$HOME/.local/bin/osv-scanner" --version || true
|
||||
"$HOME/.local/bin/oasdiff" --version || true
|
||||
zizmor --version || true
|
||||
# BLOCKING ratchet (Etapa 2): secretFindings must not regress vs the baseline.
|
||||
# --ratchet exits 1 on a measured regression; it SKIPs (exit 0) when gitleaks
|
||||
# is absent (a missing binary never blocks).
|
||||
- name: Secret scan (gitleaks, ratchet, blocking)
|
||||
run: npm run check:secrets -- --ratchet
|
||||
# BLOCKING ratchet (v3.8.27 cycle-end): vulnCount must not regress vs the
|
||||
# baseline. --ratchet exits 1 on a measured regression (measured > baseline);
|
||||
# it SKIPs (exit 0) when osv-scanner is absent or osv.dev is unreachable (a
|
||||
# missing/failed measurement never blocks). See the CVE-variance note in
|
||||
# docs/security/SUPPLY_CHAIN.md — a newly-disclosed CVE on an unchanged dep can
|
||||
# red this gate; the fix is to bump the dep or re-baseline metrics.vulnCount.
|
||||
- name: Vulnerability ratchet (osv-scanner, ratchet, blocking)
|
||||
run: npm run check:vuln-ratchet -- --ratchet
|
||||
# BLOCKING ratchet (Etapa 2): zizmorFindings must not regress vs the baseline.
|
||||
# ONLY zizmor is ratcheted — actionlint findings are reported, not blocking.
|
||||
# --ratchet exits 1 on a measured zizmor regression; it SKIPs (exit 0) when
|
||||
# zizmor is absent (a missing binary never blocks).
|
||||
- name: Workflow lint (actionlint+zizmor, ratchet, blocking)
|
||||
run: npm run check:workflows -- --ratchet
|
||||
# OpenAPI breaking-change detection (oasdiff). Diffs the PR's public API
|
||||
# contract (docs/openapi.yaml) against the base branch's spec.
|
||||
# BLOCKING ratchet (Fase 9 Onda 0): reads metrics.openapiBreaking.value and
|
||||
# exits 1 ONLY on a measured regression (count > baseline). It SKIPs (exit 0)
|
||||
# when oasdiff is absent or the base spec can't be resolved — a missing
|
||||
# measurement never blocks. BASE_REF is read by the script from the env
|
||||
# (never interpolated into a shell body) — workflow-injection-safe.
|
||||
- name: OpenAPI breaking-change (oasdiff, ratchet, blocking)
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: npm run check:openapi-breaking -- --ratchet
|
||||
|
||||
docs-sync-strict:
|
||||
name: Docs Sync (Strict)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:docs-all
|
||||
# Previously-orphaned contract gates (existed as files, never wired anywhere).
|
||||
# All exit 0 today: cli-i18n is a hard gate, openapi-coverage is a ratchet
|
||||
@@ -129,16 +373,44 @@ jobs:
|
||||
- name: i18n translation drift (warn)
|
||||
run: node scripts/i18n/check-translation-drift.mjs --warn
|
||||
|
||||
i18n-ui-coverage:
|
||||
name: i18n UI Coverage
|
||||
docs-lint:
|
||||
name: Docs Lint (prose — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory (warning-first): prose/markdown style must not block merges while the
|
||||
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: markdownlint (docs + root, advisory)
|
||||
run: npx --yes markdownlint-cli2 "docs/**/*.md" "*.md" "!docs/i18n" "!docs/research" || true
|
||||
- name: Vale prose lint (Microsoft style, advisory)
|
||||
# Non-fatal: a Vale/reviewdog setup error must not turn this advisory job red.
|
||||
continue-on-error: true
|
||||
uses: errata-ai/vale-action@reviewdog
|
||||
with:
|
||||
files: docs
|
||||
fail_on_error: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
i18n-ui-coverage:
|
||||
name: i18n UI Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
||||
|
||||
i18n-matrix:
|
||||
@@ -147,7 +419,9 @@ jobs:
|
||||
outputs:
|
||||
langs: ${{ steps.langs.outputs.langs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: langs
|
||||
run: |
|
||||
LANG_DIR="src/i18n/messages"
|
||||
@@ -164,14 +438,22 @@ jobs:
|
||||
lang: ${{ fromJson(needs.i18n-matrix.outputs.langs) }}
|
||||
needs: i18n-matrix
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Validate ${{ matrix.lang }}
|
||||
env:
|
||||
# Pass the matrix value via env (never interpolate ${{ ... }} straight
|
||||
# into the run: script body) so the shell receives a variable, not
|
||||
# inlined text — zizmor template-injection mitigation. Named MATRIX_LANG
|
||||
# to avoid clobbering the POSIX `LANG` locale variable.
|
||||
MATRIX_LANG: ${{ matrix.lang }}
|
||||
run: |
|
||||
python3 scripts/i18n/validate_translation.py quick -l '${{ matrix.lang }}' > result.txt
|
||||
python3 scripts/i18n/validate_translation.py quick -l "$MATRIX_LANG" > result.txt
|
||||
|
||||
- name: Upload result
|
||||
if: always()
|
||||
@@ -185,8 +467,9 @@ jobs:
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -198,6 +481,11 @@ jobs:
|
||||
# Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests.
|
||||
- name: Detect test-masking (weakened assertions)
|
||||
run: npm run check:test-masking
|
||||
# Evidence-in-PR-body (Hard Rule #18 mechanized): claims of "tests pass" must carry output.
|
||||
- name: Require evidence in PR body
|
||||
run: npm run check:pr-evidence
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
- name: Publish PR test policy summary
|
||||
if: always()
|
||||
run: |
|
||||
@@ -208,28 +496,40 @@ jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Next.js build cache
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8
|
||||
with:
|
||||
path: .build/next/cache
|
||||
key: nextjs-${{ runner.os }}-node-${{ env.CI_NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*', 'open-sse/**/*', 'db/**/*', 'next.config.mjs', 'tsconfig*.json', 'postcss.config.*', 'tailwind.config.*') }}
|
||||
restore-keys: |
|
||||
nextjs-${{ runner.os }}-node-${{ env.CI_NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-
|
||||
- run: npm run build
|
||||
- name: Archive Next.js build for E2E shards
|
||||
- name: Archive Next.js build for downstream jobs
|
||||
# Use tar so the archive preserves paths relative to CWD (.build/next/...).
|
||||
# upload-artifact path-stripping is ambiguous when exclude patterns are used;
|
||||
# an explicit tar avoids the double-nesting issue (.build/next/next/...).
|
||||
# Keep standalone/node_modules intact: package/electron jobs consume the
|
||||
# Next-traced standalone tree and must not replace it with root node_modules.
|
||||
run: |
|
||||
tar -czf /tmp/e2e-build.tar.gz \
|
||||
--exclude='.build/next/standalone/node_modules' \
|
||||
--exclude='.build/next/cache' \
|
||||
.build/next
|
||||
- name: Upload Next.js build for E2E shards
|
||||
- name: Upload Next.js build for downstream jobs
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-next-build
|
||||
name: next-build
|
||||
path: /tmp/e2e-build.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
@@ -240,15 +540,25 @@ jobs:
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
# build:cli runs a clean build into .build/next and assembles dist/
|
||||
# For release builds prefer: npm run build:release (clean rebuild + HEAD sentinel)
|
||||
- name: Download Next.js build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
# build:cli consumes the downloaded .build/next standalone artifact and assembles dist/;
|
||||
# it only rebuilds if the downloaded standalone artifact is missing.
|
||||
- run: npm run build:cli
|
||||
- name: Assert dist/server.js exists
|
||||
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
|
||||
@@ -263,14 +573,23 @@ jobs:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
- name: Download Next.js build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
- name: Install Electron dependencies
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
@@ -296,14 +615,16 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --max-old-space-size=4096 --import tsx --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,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.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,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts"
|
||||
|
||||
test-vitest:
|
||||
name: Vitest (MCP / autoCombo / UI components)
|
||||
@@ -315,12 +636,14 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
|
||||
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
|
||||
- run: npm run test:vitest
|
||||
@@ -331,52 +654,82 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
node-24-compat:
|
||||
name: Node 24 Compatibility (${{ matrix.shard }}/2)
|
||||
name: Node 24 Compatibility Tests (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_24_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
- run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.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,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts"
|
||||
|
||||
node-26-compat:
|
||||
name: Node 26 Compatibility (${{ matrix.shard }}/2)
|
||||
node-26-compat-build:
|
||||
name: Node 26 Compatibility Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 25
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_26_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Next.js build cache
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8
|
||||
with:
|
||||
path: .build/next/cache
|
||||
key: nextjs-${{ runner.os }}-node-${{ env.CI_NODE_26_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*', 'open-sse/**/*', 'db/**/*', 'next.config.mjs', 'tsconfig*.json', 'postcss.config.*', 'tailwind.config.*') }}
|
||||
restore-keys: |
|
||||
nextjs-${{ runner.os }}-node-${{ env.CI_NODE_26_VERSION }}-${{ hashFiles('package-lock.json') }}-
|
||||
- run: npm run build
|
||||
- run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
node-26-compat:
|
||||
name: Node 26 Compatibility Tests (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: node-26-compat-build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_26_VERSION }}
|
||||
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"
|
||||
|
||||
test-coverage-shard:
|
||||
name: Coverage Shard (${{ matrix.shard }}/8)
|
||||
@@ -392,12 +745,14 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Run c8 over shard ${{ matrix.shard }}/8
|
||||
run: |
|
||||
@@ -414,8 +769,8 @@ jobs:
|
||||
--reporter=json \
|
||||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
node --max-old-space-size=4096 --import tsx --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,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
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"
|
||||
- name: Upload raw shard coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -429,17 +784,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: test-coverage-shard
|
||||
if: ${{ always() && needs.test-coverage-shard.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.test-coverage-shard.result == 'success' }}
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- name: Download all shard coverage
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -447,13 +804,18 @@ jobs:
|
||||
path: coverage-shards/
|
||||
merge-multiple: true
|
||||
- name: Merge + report + gate
|
||||
# Merging 8 shards of raw v8 coverage is memory-heavy; the default Node
|
||||
# heap OOMs (exit 134). Raise it for the c8 merge/report step.
|
||||
# Merging 8 shards of raw v8 coverage is memory-heavy. `--merge-async`
|
||||
# keeps the V8 coverage merge incremental instead of loading every raw
|
||||
# JSON blob into one in-memory merge, which avoids Node heap OOMs.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
if [ ! -d coverage-shards ] || ! find coverage-shards -maxdepth 1 -type f -name '*.json' | grep -q .; then
|
||||
first_coverage_file=""
|
||||
if [ -d coverage-shards ]; then
|
||||
first_coverage_file="$(find coverage-shards -maxdepth 1 -type f -name '*.json' -print -quit)"
|
||||
fi
|
||||
if [ -z "$first_coverage_file" ]; then
|
||||
echo "::error::No raw coverage shard data was downloaded."
|
||||
find . -maxdepth 3 -type f | sort
|
||||
exit 1
|
||||
@@ -467,10 +829,9 @@ jobs:
|
||||
npx c8 report \
|
||||
--temp-directory coverage-shards \
|
||||
--reports-dir coverage \
|
||||
--merge-async \
|
||||
--reporter=text-summary \
|
||||
--reporter=html \
|
||||
--reporter=json-summary \
|
||||
--reporter=lcov \
|
||||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
--check-coverage \
|
||||
@@ -499,21 +860,22 @@ jobs:
|
||||
name: coverage-report
|
||||
path: |
|
||||
coverage/coverage-summary.json
|
||||
coverage/lcov.info
|
||||
coverage/coverage-report.md
|
||||
coverage/lcov.info
|
||||
if-no-files-found: warn
|
||||
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-coverage
|
||||
if: ${{ always() && needs.test-coverage.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.test-coverage.result == 'success' }}
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -537,8 +899,9 @@ jobs:
|
||||
coverage-pr-comment:
|
||||
name: PR Coverage Comment
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.code == 'true' }}
|
||||
needs:
|
||||
- changes
|
||||
- pr-test-policy
|
||||
- test-coverage
|
||||
permissions:
|
||||
@@ -631,15 +994,17 @@ jobs:
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
OMNIROUTE_PLAYWRIGHT_SKIP_BUILD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6.0.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
@@ -648,12 +1013,11 @@ jobs:
|
||||
- name: Download Next.js build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-next-build
|
||||
name: next-build
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build and restore standalone node_modules
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
cp -r node_modules .build/next/standalone/node_modules
|
||||
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
|
||||
|
||||
test-integration:
|
||||
@@ -672,14 +1036,16 @@ jobs:
|
||||
DATA_DIR: /tmp/omniroute-ci-${{ matrix.shard }}
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --import tsx --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
- run: node --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
|
||||
test-security:
|
||||
name: Security Tests
|
||||
@@ -690,31 +1056,34 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:security
|
||||
|
||||
ci-summary:
|
||||
name: CI Dashboard
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
- changes
|
||||
- lint
|
||||
- docs-sync-strict
|
||||
- i18n-ui-coverage
|
||||
- i18n
|
||||
- pr-test-policy
|
||||
|
||||
- build
|
||||
- package-artifact
|
||||
- electron-package-smoke
|
||||
- test-unit
|
||||
- node-24-compat
|
||||
- node-26-compat-build
|
||||
- node-26-compat
|
||||
- test-coverage
|
||||
- sonarqube
|
||||
@@ -751,11 +1120,11 @@ jobs:
|
||||
echo "## 🧱 Core Checks" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Change Classification | $(status '${{ needs.changes.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -765,14 +1134,15 @@ jobs:
|
||||
echo "| Build Matrix | $(status '${{ needs.build.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Package Artifact | $(status '${{ needs.package-artifact.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Electron Package Smoke | $(status '${{ needs.electron-package-smoke.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 26 Compatibility Build | $(status '${{ needs.node-26-compat-build.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## 🧪 Tests" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Suite | Status |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Unit | $(status '${{ needs.test-unit.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 24 Compatibility | $(status '${{ needs.node-24-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 26 Compatibility | $(status '${{ needs.node-26-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 24 Compatibility Tests | $(status '${{ needs.node-24-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 26 Compatibility Tests | $(status '${{ needs.node-26-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Coverage | $(status '${{ needs.test-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| PR Coverage Comment | $(status '${{ needs.coverage-pr-comment.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
7
.github/workflows/claude.yml
vendored
7
.github/workflows/claude.yml
vendored
@@ -10,6 +10,10 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
# Least-privilege default: no token permissions at the top level; the `claude` job
|
||||
# grants exactly what it needs below (Scorecard TokenPermissions).
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
@@ -26,8 +30,9 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
|
||||
31
.github/workflows/codeql.yml
vendored
Normal file
31
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: CodeQL
|
||||
# OWNER ACTION REQUIRED before enabling auto-triggers: advanced CodeQL conflicts with
|
||||
# GitHub "default setup" — the analyze step fails with "CodeQL analyses from advanced
|
||||
# configurations cannot be processed when the default setup is enabled". Switch repo
|
||||
# Settings → Code security → CodeQL from Default to Advanced, THEN restore the
|
||||
# push/pull_request/schedule triggers below. Until then this only runs on manual dispatch
|
||||
# so it never produces a red check on PRs. (The codeqlAlerts ratchet keeps working via the
|
||||
# default setup's alerts in the meantime.)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (javascript-typescript)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
62
.github/workflows/dast-smoke.yml
vendored
Normal file
62
.github/workflows/dast-smoke.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: DAST smoke (PR)
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main", "release/**"]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
dast-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# ADVISORY while this new gate matures (repo convention: advisory -> blocking).
|
||||
# Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute
|
||||
env:
|
||||
PORT: "20128"
|
||||
INJECTION_GUARD_MODE: block
|
||||
run: |
|
||||
node dist/server.js > server.log 2>&1 &
|
||||
echo $! > server.pid
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install schemathesis
|
||||
- name: Schemathesis smoke (high-risk endpoints, blocking)
|
||||
run: |
|
||||
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
|
||||
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
|
||||
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
|
||||
--request-timeout 20 --suppress-health-check all --no-color
|
||||
- name: promptfoo injection-guard (blocking)
|
||||
env:
|
||||
OMNIROUTE_URL: http://localhost:20128
|
||||
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
|
||||
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: dast-smoke-logs
|
||||
path: server.log
|
||||
retention-days: 7
|
||||
69
.github/workflows/docker-publish.yml
vendored
69
.github/workflows/docker-publish.yml
vendored
@@ -25,9 +25,10 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Least-privilege default: read-only at the top level; the build and merge jobs that
|
||||
# push to GHCR grant packages: write themselves (Scorecard TokenPermissions).
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
@@ -41,8 +42,9 @@ jobs:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
# Need full tag history for semver comparison when deciding :latest.
|
||||
fetch-depth: 0
|
||||
@@ -123,6 +125,9 @@ jobs:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.skip != 'true'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -138,8 +143,9 @@ jobs:
|
||||
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -226,6 +232,10 @@ jobs:
|
||||
- build
|
||||
if: needs.prepare.outputs.skip != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
env:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
||||
@@ -233,8 +243,9 @@ jobs:
|
||||
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -321,6 +332,56 @@ jobs:
|
||||
run: |
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}"
|
||||
|
||||
- name: Generate CycloneDX SBOM (image, advisory)
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: anchore/sbom-action@v0
|
||||
with:
|
||||
image: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
format: cyclonedx-json
|
||||
output-file: sbom-image.cdx.json
|
||||
artifact-name: sbom-image.cdx.json
|
||||
|
||||
# Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but
|
||||
# never blocks (exit-code 0). The blocking gate below narrows to CRITICAL.
|
||||
- name: Trivy image scan (SARIF, advisory)
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: "0"
|
||||
|
||||
# BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the
|
||||
# published image. Narrowed to severity CRITICAL (HIGH stays visible in the
|
||||
# SARIF step above, not blocking). ignore-unfixed:true so an unfixable base-image
|
||||
# CVE with no upstream patch does not red the release (reduces false-blocks);
|
||||
# a fixable CRITICAL still blocks. Per docs/security/SUPPLY_CHAIN.md. NB: Trivy
|
||||
# scans against a CVE DB that grows continuously — a newly-disclosed CRITICAL on
|
||||
# an unchanged base image can red this gate; the fix is to rebuild on a patched
|
||||
# base, bump the dep, or add a justified .trivyignore entry (see the CVE-variance
|
||||
# note in docs/security/SUPPLY_CHAIN.md).
|
||||
- name: Trivy CRITICAL gate (blocking)
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
format: table
|
||||
severity: CRITICAL
|
||||
ignore-unfixed: true
|
||||
exit-code: "1"
|
||||
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
- name: Update Docker Hub description
|
||||
# Only refresh README/description when we actually promote :latest
|
||||
# (avoids overwriting from main pushes or back-fill builds).
|
||||
|
||||
69
.github/workflows/electron-release.yml
vendored
69
.github/workflows/electron-release.yml
vendored
@@ -11,43 +11,56 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# Least-privilege default: read-only at the top level; each job grants the writes it
|
||||
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
|
||||
# to the reusable workflow) — Scorecard TokenPermissions.
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate version
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.validate.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version format
|
||||
id: validate
|
||||
env:
|
||||
# Pass workflow context via env (never interpolate ${{ ... }} straight
|
||||
# into the run: script body) so the shell receives variables, not
|
||||
# inlined text — zizmor template-injection mitigation. INPUT_VERSION is
|
||||
# the operator-supplied value and is regex-validated below before use.
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
else
|
||||
VERSION="${{ inputs.version }}"
|
||||
VERSION="$INPUT_VERSION"
|
||||
fi
|
||||
|
||||
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Invalid version format. Expected: v1.6.8"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ Valid version: $VERSION"
|
||||
|
||||
build:
|
||||
name: Build Electron (${{ matrix.platform }})
|
||||
needs: validate
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: write # electron-builder may publish artifacts with GH_TOKEN
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -71,7 +84,9 @@ jobs:
|
||||
deb_ext: .deb
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -79,7 +94,7 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6.0.0
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
|
||||
@@ -99,7 +114,7 @@ jobs:
|
||||
# that cause EPERM errors during Next.js standalone build glob scans.
|
||||
# Create a clean temp profile directory to avoid this.
|
||||
mkdir -p "$RUNNER_TEMP/home"
|
||||
echo "USERPROFILE=$RUNNER_TEMP/home" >> $GITHUB_ENV
|
||||
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Next.js standalone
|
||||
env:
|
||||
@@ -109,8 +124,13 @@ jobs:
|
||||
|
||||
- name: Sync version in electron/package.json
|
||||
shell: bash
|
||||
env:
|
||||
# Pass the validated version via env (never interpolate ${{ ... }}
|
||||
# straight into the run: script body) — zizmor template-injection
|
||||
# mitigation. Already regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in
|
||||
# the `validate` job, so it cannot carry shell metacharacters.
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ needs.validate.outputs.version }}"
|
||||
VERSION_NO_V="${VERSION#v}"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
@@ -192,10 +212,13 @@ jobs:
|
||||
name: Create Release
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # softprops/action-gh-release creates the GitHub Release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all artifacts
|
||||
@@ -205,14 +228,20 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create source archives
|
||||
env:
|
||||
# Pass the validated version via env (never interpolate ${{ ... }}
|
||||
# straight into the run: script body) — zizmor template-injection
|
||||
# mitigation. Already regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in
|
||||
# the `validate` job, so it cannot carry shell metacharacters.
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
# Create source code archives (excluding dev dependencies and build artifacts)
|
||||
export TARBALL="OmniRoute-${{ needs.validate.outputs.version }}.source.tar.gz"
|
||||
export ZIPBALL="OmniRoute-${{ needs.validate.outputs.version }}.source.zip"
|
||||
export TARBALL="OmniRoute-${VERSION}.source.tar.gz"
|
||||
export ZIPBALL="OmniRoute-${VERSION}.source.zip"
|
||||
|
||||
# Use git archive for clean source export
|
||||
git archive --format=tar.gz --prefix=OmniRoute-${{ needs.validate.outputs.version }}/ HEAD -o "release-assets/$TARBALL"
|
||||
git archive --format=zip --prefix=OmniRoute-${{ needs.validate.outputs.version }}/ HEAD -o "release-assets/$ZIPBALL"
|
||||
git archive --format=tar.gz --prefix="OmniRoute-${VERSION}/" HEAD -o "release-assets/$TARBALL"
|
||||
git archive --format=zip --prefix="OmniRoute-${VERSION}/" HEAD -o "release-assets/$ZIPBALL"
|
||||
|
||||
echo "✓ Created source archives:"
|
||||
ls -lh "release-assets/$TARBALL" "release-assets/$ZIPBALL"
|
||||
@@ -242,6 +271,14 @@ jobs:
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
needs: [validate, release]
|
||||
permissions:
|
||||
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
|
||||
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
|
||||
# A reusable workflow's job cannot request more permission than the caller grants,
|
||||
# so a `read` here makes GitHub reject the run at startup (startup_failure).
|
||||
contents: write
|
||||
id-token: write # npm provenance (forwarded to the reusable workflow)
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
uses: ./.github/workflows/npm-publish.yml
|
||||
with:
|
||||
version: ${{ needs.validate.outputs.version }}
|
||||
|
||||
63
.github/workflows/mutation-redundancy.yml
vendored
Normal file
63
.github/workflows/mutation-redundancy.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Mutation Redundancy (disableBail, on-demand)
|
||||
# One-off measurement to UNBLOCK R1 (test-redundancy prune). The nightly mutation run
|
||||
# (nightly-mutation.yml) bails on the first kill, so `killedBy` lists only the FIRST
|
||||
# killer — 🟠 redundant is understated and 🟢 unique overstated (see the caveat in
|
||||
# scripts/quality/mutation-radiography.mjs). This workflow re-runs the SAME combo +
|
||||
# chatCore leaf batches with stryker.disablebail.json (disableBail:true, incremental:false)
|
||||
# so `killedBy` lists EVERY killer. Feed the uploaded reports to
|
||||
# `node scripts/quality/mutation-radiography.mjs --candidates mutation-nobail-*/mutation.json`
|
||||
# to get the accurate R1 prune-candidate list (🔴 empty ∪ 🟠 redundant) for human review.
|
||||
#
|
||||
# Batches mirror the nightly's leaf decomposition (d/e/f/g/h/i) rather than 2 mega-batches:
|
||||
# disableBail is MORE expensive than bail (it never stops early), and Stryker only writes
|
||||
# mutation.json on a SUCCESSFUL finish — a batch cancelled at the cap produces NO data — so
|
||||
# smaller batches each fit the 300min headroom and run in parallel. auth/accountFallback and
|
||||
# the security quartet are out of scope: R1 targets the combo/chatCore leaves.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stryker-nobail:
|
||||
name: Stryker disableBail (batch ${{ matrix.batch.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
batch:
|
||||
- name: d
|
||||
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
|
||||
- name: e
|
||||
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
|
||||
- name: f
|
||||
mutate: "open-sse/services/combo/quotaScoring.ts,open-sse/services/combo/quotaStrategies.ts"
|
||||
- name: g
|
||||
mutate: "open-sse/handlers/chatCore/comboContextCache.ts,open-sse/handlers/chatCore/idempotency.ts,open-sse/handlers/chatCore/passthroughHelpers.ts,open-sse/handlers/chatCore/responseHeaders.ts,open-sse/handlers/chatCore/sanitization.ts,open-sse/handlers/chatCore/upstreamTimeouts.ts"
|
||||
- name: h
|
||||
mutate: "open-sse/handlers/chatCore/headers.ts,open-sse/handlers/chatCore/logTruncation.ts,open-sse/handlers/chatCore/memoryExtraction.ts,open-sse/handlers/chatCore/nonStreamingSse.ts,open-sse/handlers/chatCore/passthroughToolNames.ts,open-sse/handlers/chatCore/executorHelpers.ts"
|
||||
- name: i
|
||||
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Run Stryker (disableBail)
|
||||
env:
|
||||
BATCH_MUTATE: ${{ matrix.batch.mutate }}
|
||||
run: npx stryker run --config-file stryker.disablebail.json --mutate "$BATCH_MUTATE"
|
||||
- name: Upload mutation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-nobail-${{ matrix.batch.name }}
|
||||
path: reports/mutation/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
102
.github/workflows/nightly-llm-security.yml
vendored
Normal file
102
.github/workflows/nightly-llm-security.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: Nightly LLM Security
|
||||
on:
|
||||
schedule:
|
||||
- cron: "53 5 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
promptfoo-guard:
|
||||
name: promptfoo — injection guard (block mode, no secret)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute (block mode)
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
PORT: "20128"
|
||||
INJECTION_GUARD_MODE: block
|
||||
run: |
|
||||
node dist/server.js > server.log 2>&1 &
|
||||
echo $! > server.pid
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- name: promptfoo guard-validation
|
||||
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
|
||||
env:
|
||||
OMNIROUTE_URL: http://localhost:20128
|
||||
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
|
||||
garak:
|
||||
name: garak probes (skip without provider secret)
|
||||
runs-on: ubuntu-latest
|
||||
# NOTE: the `secrets` context is NOT available in a job-level `if:` — referencing
|
||||
# it there makes GitHub reject the file on push (startup_failure on every push).
|
||||
# Map the secret into a job-level env and gate each step on a presence check, so
|
||||
# the job stays green and simply skips the probes when the secret is absent.
|
||||
env:
|
||||
PROMPTFOO_PROVIDER_KEY: ${{ secrets.PROMPTFOO_PROVIDER_KEY }}
|
||||
steps:
|
||||
- name: Gate on provider secret
|
||||
id: gate
|
||||
run: |
|
||||
if [ -n "$PROMPTFOO_PROVIDER_KEY" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::PROMPTFOO_PROVIDER_KEY not set — skipping garak probes (advisory)."
|
||||
fi
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
- uses: actions/setup-node@v6
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
- name: Build CLI bundle
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
PORT: "20128"
|
||||
run: |
|
||||
node dist/server.js > server.log 2>&1 &
|
||||
echo $! > server.pid
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@v6
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
with: { python-version: "3.12" }
|
||||
- run: pip install garak
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
- name: garak limited probes
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.PROMPTFOO_PROVIDER_KEY }}
|
||||
OPENAI_BASE_URL: http://localhost:20128/v1
|
||||
run: garak --model_type openai --model_name gpt-4o-mini --probes promptinject,dan,leakreplay --report_prefix garak-omniroute || true
|
||||
- name: Stop server
|
||||
if: always() && steps.gate.outputs.run == 'true'
|
||||
run: kill "$(cat server.pid)" || true
|
||||
163
.github/workflows/nightly-mutation.yml
vendored
Normal file
163
.github/workflows/nightly-mutation.yml
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
name: Nightly Mutation
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stryker:
|
||||
name: Stryker mutation (batch ${{ matrix.batch.name }} — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
# Mutation testing is expensive. History of the budget:
|
||||
# - Full 8-module set TIMED OUT at the 180min cap (run 27705123780 = exactly 180min).
|
||||
# The two god-files chatCore.ts/combo.ts dominated ~2/3 of the mutants and were
|
||||
# removed from stryker.conf.json `mutate`.
|
||||
# - The remaining 6 modules in 3 batches: auth.ts and accountFallback.ts are large; the
|
||||
# a (auth+publicCreds) and b (accountFallback+error) batches still ran near the 180min
|
||||
# cap (the perTest dry-run over ~130 covering test files is itself costly), so the two
|
||||
# big modules are now ISOLATED into their own batches (a=auth, b=accountFallback).
|
||||
# - Onda 3 / Fase 9 T5 re-add: combo.ts was split into 11 leaves; the 8 well-covered
|
||||
# combo/* leaves are back in `mutate` (covered by the 24 combo-*.test.ts), grouped into
|
||||
# 2 batches (d=heavy, e=light). After #4204 (D7b) merged, the reset-aware quota pair
|
||||
# quotaScoring/quotaStrategies was added as batch f. A covering-test audit then added the
|
||||
# 6 chatCore/* leaves with direct unit coverage as batch g. A follow-up then wrote dedicated
|
||||
# unit tests for 6 more leaves and added them as batch h. A final follow-up added dedicated
|
||||
# tests (no mock.module — fetch-override + crafted inputs + temp-DATA_DIR) for telemetryHelpers
|
||||
# + memorySkillsInjection + semanticCache (its cache-HIT block now has a setCachedResponse
|
||||
# fixture) as batch i — ALL 15/15 chatCore leaves are now mutated. See
|
||||
# _mutate_godfiles_excluded_comment in stryker.conf.json.
|
||||
# 9 PARALLEL batches, each overriding the mutate set via `--mutate` (Stryker 9 CLI:
|
||||
# `-m, --mutate <comma-list>`; the conf's `mutate[]` remains the local-run default/union).
|
||||
# - Cold-seeding budget (per-batch `timeout-minutes: ${{ matrix.batch.timeout || 180 }}`):
|
||||
# a COLD run must COMPLETE once to write stryker-incremental.json (Stryker writes it only on
|
||||
# a successful finish); a job cancelled at the cap writes nothing, so the next run is cold
|
||||
# again — an infinite never-seeds loop. actions/cache is also branch-scoped, so each branch
|
||||
# (incl. release) must seed its OWN cache via a run with enough headroom. Measured cold runs
|
||||
# Measured cold totals (run 27801802713, extrapolated from the % at the 180/350 cancel point):
|
||||
# auth ~375min (2301 mutants — EXCEEDS the 360min job max even isolated), accountFallback
|
||||
# ~358min (1441), the c security quartet ~348min (1163), d ~197min (1316), g=142, h=132, e=66,
|
||||
# f=45, i=33. The widely-covered modules blow the budget because the tap-runner re-runs every
|
||||
# covering test file per mutant (a perTest fixed cost over ~138 test files, times thousands of
|
||||
# mutants). A flat timeout bump cannot rescue auth (>360min max) — so the three over-budget
|
||||
# batches are SPLIT so each half fits: auth->a1/a2 and accountFallback->b1/b2 by mutation range
|
||||
# (`file:startLine-endLine`), the c quartet->c1/c2 by module pair. Splitting also seeds each
|
||||
# sub-batch's own incremental cache, after which nightlies re-test only changed mutants.
|
||||
# Full coverage every night in parallel; wall-clock = the slowest batch's cold run until seeded.
|
||||
# Runs at stryker concurrency=4 with per-process DATA_DIR isolation
|
||||
# (tests/_setup/isolateDataDir.ts) — see _concurrency_comment in stryker.conf.json.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
batch:
|
||||
# Per-batch `timeout` (minutes) tiers the cold-seeding budget by measured cost; batches
|
||||
# without the key default to 180. Cold-run profiling (run 27801802713) showed the
|
||||
# widely-covered modules need FAR more than the 180 cap because the tap-runner re-runs every
|
||||
# covering test file per mutant: auth ~375min (2301 mutants, EXCEEDS the 360min GitHub job
|
||||
# max even isolated), accountFallback ~358min, the c security quartet ~348min — none fit a
|
||||
# single job. So auth/accountFallback are split by MUTATION RANGE (`file:startLine-endLine`,
|
||||
# ~half the mutants each) into a1/a2, b1/b2; the c quartet is split by MODULE pair into
|
||||
# c1/c2. d (3 combo modules, ~197min) stays whole. Split-heavy batches get 300min headroom
|
||||
# for their cold seeding run; once each batch completes once and writes
|
||||
# stryker-incremental.json, later runs re-test only changed mutants and finish far faster.
|
||||
# g/h (142/132min cold) keep a 240 buffer; e/f/i (33-66min) keep the 180 default.
|
||||
- name: a1
|
||||
mutate: "src/sse/services/auth.ts:1-1109"
|
||||
timeout: 300
|
||||
- name: a2
|
||||
mutate: "src/sse/services/auth.ts:1110-2218"
|
||||
timeout: 300
|
||||
- name: b1
|
||||
mutate: "open-sse/services/accountFallback.ts:1-863"
|
||||
timeout: 300
|
||||
- name: b2
|
||||
mutate: "open-sse/services/accountFallback.ts:864-1726"
|
||||
timeout: 300
|
||||
- name: c1
|
||||
mutate: "src/server/authz/routeGuard.ts,src/shared/utils/circuitBreaker.ts"
|
||||
timeout: 300
|
||||
- name: c2
|
||||
mutate: "open-sse/utils/error.ts,open-sse/utils/publicCreds.ts"
|
||||
timeout: 300
|
||||
- name: d
|
||||
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
|
||||
timeout: 300
|
||||
- name: e
|
||||
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
|
||||
- name: f
|
||||
mutate: "open-sse/services/combo/quotaScoring.ts,open-sse/services/combo/quotaStrategies.ts"
|
||||
- name: g
|
||||
mutate: "open-sse/handlers/chatCore/comboContextCache.ts,open-sse/handlers/chatCore/idempotency.ts,open-sse/handlers/chatCore/passthroughHelpers.ts,open-sse/handlers/chatCore/responseHeaders.ts,open-sse/handlers/chatCore/sanitization.ts,open-sse/handlers/chatCore/upstreamTimeouts.ts"
|
||||
timeout: 240
|
||||
- name: h
|
||||
mutate: "open-sse/handlers/chatCore/headers.ts,open-sse/handlers/chatCore/logTruncation.ts,open-sse/handlers/chatCore/memoryExtraction.ts,open-sse/handlers/chatCore/nonStreamingSse.ts,open-sse/handlers/chatCore/passthroughToolNames.ts,open-sse/handlers/chatCore/executorHelpers.ts"
|
||||
timeout: 240
|
||||
- name: i
|
||||
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
|
||||
# Per-batch budget: split-heavy batches (a1/a2/b1/b2/c1/c2/d) override to 300min, g/h to 240min;
|
||||
# the rest default to 180min. `matrix.batch.timeout` is null for batches without the key -> `|| 180`.
|
||||
# NOTE: a1+a2 both mutate auth.ts (disjoint line ranges) and b1+b2 both mutate accountFallback.ts;
|
||||
# when merging the per-batch mutation.json for radiography/scores, same-file mutants from sibling
|
||||
# ranges must be UNIONED (scripts/check/check-mutation-ratchet.mjs::measureMutationScores and
|
||||
# scripts/quality/mutation-radiography.mjs both merge per file).
|
||||
timeout-minutes: ${{ matrix.batch.timeout || 180 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore Stryker incremental cache
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: reports/mutation/stryker-incremental.json
|
||||
key: stryker-incremental-${{ matrix.batch.name }}-${{ github.run_id }}
|
||||
restore-keys: stryker-incremental-${{ matrix.batch.name }}-
|
||||
- name: Run Stryker (advisory)
|
||||
id: stryker
|
||||
continue-on-error: true
|
||||
env:
|
||||
BATCH_MUTATE: ${{ matrix.batch.mutate }}
|
||||
run: npx stryker run --mutate "$BATCH_MUTATE"
|
||||
- name: Upload mutation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-report-${{ matrix.batch.name }}
|
||||
path: reports/mutation/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
# Aggregation gate (T3): each split batch emits a PARTIAL view of a mutated file
|
||||
# (auth.ts lives in a1+a2, accountFallback in b1+b2), so a PER-BATCH ratchet would
|
||||
# only ever see half a file vs the whole-file baseline. This job runs AFTER every
|
||||
# batch, downloads all reports, and ratchets the MERGED per-module scores
|
||||
# (check-mutation-ratchet UNIONS same-file mutants across reports) against the
|
||||
# dedicatedGate `mutationScore.*` floors in quality-baseline.json (seeded ~2pt below
|
||||
# the first full measurement). Blocking: a module dropping below its floor fails the
|
||||
# run. Missing reports (e.g. an artifact-upload flake) are skipped, never failed.
|
||||
mutation-ratchet:
|
||||
name: Mutation score ratchet (blocking)
|
||||
needs: stryker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Download all mutation reports
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: mutation-report-*
|
||||
path: reports/all
|
||||
- name: Ratchet merged per-module mutation scores
|
||||
run: node scripts/check/check-mutation-ratchet.mjs reports/all/*/mutation.json --ratchet
|
||||
35
.github/workflows/nightly-property.yml
vendored
Normal file
35
.github/workflows/nightly-property.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Nightly Property Discovery
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
jobs:
|
||||
property-random-seed:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: fast-check random seed (high runs)
|
||||
id: prop
|
||||
run: FC_SEED=random FC_NUM_RUNS=2000 npm run test:property
|
||||
- name: Open issue on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: "Nightly property-test failure (Fase 8 B)",
|
||||
body: "fast-check found a counterexample with a random seed. Check the run logs for the reproducible seed + minimal case, then add it as a fixture.\n\nRun: " + context.serverUrl + "/" + context.repo.owner + "/" + context.repo.repo + "/actions/runs/" + context.runId,
|
||||
labels: ["quality-gate-finding"],
|
||||
});
|
||||
137
.github/workflows/nightly-release-green.yml
vendored
Normal file
137
.github/workflows/nightly-release-green.yml
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
name: Nightly Release-Green
|
||||
|
||||
# Solution D — continuous, NON-BLOCKING drift signal for the active release branch.
|
||||
#
|
||||
# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds
|
||||
# accrue silently on release/** and explode — in layers — at release time. This
|
||||
# nightly reproduces the release-equivalent validation on the active release branch
|
||||
# HEAD and, when there are HARD failures, opens/updates a single tracking issue.
|
||||
#
|
||||
# It is NOT a required status check and never touches a contributor PR — it only
|
||||
# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is
|
||||
# expected mid-cycle and is reported but never raises the alarm on its own; only
|
||||
# real defects (typecheck / lint errors / unit / vitest / db-rules / public-creds /
|
||||
# package-artifact) flip the issue open.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Release branch to validate (default: highest release/vX.Y.Z)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: nightly-release-green
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-green:
|
||||
name: Validate active release branch
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve active release branch
|
||||
id: branch
|
||||
env:
|
||||
INPUT_BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_BRANCH:-}" ]; then
|
||||
TARGET="$INPUT_BRANCH"
|
||||
else
|
||||
# highest release/vX.Y.Z by semver among remote branches
|
||||
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
|
||||
| sed 's#origin/##' \
|
||||
| sort -t/ -k2 -V \
|
||||
| tail -1)
|
||||
fi
|
||||
if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi
|
||||
# Strict format guard — reject anything that isn't release/vX.Y.Z (blocks
|
||||
# ref/command injection via the workflow_dispatch input).
|
||||
if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Refusing non-canonical branch name: $TARGET"; exit 1
|
||||
fi
|
||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
echo "Active release branch: $TARGET"
|
||||
|
||||
- name: Checkout the release branch
|
||||
env:
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git checkout "$TARGET"
|
||||
git log -1 --oneline
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
|
||||
- name: Release-green validation (full)
|
||||
id: validate
|
||||
run: |
|
||||
set +e
|
||||
node scripts/quality/validate-release-green.mjs --json --with-build \
|
||||
1> release-green.json 2> release-green.log
|
||||
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
||||
echo "------- report -------"
|
||||
cat release-green.log
|
||||
|
||||
- name: Open / update tracking issue on HARD failure
|
||||
if: steps.validate.outputs.exit != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="🔴 Release branch not green: ${TARGET}"
|
||||
{
|
||||
echo "The nightly **release-green** validation found HARD failures on \`${TARGET}\`."
|
||||
echo "These are real defects that would block the release PR — fix them in the"
|
||||
echo "originating PR branch (via co-authorship), not by demanding it from contributors."
|
||||
echo ""
|
||||
echo "**Run:** ${RUN_URL}"
|
||||
echo ""
|
||||
echo '```'
|
||||
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) listed above is expected mid-cycle and is rebaselined at release — it is NOT a contributor concern and did not, on its own, open this issue._"
|
||||
} > issue-body.md
|
||||
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
|
||||
echo "Updated existing issue #$EXISTING"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-green-report
|
||||
path: |
|
||||
release-green.json
|
||||
release-green.log
|
||||
if-no-files-found: ignore
|
||||
110
.github/workflows/nightly-resilience.yml
vendored
Normal file
110
.github/workflows/nightly-resilience.yml
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
name: Nightly Resilience
|
||||
on:
|
||||
schedule:
|
||||
- cron: "41 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
heap:
|
||||
name: Heap-growth gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:heap
|
||||
|
||||
chaos:
|
||||
name: Resilience chaos (fault injection)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:chaos
|
||||
|
||||
k6-soak:
|
||||
name: k6 load/soak
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute (background)
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
PORT: "20128"
|
||||
run: |
|
||||
node dist/server.js > server.log 2>&1 &
|
||||
echo $! > server.pid
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- name: Install k6
|
||||
uses: grafana/setup-k6-action@v1
|
||||
- name: Run k6 soak
|
||||
run: k6 run tests/load/k6-soak.js
|
||||
env:
|
||||
BASE_URL: http://localhost:20128
|
||||
SOAK_DURATION: "3m"
|
||||
SOAK_VUS: "10"
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
|
||||
a11y:
|
||||
name: A11y axe (nightly, freeze-and-alert)
|
||||
runs-on: ubuntu-latest
|
||||
# The Playwright webServer (`start` mode) builds Next via build-next-isolated.mjs and
|
||||
# boots the standalone server itself (waits on /api/monitoring/health, 15min webServer
|
||||
# timeout). Unlike the per-PR test-e2e job, this nightly job has no pre-built artifact,
|
||||
# so it self-builds — hence the generous job timeout. REQUIRE_AXE=1 makes the suite run
|
||||
# the real axe analysis (the 4 page tests are gated to nightly so per-PR e2e stays fast)
|
||||
# and makes the meta-test fail loudly if @axe-core/playwright ever goes missing.
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
REQUIRE_AXE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6.0.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: playwright-chromium-${{ runner.os }}-
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Run axe a11y suite (self-building webServer)
|
||||
run: npx playwright test tests/e2e/a11y.spec.ts
|
||||
72
.github/workflows/nightly-schemathesis.yml
vendored
Normal file
72
.github/workflows/nightly-schemathesis.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Nightly Schemathesis
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
schemathesis:
|
||||
name: Schemathesis — OpenAPI contract fuzz (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute (background)
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
PORT: "20128"
|
||||
run: |
|
||||
node dist/server.js > server.log 2>&1 &
|
||||
echo $! > server.pid
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@v6
|
||||
with: { python-version: "3.12" }
|
||||
- name: Install schemathesis
|
||||
run: pip install schemathesis
|
||||
- name: Schemathesis contract fuzz (advisory)
|
||||
# Advisory gate: never fails the job. `continue-on-error` covers a crash of the
|
||||
# step itself; `|| true` covers schemathesis exiting non-zero when it finds spec
|
||||
# violations / upstream 500s — both are expected here (most /v1 endpoints proxy an
|
||||
# upstream that has no provider configured in CI). The point of the nightly is to
|
||||
# PROVE the contract is fuzzable and surface regressions, not to gate the build.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
schemathesis run docs/openapi.yaml \
|
||||
--url http://localhost:20128 \
|
||||
--max-examples 20 \
|
||||
--workers 4 \
|
||||
--checks all \
|
||||
--max-response-time 30 \
|
||||
--request-timeout 30 \
|
||||
--suppress-health-check all \
|
||||
--report junit \
|
||||
--report-junit-path schemathesis-report/junit.xml \
|
||||
--no-color \
|
||||
|| true
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
- name: Upload schemathesis report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: schemathesis-report
|
||||
path: |
|
||||
schemathesis-report/
|
||||
server.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
42
.github/workflows/npm-publish.yml
vendored
42
.github/workflows/npm-publish.yml
vendored
@@ -37,10 +37,11 @@ on:
|
||||
NPM_TOKEN:
|
||||
required: true
|
||||
|
||||
# Least-privilege default: read-only at the top level; each publish job grants the
|
||||
# id-token (npm provenance) / packages (GitHub Packages) writes it needs (Scorecard
|
||||
# TokenPermissions).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
NPM_PUBLISH_NODE_VERSION: "24"
|
||||
@@ -48,10 +49,15 @@ env:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # gh release upload (attach SBOM to the GitHub Release)
|
||||
id-token: write # npm provenance
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Need full tag history to compare against highest semver when
|
||||
# deciding whether this release should claim dist-tag `latest`.
|
||||
fetch-depth: 0
|
||||
@@ -141,6 +147,25 @@ jobs:
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: npm run check:pack-artifact
|
||||
|
||||
- name: Generate CycloneDX SBOM (npm)
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: npx @cyclonedx/cyclonedx-npm --ignore-npm-errors --output-format JSON --output-file sbom-npm.cdx.json
|
||||
|
||||
- name: Upload SBOM (npm) as workflow artifact
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sbom-npm
|
||||
path: sbom-npm.cdx.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach SBOM to GitHub Release
|
||||
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'release'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
@@ -152,7 +177,7 @@ jobs:
|
||||
# Always pass --tag explicitly. Defense in depth: even if VERSION is
|
||||
# accidentally an older release, `npm publish --tag historic` will
|
||||
# NOT promote it to `@latest`.
|
||||
npm publish --access public --tag "$TAG"
|
||||
npm publish --provenance --access public --tag "$TAG"
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
|
||||
|
||||
- name: Publish to GitHub Packages
|
||||
@@ -172,9 +197,14 @@ jobs:
|
||||
|
||||
publish-opencode-plugin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -208,5 +238,5 @@ jobs:
|
||||
echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish --access public --ignore-scripts
|
||||
npm publish --provenance --access public --ignore-scripts
|
||||
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"
|
||||
|
||||
8
.github/workflows/opencode-plugin-ci.yml
vendored
8
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -32,7 +32,9 @@ jobs:
|
||||
matrix:
|
||||
node: ["22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
@@ -47,7 +49,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
8
.github/workflows/opencode-provider-ci.yml
vendored
8
.github/workflows/opencode-provider-ci.yml
vendored
@@ -32,7 +32,9 @@ jobs:
|
||||
matrix:
|
||||
node: ["20", "22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
@@ -46,7 +48,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
144
.github/workflows/quality.yml
vendored
Normal file
144
.github/workflows/quality.yml
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
name: Quality Gates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["release/**"]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CI_NODE_VERSION: "24"
|
||||
|
||||
jobs:
|
||||
fast-gates:
|
||||
name: Fast Quality Gates
|
||||
runs-on: ubuntu-latest
|
||||
# tsx gates (known-symbols, route-guard-membership) import modules that open
|
||||
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:fetch-targets
|
||||
- run: npm run check:openapi-routes
|
||||
- run: npm run check:docs-symbols
|
||||
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
|
||||
run: npm run check:docs-all
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
- run: npm run check:error-helper
|
||||
- run: npm run check:migration-numbering
|
||||
- run: npm run check:public-creds
|
||||
- run: npm run check:db-rules
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:test-runner-api
|
||||
- run: npm run check:any-budget:t11
|
||||
# Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
|
||||
# scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
|
||||
- run: npm run check:build-scope
|
||||
# Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file
|
||||
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
|
||||
# the release PR's heavy Package Artifact job.
|
||||
- run: npm run check:pack-policy
|
||||
# Complexity + cognitive-complexity ratchets on the fast-path (PR→release) so
|
||||
# cycle drift is rebaselined PER-PR instead of cascading onto the release PR's
|
||||
# Quality Ratchet (v3.8.36: +30 complexity / +15 cognitive surfaced only post-merge).
|
||||
- run: npm run check:complexity
|
||||
- run: npm run check:cognitive-complexity
|
||||
- name: Typecheck (core)
|
||||
run: npm run typecheck:core
|
||||
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
|
||||
# unit tests impacted by this PR's changed files. Fail-safe runs the FULL
|
||||
# unit suite on hub/unmapped changes — TIA accelerates, never replaces, the net.
|
||||
#
|
||||
# BLOCKING (flipped 2026-06-17). The pre-existing release unit test-debt that kept
|
||||
# this advisory was cleared: #4030 (16 Zod/registry reds, lossless restore) and
|
||||
# #4063 (the last red — the LiveWS boot test — root-caused as a real event-loop
|
||||
# stall in the WS sidecar, fixed + relocated to the integration suite). A full
|
||||
# ci.yml run on release/v3.8.28 then showed all 8 unit shards green, so PR->release
|
||||
# now blocks on unit-test regressions in the impacted set (typecheck:core already
|
||||
# blocked above). Fail-safe still runs the FULL unit suite on hub/unmapped changes.
|
||||
- name: Impacted unit tests (TIA, fail-safe full; blocking)
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
|
||||
if [ -z "$SEL" ]; then echo "No source/test changes — skipping unit tests"; exit 0; fi
|
||||
# CI runners are 4-vCPU; run at --test-concurrency=4 (matching the ci.yml unit
|
||||
# job) rather than test:unit's local-tuned concurrency=20. Oversubscribing the
|
||||
# runner makes timing-sensitive tests (db-backup, upstream-timeout, ...) flake,
|
||||
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
|
||||
# run race-free regardless of concurrency.
|
||||
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
||||
echo "Fail-safe: running FULL unit suite (CI concurrency)"; npm run test:unit:ci; exit $?
|
||||
fi
|
||||
echo "Running impacted tests:"; echo "$SEL"
|
||||
mapfile -t FILES <<< "$SEL"
|
||||
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}"
|
||||
|
||||
fast-vitest:
|
||||
name: Vitest (fast-path)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:vitest
|
||||
|
||||
fast-unit:
|
||||
name: Unit Tests fast-path (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- 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 }}/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}/**/*.test.ts"
|
||||
40
.github/workflows/scorecard.yml
vendored
Normal file
40
.github/workflows/scorecard.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: OpenSSF Scorecard
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: "27 7 * * 1"
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# security-events: write removed — Scorecard findings are advisory and no longer
|
||||
# uploaded to the code-scanning Security tab (they are supply-chain/posture scores,
|
||||
# not code vulnerabilities, and drowned out real CodeQL alerts). The run still
|
||||
# produces the OpenSSF badge (publish_results) and a downloadable SARIF artifact.
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run analysis
|
||||
uses: ossf/scorecard-action@v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
29
.github/workflows/semgrep.yml
vendored
Normal file
29
.github/workflows/semgrep.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: semgrep
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main", "release/**"]
|
||||
push:
|
||||
branches: ["main"]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
semgrep:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: semgrep/semgrep
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run semgrep (advisory)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
semgrep scan --config p/owasp-top-ten --config p/secrets \
|
||||
--sarif --output semgrep.sarif --metrics off || true
|
||||
python -c "import json; d=json.load(open('semgrep.sarif')); print('semgrepFindings=%d' % len(d['runs'][0]['results']))" || echo "semgrepFindings=SKIP"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: semgrep-sarif
|
||||
path: semgrep.sarif
|
||||
retention-days: 14
|
||||
69
.github/workflows/wiki-sync.yml
vendored
Normal file
69
.github/workflows/wiki-sync.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Wiki Sync
|
||||
|
||||
# Keeps the GitHub wiki in sync with docs/ on every release that lands on main.
|
||||
# The wiki has no native generator and historically drifts (it sat at "212+ providers /
|
||||
# 14 strategies / 37 MCP tools" while code was at 226 / 15 / 87, and new docs like
|
||||
# SUPPLY_CHAIN never appeared). This runs scripts/docs/sync-wiki.mjs, which:
|
||||
# - ADDS any docs/ page missing from the wiki (curated; internal reports excluded),
|
||||
# - syncs the four cover-page counts on Home.md.
|
||||
# It does NOT overwrite existing wiki pages by default: several docs sources still carry
|
||||
# stale counts (e.g. ARCHITECTURE.md says "177 providers" while the wiki cover is 226),
|
||||
# so blind overwrite would regress the wiki. Full content parity (--update-existing) is
|
||||
# gated on regenerating those sources — see docs/ops/DOCUMENTATION_AUDIT_REPORT.md.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "README.md"
|
||||
- "AGENTS.md"
|
||||
- "src/shared/constants/routingStrategies.ts"
|
||||
- "config/i18n.json"
|
||||
- "open-sse/mcp-server/server.ts"
|
||||
- "scripts/docs/sync-wiki.mjs"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: wiki-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-wiki:
|
||||
name: Sync wiki with docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Clone wiki
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
git clone "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.wiki.git" wiki
|
||||
|
||||
- name: Sync wiki (add missing pages + cover counts)
|
||||
run: node scripts/docs/sync-wiki.mjs --wiki-dir wiki
|
||||
|
||||
- name: Commit & push if changed
|
||||
run: |
|
||||
cd wiki
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git commit -m "docs(wiki): auto-sync pages + cover counts with docs"
|
||||
git push
|
||||
echo "Wiki updated."
|
||||
else
|
||||
echo "Wiki already in sync — nothing to push."
|
||||
fi
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -23,6 +23,11 @@ coverage/
|
||||
.build/**
|
||||
.out/**
|
||||
|
||||
# Stryker mutation testing — ephemeral sandbox + generated reports (never commit)
|
||||
.stryker-tmp/
|
||||
reports/mutation/
|
||||
stryker-output-*.json
|
||||
|
||||
|
||||
# Memory Bank and Cursor rules (local-only AI agent context)
|
||||
memory-bank/
|
||||
@@ -44,6 +49,10 @@ docs/new-features/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
# Also ignore a root node_modules SYMLINK (worktree setups symlink it from the main
|
||||
# checkout). The trailing-slash pattern above only matches a directory, so without this
|
||||
# a symlink named node_modules could be staged by `git add -A` and committed.
|
||||
/node_modules
|
||||
*.map
|
||||
.DS_Store
|
||||
|
||||
@@ -152,6 +161,11 @@ typescript
|
||||
|
||||
# Superpowers plans/specs (internal tooling, not project code)
|
||||
docs/superpowers/
|
||||
# Superpowers visual-companion brainstorm mockups (ephemeral)
|
||||
.superpowers/
|
||||
|
||||
# TIA test-impact map — generated at runtime in CI (build-test-impact-map.mjs), never committed (~21MB)
|
||||
config/quality/test-impact-map.json
|
||||
|
||||
# GitNexus local index
|
||||
.gitnexus
|
||||
@@ -203,8 +217,11 @@ pr_reviews*.json
|
||||
|
||||
# internal setup prompts with personal credentials — never commit
|
||||
CODEX-SETUP-PROMPT.md
|
||||
# Quality ratchet — métricas efêmeras (baseline é commitado, métricas não)
|
||||
quality-metrics.json
|
||||
# Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não)
|
||||
config/quality/quality-metrics.json
|
||||
|
||||
# Runtime logs (diretório local, nunca versionado)
|
||||
/logs/
|
||||
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
|
||||
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260410 .txt
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute.md
|
||||
|
||||
76
.gitleaks.toml
Normal file
76
.gitleaks.toml
Normal file
@@ -0,0 +1,76 @@
|
||||
# .gitleaks.toml — Configuração do gitleaks para OmniRoute
|
||||
# Task 7.18 — PLANO-QUALITY-GATES-FASE7.md
|
||||
#
|
||||
# Estende as regras padrão do gitleaks com allowlists específicas do projeto.
|
||||
#
|
||||
# INSTRUÇÕES para allowlists:
|
||||
# Findings legítimos (fixtures de teste, creds OAuth públicas já cobertas pelo
|
||||
# check-public-creds.mjs, valores de exemplo em docs) devem ser registrados abaixo
|
||||
# em [[allowlist]] com um comentário explicativo obrigatório.
|
||||
#
|
||||
# NÃO adicione uma entrada de allowlist sem justificativa. Cada entrada é revisada
|
||||
# a cada release (stale-enforcement). Regra: se o finding é um valor real que o
|
||||
# sistema usa em produção, é um verdadeiro positivo — não allowliste, corrija.
|
||||
#
|
||||
# Referência: docs/security/PUBLIC_CREDS.md (credenciais OAuth públicas conhecidas)
|
||||
# CLAUDE.md Hard Rule #11 (resolvePublicCred obrigatório)
|
||||
|
||||
# Herdar TODAS as regras padrão do gitleaks. ATENÇÃO: um config customizado
|
||||
# SEM [extend].useDefault = true (e sem [[rules]] próprias) resulta em ZERO
|
||||
# regras — o gitleaks SUBSTITUI o ruleset padrão pelo arquivo, não o estende
|
||||
# automaticamente. Sem esta seção, `gitleaks --config .gitleaks.toml` nunca
|
||||
# detecta nada (todo finding vira 0), tornando o gate inerte. Com useDefault,
|
||||
# a allowlist abaixo é aplicada POR CIMA das ~170 regras padrão.
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
# Para desabilitar uma regra específica, usar:
|
||||
# [[rules]]
|
||||
# id = "rule-id"
|
||||
# [rules.allowlist]
|
||||
# description = "..."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist global do projeto
|
||||
# Entradas aqui são ignoradas em TODAS as varreduras.
|
||||
# ---------------------------------------------------------------------------
|
||||
[allowlist]
|
||||
description = "OmniRoute project-level allowlist — fixtures, test vectors, public OAuth creds"
|
||||
|
||||
# Paths a ignorar completamente (node_modules, builds, etc.)
|
||||
paths = [
|
||||
'''node_modules''',
|
||||
'''\.next''',
|
||||
'''dist''',
|
||||
'''\.git''',
|
||||
'''coverage''',
|
||||
'''\.nyc_output''',
|
||||
]
|
||||
|
||||
# Commits específicos a ignorar (ex: commit que introduziu fixtures de teste)
|
||||
# commits = []
|
||||
|
||||
# Regexes de stopwords — linhas que contêm estes padrões são ignoradas.
|
||||
# Usar apenas para falsos positivos comprovados com justificativa abaixo.
|
||||
# stopwords = []
|
||||
|
||||
# Regexes de targets (paths de arquivos) que podem ser allowlistados por regra.
|
||||
# Ver [[allowlist]] por-regra abaixo para granularidade.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist por-regra (adicionar conforme necessário durante o stale review)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Exemplo (REMOVER / SUBSTITUIR por entradas reais quando necessário):
|
||||
#
|
||||
# [[rules]]
|
||||
# # Allowlistar fixtures de teste que contêm tokens OAuth de exemplo/inválidos
|
||||
# # Adicionado: 2026-06-13 | Revisar em: v3.9.0 | Justificativa: valores não-reais de teste
|
||||
# id = "github-fine-grained-pat"
|
||||
# [rules.allowlist]
|
||||
# description = "Test fixture PATs — valores sintéticos, não funcionais"
|
||||
# paths = [
|
||||
# '''tests/fixtures/''',
|
||||
# '''tests/unit/''',
|
||||
# ]
|
||||
#
|
||||
@@ -10,3 +10,4 @@ fi
|
||||
npx lint-staged
|
||||
node scripts/check/check-docs-sync.mjs
|
||||
npm run check:any-budget:t11
|
||||
node scripts/check/check-tracked-artifacts.mjs
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
#!/usr/bin/env sh
|
||||
#if ! command -v npm >/dev/null 2>&1; then
|
||||
# echo "⚠️ npm not found in PATH — skipping pre-push hooks"
|
||||
# echo " Run 'npm test' manually before pushing."
|
||||
# exit 0
|
||||
#fi
|
||||
# .husky/pre-push — fast deterministic gates (<10s total)
|
||||
# Intentionally excludes test:unit (slow; covered by CI pre-push remote run).
|
||||
# Activated: 2026-06-13 (6A.12 — replaced commented-out test:unit stub)
|
||||
|
||||
#npm run test:unit
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "⚠️ npm not found in PATH — skipping pre-push hooks"
|
||||
echo " Run 'npm run check:any-budget:t11 && npm run check:tracked-artifacts' manually before pushing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
npm run check:any-budget:t11 && npm run check:tracked-artifacts
|
||||
|
||||
14
.markdownlint.json
Normal file
14
.markdownlint.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"_comment": "Advisory markdown lint for docs/ + root *.md. Rules that conflict with the existing doc style (heavy inline HTML, long lines, centered headings) are disabled so the gate stays signal-not-noise. Run: npm run lint:md",
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD033": false,
|
||||
"MD041": false,
|
||||
"MD024": { "siblings_only": true },
|
||||
"MD026": false,
|
||||
"MD036": false,
|
||||
"MD040": false,
|
||||
"MD029": false,
|
||||
"MD007": { "indent": 2 },
|
||||
"MD046": false
|
||||
}
|
||||
17
.mcp.json.example
Normal file
17
.mcp.json.example
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$comment_purpose": "OPT-IN agent-lsp / LSP-in-the-loop (Quality Gates Fase 7 Task 15). Copy this file to `.mcp.json` to enable. It exposes a TypeScript language server to coding agents (Claude Code, etc.) so they get diagnostics / hover / go-to-definition / blast-radius BEFORE writing code — turning 'invented symbol' review-catches into impossible-at-edit-time. Pairs with `npm run typecheck:core` as a compile-before-claim check.",
|
||||
"$comment_safety": "Shipped as `.example` (NOT `.mcp.json`) on purpose so it never auto-loads an unvetted server into everyone's session. Pick an MCP<->LSP bridge you trust and have verified locally, then drop in its package + args below. A broken MCP entry only logs a connection error; it does not break agent sessions. The underlying language server is `typescript-language-server` (npm, mature) — install via `npm i -g typescript-language-server typescript` or rely on npx.",
|
||||
"mcpServers": {
|
||||
"typescript-lsp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"<your-mcp-lsp-bridge>",
|
||||
"--lsp",
|
||||
"typescript-language-server",
|
||||
"--stdio"
|
||||
],
|
||||
"$note": "Replace <your-mcp-lsp-bridge> with the concrete MCP<->LSP adapter you chose. It must speak MCP on stdio and proxy to `typescript-language-server --stdio`. Scope it to this repo's tsconfig (open-sse/tsconfig.json / tsconfig.json) for accurate diagnostics."
|
||||
}
|
||||
}
|
||||
}
|
||||
22
.size-limit.json
Normal file
22
.size-limit.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"name": "CLI entry (omniroute.mjs)",
|
||||
"path": "bin/omniroute.mjs",
|
||||
"limit": "15 KB"
|
||||
},
|
||||
{
|
||||
"name": "MCP server entry (mcp-server.mjs)",
|
||||
"path": "bin/mcp-server.mjs",
|
||||
"limit": "5 KB"
|
||||
},
|
||||
{
|
||||
"name": "Node runtime support (nodeRuntimeSupport.mjs)",
|
||||
"path": "bin/nodeRuntimeSupport.mjs",
|
||||
"limit": "8 KB"
|
||||
},
|
||||
{
|
||||
"name": "Reset password entry (reset-password.mjs)",
|
||||
"path": "bin/reset-password.mjs",
|
||||
"limit": "6 KB"
|
||||
}
|
||||
]
|
||||
@@ -4,7 +4,6 @@ var docs = defineDocs({
|
||||
dir: "docs",
|
||||
docs: {
|
||||
files: [
|
||||
"./getting-started/**/*.md",
|
||||
"./architecture/**/*.md",
|
||||
"./guides/**/*.md",
|
||||
"./reference/**/*.md",
|
||||
|
||||
15
.vale.ini
Normal file
15
.vale.ini
Normal file
@@ -0,0 +1,15 @@
|
||||
# Advisory prose lint for OmniRoute docs (Vale — https://vale.sh).
|
||||
# Warning-first: surfaces vague language, passive voice, and terminology drift without
|
||||
# blocking CI. The Microsoft style is pulled by the CI step (vale sync / vale-action).
|
||||
# Project terms live in the OmniRoute vocabulary so they never read as misspellings.
|
||||
StylesPath = .vale/styles
|
||||
MinAlertLevel = warning
|
||||
Packages = Microsoft
|
||||
Vocab = OmniRoute
|
||||
|
||||
[*.md]
|
||||
BasedOnStyles = Vale, Microsoft
|
||||
|
||||
# Code-heavy reference docs and auto-generated catalogs are noisy under prose rules.
|
||||
[docs/reference/PROVIDER_REFERENCE.md]
|
||||
BasedOnStyles = Vale
|
||||
50
.vale/styles/Vocab/OmniRoute/accept.txt
Normal file
50
.vale/styles/Vocab/OmniRoute/accept.txt
Normal file
@@ -0,0 +1,50 @@
|
||||
OmniRoute
|
||||
combo
|
||||
combos
|
||||
[Cc]ombo
|
||||
[Aa]uto-[Cc]ombo
|
||||
MCP
|
||||
A2A
|
||||
ACP
|
||||
RTK
|
||||
Caveman
|
||||
Troglodita
|
||||
Qdrant
|
||||
FTS5
|
||||
SQLite
|
||||
LowDB
|
||||
OAuth
|
||||
PKCE
|
||||
SSE
|
||||
JSON-RPC
|
||||
LKGP
|
||||
P2C
|
||||
Antigravity
|
||||
Codex
|
||||
Kiro
|
||||
Qoder
|
||||
Pollinations
|
||||
LongCat
|
||||
Cerebras
|
||||
DeepSeek
|
||||
Groq
|
||||
Cline
|
||||
OpenCode
|
||||
Termux
|
||||
Electron
|
||||
Fumadocs
|
||||
Notion
|
||||
Obsidian
|
||||
WebDAV
|
||||
IPv4
|
||||
IPv6
|
||||
SOCKS5
|
||||
loopback
|
||||
backoff
|
||||
changelog
|
||||
i18n
|
||||
locale
|
||||
locales
|
||||
roadmap
|
||||
[Ww]ildcard
|
||||
upstream
|
||||
99
.vscode/settings.json
vendored
99
.vscode/settings.json
vendored
@@ -19,30 +19,79 @@
|
||||
}
|
||||
},
|
||||
"git.ignoreLimitWarning": true,
|
||||
|
||||
// ─── Git: não adicionar os ~44 repos aninhados (9 worktrees + _references/* +
|
||||
// _mono_repo/* + _ideia/_tasks/.agents) ao Source Control. Era a causa do
|
||||
// "validando muito": um git status + watcher por repo. Só o repo raiz fica. ───
|
||||
"git.autoRepositoryDetection": false,
|
||||
"git.repositoryScanMaxDepth": 0,
|
||||
"git.detectSubmodules": false,
|
||||
"git.autofetch": false,
|
||||
"git.autorefresh": true,
|
||||
|
||||
// ─── Performance: não seguir o symlink de node_modules criado pelas worktrees ───
|
||||
// As worktrees em .worktrees/ apontam node_modules para o checkout principal;
|
||||
// sem isto a busca atravessa o symlink mesmo com node_modules excluído.
|
||||
"search.followSymlinks": false,
|
||||
|
||||
// ─── TypeScript server (monorepo grande: ~6,6k arquivos rastreados, Next.js 16) ───
|
||||
"typescript.tsserver.maxTsServerMemory": 4096,
|
||||
"typescript.disableAutomaticTypeAcquisition": true,
|
||||
"typescript.tsc.autoDetect": "off",
|
||||
"npm.autoDetect": "off",
|
||||
// O tsserver tem o próprio watcher (independente de files.watcherExclude).
|
||||
// excludeDirectories evita que ele vigie as árvores pesadas.
|
||||
"typescript.tsserver.watchOptions": {
|
||||
"excludeDirectories": [
|
||||
"**/node_modules",
|
||||
"**/.next",
|
||||
"**/.build",
|
||||
"**/dist",
|
||||
"**/coverage",
|
||||
"**/.worktrees"
|
||||
]
|
||||
},
|
||||
|
||||
// Para esconder os diretórios gerados da árvore do Explorer, descomente:
|
||||
// "files.exclude": {
|
||||
// "**/.worktrees": true,
|
||||
// "**/coverage": true,
|
||||
// "**/dist": true,
|
||||
// "**/.build": true,
|
||||
// "**/.next": true,
|
||||
// "**/_references": true,
|
||||
// "**/_mono_repo": true,
|
||||
// "**/electron": true,
|
||||
// "**/node_modules": true,
|
||||
// "**/.next": true,
|
||||
// "**/coverage": true,
|
||||
// "**/omniroute-*.tgz": true,
|
||||
// "**/_tasks": true
|
||||
// "**/_tasks": true,
|
||||
// "**/omniroute-*.tgz": true
|
||||
// },
|
||||
"files.watcherExclude": {
|
||||
"**/_references/**": true,
|
||||
"**/_mono_repo/**": true,
|
||||
"**/electron/**": true,
|
||||
"**/.git/objects/**": true,
|
||||
"**/.git/subtree-cache/**": true,
|
||||
"**/node_modules/**": true,
|
||||
"**/.next/**": true,
|
||||
"**/coverage/**": true,
|
||||
"**/_tasks/**": true,
|
||||
"**/.git/objects/**": true,
|
||||
"**/.build/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/build/**": true,
|
||||
"**/out/**": true,
|
||||
"**/coverage/**": true,
|
||||
"**/.coverage/**": true,
|
||||
"**/.nyc_output/**": true,
|
||||
"**/.cache/**": true,
|
||||
"**/.turbo/**": true,
|
||||
"**/.swc/**": true,
|
||||
"**/.stryker-tmp/**": true,
|
||||
"**/stryker-output-*/**": true,
|
||||
"**/playwright-report/**": true,
|
||||
"**/test-results/**": true,
|
||||
"**/.worktrees/**": true,
|
||||
"**/.claude/worktrees/**": true,
|
||||
"**/electron/**": true,
|
||||
"**/_references/**": true,
|
||||
"**/_mono_repo/**": true,
|
||||
"**/_tasks/**": true,
|
||||
"**/logs/**": true,
|
||||
"**/*.tgz": true,
|
||||
"**/*.tsbuildinfo": true,
|
||||
"**/OmniRoute-*/**": true,
|
||||
"**/*-merge-*/**": true,
|
||||
"**/*-worktree-*/**": true,
|
||||
@@ -50,16 +99,32 @@
|
||||
"**/*-reorg*/**": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/_references": true,
|
||||
"**/_mono_repo": true,
|
||||
"**/electron": true,
|
||||
"**/node_modules": true,
|
||||
"**/.next": true,
|
||||
"**/coverage": true,
|
||||
"**/_tasks": true,
|
||||
"**/.build": true,
|
||||
"**/dist": true,
|
||||
"**/build": true,
|
||||
"**/out": true,
|
||||
"**/coverage": true,
|
||||
"**/.coverage": true,
|
||||
"**/.nyc_output": true,
|
||||
"**/.cache": true,
|
||||
"**/.turbo": true,
|
||||
"**/.swc": true,
|
||||
"**/.stryker-tmp": true,
|
||||
"**/stryker-output-*": true,
|
||||
"**/playwright-report": true,
|
||||
"**/test-results": true,
|
||||
"**/.worktrees": true,
|
||||
"**/.claude/worktrees": true,
|
||||
"**/electron": true,
|
||||
"**/_references": true,
|
||||
"**/_mono_repo": true,
|
||||
"**/_tasks": true,
|
||||
"**/logs": true,
|
||||
"**/*.tgz": true,
|
||||
"**/*.tsbuildinfo": true,
|
||||
"**/package-lock.json": true,
|
||||
"**/OmniRoute-*": true,
|
||||
"**/*-merge-*": true,
|
||||
"**/*-worktree-*": true,
|
||||
|
||||
104
.zizmor.yml
Normal file
104
.zizmor.yml
Normal file
@@ -0,0 +1,104 @@
|
||||
# .zizmor.yml — zizmor security audit configuration
|
||||
# https://github.com/woodruffw/zizmor
|
||||
#
|
||||
# zizmor audits GitHub Actions workflows for security anti-patterns:
|
||||
# • Unpinned third-party actions (supply-chain risk)
|
||||
# • Script injection via ${{ github.* }} in run: steps
|
||||
# • pull_request_target misuse (allows untrusted code to reach secrets)
|
||||
# • Excessive permissions / overly broad GITHUB_TOKEN scopes
|
||||
# • Cache poisoning via actions/cache with unguarded keys
|
||||
# • … 20+ additional audits
|
||||
#
|
||||
# MOTIVATION (2026-03 incident): the trivy-action/LiteLLM supply-chain attack
|
||||
# exploited exactly the kind of pull_request_target misconfiguration that zizmor
|
||||
# detects statically. OmniRoute's npm/Docker/Electron publish workflows are high-
|
||||
# value targets that warrant proactive audit.
|
||||
#
|
||||
# BASELINE STRATEGY: this file starts with an EMPTY ignores list. As zizmor is
|
||||
# first run against the existing workflows, findings will be reviewed:
|
||||
# • True positives → fix the workflow (preferred)
|
||||
# • Accepted risk → add an entry below with a justification comment
|
||||
# This enforces the same stale-enforcement discipline as other allowlists in
|
||||
# this project: every ignore requires human sign-off and is subject to review
|
||||
# at each release cycle.
|
||||
#
|
||||
# RATCHET: zizmorFindings metric in quality-baseline.json (direction: down)
|
||||
# starts advisory; current baseline is frozen at first green run; new findings
|
||||
# must be remediated or explicitly ignored here.
|
||||
|
||||
# ── Global settings ──────────────────────────────────────────────────────────
|
||||
# Uncomment to pin to a specific minimum severity level.
|
||||
# min-severity: low # low | medium | high | critical (default: low = all)
|
||||
|
||||
# ── Per-rule ignores ─────────────────────────────────────────────────────────
|
||||
# zizmor ≥1.0 replaced the old top-level `ignores:` list with a `rules:` map
|
||||
# keyed by audit id. Per-rule config lives under `rules.<audit-id>.ignore`,
|
||||
# where each entry is `filename` or `filename:line` (line optional, column
|
||||
# further-optional). See: https://docs.zizmor.sh/configuration/
|
||||
#
|
||||
# Format:
|
||||
# rules:
|
||||
# <zizmor-audit-id>: # e.g. "unpinned-uses", "template-injection"
|
||||
# ignore:
|
||||
# - <workflow-filename> # ignore this audit across the file
|
||||
# - <workflow-filename>:<line> # or scope to a specific line
|
||||
#
|
||||
# Example (do not uncomment without real justification):
|
||||
#
|
||||
# rules:
|
||||
# unpinned-uses:
|
||||
# # actions/checkout@v6 is pinned at the major-version tag intentionally:
|
||||
# # GitHub-managed first-party action; SHA pinning buys little against a
|
||||
# # GitHub-side compromise and adds significant maintenance burden.
|
||||
# ignore:
|
||||
# - ci.yml
|
||||
|
||||
# Every entry below is an explicit, justified ignore (same stale-review
|
||||
# discipline as the other allowlists in this project). New ignores require a
|
||||
# per-rule entry with a justification comment.
|
||||
rules:
|
||||
dangerous-triggers:
|
||||
# deploy-vps.yml uses `on: workflow_run` (after "Publish to Docker Hub").
|
||||
# zizmor flags workflow_run as "almost always used insecurely", but this one
|
||||
# is guarded and not exploitable:
|
||||
# • The deploy job is gated on `github.event.workflow_run.conclusion ==
|
||||
# 'success'` (deploy-vps.yml job `if:`, L15), so it only runs after a
|
||||
# successful, trusted upstream run — never on a forked/PR-triggered
|
||||
# failure.
|
||||
# • It does NOT checkout or execute untrusted code: the deploy runs over SSH
|
||||
# using repository secrets; there is no `actions/checkout` of an attacker
|
||||
# ref in the privileged context.
|
||||
# Accepted risk, re-review at the next release cycle (added 2026-06-15).
|
||||
ignore:
|
||||
- deploy-vps.yml
|
||||
|
||||
cache-poisoning:
|
||||
# zizmor's cache-poisoning audit flags `actions/setup-node` (cache: npm) and
|
||||
# `actions/cache` steps that populate a cache which a later artifact-publishing
|
||||
# job could consume — the attack is: a fork PR seeds a poisoned cache that a
|
||||
# trusted publish run then restores and ships. None of the four entries below
|
||||
# are exploitable in that way:
|
||||
#
|
||||
# • electron-release.yml / npm-publish.yml — PUBLISH workflows that trigger
|
||||
# ONLY on trusted events: `push: tags v*` + `workflow_dispatch` (electron)
|
||||
# and `release: [released]` + `workflow_dispatch` + `workflow_call`
|
||||
# (npm). They NEVER run on a `pull_request` from a fork, so an untrusted
|
||||
# ref can never populate the npm/node_modules cache they restore. The
|
||||
# cache key is `hashFiles('package-lock.json')` / `runner.os-node-…`, a
|
||||
# first-party, lockfile-pinned key on trusted events.
|
||||
#
|
||||
# • opencode-plugin-ci.yml / opencode-provider-ci.yml — CI-only workflows
|
||||
# (lint/test of the two opencode packages). They DO run on `pull_request`,
|
||||
# but (a) they publish NO artifacts — there is no downstream publish job
|
||||
# that restores their cache, and (b) GitHub Actions cache is branch-scoped:
|
||||
# a fork-PR run writes to a PR-isolated cache that base-branch / release
|
||||
# runs cannot read. The `cache: npm` here only speeds up `npm ci` within
|
||||
# the same ephemeral CI run.
|
||||
#
|
||||
# Accepted risk (first-party caching on trusted/isolated events), re-review at
|
||||
# the next release cycle (added 2026-06-16).
|
||||
ignore:
|
||||
- electron-release.yml
|
||||
- npm-publish.yml
|
||||
- opencode-plugin-ci.yml
|
||||
- opencode-provider-ci.yml
|
||||
698
@omniroute/opencode-plugin/package-lock.json
generated
698
@omniroute/opencode-plugin/package-lock.json
generated
@@ -26,9 +26,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -43,9 +43,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -60,9 +60,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -77,9 +77,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -94,9 +94,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -111,9 +111,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -128,9 +128,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -145,9 +145,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -162,9 +162,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -179,9 +179,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -196,9 +196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -213,9 +213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -230,9 +230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -247,9 +247,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -264,9 +264,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -281,9 +281,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -298,9 +298,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -315,9 +315,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -332,9 +332,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -349,9 +349,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -366,9 +366,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -383,9 +383,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -400,9 +400,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -417,9 +417,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -434,9 +434,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -451,9 +451,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1165,9 +1165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1178,32 +1178,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
@@ -1843,490 +1843,6 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -3,19 +3,16 @@
|
||||
"version": "0.1.0",
|
||||
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./runtime": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -69,5 +66,8 @@
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,12 +13,12 @@ import { createOmniRouteAuthHook } from "../src/index.js";
|
||||
|
||||
test("createOmniRouteAuthHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.equal(hook.provider, "omniroute");
|
||||
assert.equal(hook.provider, "opencode-omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: custom providerId binds to hook.provider (multi-instance)", () => {
|
||||
const hook = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(hook.provider, "omniroute-preprod");
|
||||
assert.equal(hook.provider, "opencode-omniroute-preprod");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: methods[0] is type 'api' with label including displayName", () => {
|
||||
@@ -30,7 +30,7 @@ test("createOmniRouteAuthHook: methods[0] is type 'api' with label including dis
|
||||
assert.equal(m.label, "OmniRoute API Key");
|
||||
|
||||
const custom = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (omniroute-preprod) API Key");
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (opencode-omniroute-preprod) API Key");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: prompts[0] uses key='apiKey' per @opencode-ai/plugin contract", () => {
|
||||
|
||||
@@ -447,14 +447,14 @@ test("models() returns combo entries merged into the map", async () => {
|
||||
|
||||
// 3 raw models + 1 combo = 4 entries
|
||||
assert.equal(Object.keys(out).length, 4);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["claude-secondary"]);
|
||||
assert.ok(out["gemini-3-flash"]);
|
||||
assert.ok(out["combo/claude-tier"]);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-secondary"]);
|
||||
assert.ok(out["opencode-omniroute/gemini-3-flash"]);
|
||||
assert.ok(out["opencode-omniroute/claude-tier"]);
|
||||
|
||||
const combo = out["combo/claude-tier"];
|
||||
assert.equal(combo.name, "Combo: Claude Tier");
|
||||
assert.equal(combo.providerID, "omniroute");
|
||||
const combo = out["opencode-omniroute/claude-tier"];
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.providerID, "opencode-omniroute");
|
||||
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
|
||||
assert.equal(combo.limit.context, 100_000);
|
||||
assert.equal(combo.capabilities.reasoning, false);
|
||||
@@ -478,11 +478,11 @@ test("models(): combo with unknown member ids degrades to all-false LCD posture"
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["combo/phantom-combo"]);
|
||||
assert.ok(out["opencode-omniroute/phantom-combo"]);
|
||||
// With zero resolvable members, LCD = all-false (defensive posture).
|
||||
assert.equal(out["combo/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["combo/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["combo/phantom-combo"].limit.context, 0);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].limit.context, 0);
|
||||
});
|
||||
|
||||
test("models(): hidden combos are excluded from the map", async () => {
|
||||
@@ -505,11 +505,11 @@ test("models(): hidden combos are excluded from the map", async () => {
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["combo/visible"]);
|
||||
assert.ok(!out["combo/hidden"], "hidden combo must be omitted");
|
||||
assert.ok(out["opencode-omniroute/visible"]);
|
||||
assert.ok(!out["opencode-omniroute/hidden"], "hidden combo must be omitted");
|
||||
});
|
||||
|
||||
test("models(): combo name exactly matches raw model id → raw deleted, combo lives at combo/ key, no warn", async () => {
|
||||
test("models(): combo name exactly matches raw model id → raw deleted, raw deleted, no warn", async () => {
|
||||
// Combo.name === raw model id triggers the dedup deletion. This mirrors
|
||||
// the real OmniRoute payload where /v1/models pre-mirrors combos as
|
||||
// no-slash raw entries whose ids match /api/combos friendly names.
|
||||
@@ -529,10 +529,9 @@ test("models(): combo name exactly matches raw model id → raw deleted, combo l
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
|
||||
// Raw model deleted by combo-name dedup; combo surfaces under combo/<slug>.
|
||||
assert.equal(out["claude-primary"], undefined, "raw deleted by combo-name dedup");
|
||||
assert.ok(out["combo/claude-primary"], "combo surfaces under combo/ namespace");
|
||||
assert.equal(out["combo/claude-primary"].name, "Combo: claude-primary");
|
||||
// Raw model replaced by combo of the same key; combo now lives at the bare slug.
|
||||
assert.ok(out["opencode-omniroute/claude-primary"], "combo surfaces under prefixed key");
|
||||
assert.equal(out["opencode-omniroute/claude-primary"].name, "claude-primary");
|
||||
|
||||
// No collision warning fires — dedup makes keys disjoint.
|
||||
const collisionWarns = warnings.filter((w) => {
|
||||
@@ -543,7 +542,7 @@ test("models(): combo name exactly matches raw model id → raw deleted, combo l
|
||||
});
|
||||
|
||||
test("models(): two combos with same slug → second gets disambiguator suffix", async () => {
|
||||
// Both combos slug to `claude` — second must get `combo/claude-<id-prefix>`.
|
||||
// Both combos slug to `claude` — second must get `claude-<id-prefix>`.
|
||||
const combos: OmniRouteRawCombo[] = [
|
||||
{
|
||||
id: "uuid-a",
|
||||
@@ -566,8 +565,8 @@ test("models(): two combos with same slug → second gets disambiguator suffix",
|
||||
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
// First combo gets the bare slug; second gets disambiguated.
|
||||
assert.ok(out["combo/claude"], "first combo at bare slug");
|
||||
assert.ok(out["combo/claude-uuid"], "second combo disambiguated by id prefix");
|
||||
assert.ok(out["opencode-omniroute/claude"], "first combo at prefixed slug");
|
||||
assert.ok(out["opencode-omniroute/claude-uuid"], "second combo disambiguated by id prefix");
|
||||
});
|
||||
|
||||
test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => {
|
||||
@@ -584,8 +583,8 @@ test("models(): combos fetch fails → falls back to models-only, warn emitted,
|
||||
|
||||
// Catalog includes the models but NOT any combo entries.
|
||||
assert.equal(Object.keys(out).length, 2);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["claude-secondary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-secondary"]);
|
||||
|
||||
// Soft-fail warning surfaced.
|
||||
const softFail = warnings.find((w) => {
|
||||
@@ -610,7 +609,7 @@ test("models(): combos cached + reused within TTL (one combo fetch per TTL windo
|
||||
const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL");
|
||||
assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL");
|
||||
assert.ok(second["combo/claude-tier"]);
|
||||
assert.ok(second["opencode-omniroute/claude-tier"]);
|
||||
});
|
||||
|
||||
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
|
||||
@@ -639,3 +638,74 @@ test("models(): combos fetcher receives the resolved baseURL + apiKey", async ()
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-spy") as never });
|
||||
assert.deepEqual(combosFetcher.callsBy()[0], ["https://or.example.com/v1", "sk-spy"]);
|
||||
});
|
||||
|
||||
test("models(): nested combo-ref context is the min of nested + raw members", async () => {
|
||||
// Top-level combo MASTER-LIGHT has 1 raw model (claude-primary, 200k)
|
||||
// and 2 combo-refs: OldLLM (8k member) and KIRO (32k member). The OLD
|
||||
// plugin would advertise 200k (only the raw model); the fix should
|
||||
// make it advertise 8k (the bottleneck across the member graph).
|
||||
const modelsFetcher = stubModelsFetcher([
|
||||
MODEL_PRIMARY,
|
||||
{
|
||||
id: "oldllm-member-1",
|
||||
context_length: 8_000,
|
||||
max_output_tokens: 4_000,
|
||||
capabilities: {
|
||||
tool_calling: false,
|
||||
reasoning: false,
|
||||
vision: false,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "kiro-member-1",
|
||||
context_length: 32_000,
|
||||
max_output_tokens: 8_000,
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: false,
|
||||
vision: false,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
]);
|
||||
const combosFetcher = stubCombosFetcher([
|
||||
{
|
||||
id: "oldllm",
|
||||
name: "OldLLM",
|
||||
models: [{ id: "s1", kind: "model", model: "oldllm-member-1", weight: 100 }],
|
||||
},
|
||||
{
|
||||
id: "kiro",
|
||||
name: "KIRO",
|
||||
models: [{ id: "s1", kind: "model", model: "kiro-member-1", weight: 100 }],
|
||||
},
|
||||
{
|
||||
id: "master-light",
|
||||
name: "MASTER-LIGHT",
|
||||
models: [
|
||||
{ id: "r1", kind: "model", model: "claude-primary", weight: 50 },
|
||||
{ id: "r2", kind: "combo-ref", comboName: "OldLLM", weight: 25 },
|
||||
{ id: "r3", kind: "combo-ref", comboName: "KIRO", weight: 25 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
const masterLight = out["opencode-omniroute/master-light"];
|
||||
assert.ok(masterLight, "MASTER-LIGHT entry must exist");
|
||||
assert.equal(
|
||||
masterLight.limit.context,
|
||||
8_000,
|
||||
`expected 8_000 (OldLLM bottleneck), got ${masterLight.limit.context}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -203,7 +203,7 @@ function makeInput(initialProvider: Record<string, unknown> = {}): Config {
|
||||
|
||||
test("config: with valid auth.json + apiKey + baseURL → mutates input.provider[id] with stripped models block", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -217,8 +217,8 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider.omniroute;
|
||||
assert.ok(entry, "input.provider.omniroute set");
|
||||
const entry = provider["opencode-omniroute"];
|
||||
assert.ok(entry, "input.provider['opencode-omniroute'] set");
|
||||
assert.equal(entry.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(entry.name, "OmniRoute");
|
||||
assert.equal(entry.options.baseURL, "https://or.example.com/v1");
|
||||
@@ -227,7 +227,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
// Stripped per-model shape: name + cap flags + modalities + (optional)
|
||||
// cost. OC's SDK static schema accepts only `limit.{context,output}` —
|
||||
// `limit.input` is NOT in the SDK shape and gets dropped silently.
|
||||
const claude = entry.models["claude-sonnet-4-6"];
|
||||
const claude = entry.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.ok(claude, "claude model surfaced");
|
||||
assert.equal(claude.name, "claude-sonnet-4-6");
|
||||
assert.equal(claude.attachment, true);
|
||||
@@ -246,16 +246,75 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
assert.deepEqual(claude.modalities?.input, ["text", "image"]);
|
||||
assert.deepEqual(claude.modalities?.output, ["text"]);
|
||||
|
||||
// Combo surfaces under `combo/<friendly-name>` namespace + LCD'd
|
||||
// Combo surfaces under bare key + LCD'd
|
||||
// (gemini's reasoning=false → combo reasoning=false).
|
||||
const combo = entry.models["combo/claude-tier"];
|
||||
assert.ok(combo, "combo surfaced under combo/ namespace");
|
||||
assert.equal(combo.name, "Combo: Claude Tier");
|
||||
const combo = entry.models["opencode-omniroute/claude-tier"];
|
||||
assert.ok(combo, "combo surfaced under bare key");
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false");
|
||||
assert.equal(combo.tool_call, true);
|
||||
assert.equal(combo.limit?.context, 200_000, "LCD: min(200_000, 1_000_000)");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 1b. Dual-key fallback (#5027) — auth.json stored under the BARE providerId
|
||||
// (pre-auto-prefix login) must still resolve when the active providerId is
|
||||
// prefixed (`opencode-omniroute`). Without the fallback the lookup misses
|
||||
// the stored key and the user is forced to re-auth.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: auth.json under bare key (pre-prefix login) resolves via dual-key fallback", async () => {
|
||||
// Stored under bare `omniroute` (the key OC wrote before the auto-prefix fix),
|
||||
// but the resolved providerId is now `opencode-omniroute`.
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-bare-1", baseURL: "https://or.example.com/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" }, // resolves to opencode-omniroute internally
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider["opencode-omniroute"];
|
||||
assert.ok(entry, "provider entry published from bare-key apiKey");
|
||||
assert.equal(entry.options.apiKey, "sk-bare-1", "apiKey resolved from the bare auth.json key");
|
||||
assert.equal(entry.options.baseURL, "https://or.example.com/v1");
|
||||
});
|
||||
|
||||
test("config: prefixed key wins over bare key when both present (dual-key precedence)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-prefixed", baseURL: "https://pref.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-bare", baseURL: "https://bare.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(
|
||||
entry.options.apiKey,
|
||||
"sk-prefixed",
|
||||
"prefixed key takes precedence (looked up first)"
|
||||
);
|
||||
assert.equal(entry.options.baseURL, "https://pref.example/v1");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Missing auth.json → no-op, no throw, no mutation
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -323,7 +382,7 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
models: { "manual-model": { name: "manual-model" } },
|
||||
};
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -333,11 +392,11 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput({ omniroute: manual });
|
||||
const input = makeInput({ "opencode-omniroute": manual });
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, unknown> }).provider;
|
||||
assert.equal(provider.omniroute, manual, "manual override preserved by reference");
|
||||
assert.equal(provider["opencode-omniroute"], manual, "manual override preserved by reference");
|
||||
assert.equal(fetcher.callCount(), 0, "no fetch — short-circuited before I/O");
|
||||
assert.equal(readAuthJson.callCount(), 0, "no auth.json read either");
|
||||
assert.ok(
|
||||
@@ -352,7 +411,7 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
|
||||
test("config: fetchers throw → warn + emit stub entry with models: {}", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = throwingCombosFetcher();
|
||||
@@ -368,8 +427,9 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "stub provider entry published even when fetchers fail");
|
||||
assert.equal(entry.npm, "@ai-sdk/openai-compatible");
|
||||
assert.deepEqual(entry.models, {}, "models stub is empty object");
|
||||
@@ -392,7 +452,7 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
|
||||
|
||||
test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = throwingCombosFetcher();
|
||||
@@ -405,12 +465,16 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
const ids = Object.keys(entry.models).sort();
|
||||
assert.deepEqual(ids, ["claude-sonnet-4-6", "gemini-3-flash"]);
|
||||
assert.equal(entry.models["combo-claude-tier"], undefined, "no combo entry");
|
||||
assert.deepEqual(ids, [
|
||||
"opencode-omniroute/claude-sonnet-4-6",
|
||||
"opencode-omniroute/gemini-3-flash",
|
||||
]);
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"], undefined, "no combo entry");
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"combos-fetch breadcrumb emitted"
|
||||
@@ -423,7 +487,7 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
|
||||
test("config: baseURL from auth.json takes precedence when opts.baseURL absent", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -437,14 +501,15 @@ test("config: baseURL from auth.json takes precedence when opts.baseURL absent",
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://creds.example/v1");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.options.baseURL, "https://creds.example/v1");
|
||||
});
|
||||
|
||||
test("config: opts.baseURL wins over auth.json's stored baseURL", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -458,14 +523,15 @@ test("config: opts.baseURL wins over auth.json's stored baseURL", async () => {
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://opts.example/v1");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.options.baseURL, "https://opts.example/v1");
|
||||
});
|
||||
|
||||
test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test" }, // NO baseURL on the credential
|
||||
"opencode-omniroute": { type: "api", key: "sk-test" }, // NO baseURL on the credential
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -493,12 +559,12 @@ test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op",
|
||||
|
||||
test("config: multi-instance — two plugins with different providerIds publish to their own keys without collision", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"omniroute-prod": {
|
||||
"opencode-omniroute-prod": {
|
||||
type: "api",
|
||||
key: "sk-prod",
|
||||
baseURL: "https://prod.example/v1",
|
||||
},
|
||||
"omniroute-preprod": {
|
||||
"opencode-omniroute-preprod": {
|
||||
type: "api",
|
||||
key: "sk-preprod",
|
||||
baseURL: "https://preprod.example/v1",
|
||||
@@ -522,15 +588,18 @@ test("config: multi-instance — two plugins with different providerIds publish
|
||||
await hookB(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
assert.ok(provider["omniroute-prod"], "prod block present");
|
||||
assert.ok(provider["omniroute-preprod"], "preprod block present");
|
||||
assert.equal(provider["omniroute-prod"].options.apiKey, "sk-prod");
|
||||
assert.equal(provider["omniroute-preprod"].options.apiKey, "sk-preprod");
|
||||
assert.equal(provider["omniroute-prod"].options.baseURL, "https://prod.example/v1");
|
||||
assert.equal(provider["omniroute-preprod"].options.baseURL, "https://preprod.example/v1");
|
||||
assert.ok(provider["opencode-omniroute-prod"], "prod block present");
|
||||
assert.ok(provider["opencode-omniroute-preprod"], "preprod block present");
|
||||
assert.equal(provider["opencode-omniroute-prod"].options.apiKey, "sk-prod");
|
||||
assert.equal(provider["opencode-omniroute-preprod"].options.apiKey, "sk-preprod");
|
||||
assert.equal(provider["opencode-omniroute-prod"].options.baseURL, "https://prod.example/v1");
|
||||
assert.equal(
|
||||
provider["opencode-omniroute-preprod"].options.baseURL,
|
||||
"https://preprod.example/v1"
|
||||
);
|
||||
assert.notEqual(
|
||||
provider["omniroute-prod"],
|
||||
provider["omniroute-preprod"],
|
||||
provider["opencode-omniroute-prod"],
|
||||
provider["opencode-omniroute-preprod"],
|
||||
"blocks are distinct references"
|
||||
);
|
||||
});
|
||||
@@ -542,7 +611,7 @@ test("config: multi-instance — two plugins with different providerIds publish
|
||||
|
||||
test("config + provider share cache: second call uses cached fetch result (single fetch per TTL)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -574,7 +643,7 @@ test("config + provider share cache: second call uses cached fetch result (singl
|
||||
|
||||
test("provider → config order also dedupes (cache populated by provider, consumed by config)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -638,6 +707,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro
|
||||
"cost",
|
||||
"limit",
|
||||
"modalities",
|
||||
"providerID",
|
||||
]);
|
||||
for (const [id, entry] of Object.entries(block.models)) {
|
||||
for (const key of Object.keys(entry)) {
|
||||
@@ -653,7 +723,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro
|
||||
}
|
||||
|
||||
// Sanity: claude entry has all expected stripped fields.
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(typeof claude.name, "string");
|
||||
assert.equal(typeof claude.attachment, "boolean");
|
||||
assert.equal(typeof claude.reasoning, "boolean");
|
||||
@@ -678,8 +748,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["combo-claude-tier"], undefined);
|
||||
assert.ok(block.models["claude-sonnet-4-6"]);
|
||||
assert.equal(block.models["opencode-omniroute/claude-tier"], undefined);
|
||||
assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -695,7 +765,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.deepEqual(claude.modalities?.input, ["text", "image"]);
|
||||
assert.deepEqual(claude.modalities?.output, ["text"]);
|
||||
});
|
||||
@@ -709,7 +779,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", ()
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal((claude.limit as Record<string, unknown>).input, undefined);
|
||||
assert.equal(typeof claude.limit?.context, "number");
|
||||
assert.equal(typeof claude.limit?.output, "number");
|
||||
@@ -737,7 +807,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", ()
|
||||
"sk-test",
|
||||
enrichment
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(claude.cost?.input, 3);
|
||||
assert.equal(claude.cost?.output, 15);
|
||||
assert.equal(claude.cost?.cache_read, 0.3);
|
||||
@@ -758,8 +828,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["gemini-3-flash"].release_date, undefined);
|
||||
assert.equal(block.models["opencode-omniroute/claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["opencode-omniroute/gemini-3-flash"].release_date, undefined);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => {
|
||||
@@ -788,7 +858,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const combo = block.models["combo/mixed-tier"];
|
||||
const combo = block.models["opencode-omniroute/mixed-tier"];
|
||||
assert.ok(combo, "combo emitted under slug key");
|
||||
// claude has text+image, text-only has text → intersection drops image.
|
||||
assert.deepEqual(combo.modalities?.input, ["text"]);
|
||||
@@ -812,7 +882,7 @@ test("OmniRoutePlugin factory exposes config hook alongside auth + provider", as
|
||||
|
||||
test("config: auth.json entry of wrong type (oauth) → no-op", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "oauth", refresh: "r", access: "a", expires: 0 },
|
||||
"opencode-omniroute": { type: "oauth", refresh: "r", access: "a", expires: 0 },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -849,7 +919,7 @@ test("config: readAuthJson throws → treat as missing file (silent fallback)",
|
||||
|
||||
test("config: initialises input.provider when undefined", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -864,7 +934,7 @@ test("config: initialises input.provider when undefined", async () => {
|
||||
await hook(input);
|
||||
const provider = (input as { provider?: Record<string, unknown> }).provider;
|
||||
assert.ok(provider, "provider bag initialised");
|
||||
assert.ok(provider!.omniroute);
|
||||
assert.ok(provider!["opencode-omniroute"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -874,7 +944,7 @@ test("config: initialises input.provider when undefined", async () => {
|
||||
|
||||
test("config: enrichment fetched + name overlaid on raw-model entries", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -893,19 +963,20 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
// Combo names still come from /api/combos — enrichment overlay does NOT touch combos.
|
||||
assert.equal(entry.models["combo/claude-tier"].name, "Combo: Claude Tier");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(enrichmentFetcher.callCount(), 1);
|
||||
});
|
||||
|
||||
test("config: features.enrichment=false skips enrichment fetch + keeps raw-id names", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -923,16 +994,21 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag");
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
});
|
||||
|
||||
test("config: enrichment fetcher throws → soft-fail (warn + raw-id static catalog)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -946,10 +1022,15 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "static block still published on enrichment failure");
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
assert.equal(enrichmentFetcher.callCount(), 1);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")),
|
||||
@@ -990,7 +1071,7 @@ const MODEL_NV_LLAMA: OmniRouteRawModelEntry = {
|
||||
|
||||
test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CC_OPUS, MODEL_NV_LLAMA]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1012,8 +1093,9 @@ test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"], "claude kept");
|
||||
assert.ok(entry.models["nvidia/llama-3-70b"], "nvidia kept (filter off)");
|
||||
assert.equal(providersFetcher.callCount(), 0, "providers fetch not called when feature off");
|
||||
@@ -1021,7 +1103,7 @@ test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
|
||||
test("config: usableOnly=true → drops models for non-usable providers, keeps usable + unknown", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([
|
||||
MODEL_CC_OPUS,
|
||||
@@ -1061,8 +1143,9 @@ test("config: usableOnly=true → drops models for non-usable providers, keeps u
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"], "claude kept (active)");
|
||||
assert.equal(entry.models["nvidia/llama-3-70b"], undefined, "nvidia dropped (error status)");
|
||||
assert.ok(entry.models["agentrouter/synthetic-1"], "unknown prefix kept (subtract-filter)");
|
||||
@@ -1071,7 +1154,7 @@ test("config: usableOnly=true → drops models for non-usable providers, keeps u
|
||||
|
||||
test("config: usableOnly=true + providers fetch fails → soft-fail keeps everything", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CC_OPUS, MODEL_NV_LLAMA]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1092,8 +1175,9 @@ test("config: usableOnly=true + providers fetch fails → soft-fail keeps everyt
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"]);
|
||||
assert.ok(entry.models["nvidia/llama-3-70b"], "soft-fail keeps both");
|
||||
assert.ok(
|
||||
@@ -1104,7 +1188,7 @@ test("config: usableOnly=true + providers fetch fails → soft-fail keeps everyt
|
||||
|
||||
test("config: diskCache hydrates stale snapshot when /v1/models throws", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1141,11 +1225,15 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry.models["claude-sonnet-4-6"], "stale snapshot hydrated into static block");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"stale snapshot hydrated into static block"
|
||||
);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6 (cached)",
|
||||
"stale enrichment also reused"
|
||||
);
|
||||
@@ -1158,7 +1246,7 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
|
||||
test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1190,9 +1278,10 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
await configHook(input);
|
||||
assert.equal(enrichmentFetcher.callCount(), 1, "config reused cached enrichment");
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1203,7 +1292,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
|
||||
test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-model names", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -1238,18 +1327,25 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini-cli - Gemini 3 Flash");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/gemini-3-flash"].name,
|
||||
"Gemini-cli - Gemini 3 Flash"
|
||||
);
|
||||
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
|
||||
assert.equal(entry.models["combo/claude-tier"].name, "Combo: Claude Tier");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
|
||||
});
|
||||
|
||||
test("config: providerTag=false suppresses the suffix", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1267,10 +1363,11 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6",
|
||||
"enriched name kept, provider tag suppressed"
|
||||
);
|
||||
@@ -1278,7 +1375,7 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
|
||||
test("config: providerTag falls back to UPPER(alias) when providerDisplayName missing", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1299,14 +1396,15 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1325,14 +1423,15 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1351,14 +1450,76 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
|
||||
const inputA = makeInput();
|
||||
await hook(inputA);
|
||||
const entryA = (inputA as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entryA.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
const entryA = (inputA as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryA.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
|
||||
// Second invocation (cache hit) — name must still be single-suffixed.
|
||||
const inputB = makeInput();
|
||||
await hook(inputB);
|
||||
const entryB = (inputB as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entryB.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
const entryB = (inputB as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryB.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// T-NN: nested combo-ref resolution in the static catalog
|
||||
// (mirrors the dynamic-catalog fix in combos.test.ts)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("buildStaticProviderEntry: nested combo-ref context is the bottleneck across the graph", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
|
||||
const rawModels: OmniRouteRawModelEntry[] = [
|
||||
{
|
||||
id: "raw-big",
|
||||
context_length: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
capabilities: { tool_calling: true, reasoning: true, vision: false, temperature: true },
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "raw-tiny",
|
||||
context_length: 8_000,
|
||||
max_output_tokens: 4_000,
|
||||
capabilities: { tool_calling: false, reasoning: false, vision: false, temperature: true },
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
];
|
||||
const rawCombos: OmniRouteRawCombo[] = [
|
||||
{
|
||||
id: "tiny-combo",
|
||||
name: "TinyCombo",
|
||||
models: [{ id: "m1", kind: "model", model: "raw-tiny", weight: 100 }],
|
||||
},
|
||||
{
|
||||
id: "parent",
|
||||
name: "Parent",
|
||||
models: [
|
||||
{ id: "p1", kind: "model", model: "raw-big", weight: 50 },
|
||||
{ id: "p2", kind: "combo-ref", comboName: "TinyCombo", weight: 50 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const block = buildStaticProviderEntry(
|
||||
rawModels,
|
||||
rawCombos,
|
||||
resolved,
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
// Pre-fix: Parent would advertise 200_000 (only raw-big counted).
|
||||
// Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck).
|
||||
const parent = block.models["opencode-omniroute/parent"];
|
||||
assert.ok(parent, "Parent combo must be in the static catalog");
|
||||
assert.equal(parent.limit?.context, 8_000);
|
||||
});
|
||||
|
||||
@@ -376,7 +376,7 @@ test("provider hook: enrichment fetcher called when features.enrichment !== fals
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "enrichment fetcher called once");
|
||||
const m = out["claude-sonnet-4-6"];
|
||||
const m = out["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied");
|
||||
assert.equal(m.cost.input, 3, "enrichment pricing applied");
|
||||
assert.equal(m.cost.output, 15);
|
||||
@@ -401,7 +401,11 @@ test("provider hook: enrichment fetcher NOT called when features.enrichment:fals
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 0, "enrichment fetcher NOT called when gated off");
|
||||
assert.equal(out["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id preserved");
|
||||
assert.equal(
|
||||
out["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id preserved"
|
||||
);
|
||||
});
|
||||
|
||||
test("provider hook: compression metadata fetcher NOT called by default (opt-in)", async () => {
|
||||
@@ -459,7 +463,7 @@ test("provider hook: compression metadata fetcher called when opted in", async (
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "compression metadata fetcher called");
|
||||
const combo = out["combo/claude-primary"];
|
||||
const combo = out["opencode-omniroute/claude-primary"];
|
||||
assert.ok(combo, "combo entry present");
|
||||
assert.match(
|
||||
combo.name,
|
||||
@@ -473,7 +477,7 @@ test("provider hook: compression metadata fetcher called when opted in", async (
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const stubAuthJson = (apiKey: string) => async () => ({
|
||||
omniroute: { type: "api" as const, key: apiKey },
|
||||
"opencode-omniroute": { type: "api" as const, key: apiKey },
|
||||
});
|
||||
|
||||
test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => {
|
||||
@@ -488,7 +492,7 @@ test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => {
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
assert.ok(input.provider?.omniroute, "provider block written");
|
||||
assert.ok(input.provider?.["opencode-omniroute"], "provider block written");
|
||||
assert.equal(input.mcp, undefined, "no mcp block written");
|
||||
});
|
||||
|
||||
@@ -508,7 +512,7 @@ test("config hook: features.mcpAutoEmit:true writes mcp entry with provider apiK
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.omniroute as
|
||||
const entry = input.mcp?.["opencode-omniroute"] as
|
||||
| { type: string; url: string; enabled: boolean; headers: Record<string, string> }
|
||||
| undefined;
|
||||
assert.ok(entry, "mcp entry written");
|
||||
@@ -538,7 +542,7 @@ test("config hook: features.mcpToken overrides provider apiKey in mcp Bearer", a
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.omniroute as { headers: Record<string, string> };
|
||||
const entry = input.mcp?.["opencode-omniroute"] as { headers: Record<string, string> };
|
||||
assert.equal(
|
||||
entry.headers.Authorization,
|
||||
"Bearer sk-mcp-narrower",
|
||||
@@ -561,11 +565,11 @@ test("config hook: existing operator mcp.<providerId> wins (no overwrite)", asyn
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {
|
||||
mcp: { omniroute: { type: "custom-user-entry", url: "https://manual.example/mcp" } },
|
||||
mcp: { "opencode-omniroute": { type: "custom-user-entry", url: "https://manual.example/mcp" } },
|
||||
};
|
||||
await hook(input as never);
|
||||
assert.deepEqual(
|
||||
input.mcp?.omniroute,
|
||||
input.mcp?.["opencode-omniroute"],
|
||||
{ type: "custom-user-entry", url: "https://manual.example/mcp" },
|
||||
"operator override preserved"
|
||||
);
|
||||
@@ -580,7 +584,7 @@ test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips corr
|
||||
},
|
||||
{
|
||||
readAuthJson: async () => ({
|
||||
"omniroute-preprod": { type: "api" as const, key: "sk-preprod" },
|
||||
"opencode-omniroute-preprod": { type: "api" as const, key: "sk-preprod" },
|
||||
}),
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
@@ -589,7 +593,7 @@ test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips corr
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.["omniroute-preprod"] as { url: string };
|
||||
const entry = input.mcp?.["opencode-omniroute-preprod"] as { url: string };
|
||||
assert.equal(
|
||||
entry.url,
|
||||
"https://or-preprod.example.com/api/mcp/stream",
|
||||
|
||||
@@ -38,8 +38,8 @@ test("multi-instance: two plugin invocations bind to their own providerId", asyn
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
assert.equal(a.auth?.provider, "omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "omniroute-preprod");
|
||||
assert.equal(a.auth?.provider, "opencode-omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "opencode-omniroute-preprod");
|
||||
});
|
||||
|
||||
test("multi-instance: hook objects + nested arrays are independent references", async () => {
|
||||
@@ -70,8 +70,8 @@ test("multi-instance: identical opts twice still yield independent objects", asy
|
||||
assert.notEqual(first.auth, second.auth);
|
||||
assert.notEqual(first.auth?.methods, second.auth?.methods);
|
||||
// Same provider id is fine — what matters is no shared mutable state.
|
||||
assert.equal(first.auth?.provider, "twin");
|
||||
assert.equal(second.auth?.provider, "twin");
|
||||
assert.equal(first.auth?.provider, "opencode-twin");
|
||||
assert.equal(second.auth?.provider, "opencode-twin");
|
||||
});
|
||||
|
||||
test("multi-instance: mutating instance A's auth.methods does not affect instance B", async () => {
|
||||
@@ -132,5 +132,5 @@ test("multi-instance: invalid opts on one instance does not poison the other", a
|
||||
providerId: "recovered",
|
||||
baseURL: "https://ok.example/v1",
|
||||
});
|
||||
assert.equal(ok.auth?.provider, "recovered");
|
||||
assert.equal(ok.auth?.provider, "opencode-recovered");
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ const apiAuth = (key: string, baseURL?: string): unknown =>
|
||||
|
||||
test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] });
|
||||
assert.equal(hook.id, "omniroute");
|
||||
assert.equal(hook.id, "opencode-omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => {
|
||||
@@ -87,8 +87,8 @@ test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-ins
|
||||
{ providerId: "omniroute-local" },
|
||||
{ combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(a.id, "omniroute-preprod");
|
||||
assert.equal(b.id, "omniroute-local");
|
||||
assert.equal(a.id, "opencode-omniroute-preprod");
|
||||
assert.equal(b.id, "opencode-omniroute-local");
|
||||
});
|
||||
|
||||
test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => {
|
||||
@@ -101,7 +101,7 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -152,11 +152,13 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
const claude = out["claude-primary"];
|
||||
const claude = out["opencode-omniroute/claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
assert.equal(claude.id, "claude-primary");
|
||||
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
||||
// static-catalog reader resolves `(providerID, modelID)` from the key.
|
||||
assert.equal(claude.id, "opencode-omniroute/claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.providerID, "opencode-omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
assert.equal(claude.api.url, "https://or.example.com/v1");
|
||||
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
OmniRoutePlugin,
|
||||
OMNIROUTE_PROVIDER_KEY,
|
||||
@@ -27,7 +26,7 @@ test("scaffold: default export is v1 plugin shape { id, server: OmniRoutePlugin
|
||||
|
||||
test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
const r = resolveOmniRoutePluginOptions();
|
||||
assert.equal(r.providerId, "omniroute");
|
||||
assert.equal(r.providerId, "opencode-omniroute");
|
||||
assert.equal(r.displayName, "OmniRoute");
|
||||
assert.equal(r.modelCacheTtl, 300_000);
|
||||
assert.equal(r.baseURL, undefined);
|
||||
@@ -35,8 +34,8 @@ test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
|
||||
test("resolveOmniRoutePluginOptions: custom providerId derives displayName", () => {
|
||||
const r = resolveOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
|
||||
assert.equal(r.providerId, "omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (omniroute-preprod)");
|
||||
assert.equal(r.providerId, "opencode-omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (opencode-omniroute-preprod)");
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: explicit displayName wins", () => {
|
||||
@@ -63,11 +62,12 @@ test("OmniRoutePlugin: returns an empty hooks object (scaffold)", async () => {
|
||||
assert.notEqual(hooks, null);
|
||||
});
|
||||
|
||||
test("scaffold: CJS default export resolves via require() with v1 shape", () => {
|
||||
const require_ = createRequire(import.meta.url);
|
||||
const cjs = require_("../dist/index.cjs");
|
||||
// after cjsInterop:true, default export is on cjs.default
|
||||
assert.strictEqual(typeof cjs.default, "object");
|
||||
assert.strictEqual(cjs.default.id, "@omniroute/opencode-plugin");
|
||||
assert.strictEqual(typeof cjs.default.server, "function");
|
||||
test("scaffold: built ESM default export resolves with the v1 plugin shape", async () => {
|
||||
// The plugin is ESM-only now — the CJS bundle was dropped to fix the OpenCode
|
||||
// loader (#3883), so there is no more ../dist/index.cjs. Validate that the built
|
||||
// distributable's default export still carries the OpenCode v1 { id, server } shape.
|
||||
const mod = await import("../dist/index.js");
|
||||
assert.strictEqual(typeof mod.default, "object");
|
||||
assert.strictEqual(mod.default.id, "@omniroute/opencode-plugin");
|
||||
assert.strictEqual(typeof mod.default.server, "function");
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm", "cjs"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
target: "node22",
|
||||
outDir: "dist",
|
||||
minify: false,
|
||||
cjsInterop: true,
|
||||
cjsInterop: false,
|
||||
// Bundle runtime deps so the .tgz / npm install is self-contained.
|
||||
// `zod` is required at runtime by the options schema and would otherwise
|
||||
// need a peer install when the plugin is loaded directly from a file path
|
||||
|
||||
698
@omniroute/opencode-provider/package-lock.json
generated
698
@omniroute/opencode-provider/package-lock.json
generated
@@ -19,9 +19,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -36,9 +36,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -53,9 +53,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -70,9 +70,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -87,9 +87,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -104,9 +104,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -121,9 +121,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -138,9 +138,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -155,9 +155,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -172,9 +172,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -189,9 +189,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -206,9 +206,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -223,9 +223,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -240,9 +240,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -257,9 +257,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -274,9 +274,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -291,9 +291,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -308,9 +308,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -325,9 +325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -342,9 +342,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -359,9 +359,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -376,9 +376,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -393,9 +393,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -410,9 +410,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -427,9 +427,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -444,9 +444,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -974,9 +974,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -987,32 +987,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
@@ -1482,490 +1482,6 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -56,5 +56,8 @@
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
91
AGENTS.md
91
AGENTS.md
@@ -3,14 +3,14 @@
|
||||
## Project
|
||||
|
||||
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
|
||||
with **229 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
with **231 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
|
||||
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
|
||||
with **MCP Server** (69 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
with **MCP Server** (87 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
|
||||
> **Live counts (v3.8.16)**: providers 229 · MCP tools 69 · MCP scopes 13 · A2A skills 6 ·
|
||||
> open-sse services 111 · routing strategies 15 · auto-combo scoring factors 12 ·
|
||||
> DB modules 76 · DB migrations 94 · base tables 17 · search providers 12 ·
|
||||
> **Live counts (v3.8.31)**: providers 231 · MCP tools 87 · MCP scopes 30 · A2A skills 6 ·
|
||||
> open-sse services 115 · routing strategies 15 · auto-combo scoring factors 9 ·
|
||||
> DB modules 83 · DB migrations 97 · base tables 17 · search providers 11 ·
|
||||
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
|
||||
|
||||
## Doc Accuracy Discipline (read before writing any doc)
|
||||
@@ -44,8 +44,8 @@ codebase. Run it locally before pushing docs; it runs in CI via `npm run check:d
|
||||
|
||||
## Stack
|
||||
|
||||
- **Runtime**: Next.js 16 (App Router), Node.js `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25`, ES Modules (`"type": "module"`)
|
||||
- **Language**: TypeScript 5.9 (`src/`) + JavaScript (`open-sse/`, `electron/`)
|
||||
- **Runtime**: Next.js 16 (App Router), Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
|
||||
- **Language**: TypeScript 6.0 (`src/`) + JavaScript (`open-sse/`, `electron/`)
|
||||
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
|
||||
- **Streaming**: SSE via `open-sse` internal workspace package
|
||||
- **Styling**: Tailwind CSS v4
|
||||
@@ -178,7 +178,7 @@ Always run `prettier --write` on changed files.
|
||||
|
||||
### Data Layer (`src/lib/db/`)
|
||||
|
||||
All persistence uses SQLite through **76 domain-specific modules** in `src/lib/db/`. Top modules:
|
||||
All persistence uses SQLite through **83 domain-specific modules** in `src/lib/db/`. Top modules:
|
||||
|
||||
- Core: `core.ts`, `migrationRunner.ts`, `encryption.ts`, `stateReset.ts`
|
||||
- Providers / catalog: `providers.ts`, `models.ts`, `providerLimits.ts`, `compressionAnalytics.ts`
|
||||
@@ -188,8 +188,8 @@ All persistence uses SQLite through **76 domain-specific modules** in `src/lib/d
|
||||
- Storage: `backup.ts`, `cleanup.ts`, `jsonMigration.ts`, `healthCheck.ts`, `databaseSettings.ts`
|
||||
- Extension modules: `evals.ts`, `webhooks.ts`, `reasoningCache.ts`, `readCache.ts`, `tierConfig.ts`, `compressionCombos.ts`, `compressionScheduler.ts`, `batches.ts`, `files.ts`, `syncTokens.ts`, `proxies.ts`, `oneproxy.ts`, `upstreamProxy.ts`, `versionManager.ts`, `cliToolState.ts`, `prompts.ts`, `detailedLogs.ts`, `contextHandoffs.ts`, `compression.ts`, `stats.ts`
|
||||
|
||||
Live count: `ls src/lib/db/*.ts | wc -l` (currently 76). Drift detection: `npm run check:docs-counts`.
|
||||
Schema migrations live in `db/migrations/` (**94 files** as of v3.8.16) and run via `migrationRunner.ts`.
|
||||
Live count: `ls src/lib/db/*.ts | wc -l` (currently 83). Drift detection: `npm run check:docs-counts`.
|
||||
Schema migrations live in `db/migrations/` (**97 files** as of v3.8.24) and run via `migrationRunner.ts`.
|
||||
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
|
||||
|
||||
#### DB Internals
|
||||
@@ -198,7 +198,7 @@ Schema migrations live in `db/migrations/` (**94 files** as of v3.8.16) and run
|
||||
journaling. `SCHEMA_SQL` defines **17 base tables** (verify with `grep -c "CREATE TABLE" src/lib/db/core.ts` minus 1 for the bookkeeping `_omniroute_migrations` table). Helpers: `rowToCamel`, `encryptConnectionFields`.
|
||||
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
|
||||
Tracks applied migrations in `_omniroute_migrations` table.
|
||||
- **Migrations**: 94 files (`001_initial_schema.sql` → `094_*.sql`).
|
||||
- **Migrations**: 97 files (`001_initial_schema.sql` → `099_*.sql`).
|
||||
Each migration is idempotent and runs in a transaction. Live count: `ls src/lib/db/migrations/*.sql | wc -l`.
|
||||
- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations.
|
||||
Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`,
|
||||
@@ -336,7 +336,7 @@ Includes request/response translators with helpers for image handling.
|
||||
|
||||
### Services (`open-sse/services/`)
|
||||
|
||||
111 service modules in `open-sse/services/` (top-level only; 171 including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
|
||||
115 service modules in `open-sse/services/` (top-level only; 184 including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
|
||||
`combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
|
||||
`rateLimitManager.ts`, `accountFallback.ts`, `sessionManager.ts`, `wildcardRouter.ts`,
|
||||
`autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`,
|
||||
@@ -391,7 +391,7 @@ Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
|
||||
|
||||
### MCP Server (`open-sse/mcp-server/`)
|
||||
|
||||
69 tools across 10 tool files (advancedTools: 5, agentSkillTools: 3, compressionTools: 5, gamificationTools: 8, memoryTools: 3, notionTools: 6, obsidianTools: 22, pluginTools: 8, poolTools: 5, skillTools: 4), 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (13 scopes — see `OMNIROUTE_MCP_SCOPES` in `open-sse/mcp-server/README.md:51`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
|
||||
**87 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 33-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (30 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
|
||||
|
||||
**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota,
|
||||
route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard,
|
||||
@@ -409,6 +409,14 @@ list_compression_combos, compression_combo_stats.
|
||||
|
||||
**Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions.
|
||||
|
||||
**Agent-skill tools** (3): A2A skill discovery / invocation bridges.
|
||||
|
||||
**Gamification tools** (8): levels, badges, leaderboard, and community-federation queries.
|
||||
|
||||
**Plugin tools** (8): plugin marketplace listing, install/enable/disable, and runtime inspection.
|
||||
|
||||
**Notion tools** (6) + **Obsidian tools** (22): knowledge-base read/write integrations (the largest tool family — vault search, note CRUD, WebDAV-backed file ops).
|
||||
|
||||
#### MCP Internals
|
||||
|
||||
- **Tool registration**: Each tool is an object with `{ name, description, inputSchema: ZodSchema,
|
||||
@@ -417,7 +425,7 @@ handler: async (args) => {...} }`. Zod validates inputs before the handler fires
|
||||
`createMcpServer()` wires all tool sets; `startMcpStdio()` launches the stdio transport.
|
||||
- **Transports**: stdio (CLI `omniroute --mcp`), SSE (`/api/mcp/sse`), Streamable HTTP
|
||||
(`/api/mcp/stream`). All share the same tool/scope engine.
|
||||
- **Scopes** (13): Control which tool categories an API key can access. Enforcement happens
|
||||
- **Scopes** (30): Control which tool categories an API key can access. Enforcement happens
|
||||
before handler dispatch.
|
||||
- **Audit**: Every tool invocation is logged to SQLite (`mcp_audit` table) with tool name,
|
||||
args, success/failure, API key attribution, and timestamp.
|
||||
@@ -481,7 +489,7 @@ Request middleware including `promptInjectionGuard.ts`.
|
||||
|
||||
### Guardrails (`src/lib/guardrails/`)
|
||||
|
||||
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open; per-request opt-out via header. See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
|
||||
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open. The `pii-masker` guardrail is registered and runs on every request, but its data-mutating logic is **opt-in** and OFF by default — it only redacts when `PII_REDACTION_ENABLED` (request) / `PII_RESPONSE_SANITIZATION` (response + streaming) are enabled (both `defaultValue: "false"`); with them off, payloads pass through untouched. A request can additionally opt OUT of any guardrail via header (`x-omniroute-disabled-guardrails`). Never make PII default-on (Hard Rule #20). See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
|
||||
|
||||
### Cloud Agents (`src/lib/cloudAgent/`)
|
||||
|
||||
@@ -519,7 +527,6 @@ Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md
|
||||
|
||||
## Subdirectory AGENTS.md Files
|
||||
|
||||
- **[`open-sse/AGENTS.md`](open-sse/AGENTS.md)** — Streaming engine, request pipeline, handlers, and executors
|
||||
- **[`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md)** — SQLite persistence, domain modules, migrations
|
||||
- **[`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md)** — Routing engine, combo resolution, strategy selection
|
||||
|
||||
@@ -527,31 +534,33 @@ Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md
|
||||
|
||||
For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
| Area | Doc |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
|
||||
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Auto-Combo (12-factor, 15 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
|
||||
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
|
||||
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
|
||||
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
|
||||
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
|
||||
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
|
||||
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
|
||||
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
|
||||
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/reference/openapi.yaml`](docs/reference/openapi.yaml) |
|
||||
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
|
||||
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
|
||||
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
|
||||
| Area | Doc |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
|
||||
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Auto-Combo (12-factor, 15 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
|
||||
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
|
||||
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
|
||||
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
|
||||
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
|
||||
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
|
||||
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
|
||||
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
|
||||
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/openapi.yaml`](docs/openapi.yaml) |
|
||||
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
|
||||
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
|
||||
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
|
||||
| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||
| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
1335
CHANGELOG.md
1335
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
177
CLAUDE.md
177
CLAUDE.md
@@ -35,22 +35,22 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 160+ LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 231 LLM providers, auto-fallback.
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | ------------------------------------------------------------------ |
|
||||
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
|
||||
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
|
||||
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
|
||||
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
||||
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (45+ files, 55 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 43 tools (30 base + 3 memory + 4 skills + 6 notion), 3 transports, ~13 scopes |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
| Skills | `src/lib/skills/` | Extensible skill framework |
|
||||
| Memory | `src/lib/memory/` | Persistent conversational memory |
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
|
||||
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
|
||||
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
|
||||
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
||||
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (83 files, 97 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 87 tools (33 base + memory/skill/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 30 scopes |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
| Skills | `src/lib/skills/` | Extensible skill framework |
|
||||
| Memory | `src/lib/memory/` | Persistent conversational memory |
|
||||
|
||||
Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point).
|
||||
|
||||
@@ -72,7 +72,7 @@ Client → /v1/chat/completions (Next.js route)
|
||||
|
||||
API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific.
|
||||
|
||||
**Combo routing** (`open-sse/services/combo.ts`): 14 strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, reset-aware, strict-random, auto, lkgp, context-optimized, context-relay). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. See `docs/routing/AUTO-COMBO.md` for the 9-factor Auto-Combo scoring and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||
**Combo routing** (`open-sse/services/combo.ts`): 17 strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 9-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||
|
||||
---
|
||||
|
||||
@@ -311,7 +311,7 @@ connection continue serving other models.
|
||||
4. Create 7 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`.
|
||||
5. Verify `/api/services/` is in `LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`; add a test asserting `isLocalOnlyPath()` returns `true` for the new prefix if you add one (hard rule #17).
|
||||
6. Add a UI tab in `src/app/(dashboard)/dashboard/providers/services/tabs/` reusing `ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`.
|
||||
7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/reference/openapi.yaml`.
|
||||
7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/openapi.yaml`.
|
||||
8. Write tests: unit (`tests/unit/services/`), integration (`tests/integration/services/`, gated by `RUN_SERVICES_INT=1`), and update `docs/ops/RELEASE_CHECKLIST.md` smoke section.
|
||||
|
||||
### Adding a New Guardrail / Eval / Skill / Webhook event
|
||||
@@ -327,32 +327,33 @@ connection continue serving other models.
|
||||
|
||||
For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
| Area | Doc |
|
||||
| -------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
||||
| Architecture | `docs/architecture/ARCHITECTURE.md` |
|
||||
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
||||
| Auto-Combo (9-factor scoring, 14 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
|
||||
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
|
||||
| Skills framework | `docs/frameworks/SKILLS.md` |
|
||||
| Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` |
|
||||
| Cloud agents | `docs/frameworks/CLOUD_AGENT.md` |
|
||||
| Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` |
|
||||
| Public upstream credentials (Gemini/etc.) | `docs/security/PUBLIC_CREDS.md` |
|
||||
| Error message sanitization | `docs/security/ERROR_SANITIZATION.md` |
|
||||
| Evals | `docs/frameworks/EVALS.md` |
|
||||
| Compliance / audit | `docs/security/COMPLIANCE.md` |
|
||||
| Webhooks | `docs/frameworks/WEBHOOKS.md` |
|
||||
| Authorization pipeline | `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| Stealth (TLS / fingerprint) | `docs/security/STEALTH_GUIDE.md` |
|
||||
| Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` |
|
||||
| MCP server | `docs/frameworks/MCP-SERVER.md` |
|
||||
| A2A server | `docs/frameworks/A2A-SERVER.md` |
|
||||
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/reference/openapi.yaml` |
|
||||
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
|
||||
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
|
||||
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
| Area | Doc |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
||||
| Architecture | `docs/architecture/ARCHITECTURE.md` |
|
||||
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
||||
| Auto-Combo (9-factor scoring, 17 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
|
||||
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
|
||||
| Skills framework | `docs/frameworks/SKILLS.md` |
|
||||
| Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` |
|
||||
| Cloud agents | `docs/frameworks/CLOUD_AGENT.md` |
|
||||
| Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` |
|
||||
| Public upstream credentials (Gemini/etc.) | `docs/security/PUBLIC_CREDS.md` |
|
||||
| Error message sanitization | `docs/security/ERROR_SANITIZATION.md` |
|
||||
| Evals | `docs/frameworks/EVALS.md` |
|
||||
| Compliance / audit | `docs/security/COMPLIANCE.md` |
|
||||
| Webhooks | `docs/frameworks/WEBHOOKS.md` |
|
||||
| Authorization pipeline | `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| Stealth (TLS / fingerprint) | `docs/security/STEALTH_GUIDE.md` |
|
||||
| Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` |
|
||||
| MCP server | `docs/frameworks/MCP-SERVER.md` |
|
||||
| A2A server | `docs/frameworks/A2A-SERVER.md` |
|
||||
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/openapi.yaml` |
|
||||
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
|
||||
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
|
||||
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
| Quality gates (~48 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
|
||||
|
||||
---
|
||||
|
||||
@@ -373,9 +374,10 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
**Test layer preference**: unit first → integration (multi-module or DB state) → e2e (UI/workflow only). Encode bug reproductions as automated tests before or alongside the fix.
|
||||
|
||||
**Both test runners must pass**: `npm run test:unit` (Node native — most tests) AND `npm run test:vitest` (MCP server, autoCombo, cache) cover **non-overlapping files**. Both must be green before merging. A PR where only one suite passes may silently ship broken MCP tools or routing regressions.
|
||||
**Both test runners must pass**: `npm run test:unit` (Node native — most tests) AND `npm run test:vitest` (MCP server, autoCombo, cache) cover **non-overlapping files**. Both are wired in CI (jobs `test-unit` and `test-vitest`) and must be green before merging. A PR where only one suite passes may silently ship broken MCP tools or routing regressions.
|
||||
|
||||
**Bug fix / issue triage protocol (Hard Rule #18)**: Every fix for a reported issue must be validated by one of the following — no exceptions:
|
||||
|
||||
1. **TDD (preferred)** — write a failing test reproducing the bug → fix it → confirm the test passes. The test becomes the permanent regression guard. Touch only the files the test proves need changing; nothing more.
|
||||
2. **Real-environment test (when TDD is not possible)** — deploy to the production VPS (`root@192.168.0.15`) and run a documented live test. Record the exact command + result in the PR description. Applies to: OAuth upstream flows, Cloudflare/WS upstream behavior, UI-only regressions, hardware-dependent behavior.
|
||||
3. "It worked locally without a test" does not count. A fix without a test or a VPS validation record is not a fix — it is a guess.
|
||||
@@ -402,14 +404,59 @@ git push -u origin feat/your-feature
|
||||
**Husky hooks**:
|
||||
|
||||
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11`
|
||||
- **pre-push**: `npm run test:unit`
|
||||
- **pre-push**: fast deterministic gates (`check:any-budget:t11` + `check:tracked-artifacts`); intentionally excludes `test:unit` (slow — covered by the CI `test-unit` job). Activated 2026-06-13 (Quality Gates Fase 6A.12).
|
||||
|
||||
### Worktree isolation (MANDATORY for every development task)
|
||||
|
||||
Multiple sessions/agents work this repo in parallel. The main checkout is **shared**, so a
|
||||
`git checkout`/branch switch in it silently discards another session's uncommitted work and
|
||||
yanks the branch out from under whatever else is running (incidents: 2026-06-05, 2026-06-13).
|
||||
|
||||
**Rule: never develop on the shared main checkout. Every task gets its own git worktree on its
|
||||
own dedicated branch, and you MUST confirm the base branch with the operator before creating it.**
|
||||
|
||||
1. **Ask first — which base branch?** Before creating anything, ask the operator (via
|
||||
`AskUserQuestion`, unless they already told you) from which branch the new worktree/branch
|
||||
should be cut. Do NOT assume `main` or "whatever I'm on" — the answer is usually the active
|
||||
`release/vX.Y.Z`, but it can be another feature/release branch. Get the base explicitly.
|
||||
2. **Create an isolated worktree + branch off that base** (never reuse the main checkout).
|
||||
**🔴 MANDATORY PATH: every worktree lives under `.claude/worktrees/` — and nowhere else.**
|
||||
This is the single canonical location (the same dir the native `EnterWorktree` tool uses). It
|
||||
is gitignored AND in the `tsconfig.json` / `.dockerignore` excludes, so worktrees never leak
|
||||
into the build scope. **Never** use `.worktrees/`, repo-root, or any other path — a worktree
|
||||
outside `.claude/worktrees/` (a) escapes the build-scope excludes and poisons `next build` (the
|
||||
`tsconfig` `include: **/*` globs ~70× the codebase → OOM; incident 2026-06-25) and (b) scatters
|
||||
worktrees across two dirs.
|
||||
|
||||
```bash
|
||||
BASE_BRANCH="release/vX.Y.Z" # ← the branch the operator confirmed in step 1
|
||||
TASK="feat/your-feature" # feat/ fix/ refactor/ docs/ test/ chore/
|
||||
git fetch origin "$BASE_BRANCH"
|
||||
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
||||
cd ".claude/worktrees/${TASK##*/}"
|
||||
# symlink node_modules from the main checkout to skip a per-worktree npm install:
|
||||
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||
```
|
||||
|
||||
In Claude Code prefer the native `EnterWorktree` tool (it already creates worktrees under
|
||||
`.claude/worktrees/`): create the worktree with the command above, then call `EnterWorktree`
|
||||
with its `path`.
|
||||
|
||||
3. **Work, commit, push, open the PR — all from inside the worktree.** Never `git checkout` a
|
||||
different branch inside a worktree another session might share.
|
||||
4. **Tear down only your own** worktree + branch when done, from the main checkout:
|
||||
`git worktree remove .claude/worktrees/<dir>` then `git branch -D <task>`. Never blanket-delete
|
||||
`fix/*`/`feat/*` — other sessions keep their own; delete only the branches you created, by name.
|
||||
5. **Never touch another session's worktree, branch, or uncommitted changes.** If `git worktree
|
||||
list` shows worktrees you didn't create, leave them alone. End every session with the main
|
||||
checkout back on the branch it started on (the active `release/vX.Y.Z`, never `main`).
|
||||
|
||||
---
|
||||
|
||||
## Environment
|
||||
|
||||
- **Runtime**: Node.js ≥20.20.2 <21 || ≥22.22.2 <23 || ≥24 <25, ES Modules
|
||||
- **TypeScript**: 5.9+, target ES2022, module esnext, resolution bundler
|
||||
- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules
|
||||
- **TypeScript**: 6.0+, target ES2022, module esnext, resolution bundler
|
||||
- **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*`
|
||||
- **Default port**: 20128 (API + dashboard on same port)
|
||||
- **Data directory**: `DATA_DIR` env var, defaults to `~/.omniroute/`
|
||||
@@ -418,6 +465,33 @@ git push -u origin feat/your-feature
|
||||
|
||||
---
|
||||
|
||||
## Quality Gates & Ratchets
|
||||
|
||||
OmniRoute has **~48 quality-gate scripts** (`scripts/check/` + `scripts/quality/`) wired
|
||||
across **9 gate-running jobs** in `.github/workflows/ci.yml` (`lint`, `quality-gate`,
|
||||
`quality-extended`, `docs-sync-strict`, `i18n-ui-coverage`, `i18n`, `pr-test-policy`,
|
||||
`test-vitest`, `sonarqube`), plus the `quality.yml` fast-gates job (PR→`release/**`) and
|
||||
3 nightly workflows (`nightly-property`, `nightly-resilience`, `nightly-llm-security`;
|
||||
`nightly-mutation` once merged). Full inventory, per-job breakdown, and operational
|
||||
procedures are in [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md).
|
||||
|
||||
**Quick reference:**
|
||||
|
||||
- Gates in jobs `lint` + `docs-sync-strict`: pass/fail policy gates —
|
||||
fix the violation or add an allowlist entry with a justification comment + tracking issue.
|
||||
- Gates in job `quality-gate`: ratchet — metrics (ESLint warnings, code coverage, duplication,
|
||||
complexity) must not regress vs `quality-baseline.json`. Update via
|
||||
`npm run quality:ratchet -- --update` when a metric genuinely improves.
|
||||
- Job `test-vitest` runs `npm run test:vitest` (MCP tools, autoCombo, cache) — blocking.
|
||||
`test:vitest:ui` is advisory until UI component tests are triaged.
|
||||
|
||||
**Allowlist policy (short form):** Fix the cause; use the allowlist only for pre-existing
|
||||
violations you cannot fix in the same PR. Add a comment with justification + issue number.
|
||||
Stale allowlist entries (suppressing a violation that no longer exists) will be caught by
|
||||
the stale-enforcement added in Fase 6A.3.
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules
|
||||
|
||||
1. Never commit secrets or credentials
|
||||
@@ -428,26 +502,31 @@ git push -u origin feat/your-feature
|
||||
6. Never silently swallow errors in SSE streams
|
||||
7. Always validate inputs with Zod schemas
|
||||
8. Always include tests when changing production code
|
||||
9. Coverage must stay ≥60% (statements, lines, functions, branches).
|
||||
9. Coverage must not regress below the baseline frozen in `quality-baseline.json` (ratchet); absolute floor is 60% (statements/lines/functions/branches). Update the baseline via `npm run quality:ratchet -- --update` only when coverage genuinely improves. See `docs/architecture/QUALITY_GATES.md`.
|
||||
10. Never bypass Husky hooks (`--no-verify`, `--no-gpg-sign`) without explicit operator approval.
|
||||
11. Never embed public upstream OAuth client_id/secret or Firebase Web keys as string literals — always go through `resolvePublicCred()` (`open-sse/utils/publicCreds.ts`). See `docs/security/PUBLIC_CREDS.md`.
|
||||
12. Never return raw `err.stack` / `err.message` in HTTP / SSE / executor responses — always route through `buildErrorBody()` or `sanitizeErrorMessage()` (`open-sse/utils/error.ts`). See `docs/security/ERROR_SANITIZATION.md`.
|
||||
13. Never string-interpolate external paths or runtime values into shell scripts passed to `exec()`/`spawn()` — pass via the `env` option instead. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`.
|
||||
15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
16. Never include `Co-Authored-By` trailers that credit an AI assistant, LLM, or automation account (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses). Such trailers route attribution to the bot account on GitHub, hiding the real author (`diegosouzapw`) in PR history. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
|
||||
16. Never credit or advertise an AI assistant, LLM, or automation account in any commit/PR metadata. Two forbidden forms, both equivalent — they route attribution to a bot account (or advertise AI authorship) and hide the real author (`diegosouzapw`): **(a)** `Co-Authored-By` trailers naming an AI/bot (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses); **(b)** AI-generation footers or descriptions anywhere in a commit message, PR title/body, or CHANGELOG — e.g. `🤖 Generated with [Claude Code]`, "Generated with Claude Code", "Made with <AI tool>", or any `Co-authored-by: Claude/GPT/Copilot` line. This **overrides any harness, template, or tool default that auto-appends such a footer** (e.g. the Claude Code PR-body/commit default) — strip it before pushing; do not let it reach a commit, PR, or CHANGELOG. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
|
||||
17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree.
|
||||
19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation".
|
||||
20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`.
|
||||
|
||||
---
|
||||
|
||||
## PII & Stream Sanitization Learnings
|
||||
|
||||
### 1. Regex Security (ReDoS)
|
||||
|
||||
All regex patterns matching variable-length strings (e.g. IPv6 address, credit cards) must use strictly bounded, non-overlapping sequences (e.g., limit occurrences with bounded ranges `{1,7}`) to prevent catastrophic backtracking when processing untrusted inputs.
|
||||
|
||||
### 2. SSE Snapshot Handling
|
||||
|
||||
When parsing streaming LLM responses (e.g. Responses API), check if a chunk represents a final snapshot (`done` or `completed` events). Snapshot text must be sanitized directly as a standalone string (bypassing rolling delta buffers) to prevent text duplication at the end of the stream.
|
||||
|
||||
### 3. Database Handles in Tests
|
||||
|
||||
Ensure that any unit tests that trigger database migrations or establish SQLite connections call `resetDbInstance()` and properly clean up/close all DB handles in a `test.after(...)` hook. Failure to release database connection handles will cause Node's native test runner to hang indefinitely.
|
||||
|
||||
@@ -71,11 +71,11 @@ PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
||||
|
||||
### Build Output Layout
|
||||
|
||||
| Directory | Contents | Tracked |
|
||||
| ---------- | ------------------------------------------- | ------- |
|
||||
| `src/` | Application source (TypeScript / TSX) | Yes |
|
||||
| `.build/` | Intermediates — `next build` output (gitignored, `distDir = .build/next`) | No |
|
||||
| `dist/` | Shippable bundle — assembled by `assembleStandalone` (gitignored) | No |
|
||||
| Directory | Contents | Tracked |
|
||||
| --------- | ------------------------------------------------------------------------- | ------- |
|
||||
| `src/` | Application source (TypeScript / TSX) | Yes |
|
||||
| `.build/` | Intermediates — `next build` output (gitignored, `distDir = .build/next`) | No |
|
||||
| `dist/` | Shippable bundle — assembled by `assembleStandalone` (gitignored) | No |
|
||||
|
||||
The build pipeline is a single pass:
|
||||
|
||||
@@ -167,6 +167,18 @@ npm run coverage:report
|
||||
# Lint + format check
|
||||
npm run lint
|
||||
npm run check
|
||||
|
||||
# Gated real-upstream combo smoke (requires VPS access + real provider credits)
|
||||
# Hits REAL providers — costs a little. NEVER runs in CI. Skips cleanly without the gate.
|
||||
# Needs: ssh root@192.168.0.15 access (sources a read-only DB snapshot from the VPS).
|
||||
RUN_COMBO_LIVE=1 npm run test:combo:live
|
||||
|
||||
# Phase-3 VPS live smoke — plain Node ESM scripts, hit the live .15 server directly.
|
||||
# Requires: ssh root@192.168.0.15 access (combos created/torn down via SSH sqlite).
|
||||
# Hits REAL providers (small cost). Creates/deletes only __live_test__* combos. NEVER runs in CI.
|
||||
# REQUIRE_API_KEY=false on .15 so no API key needed, but honors COMBO_LIVE_BASE_URL / COMBO_LIVE_API_KEY if set.
|
||||
npm run test:combo:live:vps # 7 HTTP scenarios (priority/round-robin/weighted/cost/fusion/auto + health)
|
||||
npm run test:combo:live:vps:failover # adds a real cross-provider failover scenario (8 total)
|
||||
```
|
||||
|
||||
Coverage notes:
|
||||
@@ -252,7 +264,7 @@ open-sse/ # @omniroute/open-sse workspace
|
||||
electron/ # Electron desktop app (cross-platform)
|
||||
|
||||
tests/
|
||||
├── unit/ # Node.js test runner (122 test files)
|
||||
├── unit/ # Node.js test runner (1,574 test files)
|
||||
├── integration/ # Integration tests
|
||||
├── e2e/ # Playwright tests
|
||||
├── security/ # Security tests
|
||||
|
||||
254
DESING.md
Normal file
254
DESING.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# OmniRoute — Design System & Visual Identity
|
||||
|
||||
> **Status:** analysis + standardization plan (no code applied yet — this doc is the spec to approve before implementation).
|
||||
> **Date:** 2026-06-16 · **Scope:** unify the OmniRoute dashboard (`src/`) with the marketing site (`_mono_repo/omnirouteSite/`) into **one visual identity** — same graph-paper grid background, same color tokens, standardized components.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The marketing site (`viral.omniroute.online`, `why.omniroute.online`, `omniroute.online`) and the product dashboard should look like **one product**. The site already borrowed its palette from the dashboard — its `css/tokens.css` even says _"Palette mirrors the OmniRoute dashboard (src/app/globals.css)"_. So the two are already ~80% aligned at the color level. What's missing on the dashboard:
|
||||
|
||||
1. The **graph-paper grid wallpaper** the site uses on every page.
|
||||
2. A handful of **shared design tokens** the site has but the dashboard lacks (radius scale, brand gradient, `surface-2`, mono font).
|
||||
3. **Component-level consistency** — a number of dashboard components bypass the theme tokens with hardcoded hex/rgba.
|
||||
|
||||
This document is the analysis and the plan. **Nothing is changed until approved.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principles
|
||||
|
||||
- **Single source of truth = `src/app/globals.css`.** The site mirrors the dashboard, never the other way around. New tokens land in `globals.css` first.
|
||||
- **Tokens, never literals.** Components consume semantic tokens (`bg-surface`, `text-primary`, `border-border`), never raw `#hex`.
|
||||
- **Subtle, not loud.** The grid is a faint wallpaper that sits behind content — it must never reduce text contrast or fight the UI.
|
||||
- **Theme-aware.** Everything works in both `.dark` (default-ish, the product's signature look) and light.
|
||||
- **Surgical rollout.** Ship the grid + tokens first (low risk, high visibility), then component cleanups in waves.
|
||||
|
||||
---
|
||||
|
||||
## 3. Current state — what's already aligned vs. what's not
|
||||
|
||||
### 3.1 Colors — already unified ✅
|
||||
|
||||
Every brand color and surface already matches the site **by value** (only the names differ — dashboard prefixes with `--color-`). Verified in `src/app/globals.css:30-128`:
|
||||
|
||||
| Concept | Site token (`tokens.css`) | Dashboard token (`globals.css`) | Match |
|
||||
| -------------------------- | ------------------------------------------- | ------------------------------- | ------------ |
|
||||
| primary | `--primary #e54d5e` | `--color-primary #e54d5e` | ✅ |
|
||||
| primary-hover | `--primary-hover #c93d4e` | `--color-primary-hover #c93d4e` | ✅ |
|
||||
| accent | `--accent #6366f1` | `--color-accent #6366f1` | ✅ |
|
||||
| accent-2 | `--accent-2 #8b5cf6` | `--color-accent-hover #8b5cf6` | ✅ (renamed) |
|
||||
| accent-3 | `--accent-3 #a855f7` | `--color-accent-light #a855f7` | ✅ (renamed) |
|
||||
| success / warning / error | `#22c55e / #f59e0b / #ef4444` | identical | ✅ |
|
||||
| traffic lights | `#ff5f56 / #ffbd2e / #27c93f` | identical | ✅ |
|
||||
| dark bg / surface / border | `#0b0e14 / #161b22 / rgba(255,255,255,.08)` | identical | ✅ |
|
||||
| light bg / surface / text | `#f9f9fb / #fff / #1a1a2e` | identical | ✅ |
|
||||
|
||||
**Conclusion:** there is no color migration to do. The identity is already shared; we are _finishing_ it, not rebuilding it.
|
||||
|
||||
### 3.2 Gaps — what the dashboard is missing
|
||||
|
||||
| Gap | Site has | Dashboard | Action |
|
||||
| ----------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------- | ---------------------- |
|
||||
| **Grid wallpaper** | `body::before` graph-paper, `--grid-line`, `--grid-size 46px`, `--section-alt` | none (flat `--color-bg`) | **Part A** |
|
||||
| **Radius scale** | `--radius 14px`, `--radius-sm 9px` | none — primitives use ad-hoc `rounded-md/lg/xl` (6/8/12px) | **Part B** |
|
||||
| **Brand gradient** | `--grad-brand 135deg primary→accent-3` | none — only a one-off `.bg-hero-gradient` | **Part B** |
|
||||
| **Nested surface** | `--surface-2 #1c2230` | none | **Part B** |
|
||||
| **Mono font** | `--font-mono` (ui-monospace stack) | none (code/terminal areas have no token) | **Part B** |
|
||||
| **`text-muted` (dark)** | `#8b8b9e` | `#a1a1aa` (zinc-400) | reconcile — **Part B** |
|
||||
|
||||
### 3.3 Theming mechanics (so we don't break anything)
|
||||
|
||||
- **Tailwind v4, CSS-first** (no `tailwind.config.*`). Tokens are defined in `:root`/`.dark` and exposed to utilities via `@theme inline` (`globals.css:130-179`).
|
||||
- **Dark via `.dark` class** on `<html>` (`@custom-variant dark` at `globals.css:22`), toggled by a custom Zustand store (`src/store/themeStore.ts`), default theme = `system` (`src/shared/constants/appConfig.ts:11`). The site uses `html[data-theme="light"]` instead — **the mechanisms differ but never meet** (separate origins), so no conflict. We keep the dashboard's `.dark` mechanism.
|
||||
- **Runtime primary override** exists (`themeStore.ts:85-97`, presets in `COLOR_THEMES`) — users can swap `--color-primary`. Any new token (gradient, etc.) that references `--color-primary` will inherit those overrides for free. ✅
|
||||
|
||||
---
|
||||
|
||||
## 4. Part A — The graph-paper grid background (headline ask)
|
||||
|
||||
### 4.1 What it is
|
||||
|
||||
The exact recipe from the site (`_mono_repo/omnirouteSite/css/base.css`): a **fixed, full-viewport pseudo-element** painting two 1px line gradients, sitting at `z-index:-1` behind all content.
|
||||
|
||||
```css
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: var(--grid-size) var(--grid-size);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works even though `body` has an opaque `background-color`:** a `::before` with `z-index:-1` paints _above_ the element's own background but _below_ its in-flow content. So `--color-bg` is the base fill, the grid is layered on top of it, and the app renders above the grid.
|
||||
|
||||
### 4.2 Precedent already in the codebase
|
||||
|
||||
`src/app/landing/page.tsx:16-26` **already implements this same grid per-page** — but with **red** lines (`#E54D5E`, opacity `0.06`) at **50px**, plus animated orbs. So the pattern is proven in the product; we are promoting it to a **global, theme-aware** wallpaper and (optionally) retiring the duplicate.
|
||||
|
||||
### 4.3 Tokens to add (in `globals.css`)
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* light */
|
||||
--grid-line: rgba(0, 0, 0, 0.045);
|
||||
--grid-size: 46px;
|
||||
--section-alt: rgba(0, 0, 0, 0.022);
|
||||
}
|
||||
.dark {
|
||||
/* dark */
|
||||
--grid-line: rgba(255, 255, 255, 0.035);
|
||||
--section-alt: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 The single blocker
|
||||
|
||||
The grid is global by construction (it covers the panel, `auth`/`login`, error pages — every route — at once). Exactly **one** element hides it inside the panel:
|
||||
|
||||
- `src/shared/components/layouts/DashboardLayout.tsx:62` — the outer wrapper paints an opaque `bg-bg`:
|
||||
|
||||
```jsx
|
||||
<div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
```
|
||||
|
||||
Everything below it is already transparent — `<main>` (`:93`), the scroll container (`:102`), the `max-w-7xl` inner (`:103`). So **removing `bg-bg` from this one line** lets the body grid show through the entire content area (the body's `--color-bg` remains the base fill underneath the grid).
|
||||
|
||||
```diff
|
||||
- <div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
+ <div className="flex h-dvh min-h-0 w-full overflow-hidden">
|
||||
```
|
||||
|
||||
### 4.5 Chrome interaction (sidebar / header)
|
||||
|
||||
- `Header` (`src/shared/components/Header.tsx:207`, `bg-bg`) and `Sidebar` (`src/shared/components/Sidebar.tsx:430`, `bg-sidebar`) stay **opaque** → the grid shows in the **content area only**, with solid chrome framing it. This is the recommended, calm default and matches how the site separates chrome from canvas.
|
||||
- _Optional vibrancy variant:_ make the header translucent (`bg-bg/80 backdrop-blur`) so the grid runs behind it. A `.bg-vibrancy` helper already exists (`globals.css:370`). **Decision D3 below.**
|
||||
|
||||
### 4.6 Login / auth / error pages
|
||||
|
||||
These render directly under `<body>` (no panel chrome) and their page wrappers are mostly transparent — the global grid appears behind them automatically. One exception: `src/app/login/page.tsx:124,139` uses opaque `bg-bg` wrappers; soften the same way if we want the grid there too (minor, **D4**).
|
||||
|
||||
### 4.7 Landing page
|
||||
|
||||
`landing/page.tsx` keeps its richer animated background (orbs + vignette). Options: (a) leave it as-is (its own splash identity), or (b) align its grid to the global tokens (46px, neutral lines) for consistency. **Recommend (a)** — it's a marketing splash, not a panel screen. **Decision D5.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Part B — Token unification
|
||||
|
||||
Add to `globals.css` (`:root` + `@theme inline`) so the dashboard gains the site's missing tokens. None of these change existing colors; they add the _missing_ primitives.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--surface-2: #f5f5fa; /* light: nested panels */
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--grad-brand: linear-gradient(135deg, var(--color-primary), var(--color-accent-light));
|
||||
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
}
|
||||
.dark {
|
||||
--surface-2: #1c2230;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-surface-2: var(--surface-2); /* enables bg-surface-2 */
|
||||
--radius-lg: var(--radius); /* enables rounded-lg = 14px */
|
||||
--radius-md: var(--radius-sm); /* enables rounded-md = 9px */
|
||||
--font-mono: var(--font-mono); /* enables font-mono */
|
||||
}
|
||||
```
|
||||
|
||||
| Token | Why | Consumers |
|
||||
| -------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `--radius` / `--radius-sm` | One radius scale (14/9) instead of 6/8/12 ad-hoc | Button, Card, Modal, Input, Select |
|
||||
| `--grad-brand` | Brand gradient for primary CTAs (red→violet), matching the site | Button `primary`, hero/CTA surfaces |
|
||||
| `--surface-2` | Nested panels / table headers / inset rows | Card.Section, DataTable header, inputs |
|
||||
| `--font-mono` | Code blocks, terminal, IDs, endpoints | ConsoleLogViewer, code snippets, `localhost:20128/v1` chips |
|
||||
| `--text-muted` reconcile | Pick one value site↔panel | global |
|
||||
|
||||
**Decision D2 (text-muted):** site `#8b8b9e` vs dashboard `#a1a1aa`. Recommend keeping the **dashboard's `#a1a1aa`** (it's the live product, slightly higher contrast) and updating the _site_ to match. Low priority, cosmetic.
|
||||
|
||||
---
|
||||
|
||||
## 6. Part C — Component standardization
|
||||
|
||||
The component layer is **custom** (no shadcn/Radix), Tailwind v4, semantic tokens **mostly** adopted (`bg-surface`, `border-white/10`, `ring-primary`) — good adoption (195 files import the shared barrel). The work is removing the **bypasses**. Home: `src/shared/components/`.
|
||||
|
||||
Ranked by impact × reach:
|
||||
|
||||
| # | Item | File(s) | Problem → Target |
|
||||
| --- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| C1 | **Radius alignment** | `Button.tsx:14-18`, `Card.tsx:39`, `Modal.tsx`, `Input.tsx`, `Select.tsx` | mixed 6/8/12px → repoint to `--radius`/`--radius-sm` (14/9) |
|
||||
| C2 | **Button gradient + `accent` variant** | `Button.tsx:5-12` | primary is flat red→red (`from-primary to-primary-hover`); align to `--grad-brand` (red→violet) and add the missing `accent` variant (indigo `#6366f1` is unused by buttons) — **highest visibility, ~195 importers**. **Decision D1.** |
|
||||
| C3 | **Tables** | `DataTable.tsx:122-176`, `logTableStyles.ts`, `globals.css:405-414` (Ant remnants) | `DataTable` is 100% inline hardcoded rgba + references non-existent vars (`--text-secondary`, `--bg-table-header`); migrate to tokens, retire the 2 divergent table styles. Tables are everywhere (providers/connections/logs) — worst offender. |
|
||||
| C4 | **Centralize status colors** | `flow/edgeStyles.ts:7-12`, `TokenHealthBadge.tsx:14-19`, `DegradationBadge.tsx`, `ProviderCascadeNode.tsx`, `Badge.tsx`, +5 `statusColor` helpers | 6+ copies of the same `#22c55e/#f59e0b/#ef4444` hex; create one `statusColors` module driven off `--color-success/warning/error`. Critical for circuit-breaker / cooldown / lockout badges to read consistently. |
|
||||
| C5 | **Card border** | `Card.tsx:39` | uses `border-white/5`; brand border is `/8` → align |
|
||||
| C6 | **Focus ring reconcile** | `globals.css:183` vs component `ring-primary/30` | global `:focus-visible` is indigo (`--color-accent`), components are red (`ring-primary`) — pick one (recommend **accent/indigo** globally, it reads as the "interactive" color) |
|
||||
| C7 | **Add `Checkbox` + `Textarea` primitives** | currently raw `<input>`/`<textarea>` with inline `accentColor:#6366f1` (e.g. `ColumnToggle.tsx:91`) | create token-driven primitives |
|
||||
| C8 | **Hardcoded-hex sweep** | `ConsoleLogViewer.tsx:240` (`#161b22`/`#30363d`), `ComboLiveStudio.tsx:306` (`#6366f120`), Modal traffic dots `Modal.tsx:149-159`, ~14 chart/component files with literal `#6366f1`/`#a855f7` | replace literals with `bg-surface`/`border-border`/`text-accent` etc. |
|
||||
| C9 | **`cn()` → clsx + tailwind-merge** | `src/shared/utils/cn.ts` | current `cn` just joins; conflicting classes stack (a `className="rounded-2xl"` override won't replace a primitive's `rounded-lg`). Needed for C1 overrides to behave. |
|
||||
|
||||
**Already on-brand (token-driven, only need radius):** `Badge`, `Toggle`, `SegmentedControl`, `Input`, `Select`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout plan (phased, each phase shippable + testable)
|
||||
|
||||
- **Phase 1 — Grid + tokens (low risk, high visibility).**
|
||||
1. Add grid + identity tokens to `globals.css` (Part A §4.3, Part B §5).
|
||||
2. Add `body::before` grid.
|
||||
3. Remove `bg-bg` from `DashboardLayout.tsx:62`.
|
||||
4. Verify across themes + key screens (dashboard, providers, logs, login, an error page). Confirm contrast unchanged.
|
||||
→ _Delivers the headline ask. Reversible in one commit._
|
||||
|
||||
- **Phase 2 — Primitives radius + Button (C1, C2, C5, C9).** The visible "feel" pass. `cn()` upgrade first so overrides behave.
|
||||
|
||||
- **Phase 3 — Tables + status colors (C3, C4).** The largest consistency win; touch the data-heavy screens.
|
||||
|
||||
- **Phase 4 — Cleanup (C6, C7, C8).** Focus ring, new primitives, hardcoded-hex sweep.
|
||||
|
||||
Each phase: `npm run lint` + `npm run typecheck:core` + a visual pass. Per repo rule, production-code changes ship with tests where applicable (token/CSS changes are visual — validated by screenshots; component API changes get unit coverage).
|
||||
|
||||
---
|
||||
|
||||
## 8. Open decisions (need your call before/while implementing)
|
||||
|
||||
- **D1 — Button primary look.** Keep the current **red→red** gradient, or switch the product's primary buttons to the **red→violet `--grad-brand`** (matches the site CTAs)? _(Affects every primary button.)_ Recommend: **red→violet**, with `--grad-brand`.
|
||||
- **D2 — Grid line color.** **Neutral** lines (site style: faint white/black, `rgba(255,255,255,0.035)`) — calm, content-first — **or** the landing's **brand-red** lines? Recommend: **neutral** (matches the site's interior pages; red is louder and can tint readability). Size **46px** (site) to retire the landing's 50px drift.
|
||||
- **D3 — Chrome vibrancy.** Sidebar/header stay **solid** (grid in content area only), or go **translucent** so the grid runs behind them? Recommend: **solid** (calmer; less risk).
|
||||
- **D4 — Auth/login grid.** Soften `login/page.tsx` wrappers so the grid shows there too? Recommend: **yes** (cheap, more cohesive).
|
||||
- **D5 — Landing page.** Leave its animated splash bg as-is, or align it to the global grid? Recommend: **leave as-is**.
|
||||
- **D6 — Radius value.** Adopt **14/9** everywhere (bigger, softer, site-matching) — confirm you want this product-wide shift. Recommend: **yes**, it's the single biggest "one identity" signal.
|
||||
- **D7 — Scope of first PR.** Ship **Phase 1 only** first (grid + tokens), then iterate? Recommend: **yes** — validate the wallpaper live before the component waves.
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of scope / risks
|
||||
|
||||
- **No palette change** — colors already match; we only add missing tokens. Zero risk of recoloring the product.
|
||||
- **No theme-engine change** — keep `.dark` + Zustand store; don't migrate to `next-themes` or to the site's `data-theme`.
|
||||
- **Radius shift is broad** (D6) — it touches every card/button/input; that's the point, but it's the one change worth eyeballing on busy screens (tables, modals) before merge.
|
||||
- **Tables (C3)** carry the most hardcoded styling and the highest regression surface — isolate in its own PR with before/after screenshots.
|
||||
- **Worktree isolation (repo hard-rule #19):** implementation runs in a dedicated worktree on a branch cut from the confirmed base (likely `release/v3.8.28`), never on the shared checkout. This doc is the only artifact written to the working tree so far.
|
||||
|
||||
---
|
||||
|
||||
## 10. Reference index
|
||||
|
||||
| Area | Path |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dashboard tokens | `src/app/globals.css:30-179` (`:root`, `.dark`, `@theme inline`), `body` `:206` |
|
||||
| Theme store | `src/store/themeStore.ts`, `src/shared/components/ThemeProvider.tsx`, `src/shared/constants/appConfig.ts:9-11` |
|
||||
| Panel shell (grid blocker) | `src/shared/components/layouts/DashboardLayout.tsx:62` |
|
||||
| Chrome | `src/shared/components/Header.tsx:207`, `src/shared/components/Sidebar.tsx:430` |
|
||||
| Grid precedent | `src/app/landing/page.tsx:16-26` |
|
||||
| Primitives | `src/shared/components/{Button,Card,Input,Select,Badge,Modal,Toggle,SegmentedControl,Loading,Tooltip,DataTable}.tsx`, barrel `index.tsx` |
|
||||
| Status-color sources | `flow/edgeStyles.ts`, `TokenHealthBadge.tsx`, `DegradationBadge.tsx`, `logTableStyles.ts` |
|
||||
| `cn` util | `src/shared/utils/cn.ts` |
|
||||
| Site reference | `_mono_repo/omnirouteSite/css/tokens.css`, `css/base.css` (grid `body::before`) |
|
||||
26
Dockerfile
26
Dockerfile
@@ -20,6 +20,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
# Workspace package manifests MUST be present before `npm ci` so npm materializes
|
||||
# the workspace and installs its *workspace-only* deps (e.g. safe-regex,
|
||||
# @toon-format/toon — declared in open-sse/package.json, not hoisted to root).
|
||||
# Without this, `npm ci` skips them and `npm run build` fails with "Module not
|
||||
# found" (root cause of the v3.8.39 Docker build break). workspaces = ["open-sse"].
|
||||
COPY open-sse/package.json ./open-sse/package.json
|
||||
COPY scripts/build/postinstall.mjs ./scripts/build/postinstall.mjs
|
||||
COPY scripts/build/postinstallSupport.mjs ./scripts/build/postinstallSupport.mjs
|
||||
COPY scripts/build/native-binary-compat.mjs ./scripts/build/native-binary-compat.mjs
|
||||
@@ -38,8 +44,24 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
&& npm rebuild better-sqlite3 \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# Use Turbopack for significant build speedup
|
||||
ENV OMNIROUTE_USE_TURBOPACK=1
|
||||
# Build with webpack (stable). Turbopack hit a non-recoverable internal panic on this
|
||||
# Next.js version during the v3.8.27 release build — TurbopackInternalError "entered
|
||||
# unreachable code: there must be a path to a root" in ImportTracer::get_traces, on both
|
||||
# linux/amd64 and linux/arm64. Webpack is the proven engine (build:release / VPS / CI Build
|
||||
# all green). Re-enable Turbopack (=1) once the upstream tracer bug is fixed.
|
||||
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
|
||||
ENV OMNIROUTE_USE_TURBOPACK=0
|
||||
|
||||
# Raise the V8 heap ceiling for the build. The webpack production optimization
|
||||
# pass (forced above since Turbopack panics) needs more than V8's default ceiling
|
||||
# (~2 GB) for a codebase this size; a memory-constrained Docker build otherwise
|
||||
# dies with "FATAL ERROR: ... JavaScript heap out of memory" at `[builder] npm run
|
||||
# build` (#4076). NODE_OPTIONS propagates to the spawned `next build` child
|
||||
# (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env). Build-only;
|
||||
# the runtime heap is set separately on the runner stage (OMNIROUTE_MEMORY_MB).
|
||||
# Override for hosts with more/less RAM: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,target=/app/.build/next/cache \
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
# Fase 6A — Auditoria Crítica das Fases 0–6: o que deixamos passar
|
||||
|
||||
> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans`, tarefa-a-tarefa. Tarefas P0/P1 maiores devem ser **expandidas em sub-plano bite-sized próprio** (`writing-plans`) no momento da execução. Hard Rule #18 (TDD/VPS) em tudo. Auditar subagentes (trust-but-verify) após cada task.
|
||||
|
||||
> # ⏳ PORTÃO DE ATIVAÇÃO — NÃO INICIAR ANTES DE **2026-06-16**
|
||||
> Mesma janela da Fase 7 (decisão do owner 2026-06-09: 1 semana de uso em produção das Fases 0–6 antes de evoluir). **Ordem na ativação: Fase 6A ANTES da Fase 7** — primeiro consertamos/endurecemos o que já existe, depois adicionamos ferramentas novas.
|
||||
> **Exceção possível (decisão do owner):** as tasks **6A.1 e 6A.2 são bugs pré-existentes descobertos pela auditoria** (testes que nunca rodam + suíte vitest fora do CI), não "gates novos" — podem ser antecipados como fix avulso se o owner preferir não esperar a janela.
|
||||
|
||||
**Goal:** Fechar os furos que a auditoria crítica pós-implementação (2026-06-09, análise inline dos 18 gates + motor + CI + baselines) encontrou nas Fases 0–6 — antes de adicionar qualquer ferramenta nova na Fase 7.
|
||||
|
||||
**Architecture:** Zero ferramenta nova (tudo homegrown, padrão `check-*.mjs` + motor existente). Três frentes: (1) **bugs sistêmicos de runner** descobertos (testes órfãos, vitest fora do CI); (2) **endurecimento do padrão de catraca** (stale-allowlist enforcement + require-tighten, validados pela prática da Notion); (3) **expansão de escopo** dos gates existentes (diretórios/superfícies que ficaram de fora).
|
||||
|
||||
**Tech Stack:** Node ≥20 ESM, ESLint 9 flat, c8, jscpd@4 (a pinar), GitHub Actions, Node native test runner, vitest. Nada novo em `dependency-allowlist.json` exceto a promoção do jscpd a devDependency (Task 6A.12).
|
||||
|
||||
---
|
||||
|
||||
## Origem: o que a auditoria encontrou (resumo dos achados)
|
||||
|
||||
Método: releitura inline de todos os `scripts/check/*.{mjs,ts}` criados nas Fases 0–6, `scripts/quality/*`, baselines, `ci.yml`, `package.json`, hooks Husky e docs — sem subagentes — mais validação por pesquisa (sistema de ratcheting da Notion; práticas de suppression-hygiene de linters).
|
||||
|
||||
| # | Achado | Gravidade | Task |
|
||||
|---|--------|-----------|------|
|
||||
| A1 | **≈135 arquivos `*.test.ts` em subdiretórios de `tests/unit/` não são coletados por NENHUM runner** — `test:unit`, `test:coverage` e os shards do CI usam o glob não-recursivo `tests/unit/*.test.ts`; o vitest só inclui `autoCombo/**` (+ `.tsx`). Inclui `authz/routeGuard.test.ts` (Hard Rules #15/#17), 50 testes de `compression/`, 12 de `services/`, 10 de `gamification/`, 6 de `guardrails/`, 5 de `security/`. **Amostra rodada na auditoria: 2 asserts de `routeGuard.test.ts` FALHAM hoje** ("management policy allows /api/services/ (e /api/copilot/chat) from localhost with valid CLI token") — o arquivo apodreceu sem ninguém ver, provavelmente desde o redesign do peer-stamp (2026-05-31) | **P0 — falso verde sistêmico** | 6A.1 |
|
||||
| A2 | **Nenhum workflow roda `test:vitest`** (`grep -rln vitest .github/workflows/` = vazio). O CLAUDE.md afirma "Both test runners must pass… before merging", mas a suíte vitest (MCP server 43 tools, autoCombo, cache, componentes) está 100% fora da esteira | **P0** | 6A.2 |
|
||||
| B1 | **Nenhum gate falha quando uma entrada de allowlist deixa de ser necessária.** Os 18 gates congelam ~90 violações em `KNOWN_*`; quando alguém corrige a violação (ex.: criar `/api/gamification/level` da issue #3484, remover `krutrim` da #3483), a entrada vira um furo aberto — a regressão pode VOLTAR sem revisão. Só `check-error-helper` tem detecção parcial (WARN de arquivo inexistente, que ninguém lê). Prática validada: linters maduros "yell when an exclusion exists that doesn't break the rule" | **P0 — corrói a catraca com o tempo** | 6A.3 |
|
||||
| B2 | **Melhoria não-capturada vira folga permanente no motor**: sem `--update` manual, uma métrica que melhorou pode regredir de volta até o baseline antigo sem ninguém ver. A Notion auto-decrementa budgets no pre-commit; nosso motor não exige aperto | P1 | 6A.5 |
|
||||
| B3 | EPS único (0.01) para todas as métricas; o plano da Fase 4 pedia epsilon maior para `coverage.branches` (não-determinismo do v8) e não foi implementado | P1 | 6A.5 |
|
||||
| B4 | Métrica coletada sem entrada no baseline é ignorada em silêncio (coletor novo esquecido do baseline = falso conforto) | P2 | 6A.5 |
|
||||
| C1 | `check-fetch-targets` só varre `src/app/(dashboard)` — **20+ arquivos com `fetch("/api/…")` fora do escopo**: `src/shared/components/` (Sidebar, CommandPalette, modais…), `src/app/connect/`, `src/app/status/`, `src/lib/evals/` | P1 | 6A.7 |
|
||||
| C2 | `check-fetch-targets` ignora 100% dos template literals (`` fetch(`/api/x/${id}`) ``) — nem o prefixo estático é validado | P1 | 6A.7 |
|
||||
| C3 | `check-fetch-targets` não valida o método HTTP (fetch `POST` → rota só com `GET` = 405 em runtime, gate verde) | P2 | 6A.7 |
|
||||
| C4 | `check-deps` cobre só `package.json` raiz + `electron/` — **`@omniroute/opencode-plugin` (dep `zod` + 5 devDeps, pacote PUBLICADO no npm), `@omniroute/opencode-provider` e `open-sse/` ficam fora** | P1 | 6A.8 |
|
||||
| C5 | `check-public-creds` escaneia só 2 arquivos hardcoded — credencial literal em arquivo NOVO (executor, oauth provider) passa batida | P1 | 6A.8 |
|
||||
| C6 | `check-error-helper` cobre `open-sse/executors` + `handlers` — a Hard Rule #12 também fala de **MCP handlers** (`open-sse/mcp-server/`) e rotas HTTP (`src/app/api/`), fora do escopo | P1 | 6A.8 |
|
||||
| C7 | `check-file-size` e `check-complexity` varrem `src` + `open-sse` — `electron/` e `bin/` fora (god-file pode nascer lá) | P2 | 6A.11 |
|
||||
| C8 | `check-known-symbols` cobre executors/strategies/translators — **faltam as 3 superfícies de despacho por-string restantes: MCP tools (43), A2A skills (5, `A2A_SKILL_HANDLERS`), cloud agents (3, registry)** | P1 | 6A.9 |
|
||||
| C10 | `check-route-guard-membership` depende da lista manual `SPAWN_CAPABLE_ROUTE_ROOTS` (3 raízes) — rota nova que spawna processo FORA dessas raízes é invisível ao gate | P1 | 6A.8 |
|
||||
| C11 | `check-openapi-coverage` (THRESHOLD=36%) e `check-ui-keys-coverage` (65%) são pisos fixos manuais — não ratcheteiam; rotas/strings novas sem doc/i18n passam enquanto o % não cai do piso | P2 | 6A.11 |
|
||||
| C12 | `check-test-masking`: (a) `--diff-filter=M` não vê teste **DELETADO** (o masking mais brutal); (b) não vê `.skip`/`.todo`/`.only` adicionados (mantêm os asserts no texto, mas nunca rodam); (c) tautologia só cobre `assert.ok(true)` | P1 | 6A.10 |
|
||||
| D1 | **`ci.yml` roda gates apenas em `pull_request → main`** — todo o ciclo de PRs feature→`release/vX` passa SEM gate; as violações acumulam por semanas e estouram juntas no merge release→main (observado no gate da v3.8.18: 4 fixes de typecheck + ReDoS de última hora) | P1 — decisão do owner | 6A.6 |
|
||||
| D2 | `.husky/pre-push` está 100% comentado, mas o CLAUDE.md afirma "pre-push: npm run test:unit" (drift doc↔real) | P2 | 6A.12 |
|
||||
| D3 | **CLAUDE.md não menciona nenhum dos 18 gates** — um agente futuro não sabe que existem, qual a política de allowlist ("corrija, não congele"), nem como apertar baselines. Hard Rule #9 ainda diz "≥60%" (a catraca real é 80/80/82/73) | **P0 — anti-alucinação para os próprios agentes** | 6A.4 |
|
||||
| D4 | `check-duplication` roda `npx --yes jscpd@4` — pacote **não pinado por lockfile**, baixado do registry a cada run do CI (supply-chain + flakiness + latência); contraria o espírito do próprio `check-deps` | P2 | 6A.12 |
|
||||
| D5 | A skill `/quality-scan` roda ~20 comandos um a um — falta um runner agregador paralelo | P2 | 6A.12 |
|
||||
| D6 | Baselines de coverage com folga de ~2,5pt (80/80/82/73 vs real ~82,6/82,6/84,2/75,2) — a nota "_aperte após o 1º run verde_" existe no JSON mas não é tarefa de ninguém | P1 | 6A.5 |
|
||||
| E4 | Sem guarda contra artefato trackeado por engano — `node_modules` symlink já foi commitado 2× neste repo (`git add -A` em worktree) | P2 | 6A.12 |
|
||||
|
||||
**Decisões conscientes de NÃO fazer (avaliadas e descartadas, com motivo):**
|
||||
- **Gate de idempotência de migrations via regex** — SQLite não tem `ADD COLUMN IF NOT EXISTS`; idempotência vive em try/catch do runner; regex seria frágil (FP/FN). O `check-migration-numbering` + revisão humana bastam.
|
||||
- **Endurecer `check-docs-counts-sync` para fail** — contagens em prosa são heurísticas; soft-fail é o design correto. A cobertura de MCP tools entra pela 6A.9 (símbolos, não prosa).
|
||||
- **Sentido inverso do provider-consistency (providers.ts → REGISTRY)** — muitos providers canônicos legitimamente não têm entrada no REGISTRY (web/OAuth-only); a allowlist nasceria com dezenas de entradas e baixa razão sinal/ruído. Reavaliar quando o refactor #3501 tocar o split de providers.
|
||||
- **Complexidade por-função/por-arquivo (formato any-budget)** — upgrade real, mas o count global + `max-lines-per-function` já bloqueiam o grosso; o formato per-file entra junto com `sonarjs/cognitive-complexity` na Fase 7 Task 5 para não pagar duas migrações de baseline.
|
||||
|
||||
---
|
||||
|
||||
# Tasks
|
||||
|
||||
## P0 — bugs sistêmicos + documentação
|
||||
|
||||
### Task 6A.1 — `check-test-discovery` + religamento triado dos ~135 testes órfãos ⭐
|
||||
|
||||
**O achado nº1 da auditoria.** Testes que não rodam são o falso verde definitivo — todo o investimento anti test-masking da Fase 4 protege asserts de testes que **nem executam**.
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/check/check-test-discovery.mjs` + `tests/unit/check-test-discovery.test.ts`
|
||||
- Modify: `package.json` (globs de `test:unit`, `test:coverage`), `.github/workflows/ci.yml` (globs dos shards 8×/node24/node26), `vitest.mcp.config.ts` ou `vitest.config.ts` (se algum subdir for re-homed para vitest)
|
||||
- Create: `test-discovery-baseline.json` (órfãos ainda-não-religados, catraca `down` até zerar)
|
||||
|
||||
**Approach (expandir em sub-plano na execução):**
|
||||
1. **Gate primeiro (TDD):** `check-test-discovery.mjs` enumera todo `**/*.{test,spec}.{ts,tsx}` do repo (fora de `node_modules`/`.next`) e verifica que cada arquivo é coletado por ≥1 runner: (a) globs do node test runner extraídos de `package.json`/`ci.yml`; (b) `include` dos dois `vitest.*config.ts`; (c) projetos Playwright. Órfão fora do baseline → exit 1. O baseline congela os órfãos atuais (catraca: não pode SUBIR; religamentos a fazem cair até `{}`).
|
||||
2. **Inventário verde/vermelho:** rodar cada subdir órfão isoladamente (`node --import tsx --test tests/unit/<dir>/*.test.ts`), registrar passa/falha. *Não* ligar tudo de uma vez — a amostra já provou que há vermelhos (`authz/routeGuard.test.ts`: 2 asserts).
|
||||
3. **Religar os verdes:** trocar o glob principal para recursivo — `"tests/unit/**/*.test.ts"` ENTRE ASPAS (expandido pelo test runner do Node, não pelo shell; **Step 0: validar o suporte a glob do runner na menor versão de Node suportada pelo repo** — fallback: listar os subdirs explicitamente) — em `test:unit`, `test:coverage` e nos 3 lugares do `ci.yml` (shards 8×, node24, node26). Remover religados do baseline.
|
||||
4. **Triar os vermelhos (Hard Rule #18 em cada um):** teste desatualizado → atualizar para o comportamento real (e provar que o comportamento real é o desejado); bug real revelado → fix TDD; teste de feature morta → deletar com justificativa no commit. Os 2 asserts do `routeGuard.test.ts` ("allows … with valid CLI token") são o primeiro caso: provável drift do peer-stamp de 2026-05-31 — MAS, por ser superfície de segurança (#15/#17), confirmar com cuidado que é o teste que está errado, não o guard.
|
||||
5. **Recalibrar cobertura:** religar ≈135 arquivos muda o denominador/numerador da cobertura — re-medir e apertar `quality-baseline.json` via `--update` no mesmo PR (resolve também o D6).
|
||||
|
||||
**Acceptance:** `check-test-discovery` no CI (job lint); zero órfãos fora do baseline; baseline decrescente documentado; suíte verde com os religados; cobertura recalibrada.
|
||||
|
||||
### Task 6A.2 — vitest no CI
|
||||
|
||||
**Files:** `.github/workflows/ci.yml` (job novo `test-vitest`, paralelo aos shards; NÃO tocar nos triggers — apenas adicionar job).
|
||||
|
||||
**Approach:** job com `npm ci` + `npm run test:vitest` (+ `test:vitest:ui` se o tempo couber; senão segundo step). Rodar localmente primeiro para garantir verde (a suíte passa hoje fora da esteira — confirmar). Se houver vermelho pré-existente, triagem antes do wire (mesmo protocolo da 6A.1 passo 4).
|
||||
|
||||
**Acceptance:** PR→main roda as DUAS suítes; o claim do CLAUDE.md ("both must be green") vira verdade mecânica.
|
||||
|
||||
### Task 6A.3 — Stale-allowlist enforcement em todos os gates (suppression hygiene)
|
||||
|
||||
**O endurecimento sistêmico nº1.** Padrão validado (ESLint `--report-unused-disable-directives`; Notion): exclusão que não exclui nada vivo é dívida fantasma e furo de regressão.
|
||||
|
||||
**Files:** todos os gates com allowlist + seus testes:
|
||||
`check-fetch-targets` (KNOWN_MISSING, 7) · `check-provider-consistency` (KNOWN_REGISTRY_ONLY, 1) · `check-openapi-routes` (KNOWN_STALE_SPEC, 1) · `check-public-creds` (KNOWN_LITERAL_CREDS, 5) · `check-db-rules` (KNOWN_UNEXPORTED 25 + KNOWN_RAW_SQL 15) · `check-docs-symbols` (KNOWN_STALE_DOC_REFS, 30) · `check-migration-numbering` (KNOWN_GAPS/DUPLICATES) · `check-error-helper` (KNOWN_MISSING_ERROR_HELPER, 7 — promover o WARN existente a FAIL e cobrir também "arquivo existe mas não viola mais") · `check-deps` (dependency-allowlist: entrada sem dep correspondente em manifest algum = stale) · `check-file-size` (entrada `frozen` cujo arquivo foi deletado/renomeado) · `check-route-guard-membership` (KNOWN_UNCLASSIFIED — vazio hoje; implementar o check para quando deixar de ser).
|
||||
|
||||
**Approach (mecânica única, TDD por gate):** após a detecção normal, re-avaliar cada entrada da allowlist: *"se esta entrada não existisse, o gate flagaria algo?"* — para allowlists de path/valor isso é `violationsDetectadas.has(entry)`; para arquivos, existência + violação presente. Entrada que não suprime nada → **exit 1** com mensagem `entrada obsoleta — a violação foi corrigida; REMOVA a entrada para travar a correção`. Extrair helper comum `reportStaleEntries(allowlist, liveViolations, gateName)` em `scripts/check/lib/allowlist.mjs` para não duplicar 11×.
|
||||
|
||||
**Acceptance:** corrigir qualquer violação congelada (ex.: as issues #3483–#3501) passa a EXIGIR a remoção da entrada no mesmo PR; teste sintético prova fail-on-stale em cada gate.
|
||||
|
||||
### Task 6A.4 — Documentar os gates no CLAUDE.md (+ corrigir drifts de doc)
|
||||
|
||||
**Files:** `CLAUDE.md`, `AGENTS.md` (se houver seção espelho), `docs/architecture/` (página `QUALITY_GATES.md` referenciada pela tabela de docs).
|
||||
|
||||
**Approach:** (1) seção nova "Quality Gates & Ratchets" no CLAUDE.md: tabela dos gates (nome → o que trava → allowlist/baseline), a política **"corrija a causa; allowlist só com justificativa + issue"**, como apertar (`npm run quality:ratchet -- --update`, `check:<gate> -- --update`), e o que fazer quando um gate falha num PR. (2) Corrigir: Hard Rule #9 (60% → "catraca de cobertura: nunca abaixo do baseline congelado em `quality-baseline.json`; piso absoluto 60"), claim do pre-push (refletir o real pós-6A.12), claim "both runners" (verdade pós-6A.2). (3) Página `docs/architecture/QUALITY_GATES.md` com o detalhe operacional (o CLAUDE.md fica curto, linka). Rodar `check:docs-all` após editar (o próprio docs-sync valida).
|
||||
|
||||
**Acceptance:** agente novo lendo o CLAUDE.md descobre os gates e a política sem ler scripts; `check:docs-all` verde.
|
||||
|
||||
## P1 — endurecimento do motor + escopos
|
||||
|
||||
### Task 6A.5 — Motor v2: `--require-tighten`, eps por métrica, métricas órfãs
|
||||
|
||||
**Files:** `scripts/quality/check-quality-ratchet.mjs`, `quality-baseline.json` (schema), `tests/unit/quality-ratchet.test.ts`, `.github/workflows/ci.yml` (flag no job quality-gate).
|
||||
|
||||
**Approach (TDD):**
|
||||
1. Schema por métrica ganha campos opcionais: `eps` (default 0.01) e `tightenSlack` (default: igual a `eps`).
|
||||
2. Novo modo `--require-tighten` (ligado no CI): se `atual` melhor que `baseline` além de `tightenSlack`, **exit 1** com `melhorou de X para Y — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`. Métricas determinísticas (`eslintWarnings`) usam slack 0; cobertura usa slack 1.5 (flutuação v8). É o "auto-decrement" da Notion adaptado a CI sem bot de commit.
|
||||
3. Warning para métricas presentes em `quality-metrics.json` sem entrada no baseline (coletor órfão).
|
||||
4. Calibrar os 4 `coverage.*` para o real medido (fecha D6 — coordenar com a 6A.1 passo 5, que muda a base).
|
||||
|
||||
**Acceptance:** melhoria sem aperto de baseline falha no CI; flutuação de coverage dentro do slack não falha; testes cobrem os 3 comportamentos novos.
|
||||
|
||||
### Task 6A.6 — `quality.yml`: gates rápidos em PRs → `release/**` ⚠️ DECISÃO DO OWNER
|
||||
|
||||
**Contexto sensível:** o owner já reverteu mudança de trigger no `ci.yml` ("não mexe na CI"). Esta task **não toca o `ci.yml`** — cria um workflow NOVO e enxuto. Ainda assim, **passo 0 = confirmação explícita do owner**.
|
||||
|
||||
**Files:** Create: `.github/workflows/quality.yml`.
|
||||
|
||||
**Approach:** `on: pull_request: branches: ["release/**"]`; um job único (~1–2 min) só com os gates determinísticos filesystem-only: provider-consistency, fetch-targets, openapi-routes, docs-symbols, deps, file-size, error-helper, migration-numbering, public-creds, db-rules, known-symbols, route-guard-membership, test-discovery (pós-6A.1) + `check:any-budget:t11`. SEM lint/test/build (continuam só no PR→main). Ganho: a violação aparece no PR que a introduz, não semanas depois no gate do release (padrão observado: 4 fixes de última hora no release da v3.8.18).
|
||||
|
||||
**Acceptance:** PR de teste contra a release branch com uma rota inventada falha em <2 min; PRs limpos não ganham mais que ~2 min de CI.
|
||||
|
||||
### Task 6A.7 — `check-fetch-targets` v2: escopo completo + prefixo de template + método HTTP
|
||||
|
||||
**Files:** `scripts/check/check-fetch-targets.mjs`, `tests/unit/check-fetch-targets.test.ts`.
|
||||
|
||||
**Approach (TDD):**
|
||||
1. **Escopo:** varrer todo `src/**/*.{ts,tsx}` client-side (excluindo `src/app/api/**`, testes, `src/lib/db`), não só `(dashboard)` — congela os misses pré-existentes que aparecerem em `KNOWN_MISSING` (com triagem/issue por cluster, igual Fase 2).
|
||||
2. **Template literals:** extrair o prefixo estático de `` fetch(`/api/x/y/${id}…`) `` e validar por **prefix-match** contra as rotas reais (existe alguma rota cujo path começa com `/api/x/y/`?). Pega diretório inteiro alucinado; não tenta resolver o sufixo dinâmico.
|
||||
3. **Método HTTP (heurístico, mesma chamada):** quando o 2º argumento literal contém `method: "POST"` (etc.), verificar que o `route.ts` resolvido exporta a função correspondente (`grep` por `export (async )?function POST` / `export const POST`). Sem method literal → assume GET-ok (rota existe basta). Casos dinâmicos → skip silencioso.
|
||||
|
||||
**Acceptance:** fixture com fetch em `src/shared/components` + template com prefixo falso + `method: "DELETE"` para rota só-GET — 3 detecções; repo real verde com os novos congelados documentados.
|
||||
|
||||
### Task 6A.8 — Escopo dos gates de segurança: error-helper, public-creds, route-guard, deps
|
||||
|
||||
**Files:** `check-error-helper.mjs`, `check-public-creds.mjs`, `check-route-guard-membership.ts`, `check-deps.mjs` + testes.
|
||||
|
||||
**Approach (TDD, um sub-commit por gate):**
|
||||
1. **error-helper** (+Rule #12 completa): incluir `open-sse/mcp-server/**` e `src/app/api/**/route.ts` no SCAN_DIRS; rodar; congelar os achados novos em KNOWN com comentário-justificativa cada (e issue por cluster).
|
||||
2. **public-creds**: além dos 2 arquivos âncora, varrer `open-sse/**` e `src/lib/oauth/**` com a mesma `CRED_KEY_RE` (linha a linha, barato); congelar achados. Limitação documentada: `const CLIENT_ID = "…"` (variável solta) continua fora — o gitleaks da Fase 7 cobre essa classe.
|
||||
3. **route-guard**: novo sub-check — todo `route.ts` (qualquer raiz) cujo fonte OU imports de 1º nível relativos contenham `child_process`/`spawn(`/`execFile(`/`worker_threads` deve ser classificado local-only por `isLocalOnlyPath()`. Mata a dependência da lista manual de 3 raízes.
|
||||
4. **deps**: `MANIFESTS` → descoberta automática de todo `package.json` do repo (fora `node_modules`/`.next`): hoje raiz, `electron/`, `open-sse/`, `@omniroute/opencode-plugin/` (dep `zod` entra na allowlist), `@omniroute/opencode-provider/`. Workspace novo amanhã entra sozinho.
|
||||
|
||||
**Acceptance:** fixtures sintéticas por gate; repo real verde com achados congelados + issues; dep nova em QUALQUER manifest do repo dispara o gate.
|
||||
|
||||
### Task 6A.9 — `check-known-symbols` v2: MCP tools, A2A skills, cloud agents
|
||||
|
||||
**Files:** `scripts/check/check-known-symbols.ts`, `tests/unit/check-known-symbols.test.ts`.
|
||||
|
||||
**Approach (TDD, mesmo padrão das 3 superfícies existentes — Step 0 de verificação dos exports reais antes de codar):**
|
||||
1. **MCP tools:** enumerar os tools registrados (via `createMcpServer()` ou parse determinístico do tool-set em `open-sse/mcp-server/tools/`) e congelar o snapshot de nomes (catraca: tool sumir = fail; tool novo = report). Cruzar com os scopes (~13) — tool sem scope atribuído = fail.
|
||||
2. **A2A skills:** chaves de `A2A_SKILL_HANDLERS` (`src/lib/a2a/taskExecution.ts`) ↔ skills expostas no Agent Card (`src/app/.well-known/agent.json/route.ts`) — divergência = fail.
|
||||
3. **Cloud agents:** entradas do `src/lib/cloudAgent/registry.ts` ↔ classes em `agents/` — incompleto/órfão = fail.
|
||||
|
||||
**Acceptance:** remover um tool/skill/agent do registro quebra o gate; adicionar reporta (e `check-docs-counts-sync` continua cuidando da prosa).
|
||||
|
||||
## P2 — refinamentos
|
||||
|
||||
### Task 6A.10 — `check-test-masking` v2: deleções, skips, tautologias
|
||||
|
||||
**Files:** `scripts/check/check-test-masking.mjs`, `tests/unit/check-test-masking.test.ts`.
|
||||
|
||||
**Approach (TDD):** (a) `--diff-filter=M` → `MDR` (com `-M` para rename-detection): arquivo de teste **deletado** = flag automático ("N asserts removidos — arquivo deletado"); renamed = comparar contra o path antigo. (b) Contar `\.(skip|todo|only)\s*\(` + `\{\s*skip:\s*true` base vs HEAD — **aumento líquido de skips = flag** (skip novo esconde asserts sem removê-los); `.only` novo = flag sempre (filtra o resto da suíte). (c) Tautologias extras: `expect(true).toBe(true)`, `assert.equal(1, 1)`, `expect(x).toBeDefined()` como ÚNICO assert do teste.
|
||||
|
||||
**Acceptance:** fixtures para os 3 bypasses (delete, skip, only) — todos flagados; suíte real verde.
|
||||
|
||||
### Task 6A.11 — Pisos manuais → catraca do motor + escopo electron/bin
|
||||
|
||||
**Files:** `scripts/quality/collect-metrics.mjs`, `quality-baseline.json`, `check-openapi-coverage.mjs`, `scripts/i18n/check-ui-keys-coverage.mjs` (só leitura do valor), `check-file-size.mjs`, `eslint.complexity.config.mjs` + baselines.
|
||||
|
||||
**Approach:** (1) coletor emite `openapiCoverage.pct` e `i18nUiCoverage.pct` → baseline `{direction: up}` com o valor real atual (36→real, 65→real); os THRESHOLDs fixos viram redundância de segurança (mantidos). Rotas/strings novas sem doc/i18n agora REGRIDEM o % e falham. (2) `check-file-size` e `check-complexity`: adicionar `electron/` e `bin/` ao scan (congelar os >cap que existirem).
|
||||
|
||||
**Acceptance:** rota nova não-documentada derruba `openapiCoverage.pct` → gate falha; god-file novo em `electron/` falha.
|
||||
|
||||
### Task 6A.12 — Higiene operacional (4 itens pequenos)
|
||||
|
||||
**Files:** `package.json`, `dependency-allowlist.json`, `.husky/pre-push`, Create: `scripts/check/check-tracked-artifacts.mjs`, `scripts/quality/run-all-gates.mjs`; Modify: `.agents/skills/quality-scan/SKILL.md`.
|
||||
|
||||
**Approach:**
|
||||
1. **jscpd pinado:** `jscpd@^4` como devDependency (entra no lockfile + allowlist); `check-duplication` chama o binário local em vez de `npx --yes jscpd@4` (remove download de registry no CI + supply-chain risk + flakiness).
|
||||
2. **pre-push barato:** reativar com APENAS os gates determinísticos rápidos (<10s: fetch-targets, openapi-routes, db-rules, public-creds, migration-numbering, file-size, deps, error-helper) — NÃO `test:unit` (lento; CI cobre). Atualizar o claim do CLAUDE.md (coordenar com 6A.4).
|
||||
3. **check-tracked-artifacts:** falhar se `git ls-files` contém `node_modules/`, `.next/`, `coverage/`, `quality-metrics.json` ou symlink para fora do repo (o incidente do symlink trackeado já aconteceu 2×). Wire no lint job + pre-commit (é instantâneo).
|
||||
4. **Runner agregador:** `scripts/quality/run-all-gates.mjs` roda os gates em paralelo (pool ~4), agrega `{gate, exitCode, lastLine, durationMs}` e imprime a tabela consolidada; `npm run quality:scan`. A skill `/quality-scan` passa a chamá-lo (atualizar SKILL.md).
|
||||
|
||||
**Acceptance:** `npm run quality:scan` < 3 min com tabela única; pre-push roda <10s; `git add node_modules && commit` falha no pre-commit.
|
||||
|
||||
---
|
||||
|
||||
## Ordem de execução recomendada (na ativação, 2026-06-16+)
|
||||
|
||||
1. **6A.1 + 6A.2** (bugs de runner — destravam números reais de cobertura para o resto)
|
||||
2. **6A.3 + 6A.4** (stale-enforcement + docs — endurecem o que já roda)
|
||||
3. **6A.5** (motor v2) → **6A.6** (quality.yml, após OK do owner)
|
||||
4. **6A.7 → 6A.9** (escopos)
|
||||
5. **6A.10 → 6A.12** (refinamentos)
|
||||
6. Só então **Fase 7** (ferramentas novas sobre uma fundação consertada)
|
||||
|
||||
## Self-Review
|
||||
- **Cobertura dos achados:** todos os achados A*/B*/C*/D*/E* da tabela têm task (coluna Task); os descartados estão em "Decisões conscientes de NÃO fazer" com motivo. ✓
|
||||
- **Zero dependência nova** exceto a promoção do jscpd (que já roda hoje via npx, não-pinado — a task REDUZ risco). ✓
|
||||
- **Sem flag-day:** toda expansão de escopo congela os achados pré-existentes (allowlist + issue), igual Fases 0–6; o stale-enforcement só exige remoção quando a correção JÁ aconteceu. ✓
|
||||
- **Consistência com o motor:** novas métricas (`openapiCoverage.pct`, `i18nUiCoverage.pct`) usam o formato `{value, direction}`; `eps`/`tightenSlack` são opcionais e retrocompatíveis. ✓
|
||||
- **Não-duplicação com a Fase 7:** cognitive-complexity per-file, gitleaks (creds não-públicas), knip, osv — tudo continua na Fase 7; a 6A só conserta/endurece o existente. ✓
|
||||
@@ -1,182 +0,0 @@
|
||||
# Fase 7 — Quality Gates: Segurança, Dead-Code, Mutação & Ferramental Community
|
||||
|
||||
> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans`, tarefa-a-tarefa. Cada tarefa aqui é um subsistema independente → **expandir em sub-plano bite-sized próprio** no momento da execução. Hard Rule #18 (TDD/VPS) em tudo.
|
||||
|
||||
> # ⏳ PORTÃO DE ATIVAÇÃO — NÃO INICIAR ANTES DE **2026-06-16**
|
||||
> **Este plano está GUARDADO, não ativo.** Decisão do owner (2026-06-09): finalizar 100% as Fases 0–6 (PR #3471), **usar em produção por 1 semana** para validar na prática, e só então evoluir. **Data cravada de início da Fase 7: 2026-06-16.** Não ativar antes — o objetivo da semana é coletar sinal real (falsos-positivos dos gates, custo de CI, atrito) antes de adicionar mais.
|
||||
> **Pré-condições para ativar:** (1) PR #3471 (Fases 0–6) mergeada e rodada ≥1 semana; (2) re-home do PR para `release/v3.8.18` resolvido; (3) as issues #3483–#3501 com decisões aplicadas ou conscientemente adiadas; (4) **Fase 6A executada (ou conscientemente re-priorizada)** — a auditoria crítica de 2026-06-09 ([`PLANO-QUALITY-GATES-FASE6A.md`](./PLANO-QUALITY-GATES-FASE6A.md)) encontrou bugs sistêmicos de runner (≈135 testes órfãos, vitest fora do CI) e furos de escopo nos gates existentes que devem ser consertados ANTES de adicionar ferramentas novas por cima.
|
||||
|
||||
**Goal:** Maximizar a cobertura de quality gates do OmniRoute adicionando catracas de **segurança** (Sonar/osv/CodeQL → zero na timeline), **dead-code**, **complexidade cognitiva**, **type-coverage**, **mutação**, **bundle-size**, **a11y** e completando o anti-slopsquatting — usando **somente ferramentas Community/OSS** (projeto é open-source, zero SaaS pago, dados na box).
|
||||
|
||||
**Architecture:** Reusa o motor existente — toda métrica numérica entra como `{value, direction}` em `quality-baseline.json` (catraca só-regressão) ou vira um `scripts/check/check-*.mjs` dedicado (padrão `check-t11-any-budget.mjs`). Gates pesados vão no job paralelo `quality-gate`; gates rápidos no `lint`; mutação/visual em job nightly separado. Tudo só-regressão (sem flag-day).
|
||||
|
||||
**Tech Stack (tudo OSS/Community):** SonarQube **Community Build** (self-hosted) · osv-scanner (Google) · CodeQL (GitHub, grátis p/ público) · knip · eslint-plugin-sonarjs · type-coverage · lockfile-lint · dpdm · Stryker (`@stryker-mutator/*`) · size-limit · `@axe-core/playwright` · semcheck · agent-lsp (MCP) · Qlty CLI (OSS, opcional) · **gitleaks** (secret scanning, MIT; avaliar o sucessor drop-in Betterleaks, 2026-03) · **actionlint + zizmor** (lint + auditoria de segurança dos workflows) · **license-compliance** (allowlist SPDX de licenças). ESLint 9 flat · c8 · Node native test runner · GitHub Actions.
|
||||
|
||||
---
|
||||
|
||||
## Princípio (igual às Fases 0–6)
|
||||
|
||||
Toda catraca é **só-regressão**: congela o baseline atual, bloqueia QUALQUER piora, decai a zero/melhor com o tempo via `--update`. Nenhum gate exige limpeza imediata (flag-day). Cada ferramenta nova que vira dependência **deve ser adicionada a `dependency-allowlist.json`** (o gate `check-deps` da Fase 2 vai exigir — é o ponto de revisão humana).
|
||||
|
||||
## Mapa de arquivos (criar/modificar)
|
||||
|
||||
| Arquivo | Responsabilidade |
|
||||
|---|---|
|
||||
| `quality-baseline.json` (modificar) | + `vulnCount`, `codeqlAlerts`, `sonarIssues`, `cognitiveComplexity`, `typeCoveragePct`, `deadExports` |
|
||||
| `scripts/quality/collect-metrics.mjs` (modificar) | + coletores: osv-scanner, CodeQL count, Sonar API, knip, type-coverage, sonarjs |
|
||||
| `scripts/check/check-vuln-ratchet.mjs` (criar) | osv-scanner → vulnCount (catraca) |
|
||||
| `scripts/check/check-dead-code.mjs` (criar) | knip → exports/files/deps mortos (catraca) |
|
||||
| `scripts/check/check-cognitive-complexity.mjs` + `eslint.sonarjs.config.mjs` (criar) | sonarjs/cognitive-complexity em config isolado (não polui o count principal) |
|
||||
| `scripts/check/check-type-coverage.mjs` (criar) | type-coverage % (catraca up) |
|
||||
| `scripts/check/check-lockfile.mjs` (criar) | lockfile-lint (host/https/integrity) |
|
||||
| `scripts/check/check-pr-evidence.mjs` (criar) | exige output de comando no corpo do PR (Rule #18 mecânica) |
|
||||
| `scripts/check/check-bundle-size.mjs` + `.size-limit.json` (criar) | size-limit → orçamento de bundle |
|
||||
| `tests/e2e/a11y.spec.ts` (criar) | `@axe-core/playwright` nas páginas-chave |
|
||||
| `stryker.conf.json` (criar) | mutação nos ~8 módulos críticos (nightly) |
|
||||
| `sonar-project.properties` (modificar) | remover `coverage`/`cpd` exclusions; ativar new-code gate |
|
||||
| `.github/workflows/ci.yml` (modificar) | wirar novos gates (lint / quality-gate / nightly) + `qualitygate.wait` no Sonar |
|
||||
| `semcheck.yaml` (criar) | semcheck: docs↔código (camada fuzzy LLM, opcional) |
|
||||
| `.mcp.json` / config de agentes (modificar) | registrar agent-lsp (LSP-in-the-loop) |
|
||||
| `.gitleaks.toml` + `scripts/check/check-secrets.mjs` (criar) | gitleaks → catraca de findings de secret (Task 18) |
|
||||
| `.github/workflows/quality.yml` ou job lint (modificar) | actionlint + zizmor sobre `.github/workflows/**` (Task 19) |
|
||||
| `scripts/check/check-licenses.mjs` + `.license-allowlist.json` (criar) | allowlist SPDX das licenças das deps (Task 20) |
|
||||
| `dependency-allowlist.json` (modificar) | + osv-scanner, knip, sonarjs, type-coverage, lockfile-lint, stryker, size-limit, axe-core, dpdm, license-compliance |
|
||||
|
||||
---
|
||||
|
||||
## Tarefas (cada uma = 1 sub-plano bite-sized na execução)
|
||||
|
||||
### Task 1 — Ativar SonarQube Community + "Clean as You Code" (gate de segurança nativo)
|
||||
- **Tool:** SonarQube Community Build (self-hosted, grátis).
|
||||
- **Files:** `sonar-project.properties`, `.github/workflows/ci.yml` (job `sonarqube`).
|
||||
- **Approach:** setar secrets `SONAR_TOKEN`/`SONAR_HOST_URL`; **remover** `sonar.coverage.exclusions=**/*` e `sonar.cpd.exclusions=**/*` (hoje neutralizam o Sonar); ativar o quality gate **new-code / "Clean as You Code"** (código novo não pode adicionar issue/bug/vuln/hotspot; legado grandfathered) + adicionar `-Dsonar.qualitygate.wait=true` para **bloquear** o PR (hoje o job é inerte: secrets-gated, sem wait).
|
||||
- **Acceptance:** PR que introduz um code-smell/bug/vuln em código novo falha o gate; legado não bloqueia. Documentar suppressions legítimas (já há h1–h6 no properties).
|
||||
|
||||
### Task 2 — Catraca de vulnerabilidades (osv-scanner)
|
||||
- **Tool:** osv-scanner (Google/OSV, OSS) — `--format json`, on-box.
|
||||
- **Files:** `scripts/check/check-vuln-ratchet.mjs`, `quality-baseline.json` (+`vulnCount`), `collect-metrics.mjs`, `ci.yml` (job `quality-gate`), `dependency-allowlist.json`.
|
||||
- **Approach:** rodar `osv-scanner --format json` sobre os lockfiles → contar vulns → métrica `vulnCount {direction: down}`. Catraca: não pode subir, decai a zero. Mantém o `npm audit` escalonado da Fase 0 como bloqueio de crítico imediato; osv é o ratchet de timeline.
|
||||
- **Acceptance:** nova dep com vuln conhecida sobe o count → falha; remediar/remover baixa → `--update`.
|
||||
|
||||
### Task 3 — Catraca de alertas CodeQL
|
||||
- **Tool:** GitHub CodeQL (já roda; grátis p/ repo público).
|
||||
- **Files:** `scripts/check/check-codeql-ratchet.mjs`, `quality-baseline.json` (+`codeqlAlerts`), `ci.yml`.
|
||||
- **Approach:** puxar a contagem de alertas abertos via `gh api /repos/{owner}/{repo}/code-scanning/alerts?state=open` → métrica `codeqlAlerts {down}`. (Respeitar Hard Rule #14 — dismiss só com justificativa; alertas dismissed não contam.)
|
||||
- **Acceptance:** novo alerta CodeQL sobe o count → sinaliza; resolver baixa.
|
||||
|
||||
### Task 4 — Dead-code / unused-exports / unused-deps (knip)
|
||||
- **Tool:** knip (OSS, v6+) — `--reporter json`.
|
||||
- **Files:** `scripts/check/check-dead-code.mjs`, `quality-baseline.json` (+`deadExports`/`unusedDeps`), `knip.json` (config), `collect-metrics.mjs`, `ci.yml`, `dependency-allowlist.json`.
|
||||
- **Approach:** `knip --reporter json` sobre os workspaces `src/`+`open-sse/` → contar unused files/exports/deps → catraca `down`. Config knip ciente do monorepo + Next 16.
|
||||
- **Acceptance:** novo export/dep morto sobe → falha; remoção baixa.
|
||||
|
||||
### Task 5 — Complexidade cognitiva (eslint-plugin-sonarjs, config isolado)
|
||||
- **Tool:** eslint-plugin-sonarjs (OSS) — `sonarjs/cognitive-complexity`.
|
||||
- **Files:** `eslint.sonarjs.config.mjs` (config standalone, NÃO o principal — não polui o `eslintWarnings=3482`), `scripts/check/check-cognitive-complexity.mjs`, `quality-baseline.json` (+`cognitiveComplexity`), `ci.yml` (job `quality-gate`), `dependency-allowlist.json`.
|
||||
- **Approach:** mesmo molde do `check-complexity` (Fase 6) mas com `sonarjs/cognitive-complexity` num config isolado; contar violações → catraca `down`. (Complementa a complexidade ciclomática core já existente.)
|
||||
- **Acceptance:** função acima do limite cognitivo sobe o count → falha.
|
||||
|
||||
### Task 6 — Type-coverage ratchet
|
||||
- **Tool:** type-coverage (OSS).
|
||||
- **Files:** `scripts/check/check-type-coverage.mjs`, `quality-baseline.json` (+`typeCoveragePct {up}`), `dependency-allowlist.json`.
|
||||
- **Approach:** `type-coverage --detail --json` → % de símbolos tipados → catraca `up`. Complementa o `check:any-budget` (count de `any` por arquivo) com a visão %-global.
|
||||
- **Acceptance:** queda do % tipado → falha.
|
||||
|
||||
### Task 7 — Lockfile policy (lockfile-lint)
|
||||
- **Tool:** lockfile-lint (OSS, v5).
|
||||
- **Files:** `scripts/check/check-lockfile.mjs`, `ci.yml` (lint), `dependency-allowlist.json`.
|
||||
- **Approach:** `lockfile-lint --path package-lock.json --type npm --validate-https --validate-integrity --allowed-hosts npm` → gate pass/fail (não é ratchet; é política anti-poisoning). Complementa o `check-deps` (Fase 2).
|
||||
- **Acceptance:** lockfile com host não-https/sem integrity → falha.
|
||||
|
||||
### Task 8 — Completar anti-slopsquatting (registry-existence + age-cooldown)
|
||||
- **Tool:** npm registry API (`npm view <pkg> time.created`).
|
||||
- **Files:** `scripts/check/check-deps.mjs` (estender), test.
|
||||
- **Approach:** além do allowlist-diff atual, para uma dep NOVA: verificar que existe no registry E que foi publicada há ≥72h (age-cooldown contra "registra o nome alucinado em horas"). Base: CSA 2026 (19,7% de nomes alucinados; 43% reaparecem).
|
||||
- **Acceptance:** dep nova inexistente no registry ou publicada há <72h → falha (a menos que allowlistada com justificativa).
|
||||
|
||||
### Task 9 — Pisos de cobertura por módulo crítico (peça adiada da Fase 4)
|
||||
- **Files:** `scripts/quality/collect-metrics.mjs` (estender), `quality-baseline.json`.
|
||||
- **Approach:** emitir `coverage.<modulo>.lines` (lido do `coverage-summary.json` por-arquivo) para ~8 módulos de alto risco: `open-sse/handlers/chatCore.ts`, `open-sse/services/combo.ts`, `open-sse/services/accountFallback.ts`, `src/sse/services/auth.ts`, `src/server/authz/routeGuard.ts`, `open-sse/utils/error.ts`, `open-sse/utils/publicCreds.ts`, `src/shared/utils/circuitBreaker.ts`. Cada um vira métrica `up`. **Calibrar a partir do coverage mergeado real do 1º run verde na main.**
|
||||
- **Acceptance:** queda de cobertura num módulo crítico → falha, mesmo que o global não caia.
|
||||
|
||||
### Task 10 — Evidence-in-PR-body (peça adiada da Fase 5)
|
||||
- **Files:** `scripts/check/check-pr-evidence.mjs`, `ci.yml` (job `pr-test-policy`).
|
||||
- **Approach:** se o corpo do PR afirma "tests pass"/"added endpoint X"/"fixed Y" sem um bloco de **output de comando** anexado (typecheck/test/grep), falha (torna a Rule #18 mecânica — "evidence before assertions"). Heurístico, no contexto de PR.
|
||||
- **Acceptance:** PR alegando sucesso sem output anexado → falha.
|
||||
|
||||
### Task 11 — Mutation testing nos módulos críticos (Stryker, nightly)
|
||||
- **Tool:** Stryker (`@stryker-mutator/core` + runner; OSS).
|
||||
- **Files:** `stryker.conf.json`, `ci.yml` (job NIGHTLY separado — não no PR), `dependency-allowlist.json`.
|
||||
- **Approach:** escopar Stryker aos ~8 módulos críticos da Task 9 (não repo-wide — é caro + c8 já OOM-prone). Mutantes sobreviventes = **testes tautológicos** (passam sem provar nada) → complementa o `check-test-masking` da Fase 4. Rodar nightly/weekly, não por-PR.
|
||||
- **Acceptance:** mutation score por módulo crítico vira métrica (catraca `up`, nightly).
|
||||
|
||||
### Task 12 — Bundle-size / perf budget (size-limit)
|
||||
- **Tool:** size-limit (OSS).
|
||||
- **Files:** `.size-limit.json`, `scripts/check/check-bundle-size.mjs`, `ci.yml`, `dependency-allowlist.json`.
|
||||
- **Approach:** definir orçamento por bundle Next 16; size-limit emite tamanhos → catraca `down` (bundle não pode inchar).
|
||||
- **Acceptance:** PR que estoura o orçamento de bundle → falha.
|
||||
|
||||
### Task 13 — a11y gate (axe-core + Playwright)
|
||||
- **Tool:** `@axe-core/playwright` (OSS; Playwright já existe).
|
||||
- **Files:** `tests/e2e/a11y.spec.ts`, `ci.yml` (job `test-e2e`), `dependency-allowlist.json`.
|
||||
- **Approach:** rodar axe nas páginas-chave do dashboard; congelar violações atuais (catraca `down`). Atende o item a11y/visual do plano t15.
|
||||
- **Acceptance:** nova violação a11y → falha; correção baixa.
|
||||
|
||||
### Task 14 — semcheck (camada fuzzy docs↔código, opcional/LLM)
|
||||
- **Tool:** semcheck (OSS, MIT) — `fail-on-issues`.
|
||||
- **Files:** `semcheck.yaml`, `ci.yml` (advisory).
|
||||
- **Approach:** regras ligando `docs/**` ao módulo de código que documentam; pega docs que descrevem o que o código NÃO faz (camada fuzzy sobre o determinístico `check-docs-symbols` da Fase 6). É LLM → rodar advisory/non-blocking ou em label, por custo.
|
||||
- **Acceptance:** doc que descreve comportamento inexistente → flag (advisory).
|
||||
|
||||
### Task 15 — agent-lsp (LSP-in-the-loop para os agentes)
|
||||
- **Tool:** agent-lsp (MCP server, OSS).
|
||||
- **Files:** config MCP dos agentes (`.mcp.json`/equivalente).
|
||||
- **Approach:** expor `tsserver`/agent-lsp aos agentes para `blast_radius`/diagnostics/`preview_edit` ANTES de escrever — vira "símbolo inventado" de catch-de-review para impossibilidade-no-edit. Pareia com `typecheck:core` como gate pré-PR (compile-before-claim).
|
||||
- **Acceptance:** agentes resolvem símbolo/import via LSP; menos alucinação de símbolo na origem.
|
||||
|
||||
### Task 16 — dpdm circular-deps JSON cross-check (opcional)
|
||||
- **Tool:** dpdm (OSS, v4) — `--circular --output`.
|
||||
- **Approach:** cross-check JSON de ciclos complementando o `check-cycles.mjs` existente (AST-TS mais preciso). Catraca de contagem de ciclos. Baixa prioridade (já temos check-cycles).
|
||||
|
||||
### Task 17 — Avaliar Qlty CLI como consolidador (opcional, spike)
|
||||
- **Tool:** Qlty CLI (OSS, grátis).
|
||||
- **Approach:** spike: avaliar se Qlty (Baseline analysis + 70 analyzers) substitui N scripts caseiros sem perder o controle/determinismo. Decisão build-vs-buy. Não obrigatório.
|
||||
|
||||
### Task 18 — Secret scanning local (gitleaks) ➕ *adicionada pela auditoria 6A (2026-06-09)*
|
||||
- **Tool:** gitleaks (OSS, MIT — binário Go, on-box). Nota 2026: o criador original (Zach Rice) lançou o **Betterleaks** (2026-03) como drop-in replacement (flags/config compatíveis) — avaliar os dois no Step 0 e escolher 1.
|
||||
- **Files:** `.gitleaks.toml`, `scripts/check/check-secrets.mjs`, `quality-baseline.json` (+`secretFindings {down}`), `ci.yml` (job quality-gate), pre-commit (modo `--staged`, é rápido).
|
||||
- **Approach:** complementa o `check-public-creds` da Fase 6 (que cobre apenas credenciais PÚBLICAS por chave de objeto em 2 arquivos): gitleaks pega a classe geral — `const API_KEY = "sk-…"`, tokens em config/teste/docs, secrets em histórico. Rodar `gitleaks dir --report-format json` → contar findings → catraca `down`. Findings legítimos (creds públicas já congeladas no check-public-creds, fixtures de teste) vão para `.gitleaks.toml` `[allowlist]` com comentário — sujeitos ao stale-enforcement da 6A.3 (conceitual: revisar allowlist a cada release).
|
||||
- **Acceptance:** secret real plantado em fixture é detectado; baseline congela os findings atuais; novo finding falha o gate.
|
||||
|
||||
### Task 19 — Lint + auditoria de segurança dos workflows (actionlint + zizmor) ➕ *adicionada pela auditoria 6A*
|
||||
- **Tools:** actionlint (OSS — correção/sintaxe/shellcheck dos YAML) + zizmor (OSS, zizmorcore — 24+ audits de segurança: unpinned actions, script injection, `pull_request_target` perigoso, cache poisoning). Complementares por design; o repo tem 10 workflows sem NENHUMA validação hoje.
|
||||
- **Motivação 2026:** o incidente trivy-action/LiteLLM (2026-03) explorou exatamente uma misconfiguração de `pull_request_target` que o zizmor detecta estaticamente. Os release-workflows do OmniRoute (npm/Docker/Electron publish) são alvo de alto valor.
|
||||
- **Files:** `ci.yml` ou `quality.yml` (steps actionlint + zizmor), `zizmor.yml` (config/ignores justificados), `quality-baseline.json` (+`zizmorFindings {down}` — começar advisory, congelar baseline, depois bloquear).
|
||||
- **Approach:** actionlint = pass/fail imediato (sintaxe não tem "legado aceitável"); zizmor = catraca `down` no padrão do motor (os findings atuais — provavelmente actions não-pinadas por SHA — são dívida congelada que decai).
|
||||
- **Acceptance:** workflow novo com `pull_request_target` + checkout de código do PR falha; action não-pinada NOVA sobe o count e falha.
|
||||
|
||||
### Task 20 — License compliance (allowlist SPDX) ➕ *adicionada pela auditoria 6A*
|
||||
- **Tool:** license-compliance ou @onebeyond/license-checker (ambos OSS, npm). Projeto é **MIT** — deps com copyleft forte (GPL/AGPL) em produção são risco de compliance para os usuários do proxy.
|
||||
- **Files:** `scripts/check/check-licenses.mjs`, `.license-allowlist.json` (SPDX permitidas: MIT, Apache-2.0, BSD-2/3, ISC, 0BSD, …), `ci.yml` (lint job), `dependency-allowlist.json`.
|
||||
- **Approach:** rodar sobre as `dependencies` de produção (devDependencies = relatório advisory); licença fora da allowlist → fail com o caminho da dep. Exceções pontuais (dual-license, LGPL avaliada) entram na allowlist por **pacote** com justificativa — pareia com o `check-deps` da Fase 2 (lá controla O QUE entra; aqui, SOB QUAL licença).
|
||||
- **Acceptance:** dep GPL-3.0 sintética em fixture falha; árvore atual passa com a allowlist calibrada.
|
||||
|
||||
---
|
||||
|
||||
## Wiring & CI (resumo)
|
||||
- **lint job:** check-lockfile, check-cognitive-complexity (rápido?), check-type-coverage, **check-licenses (Task 20)**, **actionlint (Task 19)**.
|
||||
- **quality-gate job (paralelo):** check-vuln-ratchet, check-dead-code, check-codeql-ratchet, check-cognitive-complexity (se lento), check-bundle-size, **check-secrets (Task 18)**, **zizmor (Task 19)**.
|
||||
- **pr-test-policy job:** check-pr-evidence.
|
||||
- **sonarqube job:** Clean-as-You-Code + `qualitygate.wait`.
|
||||
- **NIGHTLY job (novo):** Stryker (mutação), semcheck (advisory), a11y full.
|
||||
- Todas as métricas numéricas → `quality-baseline.json` (motor da Fase 1, com `eps`/`tightenSlack` da 6A.5). Toda dep nova → `dependency-allowlist.json`. Toda allowlist nova nasce com o stale-enforcement da 6A.3.
|
||||
|
||||
## Self-Review
|
||||
- **Cobertura do spec:** 7 gates sugeridos = Task 1-3 (segurança), 4 (knip), 9 (coverage por módulo), 10 (evidence), 11 (mutação), 12 (bundle), 13 (a11y). "Todas as ferramentas discutidas" = Tasks 1-8, 11-17 (Sonar/osv/CodeQL/knip/sonarjs/type-coverage/lockfile/dpdm/stryker/size-limit/axe/semcheck/agent-lsp/Qlty). Auditoria 6A (2026-06-09) acrescentou Tasks 18-20 (gitleaks, actionlint+zizmor, license compliance). ✓
|
||||
- **Community/OSS only:** confirmado — Sonar Community Build, todos os demais OSS (gitleaks MIT, zizmor/actionlint OSS, license-compliance npm), zero SaaS pago. ✓
|
||||
- **Sem flag-day:** toda catraca é só-regressão, calibrada do estado atual (zizmor/gitleaks começam advisory→baseline→bloqueio). ✓
|
||||
- **Consistência:** todas as métricas usam o formato `{value, direction}` do motor da Fase 1; deps novas passam pelo `check-deps`+`dependency-allowlist.json`. ✓
|
||||
- **Não-sobreposição com a 6A:** a 6A conserta/endurece o EXISTENTE (runners, stale-allowlists, escopos); a Fase 7 só adiciona ferramenta nova. gitleaks (Task 18) complementa — não substitui — o check-public-creds expandido pela 6A.8. ✓
|
||||
|
||||
## Handoff (na ativação, 2026-06-16+)
|
||||
**Ordem: Fase 6A primeiro** ([`PLANO-QUALITY-GATES-FASE6A.md`](./PLANO-QUALITY-GATES-FASE6A.md)) — consertar os runners (testes órfãos + vitest no CI) e endurecer as catracas existentes muda os baselines (cobertura recalibrada) sobre os quais várias tasks daqui (1, 9, 11) calibram. Depois: começar pela **Task 1-3 (catraca de segurança)**, **Task 4 (knip)** e **Task 19 (zizmor — protege os release-workflows)** — maior retorno. Cada Task vira um sub-plano `writing-plans` bite-sized próprio. Recomendado: Subagent-Driven, 1 subagente por Task, com auditoria (trust-but-verify) e o ratchet `eslintWarnings`/`check-deps` validando que cada adição não regride o que já temos.
|
||||
@@ -1,688 +0,0 @@
|
||||
# Plano de Implementação — Quality Gates & Catraca Anti-Alucinação
|
||||
|
||||
> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: use `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans` para executar este plano tarefa-a-tarefa. Os passos usam checkbox (`- [ ]`) para rastreio. **Cada fix de bug obedece à Hard Rule #18** (teste falha→passa OU validação ao vivo no VPS). Não burle Husky (`--no-verify`) sem aprovação. Veja o diagnóstico completo em [`RELATORIO-QUALITY-GATES.md`](./RELATORIO-QUALITY-GATES.md).
|
||||
|
||||
**Goal:** Generalizar a catraca de qualidade do OmniRoute (hoje só para `any`) para todas as métricas relevantes — cobertura, duplicação, tamanho de arquivo, complexidade — e adicionar gates determinísticos anti-alucinação, tudo no padrão `check-*.mjs` já existente, sem SaaS novo.
|
||||
|
||||
**Architecture:** Camadas incrementais. (0) reativa/reconcilia o que já existe; (1) constrói o **motor de catraca** (`quality-baseline.json` commitado + coletor + comparador genérico que clona o `check-t11-any-budget.mjs`); (2) adiciona gates determinísticos que matam os ímãs de alucinação (provider-consistency, fetch-targets, openapi-routes); (3) catraca de duplicação+tamanho; (4) catraca de cobertura + anti test-masking; (5) skill `/babysit` + evidência. Toda catraca é **só-regressão** (baseline congelado) — nunca um piso absoluto que exija limpeza flag-day.
|
||||
|
||||
**Tech Stack:** Node ≥20 ESM (`.mjs`/`.ts` via `tsx`), ESLint 9 flat config, c8, jscpd v5, eslint-plugin-sonarjs v4, GitHub Actions, Node native test runner (`node --import tsx --test`), `gh` CLI + GraphQL.
|
||||
|
||||
**Escopo / sub-planos (scope-check):** Fases 0, 1 e 2 estão totalmente bite-sized aqui. Fases 3, 4 e 5 são subsistemas independentes — cada uma deve ser **expandida no próprio sub-plano** (via `writing-plans`) no momento da execução, a partir das specs/critérios de aceitação definidos aqui. Cada fase entrega software funcional e testável por si só.
|
||||
|
||||
---
|
||||
|
||||
## Mapa de arquivos (o que será criado/modificado)
|
||||
|
||||
| Arquivo | Responsabilidade |
|
||||
|---------|------------------|
|
||||
| `quality-baseline.json` (criar, **commitar**) | Baseline congelado: por métrica `{value, direction}` (`down`=menor-é-melhor, `up`=maior-é-melhor) |
|
||||
| `scripts/quality/collect-metrics.mjs` (criar) | Roda os coletores → emite `quality-metrics.json` |
|
||||
| `scripts/quality/check-quality-ratchet.mjs` (criar) | Comparador genérico: falha em qualquer regressão; com `--update` ratcheta o baseline |
|
||||
| `scripts/check/check-fetch-targets.mjs` (criar) | Todo `fetch("/api/...")` do dashboard resolve para um `route.ts` real |
|
||||
| `scripts/check/check-provider-consistency.ts` (criar) | ids de provider batem entre `providers.ts` ↔ `providerRegistry.ts` ↔ `validation.ts` |
|
||||
| `scripts/check/check-openapi-routes.mjs` (criar) | Toda `path` do `openapi.yaml` ↔ `route.ts` real (bidirecional) |
|
||||
| `scripts/check/check-deps.mjs` (criar) | Anti-slopsquatting: allowlist + existência no registry + age-cooldown |
|
||||
| `.github/workflows/ci.yml` (modificar) | Novo job `quality-gate`; reconciliar gate de cobertura; escalonar audit; plugar scripts órfãos |
|
||||
| `.husky/pre-commit` (modificar) | Reativar a parte barata |
|
||||
| `package.json` (modificar) | Novos scripts `check:*` / `quality:*` |
|
||||
| `eslint.config.mjs` (modificar, Fase 3) | `max-lines`, `max-lines-per-function`, `complexity`, `sonarjs/cognitive-complexity` (warn) |
|
||||
| `.claude/skills/babysit/SKILL.md` (criar, Fase 5) | Skill `/babysit` |
|
||||
| `tests/unit/quality-ratchet.test.ts` etc. (criar) | Testes TDD de cada gate |
|
||||
|
||||
---
|
||||
|
||||
# FASE 0 — Reativar & Reconciliar (quick wins, sem tooling novo)
|
||||
|
||||
### Task 0.1: Reconciliar o gate de cobertura do CI (40 → baseline real)
|
||||
|
||||
**Contexto:** `ci.yml:377` gata em `40/40/40/40`; local gata `60`; comentário renderiza `60`; baseline real ≈ 79–82%. O 40 torna o gate quase banguela.
|
||||
|
||||
**Files:**
|
||||
- Modify: `.github/workflows/ci.yml:376-377`
|
||||
|
||||
- [ ] **Step 1: Confirmar o baseline real de cobertura**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npm run test:coverage 2>&1 | tail -20
|
||||
node -e "const c=require('./coverage/coverage-summary.json').total; console.log(c.statements.pct,c.lines.pct,c.functions.pct,c.branches.pct)"
|
||||
```
|
||||
Expected: 4 números (ex.: `79.8 79.8 82.2 75.2`). Anote-os.
|
||||
|
||||
- [ ] **Step 2: Subir o gate do CI para `baseline_real - 2` (headroom anti-flake)**
|
||||
|
||||
Em `.github/workflows/ci.yml`, troque a linha `--statements 40 --lines 40 --functions 40 --branches 40` pelos valores `(real-2)` de cada métrica (ex.: `--statements 77 --lines 77 --functions 80 --branches 73`). Mantenha como **piso**; a catraca da Fase 4 cuidará do "não-cair".
|
||||
|
||||
- [ ] **Step 3: Alinhar o script local e o display do comentário** para os mesmos números (procure `60` em `package.json` `test:coverage` e em `scripts/check/test-report-summary.mjs`).
|
||||
|
||||
- [ ] **Step 4: Verificar que o CI não quebra** — abrir um PR de teste (ou rodar `act`/push numa branch) e confirmar que o job `test-coverage` fica verde com o novo piso.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add .github/workflows/ci.yml package.json scripts/check/test-report-summary.mjs
|
||||
git commit -m "fix(ci): reconcile coverage gate to real baseline (40->~78) across CI/local/report"
|
||||
```
|
||||
|
||||
### Task 0.2: Escalonar `npm audit` (critical=bloqueia / high=avisa)
|
||||
|
||||
**Files:** Modify: `package.json:112`
|
||||
|
||||
- [ ] **Step 1: Trocar o script `audit:deps`** de `npm audit --audit-level=moderate && npm run audit:electron` para:
|
||||
```json
|
||||
"audit:deps": "npm audit --audit-level=critical && (npm audit --audit-level=high || echo '::warning::high-severity advisories present (non-blocking)') && npm run audit:electron",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rodar e observar o comportamento**
|
||||
```bash
|
||||
npm run audit:deps; echo "exit=$?"
|
||||
```
|
||||
Expected: `exit=0` se não houver critical; mensagem de warning se houver high.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add package.json && git commit -m "chore(ci): tier npm audit (critical blocks, high warns)"
|
||||
```
|
||||
|
||||
### Task 0.3: Plugar os 3 scripts órfãos no CI
|
||||
|
||||
**Contexto:** `check:cli-i18n`, `check:openapi-coverage`, `check:openapi-security-tiers` existem, dão `exit 1`, mas não rodam em lugar nenhum (Hard Rules #15/#17).
|
||||
|
||||
**Files:** Modify: `.github/workflows/ci.yml` (job `docs-sync-strict` ou `lint`)
|
||||
|
||||
- [ ] **Step 1: Rodar os 3 localmente para confirmar verde no estado atual**
|
||||
```bash
|
||||
npm run check:cli-i18n && npm run check:openapi-coverage && npm run check:openapi-security-tiers; echo "exit=$?"
|
||||
```
|
||||
Expected: `exit=0` (se algum falhar, corrija a deriva antes de plugar).
|
||||
|
||||
- [ ] **Step 2: Adicionar os 3 como steps** no job `docs-sync-strict` do `ci.yml`, após `check:docs-all`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add .github/workflows/ci.yml && git commit -m "ci: wire orphaned gates (cli-i18n, openapi-coverage, openapi-security-tiers)"
|
||||
```
|
||||
|
||||
### Task 0.4: Reativar a parte barata do pre-commit do Husky
|
||||
|
||||
**Files:** Modify: `.husky/pre-commit`
|
||||
|
||||
- [ ] **Step 1: Descomentar SÓ as 3 linhas baratas e determinísticas:**
|
||||
```sh
|
||||
npx lint-staged
|
||||
node scripts/check/check-docs-sync.mjs
|
||||
npm run check:any-budget:t11
|
||||
```
|
||||
(Deixe i18n/openapi comentados por enquanto — eles são mais lentos; rodam no CI.)
|
||||
|
||||
- [ ] **Step 2: Testar o hook** com um commit trivial e medir o tempo:
|
||||
```bash
|
||||
time git commit --allow-empty -m "chore: test pre-commit hook"
|
||||
git reset --soft HEAD~1
|
||||
```
|
||||
Expected: hook roda lint-staged + 2 checks em poucos segundos.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add .husky/pre-commit && git commit -m "chore(husky): re-enable cheap pre-commit gates (lint-staged, docs-sync, any-budget)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# FASE 1 — Motor de Catraca (o coração) ⭐
|
||||
|
||||
> Generaliza o `check-t11-any-budget.mjs` (catraca de `any` por arquivo) para um motor de catraca genérico, multi-métrica, que lê um baseline commitado e falha em qualquer regressão. Começa com 2 métricas (warnings de ESLint + cobertura) e é estendido nas fases seguintes.
|
||||
|
||||
### Task 1.1: Comparador genérico de catraca (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/quality/check-quality-ratchet.mjs`
|
||||
- Test: `tests/unit/quality-ratchet.test.ts`
|
||||
|
||||
- [ ] **Step 1: Escrever o teste que falha**
|
||||
```ts
|
||||
// tests/unit/quality-ratchet.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const SCRIPT = path.resolve("scripts/quality/check-quality-ratchet.mjs");
|
||||
|
||||
function run(baseline, metrics, extraArgs = []) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ratchet-"));
|
||||
const bPath = path.join(dir, "baseline.json");
|
||||
const mPath = path.join(dir, "metrics.json");
|
||||
fs.writeFileSync(bPath, JSON.stringify(baseline));
|
||||
fs.writeFileSync(mPath, JSON.stringify(metrics));
|
||||
try {
|
||||
const out = execFileSync("node", [SCRIPT, "--baseline", bPath, "--metrics", mPath, ...extraArgs], { encoding: "utf8" });
|
||||
return { code: 0, out, dir, bPath };
|
||||
} catch (e) {
|
||||
return { code: e.status, out: (e.stdout || "") + (e.stderr || ""), dir, bPath };
|
||||
}
|
||||
}
|
||||
|
||||
test("passes when metrics equal baseline", () => {
|
||||
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" }, "coverage.lines": { value: 80, direction: "up" } } };
|
||||
assert.equal(run(b, { eslintWarnings: 100, "coverage.lines": 80 }).code, 0);
|
||||
});
|
||||
|
||||
test("fails when a 'down' metric regresses (more warnings)", () => {
|
||||
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
|
||||
const r = run(b, { eslintWarnings: 101 });
|
||||
assert.equal(r.code, 1);
|
||||
assert.match(r.out, /eslintWarnings/);
|
||||
});
|
||||
|
||||
test("fails when an 'up' metric regresses (coverage drops)", () => {
|
||||
const b = { metrics: { "coverage.lines": { value: 80, direction: "up" } } };
|
||||
assert.equal(run(b, { "coverage.lines": 79 }).code, 1);
|
||||
});
|
||||
|
||||
test("passes on improvement; --update ratchets the baseline", () => {
|
||||
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
|
||||
const r = run(b, { eslintWarnings: 90 }, ["--update"]);
|
||||
assert.equal(r.code, 0);
|
||||
const updated = JSON.parse(fs.readFileSync(r.bPath, "utf8"));
|
||||
assert.equal(updated.metrics.eslintWarnings.value, 90);
|
||||
});
|
||||
|
||||
test("fails (code 2) when a baseline metric is missing from collected metrics", () => {
|
||||
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
|
||||
assert.equal(run(b, {}).code, 1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rodar o teste e ver falhar**
|
||||
```bash
|
||||
node --import tsx --test tests/unit/quality-ratchet.test.ts
|
||||
```
|
||||
Expected: FAIL (script não existe ainda).
|
||||
|
||||
- [ ] **Step 3: Implementar o comparador**
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
// scripts/quality/check-quality-ratchet.mjs
|
||||
// Catraca genérica multi-métrica. Clona o espírito de check-t11-any-budget.mjs:
|
||||
// um baseline congelado por métrica; falha em qualquer regressão; só anda num sentido.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const cwd = process.cwd();
|
||||
function getArg(name, fallback) {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
||||
}
|
||||
const BASELINE = path.resolve(getArg("--baseline", path.join(cwd, "quality-baseline.json")));
|
||||
const METRICS = path.resolve(getArg("--metrics", path.join(cwd, "quality-metrics.json")));
|
||||
const SUMMARY = getArg("--summary", null);
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
const EPS = 0.01;
|
||||
|
||||
function load(p) {
|
||||
if (!fs.existsSync(p)) { console.error(`[quality-ratchet] arquivo ausente: ${p}`); process.exit(2); }
|
||||
return JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
}
|
||||
|
||||
const baseline = load(BASELINE);
|
||||
const metrics = load(METRICS);
|
||||
const failures = [];
|
||||
const improvements = [];
|
||||
const rows = [];
|
||||
|
||||
for (const [key, spec] of Object.entries(baseline.metrics)) {
|
||||
const current = metrics[key];
|
||||
const base = spec.value;
|
||||
const dir = spec.direction; // "down" = menor-é-melhor | "up" = maior-é-melhor
|
||||
if (current === undefined) { failures.push(`métrica "${key}" ausente em ${path.basename(METRICS)}`); rows.push([key, base, "—", "MISSING"]); continue; }
|
||||
let status = "ok";
|
||||
if (dir === "down") {
|
||||
if (current > base + EPS) { failures.push(`${key}: ${current} > baseline ${base} (não pode aumentar)`); status = "REGRESSÃO"; }
|
||||
else if (current < base - EPS) { improvements.push([key, current]); status = "↑ melhorou"; }
|
||||
} else {
|
||||
if (current < base - EPS) { failures.push(`${key}: ${current} < baseline ${base} (não pode cair)`); status = "REGRESSÃO"; }
|
||||
else if (current > base + EPS) { improvements.push([key, current]); status = "↑ melhorou"; }
|
||||
}
|
||||
rows.push([key, base, current, status]);
|
||||
}
|
||||
|
||||
if (SUMMARY) {
|
||||
const md = ["# Quality Ratchet", "", "| Métrica | Baseline | Atual | Status |", "|---|---|---|---|",
|
||||
...rows.map(([k, b, c, s]) => `| ${k} | ${b} | ${c} | ${s} |`), "",
|
||||
failures.length ? `**${failures.length} regressão(ões) — gate BLOQUEADO.**` : "**Sem regressões — gate OK.**"].join("\n");
|
||||
fs.mkdirSync(path.dirname(SUMMARY), { recursive: true });
|
||||
fs.writeFileSync(SUMMARY, md + "\n");
|
||||
}
|
||||
|
||||
if (UPDATE && failures.length === 0 && improvements.length) {
|
||||
for (const [key, val] of improvements) baseline.metrics[key].value = val;
|
||||
fs.writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + "\n");
|
||||
console.log(`[quality-ratchet] baseline ratcheado: ${improvements.length} métrica(s) melhoraram`);
|
||||
}
|
||||
|
||||
if (failures.length) { console.error("[quality-ratchet] FALHOU:\n" + failures.map((f) => " ✗ " + f).join("\n")); process.exit(1); }
|
||||
console.log(`[quality-ratchet] OK (${rows.length} métricas, ${improvements.length} melhoraram)`);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rodar o teste e ver passar**
|
||||
```bash
|
||||
node --import tsx --test tests/unit/quality-ratchet.test.ts
|
||||
```
|
||||
Expected: PASS (5/5).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add scripts/quality/check-quality-ratchet.mjs tests/unit/quality-ratchet.test.ts
|
||||
git commit -m "feat(quality): generic ratchet comparator (multi-metric, regression-only)"
|
||||
```
|
||||
|
||||
### Task 1.2: Coletor de métricas (ESLint warnings + cobertura)
|
||||
|
||||
**Files:** Create: `scripts/quality/collect-metrics.mjs`
|
||||
|
||||
- [ ] **Step 1: Implementar o coletor**
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
// scripts/quality/collect-metrics.mjs — emite quality-metrics.json
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const out = {};
|
||||
|
||||
// 1) ESLint: contagem de warnings (errors devem ser 0; o lint já gata isso)
|
||||
function eslintCounts() {
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync("npx", ["eslint", ".", "--format", "json"], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
||||
} catch (e) { stdout = e.stdout?.toString() || "[]"; } // eslint sai !=0 quando há errors
|
||||
const results = JSON.parse(stdout);
|
||||
out.eslintWarnings = results.reduce((n, r) => n + (r.warningCount || 0), 0);
|
||||
out.eslintErrors = results.reduce((n, r) => n + (r.errorCount || 0), 0);
|
||||
}
|
||||
|
||||
// 2) Cobertura: lê coverage/coverage-summary.json se existir
|
||||
function coverage() {
|
||||
const p = path.join(cwd, "coverage", "coverage-summary.json");
|
||||
if (!fs.existsSync(p)) return;
|
||||
const t = JSON.parse(fs.readFileSync(p, "utf8")).total;
|
||||
out["coverage.statements"] = t.statements.pct;
|
||||
out["coverage.lines"] = t.lines.pct;
|
||||
out["coverage.functions"] = t.functions.pct;
|
||||
out["coverage.branches"] = t.branches.pct;
|
||||
}
|
||||
|
||||
eslintCounts();
|
||||
coverage();
|
||||
fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n");
|
||||
console.log("[collect-metrics]", JSON.stringify(out));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rodar e inspecionar a saída**
|
||||
```bash
|
||||
node scripts/quality/collect-metrics.mjs && cat quality-metrics.json
|
||||
```
|
||||
Expected: JSON com `eslintWarnings`, `eslintErrors` e (se houver coverage) os 4 `coverage.*`. **Anote `eslintWarnings`.**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add scripts/quality/collect-metrics.mjs && echo "quality-metrics.json" >> .gitignore
|
||||
git add .gitignore && git commit -m "feat(quality): metrics collector (eslint warnings + coverage)"
|
||||
```
|
||||
|
||||
### Task 1.3: Congelar o baseline inicial
|
||||
|
||||
**Files:** Create: `quality-baseline.json` (**commitado**)
|
||||
|
||||
- [ ] **Step 1: Gerar o baseline a partir das métricas reais** (use os números anotados):
|
||||
```json
|
||||
{
|
||||
"_comment": "Catraca: 'down' nao pode aumentar, 'up' nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (so em melhora).",
|
||||
"metrics": {
|
||||
"eslintWarnings": { "value": <N_REAL>, "direction": "down" },
|
||||
"coverage.statements": { "value": <S>, "direction": "up" },
|
||||
"coverage.lines": { "value": <L>, "direction": "up" },
|
||||
"coverage.functions": { "value": <F>, "direction": "up" },
|
||||
"coverage.branches": { "value": <B>, "direction": "up" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validar a catraca contra si mesma**
|
||||
```bash
|
||||
node scripts/quality/collect-metrics.mjs
|
||||
node scripts/quality/check-quality-ratchet.mjs; echo "exit=$?"
|
||||
```
|
||||
Expected: `[quality-ratchet] OK` e `exit=0`.
|
||||
|
||||
- [ ] **Step 3: Provar que pega regressão** (teste manual): edite `quality-metrics.json` somando 1 a `eslintWarnings`, rode o comparador, confirme `exit=1`, depois descarte a edição.
|
||||
|
||||
- [ ] **Step 4: Adicionar scripts npm**
|
||||
```json
|
||||
"quality:collect": "node scripts/quality/collect-metrics.mjs",
|
||||
"quality:ratchet": "node scripts/quality/check-quality-ratchet.mjs",
|
||||
"quality:gate": "npm run quality:collect && npm run quality:ratchet"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add quality-baseline.json package.json
|
||||
git commit -m "feat(quality): freeze initial quality baseline (eslint warnings + coverage)"
|
||||
```
|
||||
|
||||
### Task 1.4: Wire no CI (job + artefato + comentário no PR)
|
||||
|
||||
**Files:** Modify: `.github/workflows/ci.yml`
|
||||
|
||||
- [ ] **Step 1: Adicionar job `quality-gate`** (depois de `test-coverage`, para reusar `coverage/coverage-summary.json`):
|
||||
```yaml
|
||||
quality-gate:
|
||||
name: Quality Ratchet
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-coverage
|
||||
if: ${{ always() && needs.test-coverage.result == 'success' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '24', cache: 'npm' }
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
with: { name: coverage-report, path: coverage/ }
|
||||
- run: npm run quality:collect
|
||||
- run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
|
||||
- if: always()
|
||||
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY"
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with: { name: quality-ratchet, path: .artifacts/quality-ratchet.md }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Adicionar comentário no PR** clonando o job `coverage-pr-comment` (marcador `<!-- omniroute-quality-ratchet -->`, lê `.artifacts/quality-ratchet.md`). Reuse o mesmo `github-script` de upsert de comentário.
|
||||
|
||||
- [ ] **Step 3: Validar num PR de teste** — confirmar que o job aparece, o step summary mostra a tabela, e o comentário é postado.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add .github/workflows/ci.yml
|
||||
git commit -m "ci(quality): add quality-ratchet job with PR comment + artifact"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# FASE 2 — Gates Determinísticos Anti-Alucinação
|
||||
|
||||
> Cada gate ataca um ímã de alucinação específico (ver §2.4 do relatório). Todos no padrão `check-*.mjs`. **`check-fetch-targets` vem com código completo** (parsing determinístico de arquivos, sem risco de inventar nomes de export). **`check-provider-consistency` e `check-openapi-routes` começam com um Step 0 de verificação dos nomes reais** — propositalmente, porque fabricar a forma do import/spec seria o exato anti-padrão que este plano combate.
|
||||
|
||||
### Task 2.1: `check-fetch-targets.mjs` — toda rota chamada pelo dashboard existe (TDD)
|
||||
|
||||
**Ataca:** ímã nº2 (300 paths `fetch("/api/...")` sem ligação com as rotas).
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/check/check-fetch-targets.mjs`
|
||||
- Test: `tests/unit/check-fetch-targets.test.ts`
|
||||
|
||||
- [ ] **Step 1: Escrever o teste que falha**
|
||||
```ts
|
||||
// tests/unit/check-fetch-targets.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { resolveApiPathToRoute } from "../../scripts/check/check-fetch-targets.mjs";
|
||||
|
||||
test("matches a static route file", () => {
|
||||
const files = new Set(["src/app/api/usage/route.ts"]);
|
||||
assert.equal(resolveApiPathToRoute("/api/usage", files), true);
|
||||
});
|
||||
|
||||
test("matches a dynamic [param] segment", () => {
|
||||
const files = new Set(["src/app/api/providers/[id]/models/route.ts"]);
|
||||
assert.equal(resolveApiPathToRoute("/api/providers/abc-123/models", files), true);
|
||||
});
|
||||
|
||||
test("rejects a hallucinated route", () => {
|
||||
const files = new Set(["src/app/api/usage/route.ts"]);
|
||||
assert.equal(resolveApiPathToRoute("/api/providers/refresh", files), false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rodar e ver falhar**
|
||||
```bash
|
||||
node --import tsx --test tests/unit/check-fetch-targets.test.ts
|
||||
```
|
||||
Expected: FAIL (módulo não existe).
|
||||
|
||||
- [ ] **Step 3: Implementar o gate**
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-fetch-targets.mjs
|
||||
// Todo fetch("/api/...") em src/app/(dashboard) deve resolver para um route.ts real.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const DASH = path.join(cwd, "src/app/(dashboard)");
|
||||
const API = path.join(cwd, "src/app/api");
|
||||
|
||||
// allowlist de paths dinâmicos/externos que o checker não consegue resolver estaticamente
|
||||
const IGNORE = [/^\/api\/v1\//, /\$\{/, /` \+/]; // /v1 é a superfície OpenAI-compat; templates
|
||||
|
||||
function walk(dir, acc = []) {
|
||||
if (!fs.existsSync(dir)) return acc;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p, acc);
|
||||
else if (/\.(ts|tsx)$/.test(e.name)) acc.push(p);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function collectRouteFiles() {
|
||||
return new Set(walk(API).filter((p) => /route\.tsx?$/.test(p)).map((p) => path.relative(cwd, p).replace(/\\/g, "/")));
|
||||
}
|
||||
|
||||
export function resolveApiPathToRoute(apiPath, routeFiles) {
|
||||
// apiPath ex.: /api/providers/abc/models → src/app/api/providers/[id]/models/route.ts
|
||||
const segs = apiPath.replace(/^\//, "").replace(/[?#].*$/, "").split("/"); // ["api","providers","abc","models"]
|
||||
for (const rf of routeFiles) {
|
||||
const rsegs = rf.replace(/^src\/app\//, "").replace(/\/route\.tsx?$/, "").split("/"); // ["api","providers","[id]","models"]
|
||||
if (rsegs.length !== segs.length) continue;
|
||||
const ok = rsegs.every((rs, i) => rs === segs[i] || /^\[.*\]$/.test(rs));
|
||||
if (ok) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractFetchPaths(file) {
|
||||
const src = fs.readFileSync(file, "utf8");
|
||||
const re = /(?:fetch|fetchJson|apiFetch)\(\s*["'`](\/api\/[^"'`?]+)/g;
|
||||
const out = [];
|
||||
let m;
|
||||
while ((m = re.exec(src))) out.push(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const routeFiles = collectRouteFiles();
|
||||
const misses = [];
|
||||
for (const f of walk(DASH)) {
|
||||
for (const apiPath of extractFetchPaths(f)) {
|
||||
if (IGNORE.some((rx) => rx.test(apiPath))) continue;
|
||||
if (!resolveApiPathToRoute(apiPath, routeFiles)) misses.push(`${path.relative(cwd, f)} → ${apiPath}`);
|
||||
}
|
||||
}
|
||||
if (misses.length) {
|
||||
console.error(`[check-fetch-targets] ${misses.length} fetch(es) para rota inexistente:\n` + misses.map((m) => " ✗ " + m).join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[check-fetch-targets] OK (${routeFiles.size} rotas conhecidas)`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) main();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rodar o teste unitário e ver passar**
|
||||
```bash
|
||||
node --import tsx --test tests/unit/check-fetch-targets.test.ts
|
||||
```
|
||||
Expected: PASS (3/3).
|
||||
|
||||
- [ ] **Step 5: Rodar o gate no repo real** (modo descoberta — pode encontrar misses legítimos)
|
||||
```bash
|
||||
node scripts/check/check-fetch-targets.mjs; echo "exit=$?"
|
||||
```
|
||||
Se encontrar misses reais: triagem — ou são rotas faltantes (bug), ou paths dinâmicos a adicionar ao `IGNORE`. **Não** mascare; documente cada exceção no `IGNORE` com comentário.
|
||||
|
||||
- [ ] **Step 6: Adicionar script + commit**
|
||||
```json
|
||||
"check:fetch-targets": "node scripts/check/check-fetch-targets.mjs"
|
||||
```
|
||||
```bash
|
||||
git add scripts/check/check-fetch-targets.mjs tests/unit/check-fetch-targets.test.ts package.json
|
||||
git commit -m "feat(check): gate that every dashboard fetch() targets a real API route"
|
||||
```
|
||||
|
||||
### Task 2.2: `check-provider-consistency.ts` — ids batem entre os 3 arquivos
|
||||
|
||||
**Ataca:** ímã nº1 (split de provider; 229 vs 155 vs N já divergem).
|
||||
|
||||
**Files:** Create: `scripts/check/check-provider-consistency.ts` (rodado via `tsx`); Test: `tests/unit/check-provider-consistency.test.ts`
|
||||
|
||||
- [ ] **Step 0 (VERIFICAÇÃO — obrigatório antes de codar):** descobrir os nomes reais de export, para **não inventar**:
|
||||
```bash
|
||||
grep -nE "export (const|function) " src/shared/constants/providers.ts | grep -iE "provider|section" | head
|
||||
grep -nE "getOrCreateAiProviders|_PROVIDER_SECTIONS|AI_PROVIDERS" src/shared/constants/providers.ts | head
|
||||
grep -nE "baseUrl|^\s*['\"][a-z0-9-]+['\"]\s*:" open-sse/config/providerRegistry.ts | head
|
||||
grep -nE "case ['\"]|=== ['\"]|SPECIALTY_VALIDATORS" src/lib/providers/validation.ts | head
|
||||
```
|
||||
Anote: como enumerar os ids canônicos em runtime, a forma das chaves do `providerRegistry.ts`, e onde `validation.ts` lista os providers cobertos.
|
||||
|
||||
- [ ] **Step 1: Escrever o teste** com fixtures sintéticas (3 conjuntos de ids), afirmando que o diff detecta um id presente em um e ausente em outro. (Implemente a lógica como função pura `diffProviderSets(canonical, registry, validators)` que retorna `{missingInRegistry, missingInValidators, orphanInRegistry}`.)
|
||||
|
||||
- [ ] **Step 2: Rodar e ver falhar.**
|
||||
|
||||
- [ ] **Step 3: Implementar** importando os ids reais (descobertos no Step 0) e cruzando-os. Reusar o padrão de `scripts/check/check-docs-counts-sync.mjs` (que **já conta** executors/oauth/strategies vs docs — é o template direto). Para ids que legitimamente vivem só num lugar, manter um `KNOWN_EXCEPTIONS` allowlist com comentário por entrada.
|
||||
|
||||
- [ ] **Step 4: Rodar no repo real**, triar as divergências reais (as contagens já divergem — algumas serão bugs de registro pela metade, outras exceções legítimas).
|
||||
|
||||
- [ ] **Step 5: Commit** (`feat(check): provider-id consistency gate across providers.ts/registry/validation`).
|
||||
|
||||
**Critério de aceitação:** o gate falha quando um id existe em `providers.ts` mas não no `providerRegistry.ts` (ou vice-versa), e quando um model no registry referencia um provider id desconhecido.
|
||||
|
||||
### Task 2.3: `check-openapi-routes.mjs` — spec ↔ rotas (bidirecional)
|
||||
|
||||
**Ataca:** docs-hallucination (endpoint inventado no `openapi.yaml`).
|
||||
|
||||
**Files:** Create: `scripts/check/check-openapi-routes.mjs`; Test correspondente.
|
||||
|
||||
- [ ] **Step 0 (VERIFICAÇÃO):** confirmar o caminho e a forma do spec:
|
||||
```bash
|
||||
ls docs/reference/openapi.yaml && grep -nE "^\s{2,4}/[a-z]" docs/reference/openapi.yaml | head
|
||||
```
|
||||
Confirmar como `check-openapi-coverage.mjs` já parseia o YAML (reusar o parser dele).
|
||||
|
||||
- [ ] **Step 1–5 (TDD):** teste com spec sintética → implementar: toda `path` do spec resolve para um `route.ts` (reusando `resolveApiPathToRoute` da Task 2.1) e toda rota não-interna aparece no spec (com allowlist para rotas LOCAL_ONLY/internas). Commit.
|
||||
|
||||
### Task 2.4: `check-deps.mjs` — anti-slopsquatting
|
||||
|
||||
**Ataca:** pacotes alucinados (CSA: 19,7% das amostras IA).
|
||||
|
||||
**Files:** Create: `scripts/check/check-deps.mjs`; Test.
|
||||
|
||||
- [ ] **Step 0 (VERIFICAÇÃO):** decidir política — allowlist = todas as deps atuais do `package.json`/lockfile como baseline; novas deps exigem (a) existir no registry, (b) idade ≥ 72h, (c) entrada explícita.
|
||||
- [ ] **Step 1–5 (TDD):** teste com `package.json` sintético adicionando uma dep nova → gate falha se a dep não está no baseline E (não existe no registry OU foi publicada há <72h). Usar `npm view <pkg> time.created` para idade. Reusar o padrão diff-base...HEAD de `check-pr-test-policy.mjs`. Commit.
|
||||
|
||||
### Task 2.5 (opcional): lints Rule #11/#12 via `no-restricted-syntax`
|
||||
|
||||
- [ ] Estender o `eslint.config.mjs` (bloco `no-restricted-syntax` já usado p/ a regra de busca turca) para sinalizar `NextResponse.json({ error: "<string>" })` cru (exigir `buildErrorBody()`) e literais de `clientIdDefault`/`clientSecretDefault` no `providerRegistry.ts` (exigir `resolvePublicCred()`). Ratchet via contagem (adoção 11% → sobe). TDD com fixtures de ESLint.
|
||||
|
||||
**Plugar tudo da Fase 2 no CI:** adicionar `check:fetch-targets`, `check:provider-consistency`, `check:openapi-routes`, `check:deps` ao job `lint` (ou `docs-sync-strict`) do `ci.yml`, e ao pre-commit barato quando rápidos o suficiente.
|
||||
|
||||
---
|
||||
|
||||
# FASE 3 — Catraca de Duplicação + Tamanho (mata-slop) — *expandir em sub-plano*
|
||||
|
||||
**Justificativa:** GitClear mostra duplicação 4–8× na era IA; é a assinatura nº1 de slop. Nenhum gate hoje (Sonar CPD excluído).
|
||||
|
||||
**Specs (criar sub-plano `writing-plans` a partir daqui):**
|
||||
|
||||
1. **Adicionar `jscpd` v5** como devDependency. Rodar `jscpd --reporters json src open-sse` → `quality-metrics.json` ganha `duplication.pct`. **[verificar no install]** o schema JSON do v5 antes de parsear (ver ressalva no relatório §4.2).
|
||||
2. **Estender `collect-metrics.mjs`** com um coletor de duplicação (lê o JSON do jscpd) e um coletor de **tamanho de arquivo** (conta LOC de cada arquivo em `src`+`open-sse`, emite `fileSize.<path>` para os arquivos já acima de um teto, ex. >700 LOC — congelando os 64 atuais).
|
||||
3. **Adicionar regras ESLint** em `eslint.config.mjs` como `warn`: `max-lines` (700), `max-lines-per-function` (80), `complexity` (15), `sonarjs/cognitive-complexity` (15). Coletor adiciona `eslintWarnings` por categoria (a contagem já está na catraca da Fase 1; opcionalmente segregar `complexityWarnings`).
|
||||
4. **Estender `quality-baseline.json`** com `duplication.pct` (`down`) e os `fileSize.*` dos arquivos grandes (`down` — só podem encolher; arquivos novos têm teto absoluto).
|
||||
5. **Teto absoluto para arquivos novos:** o gate falha se um arquivo **não** presente no baseline nasce acima do teto (ex. 700 LOC) — impede o próximo god-component.
|
||||
|
||||
**Critérios de aceitação:** PR que aumenta a duplicação % falha; PR que cresce qualquer um dos 64 arquivos grandes falha; PR que cria arquivo novo >700 LOC falha; refator que encolhe um arquivo grande ratcheta o baseline para baixo (via `--update`).
|
||||
|
||||
---
|
||||
|
||||
# FASE 4 — Catraca de Cobertura + Anti Test-Masking — *expandir em sub-plano*
|
||||
|
||||
**Specs:**
|
||||
|
||||
1. **`check-coverage-ratchet`** já é coberto pelo motor da Fase 1 (as 4 métricas `coverage.*` com `direction: "up"`). Confirmar que o job `quality-gate` roda **após** o merge dos 8 shards de cobertura no CI (não localmente, onde a cobertura é parcial). Adicionar **epsilon** maior (ex. 0.5) para `coverage.branches` por causa do não-determinismo do v8.
|
||||
2. **Pisos por módulo crítico:** estender `collect-metrics.mjs` para emitir `coverage.<modulo>.lines` para uma lista curta de módulos de alto risco (lendo o `coverage-summary.json` por arquivo): `open-sse/handlers/chatCore.ts`, `open-sse/services/combo.ts`, `open-sse/services/accountFallback.ts`, `src/sse/services/auth.ts`, `src/server/authz/routeGuard.ts`, `open-sse/utils/error.ts`, `open-sse/utils/publicCreds.ts`, `src/shared/utils/circuitBreaker.ts`. Cada um vira métrica `up` no baseline (implementa o "risco, não % bruto" do vídeo).
|
||||
3. **`check-test-masking.mjs`** (anti enfraquecimento de asserts): clonar `check-pr-test-policy.mjs` (diff `base...HEAD`); para cada `*.test.ts`/`*.spec.ts` alterado, contar `assert*(`/`expect(` em base vs HEAD; **sinalizar remoção líquida** de asserts para revisão humana. Banir novos `assert.ok(true)`. Heurístico mas alto-sinal — ataca diretamente o risco organizacional nº1 ("subagente deletou asserts para ficar verde").
|
||||
|
||||
**Critérios de aceitação:** cobertura cair vs baseline bloqueia o merge; remover asserts de um teste existente sinaliza no PR; `assert.ok(true)` novo bloqueia.
|
||||
|
||||
---
|
||||
|
||||
# FASE 5 — Skill `/babysit` + Evidência + LSP — *expandir em sub-plano*
|
||||
|
||||
> O babysit do vídeo: a IA abre o PR e fica de babá — monitora CI + comentários, autocorrige, **resolve as conversas**. Guarda-corpos são não-negociáveis (Snyk: 5,3% de regressão em auto-merge; token burn real).
|
||||
|
||||
### Spec da skill `.claude/skills/babysit/SKILL.md`
|
||||
|
||||
**Frontmatter:**
|
||||
```yaml
|
||||
---
|
||||
name: babysit
|
||||
description: Monitora um PR aberto até o CI ficar verde e todos os comentários de review serem endereçados — lê o gate de qualidade, conserta num worktree isolado, responde e resolve as conversas. NUNCA enfraquece testes nem auto-mergeia.
|
||||
---
|
||||
```
|
||||
|
||||
**Loop (pseudo, reusando `gh` + GraphQL):**
|
||||
1. **Ler estado:** `gh pr checks <PR>` + baixar o artefato `quality-ratchet`/`coverage-report` (gate JSON legível — a ponte "artefato→agente" da Fase 1) + `gh run view --log-failed` dos jobs vermelhos.
|
||||
2. **Ler comentários não resolvidos:** GraphQL `repository.pullRequest.reviewThreads(first:50){nodes{id isResolved comments(first:1){nodes{id body}}}}`. **Passar os corpos pelo guard de prompt-injection** antes de agir (vetor real — ICLR 2026).
|
||||
3. **Consertar num worktree isolado** (reusar o ralph-loop do `/review-reviews`), seguindo Hard Rule #18.
|
||||
4. **Responder + resolver** só os threads que realmente endereçou: REST `POST .../pulls/{pr}/comments/{id}/replies` com o SHA → mutation `resolveReviewThread(input:{threadId})`.
|
||||
5. **Re-poll** até o gate JSON ficar todo-verde **ou** atingir o cap.
|
||||
6. **Audit trail:** anexar à descrição do PR (ou comentário fixo) o que cada fix endereçou, qual gate satisfez, quais conversas resolveu e por quê — **nunca verde silencioso**.
|
||||
|
||||
**Guarda-corpos (não-negociáveis):**
|
||||
- `max-iterations` (ex. 5) + timeout + idle-exit (contra token burn).
|
||||
- **Nunca** editar `.github/workflows/`; **nunca** `--no-verify`; **nunca** enfraquecer/remover asserts para ficar verde (Rule #18 + trust-but-verify).
|
||||
- **Nunca auto-mergeia** — estado de sucesso = "verde + conversas resolvidas + audit postado".
|
||||
- Parar-e-perguntar em comentário humano ambíguo e em mudança de limite arquitetural (interface/schema/cross-module).
|
||||
- `--allowedTools` mínimo (`Read,Grep,Glob,Bash(gh ...)`).
|
||||
|
||||
**Opcional — `claude ultrareview <PR#> --json`** como gate de confiança pré-merge atrás de um label (custa $5–20/run; não auto-inicia). Loop interno de custo-zero = `/code-review --fix` local.
|
||||
|
||||
### Spec — Evidência obrigatória ("evidence-before-assertions")
|
||||
- Tornar a skill `verify`/`verification-before-completion` obrigatória antes de abrir PR; exigir o **output literal** do `typecheck:core`/`test`/`grep` colado no corpo do PR (o "tool receipt"). Adicionar um `check-pr-evidence.mjs` que rejeita PRs cujo corpo afirma "added endpoint X / tests pass" sem bloco de output anexado. Formaliza a Rule #18.
|
||||
|
||||
### Spec — LSP-in-the-loop (opcional)
|
||||
- Registrar `agent-lsp` (MCP) ou o `tsserver` para os agentes terem `blast_radius`/diagnostics e `preview_edit` antes de escrever — vira "símbolo inventado" de catch-de-review para impossibilidade-no-edit. Pareia com `typecheck:core` como gate pré-PR (compile-before-claim).
|
||||
|
||||
**Critérios de aceitação:** a skill `/babysit <PR#>` leva um PR de vermelho a verde sem auto-merge, resolve só as conversas que endereçou, respeita o cap de iterações, e deixa rastro auditável; nenhum assert é enfraquecido.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (checklist do autor)
|
||||
|
||||
- **Cobertura do spec:** Fases 0–2 mapeiam 1:1 com as recomendações do relatório §5; Fases 3–5 cobrem duplicação/tamanho, cobertura/masking e babysit/evidência. ✓
|
||||
- **Sem placeholders nos passos bite-sized:** Fases 0/1/2.1 têm comandos e código reais. As Fases 2.2–2.4 usam um Step-0 de verificação **deliberado** (não placeholder) para não fabricar nomes de export — coerente com o objetivo anti-alucinação. ✓
|
||||
- **Consistência de tipos:** `resolveApiPathToRoute` (Task 2.1) é reusada na Task 2.3; o comparador usa o mesmo formato `{value, direction}` em todas as fases; `quality-metrics.json` é a interface única coletor↔comparador. ✓
|
||||
- **Ordem de dependência:** Fase 1 (motor) precede 3/4 (que só adicionam métricas ao baseline); Fase 0 destrava o resto. ✓
|
||||
|
||||
## Handoff de Execução
|
||||
|
||||
**Plano salvo em `PLANO-QUALITY-GATES.md`.** Duas opções:
|
||||
|
||||
1. **Subagent-Driven (recomendado)** — um subagente fresco por task, review entre tasks. SUB-SKILL: `superpowers:subagent-driven-development`.
|
||||
2. **Inline** — executar nesta sessão com checkpoints. SUB-SKILL: `superpowers:executing-plans`.
|
||||
|
||||
**Recomendação:** começar pela **Fase 0** (quick wins, baixo risco) e **Fase 1** (motor de catraca) numa branch `feat/quality-ratchet`, validar num PR de teste, e só então abrir as fases anti-alucinação. Fases 3–5 viram sub-planos próprios.
|
||||
349
README.md
349
README.md
@@ -6,7 +6,7 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
### Never stop coding. Connect every AI tool to **177 providers** — **50+ free** — through one endpoint.
|
||||
### Never stop coding. Connect every AI tool to **231 providers** — **50+ free** — through one endpoint.
|
||||
|
||||
**Plug Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini. Auto-fallback.**
|
||||
<br/>
|
||||
@@ -15,27 +15,27 @@
|
||||
|
||||
<br/>
|
||||
|
||||
**~1.9B+ documented free tokens/month** — up to **~2.5B in your first month** with signup credits — aggregated across the free tiers, and the compression above stretches every one further. ([how we count →](docs/reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate))
|
||||
**~1.6B documented free tokens/month** — up to **~2.1B in your first month** with signup credits — aggregated across the free tiers, plus a long tail of permanently-free, no-cap providers, and the compression above stretches every one further. ([how we count →](docs/reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate))
|
||||
|
||||
<br/>
|
||||
|
||||
[](#-177-ai-providers--50-free)
|
||||
[](#-177-ai-providers--50-free)
|
||||
[](docs/reference/FREE_TIERS.md)
|
||||
[](#-231-ai-providers--50-free)
|
||||
[](#-231-ai-providers--50-free)
|
||||
[](docs/reference/FREE_TIERS.md)
|
||||
[](#%EF%B8%8F-save-1595-tokens--automatically)
|
||||
[](#-combos--the-flagship)
|
||||
[](#-combos--the-flagship)
|
||||
[](#-quick-start)
|
||||
|
||||
<br/>
|
||||
|
||||
### 💬 Join the community
|
||||
|
||||
[](https://discord.gg/hmexnhgE)
|
||||
[](https://discord.gg/EkzRkpzKYt)
|
||||
[](https://t.me/omnirouteOficial)
|
||||
[](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
|
||||
[](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)
|
||||
|
||||
**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/hmexnhgE) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 Brasil](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)**
|
||||
**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/EkzRkpzKYt) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 Brasil](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)**
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
[](LICENSE)
|
||||
[](package.json)
|
||||
[](package.json)
|
||||
[](https://github.com/diegosouzapw/OmniRoute)
|
||||
|
||||
<div align="center">
|
||||
@@ -59,15 +59,15 @@
|
||||
|
||||
<br/>
|
||||
|
||||
[**🚀 Quick Start**](#-quick-start) • [**🎯 Combos**](#-combos--the-flagship) • [**🌐 Providers**](#-177-ai-providers--50-free) • [**🔌 CLI & MCP**](#-full-cli--a2a--mcp) • [**🗜️ Compression**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 Website**](https://omniroute.online)
|
||||
[**🚀 Quick Start**](#-quick-start) • [**🎯 Combos**](#-combos--the-flagship) • [**🌐 Providers**](#-231-ai-providers--50-free) • [**🔌 CLI & MCP**](#-full-cli--a2a--mcp) • [**🗜️ Compression**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 Website**](https://omniroute.online)
|
||||
|
||||
[💥 The Promise](#-the-promise) • [🤔 Why](#-why-omniroute) • [🏆 What Sets Apart](#-what-sets-omniroute-apart) • [🤖 Compatible CLIs](#-compatible-clis--coding-agents) • [🖥️ Where It Runs](#%EF%B8%8F-where-omniroute-runs--anywhere) • [🔒 Private](#-private--local-first) • [🎬 In Action](#-omniroute-in-action) • [📚 Explore More](#-explore-more) • [📧 Support](#-support--community)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<b>🌐 Available in 40+ languages</b>
|
||||
<table>
|
||||
<b>🌐 Available in 41+ languages</b>
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="README.md">🇺🇸</a></td>
|
||||
<td align="center"><a href="docs/i18n/pt-BR/README.md">🇧🇷</a></td>
|
||||
@@ -76,6 +76,7 @@
|
||||
<td align="center"><a href="docs/i18n/it/README.md">🇮🇹</a></td>
|
||||
<td align="center"><a href="docs/i18n/ru/README.md">🇷🇺</a></td>
|
||||
<td align="center"><a href="docs/i18n/zh-CN/README.md">🇨🇳</a></td>
|
||||
<td align="center"><a href="docs/i18n/zh-TW/README.md">🇹🇼</a></td>
|
||||
<td align="center"><a href="docs/i18n/de/README.md">🇩🇪</a></td>
|
||||
<td align="center"><a href="docs/i18n/ja/README.md">🇯🇵</a></td>
|
||||
<td align="center"><a href="docs/i18n/ko/README.md">🇰🇷</a></td>
|
||||
@@ -114,14 +115,15 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 💰 ~1.9B Free Tokens / Month
|
||||
# 💰 ~1.6B Free Tokens / Month
|
||||
|
||||
</div>
|
||||
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute aggregates the **documented** free tiers of **50+ provider pools / 530 models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`).
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute aggregates the **documented** free tiers of **40+ provider pools / 500+ models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`).
|
||||
|
||||
- **~1.9B free tokens / month** (steady) — and **up to ~2.5B in your first month** with signup credits.
|
||||
- **Pool-deduped, honest** — we count each shared free pool **once**, so the headline isn't inflated by rate-limit ceilings the way multi-billion competitor claims are. (The naïve per-model sum would read ~8B; we don't publish that.)
|
||||
- **~1.6B free tokens / month** (steady) — and **up to ~2.1B in your first month** with signup credits.
|
||||
- **Pool-deduped, honest** — we count each shared free pool **once**, so the headline isn't inflated by rate-limit ceilings the way multi-billion competitor claims are. (Counting every rate limit 24/7 would read ~10B; we don't publish that.)
|
||||
- **Plus the un-countable** — permanently-free, no-token-cap providers (SiliconFlow, Z.AI GLM-Flash, Kilo, OpenCode Zen…) and a **$10 OpenRouter top-up** that unlocks **+24M/mo**, both surfaced separately so they never inflate the headline.
|
||||
- **Per-model breakdown**, **live used / remaining** for the current month, and a transparent **terms flag** per provider.
|
||||
|
||||

|
||||
@@ -136,18 +138,18 @@
|
||||
|
||||
</div>
|
||||
|
||||
> One endpoint. **177 providers.** Never stop building — and let OmniRoute pick the cheapest one that works.
|
||||
> One endpoint. **231 providers.** Never stop building — and let OmniRoute pick the cheapest one that works.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><b>🚫 Never hit limits</b><br/><sub>Auto-fallback across 177 providers in milliseconds. Quota out? Next provider takes over — zero downtime.</sub></td>
|
||||
<td width="33%" valign="top"><b>🚫 Never hit limits</b><br/><sub>Auto-fallback across 231 providers in milliseconds. Quota out? Next provider takes over — zero downtime.</sub></td>
|
||||
<td width="33%" valign="top"><b>💸 Save up to 95% tokens</b><br/><sub>RTK + Caveman stacked compression cuts 15–95% of eligible tokens (~89% avg on tool-heavy sessions).</sub></td>
|
||||
<td width="33%" valign="top"><b>🆓 $0 to start</b><br/><sub>50+ providers with a free tier, 11 free <i>forever</i> (Kiro, Qoder, Pollinations, LongCat…). No card needed.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><b>🔌 Every tool works</b><br/><sub>16+ coding agents — Claude Code, Codex, Cursor, Cline, Copilot, Antigravity — through one config.</sub></td>
|
||||
<td width="33%" valign="top"><b>🧩 One endpoint</b><br/><sub>OpenAI ↔ Claude ↔ Gemini ↔ Responses API translation. Point any tool at <code>/v1</code> and it just works.</sub></td>
|
||||
<td width="33%" valign="top"><b>🛡️ Production-grade</b><br/><sub>Circuit breakers, TLS stealth, MCP (37 tools), A2A, memory, guardrails, evals. 4,690+ tests.</sub></td>
|
||||
<td width="33%" valign="top"><b>🛡️ Production-grade</b><br/><sub>Circuit breakers, TLS stealth, MCP (87 tools), A2A, memory, guardrails, evals. 14,965 tests.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -181,7 +183,7 @@
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ OmniRoute — Smart Router │
|
||||
│ RTK + Caveman compression · 14 routing strategies │
|
||||
│ RTK + Caveman compression · 17 routing strategies │
|
||||
│ Circuit breakers · TLS stealth · MCP · A2A · Guardrails │
|
||||
└─────────────────────────┬──────────────────────────────────┘
|
||||
┌─────────────┬────┴────────┬─────────────┐
|
||||
@@ -219,7 +221,7 @@ No combo to create. Set your model to `auto` (or a variant) and OmniRoute builds
|
||||
|
||||
##
|
||||
|
||||
### 🔀 Or build your own — 14 routing strategies
|
||||
### 🔀 Or build your own — 17 routing strategies
|
||||
|
||||
| Goal | Strategy / combo |
|
||||
| --------------------------------------- | -------------------------------------------------- |
|
||||
@@ -228,6 +230,8 @@ No combo to create. Set your model to `auto` (or a variant) and OmniRoute builds
|
||||
| 💸 Always cheapest viable model | `cost-optimized` · `auto/cheap` |
|
||||
| 🧠 Hand off long context between models | `context-relay` · `context-optimized` |
|
||||
| 🎲 Randomized / privacy routing | `random` · `strict-random` |
|
||||
| 🧬 Fan out to a panel + judge synthesis | `fusion` |
|
||||
| 📊 Route by remaining quota headroom | `reset-window` · `headroom` |
|
||||
| 🤖 Just make it smart | `auto` (9-factor scoring) · `lkgp` · `reset-aware` |
|
||||
|
||||
<sub>The Auto-Combo engine scores every candidate on **9 factors** (health, quota, cost, latency, success rate, freshness…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
@@ -261,20 +265,20 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
</div>
|
||||
|
||||
| Feature | OmniRoute | Other routers |
|
||||
| -------------------------------------- | ----------------------------------------------------------- | ------------- |
|
||||
| 🌐 Providers | **177** | 20–100 |
|
||||
| 🆓 Free providers | **50+ (11 free forever)** | 1–5 |
|
||||
| 🔀 Routing strategies | **14** (priority, weighted, cost-optimized, context-relay…) | 1–3 |
|
||||
| 🗜️ Token compression | **RTK + Caveman stacked (15–95%)** | None / 20–40% |
|
||||
| 🧰 Built-in MCP server | **37 tools, 3 transports, 13 scopes** | Rare |
|
||||
| 🤝 A2A agent protocol | **5 skills, JSON-RPC 2.0** | None |
|
||||
| 🧠 Memory (FTS5 + vector) | **Yes** | Rare |
|
||||
| 🛡️ Guardrails (PII, injection, vision) | **Yes** | Rare |
|
||||
| ☁️ Cloud agents | **Codex, Devin, Jules** | None |
|
||||
| 🥷 TLS fingerprint stealth | **JA3/JA4 via wreq-js** | None |
|
||||
| 🖥️ Multi-platform | **Web · Desktop · Termux · PWA** | Web only |
|
||||
| 🌍 i18n | **40+ locales** | 0–4 |
|
||||
| Feature | OmniRoute | Other routers |
|
||||
| -------------------------------------- | ------------------------------------------------------------------- | ------------- |
|
||||
| 🌐 Providers | **231** | 20–100 |
|
||||
| 🆓 Free providers | **50+ (11 free forever)** | 1–5 |
|
||||
| 🔀 Routing strategies | **17** (priority, weighted, cost-optimized, context-relay, fusion…) | 1–3 |
|
||||
| 🗜️ Token compression | **RTK + Caveman stacked (15–95%)** | None / 20–40% |
|
||||
| 🧰 Built-in MCP server | **87 tools, 3 transports, 30 scopes** | Rare |
|
||||
| 🤝 A2A agent protocol | **6 skills, JSON-RPC 2.0** | None |
|
||||
| 🧠 Memory (FTS5 + vector) | **Yes** | Rare |
|
||||
| 🛡️ Guardrails (PII, injection, vision) | **Yes** | Rare |
|
||||
| ☁️ Cloud agents | **Codex, Devin, Jules** | None |
|
||||
| 🥷 TLS fingerprint stealth | **JA3/JA4 via wreq-js** | None |
|
||||
| 🖥️ Multi-platform | **Web · Desktop · Termux · PWA** | Web only |
|
||||
| 🌍 i18n | **42 locales** | 0–4 |
|
||||
|
||||
<sub>📊 Detailed comparison vs LiteLLM, OpenRouter & Portkey → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
@@ -282,6 +286,28 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
<div align="center">
|
||||
|
||||
# ✨ What's New
|
||||
|
||||
</div>
|
||||
|
||||
> Recent highlights from **v3.8.20 → v3.8.39**. Full history in [`CHANGELOG.md`](CHANGELOG.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, Gemini CLI); `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)
|
||||
- **🗜️ Pluggable compression** — an async pipeline of **9 composable engines** with Compression Studios, an LLMLingua-2 ONNX engine and a heuristic/SLM two-tier **Ultra**, RTK, delegated Anthropic Context Editing, **Output Styles** (output-axis steering: terse-prose / less-code / terse-CJK), an **adaptive context-budget dial** (escalate only as far as needed to fit the context window), per-request `x-omniroute-compression` control, an opt-in offline eval harness, one-click **Headroom** proxy lifecycle management from the dashboard (Docker sidecar supported), a synthetic **compression playground** (Play lanes + A/B Compare with USD-capped fidelity verdicts), an opt-in **per-step fidelity gate** that rejects a lossy engine before it degrades the prompt, a **best-of-N candidate encoder** (GCF vs TOON — keep whichever is shorter, with an A/B bytes/token table in the studio), **CCR ranged/grep/stats retrieval** (pull an exact byte/line slice or summary of a stored block instead of re-expanding it), and a unified panel with named profiles + an active-profile selector. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||
- **🕵️ Transparent MITM decrypt (TPROXY)** — capture & translate traffic from CLIs that ignore proxy env vars, with a per-SNI certificate authority and a trust-store installer. → [MITM/TPROXY](docs/security/MITM-TPROXY-DECRYPT.md)
|
||||
- **💸 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 231-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). → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 🤖 Compatible CLIs & Coding Agents
|
||||
|
||||
> One config — `http://localhost:20128/v1` — and **every** AI IDE or CLI runs on free & low-cost models.
|
||||
@@ -319,11 +345,11 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 🌐 177 AI Providers — 50+ Free
|
||||
# 🌐 231 AI Providers — 50+ Free
|
||||
|
||||
</div>
|
||||
|
||||
> The most complete catalog of any open-source router: **177 providers**, **50+ with a free tier**, **11 free forever**.
|
||||
> The most complete catalog of any open-source router: **231 providers**, **50+ with a free tier**, **11 free forever**.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -407,6 +433,22 @@ omniroute setup # guided first-run wizard
|
||||
omniroute doctor # diagnose providers, ports, native deps
|
||||
```
|
||||
|
||||
### 🛰️ Remote mode — run the CLI here, OmniRoute on a VPS
|
||||
|
||||
OmniRoute on a server? Drive it from your laptop with the **same CLI**. Log in once
|
||||
with a scoped access token; every command then targets the remote.
|
||||
|
||||
```bash
|
||||
omniroute connect 192.168.0.15 # password → scoped token, saved as a context
|
||||
omniroute models list # ← runs against the REMOTE server
|
||||
omniroute configure codex # ← picks a remote model, writes a local Codex profile
|
||||
omniroute tokens create --name ci --scope read # mint narrower tokens for other machines
|
||||
omniroute contexts use default # ← switch back to the local server
|
||||
```
|
||||
|
||||
Tokens are scoped `read` / `write` / `admin`; process-spawning routes stay loopback-only.
|
||||
<sub>📖 [Remote Mode](docs/guides/REMOTE-MODE.md)</sub>
|
||||
|
||||
<div align="center">
|
||||
|
||||
`providers` · `oauth` · `keys` · `combo` · `nodes` · `models` · `cache` · `compression` · `cost` · `usage` · `quota` · `health` · `resilience` · `telemetry` · `logs` · `audit` · `mcp` · `a2a` · `cloud` · `memory` · `skills` · `eval` · `tunnel` · `backup` · `sync` · `webhooks` · `policy` · `pricing` · `translator` · `simulate` …
|
||||
@@ -420,9 +462,9 @@ Expose OmniRoute over **MCP** or **A2A** and any capable agent gets the keys to
|
||||
| Protocol | Endpoint | Use it for |
|
||||
| ------------------ | ----------------------------------------------- | ------------------------------------------------------ |
|
||||
| 🧰 **MCP (stdio)** | `omniroute --mcp` | Plug into Claude Desktop, Cursor, any MCP client |
|
||||
| 🌊 **MCP (HTTP)** | `http://localhost:20128/api/mcp/stream` | Remote MCP — **37 tools**, 13 scopes, full audit trail |
|
||||
| 🌊 **MCP (HTTP)** | `http://localhost:20128/api/mcp/stream` | Remote MCP — **87 tools**, 30 scopes, full audit trail |
|
||||
| 📡 **MCP (SSE)** | `http://localhost:20128/api/mcp/sse` | Streaming MCP transport |
|
||||
| 🤝 **A2A** | `http://localhost:20128/.well-known/agent.json` | Agent-to-agent, **JSON-RPC 2.0** + SSE, 5 skills |
|
||||
| 🤝 **A2A** | `http://localhost:20128/.well-known/agent.json` | Agent-to-agent, **JSON-RPC 2.0** + SSE, 6 skills |
|
||||
|
||||
```bash
|
||||
# Give Claude Code the full OmniRoute toolset over MCP:
|
||||
@@ -439,7 +481,25 @@ 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 stacks ideas from [RTK](https://github.com/rtk-ai/rtk), [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 51K+), and [Troglodita](https://github.com/leninejunior/troglodita) (PT-BR).
|
||||
> **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 9 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) (⭐ 51K+), [LLMLingua-2](https://github.com/microsoft/LLMLingua), and [Troglodita](https://github.com/leninejunior/troglodita) (PT-BR).
|
||||
|
||||
### 🧱 The 9-engine stack
|
||||
|
||||
Engines run in pipeline order; each is independently toggleable and configurable per combo:
|
||||
|
||||
| # | Engine | What it does |
|
||||
| --- | ----------------- | ------------------------------------------------------------------- |
|
||||
| 1 | **Session-Dedup** | Drops content repeated across turns (content-addressed, cross-turn) |
|
||||
| 2 | **CCR** | Archives large blocks behind retrieve markers, fetched on demand |
|
||||
| 3 | **RTK** | Smart tool-result filtering, dedup & truncation (command-aware) |
|
||||
| 4 | **Headroom** | Lossless tabular compaction of homogeneous JSON arrays (~30%+) |
|
||||
| 5 | **Caveman** | Rule-based prose compression (~65–75% on output) |
|
||||
| 6 | **LLMLingua-2** | ML semantic pruning via MobileBERT ONNX — code-safe, async |
|
||||
| 7 | **Lite** | Whitespace + image-URL trimming (latency-light baseline) |
|
||||
| 8 | **Aggressive** | Summarization + progressive aging of old turns |
|
||||
| 9 | **Ultra** | Heuristic token pruning with an optional small-model (SLM) tier |
|
||||
|
||||
Code blocks, URLs and structured data are **always preserved** byte-perfect. **One-click presets** combine the engines:
|
||||
|
||||
| Mode | Savings | Best for |
|
||||
| ------------------------------ | ---------- | --------------------------- |
|
||||
@@ -468,10 +528,10 @@ claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp
|
||||
|
||||
<br/>
|
||||
|
||||
### 📖 How it works — pipeline, architecture & savings math</b></summary>
|
||||
### 📖 How it works — pipeline, architecture & savings math
|
||||
|
||||
```
|
||||
Client (10,000 tok) ──▶ OmniRoute Compression (7 options) ──▶ Provider (~1,080 tok, up to 95% saved)
|
||||
Client (10,000 tok) ──▶ OmniRoute Compression (9 engines) ──▶ Provider (~1,080 tok, up to 95% saved)
|
||||
```
|
||||
|
||||
Default stacked combo runs `RTK → Caveman`. When both act on the same tool/context payload, savings compound:
|
||||
@@ -482,7 +542,20 @@ average = 1 − (1 − 0.80) × (1 − 0.46) = 89.2%
|
||||
range = 78.4 – 94.6%
|
||||
```
|
||||
|
||||
Code blocks, URLs, JSON and structured data are **always protected** by the preservation engine. Auto-trigger compression by token threshold, or assign a compression pipeline per routing combo.
|
||||
Code blocks, URLs, JSON and structured data are **always protected** by the preservation engine.
|
||||
|
||||
### 🎚️ Beyond the engines — output styles, the adaptive dial & per-request control
|
||||
|
||||
The 9 engines above shrink what goes **in**. Three more layers shape **how**, **when**, and what comes **out**:
|
||||
|
||||
- **🪄 Output Styles** _(output-axis steering)_ — inject deterministic, cache-safe response-shaping instructions; combinable, each at `lite` / `full` / `ultra` intensity. Adding a style is a one-line registry entry:
|
||||
- **Terse prose** — drop filler / articles / hedging; keep technical substance exact.
|
||||
- **Less code** — "lazy senior dev" YAGNI: smallest working change, no unrequested scaffolding.
|
||||
- **Terse CJK (文言)** — classical-Chinese ultra-terse style (locale-gated to `zh`).
|
||||
- **🎯 Adaptive context-budget** _(the dial)_ — instead of one on/off token threshold, escalate the cheapest, most-lossless engines only as far as needed to **fit the model's context window**. Policy: `reserve-output` (default, model-aware) · `percentage` · `absolute`. Mode: `floor` (guarantee fit) · `replace-autotrigger` (your explicit choice wins) · `off` (legacy threshold).
|
||||
- **🎛️ Where compression is decided** _(precedence, high → low)_ — per-request `x-omniroute-compression` header › routing-combo override › active named profile › adaptive / auto-trigger › panel default › off. The applied plan echoes back in the `X-OmniRoute-Compression: <mode>; source=<source>` response header.
|
||||
|
||||
Auto-trigger by token threshold, flip on the adaptive dial, pin a named profile, set a one-off per request, or assign a pipeline per routing combo — whichever fits the workload. An opt-in offline **eval harness** (`npm run eval:compression`) scores fidelity vs. savings on a pinned corpus before you promote a change.
|
||||
|
||||
📖 [`COMPRESSION_GUIDE.md`](docs/compression/COMPRESSION_GUIDE.md) · [`RTK_COMPRESSION.md`](docs/compression/RTK_COMPRESSION.md) · [`COMPRESSION_ENGINES.md`](docs/compression/COMPRESSION_ENGINES.md)
|
||||
|
||||
@@ -505,7 +578,7 @@ Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`.
|
||||
|
||||
**2) Connect a FREE provider (no signup)**
|
||||
|
||||
Dashboard → **Providers** → connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → done.
|
||||
Dashboard → **Providers** → connect **Kiro AI** (free Claude, ~50 credits/month per account) or **OpenCode Free** (no auth) → done.
|
||||
|
||||
**3) Point your coding tool**
|
||||
|
||||
@@ -538,7 +611,7 @@ Use these only for clients that cannot attach `Authorization: Bearer ...`. Heade
|
||||
|
||||
<br/>
|
||||
|
||||
## 📦 More install methods — Docker, source, pnpm, Arch</b></summary>
|
||||
## 📦 More install methods — Docker, source, pnpm, Arch
|
||||
|
||||
**🐳 Docker**
|
||||
|
||||
@@ -595,8 +668,6 @@ podman compose --profile base up -d
|
||||
|
||||
📖 [Podman Guide](contrib/podman/README.md) — Quadlet setup, podman-compose, Quadlet.
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
@@ -676,7 +747,7 @@ podman compose --profile base up -d
|
||||
**$0 forever:**
|
||||
|
||||
```
|
||||
1. kr/claude-sonnet-4.5 (Kiro — unlimited)
|
||||
1. kr/claude-sonnet-4.5 (Kiro — ~50 credits/mo per acct)
|
||||
2. if/kimi-k2-thinking (Qoder — unlimited)
|
||||
3. pol/gpt-5 (Pollinations — no key)
|
||||
4. lc/longcat-flash-lite (50M tok/day backup)
|
||||
@@ -709,11 +780,13 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
|
||||
<br/>
|
||||
|
||||
**Routing:** 14 strategies · task-aware smart routing · thinking budget controls · wildcard routing · system prompt injection.
|
||||
**Routing:** 15 strategies · task-aware smart routing · thinking budget controls · wildcard routing · system prompt injection.
|
||||
**Compatibility:** OpenAI ↔ Claude ↔ Gemini ↔ Responses API · auto OAuth refresh (PKCE, 8 providers) · multi-account round-robin · Batch + Files API · live OpenAPI 3.0.
|
||||
**Protocols:** MCP (37 tools, 3 transports, 13 scopes) · A2A (JSON-RPC 2.0, SSE, skills) · ACP · cloud agents (Codex, Devin, Jules).
|
||||
**Protocols:** MCP (87 tools, 3 transports, 30 scopes) · A2A (JSON-RPC 2.0, SSE, 6 skills) · ACP · cloud agents (Codex, Devin, Jules).
|
||||
**Plugins:** custom plugin marketplace (system-configured registry URL with SSRF-guarded fetch) · install / enable / disable · Notion + Obsidian knowledge-base integrations (WebDAV file server, vault search, note CRUD).
|
||||
**Embedded services:** one-click install & lifecycle management of local sidecar services (CLIProxy, NineRouter).
|
||||
**Quality & Ops:** built-in **Evals** (golden-set: exact/contains/regex/custom) · guardrails (PII, injection, vision) · health dashboard · p50/p95/p99 telemetry · webhooks · compliance audit.
|
||||
**AI Agent Skills:** drop-in markdown manifests — point any agent at `skills/omniroute/SKILL.md`. 10 skills available.
|
||||
**AI Agent Skills:** drop-in markdown manifests — point any agent at a `skills/*/SKILL.md` manifest. 43 skills available.
|
||||
|
||||
📖 [MCP Server](open-sse/mcp-server/README.md) · [A2A Server](src/lib/a2a/README.md) · [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) · [Features Gallery](docs/guides/FEATURES.md)
|
||||
|
||||
@@ -731,9 +804,9 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
| `DATA_DIR` | `~/.omniroute` | Database & config storage |
|
||||
|
||||
**Will I be charged by OmniRoute?** No — it's free, open-source software on your machine. You only pay paid providers directly. OmniRoute has no billing system.
|
||||
**Are FREE providers really unlimited?** Yes — Kiro, Qoder, Pollinations, LongCat, Cloudflare. No catch.
|
||||
**Are FREE providers really unlimited?** Mostly — Qoder, Pollinations, LongCat, and Cloudflare are free with no per-account credit cap. Kiro is free too but capped at ~50 credits/month per account. Stack multiple free providers in a combo and auto-fallback keeps you serving for $0.
|
||||
**Will compression hurt quality?** No — it only compresses the **input**; code, URLs, JSON are always protected.
|
||||
**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 177 providers.
|
||||
**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 231 providers.
|
||||
|
||||
📖 [User Guide](docs/guides/USER_GUIDE.md) · [API Reference](docs/reference/API_REFERENCE.md) · [Environment Config](docs/reference/ENVIRONMENT.md)
|
||||
|
||||
@@ -751,7 +824,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
| OAuth token expired | Auto-refreshed; if stuck, delete + re-auth in Providers |
|
||||
| `unsupported_country_region_territory` | Configure proxy in Settings → Proxy |
|
||||
| Docker SQLite locks | Use `--stop-timeout 40` for clean WAL checkpoint |
|
||||
| Node runtime errors | Use Node `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24 <25` |
|
||||
| Node runtime errors | Use Node `>=22.0.0 <23` or `>=24.0.0 <27` |
|
||||
|
||||
🐛 **Reporting a bug?** Run `npm run system-info` and attach `system-info.txt`. 📖 [`docs/guides/TROUBLESHOOTING.md`](docs/guides/TROUBLESHOOTING.md)
|
||||
|
||||
@@ -795,15 +868,15 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
|
||||
</div>
|
||||
|
||||
- **Runtime**: Node.js 20.20.2+, 22.22.2+, or 24.x LTS (24 LTS recommended)
|
||||
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0)
|
||||
- **Runtime**: Node.js 22.x or 24.x LTS (24 LTS recommended) — `>=22.0.0 <23 || >=24.0.0 <27`
|
||||
- **Language**: TypeScript 6.0 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0)
|
||||
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
|
||||
- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills
|
||||
- **Schemas**: Zod (MCP tool I/O validation, API contracts)
|
||||
- **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE)
|
||||
- **Streaming**: Server-Sent Events (SSE) + WebSocket bridge (`/v1/ws`)
|
||||
- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys + MCP Scoped Authorization
|
||||
- **Testing**: Node.js test runner + Vitest (**4,690+ test cases** across 517 files — unit, integration, E2E, security, ecosystem)
|
||||
- **Testing**: Node.js test runner + Vitest (**14,965 test cases** across 517 files — unit, integration, E2E, security, ecosystem)
|
||||
- **Platforms**: Desktop (Electron), Android (Termux), PWA (any browser)
|
||||
- **CI/CD**: GitHub Actions (auto npm publish + Docker Hub on release)
|
||||
- **Website**: [omniroute.online](https://omniroute.online)
|
||||
@@ -821,63 +894,66 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
|
||||
### 📘 Getting Started
|
||||
|
||||
| Document | Description |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment |
|
||||
| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
|
||||
| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
|
||||
| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure |
|
||||
| Document | Description |
|
||||
| -------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment |
|
||||
| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
|
||||
| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
|
||||
| [Remote Mode](docs/guides/REMOTE-MODE.md) | Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens |
|
||||
| [Claude Code Config](docs/guides/CLAUDE-CODE-CONFIGURATION.md) | Point Claude Code at OmniRoute (local/remote) with `launch` + per-model profiles |
|
||||
| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure |
|
||||
|
||||
### 🔧 Operations & Deployment
|
||||
|
||||
| Document | Description |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| [Docker Guide](docs/guides/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags |
|
||||
| [Podman Guide](contrib/podman/README.md) | Quadlet systemd integration, podman-compose, SELinux |
|
||||
| Document | Description |
|
||||
| -------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| [Docker Guide](docs/guides/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags |
|
||||
| [Podman Guide](contrib/podman/README.md) | Quadlet systemd integration, podman-compose, SELinux |
|
||||
| [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup |
|
||||
| [Fly.io Deployment](docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md) | Deploy to Fly.io with persistent storage |
|
||||
| [Termux Guide](docs/guides/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux |
|
||||
| [PWA Guide](docs/guides/PWA_GUIDE.md) | Progressive Web App install, caching, architecture |
|
||||
| [Uninstall Guide](docs/guides/UNINSTALL.md) | Clean removal for all install methods |
|
||||
| [Environment Config](docs/reference/ENVIRONMENT.md) | Complete `.env` variables and references |
|
||||
| [Termux Guide](docs/guides/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux |
|
||||
| [PWA Guide](docs/guides/PWA_GUIDE.md) | Progressive Web App install, caching, architecture |
|
||||
| [Uninstall Guide](docs/guides/UNINSTALL.md) | Clean removal for all install methods |
|
||||
| [Environment Config](docs/reference/ENVIRONMENT.md) | Complete `.env` variables and references |
|
||||
|
||||
### 🧠 Features & Architecture
|
||||
|
||||
| Document | Description |
|
||||
| ---------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [Architecture](docs/architecture/ARCHITECTURE.md) | System architecture, data flow, and internals |
|
||||
| [Compression Guide](docs/compression/COMPRESSION_GUIDE.md) | 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked |
|
||||
| [RTK Compression](docs/compression/RTK_COMPRESSION.md) | Command-output compression, filters, trust, verify, raw-output recovery |
|
||||
| [Compression Engines](docs/compression/COMPRESSION_ENGINES.md) | Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces |
|
||||
| [Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters |
|
||||
| [Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring |
|
||||
| [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing |
|
||||
| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 6-factor scoring, mode packs, self-healing |
|
||||
| [Proxy Guide](docs/ops/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD |
|
||||
| [Free Tiers](docs/reference/FREE_TIERS.md) | 25+ free API providers consolidated directory |
|
||||
| [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots |
|
||||
| [Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough |
|
||||
| Document | Description |
|
||||
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [Architecture](docs/architecture/ARCHITECTURE.md) | System architecture, data flow, and internals |
|
||||
| [Compression Guide](docs/compression/COMPRESSION_GUIDE.md) | 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked |
|
||||
| [RTK Compression](docs/compression/RTK_COMPRESSION.md) | Command-output compression, filters, trust, verify, raw-output recovery |
|
||||
| [Compression Engines](docs/compression/COMPRESSION_ENGINES.md) | Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces |
|
||||
| [Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters |
|
||||
| [Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring |
|
||||
| [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing |
|
||||
| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 9-factor scoring, mode packs, self-healing |
|
||||
| [Proxy Guide](docs/ops/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD |
|
||||
| [Free Tiers](docs/reference/FREE_TIERS.md) | 25+ free API providers consolidated directory |
|
||||
| [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots |
|
||||
| [Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough |
|
||||
|
||||
### 🤖 Protocols & APIs
|
||||
|
||||
| Document | Description |
|
||||
| ------------------------------------------- | --------------------------------------------------- |
|
||||
| [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples |
|
||||
| [OpenAPI Spec](docs/reference/openapi.yaml) | OpenAPI 3.0 specification |
|
||||
| [MCP Server](open-sse/mcp-server/README.md) | 29 MCP tools, IDE configs, Python/TS/Go clients |
|
||||
| [MCP Server Guide](docs/frameworks/MCP-SERVER.md) | MCP installation, transports, and tool reference |
|
||||
| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt |
|
||||
| [A2A Server Guide](docs/frameworks/A2A-SERVER.md) | A2A agent card, tasks, skills, and streaming |
|
||||
| Document | Description |
|
||||
| ------------------------------------------------- | --------------------------------------------------- |
|
||||
| [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples |
|
||||
| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification |
|
||||
| [MCP Server](open-sse/mcp-server/README.md) | 87 MCP tools, IDE configs, Python/TS/Go clients |
|
||||
| [MCP Server Guide](docs/frameworks/MCP-SERVER.md) | MCP installation, transports, and tool reference |
|
||||
| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt |
|
||||
| [A2A Server Guide](docs/frameworks/A2A-SERVER.md) | A2A agent card, tasks, skills, and streaming |
|
||||
|
||||
### 📋 Project & Quality
|
||||
|
||||
| Document | Description |
|
||||
| ---------------------------------------------- | ----------------------------------------------- |
|
||||
| [Contributing](CONTRIBUTING.md) | Development setup and guidelines |
|
||||
| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices |
|
||||
| [i18n Guide](docs/guides/I18N.md) | 40+ language support, translation workflow, RTL |
|
||||
| Document | Description |
|
||||
| -------------------------------------------------- | ----------------------------------------------- |
|
||||
| [Contributing](CONTRIBUTING.md) | Development setup and guidelines |
|
||||
| [Changelog](CHANGELOG.md) | Full per-version release history |
|
||||
| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices |
|
||||
| [i18n Guide](docs/guides/I18N.md) | 40+ language support, translation workflow, RTL |
|
||||
| [Release Checklist](docs/ops/RELEASE_CHECKLIST.md) | Pre-release validation steps |
|
||||
| [Coverage Plan](docs/ops/COVERAGE_PLAN.md) | Test coverage strategy and 4,690+ test suite |
|
||||
| [Coverage Plan](docs/ops/COVERAGE_PLAN.md) | Test coverage strategy and 14,965 test suite |
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -1003,15 +1079,78 @@ gh release create v3.8.2 --title "v3.8.2" --generate-notes
|
||||
|
||||
</div>
|
||||
|
||||
Special thanks to **[9router](https://github.com/decolua/9router)** by **[decolua](https://github.com/decolua)** — the original project that inspired this fork. OmniRoute builds upon that incredible foundation with additional features, multi-modal APIs, and a full TypeScript rewrite.
|
||||
OmniRoute stands on the shoulders of giants. It started as a fork of **[9router](https://github.com/decolua/9router)** and a TypeScript port of the Go project **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — and from there, every subsystem below was inspired by an open-source project that got there first. Each one shaped a concrete piece of OmniRoute. This is our thank-you to all of them. 🙏
|
||||
|
||||
Special thanks to **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** by **[router-for-me](https://github.com/router-for-me)** — the original Go implementation that inspired this JavaScript port.
|
||||
> ⭐ star counts as of June 2026 — go give these projects a star.
|
||||
|
||||
Special thanks to **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project whose caveman-speak compression philosophy inspired OmniRoute's standard compression mode and 30+ filler/condensation regex rules.
|
||||
### 🧬 Lineage & gateway
|
||||
|
||||
Special thanks to **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** by **[RTK AI](https://github.com/rtk-ai)** — the high-performance command-output compression project whose terminal, build, test, git, and tool-output filtering model inspired OmniRoute's RTK engine, JSON filter DSL, raw-output recovery, and stacked RTK → Caveman compression pipeline.
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ------------------------------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[9router](https://github.com/decolua/9router)** · decolua | 17.9k | The original project this fork is built on — extended here with multi-modal APIs and a full TypeScript rewrite. |
|
||||
| **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** · router-for-me | 37.8k | The Go implementation that inspired this JavaScript / TypeScript port. |
|
||||
| **[LiteLLM](https://github.com/BerriAI/litellm)** · BerriAI | 50.8k | The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing. |
|
||||
|
||||
Special thanks to **[Troglodita](https://github.com/leninejunior/troglodita)** by **[Lenine Júnior](https://github.com/leninejunior)** — the PT-BR token compression project ("por que gastar muitos tokens quando poucos resolve?") whose Portuguese-native rules power OmniRoute's pt-BR language pack: pleonasm reduction, filler removal tuned for Brazilian Portuguese grammar, and technical abbreviations for the dev BR community.
|
||||
### 🗜️ Context & token compression — engines
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ---------------------------------------------------------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[Caveman](https://github.com/JuliusBrussee/caveman)** · JuliusBrussee | 74.5k | The viral "why use many token when few token do trick" project — its caveman-speak philosophy powers our standard compression mode and 30+ filler/condensation rules. |
|
||||
| **[RTK – Rust Token Killer](https://github.com/rtk-ai/rtk)** · rtk-ai | 63.6k | High-performance command-output compression — inspired our RTK engine, JSON filter DSL, raw-output recovery and the stacked RTK → Caveman pipeline. |
|
||||
| **[headroom](https://github.com/chopratejas/headroom)** · chopratejas | 33.6k | Reversible context-compression (SmartCrusher) — inspired our `headroom` engine and the `ccr` retrieve-marker pattern. |
|
||||
| **[LLMLingua](https://github.com/microsoft/LLMLingua)** · Microsoft | 6.3k | Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open `llmlingua` engine. |
|
||||
| **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** · atjsh | 27 | The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine. |
|
||||
| **[Troglodita](https://github.com/leninejunior/troglodita)** · Lenine Júnior | 15 | PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar. |
|
||||
| **[ponytail](https://github.com/DietrichGebert/ponytail)** · DietrichGebert | 51.4k | The viral "lazy senior dev" YAGNI-coder skill — inspired our **less-code** Output Style: smallest-working-change steering that cuts _generated_ code (the output-axis sibling to Caveman's terse prose). |
|
||||
|
||||
### 🧩 Compact formats, token research & code-aware tooling
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ---------------------------------------------------------------------------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[TOON](https://github.com/toon-format/toon)** · toon-format | 24.6k | Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage. |
|
||||
| **[GCF – Graph Compact Format](https://github.com/blackwell-systems/gcf)** · Blackwell Systems | 11 | Schema-aware "JSON for LLMs" notation — co-inspired our lossless homogeneous-array compaction with `[N rows]` markers. |
|
||||
| **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** · ooples | 409 | Brotli/SQLite cache + per-session context-delta — inspired our `session-dedup` engine. |
|
||||
| **[token-savior](https://github.com/Mibayy/token-savior)** · Mibayy | 993 | Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction. |
|
||||
| **[token-saver](https://github.com/ppgranger/token-saver)** · ppgranger | 103 | Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip. |
|
||||
| **[token-optimizer](https://github.com/alexgreensh/token-optimizer)** · alexgreensh | 1.4k | "Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking. |
|
||||
| **[TokenMizer](https://github.com/Shweta-Mishra-ai/tokenmizer)** · Shweta-Mishra-ai | 1 | A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design. |
|
||||
| **[OmniCompress](https://github.com/jessefreitas/OmniCompress)** · jessefreitas | 2 | Rust columnar-JSON + content-addressed retrieve + cross-message dedup — validated our `headroom`/`ccr`/`session-dedup` engine design and the cache-stable "compressed form is position-independent" invariant. |
|
||||
| **[mcp-compressor](https://github.com/atlassian-labs/mcp-compressor)** · Atlassian Labs | 80 | MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction. |
|
||||
| **[RepoMapper](https://github.com/pdavis68/RepoMapper)** · pdavis68 | 182 | Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration. |
|
||||
| **[quiet-shell-mcp](https://github.com/mrsimpson/quiet-shell-mcp)** · mrsimpson | 4 | Declarative shell-output reduction over MCP — validated our declarative bash-output compaction. |
|
||||
| **[ts-morph](https://github.com/dsherret/ts-morph)** · David Sherret | 6.1k | TypeScript Compiler API toolkit — inspired our parser-based comment removal that preserves string, template and regex literals. |
|
||||
|
||||
### 🧠 Memory & RAG
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ------------------------------------------------------------------ | ----: | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[Mem0](https://github.com/mem0ai/mem0)** · mem0ai | 58.9k | Universal memory layer — its proxy-as-write/read-boundary model shaped our memory architecture. |
|
||||
| **[Letta (MemGPT)](https://github.com/letta-ai/letta)** · letta-ai | 23.4k | Stateful agents with tiered memory — inspired our Context Control & Recovery (CCR) tiered model. |
|
||||
| **[WFGY](https://github.com/onestardao/WFGY)** · onestardao | 1.8k | The ProblemMap taxonomy of 16 recurring RAG/LLM failure modes — the shared vocabulary in our troubleshooting guide. |
|
||||
|
||||
### 🛰️ Traffic inspection, MITM & transparent proxy
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| --------------------------------------------------------------------------------- | ---: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[llm-interceptor](https://github.com/chouzz/llm-interceptor)** · chouzz | 46 | MITM interception/analysis of coding-assistant ↔ LLM traffic — our Traffic Inspector ports its SSE merge, conversation normalization, host passthrough and secret masking (MIT). |
|
||||
| **[ProxyBridge](https://github.com/InterceptSuite/ProxyBridge)** · InterceptSuite | 5.1k | Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, `/proc` process attribution and TPROXY capture. |
|
||||
|
||||
### 📚 Model data, observability & UI
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| -------------------------------------------------------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[models.dev](https://github.com/anomalyco/models.dev)** · SST / OpenCode | 5.1k | Open database of AI model specs, pricing and capabilities — synced natively into our model catalog. |
|
||||
| **[React Flow / xyflow](https://github.com/xyflow/xyflow)** · xyflow | 37.1k | The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio. |
|
||||
| **[LangGraph](https://github.com/langchain-ai/langgraph)** · LangChain | 35.1k | LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view. |
|
||||
| **[Langfuse](https://github.com/langfuse/langfuse)** · Langfuse | 29.3k | Its trace → span → generation observability model shaped our Compression Studio waterfall. |
|
||||
| **[Kiali](https://github.com/kiali/kiali)** · Kiali | 3.6k | Istio service-mesh observability — inspired our circuit-breaker badges and error-edge visuals in the Routing/Combo Studio. |
|
||||
| **[lobe-icons](https://github.com/lobehub/lobe-icons)** · LobeHub | 2.1k | AI/LLM brand logos that render the provider icons across our dashboard. |
|
||||
|
||||
### 🛡️ Security
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ------------------------------------------------------------------------------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **[awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)** · tldrsec | 708 | A curated list of secure-by-default libraries that guides our security choices (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink). |
|
||||
|
||||
## ❤️ Support
|
||||
|
||||
@@ -1031,7 +1170,7 @@ MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
**[⬆ Back to top](#-omniroute)** · Built with ❤️ for the open-source AI community.
|
||||
|
||||
<sub>OmniRoute v3.8.6 · Node ≥22.22.2 · MIT License · <a href="https://omniroute.online">omniroute.online</a></sub>
|
||||
<sub>OmniRoute v3.8.24 · Node ≥22.0.0 · MIT License · <a href="https://omniroute.online">omniroute.online</a></sub>
|
||||
|
||||
</div>
|
||||
<!-- GitHub Discussions enabled for community Q&A -->
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
# Relatório — Quality Gates, Catraca & Anti-Alucinação no OmniRoute
|
||||
|
||||
> **Data:** 2026-06-09
|
||||
> **Origem:** Auditoria do projeto (5 subagentes Opus em paralelo mapeando todas as pastas exceto `node_modules`/`_references`/`dist`) + análise da transcrição do vídeo *"Qualidade de código"* (Stupid Button Club, 2026-05-04) + pesquisa web 2026 (4 frentes, últimos ~3 meses).
|
||||
> **Companheiro:** Veja [`PLANO-QUALITY-GATES.md`](./PLANO-QUALITY-GATES.md) para o plano de implementação bite-sized (TDD).
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **O OmniRoute já é muito mais maduro** que o projeto "Strawberry" do vídeo: tem CI com 20 jobs, gate de cobertura, ESLint 9 flat, SonarQube, 14 scripts `check-*.mjs` e **uma catraca real já funcionando** (`check-t11-any-budget.mjs` — orçamento de `any` por arquivo que só pode encolher). O vídeo descreve onde queremos chegar; nós já estamos a meio caminho.
|
||||
2. **Mas faltam exatamente as catracas que o vídeo prega.** Não há baseline congelado de métricas, nem gate de **duplicação**, nem de **tamanho de arquivo**, e o gate de cobertura é um **piso fixo** — não uma catraca "nunca piorar".
|
||||
3. **Há derivas (drifts) reais que pegamos na auditoria:** o gate de cobertura **no CI é `40/40/40/40`** (ci.yml:377), não os `60/60/60/60` que o CLAUDE.md anuncia (esse é só o script local). O Husky está **100% comentado** (zero gate local). O SonarQube tem `coverage` e `cpd` **excluídos** (`sonar-project.properties:9-10`) — as duas métricas mais úteis contra "slop" estão desligadas. E 3 scripts `check-*` existem mas **não rodam em lugar nenhum**.
|
||||
4. **Os maiores ímãs de alucinação são estruturais:** o split de provider em 3 arquivos gigantes em 2 workspaces (`providers.ts` ↔ `providerRegistry.ts` ↔ `validation.ts`, com contagens que já divergem: 229 ids vs 155 blocos vs N validadores), os 300 paths `fetch("/api/...")` hardcoded sem ligação de compilação com as rotas, e o arquivo de **12.760 linhas** (`providers/[id]/page.tsx`) que nenhum agente consegue segurar em contexto.
|
||||
5. **2026 confirma a tese do vídeo com dados:** GitClear (211M linhas) mostra duplicação crescendo 4–8× na era da IA; o paper SlopCodeBench prova que *instrução de prompt sozinha não impede a degradação* — só gates determinísticos seguram. O ecossistema 2026 tem ferramentas maduras para cada métrica (jscpd v5, knip v6, eslint-plugin-sonarjs v4, osv-scanner, Qlty, Sonar "Clean as You Code"/"AI Code Assurance").
|
||||
6. **A jogada é em camadas:** (a) reativar/reconciliar o que já existe; (b) construir o **motor de catraca** (baseline.json + coletor + comparador, clonando o `any-budget`); (c) adicionar **gates determinísticos anti-alucinação** (provider-consistency, fetch-target, openapi-routes); (d) catraca de **duplicação + tamanho**; (e) catraca de **cobertura** + detecção de test-masking; (f) skill **`/babysit`** com guarda-corpos. Detalhe no plano.
|
||||
|
||||
---
|
||||
|
||||
## 1. O que o vídeo ensina (insights destilados)
|
||||
|
||||
O vídeo é uma fala sem roteiro sobre *qualidade de código no mundo em que a IA escreve ~100% do código*. Pontos centrais:
|
||||
|
||||
| # | Insight | Citação/essência |
|
||||
|---|---------|------------------|
|
||||
| 1 | **O humano virou o gargalo.** | "Eu acabei virando o gargalo da IA. Fazer o babysit das coisas do request básicas é o gargalo. Não consigo entregar 4 tarefas ao mesmo tempo se preciso ler 10.000 linhas/dia." |
|
||||
| 2 | **Quality Gate = portão que a IA tem que passar.** | Todo PR passa por um portão; a IA fica em loop se autocorrigindo até ficar verde, em vez de o humano revisar e pedir refação. |
|
||||
| 3 | **Baseline + Catraca (ratchet).** | "Tu congela o baseline e o repositório só pode melhorar a partir dali ou empatar." A catraca anda num sentido só. |
|
||||
| 4 | **Regra de ouro.** | "Cada PR pode adicionar código, mas não pode aumentar nenhuma das métricas — nem por uma violação, nem por uma linha, nem por 0,1 ponto percentual." |
|
||||
| 5 | **Métricas do baseline.** | Violações de ESLint (483 em 120 arquivos), duplicação de código (2,2% via JSCPD), cobertura (%), arquivos acima do limite de tamanho (19 arquivos; o maior com 4.600 linhas). |
|
||||
| 6 | **Pipeline de CI.** | `npm ci` → `npm audit` (critical=bloqueia / high=avisa) → `npm run lint` → `test:coverage` → **script node de quality gate** que compara métricas atuais vs `baseline.json` e falha em qualquer regressão → comentário no PR + **upload de artefatos** que o agente lê para se autocorrigir. |
|
||||
| 7 | **Artefatos legíveis pelo agente.** | "Não adianta cuspir isso no PR. O agente precisa ter acesso ao que está dando errado." |
|
||||
| 8 | **Babysit skill.** | "Recomendo criar uma skill de babysit": a IA monitora o CI + comentários dos revisores, endereça os comentários e **resolve as conversas** para dar rastreabilidade no GitHub. |
|
||||
| 9 | **Comentários perto do código (legibilidade p/ agente).** | Mudou de ideia: antes era contra comentários ("o código é a documentação"); agora, no mundo de agentes, comentário explicando *o quê* e *por quê* perto do código vale mais que um MD gigante, porque o harness faz `grep` no arquivo e lê o comentário junto. |
|
||||
| 10 | **É "só" colar ferramentas.** | "Não é nada excepcional. Eu só estou colando um monte de ferramentas e chamando de quality gate." Pode-se usar SonarQube ou GitHub Code Quality no lugar do script caseiro. |
|
||||
| 11 | **Por que a IA não faz certo de primeira.** | Modelos top já sabem (foram treinados com os livros), mas são "preguiçosos" porque output imperfeito = mais tokens vendidos. A catraca força o nível. |
|
||||
|
||||
**Tradução para o nosso contexto:** o vídeo descreve um sistema que **já temos em embrião** (o `any-budget` é exatamente a catraca da regra de ouro, só que para uma métrica). O salto é (1) generalizar a catraca para todas as métricas, (2) reconciliar os drifts, e (3) fechar os buracos anti-alucinação que são específicos do nosso tamanho.
|
||||
|
||||
---
|
||||
|
||||
## 2. Onde o OmniRoute está hoje (panorama auditado)
|
||||
|
||||
### 2.1 O que já temos (e o vídeo nem sonha)
|
||||
|
||||
- **CI robusto** (`.github/workflows/ci.yml`, ~25 KB, 20 jobs): lint + audit + cycles + route-validation + any-budget + docs-sync + typecheck (core e noimplicit) + build + package-artifact + electron-smoke + unit (8 shards) + Node 24/26 compat + coverage (8 shards + merge) + SonarQube + e2e (9 shards) + integration + security.
|
||||
- **Catraca real já existente:** `scripts/check/check-t11-any-budget.mjs` — array `{file, maxAny}` (a maioria `0`), strip de comentários, anotações de falso-positivo, `exit 1` em regressão. **É o template exato da catraca do vídeo.**
|
||||
- **14 scripts `check-*.mjs`** (cycles, route-validation, any-budget, docs-sync, docs-counts, env-doc-sync, deprecated-versions, doc-links, cli-i18n, openapi-coverage, openapi-security-tiers, pr-test-policy, node-runtime, test-report-summary) — vários já são *gates de consistência fonte-vs-derivado*, o mesmo padrão que precisamos para anti-alucinação.
|
||||
- **PR test policy:** `check-pr-test-policy.mjs` já força "mudou código de produção ⇒ mudou teste" (diff base...HEAD).
|
||||
- **Cobertura sumarizada + comentada no PR:** `test-report-summary.mjs` + `coverage/coverage-summary.json` + job `coverage-pr-comment` (comentário com marcador `<!-- omniroute-coverage-report -->`). **Isto é exatamente o "artefato legível pelo agente" do vídeo** — já construído.
|
||||
- **Disciplina TDD institucionalizada** (Hard Rule #18: todo fix precisa de teste falha→passa ou validação ao vivo no VPS).
|
||||
- **SonarQube** configurado (job no CI + `sonar-project.properties`).
|
||||
- **Skills agênticas de review** já existem: `/review-prs`, `/review-reviews` (bateria de 8 reviewers + ralph-loop), `/code-review`, `/generate-release` (a única com babysit real de CI, mas de workflows de *release*, não do `ci.yml` do PR).
|
||||
|
||||
### 2.2 O que está DESLIGADO ou inerte ⚠️ (achados da auditoria)
|
||||
|
||||
| Item | Estado | Evidência | Impacto |
|
||||
|------|--------|-----------|---------|
|
||||
| **Husky** | 100% comentado (pre-commit **e** pre-push) | `.husky/pre-commit`, `.husky/pre-push` (todas as linhas com `#`) | Zero enforcement local — lint-staged, docs-sync, any-budget, env-doc-sync, openapi checks e o `test:unit` de pre-push dependem 100% do CI. |
|
||||
| **Gate de cobertura no CI** | **40/40/40/40** (não 60) | `ci.yml:377` `--statements 40 --lines 40 --functions 40 --branches 40` | O comentário do PR renderiza contra 60, o script local gata 60, o RELEASE_CHECKLIST diz 75/70, e o baseline real é ~79–82%. **O único número que bloqueia merge é 40** → gate de cobertura quase banguela. |
|
||||
| **Sonar coverage** | Excluído | `sonar-project.properties:9` `sonar.coverage.exclusions=**/*` | Sonar ignora cobertura de todo arquivo. |
|
||||
| **Sonar CPD (duplicação)** | Excluído | `sonar-project.properties:10` `sonar.cpd.exclusions=**/*` | Sonar não detecta copy-paste — a assinatura nº1 de slop de IA. |
|
||||
| **SonarQube job** | Inerte | `ci.yml` roda scan só se `PR && SONAR_TOKEN != '' && SONAR_HOST_URL != ''`; sem `qualitygate.wait` | Em runs sem secret, escreve "skipped"; mesmo quando roda, nunca falha o build. |
|
||||
| **`npm audit`** | Plano (`moderate`), não escalonado | `package.json:112` `--audit-level=moderate` | O vídeo prega critical=bloqueia / high=avisa. O nosso é um nível único. |
|
||||
| **3 scripts órfãos** | Sem CI nem husky | `check:cli-i18n`, `check:openapi-coverage`, `check:openapi-security-tiers` | Existem, dão `exit 1`, mas não rodam em lugar nenhum (Hard Rules #15/#17 guardadas só por um deles). |
|
||||
| **`typecheck:noimplicit:core`** | `continue-on-error: true` | `ci.yml:45-46` | Warn-only "forward-looking". |
|
||||
|
||||
### 2.3 Hotspots de tamanho (sem gate hoje)
|
||||
|
||||
`64 arquivos > 1000 LOC`, `194 > 500 LOC` (src + open-sse, sem testes). Top:
|
||||
|
||||
| LOC | Arquivo | Risco para edição por IA |
|
||||
|-----|---------|--------------------------|
|
||||
| **12.760** | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` | God-component: **192 `useState`**, 21 `useEffect`, 87 `fetch()` inline, 34 tipos inline. Nenhuma IA segura em contexto; qualquer edição arrisca apagar estado não relacionado. |
|
||||
| 5.977 | `open-sse/handlers/chatCore.ts` | God-handler: 58 funções, invariantes demais, blast radius alto. |
|
||||
| 4.590 | `open-sse/config/providerRegistry.ts` | Array gigante providers+models+OAuth; mistura `resolvePublicCred()` e literais crus. |
|
||||
| 4.456 | `open-sse/services/combo.ts` | 14 estratégias num `if/else if` sem enum/exhaustiveness — estratégia desconhecida vira no-op silencioso. |
|
||||
| 4.349 | `src/app/(dashboard)/dashboard/combos/page.tsx` | 51 `useState`, mesmo padrão god-component. |
|
||||
| 4.205 | `src/lib/providers/validation.ts` | Mega-função com closures e `SPECIALTY_VALIDATORS` definidos *dentro* da função. |
|
||||
| 3.776 | `open-sse/handlers/imageGeneration.ts` | Branching multi-provider num handler. |
|
||||
| 3.076 | `src/shared/constants/providers.ts` | 229 ids em 27 consts agrupados via `Proxy` — sem lista plana. |
|
||||
| 2.869 | `open-sse/executors/chatgpt-web.ts` | Sessão web reversa; classe só começa na linha 2443. |
|
||||
| 2.278 | `src/app/api/providers/[id]/models/route.ts` | God-route: importa ~30 módulos provider-específicos e ramifica por provider num GET. |
|
||||
|
||||
### 2.4 Ímãs de alucinação (ranqueados, da auditoria)
|
||||
|
||||
1. **Split de provider em 3 arquivos / 2 workspaces (nº1).** Não há lista plana de providers — 229 ids escondidos atrás de 27 consts + merge via `Proxy` (`AI_PROVIDERS`). Uma IA não consegue enumerar "quais providers existem" barato → **inventa ids plausíveis** (variantes `*-web`/`*-cli` inexistentes) ou registra no grupo errado. As três contagens (`providers.ts` 229 ids ↔ `providerRegistry.ts` 155 blocos ↔ `validation.ts` N validadores) **já divergem**, então não há cross-check autoritativo. *(Esse é o tema recorrente das nossas memórias de alucinação — ex.: ids inventados, modelos inexistentes.)*
|
||||
2. **300 paths `fetch("/api/...")` hardcoded** no dashboard (659 call sites), sem client tipado. Refatore uma rota e os call sites apodrecem silenciosamente; uma IA editando a UI inventa rota (`/api/providers/[id]/refresh`) ou assume `res.error.message` numa rota que devolve `{error:"..."}`. Sem ligação de símbolo entre os 659 call sites e os 488 `route.ts`.
|
||||
3. **Dual chat stack (armadilha documentada).** Seleção/fallback de conta vive em `src/sse/` (não `open-sse/`). Uma IA pedida para "consertar fallback de conta" edita `open-sse/handlers/chatCore.ts` (errado) em vez de `src/sse/services/auth.ts`. Nada no código cruza os dois stacks. *(Já erramos um diagnóstico público por isso.)*
|
||||
4. **Estratégias de combo inventadas.** 14 nomes reais soterrados num `if/else` de strings (sem enum exportado) → IA inventa nomes plausíveis-mas-falsos (`"latency-optimized"`, `"failover"`) que passam no typecheck como string e viram no-op.
|
||||
5. **Métodos de executor inventados.** O padrão real é "sobrescreve `execute()` inteiro" (48/50 executors), sem hooks documentados → IA inventa `buildRequest`/`parseChunk`/`mapError` que não existem em `BaseExecutor`.
|
||||
6. **Helpers de erro inventados** + queda para `err.message` cru (viola Rule #12) quando o helper inventado "falha"; 5 web executors hoje **não importam helper nenhum**.
|
||||
7. **AGENTS.md de DB defasado:** documenta 21 migrations / 22 módulos quando o real é **94 / 75** — uma IA lendo isso acredita em conjuntos de tabelas/módulos que não existem mais.
|
||||
8. **Route-guard omitido:** rotas novas spawn-capazes (`/api/services/`, `/api/mcp/`) devem entrar em `LOCAL_ONLY_API_PREFIXES`; a convenção está só no CLAUDE.md, não num teste que a IA veja (parcialmente coberta por 1 script órfão).
|
||||
|
||||
---
|
||||
|
||||
## 3. Gap analysis — modelo do vídeo vs OmniRoute
|
||||
|
||||
| Métrica/peça do vídeo | OmniRoute hoje | Gap |
|
||||
|-----------------------|----------------|-----|
|
||||
| `npm ci` determinístico | ✅ em todos os 14 jobs | — |
|
||||
| `npm audit` critical=bloqueia / high=avisa | ⚠️ `--audit-level=moderate` (nível único) | **Escalonar** em dois invokes |
|
||||
| `lint` | ✅ bloqueante | — |
|
||||
| `test` + cobertura | ✅ mas piso **40** no CI (drift) | **Reconciliar** p/ baseline real + catraca |
|
||||
| **Contagem de ESLint congelada** | ❌ (lint é 0-erros, mas warnings livres) | **Construir** (ratchet de violações) |
|
||||
| **Duplicação % (JSCPD)** | ❌ (Sonar CPD excluído, sem jscpd) | **Construir** (jscpd + catraca) |
|
||||
| **Limite de tamanho de arquivo** | ❌ (sem `max-lines`, sem script) | **Construir** (ESLint max-lines + catraca, freeze dos 64) |
|
||||
| **`baseline.json` congelado** | ❌ (nenhum baseline de métricas no repo) | **Construir** (o coração da catraca) |
|
||||
| **Script comparador (regra de ouro)** | 🟡 existe **para `any`** (`any-budget`) | **Generalizar** p/ todas as métricas |
|
||||
| **Sumário markdown + artefatos p/ o agente** | ✅ (coverage summary + PR comment + artifact) | **Reusar** wholesale |
|
||||
| **Babysit skill (monitora CI + resolve conversas)** | 🟡 `review-prs`/`review-reviews`/`generate-release` parciais; nenhuma resolve threads do PR nem loopa no `ci.yml` | **Construir** `/babysit` |
|
||||
| **Comentários perto do código p/ legibilidade de agente** | 🟡 `routeGuard.ts` é exemplar; resto irregular | **Padrão cultural** (Karpathy/guidelines) |
|
||||
|
||||
---
|
||||
|
||||
## 4. O que o mundo faz em 2026 (pesquisa, últimos ~3 meses)
|
||||
|
||||
> Todas as fontes abaixo vêm com URL + data nas seções de origem (ver §7). Onde a pesquisa **não conseguiu confirmar** algo de fonte primária, está marcado **[não-verificado]** — honestidade de engenharia.
|
||||
|
||||
### 4.1 Catraca / baseline-freeze (a "catraca" do vídeo)
|
||||
|
||||
- **betterer** — o tool canônico de ratchet (snapshot de métrica → `.betterer.results`; CI falha se piora, auto-atualiza se melhora). **[caveat]** Baixa velocidade: último commit no `master` em **ago/2025**, releases vazias no GitHub. Viável, mas **não** apostar como peça load-bearing de longo prazo.
|
||||
- **eslint-formatter-ratchet** — formatter que congela contagem de violações de ESLint. **Ativamente mantido** (commit 2026-03-17). Mais estreito (só ESLint) mas é a trajetória oposta ao betterer.
|
||||
- **SonarQube "Clean as You Code" (new-code conditions)** — o padrão baseline-freeze mais maduro: o quality gate aplica condições **só ao código novo** (branch de referência), grandfathering do legado. Atual.
|
||||
- **SonarQube "AI Code Assurance" (2026.1.0)** — gate específico para código gerado por IA (tag o projeto → workflow de assurance + "Sonar way for AI Code" mais estrito). **[não-verificado]** se *bloqueia* o PR (docs canônicas deram 404; descrito como "enforced quality gate" mas mecânica de bloqueio não confirmada em fonte única).
|
||||
- **Qlty CLI (qlty.sh)** — o produto 2026 mais aderente: CLI Rust **OSS e grátis** (v0.630.0, 2026-05-08) que agrega 70+ analisadores; tem **Baseline analysis** (= a catraca), **Quality Gates** com veredito go/no-go e coverage gates. **[caveat]** `qlty metrics` (a tabela LOC/complexidade) **não tem flag JSON** — só `qlty check --sarif`/`qlty smells --sarif`; o ratchet de tamanho/complexidade por arquivo ainda precisa do JSON do ESLint.
|
||||
- **Code Climate Quality → Qlty** — a marca clássica de ratchet virou empresa separada (Qlty, nov/2024). Write-ups antigos de "Code Climate" = Qlty hoje.
|
||||
|
||||
### 4.2 Ferramentas de métrica por tipo (todas com JSON p/ alimentar a catraca)
|
||||
|
||||
| Métrica | Tool 2026 | Comando JSON | Status |
|
||||
|---------|-----------|--------------|--------|
|
||||
| Duplicação | **jscpd v5** (reescrita Rust) | `jscpd --reporters json` (ou `sarif`) | Muito ativo (v5.0.4, 2026-06-08). **[caveat]** schema JSON v4→v5 não confirmado — verificar no install. |
|
||||
| Tamanho/fn-length/ciclomática | **ESLint core** (`max-lines`, `max-lines-per-function`, `complexity`) | `eslint --format json` | Built-in ESLint 9 |
|
||||
| Complexidade cognitiva | **eslint-plugin-sonarjs** (`sonarjs/cognitive-complexity`) | `eslint --format json` | Mantido (v4.0.3, 2026-04-16; agora no monorepo SonarJS — o repo standalone foi arquivado, mas o pacote está vivo). **[caveat]** README das rules deu 404; presença de S3776 em v4 é alta-confiança mas confirmar no install. |
|
||||
| Dead code / unused exports / unused deps | **knip** (vence ts-prune **arquivado** + depcheck **arquivado**) | `knip --reporter json` | Muito ativo (v6.16.1, 2026-06-06) |
|
||||
| Ciclos | **check-cycles.mjs** (já temos) + **dpdm** opcional | `dpdm --circular --output deps.json` | dpdm ativo (v4.2.0, 2026-05-09); madge estagnado |
|
||||
| Vulnerabilidades | **osv-scanner** (Google/OSV) | `osv-scanner --format json` | Muito ativo (push 2026-06-08) |
|
||||
| Política de lockfile (gate, não métrica) | **lockfile-lint** | `lockfile-lint --validate-https --validate-integrity` | Mantido (v5.0.0, 2026-01-25) |
|
||||
|
||||
> **Realidade do ratchet:** não existe tool único 2026 que emita *todas* as métricas como um JSON limpo. O padrão robusto é **N tools que emitem JSON + um reducer Node** que monta `metrics-summary.json` + o comparador que falha só em regressão (exatamente o que o `any-budget` já faz para uma métrica).
|
||||
|
||||
### 4.3 Anti-alucinação (2026)
|
||||
|
||||
- **LSP-in-the-loop / `agent-lsp` (MCP)** — servidor MCP que dá ao agente fatos verificáveis do language server (definições, referências, tipos, diagnostics, `blast_radius`) e `preview_edit` antes de escrever. Funciona com Claude Code. **Fit alto:** vira "símbolo inventado" de catch-de-review para *impossibilidade-no-edit*. (v0.13.0, 2026-06-04 — pequeno mas ativo.)
|
||||
- **Slopsquatting / pacotes alucinados** — **CSA Research Note (2026-04-19):** **19,7%** de 2,23M amostras de código IA continham nomes de pacote alucinados; 205k nomes fabricados únicos; **43%** reaparecem em re-runs (registráveis por atacantes). Defesas: **allowlist** de deps para agentes, **registry existence check** antes de instalar, **age-cooldown** (24–72h), lockfile-exact, scripts de install desabilitados. **Fit alto:** novo `check-deps.mjs`.
|
||||
- **Semcheck** (v1.2.1, fev/2026) — CLI que usa LLM para verificar que a implementação bate com o spec/doc, via `semcheck.yaml` ligando doc↔código; roda em pre-commit e Actions com `fail-on-issues`. Feito para pegar **"docs que descrevem features não implementadas"**. **Fit muito alto** para nossos incidentes recorrentes de docs alucinadas — porém é fuzzy (LLM); pareie com checks determinísticos.
|
||||
- **OpenAPI drift determinístico** — check que toda `path` do `openapi.yaml` resolve para um `route.ts` real (e vice-versa). Rápido, sem LLM, pega "endpoint inventado". **Fit alto** para `docs/reference/openapi.yaml`.
|
||||
- **Skill `verify` / `verification-before-completion`** (já no nosso ambiente) — "evidence before assertions": o veredito PASS/FAIL repousa **só** no que o app rodando demonstrou; rejeita "rodei os testes" como prova. **Fit altíssimo:** é a formalização da nossa Hard Rule #18 — exigir o *output literal* do comando colado no PR ("tool receipt").
|
||||
- **Adversarial review (críticos de sessão fresca)** — agentes Skeptic/Architect/Minimalist leem o diff *contra o spec* ("o autor está comprometido — vai racionalizar"); símbolos/APIs inventados viram violação de spec. Mapeia no nosso `/review-reviews`.
|
||||
- **SlopCodeBench (arXiv 2603.24755, ~mai/2026)** — sem mitigação, erosão estrutural aumentou em **77%** das trajetórias; o código de agente acumula verbosidade ~7× e erosão ~5× mais rápido que repos humanos. **Mitigações só-de-prompt ("anti-slop", "plan-first") melhoram o início mas NÃO param a degradação iterativa.** → **justificativa empírica** de que precisamos de gates determinísticos, não instrução.
|
||||
- **GPT-5.5 System Card (2026-04-23)** — figuras oficiais são modestas (23% mais provável de acerto factual; 3% menos erros num set propenso). O headline de **"queda de 60% em alucinação / 88,7% SWE-bench" é imprensa secundária [não-verificado]**, não a seção de factualidade do system card. Upgrade de modelo ajuda na margem, não substitui gate.
|
||||
|
||||
### 4.4 Babysit loops (2026)
|
||||
|
||||
- **Claude Code "auto-fix in the cloud"** (Anthropic, lançado 2026-03-27): "observa seus PRs na nuvem, resolvendo falhas de CI e comentários de review automaticamente; empurra fixes quando claro, pergunta quando ambíguo." Não auto-mergeia.
|
||||
- **Devin Autofix** (2026-02-10): auto-conserta comentários de review + lint/CI; endereça comentários de bots, mas deixa julgamento humano nas conversas humanas.
|
||||
- **CodeRabbit Autofix** (early access abr/2026): coleta o bloco **"Prompt for AI Agents"** de cada comentário, aplica fix, roda build-verification; **nada mergeia automaticamente**.
|
||||
- **Greptile `greploop` + skill `check-pr`** (MIT) — "dispara review → conserta comentários → re-review até 5/5 de confiança e zero comentários". **Template quase-exato** para a nossa `/babysit`.
|
||||
- **Claude `claude ultrareview <PR#> --json`** (subcomando não-interativo, research preview): bloqueia até terminar, `exit 0/1`, payload de bugs verificados parseável. **Não auto-inicia** e custa $5–20/run → usar atrás de label, não em todo push. (O `/code-review ultra` local com `--fix` é o loop interno de custo-zero.)
|
||||
- **Resolver threads de review:** não há comando `gh` nativo (cli/cli#12419). Padrão de 2 passos GraphQL: `reviewThreads(first:50){nodes{id isResolved...}}` → `mutation { resolveReviewThread(input:{threadId}) }`, respondendo antes com o SHA do commit via REST.
|
||||
- **Guarda-corpos (críticos):**
|
||||
- **Snyk Agent Fix field test (2026): ~5,3% de regressão** ("1 em 19 fixes auto-mergeados introduz problema novo") → **forte argumento contra auto-merge**.
|
||||
- **Token burn:** loops ingênuos realimentam a conversa crescente → prompt incha → alucina do próprio histórico. Uber capou gasto em **$1.500/mês/dev/tool** (abr/2026). Mitigação: **max-iterations**, time limit, idle-exit.
|
||||
- **Test-masking:** o babysit **NÃO** pode enfraquecer/remover asserts para ficar verde (= nossa Rule #18 + memória "trust but verify"). Revisão humana fica nos limites arquiteturais (interface/schema/cross-service).
|
||||
- **Audit trail:** deixar rastro humano-legível (qual fix endereçou o quê, qual gate satisfez, quais conversas resolveu) — nunca um verde silencioso. Reforça o guard de prompt-injection sobre os *corpos de comentário* que o agente ingere (21% dos reviews do ICLR 2026 eram IA; injeção embutida em código é vetor real).
|
||||
|
||||
---
|
||||
|
||||
## 5. Recomendações (ranqueadas) → ver o PLANO
|
||||
|
||||
> Build-vs-buy: **construir in-repo** os gates determinísticos (zero SaaS, dados não saem da box, reusa o harness `check-*.mjs`). **Avaliar Qlty CLI** depois, se quisermos consolidar N scripts num tool. **Não** depender de CodeRabbit/Greptile/Diamond como *o* gate de "não-piorar-métrica" — são opiniões de LLM, não contadores determinísticos.
|
||||
|
||||
**Fase 0 — Reativar & reconciliar (quick wins, sem tooling novo):**
|
||||
1. Reativar pre-commit barato do Husky (lint-staged + docs-sync + any-budget).
|
||||
2. Reconciliar o gate de cobertura: subir o CI de 40 → baseline real (com headroom) e alinhar os 4 lugares que divergem.
|
||||
3. Escalonar `npm audit` (critical=bloqueia / high=avisa).
|
||||
4. Plugar os 3 scripts órfãos (`cli-i18n`, `openapi-coverage`, `openapi-security-tiers`) no CI.
|
||||
|
||||
**Fase 1 — Motor de catraca (o coração):**
|
||||
5. `quality-baseline.json` commitado + `collect-metrics.mjs` (coletor) + `check-quality-ratchet.mjs` (comparador, clone do `any-budget`) + job de CI + artefato + comentário no PR (clone do `coverage-pr-comment`).
|
||||
|
||||
**Fase 2 — Gates determinísticos anti-alucinação:**
|
||||
6. `check-provider-consistency.mjs` (o ímã nº1), `check-fetch-targets.mjs`, `check-openapi-routes.mjs`, allow-list de estratégias/translators/executors, lint Rule #11/#12, `check-deps.mjs` (slopsquatting).
|
||||
|
||||
**Fase 3 — Catraca de duplicação + tamanho (mata-slop):**
|
||||
7. jscpd + ESLint `max-lines`/`max-lines-per-function`/`complexity` + `sonarjs/cognitive-complexity`, congelando os 64 arquivos grandes (catraca só-pode-encolher).
|
||||
|
||||
**Fase 4 — Catraca de cobertura + anti test-masking:**
|
||||
8. `check-coverage-ratchet.mjs` (cobertura não cai vs baseline) + pisos por módulo crítico + `check-test-masking.mjs` (delta de contagem de asserts em testes alterados).
|
||||
|
||||
**Fase 5 — Skill `/babysit` + evidência + LSP:**
|
||||
9. Skill `/babysit` (gh pr checks + reviewThreads + worktree de fix + resolveReviewThread + loop-até-verde, com guarda-corpos), "evidence-before-assertions" obrigatório no corpo do PR, e (opcional) `agent-lsp` MCP.
|
||||
|
||||
---
|
||||
|
||||
## 6. Riscos & ressalvas (honestidade de engenharia)
|
||||
|
||||
- **Flag-day risk:** ligar qualquer gate num projeto que nunca o teve deixa tudo vermelho. **Toda** catraca aqui é *só-regressão* (baseline congelado), nunca um piso absoluto que exige limpeza imediata — exatamente o ponto do vídeo.
|
||||
- **Custo de IA:** o babysit pode queimar tokens. Guarda-corpos (max-iterations, sem auto-merge, sem editar `.github/workflows/`) são não-negociáveis.
|
||||
- **Ressalvas de pesquisa não-verificadas:** betterer baixa-velocidade; Sonar "AI Code Assurance" bloqueio-de-PR não confirmado; "GPT-5.5 −60% alucinação" é imprensa, não system card; schema JSON do jscpd v5 e S3776 do sonarjs v4 a confirmar no install; `qlty metrics` sem JSON. Nenhuma decisão do plano depende criticamente de um item não-verificado.
|
||||
- **Trust-but-verify:** estes números internos (CI=40, husky off, sonar exclusions, 12.760 LOC, any-budget como catraca) foram **conferidos manualmente** contra os arquivos, não só relatados pelos subagentes.
|
||||
|
||||
---
|
||||
|
||||
## 7. Fontes (consolidadas)
|
||||
|
||||
**Catraca / ratchet:** betterer `github.com/phenomnomnominal/betterer` (último master ago/2025); eslint-formatter-ratchet `github.com/Jmsa/eslint-formatter-ratchet` (commit 2026-03-17); SonarQube Clean as You Code `docs.sonarsource.com/.../clean-as-you-code/about-new-code/`; Sonar AI Code Assurance `sonarsource.com/solutions/ai/ai-code-assurance/` + community thread 2026.1.0; Qlty `github.com/qltysh/qlty` (v0.630.0, 2026-05-08), `docs.qlty.sh`; Code Climate→Qlty `codeclimate.com/legacy/...` (2024-11-11).
|
||||
|
||||
**Métricas:** jscpd `github.com/kucherenko/jscpd` (v5.0.4, 2026-06-08); ESLint v10 `eslint.org/blog/2026/02/eslint-v10.0.0-released/` (2026-02-06); eslint-plugin-sonarjs `npm` (v4.0.3, 2026-04-16) + `github.com/SonarSource/SonarJS`; knip `knip.dev` (v6.16.1, 2026-06-06); dpdm `github.com/acrazing/dpdm` (v4.2.0, 2026-05-09); osv-scanner `google.github.io/osv-scanner` (push 2026-06-08); lockfile-lint `github.com/lirantal/lockfile-lint` (v5.0.0, 2026-01-25); GitClear `gitclear.com/ai_assistant_code_quality_2025_research`.
|
||||
|
||||
**Anti-alucinação:** agent-lsp `github.com/blackwell-systems/agent-lsp` (v0.13.0, 2026-06-04); CSA Slopsquatting `labs.cloudsecurityalliance.org/research/...slopsquatting...20260419...` (2026-04-19); Nesbitt package defenses `nesbitt.io/2026/04/09/...` (2026-04-09); Semcheck `github.com/rejot-dev/semcheck` (v1.2.1, fev/2026); verify skill `github.com/Piebald-AI/claude-code-system-prompts/.../skill-verify-skill.md`; Claude best practices `code.claude.com/docs/en/best-practices`; SlopCodeBench `arxiv.org/pdf/2603.24755`; GPT-5.5 system card `deploymentsafety.openai.com/gpt-5-5` (2026-04-23); adversarial review `asdlc.io/patterns/adversarial-code-review/`; OpenAPI drift `speakeasy.com/blog/openapi-spec-drift-detection`.
|
||||
|
||||
**Babysit:** Claude auto-fix cloud `producthunt.com/products/claude-code-auto-fix-in-the-cloud` (2026-03-27); Devin Autofix `cognition.ai/blog/closing-the-agent-loop-...` (2026-02-10); CodeRabbit Autofix `coderabbit.ai/blog/fix-all-issues-with-ai-agents` (2026-02-19); Greptile skills `github.com/greptileai/skills`; Claude GitHub Actions/Code Review/ultrareview `code.claude.com/docs/en/{github-actions,code-review,ultrareview}`; Nx self-healing `nx.dev/blog/autonomous-ai-workflows-with-nx` (2026-02-03); Snyk Agent Fix 5,3% `safeguard.sh/resources/blog/snyk-agent-fix-autofix-field-test-2026`; resolveReviewThread `nakamasato.medium.com/...` + `github.com/cli/cli/issues/12419`; ICLR 2026 AI review `blog.pebblous.ai/report/iclr-2026-ai-peer-review-crisis`.
|
||||
|
||||
---
|
||||
|
||||
*Relatório gerado a partir de auditoria paralela do código + transcrição do vídeo + pesquisa web 2026. Próximo passo: aprovar o [`PLANO-QUALITY-GATES.md`](./PLANO-QUALITY-GATES.md) e escolher por onde começar (recomendação: Fase 0 → Fase 1).*
|
||||
70
bin/_ops-common.sh
Normal file
70
bin/_ops-common.sh
Normal file
@@ -0,0 +1,70 @@
|
||||
# bin/_ops-common.sh — shared helpers for the OmniRoute ops runbook scripts.
|
||||
#
|
||||
# Sourced (not executed) by rollback.sh / snapshot-data.sh / restore-data.sh /
|
||||
# restore-policies.sh / cold-start-bench.sh. The runbook context lives in
|
||||
# docs/INCIDENT_RESPONSE.md and docs/PERF_BUDGETS.md.
|
||||
#
|
||||
# Path resolution mirrors the app (src/lib/db/core.ts): the SQLite store is
|
||||
# $DATA_DIR/storage.sqlite and managed backups go to $DATA_DIR/db_backups
|
||||
# (overridable via DB_BACKUPS_DIR), so snapshots created here are interchangeable
|
||||
# with the ones the server writes on migrations.
|
||||
|
||||
# Recompute the data-dir-derived paths. Called once on source, and again by
|
||||
# scripts that accept a --data-dir override.
|
||||
ops_set_data_dir() {
|
||||
OMNIROUTE_DATA_DIR="$1"
|
||||
OMNIROUTE_SQLITE="${OMNIROUTE_DATA_DIR}/storage.sqlite"
|
||||
OMNIROUTE_BACKUPS_DIR="${DB_BACKUPS_DIR:-${OMNIROUTE_DATA_DIR}/db_backups}"
|
||||
}
|
||||
ops_set_data_dir "${DATA_DIR:-$HOME/.omniroute}"
|
||||
|
||||
ops_log() { printf '[%s] %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2; }
|
||||
ops_die() {
|
||||
printf '[%s] ERROR: %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ops_require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || ops_die "required command not found: $1"
|
||||
}
|
||||
|
||||
# ops_confirm "<prompt>" — return 0 to proceed. Honors ASSUME_YES=1 (set by the
|
||||
# --yes flag) and REFUSES a destructive action on a non-interactive stdin unless
|
||||
# ASSUME_YES is set, so an unattended/CI invocation can never silently destroy data.
|
||||
ops_confirm() {
|
||||
local prompt="$1" reply
|
||||
if [ "${ASSUME_YES:-0}" = "1" ]; then return 0; fi
|
||||
if [ ! -t 0 ]; then
|
||||
ops_die "refusing a destructive action without a TTY; pass --yes to proceed non-interactively"
|
||||
fi
|
||||
read -r -p "$prompt [y/N] " reply
|
||||
case "$reply" in
|
||||
[yY] | [yY][eE][sS]) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ops_find_snapshot <id> — resolve a snapshot identifier (a snapshot dir name,
|
||||
# a bare timestamp/sha, or an explicit path) to a directory containing
|
||||
# storage.sqlite. Echoes the resolved dir or dies.
|
||||
ops_find_snapshot() {
|
||||
local id="$1" cand
|
||||
[ -n "$id" ] || ops_die "snapshot id required (a timestamp/sha, dir name, or path)"
|
||||
for cand in \
|
||||
"$id" \
|
||||
"$id/" \
|
||||
"$OMNIROUTE_BACKUPS_DIR/$id" \
|
||||
"$OMNIROUTE_BACKUPS_DIR/snapshot_$id"; do
|
||||
if [ -f "${cand%/}/storage.sqlite" ]; then
|
||||
printf '%s\n' "${cand%/}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fall back to a prefix match against snapshot_* dirs (e.g. a short sha/date).
|
||||
if [ -d "$OMNIROUTE_BACKUPS_DIR" ]; then
|
||||
for cand in "$OMNIROUTE_BACKUPS_DIR"/snapshot_*"$id"*; do
|
||||
[ -f "$cand/storage.sqlite" ] && { printf '%s\n' "$cand"; return 0; }
|
||||
done
|
||||
fi
|
||||
ops_die "no snapshot matching '$id' under $OMNIROUTE_BACKUPS_DIR (run bin/snapshot-data.sh first)"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDataDir } from "./data-dir.mjs";
|
||||
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";
|
||||
import { resolveActiveContext } from "./contexts.mjs";
|
||||
|
||||
export const RETRY_DEFAULTS = Object.freeze({
|
||||
maxAttempts: 3,
|
||||
@@ -28,14 +26,12 @@ export function getBaseUrl(opts = {}) {
|
||||
const envUrl = process.env.OMNIROUTE_BASE_URL;
|
||||
if (envUrl) return stripTrailingSlash(envUrl);
|
||||
|
||||
// Resolve from the active context (canonical store + legacy profile fallback).
|
||||
// This is what makes "remote mode" work: `omniroute contexts use <remote>`
|
||||
// routes every command at the remote server's baseUrl.
|
||||
try {
|
||||
const configPath = join(resolveDataDir(), "config.json");
|
||||
if (existsSync(configPath)) {
|
||||
const cfg = JSON.parse(readFileSync(configPath, "utf8"));
|
||||
const profile = cfg.activeProfile && cfg.profiles?.[cfg.activeProfile];
|
||||
if (profile?.baseUrl) return stripTrailingSlash(profile.baseUrl);
|
||||
if (cfg.baseUrl) return stripTrailingSlash(cfg.baseUrl);
|
||||
}
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
if (ctx?.baseUrl) return stripTrailingSlash(ctx.baseUrl);
|
||||
} catch {
|
||||
// Config read failures are not fatal — fall through to default.
|
||||
}
|
||||
@@ -56,15 +52,40 @@ function resolveUrl(path, opts) {
|
||||
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
async function buildHeaders(opts) {
|
||||
export async function buildHeaders(opts) {
|
||||
const headers = new Headers(opts.headers || {});
|
||||
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||
if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
const apiKey = opts.apiKey ?? process.env.OMNIROUTE_API_KEY;
|
||||
if (apiKey && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${apiKey}`);
|
||||
// Auth precedence: explicit key → active-context credential → ambient env key.
|
||||
//
|
||||
// The active context's scoped token MUST win over the ambient OMNIROUTE_API_KEY:
|
||||
// `omniroute connect <remote>` saves the context's token, but users keep
|
||||
// OMNIROUTE_API_KEY in their shell. The global `--api-key` option is bound to
|
||||
// that env var (.env("OMNIROUTE_API_KEY")), so commands that spread
|
||||
// `optsWithGlobals()` into apiFetch carry opts.apiKey === the env value. If that
|
||||
// echoed value outranked the context, every remote management command would send
|
||||
// the local inference key and fail with "Invalid management token" — defeating
|
||||
// remote mode. So an opts.apiKey that merely mirrors the ambient env var is
|
||||
// treated as ambient (a fallback), NOT as an explicit override; only a DISTINCT
|
||||
// key — a real `--api-key <x>` flag or a command-supplied token like
|
||||
// `connect --key` — counts as explicit and wins. Within a context the scoped
|
||||
// accessToken wins over the legacy apiKey.
|
||||
const ambientKey = process.env.OMNIROUTE_API_KEY || null;
|
||||
const explicitKey = opts.apiKey && opts.apiKey !== ambientKey ? opts.apiKey : null;
|
||||
let auth = explicitKey;
|
||||
if (!auth) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
auth = ctx?.accessToken || ctx?.apiKey || null;
|
||||
} catch {
|
||||
// No context credential available — fall through to the ambient fallback.
|
||||
}
|
||||
}
|
||||
if (!auth) auth = opts.apiKey || ambientKey || null;
|
||||
if (auth && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${auth}`);
|
||||
}
|
||||
// Inject machine-id derived CLI token; env var override for testing.
|
||||
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
|
||||
|
||||
180
bin/cli/commands/configure.mjs
Normal file
180
bin/cli/commands/configure.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute configure <cli>` — interactive provider+model picker that writes a
|
||||
* local CLI config pointed at the ACTIVE OmniRoute context (local or remote).
|
||||
*
|
||||
* The model catalog comes from the active context's GET /v1/models, so when you
|
||||
* are in remote mode (`omniroute connect ...`) you pick from the remote server's
|
||||
* live models and the profile is written on THIS machine.
|
||||
*
|
||||
* v1 targets the Codex CLI (writes ~/.codex/<name>.config.toml). The credential
|
||||
* is referenced by env var (OMNIROUTE_API_KEY) — never written to disk.
|
||||
*/
|
||||
|
||||
const SUPPORTED = ["codex"];
|
||||
|
||||
/** Derive a short, filesystem-safe profile name from a model id. */
|
||||
export function profileNameFromModel(modelId) {
|
||||
const afterProvider = String(modelId).includes("/")
|
||||
? String(modelId).split("/").slice(1).join("/")
|
||||
: String(modelId);
|
||||
return afterProvider.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "model";
|
||||
}
|
||||
|
||||
/** Provider id for a catalog entry: explicit owned_by, else the id prefix. */
|
||||
function providerOf(entry) {
|
||||
if (entry && typeof entry.owned_by === "string" && entry.owned_by) return entry.owned_by;
|
||||
const id = typeof entry === "string" ? entry : entry?.id || "";
|
||||
return id.includes("/") ? id.split("/")[0] : "(none)";
|
||||
}
|
||||
|
||||
function contextWindowOf(entry) {
|
||||
for (const c of [entry?.context_length, entry?.max_context_window_tokens]) {
|
||||
if (typeof c === "number" && Number.isFinite(c) && c > 0) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchModels(globalOpts) {
|
||||
const res = await apiFetch("/v1/models", { ...globalOpts, acceptNotOk: true });
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const b = await res.json();
|
||||
msg = b?.error?.message || b?.error || msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(`Could not fetch models: ${msg}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data || body.models || [];
|
||||
return list.filter((m) => (typeof m === "string" ? m : m?.id));
|
||||
}
|
||||
|
||||
function buildCodexProfile(modelId, ctx) {
|
||||
const lines = [
|
||||
`# codex --profile ${profileNameFromModel(modelId)}`,
|
||||
`# ${modelId} — generated by 'omniroute configure codex'`,
|
||||
`model = "${modelId}"`,
|
||||
`model_provider = "omniroute"`,
|
||||
];
|
||||
if (ctx && ctx > 0) {
|
||||
const compact = Math.floor(ctx * 0.85);
|
||||
lines.push(`model_context_window = ${ctx}`);
|
||||
lines.push(`model_auto_compact_token_limit = ${compact}`);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
async function configureCodex(modelId, ctxWindow, opts) {
|
||||
const codexHome = opts.codexHome || path.join(os.homedir(), ".codex");
|
||||
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
|
||||
const profile = opts.name || profileNameFromModel(modelId);
|
||||
const filePath = path.join(codexHome, `${profile}.config.toml`);
|
||||
if (existsSync(filePath)) {
|
||||
copyFileSync(filePath, `${filePath}.bak`);
|
||||
}
|
||||
writeFileSync(filePath, buildCodexProfile(modelId, ctxWindow), "utf8");
|
||||
printSuccess(`Wrote ${filePath}`);
|
||||
printInfo(`Use it: codex --profile ${profile}`);
|
||||
printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block");
|
||||
printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md).");
|
||||
}
|
||||
|
||||
export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
const target = String(cli || "").toLowerCase();
|
||||
if (!SUPPORTED.includes(target)) {
|
||||
printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`);
|
||||
return 2;
|
||||
}
|
||||
const globalOpts = cmd ? cmd.optsWithGlobals() : {};
|
||||
|
||||
let models;
|
||||
try {
|
||||
models = await fetchModels(globalOpts);
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
return 1;
|
||||
}
|
||||
if (!models.length) {
|
||||
printError("The server returned no models.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Resolve model: explicit flags or interactive pick.
|
||||
let chosenId = opts.model;
|
||||
if (chosenId && opts.provider && !chosenId.includes("/")) {
|
||||
chosenId = `${opts.provider}/${chosenId}`;
|
||||
}
|
||||
|
||||
if (!chosenId) {
|
||||
const ids = models.map((m) => (typeof m === "string" ? m : m.id));
|
||||
const providers = [...new Set(models.map(providerOf))].sort();
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
printHeading("Configure Codex CLI");
|
||||
let providerList = providers;
|
||||
if (opts.provider) {
|
||||
providerList = providers.filter((p) => p === opts.provider);
|
||||
} else {
|
||||
printInfo(`Providers: ${providers.join(", ")}`);
|
||||
const p = await prompt.ask("Provider");
|
||||
if (p) providerList = providers.filter((x) => x === p);
|
||||
}
|
||||
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
|
||||
const candidates = inProvider.length ? inProvider : ids;
|
||||
printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`);
|
||||
chosenId = await prompt.ask("Model id");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!chosenId) {
|
||||
printError("No model selected.");
|
||||
return 2;
|
||||
}
|
||||
const entry = byId(models, chosenId);
|
||||
if (!entry) {
|
||||
printError(`Model '${chosenId}' is not in the catalog.`);
|
||||
return 2;
|
||||
}
|
||||
const ctxWindow = contextWindowOf(entry);
|
||||
|
||||
if (target === "codex") {
|
||||
await configureCodex(chosenId, ctxWindow, opts);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function byId(models, id) {
|
||||
for (const m of models) {
|
||||
const mid = typeof m === "string" ? m : m.id;
|
||||
if (mid === id) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerConfigure(program) {
|
||||
program
|
||||
.command("configure <cli>")
|
||||
.description(
|
||||
t("configure.description") ||
|
||||
"Pick a provider+model from the active server and write a local CLI config (v1: codex)"
|
||||
)
|
||||
.option("--provider <id>", "Provider id (skips the interactive provider prompt)")
|
||||
.option("--model <id>", "Model id (skips the interactive model prompt)")
|
||||
.option("--name <name>", "Profile name to write (default: derived from model)")
|
||||
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
|
||||
.action(async (cli, opts, cmd) => {
|
||||
const code = await runConfigureCommand(cli, opts, cmd);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
132
bin/cli/commands/connect.mjs
Normal file
132
bin/cli/commands/connect.mjs
Normal file
@@ -0,0 +1,132 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { loadContexts, saveContexts } from "../contexts.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute connect <host>` — remote mode.
|
||||
*
|
||||
* Logs into a remote OmniRoute server and saves the result as the active context
|
||||
* so every subsequent command targets that server. Two flows:
|
||||
* - password: prompts for the management password → POST /api/cli/connect →
|
||||
* server mints a scoped access token (default scope: admin).
|
||||
* - token: `--key <oma_...>` validates via GET /api/cli/whoami and saves it.
|
||||
*/
|
||||
|
||||
/** Normalize a host/URL into a server root baseUrl (no trailing path). */
|
||||
export function normalizeBaseUrl(host, port) {
|
||||
let value = String(host || "").trim();
|
||||
if (!value) return "";
|
||||
const hadScheme = /^https?:\/\//i.test(value);
|
||||
if (!hadScheme) value = `http://${value}`;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
// Only apply the default port to a bare host; a full URL is taken as-is.
|
||||
if (!hadScheme && !u.port && port) u.port = String(port);
|
||||
return u.origin;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive a clean context name from a host (strip scheme/port). */
|
||||
export function hostLabel(host) {
|
||||
let value = String(host || "").trim().replace(/^https?:\/\//i, "");
|
||||
value = value.split("/")[0].split(":")[0];
|
||||
return value || "remote";
|
||||
}
|
||||
|
||||
async function readErrorMessage(res) {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.error?.message || body?.error || `HTTP ${res.status}`;
|
||||
} catch {
|
||||
return `HTTP ${res.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runConnectCommand(host, opts = {}) {
|
||||
const baseUrl = normalizeBaseUrl(host, opts.port || "20128");
|
||||
if (!baseUrl) {
|
||||
printError("A host is required, e.g. omniroute connect 192.168.0.15");
|
||||
return 2;
|
||||
}
|
||||
const name = opts.name || hostLabel(host);
|
||||
|
||||
let accessToken;
|
||||
let scope;
|
||||
|
||||
if (opts.key) {
|
||||
// Validate the pasted token against the remote.
|
||||
const res = await apiFetch("/api/cli/whoami", {
|
||||
baseUrl,
|
||||
apiKey: opts.key,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Token rejected by ${baseUrl}: ${await readErrorMessage(res)}`);
|
||||
return res.exitCode || 1;
|
||||
}
|
||||
const body = await res.json();
|
||||
accessToken = opts.key;
|
||||
scope = body.scope || "unknown";
|
||||
} else {
|
||||
const prompt = createPrompt();
|
||||
let password;
|
||||
try {
|
||||
password = await prompt.askSecret(`Management password for ${baseUrl}`);
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
if (!password) {
|
||||
printError("Password is required (or use --key <token>).");
|
||||
return 2;
|
||||
}
|
||||
const res = await apiFetch("/api/cli/connect", {
|
||||
baseUrl,
|
||||
method: "POST",
|
||||
body: { password, name, scope: opts.scope },
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Connect failed (${res.status}): ${await readErrorMessage(res)}`);
|
||||
return res.exitCode || 1;
|
||||
}
|
||||
const body = await res.json();
|
||||
accessToken = body.token;
|
||||
scope = body.scope;
|
||||
}
|
||||
|
||||
const cfg = loadContexts();
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl,
|
||||
accessToken,
|
||||
scope,
|
||||
description: `Remote OmniRoute (${host})`,
|
||||
};
|
||||
cfg.currentContext = name;
|
||||
saveContexts(cfg);
|
||||
|
||||
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
|
||||
printInfo("All commands now target this server.");
|
||||
printInfo("Switch back to local with: omniroute contexts use default");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerConnect(program) {
|
||||
program
|
||||
.command("connect <host>")
|
||||
.description(
|
||||
t("connect.description") || "Connect to a remote OmniRoute server and enter remote mode"
|
||||
)
|
||||
.option("--port <port>", "Server port when the host has none", "20128")
|
||||
.option("--key <token>", "Use a pre-generated scoped access token (skips the password prompt)")
|
||||
.option("--name <name>", "Context name to save (default: derived from host)")
|
||||
.option("--scope <scope>", "Requested scope for the password flow (read|write|admin)")
|
||||
.action(async (host, opts) => {
|
||||
const code = await runConnectCommand(host, opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
import { t } from "../i18n.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { loadContexts, saveContexts, configPath } from "../contexts.mjs";
|
||||
import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
async function confirm(msg) {
|
||||
/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */
|
||||
function authLabel(c) {
|
||||
if (c?.accessToken) return "token";
|
||||
if (c?.apiKey) return "key";
|
||||
return "✗";
|
||||
}
|
||||
|
||||
export async function confirm(msg) {
|
||||
// Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking
|
||||
// anyway leaves the readline question pending forever — Node then warns about an
|
||||
// "unsettled top-level await" at exit. Decline cleanly instead and point at the
|
||||
// non-interactive escape hatch so scripted callers fail safe rather than hang.
|
||||
if (!process.stdin.isTTY) {
|
||||
process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`);
|
||||
return false;
|
||||
}
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((r) => rl.question(`${msg} [y/N] `, r));
|
||||
@@ -19,6 +34,7 @@ function maskKey(k) {
|
||||
export function registerContexts(program) {
|
||||
const ctx = program
|
||||
.command("contexts")
|
||||
.alias("context") // singular alias — docs/connect output historically said `context current`
|
||||
.description(t("config.contexts.description") || "Manage server contexts/profiles");
|
||||
|
||||
ctx
|
||||
@@ -31,7 +47,8 @@ export function registerContexts(program) {
|
||||
active: name === (cfg.currentContext || "default") ? "●" : "",
|
||||
name,
|
||||
baseUrl: c.baseUrl || "",
|
||||
auth: c.apiKey ? "✓" : "✗",
|
||||
auth: authLabel(c),
|
||||
scope: c.scope || "",
|
||||
description: c.description || "",
|
||||
}));
|
||||
emit(rows, globalOpts, [
|
||||
@@ -39,6 +56,7 @@ export function registerContexts(program) {
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "baseUrl", header: "Base URL" },
|
||||
{ key: "auth", header: "Auth" },
|
||||
{ key: "scope", header: "Scope" },
|
||||
{ key: "description", header: "Description" },
|
||||
]);
|
||||
});
|
||||
@@ -47,8 +65,11 @@ export function registerContexts(program) {
|
||||
.command("add <name>")
|
||||
.description("Add a new context")
|
||||
.requiredOption("--url <u>", "Base URL")
|
||||
.option("--api-key <k>", "API key")
|
||||
.option("--api-key <k>", "Legacy inference API key")
|
||||
.option("--api-key-stdin", "Read API key from stdin")
|
||||
.option("--access-token <t>", "Scoped CLI access token (preferred over --api-key)")
|
||||
.option("--access-token-stdin", "Read access token from stdin")
|
||||
.option("--scope <s>", "Token scope hint for display (read|write|admin)")
|
||||
.option("--description <d>", "Context description")
|
||||
.action(async (name, opts) => {
|
||||
const cfg = loadContexts();
|
||||
@@ -57,15 +78,20 @@ export function registerContexts(program) {
|
||||
process.exit(2);
|
||||
}
|
||||
let apiKey = opts.apiKey || null;
|
||||
if (opts.apiKeyStdin) {
|
||||
let accessToken = opts.accessToken || null;
|
||||
if (opts.apiKeyStdin || opts.accessTokenStdin) {
|
||||
const chunks = [];
|
||||
for await (const c of process.stdin) chunks.push(c);
|
||||
apiKey = chunks.join("").trim() || null;
|
||||
const value = chunks.join("").trim() || null;
|
||||
if (opts.accessTokenStdin) accessToken = value;
|
||||
else apiKey = value;
|
||||
}
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl: opts.url,
|
||||
accessToken: accessToken || undefined,
|
||||
apiKey,
|
||||
scope: opts.scope || undefined,
|
||||
description: opts.description || undefined,
|
||||
};
|
||||
saveContexts(cfg);
|
||||
@@ -88,10 +114,27 @@ export function registerContexts(program) {
|
||||
|
||||
ctx
|
||||
.command("current")
|
||||
.description("Show current active context name")
|
||||
.action(() => {
|
||||
.description("Show the active context (server, auth, scope)")
|
||||
.option("--name-only", "Print just the context name (legacy behavior)")
|
||||
.action((opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const cfg = loadContexts();
|
||||
process.stdout.write(`${cfg.currentContext || "default"}\n`);
|
||||
const name = cfg.currentContext || cfg.activeProfile || "default";
|
||||
if (opts.nameOnly) {
|
||||
process.stdout.write(`${name}\n`);
|
||||
return;
|
||||
}
|
||||
const c = resolveActiveContext(name);
|
||||
emit(
|
||||
{
|
||||
name,
|
||||
baseUrl: c.baseUrl || "",
|
||||
auth: authLabel(c),
|
||||
scope: c.scope || "",
|
||||
description: c.description || "",
|
||||
},
|
||||
globalOpts
|
||||
);
|
||||
});
|
||||
|
||||
ctx
|
||||
@@ -108,7 +151,9 @@ export function registerContexts(program) {
|
||||
const display = {
|
||||
name,
|
||||
baseUrl: c.baseUrl,
|
||||
accessToken: maskKey(c.accessToken),
|
||||
apiKey: maskKey(c.apiKey),
|
||||
scope: c.scope,
|
||||
description: c.description,
|
||||
};
|
||||
emit(display, globalOpts);
|
||||
@@ -172,6 +217,7 @@ export function registerContexts(program) {
|
||||
if (opts.noSecrets) {
|
||||
for (const c of Object.values(out.contexts || {})) {
|
||||
c.apiKey = null;
|
||||
delete c.accessToken;
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(out, null, 2);
|
||||
@@ -209,7 +255,9 @@ export function registerContexts(program) {
|
||||
const c = raw && typeof raw === "object" ? /** @type {Record<string,unknown>} */ (raw) : {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl: typeof c.baseUrl === "string" ? c.baseUrl : "http://localhost:20128",
|
||||
accessToken: typeof c.accessToken === "string" ? c.accessToken : undefined,
|
||||
apiKey: typeof c.apiKey === "string" ? c.apiKey : null,
|
||||
scope: typeof c.scope === "string" ? c.scope : undefined,
|
||||
description: typeof c.description === "string" ? c.description : undefined,
|
||||
};
|
||||
count++;
|
||||
|
||||
180
bin/cli/commands/launch-codex.mjs
Normal file
180
bin/cli/commands/launch-codex.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
/** OpenAI/Codex env keys stripped from the child so a stale OpenAI key/base-url
|
||||
* in the shell can't shadow the omniroute provider (defense-in-depth). Mirrors
|
||||
* free-claude-code's codex adapter. NOTE: this does NOT silence codex's
|
||||
* `refresh_token` log noise — that comes from a stored OpenAI session in
|
||||
* ~/.codex/auth.json, not the env; it is cosmetic and does not block requests. */
|
||||
const STRIPPED_CODEX_ENV_KEYS = [
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_ORGANIZATION",
|
||||
"CODEX_API_KEY",
|
||||
];
|
||||
|
||||
/** Placeholder so codex's `env_key` is always satisfied when the backend is open. */
|
||||
const NO_AUTH_SENTINEL = "omniroute-no-auth";
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
let s = String(value);
|
||||
let end = s.length;
|
||||
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
|
||||
return end === s.length ? s : s.slice(0, end);
|
||||
}
|
||||
|
||||
/** TOML assignment for a `-c key=value` codex flag (strings get quoted). */
|
||||
function tomlAssign(key, value) {
|
||||
if (typeof value === "boolean" || typeof value === "number") return `${key}=${value}`;
|
||||
return `${key}=${JSON.stringify(String(value))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OmniRoute root base URL + auth for codex, honouring (in order):
|
||||
* explicit flags → active context (remote mode) → localhost:<port>.
|
||||
* @returns {{ baseUrl:string, authToken:string|undefined }}
|
||||
*/
|
||||
export function resolveCodexTarget(opts = {}) {
|
||||
const explicit = opts.remote ?? opts.baseUrl;
|
||||
let baseUrl;
|
||||
if (explicit) {
|
||||
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
|
||||
} else {
|
||||
let fromCtx;
|
||||
try {
|
||||
fromCtx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
baseUrl = fromCtx
|
||||
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
|
||||
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let authToken = opts.apiKey ?? opts["api-key"];
|
||||
if (!authToken) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!authToken) authToken = process.env.OMNIROUTE_API_KEY;
|
||||
return { baseUrl, authToken };
|
||||
}
|
||||
|
||||
/** Health-check an OmniRoute root URL before launching Codex. */
|
||||
async function healthCheck(baseUrl, timeoutMs = 3000) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the env for the Codex child: strip stale OpenAI/Codex creds, then set
|
||||
* OMNIROUTE_API_KEY (the provider env_key) to the resolved token or a sentinel.
|
||||
* @param {Record<string,string>} baseEnv
|
||||
* @param {string|undefined} authToken
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
export function buildCodexEnv(baseEnv, authToken) {
|
||||
const env = { ...baseEnv };
|
||||
for (const key of STRIPPED_CODEX_ENV_KEYS) delete env[key];
|
||||
env.OMNIROUTE_API_KEY = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex `-c` flags that define the `omniroute` provider inline, so launch works
|
||||
* WITHOUT a pre-existing ~/.codex/config.toml. Mirrors free-claude-code.
|
||||
* @param {string} baseUrl OmniRoute root URL (no /v1)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function buildCodexProviderArgs(baseUrl) {
|
||||
return [
|
||||
"-c",
|
||||
tomlAssign("model_provider", "omniroute"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.name", "OmniRoute"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.base_url", `${baseUrl}/v1`),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.env_key", "OMNIROUTE_API_KEY"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.wire_api", "responses"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.requires_openai_auth", false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{port?:string, remote?:string, profile?:string, apiKey?:string}} opts
|
||||
* @param {string[]} codexArgs pass-through args for the codex binary
|
||||
* @returns {Promise<number>} exit code
|
||||
*/
|
||||
export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
const { baseUrl, authToken } = resolveCodexTarget(opts);
|
||||
|
||||
if (!(await healthCheck(baseUrl))) {
|
||||
console.error(
|
||||
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
|
||||
"{port}",
|
||||
baseUrl
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Provider injected via -c (works without config.toml); then the profile (model),
|
||||
// then the user's pass-through args.
|
||||
const providerArgs = buildCodexProviderArgs(baseUrl);
|
||||
const profileArgs = opts.profile ? ["--profile", opts.profile] : [];
|
||||
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
|
||||
const env = buildCodexEnv(process.env, authToken);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn("codex", extraArgs, { env, stdio: "inherit" });
|
||||
child.on("error", (err) => {
|
||||
if (err?.code === "ENOENT") {
|
||||
console.error(
|
||||
"The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex"
|
||||
);
|
||||
resolve(127);
|
||||
} else {
|
||||
console.error(String(err?.message || err));
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLaunchCodex(program) {
|
||||
program
|
||||
.command("launch-codex")
|
||||
.description(
|
||||
t("launchCodex.description") || "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)")
|
||||
.option("--profile <name>", "Codex profile to activate (passed as --profile <name>)")
|
||||
.option("-p, --p <name>", "Alias for --profile")
|
||||
.option("--api-key <key>", "OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[codexArgs...]", "arguments passed through to the codex binary")
|
||||
.action(async (codexArgs, opts) => {
|
||||
const merged = { ...opts, profile: opts.profile ?? opts.p };
|
||||
const exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
155
bin/cli/commands/launch.mjs
Normal file
155
bin/cli/commands/launch.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
let s = String(value);
|
||||
let end = s.length;
|
||||
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
|
||||
return end === s.length ? s : s.slice(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clean child env for Claude Code pointed at OmniRoute.
|
||||
*
|
||||
* Strips inherited ANTHROPIC_* (avoids a stale shell token leaking through), then
|
||||
* injects the base URL, gateway model discovery, and auto-compact window.
|
||||
*
|
||||
* @param {Record<string,string>} baseEnv
|
||||
* @param {number|string} baseUrlOrPort a port (→ http://localhost:<port>) or a full base URL
|
||||
* @param {string|undefined} authToken
|
||||
* @param {{ configDir?:string, model?:string }} [opts]
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
export function buildClaudeEnv(baseEnv, baseUrlOrPort, authToken, opts = {}) {
|
||||
const env = { ...baseEnv };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("ANTHROPIC_")) delete env[key];
|
||||
}
|
||||
|
||||
// Accept a bare port (number/numeric string → localhost) or a full base URL.
|
||||
// Claude Code wants the ROOT URL (it appends /v1/messages itself) — no /v1 here.
|
||||
let baseUrl;
|
||||
if (typeof baseUrlOrPort === "number" || /^\d+$/.test(String(baseUrlOrPort))) {
|
||||
baseUrl = `http://localhost:${Number(baseUrlOrPort) || 20128}`;
|
||||
} else {
|
||||
baseUrl = stripTrailingSlash(String(baseUrlOrPort)).replace(/\/v1$/, "");
|
||||
}
|
||||
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
// Always set a token: when none is resolved, a sentinel keeps newer Claude Code
|
||||
// from stopping at its local login gate before it ever contacts OmniRoute (an
|
||||
// open backend ignores the value). Mirrors free-claude-code. ANTHROPIC_API_KEY
|
||||
// stays stripped (above) so it can't shadow the Bearer token.
|
||||
env.ANTHROPIC_AUTH_TOKEN = (authToken && String(authToken).trim()) || "omniroute-no-auth";
|
||||
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
|
||||
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000";
|
||||
// Profile isolation (Claude Code has no native profiles — CLAUDE_CONFIG_DIR is
|
||||
// the idiomatic mechanism: separate settings/credentials/history/cache per dir).
|
||||
if (opts.configDir) env.CLAUDE_CONFIG_DIR = opts.configDir;
|
||||
if (opts.model) env.ANTHROPIC_MODEL = opts.model;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OmniRoute base URL + auth for launch, honouring (in order):
|
||||
* explicit flags → the active context (remote mode) → localhost:<port>.
|
||||
* @param {{port?:string, remote?:string, baseUrl?:string, token?:string, apiKey?:string, context?:string}} opts
|
||||
* @returns {{ baseUrl:string, authToken:string|undefined }}
|
||||
*/
|
||||
export function resolveLaunchTarget(opts = {}) {
|
||||
const explicit = opts.remote ?? opts.baseUrl;
|
||||
let baseUrl;
|
||||
if (explicit) {
|
||||
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
|
||||
} else {
|
||||
let fromCtx;
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
fromCtx = ctx?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
baseUrl = fromCtx
|
||||
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
|
||||
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let authToken = opts.token ?? opts.apiKey ?? opts["api-key"];
|
||||
if (!authToken) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!authToken) authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.OMNIROUTE_API_KEY;
|
||||
return { baseUrl, authToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts
|
||||
* @param {string[]} claudeArgs pass-through args for the claude binary
|
||||
* @returns {Promise<number>} exit code
|
||||
*/
|
||||
export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
||||
const { baseUrl, authToken } = resolveLaunchTarget(opts);
|
||||
|
||||
// Health check the (possibly remote) proxy before launching.
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
} catch {
|
||||
console.error(
|
||||
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
|
||||
"{port}",
|
||||
baseUrl
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const configDir = opts.profile
|
||||
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
|
||||
: undefined;
|
||||
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
|
||||
child.on("error", (err) => {
|
||||
if (err && err.code === "ENOENT") {
|
||||
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
|
||||
resolve(127);
|
||||
} else {
|
||||
console.error(String(err?.message || err));
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLaunch(program) {
|
||||
program
|
||||
.command("launch")
|
||||
.description(
|
||||
t("launch.description") || "Launch Claude Code pointed at OmniRoute (local or remote)"
|
||||
)
|
||||
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
|
||||
.option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
|
||||
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
|
||||
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[claudeArgs...]", "arguments passed through to the claude binary")
|
||||
.action(async (claudeArgs, opts) => {
|
||||
const exitCode = await runLaunchCommand(opts, claudeArgs ?? []);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
192
bin/cli/commands/login.mjs
Normal file
192
bin/cli/commands/login.mjs
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/**
|
||||
* `omniroute login antigravity` — local OAuth helper for remote installs.
|
||||
*
|
||||
* Why this exists: Google's `firstparty/nativeapp` consent for the embedded
|
||||
* Antigravity desktop client only releases the authorization code when the
|
||||
* loopback redirect (127.0.0.1:<port>) is REACHABLE. On a remote VPS install the
|
||||
* loopback is unreachable, so the consent hangs forever and never emits a code —
|
||||
* the dashboard's "paste the callback URL" fallback has nothing to paste. (The
|
||||
* same flow works locally and over an SSH tunnel, where the loopback IS reachable.)
|
||||
*
|
||||
* This command runs the OAuth on the user's OWN machine — where 127.0.0.1 works —
|
||||
* captures the code on a local loopback server, exchanges it for tokens, and
|
||||
* prints a single-line credential blob. The user pastes that blob into the remote
|
||||
* dashboard (Antigravity → "Paste credentials"), which decodes it, finalizes the
|
||||
* onboarding server-side, and persists the connection.
|
||||
*
|
||||
* It talks ONLY to Google (no OmniRoute server needed locally), so it works even
|
||||
* if the remote VPS is firewalled from the user's machine.
|
||||
*/
|
||||
|
||||
const PROVIDER = "antigravity";
|
||||
|
||||
/** Open the system browser; no-op if the optional `open` dependency is missing. */
|
||||
async function defaultOpenBrowser(url) {
|
||||
try {
|
||||
const { default: open } = await import("open");
|
||||
await open(url);
|
||||
} catch {
|
||||
// `open` not available — the caller already printed the URL to paste manually.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a loopback HTTP server bound to 127.0.0.1 (NOT 0.0.0.0 — we never want to
|
||||
* expose the callback to the LAN). Resolves to { port, waitForCallback, close }.
|
||||
*/
|
||||
function defaultStartServer(preferredPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolveCallback;
|
||||
const callbackPromise = new Promise((r) => {
|
||||
resolveCallback = r;
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const params = Object.fromEntries(url.searchParams.entries());
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(
|
||||
"<!doctype html><meta charset=utf-8><title>OmniRoute</title>" +
|
||||
"<body style=\"font-family:system-ui;padding:2rem\">" +
|
||||
"<h2>✅ Authorization received</h2>" +
|
||||
"<p>Return to your terminal — you can close this tab.</p></body>"
|
||||
);
|
||||
resolveCallback(params);
|
||||
});
|
||||
|
||||
server.on("error", reject);
|
||||
server.listen(preferredPort || 0, "127.0.0.1", () => {
|
||||
const { port } = server.address();
|
||||
resolve({
|
||||
port,
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Lazy-load the antigravity provider + blob codec (TS source via tsx). */
|
||||
async function loadDeps() {
|
||||
const { antigravity } = await import("../../../src/lib/oauth/providers/antigravity.ts");
|
||||
const { encodeCredentialBlob } = await import("../../../src/lib/oauth/credentialBlob.ts");
|
||||
return { antigravity, encodeCredentialBlob };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Google authorization request for a given loopback port. Uses a plain
|
||||
* authorization_code grant (NO PKCE code_challenge) — matching the working flow:
|
||||
* a code_challenge here would force the exchange to require a code_verifier.
|
||||
*/
|
||||
export async function buildAntigravityAuthRequest(port, makeState = randomUUID) {
|
||||
const { antigravity } = await loadDeps();
|
||||
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
||||
const state = makeState();
|
||||
const authUrl = antigravity.buildAuthUrl(antigravity.config, redirectUri, state);
|
||||
return { redirectUri, state, authUrl };
|
||||
}
|
||||
|
||||
/** Exchange the captured code for raw Google tokens (no code_verifier — no PKCE). */
|
||||
export async function exchangeAntigravityCode(code, redirectUri) {
|
||||
const { antigravity } = await loadDeps();
|
||||
return antigravity.exchangeToken(antigravity.config, code, redirectUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrate the local login. Dependencies are injectable for testing; the real
|
||||
* path uses a 127.0.0.1 loopback server, the system browser, and a live token
|
||||
* exchange against Google. Returns the credential blob string.
|
||||
*/
|
||||
export async function runAntigravityLogin(opts = {}, deps = {}) {
|
||||
const startServer = deps.startServer ?? defaultStartServer;
|
||||
const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
|
||||
const exchange = deps.exchange ?? exchangeAntigravityCode;
|
||||
const makeState = deps.makeState ?? randomUUID;
|
||||
const print = deps.print ?? ((s) => process.stdout.write(s));
|
||||
const log = deps.log ?? ((s) => process.stderr.write(s));
|
||||
const { encodeCredentialBlob } = await loadDeps();
|
||||
|
||||
const server = await startServer(opts.port);
|
||||
const { redirectUri, state, authUrl } = await buildAntigravityAuthRequest(server.port, makeState);
|
||||
|
||||
log(`\nOpen this URL to authorize Antigravity (it will open automatically):\n\n ${authUrl}\n\n`);
|
||||
if (opts.browser !== false) await openBrowser(authUrl);
|
||||
log("Waiting for Google to redirect back to the local loopback...\n");
|
||||
|
||||
const timeoutMs = opts.timeout ?? 300000;
|
||||
let timer;
|
||||
let params;
|
||||
try {
|
||||
params = await Promise.race([
|
||||
server.waitForCallback(),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Timed out waiting for the OAuth callback")),
|
||||
timeoutMs
|
||||
);
|
||||
// Don't keep the event loop alive solely for this timer.
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
await server.close();
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
throw new Error(`Authorization failed: ${params.error_description || params.error}`);
|
||||
}
|
||||
if (params.state !== state) {
|
||||
throw new Error("State mismatch — aborting (possible CSRF). Please retry the login.");
|
||||
}
|
||||
if (!params.code) {
|
||||
throw new Error("No authorization code returned by Google.");
|
||||
}
|
||||
|
||||
const tokens = await exchange(params.code, redirectUri);
|
||||
const blob = encodeCredentialBlob({ provider: PROVIDER, tokens });
|
||||
|
||||
print(
|
||||
"\n" +
|
||||
"Antigravity authorized. Copy the line below and paste it into your remote\n" +
|
||||
"OmniRoute dashboard: Providers → Antigravity → Connect → \"Paste credentials\".\n" +
|
||||
"(This contains a refresh token — treat it like a password.)\n\n" +
|
||||
blob +
|
||||
"\n\n"
|
||||
);
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function runLoginAntigravity(opts) {
|
||||
try {
|
||||
await runAntigravityLogin({
|
||||
browser: opts.browser,
|
||||
timeout: opts.timeout,
|
||||
port: opts.port,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`\nLogin failed: ${err?.message || err}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerLogin(program) {
|
||||
const login = program
|
||||
.command("login")
|
||||
.description("Local OAuth helpers for remote OmniRoute installs (run on your own machine)");
|
||||
|
||||
login
|
||||
.command("antigravity")
|
||||
.description("Authorize Antigravity locally and print a credential blob to paste remotely")
|
||||
.option("--no-browser", "Do not auto-open the browser; print the URL instead")
|
||||
.option("--port <n>", "Fixed loopback port (default: OS-assigned)", (v) => parseInt(v, 10))
|
||||
.option("--timeout <ms>", "How long to wait for the callback", (v) => parseInt(v, 10), 300000)
|
||||
.action(runLoginAntigravity);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user