diff --git a/.env.example b/.env.example index 3335784a6c..c2b069c2da 100644 --- a/.env.example +++ b/.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 @@ -551,6 +553,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 @@ -598,6 +610,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 @@ -792,7 +809,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) — @@ -801,7 +818,7 @@ 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" @@ -823,7 +840,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) @@ -950,6 +973,15 @@ 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. @@ -1200,6 +1232,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 @@ -1637,6 +1697,7 @@ 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0da08470d4..5181ebc246 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -846,6 +846,7 @@ jobs: path: | coverage/coverage-summary.json coverage/coverage-report.md + coverage/lcov.info if-no-files-found: warn sonarqube: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b6cfcb2e8e..1c3fdc5e36 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -40,6 +40,8 @@ jobs: - 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 @@ -49,6 +51,7 @@ jobs: - 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 - name: Typecheck (core) run: npm run typecheck:core @@ -82,3 +85,48 @@ jobs: 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" diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 6aae7a8238..058b1cb8c7 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -4406,8 +4406,24 @@ export function createOmniRouteConfigHook( authJson = undefined; } - const entry = authJson?.[resolved.providerId] as AuthJsonApiEntry | undefined; - const apiKey = entry && entry.type === "api" && typeof entry.key === "string" ? entry.key : ""; + // Try both prefixed (e.g. opencode-omniroute) and unprefixed (e.g. omniroute) + // keys so a user who ran `/connect omniroute` before the auto-prefix fix + // does not need to re-auth. Also handles dual-key for auth.json entries + // written by a newer OC dispatcher with the prefixed key. + const bareKey = resolved.providerId.startsWith("opencode-") + ? resolved.providerId.slice("opencode-".length) + : resolved.providerId; + const lookupKeys = [resolved.providerId]; + if (bareKey !== resolved.providerId) lookupKeys.push(bareKey); + let entry; + for (const k of lookupKeys) { + const e = authJson?.[k]; + if (e?.type === "api" && typeof e.key === "string" && e.key.length > 0) { + entry = e; + break; + } + } + const apiKey = entry?.type === "api" && typeof entry.key === "string" ? entry.key : ""; if (!apiKey) { // (c) no apiKey — silent no-op (with debug breadcrumb). The operator diff --git a/@omniroute/opencode-plugin/tests/config-shim.test.ts b/@omniroute/opencode-plugin/tests/config-shim.test.ts index 16cb3253cf..63d7c277ac 100644 --- a/@omniroute/opencode-plugin/tests/config-shim.test.ts +++ b/@omniroute/opencode-plugin/tests/config-shim.test.ts @@ -256,6 +256,65 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider 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 }).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 }).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 // ──────────────────────────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4175f354..2a67ba8279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,181 @@ --- +## [3.8.36] — 2026-06-25 + +### ✨ New Features + +**Quota-Share system** + +- **feat(quota):** introduce a dedicated `quota-share` combo strategy (Fase 3 #9) — Deficit Round Robin scheduling with per-model in-flight gating (P2C), automatic DB migration that promotes existing `qtSd/*` combos, and per-policy gating so invalid allocations cannot bleed `allow` to unintended connections. ([#4939](https://github.com/diegosouzapw/OmniRoute/pull/4939), [#4901](https://github.com/diegosouzapw/OmniRoute/pull/4901)) +- **feat(quota):** multi-window usage buckets, per-(key,model) caps, and session stickiness — connections now track consumption across 5 h, 7 d, and per-model windows; `quota_allocation_model_caps` enforces per-key/model limits; session stickiness preserves prompt-cache integrity across turns. ([#4928](https://github.com/diegosouzapw/OmniRoute/pull/4928), [#4927](https://github.com/diegosouzapw/OmniRoute/pull/4927), [#4929](https://github.com/diegosouzapw/OmniRoute/pull/4929)) +- **feat(quota):** headroom strategy + proactive saturation — new `headroom` combo strategy selects connections by available quota margin; universal proactive saturation via upstream token-usage response headers; real Claude quota saturation sourced from `/api/oauth/usage`. ([#4908](https://github.com/diegosouzapw/OmniRoute/pull/4908), [#4907](https://github.com/diegosouzapw/OmniRoute/pull/4907), [#4885](https://github.com/diegosouzapw/OmniRoute/pull/4885)) +- **feat(quota):** concurrency control + cooldown-wait (Fase 2.1) — `max_concurrent` is enforced at dispatch time; quota-share combos queue concurrent requests with a short cooldown-wait and re-dispatch on slot availability (Variant A); a cron heal proactively restores connections after their window resets. ([#4965](https://github.com/diegosouzapw/OmniRoute/pull/4965), [#4970](https://github.com/diegosouzapw/OmniRoute/pull/4970), [#4967](https://github.com/diegosouzapw/OmniRoute/pull/4967), [#4900](https://github.com/diegosouzapw/OmniRoute/pull/4900)) + +**Combo routing** + +- **feat(combo):** task-aware routing strategy — routes requests to the best-fit connection based on task-type metadata, enabling per-task provider specialization within a combo. ([#4945](https://github.com/diegosouzapw/OmniRoute/pull/4945)) +- **feat(combo):** Fusion strategy (16th strategy) — fan out to a configurable panel of models in parallel, then synthesize results through a judge model. ([#4652](https://github.com/diegosouzapw/OmniRoute/pull/4652)) +- **feat(combos):** add an editable per-combo `description` field. The routing-combo form now has a Description input, persisted in the combo `data` blob via `/api/combos` (POST/PUT) and round-tripped through GET — no new DB column. ([#5005](https://github.com/diegosouzapw/OmniRoute/issues/5005)) +- **feat(routing):** honor `X-Route-Model` request header to override `body.model`, enabling per-request model switching without modifying the request body. ([#4863](https://github.com/diegosouzapw/OmniRoute/pull/4863) — thanks @costaeder) + +**Providers & models** + +- **feat(providers):** update volcengine-ark model list, adding DeepSeek-V4-Flash and DeepSeek-V4-Pro. ([#4905](https://github.com/diegosouzapw/OmniRoute/pull/4905) — thanks @kenlin8827) +- **feat(provider):** add CodeBuddy CN (`copilot.tencent.com`) — full OAuth + executor + model catalog stack. ([#4664](https://github.com/diegosouzapw/OmniRoute/pull/4664)) +- **feat(opencode-go):** advertise `glm-5.2` and `kimi-k2.7-code` to align with official Go endpoints. ([#4711](https://github.com/diegosouzapw/OmniRoute/pull/4711)) +- **feat(sse):** add Google Flow video-generation provider. ([#4769](https://github.com/diegosouzapw/OmniRoute/pull/4769)) +- **feat(api/v1):** include alias-backed models in the `/v1/models` listing. ([#4630](https://github.com/diegosouzapw/OmniRoute/pull/4630)) + +**Proxy pool** + +- **feat(proxy-pool):** Cloudflare Workers proxy deployer + pool integration — deploy Cloudflare Workers relays directly from the dashboard and register them in the proxy pool. ([#4640](https://github.com/diegosouzapw/OmniRoute/pull/4640)) +- **feat(proxy-pool):** Deno Deploy relays + group action buttons — deploy Deno Deploy relay workers and manage proxy groups with new bulk-action controls. ([#4643](https://github.com/diegosouzapw/OmniRoute/pull/4643)) + +**Compression & infrastructure** + +- **feat(compression):** Kiro/CodeWhisperer tool-result compression engine — dedicated compressor for Kiro/CodeWhisperer tool outputs integrated into the streaming pipeline. ([#4635](https://github.com/diegosouzapw/OmniRoute/pull/4635)) +- **feat(endpoint):** per-endpoint custom system prompt injection. A toggle + text field in the Endpoint settings card lets users inject a custom system prompt into every model request, applied via the existing system-prompt engine. Stored in settings DB. ([#5022](https://github.com/diegosouzapw/OmniRoute/pull/5022) — thanks @whale9820) +- **feat(live-ws):** allow non-loopback clients via `LIVE_WS_ALLOWED_HOSTS` env var, enabling multi-host setups to access the live WebSocket API. ([#4877](https://github.com/diegosouzapw/OmniRoute/pull/4877) — thanks @KooshaPari) +- **feat(db):** track API endpoint dimension on `usage_history` for per-endpoint cost and usage analytics. ([#4676](https://github.com/diegosouzapw/OmniRoute/pull/4676)) + +--- + +### 🔧 Bug Fixes + +**Translator** + +- **fix(translator):** regroup parallel tool results to be adjacent to their originating assistant turn, fixing tool-message ordering for providers that require strict interleaving. ([#4882](https://github.com/diegosouzapw/OmniRoute/pull/4882)) +- **fix(translator):** preserve literal empty-string tool arguments in OpenAI-to-Claude streaming — they were previously dropped, causing tool calls to arrive with missing parameters. ([#4959](https://github.com/diegosouzapw/OmniRoute/pull/4959)) +- **fix(translator):** normalize tools to Anthropic-native shape for non-Anthropic providers, ensuring tool definitions pass validation regardless of the format at the call site. ([#4650](https://github.com/diegosouzapw/OmniRoute/pull/4650)) +- **fix(translator):** provider thinking compatibility — correct thinking-block serialization for DeepSeek and Gemini providers. ([#4946](https://github.com/diegosouzapw/OmniRoute/pull/4946)) +- **fix(translator):** emit `` close marker for Anthropic thinking blocks, fixing truncated reasoning output in streamed responses. ([#4633](https://github.com/diegosouzapw/OmniRoute/pull/4633)) +- **fix(translator):** normalize `developer` role to `system` for OpenAI-format providers. ([#4625](https://github.com/diegosouzapw/OmniRoute/pull/4625)) +- **fix(translator):** strip top-level `client_metadata` on the OpenAI passthrough path (port from 9router#1157). ([#4624](https://github.com/diegosouzapw/OmniRoute/pull/4624)) +- **fix(translator):** replay `reasoning_content` on plain Xiaomi MiMo turns (port from 9router#1321). ([#4639](https://github.com/diegosouzapw/OmniRoute/pull/4639)) + +**Copilot / GitHub executor** + +- **fix(copilot):** never route Gemini/Claude model variants to the `/responses` endpoint — these models require the chat-completions path only. ([#4627](https://github.com/diegosouzapw/OmniRoute/pull/4627)) +- **fix(github):** route Copilot Codex models to `/responses` (port from 9router#102). ([#4626](https://github.com/diegosouzapw/OmniRoute/pull/4626)) +- **fix(copilot,antigravity):** cap `maxOutputTokens` at 16384 to stop "Invalid Argument" 400 errors on high-token requests. ([#4636](https://github.com/diegosouzapw/OmniRoute/pull/4636)) +- **fix(codex):** drop non-standard `codex.*` streaming events that break `responses.stream` consumers. ([#4715](https://github.com/diegosouzapw/OmniRoute/pull/4715) — thanks @jeffer1312) + +**Claude / Anthropic** + +- **fix(claude):** omit `adaptive_thinking` and `output_config.effort` for Haiku model variants, which reject those parameters. ([#4661](https://github.com/diegosouzapw/OmniRoute/pull/4661)) +- **fix(claude):** skip `mcp__` tool-name cloaking and guard against missing `connectionId` to prevent crashes on Claude-native MCP tool calls. ([#4861](https://github.com/diegosouzapw/OmniRoute/pull/4861) — thanks @costaeder) +- **fix(claude-oauth):** respect `429` backoff headers on the Claude OAuth usage endpoint to reduce polling spam during quota checks. ([#4655](https://github.com/diegosouzapw/OmniRoute/pull/4655)) + +**Routing & SSE** + +- **fix(sse):** fail over on `400` responses that carry rate-limit text in the body, not just on canonical `429` status codes. ([#4986](https://github.com/diegosouzapw/OmniRoute/pull/4986)) +- **fix(sse):** honor per-account proxy and fingerprint-rotation settings in the opencode executor. ([#4989](https://github.com/diegosouzapw/OmniRoute/pull/4989)) +- **fix(sse):** soft-penalize exhausted providers in auto-combo scoring instead of hard-excluding them, improving fallback resilience. ([#4990](https://github.com/diegosouzapw/OmniRoute/pull/4990)) +- **fix(sse):** drop the CCP pin when the pinned provider is durably unhealthy, with anti-flap logic to prevent oscillation. ([#4864](https://github.com/diegosouzapw/OmniRoute/pull/4864) — thanks @costaeder) +- **fix(combo):** fetch models dynamically from custom provider endpoints instead of relying on a static list. ([#4860](https://github.com/diegosouzapw/OmniRoute/pull/4860)) +- **fix(combo):** propagate the selected connection ID to fallback error responses so model lockout applies to the correct connection rather than the wrong fallback target. ([#4809](https://github.com/diegosouzapw/OmniRoute/pull/4809) — thanks @Chewji9875) +- **fix(sse):** skip third-party tool-name cloaking for Anthropic-native server tools to prevent naming conflicts. ([#4808](https://github.com/diegosouzapw/OmniRoute/pull/4808) — thanks @NomenAK) + +**Quota** + +- **fix(quota):** quota-exclusive `qtSd/*` connections now appear in `/v1/models` listings; EPSILON-threshold check no longer falsely blocks under-budget allocations. ([#4830](https://github.com/diegosouzapw/OmniRoute/pull/4830)) +- **fix(quota):** migration 107 correctly activates the `quota-share` strategy on existing `qtSd/*` combos. ([#4962](https://github.com/diegosouzapw/OmniRoute/pull/4962)) + +**API / responses** + +- **fix(api):** parse the `/v1/responses` request body once instead of 3–4 times on the hot path, reducing per-request overhead. ([#4958](https://github.com/diegosouzapw/OmniRoute/pull/4958)) +- **fix(api):** evict stale in-memory rate-limit windows to stop a slow heap leak on long-running instances. ([#4957](https://github.com/diegosouzapw/OmniRoute/pull/4957)) +- **fix(api):** require authentication on the compression `run-telemetry` endpoint; document `OMNIROUTE_EVAL_CREDENTIALS` env var. ([#4796](https://github.com/diegosouzapw/OmniRoute/pull/4796)) +- **fix(api):** stop `GET /api/system/env/repair` returning HTTP `500` on packaged installs (it broke the onboarding wizard). `createRequire(import.meta.url)` ran at module top-level; once webpack bundles the route into the standalone build, `import.meta.url` is frozen to the build-machine path and `createRequire` throws during evaluation, so the whole route failed to load. `createRequire` is now resolved lazily inside the guarded `better-sqlite3` block, root-dir resolution falls back to `process.cwd()`, and the route passes an explicit `rootDir`. ([#5028](https://github.com/diegosouzapw/OmniRoute/pull/5028)) + +**Dashboard** + +- **fix(dashboard):** show custom provider given-name instead of internal id across dashboard pages — cache, combo health, compression analytics, cost overview, health/autopilot, provider stats, route explainability, provider utilization, runtime. Adds shared `resolveProviderName` resolver and `useProviderNodeMap` hook. (#4603) +- **fix(dashboard):** on OAuth providers (e.g. GLM Coding), "Test all models" with auto-hide-failed now switches the model list to the "visible" filter after the run, so just-hidden failed models actually disappear on-screen — parity with the passthrough-provider path (#3610). Previously they were hidden in the DB but stayed visible under the "All" filter, so it looked like nothing was hidden. (#4887) +- **fix(dashboard):** restore the home-page provider topology card that was hidden by a default state change in #4596. ([#4963](https://github.com/diegosouzapw/OmniRoute/pull/4963)) +- **fix(dashboard):** proxy-pool success gating, sync-timestamp persistence, and opt-in Redis backend. ([#4988](https://github.com/diegosouzapw/OmniRoute/pull/4988)) +- **fix(dashboard):** show custom vision models in the LLM selector dropdown. ([#4653](https://github.com/diegosouzapw/OmniRoute/pull/4653)) + +**Providers** + +- **fix(pollinations):** stop forcing `jsonMode` on every request. Pollinations treats `jsonMode=true` as "the model MUST return JSON" and rejects (HTTP 400 "messages must contain the word 'json'") any normal chat request whose messages don't mention "json", so all non-JSON chat was broken. `jsonMode` is now only enabled when the caller actually requests JSON output (`response_format.type` of `json_object` or `json_schema`). (#3981) +- **fix(antigravity):** default `safetySettings` to all-OFF for parity with the native Gemini paths. The Antigravity (Google Cloud Code) request builder set `safetySettings: undefined`, which `JSON.stringify` drops — so no safety settings reached Google and its server-side defaults false-flagged benign technical prompts as `prohibited_content` (HTTP 200 + blocked body, which combo failover treats as terminal). Now honors a caller-supplied value and otherwise defaults to `DEFAULT_SAFETY_SETTINGS`, matching the claude-to-gemini / openai-to-gemini paths. (#5003) +- **fix(antigravity):** exclude the standard Gemini rate-limit message from quota-exhaustion keyword matching to prevent false-positive saturation flags. ([#4810](https://github.com/diegosouzapw/OmniRoute/pull/4810) — thanks @Chewji9875) +- **fix(chatgpt-web):** map the advertised `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4-pro` and `gpt-5.2-pro` catalog ids to their dash-form ChatGPT backend slugs. They were missing from `MODEL_MAP`, so the executor sent the dot-form id verbatim, which the ChatGPT backend silently ignored and served the default Plus model instead of the requested one. Adds a drift guard asserting no advertised dot-form id reaches the backend verbatim. (#4665) +- **fix(gemini):** preserve the `pattern` field in the Antigravity tool schema sanitizer to avoid stripping valid regex constraints from tool definitions. ([#4651](https://github.com/diegosouzapw/OmniRoute/pull/4651)) +- **fix(opencode):** preserve DeepSeek reasoning content in streamed responses. ([#4631](https://github.com/diegosouzapw/OmniRoute/pull/4631)) +- **fix(perplexity):** validate API keys via the `/v1/models` endpoint instead of issuing a full chat request. ([#4654](https://github.com/diegosouzapw/OmniRoute/pull/4654)) +- **fix(qoder):** exchange PAT for `jt-*` job token before initiating Cosy chat, fixing auth failures after the Qoder credential format change. ([#4884](https://github.com/diegosouzapw/OmniRoute/pull/4884)) +- **fix(executors):** strip parameters unsupported by the target provider/model to prevent `400 Invalid parameter` errors on strict endpoints. ([#4658](https://github.com/diegosouzapw/OmniRoute/pull/4658)) +- **fix(executors):** preserve literal `reasoning_effort: "max"` for Ollama Cloud instead of normalizing to `xhigh`. Ollama Cloud accepts `high|medium|low|max|none` and rejects `xhigh` (`invalid reasoning value: 'xhigh'`); OpenRouter DeepSeek `max→xhigh` normalization is unaffected. ([#4993](https://github.com/diegosouzapw/OmniRoute/pull/4993) — thanks @Thinkscape) +- **fix(headroom):** translate openai-responses input through OpenAI for external compression. `adaptBodyForCompression` now serialises `function_call_output` items whose `output` field is a JSON object (not a string) so compression engines can process the content — previously those items were excluded from compression because `hasTextContent()` returned false for object values. ([#5023](https://github.com/diegosouzapw/OmniRoute/pull/5023) — thanks @anki1kr) +- **fix(proxy):** fan out direct dispatcher streams to all registered proxy endpoints. ([#4803](https://github.com/diegosouzapw/OmniRoute/pull/4803) — thanks @makcimbx) + +**Compression** + +- **fix(compression):** eliminate ReDoS in the `math_inline` preservation regex — the previous pattern could catastrophically backtrack on untrusted input. ([#4838](https://github.com/diegosouzapw/OmniRoute/pull/4838)) +- **fix(compression):** stop RTK over-truncating file-read tool results — RTK now respects the full content length for file-read outputs. ([#4987](https://github.com/diegosouzapw/OmniRoute/pull/4987)) + +**Build / CLI / infrastructure** + +- **fix(build):** drop `@omniroute/open-sse` from `optimizePackageImports` to fix the Next.js build OOM crash. ([#4968](https://github.com/diegosouzapw/OmniRoute/pull/4968)) +- **fix(cli):** SIGKILL the systray child PID before closing the IPC channel to prevent macOS NSStatusItem orphan processes. ([#4732](https://github.com/diegosouzapw/OmniRoute/pull/4732)) +- **fix(cli):** bump `better-sqlite3` runtime pin to 12.10.1 for Node 26 compatibility. ([#4685](https://github.com/diegosouzapw/OmniRoute/pull/4685)) +- **fix(cli):** harden the systray2 tray runtime (port from 9router#1080). ([#4628](https://github.com/diegosouzapw/OmniRoute/pull/4628)) +- **fix(cli-tools):** tolerate JSONC (comments and trailing commas) in tool settings files. ([#4659](https://github.com/diegosouzapw/OmniRoute/pull/4659)) +- **fix(install):** make the `transformers` dependency optional so CUDA-host installs that lack Python bindings succeed. ([#4807](https://github.com/diegosouzapw/OmniRoute/pull/4807) — thanks @megamen32) +- **fix(db):** correct storage tuning settings to prevent WAL runaway on high-write workloads. ([#4834](https://github.com/diegosouzapw/OmniRoute/pull/4834) — thanks @rdself) +- **fix(image):** prevent compatible nodes from shadowing provider aliases in the image routing table. ([#4656](https://github.com/diegosouzapw/OmniRoute/pull/4656)) + +**Plugin** + +- **fix(plugin):** opencode `auth.json` dual-key fallback for the auto-prefix migration. The config hook now looks up both the prefixed (`opencode-omniroute`) and bare (`omniroute`) keys, so users who authenticated before the `opencode-` prefix landed no longer need to re-auth. ([#5027](https://github.com/diegosouzapw/OmniRoute/pull/5027) — thanks @herjarsa) + +--- + +### 🔒 Security + +- **fix(security):** block SSRF allowlist bypass via `x-relay-path` header manipulation on Deno/Vercel relays. ([#4899](https://github.com/diegosouzapw/OmniRoute/pull/4899)) +- **fix(security):** pin image-fetch DNS resolution to prevent SSRF DNS-rebinding attacks (GHSA-cmhj-wh2f-9cgx). ([#4634](https://github.com/diegosouzapw/OmniRoute/pull/4634)) +- **fix(security):** do not trust the loopback socket as local-only when the server is behind a reverse proxy, closing a potential auth bypass path. ([#4632](https://github.com/diegosouzapw/OmniRoute/pull/4632)) +- **fix(security):** validate the Kiro region parameter to prevent SSRF via crafted region strings (GHSA-6mwv-4mrm-5p3m). ([#4629](https://github.com/diegosouzapw/OmniRoute/pull/4629)) +- **fix(copilot):** replace `execSync` shell interpolation with `execFile` in the `runOmniRouteCli` tool to prevent command injection. The user-supplied command is now split into an argv array and passed to `execFile` (no shell), so shell metacharacters are treated as literal text; error output is routed through `sanitizeErrorMessage()`. ([#5024](https://github.com/diegosouzapw/OmniRoute/pull/5024) — thanks @hamsa0x7) + +--- + +### 📝 Maintenance + +**God-file decomposition (continued, #3501)** + +- **refactor(chatCore):** extracted 12 focused helpers from `chatCore.ts` covering the streaming pipeline (`assembleStreamingPipeline`), cache-store logic (`storeStreamingSemanticCacheResponse`, `storeSemanticCacheResponse`), response headers (`assembleStreamingResponseHeaders`, `buildNonStreamingResponseHeaders`), JSON→SSE bridge (`maybeConvertJsonBodyToSse`), guardrail context (`buildPostCallGuardrailContext`), usage buffer (`applyClientUsageBuffer`), plugin hook (`runPluginOnRequestHook`), analytics (`writeCompressionAnalytics`, `emitOutputStyleTelemetry`), and compression predicates/settings (`resolveCompressionSettings`, et al.). ([#4811](https://github.com/diegosouzapw/OmniRoute/pull/4811)–[#4837](https://github.com/diegosouzapw/OmniRoute/pull/4837)) +- **refactor(sse/db/api):** continued decomposition of `services/usage.ts` (extracted quota-core, scalar/format helpers, Antigravity/GLM/MiniMax usage families), `db/core.ts` (schema-column reconciliation, snake↔camel column-mapping), `db/apiKeys.ts` (row-parsers, model-permission matching), and `validation.ts` (URL/headers/transport leaf layer, web-cookie/Meta-AI validators, enterprise-cloud + probe, audio/speech/apikey, search/embedding/rerank, and OpenAI/Anthropic format validators). ([#4921](https://github.com/diegosouzapw/OmniRoute/pull/4921)–[#4956](https://github.com/diegosouzapw/OmniRoute/pull/4956)) +- **refactor(pricing/providers):** decomposed `pricing.ts` into shared tiers + partitioned `DEFAULT_PRICING` modules, and split the `providers.ts` catalog into semantic data modules organized by provider family. ([#4917](https://github.com/diegosouzapw/OmniRoute/pull/4917), [#4918](https://github.com/diegosouzapw/OmniRoute/pull/4918)) +- **refactor(open-sse):** extract `safeParseJSON` utility and dedup `tryParseJSON` call sites; extract and dedup the fallback `tool_call` ID generation helper. ([#4735](https://github.com/diegosouzapw/OmniRoute/pull/4735), [#4736](https://github.com/diegosouzapw/OmniRoute/pull/4736)) + +**Quality & CI** + +- **chore(quality):** release base-red reconciliation + ratchet rebaselines — file-size, env-doc, and catalog baseline updates across multiple gates. ([#4630](https://github.com/diegosouzapw/OmniRoute/pull/4630), [#4879](https://github.com/diegosouzapw/OmniRoute/pull/4879), [#4886](https://github.com/diegosouzapw/OmniRoute/pull/4886), [#4915](https://github.com/diegosouzapw/OmniRoute/pull/4915), [#4961](https://github.com/diegosouzapw/OmniRoute/pull/4961), [#4973](https://github.com/diegosouzapw/OmniRoute/pull/4973)) +- **ci(quality):** shift heavy validation gates to the PR→release merge fast-path to accelerate the release cycle. ([#4857](https://github.com/diegosouzapw/OmniRoute/pull/4857)) +- **fix(ci):** include `coverage/lcov.info` in the coverage-report artifact so SonarQube can consume it. ([#4670](https://github.com/diegosouzapw/OmniRoute/pull/4670)) +- **fix(test):** validate Anthropic-compatible connections via `POST /v1/messages` for accurate connectivity testing. ([#4657](https://github.com/diegosouzapw/OmniRoute/pull/4657)) + +**Docs** + +- **docs(resilience):** document Quota-Share Concurrency Control — `max_concurrent` enforcement, serialization behavior, and cooldown-wait semantics. ([#4980](https://github.com/diegosouzapw/OmniRoute/pull/4980)) +- **docs(perf):** add per-endpoint p50/p95/p99 latency and cost budgets reference. ([#4867](https://github.com/diegosouzapw/OmniRoute/pull/4867) — thanks @KooshaPari) +- **docs(ops):** add canonical incident response runbook. ([#4868](https://github.com/diegosouzapw/OmniRoute/pull/4868) — thanks @KooshaPari) +- **docs(ops):** document the release-green family — `green-prs`, `check:release-green`, `babysit`, and nightly gate workflows. ([#4679](https://github.com/diegosouzapw/OmniRoute/pull/4679)) +- **docs(agentbridge):** document Electron `NODE_EXTRA_CA_CERTS`, real model IDs, and identity caveat for agent bridge integrations. ([#4718](https://github.com/diegosouzapw/OmniRoute/pull/4718)) +- **docs:** clarify Kiro provides ~50 credits/month per account, not unlimited. ([#4690](https://github.com/diegosouzapw/OmniRoute/pull/4690)) + +**Misc** + +- **chore(claude,codex):** bump pinned CLI identities — Claude `2.1.158 → 2.1.187`, Codex `0.132.0 → 0.142.0`. ([#4883](https://github.com/diegosouzapw/OmniRoute/pull/4883)) +- **chore(dashboard):** rename Qoder display label from "Qoder AI" to "Qoder". ([#4733](https://github.com/diegosouzapw/OmniRoute/pull/4733)) + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features @@ -19,6 +194,7 @@ - **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). - **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). - **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Storage SQLite tuning**: `Cache Size` is now a positive KiB setting (for example, `16384`) that applies to SQLite as `PRAGMA cache_size = -16384`; Page Size and Cache Size changes are applied to the live database instead of being persisted only in the settings table. - **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). - **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). diff --git a/README.md b/README.md index 150f630fd5..37bde4d92e 100644 --- a/README.md +++ b/README.md @@ -287,18 +287,19 @@ Result: 4 layers of fallback = zero downtime -> Recent highlights from **v3.8.20 → v3.8.35**. Full history in [`CHANGELOG.md`](CHANGELOG.md). +> Recent highlights from **v3.8.20 → v3.8.36**. 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`). → [Remote Mode](docs/guides/REMOTE-MODE.md) -- **🧭 Smarter auto-routing** — OpenRouter-style `auto/:` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), 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 to a configured model. → [Auto-Combo](docs/routing/AUTO-COMBO.md) +- **🧭 Smarter auto-routing** — OpenRouter-style `auto/:` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), a **Fusion** strategy (16th — 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, 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), 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) 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) +- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, 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)
@@ -544,12 +545,12 @@ Code blocks, URLs, JSON and structured data are **always protected** by the pres 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: +- **🪄 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: ; source=` response header. +- **🎯 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: ; 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. @@ -574,7 +575,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** @@ -743,7 +744,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) @@ -800,7 +801,7 @@ 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 231 providers. @@ -934,7 +935,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo | Document | Description | | ------------------------------------------------- | --------------------------------------------------- | | [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [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 | @@ -1089,32 +1090,32 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router] ### 🗜️ 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). | +| 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. | +| 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 diff --git a/bin/_ops-common.sh b/bin/_ops-common.sh new file mode 100644 index 0000000000..820efccc30 --- /dev/null +++ b/bin/_ops-common.sh @@ -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 "" — 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 — 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)" +} diff --git a/bin/cli/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs index f74ab1eea9..852cff76b7 100644 --- a/bin/cli/runtime/nativeDeps.mjs +++ b/bin/cli/runtime/nativeDeps.mjs @@ -12,7 +12,7 @@ import { spawnSync } from "node:child_process"; import { platform } from "node:os"; import { resolveDataDir } from "../data-dir.mjs"; -const BETTER_SQLITE3_VERSION = "12.9.0"; +const BETTER_SQLITE3_VERSION = "12.10.1"; function runtimeDir() { return join(resolveDataDir(), "runtime"); diff --git a/bin/cli/runtime/sqliteRuntime.mjs b/bin/cli/runtime/sqliteRuntime.mjs index 19dd790764..506481a353 100644 --- a/bin/cli/runtime/sqliteRuntime.mjs +++ b/bin/cli/runtime/sqliteRuntime.mjs @@ -6,7 +6,7 @@ import { pathToFileURL } from "node:url"; import { validateBinaryMagic, platformBinaryLabel } from "./magicBytes.mjs"; const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); -const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.6.2"; +const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.10.1"; let resolvedCached = null; diff --git a/bin/cli/runtime/trayRuntime.ts b/bin/cli/runtime/trayRuntime.ts index c1d76a96fd..cc734b6f80 100644 --- a/bin/cli/runtime/trayRuntime.ts +++ b/bin/cli/runtime/trayRuntime.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { execSync } from "node:child_process"; @@ -6,7 +6,43 @@ import { execSync } from "node:child_process"; const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); // systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime, // not in dependencies, to avoid npm install overhead for users who don't use --tray. -const SYSTRAY_VERSION = "systray2@1.4.5"; +// +// Pin: stay on the 2.x line. The original `systray@1.0.5` package bundles a 2017 +// x86_64 Go binary whose Mach-O headers modern dyld (macOS 14+) rejects, so the +// tray silently fails to register on Apple Silicon. systray2 2.x ships newer +// getlantern/systray-portable binaries that work under Rosetta. Inherited from +// upstream decolua/9router#1080. +export const SYSTRAY_PACKAGE = "systray2"; +export const SYSTRAY_VERSION = "2.1.4"; +const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`; + +export function resolveSystrayBinName(platform: NodeJS.Platform): string | null { + if (platform === "win32") return null; + if (platform === "darwin") return "tray_darwin_release"; + return "tray_linux_release"; +} + +export interface ChmodResult { + changed: boolean; + reason?: "win32-skip" | "missing" | "chmod-failed"; +} + +// systray2's npm tarball sometimes ships the bundled Go binary without the +// executable bit set on macOS/Linux, causing spawn() to fail with EACCES. +// Set +x best-effort so the tray actually starts. Inherited from +// upstream decolua/9router#1080. +export function chmodSystrayBinAt(runtimeRoot: string, platform: NodeJS.Platform): ChmodResult { + const binName = resolveSystrayBinName(platform); + if (!binName) return { changed: false, reason: "win32-skip" }; + const binPath = join(runtimeRoot, "node_modules", SYSTRAY_PACKAGE, "traybin", binName); + if (!existsSync(binPath)) return { changed: false, reason: "missing" }; + try { + chmodSync(binPath, 0o755); + return { changed: true }; + } catch { + return { changed: false, reason: "chmod-failed" }; + } +} export async function loadSystray(): Promise<(new (...args: unknown[]) => unknown) | null> { if (process.platform === "win32") return null; // Windows uses tray.ps1 instead @@ -15,15 +51,21 @@ export async function loadSystray(): Promise<(new (...args: unknown[]) => unknow try { installSystray(); } catch (err) { + // Surface failures to stderr instead of staying silent — anyone hitting + // a tray problem otherwise has zero diagnostic. (PR #1080) console.warn(`[omniroute] tray runtime install failed: ${(err as Error).message}`); return null; } } + // Best-effort: ensure the bundled Go binary is executable. Some npm tarballs + // drop the +x bit on extraction (observed on macOS). + chmodSystrayBinAt(RUNTIME_DIR, process.platform); try { - const modPath = join(RUNTIME_DIR, "node_modules", "systray2"); + const modPath = join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE); const mod = await import(modPath); return (mod.default ?? mod.SysTray ?? mod) as (new (...args: unknown[]) => unknown) | null; - } catch { + } catch (err) { + console.warn(`[omniroute] tray runtime import failed: ${(err as Error).message}`); return null; } } @@ -37,12 +79,12 @@ function ensureRuntimeDir(): void { } function isInstalled(): boolean { - return existsSync(join(RUNTIME_DIR, "node_modules", "systray2", "package.json")); + return existsSync(join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE, "package.json")); } function installSystray(): void { execSync( - `npm install --prefix "${RUNTIME_DIR}" ${SYSTRAY_VERSION} --no-audit --no-fund --silent`, + `npm install --prefix "${RUNTIME_DIR}" ${SYSTRAY_SPEC} --no-audit --no-fund --silent`, { stdio: ["ignore", "ignore", "pipe"], timeout: 120_000 } ); } diff --git a/bin/cli/tray/tray.ts b/bin/cli/tray/tray.ts index f9e3c642e1..502467bc92 100644 --- a/bin/cli/tray/tray.ts +++ b/bin/cli/tray/tray.ts @@ -124,6 +124,10 @@ async function initUnixTray(options: TrayOptions): Promise const systray = new SysTray({ menu: { icon: getIconBase64(), + // isTemplateIcon: false on darwin — the bundled icon.png is a full-color + // RGBA logo; template mode would render it as a solid white square + // because macOS template icons only use the alpha channel. (PR #1080) + isTemplateIcon: false, title: "OmniRoute", tooltip: `OmniRoute :${options.port}`, items: menuItems.map((it) => ({ @@ -175,6 +179,8 @@ async function initUnixTray(options: TrayOptions): Promise setTooltip: () => { /* systray2 does not support runtime tooltip change */ }, - destroy: () => systray.kill(), + // Pass false so systray2's kill does NOT call process.exit(0) before the + // rest of cleanup (server SIGKILL, MITM/tunnel cleanup) runs. (PR #1080) + destroy: () => systray.kill(false), }; } diff --git a/bin/cli/tray/traySystray.mjs b/bin/cli/tray/traySystray.mjs index bf0b7caee0..4c5bcb3c5e 100644 --- a/bin/cli/tray/traySystray.mjs +++ b/bin/cli/tray/traySystray.mjs @@ -100,8 +100,32 @@ export function initSystrayUnix({ port, onQuit, onOpenDashboard, onShowLogs }) { return tray; } +/** + * Resolve the Go systray2 child subprocess PID from a tray instance. + * systray2 exposes the spawned binary either as the `_process` field or via a + * `process()` accessor depending on version. Returns the numeric PID or null. + */ +export function getSystrayChildPid(tray) { + if (!tray) return null; + try { + const proc = tray._process || (typeof tray.process === "function" ? tray.process() : null); + if (proc && typeof proc.pid === "number") return proc.pid; + } catch {} + return null; +} + export function killSystrayUnix(tray) { try { + // systray2.kill(false) closes the IPC channel but leaves the Go tray binary + // subprocess running, which keeps an orphan NSStatusItem on macOS and blocks + // a freshly spawned tray (e.g. on respawn / hide-to-tray) from registering. + // SIGKILL the child PID directly first, then close IPC. + const pid = getSystrayChildPid(tray); + if (pid) { + try { + process.kill(pid, "SIGKILL"); + } catch {} + } tray.kill(false); } catch {} } diff --git a/bin/cold-start-bench.sh b/bin/cold-start-bench.sh new file mode 100755 index 0000000000..f879610712 --- /dev/null +++ b/bin/cold-start-bench.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# bin/cold-start-bench.sh — measure OmniRoute cold-start against the budgets in +# docs/PERF_BUDGETS.md §5 (container start → HTTP listening ≤ 800 ms; first warm +# TTFB ≤ 200 ms). Boots the server on a throwaway port, times until +# /api/health/ping answers 200, measures a warm request, and reports PASS/FAIL. +set -euo pipefail +SCRIPT_NAME="cold-start-bench" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" + +usage() { + cat <<'EOF' +Usage: bin/cold-start-bench.sh [--port ] [--start-cmd ""] [--url ] + [--listen-budget-ms ] [--ttfb-budget-ms ] [-h|--help] + +Boots OmniRoute, times cold-start to the first /api/health/ping 200, measures +warm TTFB, and compares against the PERF_BUDGETS.md §5 budgets +(listen ≤ 800 ms, TTFB ≤ 200 ms). Exits non-zero if a budget is exceeded. + +--url benches an already-running server instead of booting one (skips the boot +timing; only TTFB is measured). +EOF +} + +PORT="${PORT:-21987}" +START_CMD="" +BASE_URL="" +LISTEN_BUDGET_MS=800 +TTFB_BUDGET_MS=200 + +while [ $# -gt 0 ]; do + case "$1" in + --port) PORT="${2:?--port needs a value}"; shift 2 ;; + --start-cmd) START_CMD="${2:?--start-cmd needs a value}"; shift 2 ;; + --url) BASE_URL="${2:?--url needs a value}"; shift 2 ;; + --listen-budget-ms) LISTEN_BUDGET_MS="${2:?}"; shift 2 ;; + --ttfb-budget-ms) TTFB_BUDGET_MS="${2:?}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + *) ops_die "unknown argument: $1 (see --help)" ;; + esac +done + +ops_require_cmd curl + +now_ms() { date +%s%3N; } # ms since epoch (GNU date / Linux) +ping_ok() { curl -fsS -o /dev/null --max-time 2 "$1/api/health/ping" 2>/dev/null; } + +SERVER_PID="" +cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true; } +trap cleanup EXIT + +if [ -n "$BASE_URL" ]; then + ops_log "benching already-running server at $BASE_URL (boot timing skipped)" + listen_ms="" +else + BASE_URL="http://127.0.0.1:$PORT" + [ -n "$START_CMD" ] || START_CMD="npm start -- --port $PORT" + ops_log "booting: $START_CMD" + start_ms="$(now_ms)" + # shellcheck disable=SC2086 + PORT="$PORT" $START_CMD >/tmp/omniroute-coldstart.log 2>&1 & + SERVER_PID="$!" + deadline=$(($(now_ms) + 30000)) + until ping_ok "$BASE_URL"; do + kill -0 "$SERVER_PID" 2>/dev/null || ops_die "server process exited during boot (see /tmp/omniroute-coldstart.log)" + [ "$(now_ms)" -gt "$deadline" ] && ops_die "server did not answer /api/health/ping within 30s" + sleep 0.05 + done + listen_ms=$(($(now_ms) - start_ms)) +fi + +ping_ok "$BASE_URL" || ops_die "server at $BASE_URL is not answering /api/health/ping" +ttfb_ms="$(curl -fsS -o /dev/null -w '%{time_starttransfer}' "$BASE_URL/api/health/ping" | awk '{printf "%d", $1 * 1000}')" + +fail=0 +if [ -n "$listen_ms" ]; then + echo "cold-start (start → listening): ${listen_ms} ms (budget ${LISTEN_BUDGET_MS} ms)" + [ "$listen_ms" -le "$LISTEN_BUDGET_MS" ] || { echo "FAIL: listen budget exceeded"; fail=1; } +fi +echo "warm TTFB: ${ttfb_ms} ms (budget ${TTFB_BUDGET_MS} ms)" +[ "$ttfb_ms" -le "$TTFB_BUDGET_MS" ] || { echo "FAIL: TTFB budget exceeded"; fail=1; } +[ "$fail" -eq 0 ] && echo "PASS: within cold-start budgets" +exit "$fail" diff --git a/bin/restore-data.sh b/bin/restore-data.sh new file mode 100755 index 0000000000..df907fb732 --- /dev/null +++ b/bin/restore-data.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# bin/restore-data.sh — restore the OmniRoute SQLite data volume from a snapshot +# created by bin/snapshot-data.sh. Used by the data-layer incident runbook +# (docs/INCIDENT_RESPONSE.md §4.4) after stopping writers. +# +# Safety: takes a pre-restore snapshot of the CURRENT data, refuses to run +# unattended without --yes, and verifies the snapshot before overwriting. +set -euo pipefail +SCRIPT_NAME="restore-data" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" + +usage() { + cat <<'EOF' +Usage: bin/restore-data.sh [--data-dir ] [--yes|-y] [-h|--help] + +Restores storage.sqlite (and any sibling *.sqlite) from a snapshot. The current +data is first copied to $DB_BACKUPS_DIR/pre-restore_ as a safety net. + is a timestamp/sha, a snapshot dir name, or a path (see snapshot-data.sh). +Stop OmniRoute before running, and restart it afterwards. +EOF +} + +ID="" +while [ $# -gt 0 ]; do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + --data-dir) ops_set_data_dir "${2:?--data-dir needs a value}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + -*) ops_die "unknown argument: $1 (see --help)" ;; + *) ID="$1"; shift ;; + esac +done + +[ -n "$ID" ] || ops_die "snapshot id required (see --help)" +snap="$(ops_find_snapshot "$ID")" +ops_log "restore source: $snap → $OMNIROUTE_DATA_DIR" +ops_confirm "Overwrite storage.sqlite at $OMNIROUTE_DATA_DIR from $snap?" || ops_die "aborted" + +# Pre-restore safety copy of the live data. +if [ -f "$OMNIROUTE_SQLITE" ]; then + safety="$OMNIROUTE_BACKUPS_DIR/pre-restore_$(date -u +%Y%m%dT%H%M%SZ)" + mkdir -p "$safety" + if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$OMNIROUTE_SQLITE" "VACUUM INTO '$safety/storage.sqlite'" \ + || cp -a "$OMNIROUTE_SQLITE" "$safety/storage.sqlite" + else + cp -a "$OMNIROUTE_SQLITE" "$safety/storage.sqlite" + fi + ops_log "current data saved to $safety" +fi + +mkdir -p "$OMNIROUTE_DATA_DIR" +# Drop stale WAL/SHM so the restored DB is authoritative, then copy in. +rm -f "$OMNIROUTE_SQLITE" "${OMNIROUTE_SQLITE}-wal" "${OMNIROUTE_SQLITE}-shm" +cp -a "$snap/storage.sqlite" "$OMNIROUTE_SQLITE" + +# Restore sibling DBs captured in the snapshot. +for f in "$snap"/*.sqlite; do + [ -e "$f" ] || continue + [ "$(basename "$f")" = "storage.sqlite" ] && continue + cp -a "$f" "$OMNIROUTE_DATA_DIR/" +done + +ops_log "restore complete — restart OmniRoute to pick up the restored data" diff --git a/bin/restore-policies.sh b/bin/restore-policies.sh new file mode 100755 index 0000000000..9cf4da03ef --- /dev/null +++ b/bin/restore-policies.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# bin/restore-policies.sh — restore ONLY the API-key policy tables from a +# snapshot, leaving request/audit/runtime state intact. Used by the auth-layer +# incident runbook (docs/INCIDENT_RESPONSE.md §4.3) when policies_active is empty +# but the rest of the database is healthy (so a full restore-data is overkill). +# +# "Policy" tables = api_key* definition tables (the key + its limits/allowed +# quotas/context sources), EXCLUDING usage counters and reset logs so a restore +# never rewinds live usage accounting. +set -euo pipefail +SCRIPT_NAME="restore-policies" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" + +usage() { + cat <<'EOF' +Usage: bin/restore-policies.sh [--data-dir ] [--yes|-y] [-h|--help] + +Replaces the API-key policy tables (api_key*, excluding *counter* / *_log*) in +the live DB with the copies from a snapshot, inside one transaction. The live DB +is snapshotted to $DB_BACKUPS_DIR/pre-policy-restore_ first. Requires sqlite3. +EOF +} + +ID="" +while [ $# -gt 0 ]; do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + --data-dir) ops_set_data_dir "${2:?--data-dir needs a value}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + -*) ops_die "unknown argument: $1 (see --help)" ;; + *) ID="$1"; shift ;; + esac +done + +[ -n "$ID" ] || ops_die "snapshot id required (see --help)" +ops_require_cmd sqlite3 +snap="$(ops_find_snapshot "$ID")" +[ -f "$OMNIROUTE_SQLITE" ] || ops_die "no live DB at $OMNIROUTE_SQLITE (use restore-data.sh for a full restore)" + +# Policy definition tables present in BOTH the snapshot and the live DB. GLOB +# keeps `_` literal; we drop usage counters / logs so accounting isn't rewound. +readarray -t tables < <( + sqlite3 "$snap/storage.sqlite" \ + "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'api_key*' \ + AND name NOT GLOB '*counter*' AND name NOT GLOB '*_log*' ORDER BY name;" +) +[ "${#tables[@]}" -gt 0 ] || ops_die "snapshot has no api_key* policy tables" + +ops_log "policy tables to restore: ${tables[*]}" +ops_confirm "Replace ${#tables[@]} policy table(s) in $OMNIROUTE_SQLITE from $snap?" || ops_die "aborted" + +# Safety snapshot of the live DB before mutating it. +safety="$OMNIROUTE_BACKUPS_DIR/pre-policy-restore_$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -p "$safety" +sqlite3 "$OMNIROUTE_SQLITE" "VACUUM INTO '$safety/storage.sqlite'" +ops_log "live DB saved to $safety" + +# Replace each policy table inside a single transaction, attaching the snapshot +# (only tables that also exist in the live DB are touched). +sql="ATTACH DATABASE '$snap/storage.sqlite' AS snap; +PRAGMA foreign_keys=OFF; +BEGIN;" +for t in "${tables[@]}"; do + if [ -n "$(sqlite3 "$OMNIROUTE_SQLITE" "SELECT 1 FROM sqlite_master WHERE type='table' AND name='$t' LIMIT 1;")" ]; then + sql="$sql +DELETE FROM main.\"$t\"; +INSERT INTO main.\"$t\" SELECT * FROM snap.\"$t\";" + else + ops_log "skipping '$t' — not present in live DB" + fi +done +sql="$sql +COMMIT; +DETACH DATABASE snap;" + +printf '%s\n' "$sql" | sqlite3 "$OMNIROUTE_SQLITE" +ops_log "policies restored from $snap — restart OmniRoute to apply" diff --git a/bin/rollback.sh b/bin/rollback.sh new file mode 100755 index 0000000000..41d7bfcd43 --- /dev/null +++ b/bin/rollback.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# bin/rollback.sh — roll OmniRoute back to a previous release to mitigate a bad +# deploy. Used by the incident runbook (docs/INCIDENT_RESPONSE.md §3 / §4). +# +# Methods (auto-detected; override with --method): +# • npm — `npm install -g omniroute@` and, if PM2 manages it, +# `pm2 restart omniroute`. This is how the VPS deploy runs. +# • docker — re-tag the local image omniroute: to omniroute:prod and +# recreate the prod service from docker-compose.prod.yml. (That +# compose builds the `prod` tag locally rather than pulling a +# registry tag, so the versioned image must already exist locally.) +# With no , targets the highest published release strictly below the +# current package.json version. +set -euo pipefail +SCRIPT_NAME="rollback" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +usage() { + cat <<'EOF' +Usage: bin/rollback.sh [] [--method npm|docker] [--yes|-y] [-h|--help] + +Rolls OmniRoute back to (e.g. 3.8.35 or v3.8.35). With no version, +picks the highest published release below the current package.json version. +Auto-detects npm vs docker deployment; override with --method. +EOF +} + +VERSION="" +METHOD="" +while [ $# -gt 0 ]; do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + --method) METHOD="${2:?--method needs npm|docker}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + -*) ops_die "unknown argument: $1 (see --help)" ;; + *) VERSION="${1#v}"; shift ;; + esac +done + +if [ -z "$METHOD" ]; then + if command -v docker >/dev/null 2>&1 && [ -f "$REPO_ROOT/docker-compose.prod.yml" ] \ + && docker compose -f "$REPO_ROOT/docker-compose.prod.yml" ps -q 2>/dev/null | grep -q .; then + METHOD="docker" + elif command -v npm >/dev/null 2>&1; then + METHOD="npm" + else + ops_die "no deploy method detected (no running prod compose, no npm) — pass --method npm|docker" + fi +fi + +# Resolve the previous published version when none was given. +if [ -z "$VERSION" ]; then + ops_require_cmd npm + ops_require_cmd node + current="$(node -p "require('$REPO_ROOT/package.json').version" 2>/dev/null || true)" + [ -n "$current" ] || ops_die "cannot read current version from package.json — pass " + VERSION="$(npm view omniroute versions --json 2>/dev/null | node -e ' + let s = ""; + process.stdin.on("data", (d) => (s += d)).on("end", () => { + let vs; + try { vs = JSON.parse(s); } catch { vs = []; } + if (!Array.isArray(vs)) vs = [vs]; + const ok = (v) => /^[0-9]+\.[0-9]+\.[0-9]+$/.test(v); + const cmp = (a, b) => { + const x = a.split(".").map(Number), y = b.split(".").map(Number); + return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; + }; + const cur = process.argv[1]; + const prev = vs.filter(ok).filter((v) => cmp(v, cur) < 0).sort(cmp).pop() || ""; + process.stdout.write(prev); + }); + ' "$current")" + [ -n "$VERSION" ] || ops_die "could not resolve the previous published version — pass explicitly" +fi + +ops_log "target: omniroute@$VERSION via $METHOD" +ops_confirm "Roll OmniRoute back to $VERSION via $METHOD?" || ops_die "aborted" + +case "$METHOD" in + npm) + ops_require_cmd npm + npm install -g "omniroute@$VERSION" + if command -v pm2 >/dev/null 2>&1 && pm2 jlist 2>/dev/null | grep -q '"name":"omniroute"'; then + pm2 restart omniroute --update-env + ops_log "pm2 restarted omniroute" + else + ops_log "installed omniroute@$VERSION — restart the service to apply (no PM2 'omniroute' process found)" + fi + ;; + docker) + ops_require_cmd docker + if ! docker image inspect "omniroute:$VERSION" >/dev/null 2>&1; then + ops_die "local image omniroute:$VERSION not found — build it from the $VERSION checkout first (this compose builds the 'prod' tag, it does not pull a registry tag)" + fi + docker tag "omniroute:$VERSION" omniroute:prod + docker compose -f "$REPO_ROOT/docker-compose.prod.yml" up -d --no-build + ops_log "recreated prod service from omniroute:$VERSION" + ;; + *) ops_die "unknown method: $METHOD (use npm or docker)" ;; +esac +ops_log "rollback to $VERSION complete" diff --git a/bin/snapshot-data.sh b/bin/snapshot-data.sh new file mode 100755 index 0000000000..f312a46b9a --- /dev/null +++ b/bin/snapshot-data.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# bin/snapshot-data.sh — consistent point-in-time snapshot of the OmniRoute data +# volume (the SQLite store under $DATA_DIR). Used by the data-layer incident +# runbook (docs/INCIDENT_RESPONSE.md §4.4) before any restore. +# +# Output: a directory $DB_BACKUPS_DIR/snapshot_[_