Compare commits

...

58 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
eaec3279ab Merge pull request #1192 from diegosouzapw/release/v3.6.5
chore(release): v3.6.5 — Claude Code native parity, Antigravity credit fallback
2026-04-13 22:04:40 -03:00
diegosouzapw
195c313628 chore(ci): force rebuild pipeline 2026-04-13 21:52:57 -03:00
diegosouzapw
eb62d6368b test(e2e): fix Playwright strict mode ambiguity with all tab locator 2026-04-13 21:37:35 -03:00
diegosouzapw
4655a8504d fix(ci): add missing route validation for upstream-proxy and resolve E2E tab sync 2026-04-13 19:54:44 -03:00
diegosouzapw
44e4ae2d94 chore: update Node.js version to 22 and upgrade undici dependency 2026-04-13 19:11:07 -03:00
diegosouzapw
5fa5be2136 feat: add legacy combo reference canonicalization to database health check and improve table existence handling 2026-04-13 19:09:08 -03:00
diegosouzapw
3e83402470 chore(release): v3.6.5 — integrated skills.sh and dep bumps 2026-04-13 17:40:31 -03:00
diegosouzapw
e7c2998cf9 feat: implement email masking for test result labels and refactor sidebar route matching logic 2026-04-13 17:39:45 -03:00
dependabot[bot]
9ef3dcd581 deps: bump typescript-eslint in the development group (#1225)
Integrated into release/v3.6.5
2026-04-13 17:39:42 -03:00
dependabot[bot]
8cfd26c21e deps: bump the production group with 2 updates (#1224)
Integrated into release/v3.6.5
2026-04-13 17:39:39 -03:00
Paijo
2d88469761 feat: add skills.sh as external skill provider (#1223)
Integrated into release/v3.6.5
2026-04-13 17:39:36 -03:00
diegosouzapw
f3e53a108b chore(release): v3.6.5 — stabilization and Claude Code parity finalization 2026-04-13 15:07:31 -03:00
diegosouzapw
a2436bc50b fix(gemini): harden schema sanitizing and streaming defaults
Remove unsupported Gemini schema fields including vendor-prefixed
`x-` properties so translated tool definitions avoid rejected keywords.

Emit an empty `content` field with the initial assistant role delta to
keep OpenAI-compatible streaming output consistent for clients that
expect content on the first chunk.

Also force undici's fetch whenever a dispatcher is provided and treat
`onRequestStart` version mismatches as fatal instead of falling back to
native fetch, preventing broken proxy requests under mixed undici
versions.
2026-04-13 14:58:37 -03:00
diegosouzapw
cd56ea7040 fix(open-sse): serialize xxhash startup in cch hashing
Cache the in-flight xxhash-wasm initialization so concurrent calls reuse
the same promise before the raw hash function is available.

This avoids redundant loader work and prevents races during early CCH
computations.
2026-04-13 14:43:32 -03:00
diegosouzapw
7efa672976 fix(core): restore degradation settings and clean combo stream output
Persist background degradation settings as structured data so they can
be reloaded during node startup without double-encoding JSON.

Update settings validation to accept the missing fields used by the
API and align models.dev sync interval bounds with millisecond-based
values.

Also strip omniModel tags when they are wrapped by either literal
escaped newlines or actual newline characters, and adjust the combo
routing test to match the cleaned streamed content.
2026-04-13 14:35:26 -03:00
diegosouzapw
0856854c81 Merge PR #1203
# Conflicts:
#	open-sse/executors/antigravity.ts
2026-04-13 13:13:37 -03:00
diegosouzapw
488f9653e6 Merge PR #1193
# Conflicts:
#	open-sse/services/claudeCodeCompatible.ts
2026-04-13 13:12:22 -03:00
diegosouzapw
48467bc514 feat(core): add db health checks and provider interoperability
Add automated SQLite health diagnostics with optional auto-repair,
startup and scheduled execution, authenticated API routes, an MCP tool,
and dashboard visibility for status and repair actions.

Improve provider compatibility by adding Cursor usage fetching and
v3.1.0 parity headers, introducing a per-connection Codex Responses
store opt-in with session fallback, fixing Codex non-stream combo and
SSE translation behavior, sanitizing Gemini googleSearch tool payloads
and Qwen thinking tool_choice handling, and hardening cleanup and call
log storage paths.
2026-04-13 13:11:30 -03:00
Hdsje
56f7a5baae fix: community patches for production stability (OAuth, DB safety, burst-limit, CLI tools) (#1213)
Integrated into release/v3.6.5
2026-04-13 13:10:27 -03:00
Paijo
ee5a1f0e7a docs: enhance AGENTS.md with architecture internals and create CLAUDE.md (#1210)
Integrated into release/v3.6.5
2026-04-13 13:10:22 -03:00
Vladimir Maryasov
625bcf105c fix: remove leading newline from omniModel tag injection (#1208)
Integrated into release/v3.6.5
2026-04-13 13:10:17 -03:00
Ravi Tharuma
3e3ea37aba feat: CLIProxyAPI executor dual-mode — auto-detect Claude Code OAuth for deeper emulation (#1198)
Integrated into release/v3.6.5
2026-04-13 13:10:09 -03:00
tombii
5049cc6e1d fix(qwen): add x-request-id header to device code request (#1197)
Integrated into release/v3.6.5
2026-04-13 13:10:04 -03:00
Ravi Tharuma
b142145a4e fix: hardcode Antigravity UA to darwin/arm64 to match CLIProxyAPI production behavior
CLIProxyAPI works without bans using hardcoded darwin/arm64 in the
Antigravity User-Agent. Real Antigravity is a macOS desktop tool —
reporting the actual server OS (linux/amd64 on a VPS) is MORE
suspicious than always claiming darwin/arm64. Matches proven
production fingerprint.
2026-04-13 11:45:23 +02:00
Ravi Tharuma
7eb3056856 feat: update Gemini CLI executor with dynamic UA, header scrubbing, and obfuscation
Matches the Antigravity executor treatment:
- Dynamic User-Agent: GeminiCLI/0.31.0/MODEL (OS; ARCH) per-model
- X-Goog-Api-Client: maintained (was already correct)
- Header scrubbing: removes proxy/fingerprint headers
- Sensitive word obfuscation in user message content
- Tracks current model for per-request UA generation
2026-04-13 11:41:22 +02:00
Ravi Tharuma
d8e9c16d83 feat: Antigravity/Gemini parity — header scrubbing, 429 engine, credits retry, dynamic UA
Brings the Antigravity executor to parity with CLIProxyAPI and
ZeroGravity for Gemini/Google traffic handling.

New modules:
- antigravityHeaderScrub.ts: Removes 28 proxy/fingerprint/Chromium
  headers that reveal non-native traffic. Sets Accept-Encoding to
  'gzip, deflate, br' (Node.js default).
- antigravity429Engine.ts: 4-tier 429 classification (unknown,
  rate_limited, quota_exhausted, soft_rate_limit) with nuanced
  retry decisions (soft_retry, instant_retry, short_cooldown,
  full_quota_exhausted). Per-auth credits failure tracking with
  auto-disable after 3 failures (5h cooldown).
- antigravityCredits.ts: Google One AI credits injection — retries
  quota_exhausted 429s with enabledCreditTypes: ['GOOGLE_ONE_AI'].
  Enabled via ANTIGRAVITY_CREDITS=1 env var.
- antigravityHeaders.ts: Dynamic User-Agent from OS/arch
  (antigravity/1.21.9 darwin/arm64), Gemini CLI UA per-model
  (GeminiCLI/0.31.0/MODEL (OS; ARCH)), X-Goog-Api-Client header
  (google-genai-sdk/1.41.0 gl-node/v22.19.0).
- antigravityObfuscation.ts: Sensitive word obfuscation using
  zero-width joiners for 17 client names (matching ZeroGravity).

Updated antigravity.ts:
- Dynamic UA replaces hardcoded 'antigravity/1.104.0 darwin/arm64'
- X-Goog-Api-Client header added (was missing entirely)
- Header scrubbing on all outbound requests
- 4-tier 429 engine replaces 2-category classification
- Google One AI credits retry on quota_exhausted
- Sensitive word obfuscation in user message content
2026-04-13 11:05:18 +02:00
R.D.
a58db791e1 fix(timeout): align cc-compatible timeout header with runtime config 2026-04-12 23:27:09 -04:00
diegosouzapw
fa2cfe36d4 chore(release): v3.6.5 — Claude Code native parity, Antigravity credit fallback, 5 community PRs
## New Features
- Claude Code Native Parity: CCH xxHash64 signing, TitleCase tool remapping,
  API constraint enforcement (PR #1188 — @RaviTharuma)
- Antigravity AI Credits Fallback: auto-retry with GOOGLE_ONE_AI credit
  injection on quota exhaustion (PR #1190 — @sFaxsy)
- Per-Connection Codex Defaults (PR #1176 — @rdself)
- xxhash-wasm dependency for CCH signing

## Bug Fixes
- Search cache coalescing with TTL=0 (PR #1178 — @sjhddh)
- Antigravity credit cache key alignment (PR #1190)
- Codex combo smoke test false positives (PR #1176)
- Electron NODE_PATH resolution on Windows (PR #1172 — @backryun)
- CC-compatible test assertion fix (billing header cache_control)

## Breaking Changes
- DELETE /api/settings/codex-service-tier removed (PR #1176)
- CCH signing on CC-compatible providers

Tests: 2770/2770 passing
2026-04-12 23:02:34 -03:00
diegosouzapw
b8c9880a2e docs: update CHANGELOG with v3.6.5 release notes (PR #1188, PR #1190) 2026-04-12 22:42:31 -03:00
diegosouzapw
3b4e7b0e5f feat(claude-code): PR #1188 — Native parity with CCH signing, tool remapping, and API constraints
Squash merge of feat/claude-code-native-parity into release/v3.6.5.

## Wiring

1. base.ts: CCH xxHash64 body signing for anthropic-compatible-cc-* providers,
   applied after CLI fingerprint ordering so the hash covers the final bytes
   sent upstream.

2. chatCore.ts: After buildClaudeCodeCompatibleRequest(), applies synchronous
   parity pipeline steps:
   - remapToolNamesInRequest() — TitleCase tool name mapping (14 tools)
   - enforceThinkingTemperature() — temperature=1 when thinking active
   - disableThinkingIfToolChoiceForced() — remove thinking on forced tool_choice
   Cache-control limit enforcement intentionally omitted from chatCore because
   the billing-header system block counts toward the 4-block cap and would strip
   legitimate client cache markers.

3. xxhash-wasm: installed (was declared in package.json by PR but not installed).

## Test updates

- tests/unit/claude-code-parity.test.mjs: 25 new tests covering CCH signing,
  fingerprint computation, tool remapping, and API constraints.
- tests/unit/cc-compatible-provider.test.mjs: fixed pre-existing assertion that
  expected no cache_control on system blocks (billing header now carries ephemeral
  per PR #1188 design).

Closes #1188
2026-04-12 22:41:12 -03:00
SiFax
ed27ef4cee feat(antigravity): implement AI Credits overages fallback and dashboa… (#1190)
Integrated into release/v3.6.5. Fixed accountId key consistency between executor and fetcher. Added 13 unit tests for credit cache helpers, SSE parsing, and accountId derivation contract.
2026-04-12 21:48:22 -03:00
Randi
1e2b7e728d Fix Codex combo fallback and move Codex defaults to connections (#1176)
Integrated into release/v3.6.5. Added CHANGELOG breaking change entry for the removed /api/settings/codex-service-tier endpoint and deduplicated getCodexRequestDefaults in page.tsx (now imports from requestDefaults.ts).
2026-04-12 21:40:32 -03:00
7. Sun
1e9fbd216f fix(searchCache): ttlMs=0 now bypasses inflight coalescing too (#1178)
Integrated into release/v3.6.5 — correctness fix for ttlMs=0 cache bypass + cacheTTLMs ?? operator. Added 6 unit tests covering concurrent bypass, hits counter, no-store behavior, and the nullish coalescing semantic.
2026-04-12 21:33:05 -03:00
backryun
139cd337a3 [codex] Fix Electron server node path resolution (#1172)
Integrated into release/v3.6.5 — fixes Windows Electron packaged startup failure caused by split NODE_PATH between app.asar.unpacked and app/node_modules. Review improvement: added debug log for skipped candidate paths.
2026-04-12 21:26:35 -03:00
Diego Rodrigues de Sa e Souza
531d49fa00 Merge pull request #1186 from diegosouzapw/release/v3.6.4
chore(release): v3.6.4 — Auto-Combo Unification, LKGP Standalone, Combo Builder v2, Composite Tiers
2026-04-12 20:05:27 -03:00
diegosouzapw
b17c9a067a test: fix stream assertions and resolve CI env flakes
Fixes test expectations for non-streaming requests expecting 'Accept: application/json' instead of undefined. Resolves API_KEY_SECRET missing in unit tests post security hardening. Adds robust Playwright waits to combo E2E testing.
2026-04-12 19:46:39 -03:00
diegosouzapw
4d67a4a811 chore(release): bump to v3.6.4 — security fixes, changelog, docs, version sync 2026-04-12 19:20:02 -03:00
diegosouzapw
c286fdc96a fix(auth): require admin auth for backup and translator routes
Protect database backup, export, restore, and translator save endpoints
with authentication checks to block unauthenticated data access and
state changes.

Also remove the insecure API key secret fallback, ignore nested app env
files from package publishes, and align tests with explicit
application/json Accept headers for non-stream requests
2026-04-12 19:08:06 -03:00
diegosouzapw
b65caf82b4 fix(combos): preserve legacy string combo refs during normalization
Load existing combos before normalizing POST and PUT payloads so
legacy string references can still resolve to combo-ref models.

This keeps stored combo references consistent while preserving DAG
validation for newly created and updated combos.
2026-04-12 18:57:51 -03:00
diegosouzapw
25bd04e400 feat(settings): unify routing rules and model aliases controls
Move model routing management into Settings and add a unified
model alias editor for exact and wildcard remaps.

Sync combo defaults with global routing strategy settings, add
localized combo onboarding copy, and expand routing i18n across
supported locales.

Also fix supporting routing behavior by accepting all strategy
values in settings schemas, preserving connection ids in combo
tests, honoring non-stream JSON requests for CC-compatible
providers, and handling hashed external package subpaths.
2026-04-12 18:43:19 -03:00
diegosouzapw
9944d136e4 chore(release): sync workspace versions to v3.6.4 — open-sse, electron, llm.txt 2026-04-12 14:02:59 -03:00
diegosouzapw
816db26a75 feat(combos): unify auto-combo into main combos page — LKGP standalone, intelligent routing panel, builder step, i18n consolidation 2026-04-12 13:51:03 -03:00
diegosouzapw
25815ae53d fix(combo): honor composite tier order in runtime routing
Apply composite tier ordering to top-level combo steps and direct
targets so priority and round-robin strategies follow the configured
defaultTier to fallbackTier chain before using remaining steps.

Add defensive config parsing helpers, regression tests for composite
tier ordering, and documentation updates for the structured combo
builder, quota-aware P2C, and combo target health.
2026-04-12 11:02:13 -03:00
diegosouzapw
ea61d00cf7 feat: v3.6.4 — Combo Builder v2, Composite Tiers, P2C Credentials, Observability Layer
## New Features
- Combo Builder v2 wizard UI (multi-stage: Basics → Steps → Strategy → Review)
- Combo Step Architecture Schema v2 (ComboModelStep, ComboRefStep, pinned accounts)
- Composite Tiers system for tiered model routing with fallback chains
- Model Capabilities Registry (unified resolver merging specs + registry + synced data)
- Observability module (buildHealthPayload, buildTelemetryPayload, buildSessionsSummary)
- Session & Quota Monitor panels on Health dashboard
- Combo Health per-target analytics via resolveNestedComboTargets()
- Combo Builder Options API (GET /api/combos/builder/options)

## Performance
- Middleware lazy loading (apiAuth, db/settings, modelSyncScheduler)
- E2E auth bypass mode (NEXT_PUBLIC_OMNIROUTE_E2E_MODE)

## Bug Fixes
- P2C credential selection with quota headroom awareness
- Fixed-account combo steps bypass model cooldowns/circuit breakers
- Combo metrics per-target tracking (byTarget with executionKey)
- Call logs schema expansion (7 new columns + composite index)
- Quota monitor lifecycle enrichment (status, snapshots, summary)
- Codex quota fetcher hardening

## Maintenance
- DB migration 021 (combo_call_log_targets)
- Combo CRUD normalization on read
- Playwright config + build script improvements
- OpenAPI spec version sync to 3.6.4

## Tests
- 16 new test suites + 12 existing test updates
- 86 files changed, +8318 -1378 lines
2026-04-12 10:34:10 -03:00
diegosouzapw
f15576fd98 build(deps): bump electron-builder to 26.8.1 to resolve tar CVEs 2026-04-11 19:38:18 -03:00
Diego Rodrigues de Sa e Souza
6b64702054 Merge pull request #1164 from diegosouzapw/release/v3.6.3
chore(release): v3.6.3 — Fix cloudflare config, prompt cache payloads, and openai-compatible validation
2026-04-11 18:51:03 -03:00
diegosouzapw
0887d544ce fix: ABI-mismatched sqlite native binding crash on Windows (#1163) 2026-04-11 18:25:19 -03:00
diegosouzapw
af57d67320 docs: include ENVIRONMENT.md, UNINSTALL.md and sync ignore rules 2026-04-11 18:07:42 -03:00
diegosouzapw
4d460109dd chore(release): v3.6.3 — Fix cloudflare config, prompt cache payloads, and openai-compatible validation 2026-04-11 18:00:20 -03:00
diegosouzapw
10288f87c1 docs: add dependabot bumps to changelog for v3.6.3 2026-04-11 17:18:43 -03:00
diegosouzapw
05bfe0a7b2 chore: bump version to 3.6.3 for release branches 2026-04-11 17:15:32 -03:00
dependabot[bot]
6e521af3b3 deps: bump the development group with 7 updates (#1162)
Integrated into release/v3.6.3
2026-04-11 17:14:50 -03:00
dependabot[bot]
d833cc9c54 deps: bump the production group with 3 updates (#1161)
Integrated into release/v3.6.3
2026-04-11 17:14:47 -03:00
dependabot[bot]
4e0d926f61 deps: bump electron from 40.8.5 to 41.2.0 in /electron (#1158)
Integrated into release/v3.6.3
2026-04-11 17:14:43 -03:00
dependabot[bot]
9e4ce3ae72 chore(deps): bump actions/download-artifact from 4 to 8 (#1157)
Integrated into release/v3.6.3
2026-04-11 17:14:40 -03:00
dependabot[bot]
7a8e6f8e8c chore(deps): bump docker/build-push-action from 6 to 7 (#1156)
Integrated into release/v3.6.3
2026-04-11 17:14:37 -03:00
diegosouzapw
e137d63886 ci: skip snyk on dependabot to fix empty token failures 2026-04-11 16:51:33 -03:00
diegosouzapw
5e16ef73f9 fix(workflows): repair CHANGELOG extraction regex in generate-release 2026-04-11 16:47:41 -03:00
274 changed files with 21738 additions and 4274 deletions

View File

@@ -205,7 +205,7 @@ git pull origin main
VERSION=$(node -p "require('./package.json').version")
# Extracts the changelog section for this version
NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^## \\[[0-9]+/{if(flag) exit} flag" CHANGELOG.md | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
NOTES=$(awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[[0-9]+/{if(flag) exit} flag' CHANGELOG.md | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -z "$NOTES" ]; then NOTES="OmniRoute v$VERSION Release"; fi
git tag -a "v$VERSION" -m "Release v$VERSION"

View File

@@ -1,114 +1,312 @@
# OmniRoute environment contract
# This file reflects actual runtime usage in the current codebase.
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ OmniRoute — .env Contract │
# │ This file documents EVERY environment variable read by the runtime. │
# │ Copy to .env and adjust values. Lines starting with # are commented out │
# │ (optional / off-by-default). Uncomment only what you need. │
# │ Reference: docs/ENVIRONMENT.md for full details and usage scenarios. │
# └─────────────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════
# REQUIRED SECRETS — Generate strong values!
# ═══════════════════════════════════════════════════
# Generate with: openssl rand -base64 48
# ═══════════════════════════════════════════════════════════════════════════════
# 1. REQUIRED SECRETS — Must be set before first run!
# ═══════════════════════════════════════════════════════════════════════════════
# These secrets are critical for security. Generate strong, unique values.
# JWT signing key for dashboard session tokens.
# Used by: src/lib/auth — signs/verifies all authenticated session cookies.
# Generate: openssl rand -base64 48
JWT_SECRET=
# Generate with: openssl rand -hex 32
# Encryption key for API keys stored in the database.
# Used by: src/lib/db/apiKeys.ts — encrypts API key values at rest in SQLite.
# Generate: openssl rand -hex 32
API_KEY_SECRET=
# Initial admin password — CHANGE THIS before first use!
INITIAL_PASSWORD=123456
# Initial admin login password — CHANGE THIS before first use!
# Used by: bootstrap only — sets the initial dashboard password on first boot.
# After first login you can change it from Dashboard → Settings → Security.
# Default: CHANGEME (insecure, for local dev only)
INITIAL_PASSWORD=CHANGEME
# ═══════════════════════════════════════════════════════════════════════════════
# 2. STORAGE & DATABASE
# ═══════════════════════════════════════════════════════════════════════════════
# OmniRoute uses SQLite for all persistence. These variables control where
# data lives, encryption, and cleanup policies.
# Base directory for all persistent data (SQLite DB, logs, backups).
# Used by: src/lib/db/core.ts — resolves the SQLite database file path.
# Default: ~/.omniroute/ | Override for Docker or custom installations.
# DATA_DIR=/var/lib/omniroute
# Storage (SQLite)
STORAGE_DRIVER=sqlite
# Generate with: openssl rand -hex 32
# Encryption key for SQLite database encryption at rest.
# Used by: src/lib/db/encryption.ts — encrypts the entire SQLite database.
# Generate: openssl rand -hex 32 | Leave empty to disable DB encryption.
STORAGE_ENCRYPTION_KEY=
# Version tag for the encryption key — allows future key rotation.
# Used by: scripts/bootstrap-env.mjs, electron/main.js — persists key version.
# Default: v1 | Increment when rotating STORAGE_ENCRYPTION_KEY.
STORAGE_ENCRYPTION_KEY_VERSION=v1
APP_LOG_RETENTION_DAYS=90
CALL_LOG_RETENTION_DAYS=90
SQLITE_MAX_SIZE_MB=2048
SQLITE_CLEAN_LEGACY_FILES=true
# Automatic SQLite backup on startup.
# Used by: src/lib/db/backup.ts — creates a timestamped backup before migrations.
# Default: false (backups enabled) | Set true to skip backup on every restart.
DISABLE_SQLITE_AUTO_BACKUP=false
# Recommended runtime variables
# Canonical/base port (keeps backward compatibility)
# ═══════════════════════════════════════════════════════════════════════════════
# 3. NETWORK & PORTS
# ═══════════════════════════════════════════════════════════════════════════════
# OmniRoute can run on a single port (default) or split Dashboard/API ports.
# Canonical port for both Dashboard UI and API (single-port mode).
# Used by: src/lib/runtime/ports.ts — base port for the Next.js server.
# Default: 20128
PORT=20128
# Optional split ports:
# Split-port mode: serve Dashboard and API on separate ports for network isolation.
# Used by: src/lib/runtime/ports.ts — overrides PORT for each service.
# API_PORT=20129
# API_HOST=0.0.0.0
# DASHBOARD_PORT=20128
# Optional Docker production host publish ports:
# Docker production port mappings (docker-compose.prod.yml only).
# These set the HOST-side published ports. Container ports use PORT/API_PORT.
# PROD_DASHBOARD_PORT=20130
# PROD_API_PORT=20131
# Runtime override used by Electron and wrapped environments.
# OMNIROUTE_PORT takes precedence over PORT when running inside wrappers.
# Used by: src/lib/runtime/ports.ts — preserves canonical port in Electron.
# OMNIROUTE_PORT=20128
# Environment mode — affects Next.js behavior, logging verbosity, and caching.
# Values: production | development | Default: production
NODE_ENV=production
INSTANCE_NAME=omniroute
# Recommended security and ops variables
# ═══════════════════════════════════════════════════════════════════════════════
# 4. SECURITY & AUTHENTICATION
# ═══════════════════════════════════════════════════════════════════════════════
# Salt for generating unique machine IDs (fingerprint diversification).
# Used by: src/lib/auth — combined with hardware identifiers for machine-id hash.
# Default: endpoint-proxy-salt | Change per-deployment for isolation.
MACHINE_ID_SALT=endpoint-proxy-salt
AUTH_COOKIE_SECURE=false
REQUIRE_API_KEY=false
ALLOW_API_KEY_REVEAL=false
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
# Input Sanitizer (FASE-01 — prompt injection & PII protection)
# Set true when running behind HTTPS (reverse proxy with TLS termination).
# Used by: src/lib/auth — sets the Secure flag on session cookies.
# Default: false | MUST be true in any non-localhost deployment.
AUTH_COOKIE_SECURE=false
# Require an API key for all /v1/* proxy endpoints.
# Used by: API middleware — rejects unauthenticated requests to the proxy API.
# Default: false | Set true for multi-user/public deployments.
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.
# Default: false | Security risk if enabled on shared instances.
ALLOW_API_KEY_REVEAL=false
# Comma-separated API key IDs that skip request logging (GDPR/compliance).
# Used by: src/lib/compliance/index.ts — suppresses logs for specific keys.
# NO_LOG_API_KEY_IDS=key_abc123,key_def456
# Maximum request body size in bytes (rejects larger payloads).
# Used by: src/shared/middleware/bodySizeGuard.ts — prevents oversized uploads.
# Default: 10485760 (10 MB)
# MAX_BODY_SIZE_BYTES=10485760
# CORS configuration — controls which origins can call the API.
# Used by: Next.js middleware — sets Access-Control-Allow-Origin header.
# Default: * (all origins) | Restrict for production security.
# CORS_ORIGIN=https://your-domain.com
# ═══════════════════════════════════════════════════════════════════════════════
# 5. INPUT SANITIZATION & PII PROTECTION (FASE-01)
# ═══════════════════════════════════════════════════════════════════════════════
# Multi-layer defense: request-side injection guard + response-side PII sanitizer.
# ── Request-Side: Prompt Injection Guard ──
# Scans incoming messages for prompt injection patterns before routing.
# Used by: src/middleware/promptInjectionGuard.ts
# INPUT_SANITIZER_ENABLED=true
# INPUT_SANITIZER_MODE=warn # warn | block | redact
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = strip patterns
# Legacy alias for INPUT_SANITIZER_MODE (same effect).
# INJECTION_GUARD_MODE=warn
# PII detection in incoming requests (emails, phone numbers, SSNs, etc.).
# Used by: src/middleware/promptInjectionGuard.ts — extends injection guard.
# PII_REDACTION_ENABLED=false
# Cloud sync variables
# Must point to this running instance so internal sync jobs can call /api/sync/cloud.
# Server-side preferred variables:
# ── Response-Side: PII Sanitizer ──
# Scans LLM responses for leaked PII before returning to the client.
# Used by: src/lib/piiSanitizer.ts
# PII_RESPONSE_SANITIZATION=false
# PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response
# ═══════════════════════════════════════════════════════════════════════════════
# 6. TOOL & ROUTING POLICIES
# ═══════════════════════════════════════════════════════════════════════════════
# Tool policy mode — controls which tools LLMs can invoke via function calling.
# Used by: src/lib/toolPolicy.ts — enforces allowlist/denylist on tool_choice.
# Values: allowlist | denylist | disabled | Default: disabled
# TOOL_POLICY_MODE=disabled
# ═══════════════════════════════════════════════════════════════════════════════
# 7. URLS & CLOUD SYNC
# ═══════════════════════════════════════════════════════════════════════════════
# URLs used for internal sync jobs, OAuth callbacks, and cloud relay.
# Internal base URL — used by server-side sync jobs to call /api/sync/cloud.
# Used by: src/lib/cloudSync.ts, src/lib/initCloudSync.ts
# Default: http://localhost:20128
BASE_URL=http://localhost:20128
# Cloud relay URL — premium feature for remote config sync.
# Used by: src/lib/cloudSync.ts — pushes/pulls settings from OmniRoute Cloud.
CLOUD_URL=
# Backward-compatible/public variables:
# NEXT_PUBLIC_BASE_URL is also used as the OAuth redirect_uri origin when running behind a
# reverse proxy (e.g., nginx). Set this to your public-facing URL so OAuth callbacks work.
# Example: NEXT_PUBLIC_BASE_URL=https://omniroute.example.com
# Timeout for cloud sync HTTP requests in milliseconds.
# Used by: src/lib/cloudSync.ts — fetchWithTimeout wrapper.
# Default: 12000 (12 seconds)
# CLOUD_SYNC_TIMEOUT_MS=12000
# Public-facing base URL — CRITICAL for reverse proxy / OAuth callback setups.
# Used by: OAuth redirect_uri computation, Dashboard UI links, cloud/model sync.
# Set to your public URL when behind nginx/Caddy (e.g., https://omniroute.example.com).
# Default: http://localhost:20128
NEXT_PUBLIC_BASE_URL=http://localhost:20128
# Public cloud URL — client-side mirror of CLOUD_URL.
NEXT_PUBLIC_CLOUD_URL=
# Optional outbound proxy variables for upstream provider calls
# Lowercase variants are also supported: http_proxy, https_proxy, all_proxy, no_proxy
# SOCKS5 proxy support
# Legacy alias — fallback for NEXT_PUBLIC_BASE_URL in sync schedulers.
# NEXT_PUBLIC_APP_URL=http://localhost:20128
# ═══════════════════════════════════════════════════════════════════════════════
# 8. OUTBOUND PROXY (Upstream Provider Calls)
# ═══════════════════════════════════════════════════════════════════════════════
# Route upstream LLM API calls through an HTTP/SOCKS5 proxy.
# Useful for corporate egress, geo-routing, or IP masking.
# Enable SOCKS5 proxy support in both server and client components.
# Used by: open-sse/executors — wraps fetch() calls through the proxy agent.
ENABLE_SOCKS5_PROXY=true
NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Standard proxy variables (lowercase variants also supported).
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
# ALL_PROXY=socks5://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1
# 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)
# Requires wreq-js to be installed (included in dependencies)
# 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.
# ENABLE_TLS_FINGERPRINT=true
# Optional CLI runtime overrides (Docker/host integration)
# ═══════════════════════════════════════════════════════════════════════════════
# 9. CLI TOOL INTEGRATION
# ═══════════════════════════════════════════════════════════════════════════════
# Control how OmniRoute discovers and launches CLI sidecars (Claude, Codex, etc.).
# Used by: src/shared/services/cliRuntime.ts
# CLI discovery mode: auto = search PATH | manual = use explicit paths below.
# CLI_MODE=auto
# CLI_EXTRA_PATHS=/host-cli/bin
# Additional PATH entries for finding CLI binaries (colon-separated).
# CLI_EXTRA_PATHS=/host-cli/bin:/usr/local/bin
# Home directory override for reading CLI config files (~/.claude, etc.).
# CLI_CONFIG_HOME=/root
# Allow OmniRoute to write CLI config files (token refresh, etc.).
# CLI_ALLOW_CONFIG_WRITES=true
# Override binary paths for individual CLI tools.
# CLI_CLAUDE_BIN=claude
# CLI_CODEX_BIN=codex
# CLI_DROID_BIN=droid
# CLI_OPENCLAW_BIN=openclaw
# CLI_CURSOR_BIN=agent
# CLI_CLINE_BIN=cline
# CLI_ROO_BIN=roo
# CLI_CONTINUE_BIN=cn
# CLI_QODER_BIN=qoder
# Internal agent / tool integrations (optional)
# Used by the MCP server, A2A skills, and CLI sidecars when they need to call
# the running OmniRoute instance explicitly instead of relying on localhost.
# ═══════════════════════════════════════════════════════════════════════════════
# 10. INTERNAL AGENT & MCP INTEGRATIONS
# ═══════════════════════════════════════════════════════════════════════════════
# Used by MCP server, A2A skills, and CLI sidecars to call the running instance.
# Explicit base URL for MCP/A2A tools to reach OmniRoute (overrides localhost auto-detect).
# Used by: open-sse/mcp-server/server.ts, src/lib/a2a/
# OMNIROUTE_BASE_URL=http://localhost:20128
# API key for internal tool calls (MCP tools, A2A skills).
# OMNIROUTE_API_KEY=
# API key ID for MCP audit logging.
# Used by: open-sse/mcp-server/audit.ts — tags audit events with a key identity.
# OMNIROUTE_API_KEY_ID=
# Legacy alias for OMNIROUTE_API_KEY.
# ROUTER_API_KEY=
# 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
# Comma-separated scopes granted to this MCP connection.
# Full list: admin, combos, health, models, routing, budget, metrics, pricing, memory, skills
# OMNIROUTE_MCP_SCOPES=admin,combos,health
# Model catalog sync interval in hours.
# Used by: src/shared/services/modelSyncScheduler.ts — periodic model refresh.
# Default: 24
# MODEL_SYNC_INTERVAL_HOURS=24
# ═══════════════════════════════════════════════════
# OAUTH PROVIDER CREDENTIALS
# ═══════════════════════════════════════════════════
# These are the built-in default credentials that work for localhost setups.
# For remote/VPS deployments, register your own credentials at each provider.
# The sync-env script will auto-populate these in your .env if missing.
#
# These can also be overridden via data/provider-credentials.json where supported.
# Provider limits sync interval in minutes (rate limit windows, quotas).
# Used by: src/server-init.ts — polls provider health endpoints.
# Default: 70
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
# Disable all background services (sync, pricing, model refresh).
# Used by: src/instrumentation-node.ts, src/lib/initCloudSync.ts
# Useful for: CI builds, test environments, or resource-constrained containers.
# OMNIROUTE_DISABLE_BACKGROUND_SERVICES=false
# Flag set by bootstrap script after initial setup is complete.
# Used by: src/app/(dashboard)/dashboard/page.tsx — shows setup wizard vs. dashboard.
# OMNIROUTE_BOOTSTRAPPED=false
# Allow request body to override the Antigravity project field.
# Used by: open-sse/executors/antigravity.ts — escape hatch for multi-project setups.
# OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=0
# ═══════════════════════════════════════════════════════════════════════════════
# 11. OAUTH PROVIDER CREDENTIALS
# ═══════════════════════════════════════════════════════════════════════════════
# Built-in default credentials for localhost development.
# For remote/VPS deployments, register your own at each provider's developer console.
# The bootstrap-env script auto-populates these in .env if missing.
# Can also be overridden via data/provider-credentials.json where supported.
# ── Claude Code (Anthropic) ──
CLAUDE_OAUTH_CLIENT_ID=9d1c250a-e61b-44d9-88ed-5944d1962f5e
# Custom redirect URI override for Claude OAuth callback.
# CLAUDE_CODE_REDIRECT_URI=https://platform.claude.com/oauth/code/callback
# ── Codex / OpenAI ──
CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
@@ -137,29 +335,61 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# ── Qoder ──
QODER_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW
# ── Qoder (URLs — set these to enable Qoder OAuth login) ──
# ── Qoder Browser OAuth (experimental) ──
# OmniRoute only enables the browser OAuth flow when ALL 5 variables below are set:
# - QODER_OAUTH_AUTHORIZE_URL
# - QODER_OAUTH_TOKEN_URL
# - QODER_OAUTH_USERINFO_URL
# - QODER_OAUTH_CLIENT_ID
# - QODER_OAUTH_CLIENT_SECRET
#
# Redirect URI to register in the Qoder OAuth app:
# - Localhost dev with PORT=20128: http://localhost:20128/callback
# - LAN access (example): http://192.168.0.15:20128/callback
# - Public domain (recommended): https://omniroute.example.com/callback
#
# Behind reverse proxy / public domain, also set NEXT_PUBLIC_BASE_URL to the same public origin.
# If these values are not available, prefer QODER_PERSONAL_ACCESS_TOKEN below.
# QODER_OAUTH_AUTHORIZE_URL=
# QODER_OAUTH_TOKEN_URL=
# QODER_OAUTH_USERINFO_URL=
# QODER_OAUTH_CLIENT_ID=
# ── Qoder Personal Access Token (direct API key fallback) ──
# Used by: open-sse/executors/qoder.ts — bypasses OAuth when set.
# QODER_PERSONAL_ACCESS_TOKEN=
# QODER_CLI_WORKSPACE=
# OMNIROUTE_QODER_WORKSPACE=
# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity, Gemini CLI) — IMPORTANT FOR REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
# The credentials above ONLY work when OmniRoute runs on localhost.
# If you are hosting OmniRoute on a remote server, register your own:
# For remote hosting:
# 1. Go to https://console.cloud.google.com/apis/credentials
# 2. Create an OAuth 2.0 Client ID (type: "Web application")
# 3. Add your server URL as Authorized redirect URI
# 4. Replace the values above with your credentials.
# ─────────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# Provider User-Agent Overrides (optional — customize per-provider UA headers)
# ─────────────────────────────────────────────────────────────────────────────
# ── OAuth sidecar/CLI bridge (internal) ──
# Used by: src/lib/oauth/config/index.ts — internal CLI↔OmniRoute auth bridge.
# OMNIROUTE_SERVER=http://localhost:20128
# OMNIROUTE_TOKEN=
# OMNIROUTE_USER_ID=cli
# CLI_TOKEN= # legacy alias for OMNIROUTE_TOKEN
# CLI_USER_ID= # legacy alias for OMNIROUTE_USER_ID
# SERVER_URL= # legacy alias for OMNIROUTE_SERVER
# ═══════════════════════════════════════════════════════════════════════════════
# 12. PROVIDER USER-AGENT OVERRIDES
# ═══════════════════════════════════════════════════════════════════════════════
# Customize the User-Agent header sent to each upstream provider.
# Format: {PROVIDER_ID}_USER_AGENT=custom-value
# When set, overrides the default User-Agent header sent to that provider.
# Useful when providers update versions or block old user-agents.
# 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/1.0.83 (external, cli)
CODEX_USER_AGENT=codex-cli/0.92.0 (Windows 10.0.26100; x64)
GITHUB_USER_AGENT=GitHubCopilotChat/0.26.7
@@ -170,13 +400,15 @@ QWEN_USER_AGENT=QwenCode/0.12.3 (linux; x64)
CURSOR_USER_AGENT=connect-es/1.6.1
GEMINI_CLI_USER_AGENT=google-api-nodejs-client/9.15.1
# ─────────────────────────────────────────────────────────────────────────────
# CLI Fingerprint Compatibility (optional — match native CLI binary signatures)
# ─────────────────────────────────────────────────────────────────────────────
# ═══════════════════════════════════════════════════════════════════════════════
# 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection)
# ═══════════════════════════════════════════════════════════════════════════════
# When enabled, OmniRoute reorders HTTP headers and JSON body fields to match
# the exact signature of official CLI tools, reducing account flagging risk.
# Your proxy IP is preserved — you get both stealth AND IP masking.
#
# Used by: open-sse/config/cliFingerprints.ts, open-sse/executors/base.ts
# Enable per-provider:
# CLI_COMPAT_CODEX=1
# CLI_COMPAT_CLAUDE=1
@@ -188,12 +420,18 @@ GEMINI_CLI_USER_AGENT=google-api-nodejs-client/9.15.1
# CLI_COMPAT_KILOCODE=1
# CLI_COMPAT_CLINE=1
# CLI_COMPAT_QWEN=1
#
# Or enable for all providers at once:
# CLI_COMPAT_ALL=1
# API Key Providers (Phase 1 + Phase 4)
# Add via Dashboard → Providers → Add API Key, or set here
# ═══════════════════════════════════════════════════════════════════════════════
# 14. API KEY PROVIDERS
# ═══════════════════════════════════════════════════════════════════════════════
# API keys for direct-authentication providers.
# Preferred setup: Dashboard → Providers → Add API Key.
# Setting here is an alternative for Docker/headless deployments.
# DEEPSEEK_API_KEY=
# GROQ_API_KEY=
# XAI_API_KEY=
@@ -207,54 +445,260 @@ GEMINI_CLI_USER_AGENT=google-api-nodejs-client/9.15.1
# Embedding Providers (optional — used by /v1/embeddings)
# NEBIUS_API_KEY=
# Provider keys above (openai, mistral, together, fireworks, nvidia) also work for embeddings
# Provider keys above (OpenAI, Mistral, Together, Fireworks, NVIDIA) also work for embeddings.
# Timeout settings
# REQUEST_TIMEOUT_MS=600000
# STREAM_IDLE_TIMEOUT_MS=600000
# Advanced timeout overrides (optional)
# FETCH_TIMEOUT_MS=600000
# FETCH_HEADERS_TIMEOUT_MS=600000
# FETCH_BODY_TIMEOUT_MS=600000
# FETCH_CONNECT_TIMEOUT_MS=30000
# FETCH_KEEPALIVE_TIMEOUT_MS=4000
# TLS_CLIENT_TIMEOUT_MS=600000
# API bridge timeout for /v1 proxy requests (default: 30000)
# API_BRIDGE_PROXY_TIMEOUT_MS=600000
# API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS=600000
# API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS=60000
# API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS=5000
# API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS=0
# CORS configuration (default: * allows all origins)
# CORS_ORIGIN=*
# ═══════════════════════════════════════════════════════════════════════════════
# 15. TIMEOUT SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
# All timeout values are in milliseconds.
# 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.
# The fine-grained variables below override their respective defaults only when set.
# Logging
# ── Global shortcut ──
# REQUEST_TIMEOUT_MS=600000 # Overrides both fetch and stream idle defaults
# ── Upstream fetch (provider calls) ──
# FETCH_TIMEOUT_MS=600000 # Total request timeout (default: 600000 = 10 min)
# # Also drives anthropic-compatible-cc-* X-Stainless-Timeout.
# FETCH_HEADERS_TIMEOUT_MS=600000 # Time to receive response headers
# FETCH_BODY_TIMEOUT_MS=600000 # Time to receive full response body
# FETCH_CONNECT_TIMEOUT_MS=30000 # TCP connection establishment (default: 30s)
# FETCH_KEEPALIVE_TIMEOUT_MS=4000 # Keep-alive socket idle timeout (default: 4s)
# ── Stream idle detection ──
# STREAM_IDLE_TIMEOUT_MS=600000 # Max silence between SSE chunks (default: 600000)
# # Extended-thinking models rarely pause >90s.
# ── TLS client (wreq-js fingerprint proxy) ──
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
# ── API Bridge (/v1 proxy server) ──
# API_BRIDGE_PROXY_TIMEOUT_MS=30000 # Proxy hop timeout (default: 30s)
# API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS=300000 # Overall server request timeout
# API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS=60000 # Time to send response headers
# API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS=5000 # Keep-alive idle timeout
# API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS=0 # Raw socket timeout (0 = disabled)
# ── Graceful shutdown ──
# Time to wait for in-flight requests before force-exiting on SIGTERM/SIGINT.
# Used by: src/lib/gracefulShutdown.ts
# Default: 30000 (30 seconds)
# SHUTDOWN_TIMEOUT_MS=30000
# ═══════════════════════════════════════════════════════════════════════════════
# 16. LOGGING
# ═══════════════════════════════════════════════════════════════════════════════
# Used by: src/lib/logEnv.ts, src/lib/logRotation.ts, src/shared/utils/logger.ts
# Application log level — controls console and file log verbosity.
# Values: debug | info | warn | error | Default: info
# APP_LOG_LEVEL=info
# Log output format.
# Values: text | json | Default: text
# APP_LOG_FORMAT=text
# Write logs to file in addition to stdout.
# Default: true | Set false to disable file logging.
APP_LOG_TO_FILE=true
# Path to the application log file.
# Default: logs/application/app.log (relative to project root / DATA_DIR)
# APP_LOG_FILE_PATH=logs/application/app.log
# Maximum single log file size before rotation.
# Accepts: plain bytes or suffixed (50M, 1G, 512K). Default: 50M
# APP_LOG_MAX_FILE_SIZE=50M
# Days to keep rotated application log files before auto-deletion.
# Default: 7
# APP_LOG_RETENTION_DAYS=7
# Maximum number of rotated log file backups to keep.
# Default: 20
# APP_LOG_MAX_FILES=20
# Days to keep request/call log entries in the database before auto-cleanup.
# Default: 7
# CALL_LOG_RETENTION_DAYS=7
# Maximum call log entries stored in-memory buffer.
# Default: 10000
# CALL_LOG_MAX_ENTRIES=10000
# ─────────────────────────────────────────────────────────────────────────────
# Memory Optimization (Low-RAM configurations)
# ─────────────────────────────────────────────────────────────────────────────
# Node.js heap limit in MB (default: 256 for Docker, system default for npm)
# Maximum rows in the call_logs SQLite table before oldest entries are pruned.
# Default: 100000
# CALL_LOGS_TABLE_MAX_ROWS=100000
# Maximum rows in the proxy_logs SQLite table.
# Default: 100000
# PROXY_LOGS_TABLE_MAX_ROWS=100000
# ═══════════════════════════════════════════════════════════════════════════════
# 17. MEMORY OPTIMIZATION (Low-RAM / Docker)
# ═══════════════════════════════════════════════════════════════════════════════
# Node.js V8 heap limit in MB.
# Used by: Docker entrypoint — sets --max-old-space-size.
# Default: 256 (Docker) | system default (npm)
# OMNIROUTE_MEMORY_MB=256
# Prompt cache settings
# PROMPT_CACHE_MAX_SIZE=50
# PROMPT_CACHE_MAX_BYTES=2097152
# PROMPT_CACHE_TTL_MS=300000
# ── Prompt cache (system prompt deduplication) ──
# Used by: open-sse/services — caches identical system prompts across requests.
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)
# PROMPT_CACHE_MAX_BYTES=2097152 # Max total cache size in bytes (default: 2 MB)
# PROMPT_CACHE_TTL_MS=300000 # Cache entry TTL (default: 5 minutes)
# Semantic cache settings (temperature=0 responses)
# SEMANTIC_CACHE_MAX_SIZE=100
# SEMANTIC_CACHE_MAX_BYTES=4194304
# SEMANTIC_CACHE_TTL_MS=1800000
# ── Semantic cache (deterministic response dedup, temperature=0) ──
# Used by: open-sse/services — caches identical temperature=0 responses.
# SEMANTIC_CACHE_MAX_SIZE=100 # Max cached entries (default: 100)
# SEMANTIC_CACHE_MAX_BYTES=4194304 # Max total cache size in bytes (default: 4 MB)
# SEMANTIC_CACHE_TTL_MS=1800000 # Cache entry TTL (default: 30 minutes)
# In-memory log buffers
# ── In-memory log buffers ──
# Maximum recent stream events kept in memory for the Dashboard live view.
# STREAM_HISTORY_MAX=50
# ── Context length default ──
# Global fallback max context length for models without explicit config.
# Used by: open-sse/services/contextManager.ts
# CONTEXT_LENGTH_DEFAULT=128000
# ── Usage token buffer ──
# Extra token headroom reserved when tracking usage quotas (prevents over-limit).
# Used by: open-sse/utils/usageTracking.ts
# USAGE_TOKEN_BUFFER=100
# ═══════════════════════════════════════════════════════════════════════════════
# 18. PRICING SYNC
# ═══════════════════════════════════════════════════════════════════════════════
# Automatic model pricing synchronization from external sources.
# Used by: src/lib/pricingSync.ts
# Enable periodic pricing data sync. Default: false (opt-in only).
# PRICING_SYNC_ENABLED=false
# Sync interval in seconds. Default: 86400 (24 hours).
# PRICING_SYNC_INTERVAL=86400
# Comma-separated data sources. Default: litellm
# PRICING_SYNC_SOURCES=litellm
# ═══════════════════════════════════════════════════════════════════════════════
# 19. MODEL SYNC (Dev)
# ═══════════════════════════════════════════════════════════════════════════════
# Development-time model catalog sync interval in seconds.
# Used by: src/lib/modelsDevSync.ts
# Default: 86400 (24 hours)
# MODELS_DEV_SYNC_INTERVAL=86400
# ═══════════════════════════════════════════════════════════════════════════════
# 20. PROVIDER-SPECIFIC SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
# ── OpenRouter ──
# OpenRouter model catalog cache TTL in ms.
# Used by: src/lib/catalog/openrouterCatalog.ts
# Default: 86400000 (24 hours)
# OPENROUTER_CATALOG_TTL_MS=86400000
# ── NanoBanana (Image Generation) ──
# Polling config for async image generation jobs.
# Used by: open-sse/handlers/imageGeneration.ts
# NANOBANANA_POLL_TIMEOUT_MS=120000 # Max wait for job completion (default: 120s)
# NANOBANANA_POLL_INTERVAL_MS=2500 # Poll frequency (default: 2.5s)
# ── Cloudflare Workers AI ──
# Account ID override for Cloudflare Workers AI executor.
# Used by: open-sse/executors/cloudflare-ai.ts
# CLOUDFLARE_ACCOUNT_ID=
# ── Cloudflare Tunnel (cloudflared) ──
# Custom path to cloudflared binary for tunnel management.
# Used by: src/lib/cloudflaredTunnel.ts
# CLOUDFLARED_BIN=/usr/local/bin/cloudflared
# ── Search cache ──
# TTL for search API response caching (Perplexity, Brave, etc.).
# Used by: open-sse/services/searchCache.ts
# Default: 300000 (5 minutes)
# SEARCH_CACHE_TTL_MS=300000
# ── OpenAI-compatible multi-connection ──
# Allow multiple simultaneous connections per OpenAI-compatible provider node.
# Used by: src/app/api/providers/route.ts
# ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE=false
# ── CC-compatible provider (experimental) ──
# Enable the Claude Code compatible provider endpoint.
# Used by: src/shared/utils/featureFlags.ts
# ENABLE_CC_COMPATIBLE_PROVIDER=false
# ── CLIProxyAPI bridge (legacy) ──
# Connection settings for external CLIProxyAPI instances.
# Used by: open-sse/executors/cliproxyapi.ts
# CLIPROXYAPI_HOST=127.0.0.1
# CLIPROXYAPI_PORT=5544
# CLIPROXYAPI_CONFIG_DIR=~/.cli-proxy-api
# ── Local hostnames (Docker networking) ──
# Comma-separated additional hostnames treated as "local" for provider routing.
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
# LOCAL_HOSTNAMES=omlx,mlx-audio
# ═══════════════════════════════════════════════════════════════════════════════
# 21. PROXY HEALTH
# ═══════════════════════════════════════════════════════════════════════════════
# Fine-tune proxy health checking behavior.
# Used by: src/lib/proxyHealth.ts
# Timeout for fast-fail health checks (ms). Default: 2000
# PROXY_FAST_FAIL_TIMEOUT_MS=2000
# Health check result cache TTL (ms). Default: 30000 (30s)
# PROXY_HEALTH_CACHE_TTL_MS=30000
# 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
# ═══════════════════════════════════════════════════════════════════════════════
# 22. DEBUGGING
# ═══════════════════════════════════════════════════════════════════════════════
# These variables enable verbose debugging output. NEVER enable in production.
# Dump Cursor protobuf decode/encode details to console.
# CURSOR_PROTOBUF_DEBUG=1
# Dump raw Cursor SSE stream data to console.
# CURSOR_STREAM_DEBUG=1
# Log Responses API SSE-to-JSON translation details.
# DEBUG_RESPONSES_SSE_TO_JSON=true
# Enable E2E test mode — relaxes auth and enables test harness hooks.
# NEXT_PUBLIC_OMNIROUTE_E2E_MODE=true
# ═══════════════════════════════════════════════════════════════════════════════
# 23. GITHUB INTEGRATION (Issue Reporting)
# ═══════════════════════════════════════════════════════════════════════════════
# Allow users to report issues directly from the Dashboard to GitHub.
# Used by: src/app/api/v1/issues/report/route.ts
# GitHub repository in owner/repo format.
# GITHUB_ISSUES_REPO=owner/repo
# GitHub Personal Access Token with issues:write scope.
# GITHUB_ISSUES_TOKEN=ghp_xxxx

View File

@@ -124,6 +124,7 @@ jobs:
run: npx is-my-node-vulnerable || true
- name: Run Snyk Vulnerability checks
if: github.actor != 'dependabot[bot]'
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
@@ -133,14 +134,11 @@ jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
@@ -150,9 +148,6 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
matrix:
node-version: [20, 22]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
@@ -160,7 +155,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:unit
@@ -222,7 +217,7 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
name: coverage-report
path: .
@@ -252,7 +247,7 @@ jobs:
- name: Download coverage artifact
if: ${{ needs.test-coverage.result != 'cancelled' }}
continue-on-error: true
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: coverage-report
path: .
@@ -393,7 +388,7 @@ jobs:
steps:
- name: Download i18n results
continue-on-error: true
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: i18n-*
path: results

View File

@@ -61,7 +61,7 @@ jobs:
echo "Publishing Docker image: $IMAGE_NAME:$VERSION"
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
target: runner-base

4
.gitignore vendored
View File

@@ -99,6 +99,10 @@ docs/*
!docs/MCP-SERVER.md
!docs/CLI-TOOLS.md
!docs/COVERAGE_PLAN.md
!docs/ENVIRONMENT.md
!docs/UNINSTALL.md
!docs/I18N.md
!docs/FLY_IO_DEPLOYMENT_GUIDE.md
# open-sse tests

View File

@@ -28,6 +28,8 @@ scripts/
.vscode/
.agents/
.env*
app/.env
app/.env*
eslint.config.mjs
prettier.config.mjs
postcss.config.mjs
@@ -67,3 +69,6 @@ clipr/
omnirouteCloud/
omnirouteSite/
vscode-extension/
# Root-level underscore-prefixed directories (private/draft — never publish)
/_*/

View File

@@ -16,5 +16,6 @@
"javascript:S3776": {
"level": "off"
}
}
},
"git.ignoreLimitWarning": true
}

161
AGENTS.md
View File

@@ -64,16 +64,11 @@ npm run test:protocols:e2e
# Ecosystem compatibility tests
npm run test:ecosystem
# Coverage (60% minimum for statements, lines, functions, and branches)
# Coverage (see CONTRIBUTING.md)
npm run test:coverage
```
### PR Coverage Policy
- `npm run test:coverage` is the PR coverage gate in CI.
- The repository minimum is **60%** for statements, lines, functions, and branches.
- If a PR changes production code in `src/`, `open-sse/`, `electron/`, or `bin/`, it must include or update automated tests in the same PR.
- For agent-driven review or coding flows: if coverage is below the gate or source changes ship without tests, do not stop at reporting. Add or update tests first, rerun the gate, and only then ask for confirmation.
**For authoritative coverage requirements, test execution, and PR gates, see [`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).**
---
@@ -141,9 +136,69 @@ All persistence uses SQLite through domain-specific modules:
Schema migrations live in `db/migrations/` and run via `migrationRunner.ts`.
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
#### DB Internals
- **`core.ts`**: `getDbInstance()` returns a singleton `better-sqlite3` instance with WAL
journaling. `SCHEMA_SQL` defines 15 base tables. Helpers: `rowToCamel`, `encryptConnectionFields`.
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
Tracks applied migrations in `_omniroute_migrations` table.
- **Migrations**: 21 files (`001_initial_schema.sql``021_combo_call_log_targets.sql`).
Each migration is idempotent and runs in a transaction.
- **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`,
`combos.ts``combos`). Encryption helpers protect sensitive fields at rest.
- **`localDb.ts`** re-exports all domain modules — consumers import from here for convenience.
### API Route Layer (`src/app/api/v1/`)
Next.js App Router routes — each follows a consistent pattern:
```
Route → CORS preflight → Body validation (Zod) → Optional auth (extractApiKey/isValidApiKey)
→ API key policy enforcement (enforceApiKeyPolicy) → Handler delegation (open-sse)
```
| Route | Handler | Notes |
| ------------------------------- | ------------------------- | ----------------------------------------- |
| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) |
| `responses/route.ts` | `handleChat()` (unified) | Responses API format |
| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation |
| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation |
| `audio/transcriptions/route.ts` | audio handler | Multipart form data |
| `audio/speech/route.ts` | TTS handler | Binary audio response |
| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI |
| `music/generations/route.ts` | music handler | ComfyUI workflows |
| `moderations/route.ts` | moderation handler | Content safety |
| `rerank/route.ts` | rerank handler | Document relevance |
| `search/route.ts` | search handler | Web search (5 providers) |
**No global Next.js middleware file** — interception is route-specific. Auth is optional
(controlled by `REQUIRE_API_KEY` env). Prompt injection guard is unique to chat completions.
### Request Pipeline (`open-sse/`)
`chatCore.ts` → executor → upstream provider. Translations in `open-sse/translator/`.
The `open-sse/` workspace is the core streaming engine. Full request flow:
```
Client Request
→ src/app/api/v1/.../route.ts (Next.js route)
→ open-sse/handlers/chatCore.ts::handleChatCore()
→ Semantic/signature cache check
→ Rate limit check (rateLimitManager)
→ Combo routing? → open-sse/services/combo.ts::handleComboChat()
→ resolveComboTargets() → ordered ResolvedComboTarget[]
→ For each target: handleSingleModel() (wraps chatCore)
→ translateRequest() (open-sse/translator/)
→ Convert source format (e.g., OpenAI) → target format (e.g., Claude)
→ getExecutor() → provider-specific executor instance
→ executor.execute() (BaseExecutor → DefaultExecutor or provider-specific)
→ buildUrl() + buildHeaders() + transformRequest()
→ fetch() to upstream provider
→ Retry logic with exponential backoff
→ Response translation back to client format
→ If Responses API: responsesTransformer.ts TransformStream
→ SSE stream or JSON response to client
```
**Handlers** (`open-sse/handlers/`): `chatCore.ts`, `responsesHandler.ts`, `embeddings.ts`,
`imageGeneration.ts`, `videoGeneration.ts`, `musicGeneration.ts`, `audioSpeech.ts`,
@@ -180,15 +235,46 @@ Provider-specific request executors: `base.ts`, `default.ts`, `cursor.ts`, `code
`antigravity.ts`, `github.ts`, `gemini-cli.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`,
`cloudflare-ai.ts`, `opencode.ts`, `pollinations.ts`, `puter.ts`.
#### Executor Internals
- **`base.ts`** (`BaseExecutor`): Abstract base with `buildUrl()`, `buildHeaders()`,
`transformRequest()`, retry logic (exponential backoff), and `execute()`. Subclasses
override URL/header/transform methods for provider-specific behavior.
- **`default.ts`** (`DefaultExecutor extends BaseExecutor`): Handles most OpenAI-compatible
providers. Reads provider config from `providerRegistry.ts` to resolve base URL, auth
header format, and request transformations.
- **`getExecutor()`** (`executors/index.ts`): Factory that returns the correct executor
instance based on provider ID. Provider-specific executors (Cursor, Codex, Vertex, etc.)
override only what differs from the default.
### Translator (`open-sse/translator/`)
Translates between API formats (OpenAI-format ↔ Anthropic, Gemini, etc.).
Includes request/response translators with helpers for image handling.
#### Translator Internals
- **`translator/index.ts`**: Exports `translateRequest()` and format constants. Called by
`chatCore.ts` before executor dispatch.
- **Flow**: `translateRequest(body, sourceFormat, targetFormat)` → detects source format
(OpenAI, Anthropic, Gemini) → applies the matching translator module → returns
transformed body ready for the target provider.
- **Response translation** runs in reverse after upstream response, converting back to
the client's expected format.
### Transformer (`open-sse/transformer/`)
`responsesTransformer.ts` — transforms Responses API format to/from Chat Completions format.
#### Transformer Internals
- **`createResponsesApiTransformStream()`**: Returns a `TransformStream` that converts
Chat Completions SSE chunks (`data: {"choices":[...]}`) into Responses API SSE events
(`response.output_item.added`, `response.output_text.delta`, etc.).
- Used when the client sends a Responses API request: the request is internally converted
to Chat Completions format, dispatched normally, and the response is piped through this
transform stream before reaching the client.
### Services (`open-sse/services/`)
36+ service modules including: `combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
@@ -198,6 +284,17 @@ Includes request/response translators with helpers for image handling.
`emergencyFallback.ts`, `workflowFSM.ts`, `backgroundTaskDetector.ts`, `ipFilter.ts`,
`signatureCache.ts`, `volumeDetector.ts`, `contextHandoff.ts`, and more.
#### Combo Routing Engine (`combo.ts`)
- **`handleComboChat()`**: Entry point for combo-routed requests. Receives the combo config
and iterates through targets in order until one succeeds or all fail.
- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of
`ResolvedComboTarget[]`, each specifying provider + model + account + credentials.
- **Strategies** (13): priority, weighted, fill-first, round-robin, P2C, random, least-used,
cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay.
- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with
per-target error handling and circuit breaker checks.
### Domain Layer (`src/domain/`)
Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
@@ -217,12 +314,37 @@ best_combo_for_task, explain_route, get_session_snapshot, sync_pricing.
**Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions.
#### MCP Internals
- **Tool registration**: Each tool is an object with `{ name, description, inputSchema: ZodSchema,
handler: async (args) => {...} }`. Zod validates inputs before the handler fires.
- **`createMcpServer()`** and **`startMcpStdio()`** exported from `mcp-server/index.ts`.
`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** (10): 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.
### A2A Server (`src/lib/a2a/`)
JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup(
JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup.
Agent Card at `/.well-known/agent.json`.
Skills: `quotaManagement.ts`, `smartRouting.ts`.
#### A2A Internals
- **`taskManager.ts`**: State machine lifecycle for tasks: `submitted → working →
completed | failed | canceled`. Tasks have TTL and are cleaned up automatically.
- **JSON-RPC methods**: `message/send` (sync), `message/stream` (SSE), `tasks/get`,
`tasks/cancel`. Dispatched via `POST /a2a`.
- **Skills**: Registered in a DB-backed registry. Each skill receives task context
(messages, metadata) and returns structured results. `quotaManagement.ts` summarizes
quota; `smartRouting.ts` recommends routing decisions.
- **Agent Card**: `/.well-known/agent.json` exposes capabilities, skills, and metadata
for client auto-discovery.
### ACP Module (`src/lib/acp/`)
Agent Communication Protocol registry and manager.
@@ -237,6 +359,19 @@ conversational memory across sessions.
Extensible skill framework: registry, executor, sandbox, built-in skills,
custom skill support, interception, and injection.
#### Skills Internals
- **`registry.ts`**: DB-backed skill registration and discovery. Skills have metadata
(name, description, version, enabled status) stored in SQLite.
- **`executor.ts`**: Execution engine with configurable timeout and retry logic.
Receives skill name + input, looks up the skill, runs it in the sandbox.
- **`sandbox.ts`**: Isolation layer for custom (user-provided) skills. Limits resource
access and execution time.
- **Built-in skills**: Ship with OmniRoute (e.g., quota management, routing). Located
alongside the registry.
- **Interception/Injection**: Skills can intercept requests in the pipeline (pre/post
processing) or inject context into prompts.
### Compliance (`src/lib/compliance/`)
Policy index for compliance enforcement.
@@ -259,6 +394,14 @@ Request middleware including `promptInjectionGuard.ts`.
---
## 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
---
## Review Focus
- **DB ops** go through `src/lib/db/` modules, never raw SQL in routes

View File

@@ -4,6 +4,160 @@
---
## [3.6.5] — 2026-04-13
### ✨ New Features
- **Antigravity AI Credits Fallback:** Automatically retries with `GOOGLE_ONE_AI` credit injection when free-tier quota is exhausted. Per-account credit balance (5-hour TTL) is cached from SSE `remainingCredits` and exposed as a numeric badge in the Provider Usage dashboard (#1190 — thanks @sFaxsy)
- **Claude Code Native Parity:** Full header/body signing parity with the Claude Code 2.1.87 OAuth client — CCH xxHash64 body signing with singleton WASM initialization promise (fixing race conditions), dynamic per-request fingerprint, bidirectional TitleCase ↔ lowercase tool name remapping (14 tools), API constraint enforcement (`temperature=1` for thinking, max 4 `cache_control` blocks, auto-inject ephemeral on last user message), and optional ZWJ obfuscation. Wired into `BaseExecutor` for automatic CCH signing on all `anthropic-compatible-cc-*` providers and into `chatCore` for synchronous parity pipeline steps (#1188 — thanks @RaviTharuma)
- **Per-Connection Codex Defaults:** Codex Fast Service Tier and Reasoning Effort settings are now per-connection instead of a single global toggle. Existing connections are migrated automatically on startup via an idempotent backfill migration (#1176 — thanks @rdself)
- **Cursor Usage Dashboard:** New `getCursorUsage()` fetches quotas from Cursor's `/api/usage`, `/api/auth/me`, and `/api/subscription` endpoints. Displays standard requests, on-demand usage, and per-plan limits (Free/Pro/Business/Team). Client version bumped to `3.1.0` and `x-cursor-user-agent` header added for parity
- **Database Health Check System:** Automated periodic SQLite integrity monitoring via `runDbHealthCheck()` — detects orphan quota/domain rows, broken combo references, stale snapshots, and invalid JSON state. Runs every 6 hours (configurable via `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS`), with auto-repair and pre-repair backup. Exposed as **MCP tool #18** (`omniroute_db_health_check`) with Zod schemas and `autoRepair` option. Dashboard panel in Health page with status card, issue count, repaired count, and one-click repair button
- **OpenAI Responses API Store Opt-In:** Per-connection `openaiStoreEnabled` flag controls whether the `store` field is preserved or forced to `false` on Codex Responses API requests. When enabled, `previous_response_id`, `prompt_cache_key`, `session_id`, and `conversation_id` fields are round-tripped through the Chat Completions → Responses translation, enabling multi-turn context caching on supported providers
- **Email Privacy Toggle (Combos Page):** Global email visibility toggle (`EmailPrivacyToggle`) added to the Combos page header with responsive layout, tooltip guidance, and per-connection label masking via `pickDisplayValue()`. All combo builder options, provider connection lists, and quota screens now respect the global privacy state from `emailPrivacyStore`
- **skills.sh Integration:** Added `skills.sh` as an external skill provider. Users can now search, browse, and install agent skills directly from a new "skills.sh" tab in the Skills dashboard. Includes backend API resolvers, frontend implementation with search/install states, and a dedicated unit test suite (#1223 — thanks @RaviTharuma)
- **Stabilization Settings:** Added persistence support for `lkgpEnabled` and `backgroundDegradation` settings, integrated into `instrumentation-node.ts` for improved lifecycle awareness (#1212)
- **xxhash-wasm dependency:** Added `xxhash-wasm@^1.1.0` for CCH signing (xxHash64 with seed `0x6E52736AC806831E`)
### 🐛 Bug Fixes
- **Codex `stream: false` via Combo (ALL_ACCOUNTS_INACTIVE):** Fixed a critical bug where Codex combos returned `ALL_ACCOUNTS_INACTIVE` or empty content when the client sent `stream: false`. Root cause was triple: (1) `CodexExecutor.transformRequest()` mutated `body.stream` in-place to `true`, contaminating the combo's quality check which skipped validation thinking it was streaming; (2) the non-stream SSE parser used the wrong format (Chat Completions instead of Responses API) for Codex SSE output; (3) combo quality validation read the mutated `body.stream` instead of the client's original intent. Fixed by: cloning the body via `structuredClone()` in CodexExecutor, detecting Codex/Responses SSE format in the non-stream fallback path (with auto-translation back to Chat Completions), and capturing `clientRequestedStream` before the combo loop
- **Gemini CLI Tool Schema Rejection:** Fixed 400 Bad Request errors from the Google API by strictly filtering non-standard vendor extensions (starting with `x-`) and `deprecated` fields from tool parameter schemas (#1206)
- **SOCKS5 Proxy Interop (Node.js 22):** Resolved `invalid onRequestStart method` crashes caused by `undici` version mismatches between dispatchers and the built-in fetch. Hardened `proxyFetch.ts` to strictly use the library's fetch implementation for custom dispatchers (#1219)
- **Search Cache Coalescing with TTL=0:** Fixed a bug where providers configured with `cacheTTLMs: 0` (caching explicitly disabled) still had concurrent requests coalesced and returned `{ cached: true }`. Now each call gets its own independent upstream fetch (#1178 — thanks @sjhddh)
- **Antigravity Credit Cache Alignment (PR #1190):** Reconciled `accountId` derivation between `AntigravityExecutor.collectStreamToResponse` and `getAntigravityUsage` to use consistent cache keys (`email || sub || "unknown"`). Previously, SSE-parsed credit balances could be written under a different key than the one read by the usage dashboard, causing stale/missing credit badges
- **Non-streaming reasoning_content Duplication:** Fixed clients rendering duplicated reasoning panels when both `reasoning_content` and visible `content` were present in non-streaming responses. `responseSanitizer` now strips `reasoning_content` from messages that already have visible text content, preserving it only for reasoning-only messages
- **Streaming Regression Fix:** Hardened the `sanitize` TransformStream in the combo engine to strip both literal and JSON-escaped newline sequences, eliminating leading `\n\n` prefixes in assistant responses (#1211)
- **Gemini Empty Choice Fix:** Ensured initial assistant deltas always include an empty `content: ""` string to satisfy strict OpenAI client requirements and prevent empty choice responses in tools (#1209)
- **Gemini Tools Sanitizer Deduplication:** Extracted shared tool conversion logic into `buildGeminiTools()` helper (`geminiToolsSanitizer.ts`), eliminating duplicate implementations between `openai-to-gemini.ts` and `claude-to-gemini.ts`. The new helper correctly handles `web_search` / `web_search_preview` tool types by emitting `googleSearch` tools with priority over function declarations
- **Qwen/Qoder Thinking+Tool_Choice Conflict:** Added `sanitizeQwenThinkingToolChoice()` to both `DefaultExecutor` (for Qwen provider) and `QoderExecutor` to prevent provider-side 400 errors when clients send `tool_choice` alongside thinking/reasoning parameters that are mutually exclusive upstream
- **API Key Deletion Orphan Cleanup:** Deleting an API key now also removes associated `domain_budgets` and `domain_cost_history` rows, preventing orphan data accumulation
- **CC-compatible test assertion:** Fixed pre-existing test that expected no `cache_control` on system blocks — the billing header system block now carries `cache_control: { type: "ephemeral" }` per PR #1188 design
- **Codex Combo Smoke Test False Positives:** Fixed combo tests incorrectly reporting `ERROR` for valid Codex streaming responses when `response.output` is empty but text deltas were emitted. The summary now falls back to accumulated delta text (#1176 — thanks @rdself)
- **Electron Builder Version Mismatch:** Fixed Electron desktop startup failures on Windows packaged builds caused by native modules (`better-sqlite3`) being under `app.asar.unpacked` while helpers were in `app/node_modules`. `resolveServerNodePath()` now merges both locations with deduplication and existence checks (#1172 — thanks @backryun)
### 🔧 Internal Improvements
- **SSE Parser: Responses API Non-Stream Conversion:** Added full `parseSSEToResponsesOutput()` implementation in `sseParser.ts` (255+ lines) — reconstructs complete Responses API objects from SSE event streams, handling `response.output_text.delta/done`, `response.reasoning_summary_text.delta/done`, `response.function_call_arguments.delta/done`, and terminal events. Used by the new chatCore non-stream fallback path for Codex
- **Cursor Executor Version Sync:** Updated Cursor client User-Agent to `3.1.0` and centralized version constants (`CURSOR_CLIENT_VERSION`, `CURSOR_USER_AGENT`) for consistent fingerprinting across executor, usage fetcher, and OAuth flows
- **Responses API Translator Parity:** `convertResponsesApiFormat()` now accepts credentials and passes them through to the translator, enabling store-aware field propagation. Round-trip preservation of `previous_response_id`, `prompt_cache_key`, `session_id`, and `conversation_id` fields
- **Provider Schema Validation:** Added `openaiStoreEnabled` boolean validation to `providerSpecificData` Zod schema
- **Combo Error Response Normalization:** Empty combo targets now return 404 (`comboModelNotFoundResponse`) instead of generic 503, improving client-side error differentiation
- **Dependency Updates:** Bumps `typescript-eslint` to `8.58.2` (dev), `axios` to `1.15.0` (prod), and `next` to `16.2.2` (prod) (#1224, #1225)
### ⚠️ Breaking Changes
- **`DELETE /api/settings/codex-service-tier` removed:** This endpoint no longer exists. Codex Service Tier configuration has moved to per-connection `providerSpecificData.requestDefaults`. Existing connections are migrated automatically on first startup after upgrade. Any external scripts or integrations that call this endpoint should be updated — use `PUT /api/providers/:id` with `providerSpecificData.requestDefaults.serviceTier` instead (#1176).
- **CCH signing on CC-compatible providers:** All requests to `anthropic-compatible-cc-*` providers now include an xxHash64 integrity token (`cch=...`) in the billing header. Providers that do not validate CCH will ignore it (no behavioral change), but any custom middleware inspecting the billing header should expect a 5-character hex token instead of the `00000` placeholder
---
## [3.6.4] — 2026-04-12
### ✨ New Features
- **Combo Builder v2 (Wizard UI):** Completely redesigned the combo creation/editing interface as a multi-stage wizard with stages: Basics → Steps → Strategy → Review. The builder fetches provider, model, and connection metadata via a new `GET /api/combos/builder/options` endpoint, enabling precise provider/model/account selection with duplicate detection and automatic next-connection suggestion. Heavy UI components (`ModelSelectModal`, `ProxyConfigModal`, `ModelRoutingSection`) are now lazily loaded via `next/dynamic` for faster initial page render
- **Combo Step Architecture (Schema v2):** Introduced a structured step model (`ComboModelStep`, `ComboRefStep`) replacing the legacy flat string/object combo entries. Steps carry explicit `id`, `kind`, `providerId`, `connectionId`, `weight`, and `label` fields, enabling pinned-account routing, cross-combo references, and per-step metrics. All combo CRUD operations normalize entries through the new `src/lib/combos/steps.ts` module. Zod schemas updated with `comboModelStepInputSchema` and `comboRefStepInputSchema` unions
- **Composite Tiers System:** Added tiered model routing via `config.compositeTiers` — each tier maps a named stage to a specific combo step with optional fallback chains. Includes comprehensive validation (`src/lib/combos/compositeTiers.ts`) ensuring step existence, preventing circular fallback, and validating default tier references. Zod schema enforcement blocks composite tiers on global defaults (concrete combos only)
- **Model Capabilities Registry:** Created `src/lib/modelCapabilities.ts` providing `getResolvedModelCapabilities()` — a unified resolver that merges static specs, provider registry data, and live-synced capabilities into a single `ResolvedModelCapabilities` object covering tool calling, reasoning, vision, context window, thinking budget, modalities, and model lifecycle metadata
- **Observability Module:** Extracted health and telemetry payload construction into `src/lib/monitoring/observability.ts` with `buildHealthPayload()`, `buildTelemetryPayload()`, and `buildSessionsSummary()` builders. The health endpoint now returns session activity, quota monitor status, and per-provider breakdowns alongside existing system metrics
- **Session & Quota Monitor Dashboard:** Added live Session Activity and Quota Monitors panels to the Health dashboard, showing active session counts, sticky-bound sessions, per-API-key breakdowns, and top session details alongside quota monitor alerting/exhausted/error status with per-provider drill-down
- **Combo Health Per-Target Analytics:** The combo-health API now resolves per-target metrics using the new `resolveNestedComboTargets()` function, providing step-level success rates, latency, and historical usage breakdowns per execution key — enabling per-account, per-connection health visibility
- **Auto-Combo → Combos Unification:** Merged the separate `/dashboard/auto-combo` page into the main `/dashboard/combos` page. Auto/LKGP combos are now managed alongside all other combos with a new strategy filter tabs system (All / Intelligent / Deterministic). The old auto-combo route redirects to `/dashboard/combos?filter=intelligent`. Removed the `auto-combo` sidebar entry, consolidating navigation into the single `Combos` item
- **Intelligent Routing Panel (`IntelligentComboPanel`):** New inline panel (371 lines) within the combos page that shows real-time provider scores, 6-factor scoring breakdown (quota, health, cost, latency, task fitness, stability), mode pack selector, incident mode status, and excluded providers for `auto`/`lkgp` combos — replacing the former standalone auto-combo dashboard
- **Builder Intelligent Step (`BuilderIntelligentStep`):** New conditional wizard step (280 lines) that appears in the Builder v2 flow only when `strategy=auto` or `strategy=lkgp` is selected. Exposes candidate pool selection, mode pack presets, router sub-strategy selector, exploration rate slider, budget cap, and collapsible advanced scoring weights configuration
- **Intelligent Routing Module (`intelligentRouting.ts`):** Extracted strategy categorization and filtering logic into a dedicated shared module (210 lines) with `getStrategyCategory()`, `isIntelligentStrategy()`, `filterCombosByStrategyCategory()`, `normalizeIntelligentRoutingFilter()`, and `normalizeIntelligentRoutingConfig()` utility functions
- **LKGP Standalone Strategy:** Implemented `lkgp` (Last Known Good Provider) as a fully functional standalone combo strategy. Previously, `lkgp` as a combo strategy silently fell through to `priority` ordering — the LKGP lookup only ran inside the `auto` engine. Now `strategy: "lkgp"` correctly queries the LKGP state, moves the last successful provider to the top of the target list, and saves the LKGP state after each successful request. Falls back to priority ordering when no LKGP state exists
- **Unified Routing Rules & Model Aliases:** Consolidated the routing rules and model alias management controls into the Settings page, reducing fragmentation across the dashboard
### ⚡ Performance
- **Middleware Lazy Loading:** Refactored `src/proxy.ts` to lazy-import `apiAuth`, `db/settings`, and `modelSyncScheduler` modules, reducing middleware cold-start overhead. Added inline `isPublicApiRoute()` to avoid loading the full auth module for public routes
- **E2E Auth Bypass:** Added `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` environment flag to bypass authentication gates for dashboard and management API routes during Playwright E2E test runs
### 🐛 Bug Fixes
- **P2C Credential Selection:** Implemented Power-of-Two-Choices (P2C) connection scoring in `src/sse/services/auth.ts` with quota headroom awareness, error/recency penalties, and forced/excluded connection support. The new `getProviderCredentialsWithQuotaPreflight()` function integrates quota preflight checks directly into credential selection, eliminating the separate Codex-only preflight path
- **Fixed-Account Combo Steps:** Combo steps with explicit `connectionId` now correctly bypass provider-level model cooldowns and circuit breakers, preventing a single account failure from blocking pinned-connection routing for the same model
- **Combo Metrics Per-Target Tracking:** Extended `comboMetrics.ts` to track `byTarget` metrics keyed by execution path, recording per-step `provider`, `providerId`, `connectionId`, and `label` alongside existing per-model aggregates
- **Call Logs Schema Expansion:** Added `requested_model`, `request_type`, `tokens_cache_read`, `tokens_cache_creation`, `tokens_reasoning`, `combo_step_id`, and `combo_execution_key` columns to `call_logs` with auto-migration. Added composite index `idx_cl_combo_target` for efficient per-target historical queries
- **Quota Monitor Enrichment:** Expanded `quotaMonitor.ts` with full lifecycle state tracking (`status`, `startedAt`, `lastPolledAt`, `consecutiveFailures`, `totalPolls`, `totalAlerts`), ISO-formatted snapshots via `getQuotaMonitorSnapshots()`, and sorted summary via `getQuotaMonitorSummary()`
- **Codex Quota Fetcher Hardening:** Improved `codexQuotaFetcher.ts` with safer connection registration and quota fetch error handling
- **LKGP Save Refactored to Async/Await:** Replaced fire-and-forget `.then()` chain for LKGP persistence after successful combo routing with proper `async/await` + `try/catch`, preventing unhandled promise rejections and ensuring LKGP state is reliably saved before the response is returned
- **Duplicate `auto` in Combo Strategy Schema:** Removed duplicate `"auto"` entry from `comboStrategySchema` (was listed on both line 104 and 108). Harmless to Zod runtime but cleaned up to avoid confusion. Schema now has exactly 13 unique strategy values
- **Legacy Combo Refs Normalization:** Fixed combo step normalization to preserve legacy string combo references during CRUD operations, preventing data loss when editing combos created before the v2 step architecture
### 🔒 Security
- **Auth Bypass on Backup Routes (Critical):** Added `isAuthenticated` guards to `/api/db-backups/exportAll` (full database export) and `/api/db-backups` (list, create, and restore backups) — both were previously accessible without authentication
- **Auth Guard on Translator Save:** Added `isAuthenticated` guard to `/api/translator/save` for defense-in-depth consistency
- **API Key Secret Hardening:** Removed the hardcoded `"omniroute-default-insecure-api-key-secret"` fallback from `apiKey.ts` — the function now fails fast if `API_KEY_SECRET` is unset, relying on the startup validator to auto-generate it
- **NPM Tarball Leak Fix:** Added `app/.env*` to `.npmignore` to prevent the working `.env` file from being shipped inside the npm tarball distribution
- **Electron Builder CVE Fix:** Bumped `electron-builder` to 26.8.1 to resolve `tar` CVEs in the desktop build pipeline
### 🔧 Maintenance & Infrastructure
- **DB Migration 021:** Added `combo_call_log_targets` migration for `combo_step_id` and `combo_execution_key` columns in call_logs
- **Combo CRUD Normalization:** `db/combos.ts` now normalizes all stored combo entries through the step normalization pipeline on read, ensuring consistent step IDs and kind annotations regardless of when the combo was created
- **Playwright Config:** Updated Playwright configuration and `run-next-playwright.mjs` script for improved E2E test orchestration
- **Build Script:** Updated `build-next-isolated.mjs` with additional reliability improvements
- **Auto-Combo UI Cleanup:** Deleted `AutoComboModal.tsx` (161 lines), replaced `auto-combo/page.tsx` (478→5 lines) with a server-side redirect to `/dashboard/combos?filter=intelligent`
- **Sidebar Consolidation:** Removed `"auto-combo"` from `HIDEABLE_SIDEBAR_ITEM_IDS` and `PRIMARY_SIDEBAR_ITEMS``normalizeHiddenSidebarItems()` silently discards any stale `"auto-combo"` entries in user settings
- **Schema Cleanup:** Removed obsolete `createAutoComboSchema` from `schemas.ts`. Exported `comboStrategySchema` for direct use in test and filter modules
- **A2A Agent Card Update:** Renamed skill ID from `auto-combo` to `intelligent-routing` with updated description referencing the unified combos dashboard
- **Builder Draft Refactor:** Extended `builderDraft.ts` with dynamic stage list generation via `getComboBuilderStages()` and `isIntelligentBuilderStrategy()`. Stage navigation (`getNextComboBuilderStage`, `getPreviousComboBuilderStage`, `canAccessComboBuilderStage`) now accepts options to conditionally include/skip the `intelligent` wizard step
- **i18n Consolidation:** Removed the standalone `"autoCombo"` i18n block (22 keys) from all 30 language files. Migrated keys into the `"combos"` block with new additions for filter tabs, intelligent panel, and builder step labels
### 🧪 Tests
- **16 New Test Suites:** Added comprehensive test coverage including:
- `combo-builder-draft.test.mjs` (186 lines) — Builder draft step construction and validation
- `combo-builder-options-route.test.mjs` (228 lines) — Builder options API endpoint
- `combo-health-route.test.mjs` (266 lines) — Combo health analytics with per-target metrics
- `combo-routes-composite-tiers.test.mjs` (157 lines) — Composite tiers API integration
- `composite-tiers-validation.test.mjs` (131 lines) — Composite tier validation rules
- `db-combos-crud.test.mjs` — Combo CRUD with step normalization
- `db-core-init.test.mjs` (129 lines) — DB initialization and column migrations
- `model-capabilities-registry.test.mjs` (105 lines) — Model capabilities resolution
- `observability-payloads.test.mjs` (165 lines) — Health/telemetry payload construction
- `openapi-spec-route.test.mjs` — OpenAPI spec generation
- `proxy-e2e-mode.test.mjs` (74 lines) — E2E mode auth bypass
- `quota-monitor.test.mjs` — Quota monitor lifecycle state
- `run-next-playwright.test.mjs` (119 lines) — Playwright runner script
- `sse-auth.test.mjs` (154 lines) — P2C credential selection and quota preflight
- `telemetry-summary-route.test.mjs` (35 lines) — Telemetry summary endpoint
- Plus updates to 12 existing test files for compatibility with new step architecture
- **Auto-Combo Unification Tests:**
- `autocombo-unification.test.mjs` (156 lines) — Strategy categorization, schema deduplication, sidebar cleanup, and routing strategies metadata validation
- `combo-unification.spec.ts` (189 lines) — Playwright E2E tests for filter tabs, intelligent panel rendering, redirect from old route, sidebar entry removal, and Builder v2 intelligent step flow
- 3 new LKGP standalone tests in `combo-routing-engine.test.mjs` — Validates LKGP provider prioritization, fallback to priority when no state exists, and LKGP state persistence after successful requests
- Updated `combo-builder-draft.test.mjs` with intelligent stage navigation tests
- Updated `sidebar-visibility.test.mjs` to reflect `auto-combo` removal
---
## [3.6.3] — 2026-04-11
### ✨ New Features
- **OpenAI-Compatible Loose Validation:** Empty API keys can now be naturally submitted and saved for any `openai-compatible-*` providers (e.g. Pollinations, localized routes) directly in the UI instead of blocking save actions (#1152)
- **Cloudflare Configuration:** Updated the provider schema and UI integration for Cloudflare AI to officially expose and support the backend `accountId` field securely without overrides (#1150)
### 🐛 Bug Fixes
- **Vertex JSON Validation Crash:** Prevented `invalid character in header` crashes inside the `/validate` endpoint by creating a native authentication parser that correctly handles Google Identity Service Account JSON flows prior to pinging endpoints (#1153)
- **Extraneous Payload Rejection:** Globally prevented upstream `400 Bad Request` execution crashes by stripping the non-standard `prompt_cache_retention` attribute forcibly attached by Cursor/Cline IDE engines when targeting strict OpenAI/Anthropic routes (#1154)
- **Reasoning Content Drop:** Prevented pure reasoning packets, common in advanced fallback models like DeepSeek, from being aborted mid-stream by explicitly adjusting the `Empty Content (502)` circuit breakers to acknowledge `reasoning_content` states as valid (#1155)
- **Desktop Windows Build Crash:** Fixed `better_sqlite3.node is not a valid Win32 application` preventing OmniRoute Desktop from launching on Windows by properly removing the ABI-mismatched sqlite cache from Next.js standalone and falling back to the cross-compiled Electron equivalent during packager build steps (#1163)
- **Login Visual Security:** Removed the raw fallback hash dump that artificially rendered underneath the login modal in Docker instances missing `OMNIROUTE_API_KEY_BASE64` flags (#1148)
### 🔧 Maintenance & Dependencies
- **Dependabot Updates:** Safely bumped GitHub Actions `docker/build-push-action` to v7 and `actions/download-artifact` to v8
- **Electron Updates:** Upgraded desktop wrapper core to Electron `41.2.0` and `electron-builder` to `26.8.1`, incorporating essential V8/Chromium security patches
- **NPM Package Groups:** Updated `production` and `development` NPM groups to securely handle minor audit warnings and keep toolchains modern
- **CI/CD Reliability:** Fixed persistent `Snyk` token-absence failures on automated pull requests by appropriately bypassing on dependabot actions
## [3.6.2] — 2026-04-11
### ✨ New Features

225
CLAUDE.md Normal file
View File

@@ -0,0 +1,225 @@
# CLAUDE.md — AI Agent Session Bootstrap
> Quick-start context for AI coding agents. For deep architecture details, see `AGENTS.md`.
> For contribution workflow, see `CONTRIBUTING.md`.
## Quick Start
```bash
npm install # Install deps (auto-generates .env from .env.example)
npm run dev # Dev server at http://localhost:20128
npm run build # Production build (Next.js 16 standalone)
npm run lint # ESLint (0 errors expected; warnings are pre-existing)
npm run typecheck:core # TypeScript check (should be clean)
npm run test:coverage # Unit tests + coverage gate (60% min)
npm run check # lint + test combined
```
### Running a Single Test
```bash
# Node.js native test runner (most tests)
node --import tsx/esm --test tests/unit/your-file.test.mjs
# Vitest (MCP server, autoCombo, cache)
npm run test:vitest
```
---
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 100+ 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) |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (22 files) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 25 tools, 3 transports, 10 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 |
| UI Components | `src/shared/components/` | React components (Tailwind CSS v4) |
| Provider Consts | `src/shared/constants/` | Provider registry (Zod-validated) |
| Validation | `src/shared/validation/` | Zod v4 schemas |
| Tests | `tests/` | Unit, integration, e2e, security, load |
### Monorepo Layout
```
OmniRoute/ # Root package
├── src/ # Next.js 16 app (TypeScript)
├── open-sse/ # @omniroute/open-sse workspace (streaming engine)
├── electron/ # Desktop app (Electron)
├── tests/ # All test suites
├── docs/ # Documentation
└── bin/ # CLI entry point
```
---
## Request Pipeline (Abbreviated)
```
Client → /v1/chat/completions (Next.js route)
→ CORS → Zod validation → auth? → policy check → prompt injection guard
→ handleChatCore() [open-sse/handlers/chatCore.ts]
→ cache check → rate limit → combo routing?
→ resolveComboTargets() → handleSingleModel() per target
→ translateRequest() → getExecutor() → executor.execute()
→ fetch() upstream → retry w/ backoff
→ response translation → SSE stream or JSON
```
---
## Key Conventions
### Code Style
- **2 spaces**, semicolons, double quotes, 100 char width, es5 trailing commas
- **Imports**: external → internal (`@/`, `@omniroute/open-sse`) → relative
- **Naming**: files=camelCase/kebab, components=PascalCase, constants=UPPER_SNAKE
### Database Access
- **Always** go through `src/lib/db/` domain modules
- **Never** write raw SQL in routes or handlers
- **Never** add logic to `src/lib/localDb.ts` (re-export layer only)
- **Never** barrel-import from `localDb.ts` — import specific `db/` modules
- DB singleton: `getDbInstance()` from `src/lib/db/core.ts` (WAL journaling)
- Migrations: `src/lib/db/migrations/` — 21 versioned SQL files
### Error Handling
- try/catch with specific error types, log with pino context
- Never swallow errors in SSE streams — use abort signals
- Return proper HTTP status codes (4xx/5xx)
### Security
- **Never** commit secrets/credentials
- **Never** use `eval()`, `new Function()`, or implied eval
- Validate all inputs with Zod schemas
- Encrypt credentials at rest (AES-256-GCM)
---
## Common Modification Scenarios
### Adding a New Provider
1. Register in `src/shared/constants/providers.ts` (Zod-validated at load)
2. Add executor in `open-sse/executors/` if custom logic needed
3. Add translator in `open-sse/translator/` if non-OpenAI format
4. Add OAuth config in `src/lib/oauth/constants/oauth.ts` if OAuth-based
5. Register models in `open-sse/config/providerRegistry.ts`
6. Write tests in `tests/unit/` (registration, translation, error handling)
### Adding a New API Route
1. Create directory under `src/app/api/v1/your-route/`
2. Create `route.ts` with `GET`/`POST` handlers
3. Follow pattern: CORS → Zod body validation → optional auth → handler delegation
4. Handler goes in `open-sse/handlers/` (import from there, not inline)
5. Add tests
### Adding a New DB Module
1. Create `src/lib/db/yourModule.ts`
2. Import `getDbInstance` from `./core.ts`
3. Export CRUD functions for your domain table(s)
4. Add migration in `src/lib/db/migrations/` if new tables needed
5. Re-export from `src/lib/localDb.ts` (add to the re-export list only)
6. Write tests
### Adding a New MCP Tool
1. Add tool definition in `open-sse/mcp-server/tools/`
2. Define Zod input schema + async handler
3. Register in tool set (wired by `createMcpServer()`)
4. Assign to appropriate scope(s)
5. Write tests (tool invocation logged to `mcp_audit` table)
### Adding a New A2A Skill
1. Create skill in `src/lib/a2a/skills/`
2. Skill receives task context (messages, metadata) → returns structured result
3. Register in the DB-backed skill registry
4. Write tests
---
## Testing Cheat Sheet
| What | Command |
| ----------------------- | ------------------------------------------------------- |
| All tests | `npm run test:all` |
| Unit tests | `npm run test:unit` |
| Single file | `node --import tsx/esm --test tests/unit/file.test.mjs` |
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
| E2E (Playwright) | `npm run test:e2e` |
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
| Ecosystem | `npm run test:ecosystem` |
| Coverage gate | `npm run test:coverage` (60% min all metrics) |
| Coverage report | `npm run coverage:report` |
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`,
you must include or update tests in the same PR.
---
## Git Workflow
```bash
# Never commit directly to main
git checkout -b feat/your-feature
# ... make changes ...
git commit -m "feat: describe your change"
git push -u origin feat/your-feature
```
**Branch prefixes**: `feat/`, `fix/`, `refactor/`, `docs/`, `test/`, `chore/`
**Commit format** ([Conventional Commits](https://www.conventionalcommits.org/)):
```
feat: add circuit breaker for provider calls
fix: resolve JWT secret validation edge case
docs: update AGENTS.md with pipeline internals
test: add MCP tool unit tests
refactor(db): consolidate rate limit tables
```
**Scopes**: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`, `mcp`, `a2a`,
`memory`, `skills`.
---
## Environment
- **Runtime**: Node.js ≥18 <24, ES Modules
- **TypeScript**: 5.9, target ES2022, module esnext, resolution bundler
- **Path aliases**: `@/*``src/`, `@omniroute/open-sse``open-sse/`
- **Default port**: 20128 (API + dashboard on same port)
- **Data directory**: `DATA_DIR` env var, defaults to `~/.omniroute/`
- **Key env vars**: `PORT`, `JWT_SECRET`, `INITIAL_PASSWORD`, `REQUIRE_API_KEY`, `APP_LOG_LEVEL`
---
## Hard Rules (Never Violate)
1. Never commit secrets or credentials
2. Never add logic to `localDb.ts`
3. Never use `eval()` / `new Function()` / implied eval
4. Never commit directly to `main`
5. Never write raw SQL in routes — use `src/lib/db/` modules
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)

View File

@@ -244,6 +244,8 @@ Developers pay $20200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve
- **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI
- **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next
- **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**)
- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets
- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use
- **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard
</details>
@@ -797,6 +799,8 @@ For most deployments, you only need:
Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline.
For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration.
Advanced overrides are available if you need finer control:
| Variable | Default | Purpose |
@@ -2219,6 +2223,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux
| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals |
| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough |
| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods |
| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references |
| [Contributing](CONTRIBUTING.md) | Development setup and guidelines |
| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification |
| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices |
@@ -2230,16 +2235,16 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux
## 🗺️ Roadmap
OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas:
OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas:
| Category | Planned Features | Highlights |
| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- |
| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection |
| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping |
| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model |
| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing |
| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API |
| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode |
| Category | Planned Features | Highlights |
| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- |
| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing |
| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping |
| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model |
| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing |
| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API |
| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode |
### 🔜 Coming Soon

View File

@@ -20,9 +20,9 @@ If you discover a security vulnerability in OmniRoute, please report it responsi
| Version | Support Status |
| ------- | -------------- |
| 3.4.x | ✅ Active |
| 3.0.x | ✅ Security |
| < 3.0.0 | ❌ Unsupported |
| 3.6.x | ✅ Active |
| 3.5.x | ✅ Security |
| < 3.5.0 | ❌ Unsupported |
---

View File

@@ -2,7 +2,7 @@
🌐 **Languages:** 🇺🇸 [English](ARCHITECTURE.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/ARCHITECTURE.md) | 🇪🇸 [Español](i18n/es/ARCHITECTURE.md) | 🇫🇷 [Français](i18n/fr/ARCHITECTURE.md) | 🇮🇹 [Italiano](i18n/it/ARCHITECTURE.md) | 🇷🇺 [Русский](i18n/ru/ARCHITECTURE.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/ARCHITECTURE.md) | 🇩🇪 [Deutsch](i18n/de/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](i18n/in/ARCHITECTURE.md) | 🇹🇭 [ไทย](i18n/th/ARCHITECTURE.md) | 🇺🇦 [Українська](i18n/uk-UA/ARCHITECTURE.md) | 🇸🇦 [العربية](i18n/ar/ARCHITECTURE.md) | 🇯🇵 [日本語](i18n/ja/ARCHITECTURE.md) | 🇻🇳 [Tiếng Việt](i18n/vi/ARCHITECTURE.md) | 🇧🇬 [Български](i18n/bg/ARCHITECTURE.md) | 🇩🇰 [Dansk](i18n/da/ARCHITECTURE.md) | 🇫🇮 [Suomi](i18n/fi/ARCHITECTURE.md) | 🇮🇱 [עברית](i18n/he/ARCHITECTURE.md) | 🇭🇺 [Magyar](i18n/hu/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/ARCHITECTURE.md) | 🇰🇷 [한국어](i18n/ko/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/ARCHITECTURE.md) | 🇳🇱 [Nederlands](i18n/nl/ARCHITECTURE.md) | 🇳🇴 [Norsk](i18n/no/ARCHITECTURE.md) | 🇵🇹 [Português (Portugal)](i18n/pt/ARCHITECTURE.md) | 🇷🇴 [Română](i18n/ro/ARCHITECTURE.md) | 🇵🇱 [Polski](i18n/pl/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](i18n/sk/ARCHITECTURE.md) | 🇸🇪 [Svenska](i18n/sv/ARCHITECTURE.md) | 🇵🇭 [Filipino](i18n/phi/ARCHITECTURE.md) | 🇨🇿 [Čeština](i18n/cs/ARCHITECTURE.md)
_Last updated: 2026-04-11_
_Last updated: 2026-04-12_
## Executive Summary
@@ -14,7 +14,9 @@ Core capabilities:
- OpenAI-compatible API surface for CLI/tools (100+ providers, 16 executors)
- Request/response translation across provider formats
- Model combo fallback (multi-model sequence)
- Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers`
- Account-level fallback (multi-account per provider)
- Quota preflight and quota-aware P2C account selection in the main chat path
- OAuth + API-key provider connection management (13 OAuth modules)
- Embedding generation via `/v1/embeddings` (6 providers, 9 models)
- Image generation via `/v1/images/generations` (10+ providers, 20+ models)
@@ -45,6 +47,7 @@ Core capabilities:
- Domain state persistence (SQLite write-through cache for fallbacks, budgets, lockouts, circuit breakers)
- Policy engine for centralized request evaluation (lockout → budget → fallback)
- Request telemetry with p50/p95/p99 latency aggregation
- Combo target telemetry and historical combo target health via `combo_execution_key` / `combo_step_id`
- Correlation ID (X-Request-Id) for end-to-end tracing
- Compliance audit logging with opt-out per API key
- Eval framework for LLM quality assurance
@@ -89,15 +92,15 @@ Main pages under `src/app/(dashboard)/dashboard/`:
- `/dashboard` — quick start + provider overview
- `/dashboard/endpoint` — endpoint proxy + MCP + A2A + API endpoint tabs
- `/dashboard/providers` — provider connections and credentials
- `/dashboard/combos` — combo strategies, templates, model routing rules, manual persisted ordering
- `/dashboard/combos` — combo strategies, templates, step-based builder, model routing rules, manual persisted ordering
- `/dashboard/costs` — cost aggregation and pricing visibility
- `/dashboard/analytics` — usage analytics and evaluations
- `/dashboard/analytics` — usage analytics, evaluations, combo target health
- `/dashboard/limits` — quota/rate controls
- `/dashboard/cli-tools` — CLI onboarding, runtime detection, config generation
- `/dashboard/agents` — detected ACP agents + custom agent registration
- `/dashboard/media` — image/video/music playground
- `/dashboard/search-tools` — search provider testing and history
- `/dashboard/health` — uptime, circuit breakers, rate limits
- `/dashboard/health` — uptime, circuit breakers, rate limits, quota-monitored sessions
- `/dashboard/logs` — request/proxy/audit/console logs
- `/dashboard/settings` — system settings tabs (general, routing, combo defaults, etc.)
- `/dashboard/api-manager` — API key lifecycle and model permissions

659
docs/ENVIRONMENT.md Normal file
View File

@@ -0,0 +1,659 @@
# Environment Variables Reference
> Complete reference for every environment variable recognized by OmniRoute.
> For a quick-start template, see [`.env.example`](../.env.example).
---
## Table of Contents
- [1. Required Secrets](#1-required-secrets)
- [2. Storage & Database](#2-storage--database)
- [3. Network & Ports](#3-network--ports)
- [4. Security & Authentication](#4-security--authentication)
- [5. Input Sanitization & PII Protection](#5-input-sanitization--pii-protection)
- [6. Tool & Routing Policies](#6-tool--routing-policies)
- [7. URLs & Cloud Sync](#7-urls--cloud-sync)
- [8. Outbound Proxy](#8-outbound-proxy)
- [9. CLI Tool Integration](#9-cli-tool-integration)
- [10. Internal Agent & MCP Integrations](#10-internal-agent--mcp-integrations)
- [11. OAuth Provider Credentials](#11-oauth-provider-credentials)
- [12. Provider User-Agent Overrides](#12-provider-user-agent-overrides)
- [13. CLI Fingerprint Compatibility](#13-cli-fingerprint-compatibility)
- [14. API Key Providers](#14-api-key-providers)
- [15. Timeout Settings](#15-timeout-settings)
- [16. Logging](#16-logging)
- [17. Memory Optimization](#17-memory-optimization)
- [18. Pricing Sync](#18-pricing-sync)
- [19. Model Sync (Dev)](#19-model-sync-dev)
- [20. Provider-Specific Settings](#20-provider-specific-settings)
- [21. Proxy Health](#21-proxy-health)
- [22. Debugging](#22-debugging)
- [23. GitHub Integration](#23-github-integration)
- [Deployment Scenarios](#deployment-scenarios)
- [Audit: Removed / Dead Variables](#audit-removed--dead-variables)
---
## 1. Required Secrets
These **must** be set before the first run. Without them, the application will either refuse to start or operate with insecure defaults.
| Variable | Required | Default | Source File | Description |
| ------------------ | -------- | -------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `JWT_SECRET` | **Yes** | _(none)_ | `src/lib/auth` | Signs/verifies all dashboard session cookies (JWT). Generate with `openssl rand -base64 48`. |
| `API_KEY_SECRET` | **Yes** | _(none)_ | `src/lib/db/apiKeys.ts` | AES encryption key for API key values at rest in SQLite. Generate with `openssl rand -hex 32`. |
| `INITIAL_PASSWORD` | **Yes** | `123456` | Bootstrap script | Sets the initial admin dashboard password. **Change before first use.** After login, change via Dashboard → Settings → Security. |
### Generation Commands
```bash
# Generate all three secrets at once:
echo "JWT_SECRET=$(openssl rand -base64 48)"
echo "API_KEY_SECRET=$(openssl rand -hex 32)"
echo "INITIAL_PASSWORD=$(openssl rand -base64 16)"
```
> [!CAUTION]
> Never commit `.env` files with real secrets to version control. The `.gitignore` already excludes `.env`, but verify before pushing.
---
## 2. Storage & Database
OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These variables control data location, encryption, and lifecycle.
| Variable | Default | Source File | Description |
| -------------------------------- | -------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | Root directory for SQLite DB, backups, and data files. Override for Docker volumes or custom paths. |
| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. |
| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. |
| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips the automatic database backup that runs before migrations on every startup. |
### Scenarios
| Scenario | Configuration |
| --------------------- | -------------------------------------------------------------------------------- |
| **Local development** | Leave all defaults. DB lives at `~/.omniroute/omniroute.db`. |
| **Docker** | `DATA_DIR=/data` + mount a volume at `/data`. |
| **Encrypted at rest** | Set `STORAGE_ENCRYPTION_KEY` + keep backups of the key! Losing it = losing data. |
| **CI/Testing** | `DATA_DIR=/tmp/omniroute-test` — ephemeral, no encryption needed. |
---
## 3. Network & Ports
| Variable | Default | Source File | Description |
| --------------------- | ------------ | -------------------------- | -------------------------------------------------------------------------------------- |
| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). |
| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. |
| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. |
| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. |
| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. |
| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. |
| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. |
| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. |
### Port Modes
```
┌─────────────────────────── Single Port (default) ──────────────────────────┐
│ PORT=20128 │
│ → Dashboard: http://localhost:20128 │
│ → API: http://localhost:20128/v1/chat/completions │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────── Split Ports ─────────────────────────────────────┐
│ DASHBOARD_PORT=20128 │
│ API_PORT=20129 │
│ API_HOST=0.0.0.0 │
│ → Dashboard: http://localhost:20128 │
│ → API: http://0.0.0.0:20129/v1/chat/completions │
│ Use case: Expose API to LAN while restricting Dashboard to localhost. │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────── Docker Production ──────────────────────────────┐
│ PROD_DASHBOARD_PORT=443 PROD_API_PORT=8443 │
│ → Maps container ports to host ports in docker-compose.prod.yml. │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 4. Security & Authentication
| Variable | Default | Source File | Description |
| ---------------------- | --------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt combined with hardware identifiers for machine fingerprinting. Change per-deployment for isolation. |
| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Sets the `Secure` flag on session cookies. **Must be `true`** when running behind HTTPS. |
| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. |
| `ALLOW_API_KEY_REVEAL` | `false` | Dashboard providers page | Allows revealing full API key values in the Dashboard UI. Security risk on shared instances. |
| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). |
| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. |
| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. |
### Hardening Checklist
```bash
# Production security minimum:
AUTH_COOKIE_SECURE=true # Requires HTTPS
REQUIRE_API_KEY=true # Authenticate all proxy calls
ALLOW_API_KEY_REVEAL=false # Never expose keys in UI
CORS_ORIGIN=https://your.domain.com
MAX_BODY_SIZE_BYTES=5242880 # 5 MB limit
```
---
## 5. Input Sanitization & PII Protection
OmniRoute provides a two-layer defense: request-side injection scanning and response-side PII stripping.
### Request-Side: Prompt Injection Guard
| Variable | Default | Source File | Description |
| ------------------------- | --------- | ---------------------------------------- | ------------------------------------------------------------------------------------------- |
| `INPUT_SANITIZER_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | Enable scanning of incoming messages for prompt injection patterns. |
| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | `warn` = log only, `block` = reject request with 400, `redact` = strip suspicious patterns. |
| `INJECTION_GUARD_MODE` | _(unset)_ | `src/middleware/promptInjectionGuard.ts` | Legacy alias for `INPUT_SANITIZER_MODE` — same behavior. |
| `PII_REDACTION_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | Detect PII (emails, phones, SSNs) in incoming requests. |
### Response-Side: PII Sanitizer
| Variable | Default | Source File | Description |
| -------------------------------- | -------- | ------------------------- | ----------------------------------------------------------------------- |
| `PII_RESPONSE_SANITIZATION` | `false` | `src/lib/piiSanitizer.ts` | Scan LLM responses for leaked PII before returning to client. |
| `PII_RESPONSE_SANITIZATION_MODE` | `redact` | `src/lib/piiSanitizer.ts` | `redact` = mask PII, `warn` = log only, `block` = drop entire response. |
### Scenarios
| Scenario | Configuration |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| **Enterprise compliance** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=block`, `PII_REDACTION_ENABLED=true`, `PII_RESPONSE_SANITIZATION=true` |
| **Monitoring only** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=warn` — logs but never blocks |
| **Personal use** | Leave all disabled — zero overhead |
---
## 6. Tool & Routing Policies
| Variable | Default | Source File | Description |
| ------------------ | ---------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `TOOL_POLICY_MODE` | `disabled` | `src/lib/toolPolicy.ts` | Controls LLM tool/function-calling access. `allowlist` = only listed tools, `denylist` = all except listed, `disabled` = no restrictions. |
---
## 7. URLs & Cloud Sync
| Variable | Default | Source File | Description |
| ----------------------- | ------------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | Server-side URL for internal sync jobs to call `/api/sync/cloud`. |
| `CLOUD_URL` | _(empty)_ | `src/lib/cloudSync.ts` | Cloud relay endpoint URL (premium feature). |
| `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | HTTP timeout for cloud sync requests. |
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth, Dashboard, sync | Public-facing URL for OAuth redirect_uri, Dashboard links. **Must match your public URL behind reverse proxy.** |
| `NEXT_PUBLIC_CLOUD_URL` | _(empty)_ | Client-side | Client-side mirror of `CLOUD_URL`. |
| `NEXT_PUBLIC_APP_URL` | _(unset)_ | `src/shared/services/cloudSyncScheduler.ts` | Legacy fallback for `NEXT_PUBLIC_BASE_URL`. |
> [!IMPORTANT]
> When deploying behind a reverse proxy (nginx, Caddy), `NEXT_PUBLIC_BASE_URL` **must** be set to your public URL (e.g., `https://omniroute.example.com`). Without this, OAuth callbacks will fail because the redirect_uri won't match.
---
## 8. Outbound Proxy
Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress control, geo-routing, or IP masking.
| Variable | Default | Source File | Description |
| --------------------------------- | --------- | -------------------- | ----------------------------------------------------------------------------------- |
| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. |
| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | Client-side | Client-side awareness of SOCKS5 availability. |
| `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. |
| `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. |
| `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). |
| `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. |
| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. |
### Scenarios
| Scenario | Configuration |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| **SOCKS5 through SSH tunnel** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` |
| **Corporate HTTP proxy** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` |
| **Anti-fingerprint** | `ENABLE_TLS_FINGERPRINT=true` — requires `wreq-js` (included) |
---
## 9. CLI Tool Integration
Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, etc.).
| Variable | Default | Source File | Description |
| ------------------------- | ---------- | ----------------------------------- | -------------------------------------------------------------------------- |
| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = search system PATH; `manual` = use explicit paths only. |
| `CLI_EXTRA_PATHS` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Additional PATH entries for CLI binary discovery (colon-separated). |
| `CLI_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override home directory for reading CLI configs (`~/.claude`, `~/.codex`). |
| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | Allow OmniRoute to write CLI config files (token refresh, session data). |
| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Custom path to Claude CLI binary. |
| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Custom path to Codex CLI binary. |
| `CLI_DROID_BIN` | `droid` | `src/shared/services/cliRuntime.ts` | Custom path to Droid CLI binary. |
| `CLI_OPENCLAW_BIN` | `openclaw` | `src/shared/services/cliRuntime.ts` | Custom path to OpenClaw CLI binary. |
| `CLI_CURSOR_BIN` | `agent` | `src/shared/services/cliRuntime.ts` | Custom path to Cursor agent binary. |
| `CLI_CLINE_BIN` | `cline` | `src/shared/services/cliRuntime.ts` | Custom path to Cline CLI binary. |
| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Custom path to Continue CLI binary. |
| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. |
### Docker Example
```bash
# Mount host binaries into the container and tell OmniRoute where they are:
CLI_EXTRA_PATHS=/host-cli/bin
CLI_CONFIG_HOME=/root
CLI_ALLOW_CONFIG_WRITES=true
CLI_CLAUDE_BIN=/host-cli/bin/claude
```
---
## 10. Internal Agent & MCP Integrations
| Variable | Default | Source File | Description |
| --------------------------------------- | ----------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `OMNIROUTE_BASE_URL` | auto-detect | `open-sse/mcp-server/server.ts` | Explicit URL for MCP/A2A tools to reach OmniRoute. Overrides localhost auto-detection. |
| `OMNIROUTE_API_KEY` | _(unset)_ | MCP/A2A modules | API key for internal MCP tool and A2A skill calls. |
| `OMNIROUTE_API_KEY_ID` | _(unset)_ | `open-sse/mcp-server/audit.ts` | Key ID for MCP audit log attribution. |
| `ROUTER_API_KEY` | _(unset)_ | Legacy | Legacy alias for `OMNIROUTE_API_KEY`. |
| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `false` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. |
| `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. |
| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Model catalog sync interval in hours. |
| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Provider rate-limit and quota polling interval. |
| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. |
| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. |
| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. |
### OAuth CLI Bridge (Internal)
| Variable | Default | Source File | Description |
| ------------------- | ----------- | ------------------------------- | ----------------------------------------- |
| `OMNIROUTE_SERVER` | auto-detect | `src/lib/oauth/config/index.ts` | Server URL for CLI↔OmniRoute auth bridge. |
| `OMNIROUTE_TOKEN` | _(unset)_ | `src/lib/oauth/config/index.ts` | Auth token for CLI bridge. |
| `OMNIROUTE_USER_ID` | `cli` | `src/lib/oauth/config/index.ts` | User ID for CLI bridge sessions. |
| `SERVER_URL` | _(unset)_ | `src/lib/oauth/config/index.ts` | Legacy alias for `OMNIROUTE_SERVER`. |
| `CLI_TOKEN` | _(unset)_ | `src/lib/oauth/config/index.ts` | Legacy alias for `OMNIROUTE_TOKEN`. |
| `CLI_USER_ID` | _(unset)_ | `src/lib/oauth/config/index.ts` | Legacy alias for `OMNIROUTE_USER_ID`. |
---
## 11. OAuth Provider Credentials
Built-in credentials for **localhost development**. For remote deployments, register your own at each provider's developer console.
| Variable | Provider | Notes |
| --------------------------------- | ----------------------- | --------------------------------------------------------------------------------- |
| `CLAUDE_OAUTH_CLIENT_ID` | Claude Code (Anthropic) | Public client — no secret needed. |
| `CLAUDE_CODE_REDIRECT_URI` | Claude Code | Override redirect URI. Default: `https://platform.claude.com/oauth/code/callback` |
| `CODEX_OAUTH_CLIENT_ID` | Codex / OpenAI | Public client. |
| `GEMINI_OAUTH_CLIENT_ID` | Gemini (Google) | Requires matching `_SECRET`. |
| `GEMINI_OAUTH_CLIENT_SECRET` | Gemini (Google) | — |
| `GEMINI_CLI_OAUTH_CLIENT_ID` | Gemini CLI | Usually same as Gemini. |
| `GEMINI_CLI_OAUTH_CLIENT_SECRET` | Gemini CLI | — |
| `QWEN_OAUTH_CLIENT_ID` | Qwen (Alibaba) | Public client. |
| `KIMI_CODING_OAUTH_CLIENT_ID` | Kimi Coding (Moonshot) | Public client. |
| `ANTIGRAVITY_OAUTH_CLIENT_ID` | Antigravity (Google) | Requires matching `_SECRET`. |
| `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — |
| `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | Public client. |
| `QODER_OAUTH_CLIENT_SECRET` | Qoder | — |
| `QODER_OAUTH_AUTHORIZE_URL` | Qoder | Set to enable Qoder OAuth. |
| `QODER_OAUTH_TOKEN_URL` | Qoder | — |
| `QODER_OAUTH_USERINFO_URL` | Qoder | — |
| `QODER_OAUTH_CLIENT_ID` | Qoder | — |
| `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | Direct API key fallback (bypasses OAuth). |
| `QODER_CLI_WORKSPACE` | Qoder | Workspace ID for Qoder CLI. |
| `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias for `QODER_CLI_WORKSPACE`. |
> [!WARNING]
> **Google OAuth** (Antigravity, Gemini CLI) credentials **only work on localhost**. For remote servers:
>
> 1. Go to [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials)
> 2. Create an OAuth 2.0 Client ID (type: "Web application")
> 3. Add your server URL as Authorized redirect URI
> 4. Replace the credential values in `.env`.
---
## 12. Provider User-Agent Overrides
Override the `User-Agent` header sent to each upstream provider. This is dynamically resolved at runtime by the executor base class:
```
process.env[`${PROVIDER_ID}_USER_AGENT`]
```
> **Source:** `open-sse/executors/base.ts` → `buildHeaders()`
| Variable | Default Value | When to Update |
| ------------------------ | -------------------------------------------- | ----------------------------------------- |
| `CLAUDE_USER_AGENT` | `claude-cli/1.0.83 (external, cli)` | When Anthropic releases a new CLI version |
| `CODEX_USER_AGENT` | `codex-cli/0.92.0 (Windows 10.0.26100; x64)` | When OpenAI updates the Codex CLI |
| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.26.7` | When GitHub Copilot Chat updates |
| `ANTIGRAVITY_USER_AGENT` | `antigravity/1.104.0 darwin/arm64` | When Antigravity IDE updates |
| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates |
| `QODER_USER_AGENT` | `Qoder-Cli` | When Qoder CLI updates |
| `QWEN_USER_AGENT` | `QwenCode/0.12.3 (linux; x64)` | When Qwen Code updates |
| `CURSOR_USER_AGENT` | `connect-es/1.6.1` | When Cursor updates |
| `GEMINI_CLI_USER_AGENT` | `google-api-nodejs-client/9.15.1` | When Google API client updates |
> [!TIP]
> You can add User-Agent overrides for **any** provider using the pattern `{PROVIDER_ID}_USER_AGENT`. The executor dynamically constructs the env var name.
---
## 13. CLI Fingerprint Compatibility
When enabled, OmniRoute reorders HTTP headers and JSON body fields to match the exact signature of official CLI tools. This reduces the risk of account flagging while preserving your proxy IP.
**Source:** `open-sse/config/cliFingerprints.ts`, `open-sse/executors/base.ts`
### Per-Provider
| Variable | Effect |
| -------------------------- | --------------------------------------- |
| `CLI_COMPAT_CODEX=1` | Mimics Codex CLI request signature |
| `CLI_COMPAT_CLAUDE=1` | Mimics Claude Code request signature |
| `CLI_COMPAT_GITHUB=1` | Mimics GitHub Copilot request signature |
| `CLI_COMPAT_ANTIGRAVITY=1` | Mimics Antigravity request signature |
| `CLI_COMPAT_KIRO=1` | Mimics Kiro IDE request signature |
| `CLI_COMPAT_CURSOR=1` | Mimics Cursor request signature |
| `CLI_COMPAT_KIMI_CODING=1` | Mimics Kimi Coding request signature |
| `CLI_COMPAT_KILOCODE=1` | Mimics Kilo Code request signature |
| `CLI_COMPAT_CLINE=1` | Mimics Cline request signature |
| `CLI_COMPAT_QWEN=1` | Mimics Qwen Code request signature |
### Global
| Variable | Effect |
| ------------------ | --------------------------------------------------------------- |
| `CLI_COMPAT_ALL=1` | Enable fingerprint compatibility for **all** providers at once. |
> [!NOTE]
> This feature works alongside the User-Agent overrides (§12). The fingerprint system handles header ordering and body field ordering, while User-Agent overrides handle the specific UA string. Both can be enabled independently.
---
## 14. API Key Providers
API keys for providers that use direct authentication. **Preferred setup:** Dashboard → Providers → Add API Key.
Setting via environment variables is an alternative for Docker or headless deployments.
Recognized pattern: `{PROVIDER_ID}_API_KEY`
| Variable | Provider |
| -------------------- | ------------------- |
| `DEEPSEEK_API_KEY` | DeepSeek |
| `GROQ_API_KEY` | Groq |
| `XAI_API_KEY` | xAI (Grok) |
| `MISTRAL_API_KEY` | Mistral AI |
| `PERPLEXITY_API_KEY` | Perplexity |
| `TOGETHER_API_KEY` | Together AI |
| `FIREWORKS_API_KEY` | Fireworks AI |
| `CEREBRAS_API_KEY` | Cerebras |
| `COHERE_API_KEY` | Cohere |
| `NVIDIA_API_KEY` | NVIDIA NIM |
| `NEBIUS_API_KEY` | Nebius (embeddings) |
> [!TIP]
> Keys set via the Dashboard are stored encrypted in SQLite and take precedence over environment variables.
---
## 15. Timeout Settings
All values are in **milliseconds**. Centralized resolution in `src/shared/utils/runtimeTimeouts.ts`.
### Timeout Hierarchy
```
REQUEST_TIMEOUT_MS (global override)
├─→ FETCH_TIMEOUT_MS (upstream provider calls, default: 600000)
│ ├─→ FETCH_HEADERS_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS)
│ ├─→ FETCH_BODY_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS)
│ ├─→ TLS_CLIENT_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS)
│ ├── FETCH_CONNECT_TIMEOUT_MS (independent, default: 30000)
│ └── FETCH_KEEPALIVE_TIMEOUT_MS (independent, default: 4000)
├─→ STREAM_IDLE_TIMEOUT_MS (inherits from REQUEST_TIMEOUT_MS, default: 600000)
└─→ API_BRIDGE_PROXY_TIMEOUT_MS (inherits from REQUEST_TIMEOUT_MS, default: 30000)
├─→ API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS (derived, default: 300000)
├── API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS (default: 60000)
├── API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS (default: 5000)
└── API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS (default: 0 = disabled)
```
| Variable | Default | Description |
| ---------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------- |
| `REQUEST_TIMEOUT_MS` | _(unset)_ | Global shortcut — overrides both `FETCH_TIMEOUT_MS` and `STREAM_IDLE_TIMEOUT_MS` defaults. |
| `FETCH_TIMEOUT_MS` | `600000` | Total HTTP request timeout for upstream provider calls. |
| `STREAM_IDLE_TIMEOUT_MS` | `600000` | Max silence between SSE chunks before aborting. Extended-thinking models rarely pause >90s. |
| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. |
| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. |
| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. |
| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. |
| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. |
| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. |
| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | Overall server request timeout for the bridge. |
| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Time to send response headers via the bridge. |
| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Bridge keep-alive idle timeout. |
| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). |
| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. |
### Scenarios
| Scenario | Configuration |
| -------------------------------- | ------------------------------------------------------ |
| **Long-running code generation** | `REQUEST_TIMEOUT_MS=900000` (15 min) |
| **Fast-fail for production API** | `API_BRIDGE_PROXY_TIMEOUT_MS=10000` |
| **Extended thinking models** | `STREAM_IDLE_TIMEOUT_MS=300000` (5 min between chunks) |
---
## 16. Logging
The logging system writes to both stdout and rotated log files. All configuration is read by `src/lib/logEnv.ts`.
| Variable | Default | Description |
| --------------------------- | -------------------------- | ---------------------------------------------------------------------------- |
| `APP_LOG_LEVEL` | `info` | Minimum log level: `debug`, `info`, `warn`, `error`. |
| `APP_LOG_FORMAT` | `text` | Output format: `text` (human-readable) or `json` (structured). |
| `APP_LOG_TO_FILE` | `true` | Write logs to file alongside stdout. |
| `APP_LOG_FILE_PATH` | `logs/application/app.log` | Log file path (relative to project root or `DATA_DIR`). |
| `APP_LOG_MAX_FILE_SIZE` | `50M` | Max file size before rotation. Accepts: `50M`, `1G`, `512K`, or plain bytes. |
| `APP_LOG_RETENTION_DAYS` | `7` | Days to keep rotated application log files. |
| `APP_LOG_MAX_FILES` | `20` | Maximum rotated log file backups. |
| `CALL_LOG_RETENTION_DAYS` | `7` | Days to keep request/call log entries in the database. |
| `CALL_LOG_MAX_ENTRIES` | `10000` | Max call log entries in the in-memory buffer. |
| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. |
| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `proxy_logs` SQLite table before pruning. |
---
## 17. Memory Optimization
| Variable | Default | Description |
| -------------------------- | ------------------------------- | ---------------------------------------------------------------------- |
| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. |
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. |
| `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. |
| `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. |
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max cached temperature=0 responses. |
| `SEMANTIC_CACHE_MAX_BYTES` | `4194304` (4 MB) | Max total semantic cache size. |
| `SEMANTIC_CACHE_TTL_MS` | `1800000` (30 min) | Semantic cache entry TTL. |
| `STREAM_HISTORY_MAX` | `50` | Max recent stream events in the Dashboard live view buffer. |
| `CONTEXT_LENGTH_DEFAULT` | `128000` | Global fallback max context length for models without explicit config. |
| `USAGE_TOKEN_BUFFER` | `100` | Extra token headroom reserved when tracking usage quotas. |
### Low-RAM Docker Example
```bash
OMNIROUTE_MEMORY_MB=128
PROMPT_CACHE_MAX_SIZE=20
PROMPT_CACHE_MAX_BYTES=524288 # 512 KB
SEMANTIC_CACHE_MAX_SIZE=25
SEMANTIC_CACHE_MAX_BYTES=1048576 # 1 MB
STREAM_HISTORY_MAX=10
```
---
## 18. Pricing Sync
Automatic model pricing data synchronization from external sources.
| Variable | Default | Source File | Description |
| ----------------------- | ------------- | ------------------------ | ----------------------------- |
| `PRICING_SYNC_ENABLED` | `false` | `src/lib/pricingSync.ts` | Opt-in periodic pricing sync. |
| `PRICING_SYNC_INTERVAL` | `86400` (24h) | `src/lib/pricingSync.ts` | Sync interval in seconds. |
| `PRICING_SYNC_SOURCES` | `litellm` | `src/lib/pricingSync.ts` | Comma-separated data sources. |
---
## 19. Model Sync (Dev)
| Variable | Default | Source File | Description |
| -------------------------- | ------------- | -------------------------- | -------------------------------------------------------- |
| `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. |
---
## 20. Provider-Specific Settings
| Variable | Default | Source File | Description |
| ----------------------------------------- | ------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------- |
| `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter model catalog cache TTL. |
| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. |
| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. |
| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. |
| `CLOUDFLARED_BIN` | auto-detect | `src/lib/cloudflaredTunnel.ts` | Custom path to `cloudflared` binary. |
| `SEARCH_CACHE_TTL_MS` | `300000` (5 min) | `open-sse/services/searchCache.ts` | TTL for search API (Perplexity, Brave, etc.) response caching. |
| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | Allow multiple simultaneous connections per OpenAI-compatible provider. |
| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | Enable experimental Claude Code compatible provider endpoint. |
| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). |
| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. |
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). |
---
## 21. Proxy Health
| Variable | Default | Source File | Description |
| ---------------------------- | ---------------- | --------------------------------------- | ----------------------------------------------------- |
| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. |
| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. |
| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. |
---
## 22. Debugging
> [!CAUTION]
> These variables produce **verbose output** and may leak sensitive data. **Never enable in production.**
| Variable | Default | Source File | Description |
| -------------------------------- | --------- | ----------------------------------------- | -------------------------------------------------------------- |
| `CURSOR_PROTOBUF_DEBUG` | _(unset)_ | `open-sse/utils/cursorProtobuf.ts` | Set `1` to dump Cursor protobuf decode/encode details. |
| `CURSOR_STREAM_DEBUG` | _(unset)_ | `open-sse/executors/cursor.ts` | Set `1` to dump raw Cursor SSE stream data. |
| `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. |
| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Set `true` to enable E2E test mode (relaxed auth, test hooks). |
---
## 23. GitHub Integration
Allow users to report issues directly from the Dashboard.
| Variable | Default | Source File | Description |
| --------------------- | --------- | --------------------------------------- | ------------------------------------------------------- |
| `GITHUB_ISSUES_REPO` | _(unset)_ | `src/app/api/v1/issues/report/route.ts` | Repository in `owner/repo` format. |
| `GITHUB_ISSUES_TOKEN` | _(unset)_ | `src/app/api/v1/issues/report/route.ts` | GitHub Personal Access Token with `issues:write` scope. |
---
## Deployment Scenarios
### Minimal Local Development
```bash
JWT_SECRET=$(openssl rand -base64 48)
API_KEY_SECRET=$(openssl rand -hex 32)
INITIAL_PASSWORD=dev123
PORT=20128
NODE_ENV=development
```
### Docker Production
```bash
JWT_SECRET=<generated>
API_KEY_SECRET=<generated>
INITIAL_PASSWORD=<generated>
STORAGE_ENCRYPTION_KEY=<generated>
DATA_DIR=/data
PORT=20128
API_PORT=20129
NODE_ENV=production
AUTH_COOKIE_SECURE=true
REQUIRE_API_KEY=true
NEXT_PUBLIC_BASE_URL=https://omniroute.example.com
BASE_URL=http://localhost:20128
OMNIROUTE_MEMORY_MB=512
CORS_ORIGIN=https://your-frontend.example.com
```
### Air-Gapped / CI
```bash
JWT_SECRET=test-jwt-secret-for-ci
API_KEY_SECRET=test-api-key-secret-for-ci
INITIAL_PASSWORD=testpass
NODE_ENV=production
OMNIROUTE_DISABLE_BACKGROUND_SERVICES=true
APP_LOG_TO_FILE=false
```
### VPS with Reverse Proxy (nginx + Cloudflare)
```bash
JWT_SECRET=<generated>
API_KEY_SECRET=<generated>
STORAGE_ENCRYPTION_KEY=<generated>
PORT=20128
AUTH_COOKIE_SECURE=true
REQUIRE_API_KEY=true
NEXT_PUBLIC_BASE_URL=https://omniroute.example.com
BASE_URL=http://127.0.0.1:20128
CORS_ORIGIN=https://omniroute.example.com
ENABLE_TLS_FINGERPRINT=true
CLI_COMPAT_ALL=1
```
---
## Audit: Removed / Dead Variables
The following variables appeared in previous versions of `.env.example` but have **no runtime references** in the current codebase. They have been removed:
| Variable | Reason |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `STORAGE_DRIVER=sqlite` | Never read by any source file. SQLite is the only supported driver — no selection needed. |
| `INSTANCE_NAME=omniroute` | Present in old docs/env templates but unused at runtime. May return in a future multi-instance feature. |
| `SQLITE_MAX_SIZE_MB=2048` | Not referenced in source code. Database size is not artificially limited. |
| `SQLITE_CLEAN_LEGACY_FILES=true` | Not referenced in source code. Legacy cleanup was likely removed. |
| `CLI_ROO_BIN` | Not registered in `src/shared/services/cliRuntime.ts`. |
| `CLI_KIMI_CODING_BIN` | Not registered in `src/shared/services/cliRuntime.ts` (Kimi Coding uses OAuth, not a CLI binary). |
| `IFLOW_OAUTH_CLIENT_ID` / `IFLOW_OAUTH_CLIENT_SECRET` | Not referenced anywhere in source code. |
### Default Value Corrections
| Variable | Old `.env.example` Value | Actual Code Default | Fixed |
| ------------------------- | ------------------------ | ------------------- | ------------------------------------------------------ |
| `APP_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Removed misleading value; documented `7` as default |
| `CALL_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Removed misleading value; documented `7` as default |

View File

@@ -18,6 +18,13 @@ Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI)
Create model routing combos with 13 strategies: priority, weighted, round-robin, random, least-used, cost-optimized, strict-random, auto, fill-first, p2c, lkgp, context-optimized, and **context-relay**. Each combo chains multiple models with automatic fallback and includes quick templates and readiness checks.
Recent combo improvements:
- **Structured combo builder** — create each step by selecting provider, model, and exact account/connection
- **Repeated provider support** — reuse the same provider many times in one combo as long as the `(provider, model, connection)` tuple is unique
- **Combo target health** — analytics and health surfaces now distinguish individual combo targets/steps instead of collapsing everything into model strings
- **Composite tier ordering** — `defaultTier -> fallbackTier` now influences runtime execution/fallback order for top-level combo steps
![Combos Dashboard](screenshots/02-combos.png)
---
@@ -32,7 +39,7 @@ Comprehensive usage analytics with token consumption, cost estimates, activity h
## 🏥 System Health
Real-time monitoring: uptime, memory, version, latency percentiles (p50/p95/p99), cache statistics, and provider circuit breaker states.
Real-time monitoring: uptime, memory, version, latency percentiles (p50/p95/p99), cache statistics, provider circuit breaker states, active quota-monitored sessions, and combo target health.
![Health Dashboard](screenshots/04-health.png)

155
docs/UNINSTALL.md Normal file
View File

@@ -0,0 +1,155 @@
# OmniRoute — Uninstall Guide
🌐 **Languages:** 🇺🇸 [English](UNINSTALL.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/UNINSTALL.md) | 🇪🇸 [Español](i18n/es/UNINSTALL.md) | 🇫🇷 [Français](i18n/fr/UNINSTALL.md) | 🇮🇹 [Italiano](i18n/it/UNINSTALL.md) | 🇷🇺 [Русский](i18n/ru/UNINSTALL.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/UNINSTALL.md) | 🇩🇪 [Deutsch](i18n/de/UNINSTALL.md) | 🇮🇳 [हिन्दी](i18n/in/UNINSTALL.md) | 🇹🇭 [ไทย](i18n/th/UNINSTALL.md) | 🇺🇦 [Українська](i18n/uk-UA/UNINSTALL.md) | 🇸🇦 [العربية](i18n/ar/UNINSTALL.md) | 🇯🇵 [日本語](i18n/ja/UNINSTALL.md) | 🇻🇳 [Tiếng Việt](i18n/vi/UNINSTALL.md) | 🇧🇬 [Български](i18n/bg/UNINSTALL.md) | 🇩🇰 [Dansk](i18n/da/UNINSTALL.md) | 🇫🇮 [Suomi](i18n/fi/UNINSTALL.md) | 🇮🇱 [עברית](i18n/he/UNINSTALL.md) | 🇭🇺 [Magyar](i18n/hu/UNINSTALL.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/UNINSTALL.md) | 🇰🇷 [한국어](i18n/ko/UNINSTALL.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/UNINSTALL.md) | 🇳🇱 [Nederlands](i18n/nl/UNINSTALL.md) | 🇳🇴 [Norsk](i18n/no/UNINSTALL.md) | 🇵🇹 [Português (Portugal)](i18n/pt/UNINSTALL.md) | 🇷🇴 [Română](i18n/ro/UNINSTALL.md) | 🇵🇱 [Polski](i18n/pl/UNINSTALL.md) | 🇸🇰 [Slovenčina](i18n/sk/UNINSTALL.md) | 🇸🇪 [Svenska](i18n/sv/UNINSTALL.md) | 🇵🇭 [Filipino](i18n/phi/UNINSTALL.md) | 🇨🇿 [Čeština](i18n/cs/UNINSTALL.md)
This guide covers how to cleanly remove OmniRoute from your system.
---
## Quick Uninstall (v3.6.2+)
OmniRoute provides two built-in scripts for clean removal:
### Keep Your Data
```bash
npm run uninstall
```
This removes the OmniRoute application but **preserves** your database, configurations, API keys, and provider settings in `~/.omniroute/`. Use this if you plan to reinstall later and want to keep your setup.
### Full Removal
```bash
npm run uninstall:full
```
This removes the application **and permanently erases** all data:
- Database (`storage.sqlite`)
- Provider configurations and API keys
- Backup files
- Log files
- All files in the `~/.omniroute/` directory
> ⚠️ **Warning:** `npm run uninstall:full` is irreversible. All your provider connections, combos, API keys, and usage history will be permanently deleted.
---
## Manual Uninstall
### NPM Global Install
```bash
# Remove the global package
npm uninstall -g omniroute
# (Optional) Remove data directory
rm -rf ~/.omniroute
```
### pnpm Global Install
```bash
pnpm uninstall -g omniroute
rm -rf ~/.omniroute
```
### Docker
```bash
# Stop and remove the container
docker stop omniroute
docker rm omniroute
# Remove the volume (deletes all data)
docker volume rm omniroute-data
# (Optional) Remove the image
docker rmi diegosouzapw/omniroute:latest
```
### Docker Compose
```bash
# Stop and remove containers
docker compose down
# Also remove volumes (deletes all data)
docker compose down -v
```
### Electron Desktop App
**Windows:**
- Open `Settings → Apps → OmniRoute → Uninstall`
- Or run the NSIS uninstaller from the install directory
**macOS:**
- Drag `OmniRoute.app` from `/Applications` to Trash
- Remove data: `rm -rf ~/Library/Application Support/omniroute`
**Linux:**
- Remove the AppImage file
- Remove data: `rm -rf ~/.omniroute`
### Source Install (git clone)
```bash
# Remove the cloned directory
rm -rf /path/to/omniroute
# (Optional) Remove data directory
rm -rf ~/.omniroute
```
---
## Data Directories
OmniRoute stores data in the following locations by default:
| Platform | Default Path | Override |
| ------------- | ----------------------------- | ------------------------- |
| Linux | `~/.omniroute/` | `DATA_DIR` env var |
| macOS | `~/.omniroute/` | `DATA_DIR` env var |
| Windows | `%APPDATA%/omniroute/` | `DATA_DIR` env var |
| Docker | `/app/data/` (mounted volume) | `DATA_DIR` env var |
| XDG-compliant | `$XDG_CONFIG_HOME/omniroute/` | `XDG_CONFIG_HOME` env var |
### Files in the data directory
| File/Directory | Description |
| -------------------- | ------------------------------------------------- |
| `storage.sqlite` | Main database (providers, combos, settings, keys) |
| `storage.sqlite-wal` | SQLite write-ahead log (temporary) |
| `storage.sqlite-shm` | SQLite shared memory (temporary) |
| `call_logs/` | Request payload archives |
| `backups/` | Automatic database backups |
| `log.txt` | Legacy request log (optional) |
---
## Verify Complete Removal
After uninstalling, verify there are no remaining files:
```bash
# Check for global npm package
npm list -g omniroute 2>/dev/null
# Check for data directory
ls -la ~/.omniroute/ 2>/dev/null
# Check for running processes
pgrep -f omniroute
```
If any process is still running, stop it:
```bash
pkill -f omniroute
```

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.6.2
version: 3.6.5
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -71,6 +71,37 @@ function resolveNodeExecutable(env = process.env) {
return process.execPath;
}
function resolveServerNodePath(env = process.env) {
const seen = new Set();
const entries = [];
const addEntry = (entry) => {
if (!entry || typeof entry !== "string") return;
const trimmed = entry.trim();
if (!trimmed) return;
const normalized = path.normalize(trimmed);
if (seen.has(normalized)) return; // already included
if (!fs.existsSync(normalized)) {
console.debug("[Electron] NODE_PATH candidate not found (skipped):", normalized);
return;
}
seen.add(normalized);
entries.push(normalized);
};
for (const existing of (env.NODE_PATH || "").split(path.delimiter)) {
addEntry(existing);
}
// Electron-builder installs native modules like better-sqlite3 under
// app.asar.unpacked, while the standalone bundle still carries helper deps
// such as bindings/file-uri-to-path inside resources/app/node_modules.
addEntry(path.join(process.resourcesPath, "app.asar.unpacked", "node_modules"));
addEntry(path.join(NEXT_SERVER_PATH, "node_modules"));
return entries.join(path.delimiter);
}
function resolveDataDir(overridePath, env = process.env) {
if (overridePath && overridePath.trim()) return path.resolve(overridePath);
@@ -538,7 +569,7 @@ function startNextServer() {
PORT: String(serverPort),
NODE_ENV: "production",
ELECTRON_RUN_AS_NODE: "1",
NODE_PATH: path.join(process.resourcesPath, "app.asar.unpacked", "node_modules"),
NODE_PATH: resolveServerNodePath(serverEnv),
},
stdio: "pipe",
});

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.6.2",
"version": "3.6.5",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {
@@ -26,8 +26,8 @@
"electron-updater": "^6.8.3"
},
"devDependencies": {
"electron": "^40.6.1",
"electron-builder": "^25.1.8"
"electron": "^41.2.0",
"electron-builder": "^26.8.1"
},
"build": {
"appId": "online.omniroute.desktop",

View File

@@ -40,6 +40,7 @@ const eslintConfig = [
"node_modules/**",
// VS Code extension and its large test fixtures
"vscode-extension/**",
"_mono_repo/**",
// Electron app
"electron/**",
// Docs

View File

@@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
**Current version:** 3.6.0
**Current version:** 3.6.4
## Tech Stack
@@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
└── .env.example # Environment variable template
```
## Key Features (v3.6.0)
## Key Features (v3.6.4)
### Core Proxy
- **60+ AI providers** with automatic format translation

View File

@@ -67,10 +67,10 @@ const nextConfig = {
//
// We use two strategies:
// 1. Exact-name externals for all known server-side packages.
// 2. Hash-strip catch-all: any require('<name>-<16hexchars>' strips the
// suffix and falls through to the real package name.
// 2. Hash-strip catch-all: any require('<name>-<16hexchars>[/subpath]')
// strips the hash suffix and falls through to the real package name.
//
const HASH_PATTERN = /^(.+)-[0-9a-f]{16}$/;
const HASH_PATTERN = /^(.+)-[0-9a-f]{16}(\/.*)?$/;
const KNOWN_EXTERNALS = new Set([
"better-sqlite3",
@@ -102,13 +102,15 @@ const nextConfig = {
if (KNOWN_EXTERNALS.has(request)) {
return callback(null, `commonjs ${request}`);
}
// Case 2: Hash-suffixed name — strip hash, use base name
// Case 2: Hash-suffixed name — strip hash, preserve subpath
// e.g. "better-sqlite3-90e2652d1716b047" → "better-sqlite3"
// "zod-dcb22c6336e0bc69" → "zod"
// "zod-dcb22c6336e0bc69/v3" → "zod/v3"
// "zod-dcb22c6336e0bc69/v4-mini" → "zod/v4-mini"
const hashMatch = request?.match?.(HASH_PATTERN);
if (hashMatch) {
const baseName = hashMatch[1];
return callback(null, `commonjs ${baseName}`);
const resolved = hashMatch[2] ? `${hashMatch[1]}${hashMatch[2]}` : hashMatch[1];
return callback(null, `commonjs ${resolved}`);
}
callback();
},

View File

@@ -87,6 +87,7 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
"x-app",
"User-Agent",
"X-Claude-Code-Session-Id",
"x-client-request-id",
"X-Stainless-Retry-Count",
"X-Stainless-Timeout",
"X-Stainless-Lang",
@@ -97,14 +98,15 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
"X-Stainless-Runtime-Version",
"Accept",
"accept-language",
"sec-fetch-mode",
"accept-encoding",
"Connection",
],
bodyFieldOrder: [
"model",
"messages",
"system",
"tools",
"tool_choice",
"metadata",
"max_tokens",
"thinking",

View File

@@ -14,6 +14,8 @@ export interface RegistryModel {
id: string;
name: string;
toolCalling?: boolean;
supportsReasoning?: boolean;
supportsVision?: boolean;
targetFormat?: string;
unsupportedParams?: readonly string[];
/** Maximum context window in tokens */
@@ -568,9 +570,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"Content-Type": "application/connect+proto",
"User-Agent": "connect-es/1.6.1",
"User-Agent": "Cursor/3.1.0",
},
clientVersion: "1.1.3",
clientVersion: "3.1.0",
models: [
{ id: "default", name: "Auto (Server Picks)" },
{ id: "claude-4.6-opus-high-thinking", name: "Claude 4.6 Opus High Thinking" },

View File

@@ -1,12 +1,61 @@
import crypto, { randomUUID } from "crypto";
import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts";
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts";
import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts";
import { antigravityUserAgent, googApiClientHeader } from "../services/antigravityHeaders.ts";
import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts";
import {
injectCreditsField,
shouldRetryWithCredits,
handleCreditsFailure,
} from "../services/antigravityCredits.ts";
import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts";
const MAX_RETRY_AFTER_MS = 60_000;
const LONG_RETRY_THRESHOLD_MS = 60_000;
const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours
const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]);
/**
* Per-account GOOGLE_ONE_AI credits-exhausted tracker.
* Key: accountId (OAuth subject / email). Value: expiry timestamp.
* When credits hit 0 we skip the credit retry for CREDITS_EXHAUSTED_TTL_MS.
*/
const creditsExhaustedUntil = new Map<string, number>();
/**
* Per-account GOOGLE_ONE_AI remaining credit balance cache.
* Populated from the final SSE chunk's `remainingCredits` field after every
* successful credit-injected request. Keyed by accountId.
*/
const creditBalanceCache = new Map<string, number>();
/** Read the last-known GOOGLE_ONE_AI credit balance for a given account. */
export function getAntigravityRemainingCredits(accountId: string): number | null {
const balance = creditBalanceCache.get(accountId);
return balance !== undefined ? balance : null;
}
/** Update the balance cache — called when we parse `remainingCredits` from an SSE stream. */
export function updateAntigravityRemainingCredits(accountId: string, balance: number): void {
creditBalanceCache.set(accountId, balance);
}
function isCreditsExhausted(accountId: string): boolean {
const until = creditsExhaustedUntil.get(accountId);
if (!until) return false;
if (Date.now() >= until) {
creditsExhaustedUntil.delete(accountId);
return false;
}
return true;
}
function markCreditsExhausted(accountId: string): void {
creditsExhaustedUntil.set(accountId, Date.now() + CREDITS_EXHAUSTED_TTL_MS);
}
/**
* Strip provider prefixes (e.g. "antigravity/model" → "model").
* Ensures the model name sent to the upstream API never contains a routing prefix.
@@ -39,13 +88,16 @@ export class AntigravityExecutor extends BaseExecutor {
}
buildHeaders(credentials, stream = true) {
return {
const raw = {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.accessToken}`,
"User-Agent": this.config.headers?.["User-Agent"] || "antigravity/1.104.0 darwin/arm64",
"X-OmniRoute-Source": "omniroute",
"User-Agent": antigravityUserAgent(),
"X-Goog-Api-Client": googApiClientHeader(),
Accept: "text/event-stream",
"X-OmniRoute-Source": "omniroute",
};
// Scrub proxy/fingerprint headers that reveal non-native traffic
return scrubProxyAndFingerprintHeaders(raw);
}
transformRequest(model, body, stream, credentials) {
@@ -119,6 +171,20 @@ export class AntigravityExecutor extends BaseExecutor {
const upstreamModel = cleanModelName(model);
// Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor")
const requestContents = transformedRequest.contents;
if (Array.isArray(requestContents)) {
for (const msg of requestContents) {
if (Array.isArray(msg.parts)) {
for (const part of msg.parts) {
if (typeof part.text === "string") {
part.text = obfuscateSensitiveWords(part.text);
}
}
}
}
}
return {
...body,
project: projectId,
@@ -213,7 +279,12 @@ export class AntigravityExecutor extends BaseExecutor {
if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000; // minutes
if (match[3]) totalMs += parseInt(match[3]) * 1000; // seconds
return totalMs > 0 ? totalMs : null;
// "reset after 0s" = burst/RPM limit, not quota exhaustion.
// Return a minimum backoff so the auto-retry loop handles it
// instead of falling through to the 24h exhaustion classifier.
if (totalMs === 0) return 2_000; // 2s minimum burst-limit backoff
return totalMs;
}
/**
@@ -259,6 +330,7 @@ export class AntigravityExecutor extends BaseExecutor {
let textContent = "";
let finishReason = "stop";
let usage: Record<string, unknown> | null = null;
let remainingCredits: Array<{ creditType: string; creditAmount: string }> | null = null;
const lines = rawSSE.split("\n");
for (const line of lines) {
const trimmed = line.trim();
@@ -289,6 +361,10 @@ export class AntigravityExecutor extends BaseExecutor {
total_tokens: um.totalTokenCount || 0,
};
}
// Credit balance — arrives in the final chunk alongside consumedCredits
if (Array.isArray(parsed?.remainingCredits)) {
remainingCredits = parsed.remainingCredits;
}
} catch (e) {
log?.debug?.("SSE_PARSE", `Skipping malformed SSE line: ${payload.slice(0, 80)}`);
}
@@ -307,6 +383,8 @@ export class AntigravityExecutor extends BaseExecutor {
},
],
...(usage && { usage }),
// Expose credit balance for upstream consumers (usage service, dashboard)
...(remainingCredits && { _remainingCredits: remainingCredits }),
};
const syntheticStatus = timedOut ? 504 : response.status;
@@ -334,6 +412,12 @@ export class AntigravityExecutor extends BaseExecutor {
// non-streaming Response so chatCore's non-streaming path stays unchanged.
const upstreamStream = true;
// Account ID for credits-exhausted tracking.
// Key must match getAntigravityUsage() in fetcher.ts (providerSpecificData?.email || sub).
// credentials.email and credentials.sub are populated from the same OAuth token store,
// so the cache keys written here and read in the fetcher will always match.
const accountId: string = credentials?.email || credentials?.sub || "unknown";
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, upstreamStream, urlIndex);
const headers = this.buildHeaders(credentials, upstreamStream);
@@ -369,30 +453,82 @@ export class AntigravityExecutor extends BaseExecutor {
const errorBody = await response.clone().text();
const errorJson = JSON.parse(errorBody);
const errorMessage = errorJson?.error?.message || errorJson?.message || "";
retryMs = this.parseRetryFromErrorMessage(errorMessage);
if (!retryMs) {
// Dynamic quota interpretation logic for Free vs Pro accounts
const lowerMsg = errorMessage.toLowerCase();
// 1. Try to parse explicit retry time from message
const parsedRetryMs = this.parseRetryFromErrorMessage(errorMessage);
if (
lowerMsg.includes("free tier") ||
lowerMsg.includes("exhausted your capacity") ||
lowerMsg.includes("daily limit") ||
lowerMsg.includes("quota exceeded")
) {
// Hard limit hit for Free accounts (or exhausting general capacity), fallback immediately.
// Setting a massive retryMs forces an instant fallback.
retryMs = 24 * 60 * 60 * 1000; // 24 hours
} else if (
lowerMsg.includes("pro") ||
lowerMsg.includes("per minute") ||
lowerMsg.includes("rpm")
) {
// RPM limit for Pro counts, backoff up to 1 minute, then fallback
retryMs = 60 * 1000; // 60s
// 2. Classify 429
const category = classify429(errorMessage);
// 3. For quota_exhausted, attempt Google One AI credits retry FIRST!
if (
category === "quota_exhausted" &&
shouldRetryWithCredits(
credentials?.accessToken || "",
process.env.ANTIGRAVITY_CREDITS === "1" ||
process.env.ANTIGRAVITY_CREDITS === "true"
)
) {
log?.info?.("AG_CREDITS", "Retrying with Google One AI credits");
const creditsBody = injectCreditsField(transformedBody);
try {
const creditsResp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(creditsBody),
signal,
});
if (creditsResp.ok || creditsResp.status !== HTTP_STATUS.RATE_LIMITED) {
log?.info?.("AG_CREDITS", `Credits retry succeeded: ${creditsResp.status}`);
if (!stream) {
const collected = await this.collectStreamToResponse(
creditsResp,
model,
url,
headers,
creditsBody,
log,
signal
);
// Parse _remainingCredits from the synthetic response and cache
try {
const syntheticJson = await collected.response.clone().json();
const rc = syntheticJson?._remainingCredits;
if (Array.isArray(rc)) {
const googleCredit = rc.find((c) => c.creditType === "GOOGLE_ONE_AI");
if (googleCredit) {
const balance = parseInt(googleCredit.creditAmount, 10);
if (!isNaN(balance))
updateAntigravityRemainingCredits(accountId, balance);
}
}
} catch {
/**/
}
return collected;
}
return { response: creditsResp, url, headers, transformedBody: creditsBody };
}
// Credit retry also 429'd
handleCreditsFailure(credentials?.accessToken || "");
log?.warn?.("AG_CREDITS", "Credits retry also 429'd");
// Also mark in our legacy exhaustion map to avoid retrying other routes
markCreditsExhausted(accountId);
} catch (creditsErr) {
handleCreditsFailure(credentials?.accessToken || "");
log?.warn?.("AG_CREDITS", `Credits retry failed: ${creditsErr}`);
}
}
// 4. Decide final retry time (apply 4-tier engine)
const decision: Decision = decide429(category, parsedRetryMs);
retryMs = decision.retryAfterMs;
log?.debug?.(
"AG_429",
`Category: ${category}, Decision: ${decision.kind}${decision.reason}`
);
} catch (e) {
// Ignore parse errors, will fall back to exponential backoff
}

View File

@@ -1,7 +1,8 @@
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
import { getOpenAICompatibleType } from "../services/provider.ts";
import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts";
import { signRequestBody } from "../services/claudeCodeCCH.ts";
/**
* Sanitizes a custom API path to prevent path traversal attacks.
@@ -206,9 +207,7 @@ export class BaseExecutor {
headers["Authorization"] = `Bearer ${effectiveKey}`;
}
if (stream) {
headers["Accept"] = "text/event-stream";
}
headers["Accept"] = stream ? "text/event-stream" : "application/json";
return headers;
}
@@ -331,6 +330,13 @@ export class BaseExecutor {
bodyString = fingerprinted.bodyString;
}
// CCH signing: Claude Code-compatible providers require an xxHash64 integrity
// token over the serialized body. Sign after fingerprint ordering so the hash
// covers the exact bytes that will be sent upstream.
if (isClaudeCodeCompatible(this.provider)) {
bodyString = await signRequestBody(bodyString);
}
mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders);
const fetchOptions: RequestInit = {

View File

@@ -1,8 +1,31 @@
import { BaseExecutor, mergeUpstreamExtraHeaders, mergeAbortSignals } from "./base.ts";
/**
* CLIProxyAPI Executor — routes requests to a local CLIProxyAPI instance.
*
* Always uses the OpenAI-compatible /v1/chat/completions endpoint. CLIProxyAPI
* internally detects Claude models and routes them through its Claude executor
* with full emulation (CCH signing, billing header, system prompt, uTLS,
* multi-account rotation, device profile learning, etc.).
*
* The UI toggle (cliproxyapiMode in providerSpecificData) controls WHETHER
* to use CLIProxyAPI as the backend, not the wire format. Response format
* is always OpenAI-compatible, so chatCore's SSE parsing works unchanged.
*
* Activation:
* 1. Per-provider upstream_proxy_config (mode=cliproxyapi or fallback)
* 2. Per-connection cliproxyapiMode toggle in providerSpecificData (UI)
*/
import {
BaseExecutor,
mergeUpstreamExtraHeaders,
mergeAbortSignals,
type ProviderCredentials,
} from "./base.ts";
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
const DEFAULT_PORT = 8317;
const DEFAULT_HOST = "127.0.0.1";
const HEALTH_CHECK_TIMEOUT_MS = 5000;
function resolveCliproxyapiBaseUrl(): string {
const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST;
@@ -12,6 +35,16 @@ function resolveCliproxyapiBaseUrl(): string {
export { resolveCliproxyapiBaseUrl };
/**
* Check if a connection has CLIProxyAPI deep mode enabled via UI toggle.
* Used by chatCore's resolveExecutorWithProxy to decide routing.
*/
export function isCliproxyapiDeepModeEnabled(
providerSpecificData?: Record<string, unknown> | null
): boolean {
return providerSpecificData?.cliproxyapiMode === "claude-native";
}
export class CliproxyapiExecutor extends BaseExecutor {
private readonly upstreamBaseUrl: string;
@@ -25,20 +58,27 @@ export class CliproxyapiExecutor extends BaseExecutor {
this.upstreamBaseUrl = effectiveBase;
}
buildUrl(_model: string, _stream: boolean, _urlIndex = 0): string {
buildUrl(
_model: string,
_stream: boolean,
_urlIndex = 0,
_credentials: ProviderCredentials | null = null
): string {
// Always OpenAI-compatible. CLIProxyAPI detects Claude models internally
// and applies full emulation (CCH, billing header, system prompt, uTLS).
return `${this.upstreamBaseUrl}/v1/chat/completions`;
}
buildHeaders(credentials: any, stream = true): Record<string, string> {
buildHeaders(credentials: ProviderCredentials | null, stream = true): Record<string, string> {
const key = credentials?.apiKey || credentials?.accessToken;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const key = credentials?.apiKey || credentials?.accessToken;
if (key) {
headers["Authorization"] = `Bearer ${key}`;
}
if (stream) {
headers["Accept"] = "text/event-stream";
}
@@ -46,23 +86,32 @@ export class CliproxyapiExecutor extends BaseExecutor {
return headers;
}
transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any {
if (body && typeof body === "object" && body.model !== model) {
return { ...body, model };
transformRequest(
model: string,
body: unknown,
_stream: boolean,
_credentials: ProviderCredentials | null
): unknown {
if (!body || typeof body !== "object") return body;
const transformed = { ...(body as Record<string, unknown>) };
if (transformed.model !== model) {
transformed.model = model;
}
return body;
return transformed;
}
async execute(input: {
model: string;
body: unknown;
stream: boolean;
credentials: any;
credentials: ProviderCredentials;
signal?: AbortSignal | null;
log?: any;
upstreamExtraHeaders?: Record<string, string> | null;
}) {
const url = this.buildUrl(input.model, input.stream);
const url = this.buildUrl(input.model, input.stream, 0, input.credentials);
const headers = this.buildHeaders(input.credentials, input.stream);
const transformedBody = this.transformRequest(
input.model,
@@ -77,6 +126,11 @@ export class CliproxyapiExecutor extends BaseExecutor {
? mergeAbortSignals(input.signal, timeoutSignal)
: timeoutSignal;
input.log?.info?.(
"CPA",
`CLIProxyAPI → ${url} (model: ${input.model})`
);
const response = await fetch(url, {
method: "POST",
headers,
@@ -90,6 +144,29 @@ export class CliproxyapiExecutor extends BaseExecutor {
return { response, url, headers, transformedBody };
}
/**
* Health check — verifies CLIProxyAPI is reachable.
*/
async healthCheck(): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
const start = Date.now();
try {
const res = await fetch(`${this.upstreamBaseUrl}/health`, {
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
return {
ok: res.ok,
latencyMs: Date.now() - start,
...(!res.ok ? { error: `HTTP ${res.status}` } : {}),
};
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}
}
export default CliproxyapiExecutor;

View File

@@ -1,7 +1,12 @@
import {
getCodexRequestDefaults,
isOpenAIResponsesStoreEnabled,
} from "@/lib/providers/requestDefaults";
import { BaseExecutor } from "./base.ts";
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts";
import { PROVIDERS } from "../config/constants.ts";
import { refreshCodexToken } from "../services/tokenRefresh.ts";
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
// ─── T09: Codex vs Spark Scope-Aware Rate Limiting ────────────────────────
// Codex has two independent quota pools: "codex" (standard) and "spark" (premium).
@@ -160,7 +165,6 @@ export function getCodexDualWindowCooldownMs(
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
type EffortLevel = (typeof EFFORT_ORDER)[number];
const CODEX_FAST_WIRE_VALUE = "priority";
let defaultFastServiceTierEnabled = false;
function stringifyCodexInstructionContent(content: unknown): string {
if (typeof content === "string") {
@@ -285,10 +289,6 @@ function normalizeServiceTierValue(value: unknown): string | undefined {
return normalized;
}
export function setDefaultFastServiceTierEnabled(enabled: boolean): void {
defaultFastServiceTierEnabled = enabled;
}
/**
* Maximum reasoning effort allowed per Codex model.
* Models not listed here default to "xhigh" (unrestricted).
@@ -318,6 +318,18 @@ function clampEffort(model: string, requested: string): string {
return requested;
}
function normalizeEffortValue(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
return normalized || undefined;
}
function consumeResponsesStoreMarker(body: Record<string, unknown>): unknown {
const marker = body._omnirouteResponsesStore;
delete body._omnirouteResponsesStore;
return marker;
}
/**
* Codex Executor - handles OpenAI Codex API (Responses API format)
* Automatically injects default instructions if missing.
@@ -392,8 +404,18 @@ export class CodexExecutor extends BaseExecutor {
* Transform request before sending - inject default instructions if missing
*/
transformRequest(model, body, stream, credentials) {
// Do not mutate the caller's payload in place. Combo quality checks and
// other post-execute paths still inspect the original request body.
body =
body && typeof body === "object" ? structuredClone(body) : ({} as Record<string, unknown>);
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
const requestDefaults = getCodexRequestDefaults(credentials?.providerSpecificData);
const storeEnabled = isOpenAIResponsesStoreEnabled(credentials?.providerSpecificData);
const thinkingBudgetConfig = getThinkingBudgetConfig();
const allowConnectionReasoningDefaults = thinkingBudgetConfig.mode === ThinkingMode.PASSTHROUGH;
const responsesStoreMarker = consumeResponsesStoreMarker(body);
// Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely.
if (isCompactRequest) {
@@ -407,8 +429,8 @@ export class CodexExecutor extends BaseExecutor {
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
if (requestServiceTier) {
body.service_tier = requestServiceTier;
} else if (defaultFastServiceTierEnabled) {
body.service_tier = CODEX_FAST_WIRE_VALUE;
} else if (requestDefaults.serviceTier) {
body.service_tier = requestDefaults.serviceTier;
}
// If no instructions provided, inject default Codex instructions
@@ -418,8 +440,11 @@ export class CodexExecutor extends BaseExecutor {
body.instructions = CODEX_DEFAULT_INSTRUCTIONS;
}
// Ensure store is false (Codex requirement)
body.store = false;
if (!storeEnabled) {
body.store = false;
} else if (responsesStoreMarker !== undefined && body.store === undefined) {
body.store = responsesStoreMarker;
}
// Cursor can send native Responses payloads with role=system items inside `input`.
// Codex rejects system messages there; they must be folded into `instructions`.
@@ -435,38 +460,43 @@ export class CodexExecutor extends BaseExecutor {
delete body.messages;
delete body.prompt;
if (nativeCodexPassthrough) {
return body;
}
// Extract thinking level from model name suffix
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
const effortLevels = ["none", "low", "medium", "high", "xhigh"];
let modelEffort: string | null = null;
// Track the clean model name (suffix stripped) for clamp lookup
let cleanModel = model;
let cleanModel = typeof body.model === "string" ? body.model : model;
for (const level of effortLevels) {
if (model.endsWith(`-${level}`)) {
if (typeof cleanModel === "string" && cleanModel.endsWith(`-${level}`)) {
modelEffort = level;
// Strip suffix from model name for actual API call
body.model = body.model.replace(`-${level}`, "");
body.model = cleanModel.slice(0, -`-${level}`.length);
cleanModel = body.model;
break;
}
}
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
if (!body.reasoning) {
const rawEffort = body.reasoning_effort || modelEffort || "medium";
// Clamp effort to the model's maximum allowed level (feature-07)
const effort = clampEffort(cleanModel, rawEffort);
body.reasoning = { effort };
} else if (body.reasoning.effort) {
// Also clamp if reasoning object was provided directly
body.reasoning.effort = clampEffort(cleanModel, body.reasoning.effort);
const explicitReasoning = normalizeEffortValue(body?.reasoning?.effort);
const requestReasoningEffort = normalizeEffortValue(body.reasoning_effort);
const fallbackReasoningEffort = allowConnectionReasoningDefaults
? requestDefaults.reasoningEffort || "medium"
: undefined;
const rawEffort =
explicitReasoning || requestReasoningEffort || modelEffort || fallbackReasoningEffort;
if (explicitReasoning) {
body.reasoning = {
...(body.reasoning && typeof body.reasoning === "object" ? body.reasoning : {}),
effort: clampEffort(cleanModel, explicitReasoning),
};
} else if (rawEffort) {
body.reasoning = {
...(body.reasoning && typeof body.reasoning === "object" ? body.reasoning : {}),
effort: clampEffort(cleanModel, rawEffort),
};
}
delete body.reasoning_effort;
if (nativeCodexPassthrough) {
return body;
}
// Remove unsupported parameters for Codex API
delete body.temperature;
delete body.top_p;

View File

@@ -33,6 +33,9 @@ import crypto from "crypto";
import { v5 as uuidv5 } from "uuid";
import zlib from "zlib";
const CURSOR_CLIENT_VERSION = "3.1.0";
const CURSOR_USER_AGENT = `Cursor/${CURSOR_CLIENT_VERSION}`;
// Detect cloud environment
const isCloudEnv = () => {
if (typeof caches !== "undefined" && typeof caches === "object") return true;
@@ -251,11 +254,11 @@ export class CursorExecutor extends BaseExecutor {
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"content-type": "application/connect+proto",
"user-agent": "connect-es/1.6.1",
"user-agent": CURSOR_USER_AGENT,
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
"x-client-key": crypto.createHash("sha256").update(cleanToken).digest("hex"),
"x-cursor-checksum": this.generateChecksum(machineId),
"x-cursor-client-version": "2.3.41",
"x-cursor-client-version": CURSOR_CLIENT_VERSION,
"x-cursor-client-type": "ide",
"x-cursor-client-os":
process.platform === "win32"
@@ -265,6 +268,7 @@ export class CursorExecutor extends BaseExecutor {
: "linux",
"x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64",
"x-cursor-client-device-type": "desktop",
"x-cursor-user-agent": CURSOR_USER_AGENT,
"x-cursor-config-version": crypto.randomUUID(),
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
"x-ghost-mode": ghostMode ? "true" : "false",

View File

@@ -9,6 +9,7 @@ import {
} from "../services/claudeCodeCompatible.ts";
import { getGigachatAccessToken } from "../services/gigachatAuth.ts";
import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts";
import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts";
function normalizeBaseUrl(baseUrl) {
return (baseUrl || "").trim().replace(/\/$/, "");
@@ -179,7 +180,7 @@ export class DefaultExecutor extends BaseExecutor {
}
}
if (stream) headers["Accept"] = "text/event-stream";
headers["Accept"] = stream ? "text/event-stream" : "application/json";
// Qwen header cleanup: Remove X-Dashscope-* headers if using an API key (DashScope compatible mode).
// If using OAuth (Qwen Code), we MUST keep them for portal.qwen.ai to accept the request.
@@ -203,6 +204,12 @@ export class DefaultExecutor extends BaseExecutor {
* "org/model-name") — we must NOT strip path segments. (Fix #493)
*/
transformRequest(model, body, stream, credentials) {
void model;
void stream;
void credentials;
if (this.provider === "qwen" && typeof body === "object" && body !== null) {
return sanitizeQwenThinkingToolChoice(body, "QwenExecutor");
}
return body;
}

View File

@@ -1,5 +1,8 @@
import { BaseExecutor } from "./base.ts";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
import { geminiCLIUserAgent, googApiClientHeader } from "../services/antigravityHeaders.ts";
import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts";
import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts";
const LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist";
const PROJECT_TTL_MS = 30_000; // 30 seconds — matches native Gemini CLI
@@ -22,19 +25,20 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
buildHeaders(credentials, stream = true) {
return {
const raw = {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.accessToken}`,
// Fingerprint headers matching native GeminiCLI client (prevents upstream rejection)
"User-Agent": "GeminiCLI/0.31.0/unknown (linux; x64)",
"X-Goog-Api-Client": "google-genai-sdk/1.41.0 gl-node/v22.19.0",
// Dynamic headers matching native GeminiCLI client
"User-Agent": geminiCLIUserAgent(this._currentModel || "unknown"),
"X-Goog-Api-Client": googApiClientHeader(),
...(stream && { Accept: "text/event-stream" }),
// NOTE: x-goog-user-project removed — the stored projectId can become stale for
// free-tier accounts, causing 403 "Cloud Code Private API has not been used in
// project X". The API resolves the correct project from the OAuth token alone.
};
return scrubProxyAndFingerprintHeaders(raw);
}
// Track current model for dynamic UA (set by transformRequest)
private _currentModel = "unknown";
/**
* Fetch the current cloudaicompanionProject via loadCodeAssist API.
* Native Gemini CLI refreshes this every 30 seconds — OmniRoute stores it once
@@ -134,15 +138,29 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
async transformRequest(model, body, stream, credentials) {
// Track model for dynamic User-Agent
this._currentModel = model || "unknown";
// Refresh the project ID via loadCodeAssist (cached for 30s).
// The translator builds the envelope with the stale stored projectId —
// we replace it here with the fresh one before sending to the API.
if (body && typeof body === "object" && body.request && credentials.accessToken) {
const freshProject = await this.refreshProject(credentials.accessToken);
if (freshProject) {
body.project = freshProject;
}
// If refresh failed, keep the stale projectId as a best-effort fallback
// Obfuscate sensitive client names in user content
const contents = body.request?.contents;
if (Array.isArray(contents)) {
for (const msg of contents) {
if (Array.isArray(msg.parts)) {
for (const part of msg.parts) {
if (typeof part.text === "string") {
part.text = obfuscateSensitiveWords(part.text);
}
}
}
}
}
}
return body;
}

View File

@@ -5,6 +5,7 @@ import {
type ProviderCredentials,
} from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts";
function getAuthToken(credentials: ProviderCredentials): string {
if (typeof credentials.apiKey === "string" && credentials.apiKey.trim()) {
@@ -27,6 +28,15 @@ export class QoderExecutor extends BaseExecutor {
super("qoder", PROVIDERS.qoder);
}
transformRequest(model: string, body: unknown): Record<string, unknown> {
const payload = {
...(typeof body === "object" && body !== null ? body : {}),
model,
};
return sanitizeQwenThinkingToolChoice(payload, "QoderExecutor");
}
async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) {
const token = getAuthToken(credentials);
@@ -90,10 +100,7 @@ export class QoderExecutor extends BaseExecutor {
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
const payload = {
...(typeof body === "object" && body !== null ? body : {}),
model: mappedModel,
};
const payload = this.transformRequest(mappedModel, body, stream, credentials);
const bodyStr = JSON.stringify(payload);

View File

@@ -13,7 +13,7 @@ interface ServiceAccount {
const TOKEN_CACHE = new Map<string, { token: string; expiresAt: number }>();
function parseSAFromApiKey(apiKey: string): ServiceAccount {
export function parseSAFromApiKey(apiKey: string): ServiceAccount {
try {
return JSON.parse(apiKey);
} catch {
@@ -21,7 +21,7 @@ function parseSAFromApiKey(apiKey: string): ServiceAccount {
}
}
async function getAccessToken(sa: ServiceAccount): Promise<string> {
export async function getAccessToken(sa: ServiceAccount): Promise<string> {
if (!sa.client_email || !sa.private_key) {
throw new Error(
"Service Account JSON is missing required fields (client_email or private_key)"

View File

@@ -129,6 +129,11 @@ import {
isClaudeCodeCompatibleProvider,
resolveClaudeCodeCompatibleSessionId,
} from "../services/claudeCodeCompatible.ts";
import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts";
import {
enforceThinkingTemperature,
disableThinkingIfToolChoiceForced,
} from "../services/claudeCodeConstraints.ts";
function extractMemoryTextFromResponse(
response: Record<string, unknown> | null | undefined
@@ -479,6 +484,8 @@ export async function handleChatCore({
comboName,
comboStrategy = null,
isCombo = false,
comboStepId = null,
comboExecutionKey = null,
disableEmergencyFallback = false,
}) {
let { provider, model, extendedContext } = modelInfo;
@@ -746,6 +753,8 @@ export async function handleChatCore({
sourceFormat,
targetFormat,
comboName,
comboStepId,
comboExecutionKey,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
noLog: noLogEnabled,
@@ -808,6 +817,7 @@ export async function handleChatCore({
delete b.disable_stream;
delete b.disable_streaming;
delete b.streaming;
delete b.prompt_cache_retention;
}
const stream = resolveStreamFlag(body?.stream, acceptHeader);
@@ -952,7 +962,10 @@ export async function handleChatCore({
let translatedBody = body;
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider);
const upstreamStream = stream || isClaudeCodeCompatible;
// Respect the client's explicit non-streaming intent for CC-compatible providers.
// Most upstreams can answer JSON directly; the SSE->JSON fallback remains as a
// compatibility path when an upstream still responds with event-stream.
const upstreamStream = stream;
let ccSessionId: string | null = null;
// Determine if we should preserve client-side cache_control headers
@@ -1017,6 +1030,17 @@ export async function handleChatCore({
now: new Date(),
preserveCacheControl,
});
// Apply PR #1188 parity pipeline (synchronous steps — CCH signing is async and
// runs later in BaseExecutor over the serialized string).
// Only thinking constraints and tool remapping are applied here; cache-control
// limit enforcement (enforceCacheControlLimit) is intentionally omitted because
// the billing-header system block added by buildClaudeCodeCompatibleRequest counts
// toward the 4-block cap and would strip legitimate client cache markers.
remapToolNamesInRequest(translatedBody);
enforceThinkingTemperature(translatedBody);
disableThinkingIfToolChoiceForced(translatedBody);
log?.debug?.("FORMAT", "claude-code-compatible bridge enabled");
} else if (isClaudePassthrough && preserveCacheControl) {
// Pure passthrough: when preserveCacheControl is true, forward the body
@@ -1985,6 +2009,7 @@ export async function handleChatCore({
trackPendingRequest(model, provider, connectionId, false);
const contentType = (providerResponse.headers.get("content-type") || "").toLowerCase();
let responseBody;
let responseFormatForTranslation = targetFormat;
const rawBody = await providerResponse.text();
const normalizedProviderPayload = normalizePayloadForLog(rawBody);
const looksLikeSSE =
@@ -1992,10 +2017,19 @@ export async function handleChatCore({
if (looksLikeSSE) {
// Upstream returned SSE even though stream=false; convert best-effort to JSON.
const looksLikeResponsesSSE =
targetFormat === FORMATS.OPENAI_RESPONSES ||
provider === "codex" ||
/(^|\n)\s*(?:event:\s*response\.|data:\s*\{.*"type"\s*:\s*"response\.)/m.test(rawBody);
responseFormatForTranslation = looksLikeResponsesSSE
? FORMATS.OPENAI_RESPONSES
: targetFormat === FORMATS.CLAUDE
? FORMATS.CLAUDE
: FORMATS.OPENAI;
const parsedFromSSE =
targetFormat === FORMATS.OPENAI_RESPONSES
responseFormatForTranslation === FORMATS.OPENAI_RESPONSES
? parseSSEToResponsesOutput(rawBody, model)
: targetFormat === FORMATS.CLAUDE
: responseFormatForTranslation === FORMATS.CLAUDE
? parseSSEToClaudeResponse(rawBody, model)
: parseSSEToOpenAIResponse(rawBody, model);
@@ -2187,10 +2221,10 @@ export async function handleChatCore({
// Translate response to client's expected format (usually OpenAI)
// Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605)
let translatedResponse = needsTranslation(targetFormat, clientResponseFormat)
let translatedResponse = needsTranslation(responseFormatForTranslation, clientResponseFormat)
? translateNonStreamingResponse(
responseBody,
targetFormat,
responseFormatForTranslation,
clientResponseFormat,
toolNameMap as Map<string, string> | null
)

View File

@@ -32,6 +32,23 @@ function toNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function hasVisibleMessageContent(content: unknown): boolean {
if (typeof content === "string") {
return content.trim().length > 0;
}
if (!Array.isArray(content)) return false;
return content.some((contentPart) => {
const part = toRecord(contentPart);
if (!part) return false;
if (typeof part.text === "string" && part.text.trim().length > 0) return true;
if (typeof part.content === "string" && part.content.trim().length > 0) return true;
const partType = toString(part.type);
return Boolean(partType && partType !== "thinking" && partType !== "reasoning");
});
}
// Matches <think>...</think> blocks and <thinking>...</thinking> (greedy, dotAll)
const THINK_TAG_REGEX = /<(?:think|thinking)>([\s\S]*?)<\/(?:think|thinking)>/gi;
@@ -216,6 +233,13 @@ function sanitizeMessage(msg: unknown): unknown {
}
}
// Non-streaming responses should not expose both visible content and reasoning_content.
// Some clients drop the visible assistant text or render duplicated panels when both fields
// are present in the final payload. Keep reasoning_content only for reasoning-only messages.
if (sanitized.reasoning_content !== undefined && hasVisibleMessageContent(sanitized.content)) {
delete sanitized.reasoning_content;
}
// Preserve tool_calls
if (msgRecord.tool_calls) {
sanitized.tool_calls = msgRecord.tool_calls;

View File

@@ -35,7 +35,7 @@ export async function handleResponsesCore({
signal,
}) {
// Convert Responses API format to Chat Completions format
const convertedBody = convertResponsesApiFormat(body);
const convertedBody = convertResponsesApiFormat(body, credentials);
// Ensure stream is enabled
convertedBody.stream = true;

View File

@@ -399,6 +399,132 @@ export function parseSSEToClaudeResponse(rawSSE, fallbackModel) {
* Convert Responses API SSE events into a single non-streaming response object.
* Expects events such as response.created / response.in_progress / response.completed.
*/
const RESPONSES_TERMINAL_EVENT_TYPES = new Set([
"response.completed",
"response.done",
"response.cancelled",
"response.canceled",
"response.failed",
"response.incomplete",
]);
function toOutputIndex(value) {
if (typeof value === "number" && Number.isInteger(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isInteger(parsed)) return parsed;
}
return null;
}
function cloneResponseItem(item) {
const record = toRecord(item);
return {
...record,
...(Array.isArray(record.content)
? {
content: record.content.map((contentPart) => {
const part = toRecord(contentPart);
return { ...part };
}),
}
: {}),
...(Array.isArray(record.summary)
? {
summary: record.summary.map((summaryPart) => {
const part = toRecord(summaryPart);
return { ...part };
}),
}
: {}),
};
}
function ensureResponsesMessageItem(outputItems, outputIndex) {
const existing = outputItems.get(outputIndex);
if (existing?.type === "message") return existing;
const next = {
...(existing && typeof existing === "object" ? existing : {}),
id: existing?.id || `msg_${Date.now()}_${outputIndex}`,
type: "message",
role: "assistant",
content: Array.isArray(existing?.content)
? existing.content.map((contentPart) => ({ ...toRecord(contentPart) }))
: [{ type: "output_text", annotations: [], text: "" }],
};
if (next.content.length === 0) {
next.content.push({ type: "output_text", annotations: [], text: "" });
}
outputItems.set(outputIndex, next);
return next;
}
function ensureResponsesReasoningItem(outputItems, outputIndex, itemId) {
const existing = outputItems.get(outputIndex);
if (existing?.type === "reasoning") return existing;
const next = {
...(existing && typeof existing === "object" ? existing : {}),
id: itemId || existing?.id || `rs_${Date.now()}_${outputIndex}`,
type: "reasoning",
summary: Array.isArray(existing?.summary)
? existing.summary.map((summaryPart) => ({ ...toRecord(summaryPart) }))
: [{ type: "summary_text", text: "" }],
};
if (next.summary.length === 0) {
next.summary.push({ type: "summary_text", text: "" });
}
outputItems.set(outputIndex, next);
return next;
}
function ensureResponsesFunctionCallItem(outputItems, outputIndex, itemId, callId, name) {
const existing = outputItems.get(outputIndex);
if (existing?.type === "function_call") {
if (callId && !existing.call_id) existing.call_id = callId;
if (name && !existing.name) existing.name = name;
if (itemId && !existing.id) existing.id = itemId;
return existing;
}
const next = {
...(existing && typeof existing === "object" ? existing : {}),
id: itemId || existing?.id || `fc_${callId || `${Date.now()}_${outputIndex}`}`,
type: "function_call",
call_id: callId || existing?.call_id || "",
name: name || existing?.name || "",
arguments: typeof existing?.arguments === "string" ? existing.arguments : "",
};
outputItems.set(outputIndex, next);
return next;
}
function mergeResponseItems(existing, incoming) {
const next = cloneResponseItem(incoming);
if (!existing || typeof existing !== "object") return next;
return {
...existing,
...next,
...(Array.isArray(next.content)
? {
content: next.content,
}
: {}),
...(Array.isArray(next.summary)
? {
summary: next.summary,
}
: {}),
};
}
export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
const lines = String(rawSSE || "").split("\n");
const events = [];
@@ -409,7 +535,11 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
events.push(JSON.parse(payload));
const parsed = JSON.parse(payload);
const record = toRecord(parsed);
if (Object.keys(record).length > 0) {
events.push(record);
}
} catch {
// Ignore malformed lines and continue best-effort parsing.
}
@@ -417,12 +547,104 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
if (events.length === 0) return null;
let completed = null;
let terminalResponse = null;
let terminalEventType = "";
let latestResponse = null;
const outputItems = new Map();
for (const evt of events) {
if (evt?.type === "response.completed" && evt.response) {
completed = evt.response;
const eventType = toString(evt?.type);
const outputIndex = toOutputIndex(evt?.output_index);
const item = toRecord(evt?.item);
if (outputIndex !== null && eventType === "response.output_item.added") {
outputItems.set(outputIndex, cloneResponseItem(item));
}
if (outputIndex !== null && eventType === "response.output_item.done") {
const existing = outputItems.get(outputIndex);
outputItems.set(outputIndex, mergeResponseItems(existing, item));
}
if (outputIndex !== null && eventType === "response.output_text.delta") {
const messageItem = ensureResponsesMessageItem(outputItems, outputIndex);
const content = Array.isArray(messageItem.content) ? messageItem.content : [];
const firstPart =
content.length > 0 ? { ...toRecord(content[0]) } : { type: "output_text", annotations: [] };
firstPart.type = firstPart.type || "output_text";
firstPart.annotations = Array.isArray(firstPart.annotations) ? firstPart.annotations : [];
firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`;
content[0] = firstPart;
messageItem.content = content;
}
if (outputIndex !== null && eventType === "response.output_text.done") {
const messageItem = ensureResponsesMessageItem(outputItems, outputIndex);
const content = Array.isArray(messageItem.content) ? messageItem.content : [];
const firstPart =
content.length > 0 ? { ...toRecord(content[0]) } : { type: "output_text", annotations: [] };
firstPart.type = firstPart.type || "output_text";
firstPart.annotations = Array.isArray(firstPart.annotations) ? firstPart.annotations : [];
firstPart.text = toString(evt.text, toString(firstPart.text));
content[0] = firstPart;
messageItem.content = content;
}
if (outputIndex !== null && eventType === "response.reasoning_summary_text.delta") {
const reasoningItem = ensureResponsesReasoningItem(
outputItems,
outputIndex,
toString(evt.item_id)
);
const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : [];
const firstPart =
summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" };
firstPart.type = firstPart.type || "summary_text";
firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`;
summary[0] = firstPart;
reasoningItem.summary = summary;
}
if (outputIndex !== null && eventType === "response.reasoning_summary_text.done") {
const reasoningItem = ensureResponsesReasoningItem(
outputItems,
outputIndex,
toString(evt.item_id)
);
const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : [];
const firstPart =
summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" };
firstPart.type = firstPart.type || "summary_text";
firstPart.text = toString(evt.text, toString(firstPart.text));
summary[0] = firstPart;
reasoningItem.summary = summary;
}
if (outputIndex !== null && eventType === "response.function_call_arguments.delta") {
const functionCallItem = ensureResponsesFunctionCallItem(
outputItems,
outputIndex,
toString(evt.item_id),
"",
""
);
functionCallItem.arguments = `${toString(functionCallItem.arguments)}${toString(evt.delta)}`;
}
if (outputIndex !== null && eventType === "response.function_call_arguments.done") {
const functionCallItem = ensureResponsesFunctionCallItem(
outputItems,
outputIndex,
toString(evt.item_id),
"",
""
);
functionCallItem.arguments = toString(evt.arguments, toString(functionCallItem.arguments));
}
if (RESPONSES_TERMINAL_EVENT_TYPES.has(eventType) && evt.response) {
terminalResponse = evt.response;
terminalEventType = eventType;
}
if (evt?.response && typeof evt.response === "object") {
latestResponse = evt.response;
@@ -431,16 +653,33 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
}
}
const picked = completed || latestResponse;
const picked = terminalResponse || latestResponse;
if (!picked || typeof picked !== "object") return null;
const reconstructedOutput = [...outputItems.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, item]) => item)
.filter((item) => item && typeof item === "object");
const pickedOutput = Array.isArray(picked.output) ? picked.output : [];
const statusFallback =
terminalEventType === "response.cancelled"
? "cancelled"
: terminalEventType === "response.canceled"
? "canceled"
: terminalEventType === "response.failed"
? "failed"
: terminalEventType === "response.incomplete"
? "incomplete"
: terminalResponse
? "completed"
: "in_progress";
return {
id: picked.id || `resp_${Date.now()}`,
object: "response",
object: picked.object || "response",
model: picked.model || fallbackModel || "unknown",
output: Array.isArray(picked.output) ? picked.output : [],
output: pickedOutput.length > 0 ? pickedOutput : reconstructedOutput,
usage: picked.usage || null,
status: picked.status || (completed ? "completed" : "in_progress"),
status: picked.status || statusFallback,
created_at: picked.created_at || Math.floor(Date.now() / 1000),
metadata: picked.metadata || {},
};

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createMcpServer } from "../server.ts";
import { MCP_TOOL_MAP, dbHealthCheckInput } from "../schemas/tools.ts";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
vi.mock("../audit.ts", () => ({
logToolCall: vi.fn().mockResolvedValue(undefined),
}));
describe("omniroute_db_health_check MCP tool", () => {
let client: Client;
beforeEach(async () => {
mockFetch.mockReset();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createMcpServer();
await server.connect(serverTransport);
client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
});
afterEach(async () => {
await client.close();
});
it("is registered in the MCP tool map", () => {
expect(MCP_TOOL_MAP["omniroute_db_health_check"]).toBeDefined();
expect(MCP_TOOL_MAP["omniroute_db_health_check"]?.phase).toBe(2);
});
it("validates empty input and explicit autoRepair requests", () => {
expect(dbHealthCheckInput.safeParse({}).success).toBe(true);
expect(dbHealthCheckInput.safeParse({ autoRepair: true }).success).toBe(true);
expect(dbHealthCheckInput.safeParse({ autoRepair: "yes" }).success).toBe(false);
});
it("dispatches to /api/v1/db/health using POST when autoRepair=true", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
isHealthy: false,
issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }],
repairedCount: 1,
backupCreated: true,
autoRepair: true,
checkedAt: new Date().toISOString(),
}),
});
const result = await client.callTool({
name: "omniroute_db_health_check",
arguments: { autoRepair: true },
});
expect(result.isError).toBeFalsy();
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/db/health"),
expect.objectContaining({ method: "POST" })
);
const content = result.content[0] as { type: string; text: string };
const payload = JSON.parse(content.text);
expect(payload.repairedCount).toBe(1);
expect(payload.backupCreated).toBe(true);
});
});

View File

@@ -60,6 +60,9 @@ export {
getSessionSnapshotInput,
getSessionSnapshotOutput,
getSessionSnapshotTool,
dbHealthCheckInput,
dbHealthCheckOutput,
dbHealthCheckTool,
cacheStatsInput,
cacheStatsOutput,
cacheStatsTool,

View File

@@ -831,7 +831,51 @@ export const getSessionSnapshotTool: McpToolDefinition<
sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"],
};
// --- Tool 18: omniroute_sync_pricing ---
// --- Tool 18: omniroute_db_health_check ---
export const dbHealthCheckInput = z.object({
autoRepair: z
.boolean()
.optional()
.describe("When true, runs the database auto-repair flow before returning the result"),
});
export const dbHealthCheckOutput = z.object({
isHealthy: z.boolean(),
issues: z.array(
z.object({
type: z.enum([
"integrity_check_failed",
"broken_reference",
"stale_snapshot",
"invalid_state",
]),
table: z.string(),
description: z.string(),
count: z.number(),
})
),
repairedCount: z.number(),
backupCreated: z.boolean(),
autoRepair: z.boolean(),
checkedAt: z.string(),
});
export const dbHealthCheckTool: McpToolDefinition<
typeof dbHealthCheckInput,
typeof dbHealthCheckOutput
> = {
name: "omniroute_db_health_check",
description:
"Diagnoses OmniRoute database drift such as orphan quota/domain rows, invalid JSON state, and broken combo references. Set autoRepair=true to repair those rows before returning the report.",
inputSchema: dbHealthCheckInput,
outputSchema: dbHealthCheckOutput,
scopes: ["read:health", "write:resilience"],
auditLevel: "full",
phase: 2,
sourceEndpoints: ["/api/v1/db/health"],
};
// --- Tool 19: omniroute_sync_pricing ---
export const syncPricingInput = z.object({
sources: z
.array(z.string())
@@ -959,6 +1003,7 @@ export const MCP_TOOLS = [
bestComboForTaskTool,
explainRouteTool,
getSessionSnapshotTool,
dbHealthCheckTool,
syncPricingTool,
cacheStatsTool,
cacheFlushTool,

View File

@@ -12,6 +12,11 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
getComboModelProvider,
getComboModelString,
getComboStepTarget,
} from "../../src/lib/combos/steps.ts";
import {
MCP_TOOLS,
@@ -33,6 +38,7 @@ import {
bestComboForTaskInput,
explainRouteInput,
getSessionSnapshotInput,
dbHealthCheckInput,
syncPricingInput,
} from "./schemas/tools.ts";
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
@@ -54,6 +60,7 @@ import {
handleBestComboForTask,
handleExplainRoute,
handleGetSessionSnapshot,
handleDbHealthCheck,
handleSyncPricing,
} from "./tools/advancedTools.ts";
import { memoryTools } from "./tools/memoryTools.ts";
@@ -105,11 +112,17 @@ function normalizeComboModels(
rawModels: unknown
): Array<{ provider: string; model: string; priority: number }> {
return toArray(rawModels).map((rawModel, index) => {
const model = toRecord(rawModel);
const modelRecord = toRecord(rawModel);
const modelString = getComboModelString(rawModel);
const target = getComboStepTarget(rawModel);
const provider =
getComboModelProvider(rawModel) ||
(modelString ? "unknown" : target ? "combo" : toString(modelRecord.provider, "unknown"));
return {
provider: toString(model.provider, "unknown"),
model: toString(model.model, "unknown"),
priority: toNumber(model.priority, index + 1),
provider,
model: modelString || target || toString(modelRecord.model, "unknown"),
priority: toNumber(modelRecord.priority, index + 1),
};
});
}
@@ -748,6 +761,18 @@ export function createMcpServer(): McpServer {
})
);
server.registerTool(
"omniroute_db_health_check",
{
description:
"Diagnoses or repairs OmniRoute database drift, including broken combo references and orphan quota/domain rows",
inputSchema: dbHealthCheckInput,
},
withScopeEnforcement("omniroute_db_health_check", (args) =>
handleDbHealthCheck(dbHealthCheckInput.parse(args ?? {}))
)
);
server.registerTool(
"omniroute_sync_pricing",
{

View File

@@ -1,5 +1,5 @@
/**
* OmniRoute MCP Advanced Tools — 10 intelligence tools that differentiate
* OmniRoute MCP Advanced Tools — 11 intelligence tools that differentiate
* OmniRoute from all other AI gateways.
*
* Tools:
@@ -12,12 +12,18 @@
* 7. omniroute_best_combo_for_task — AI-powered combo recommendation
* 8. omniroute_explain_route — Post-hoc routing decision explainer
* 9. omniroute_get_session_snapshot — Full session state snapshot
* 10. omniroute_sync_pricing Sync provider pricing from external source
* 10. omniroute_db_health_checkDiagnose and repair DB state drift
* 11. omniroute_sync_pricing — Sync provider pricing from external source
*/
import { logToolCall } from "../audit.ts";
import { normalizeQuotaResponse } from "../../../src/shared/contracts/quota.ts";
import { resolveOmniRouteBaseUrl } from "../../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
import {
getComboModelProvider,
getComboModelString,
getComboStepTarget,
} from "../../../src/lib/combos/steps.ts";
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
@@ -80,8 +86,8 @@ function getComboModels(combo: JsonRecord): ComboModel[] {
const nestedModels = toArrayOfRecords(toRecord(combo.data).models);
const sourceModels = directModels.length > 0 ? directModels : nestedModels;
return sourceModels.map((model) => ({
provider: toString(model.provider, "unknown"),
model: toString(model.model, ""),
provider: getComboModelProvider(model) || (getComboModelString(model) ? "unknown" : "combo"),
model: getComboModelString(model) || getComboStepTarget(model) || "",
inputCostPer1M: toNumber(model.inputCostPer1M, 3.0),
}));
}
@@ -858,3 +864,33 @@ export async function handleGetSessionSnapshot() {
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
}
export async function handleDbHealthCheck(args: { autoRepair?: boolean }) {
const start = Date.now();
const autoRepair = args.autoRepair === true;
try {
const result = toRecord(
await apiFetch("/api/v1/db/health", {
method: autoRepair ? "POST" : "GET",
})
);
await logToolCall(
"omniroute_db_health_check",
args,
{
isHealthy: toBoolean(result.isHealthy, false),
repairedCount: toNumber(result.repairedCount, 0),
},
Date.now() - start,
true
);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await logToolCall("omniroute_db_health_check", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.6.2",
"version": "3.6.5",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",

134
open-sse/services/AGENTS.md Normal file
View File

@@ -0,0 +1,134 @@
# open-sse/services/ — Routing Engine & Cross-Cutting Services
**Purpose**: 36+ service modules powering request routing, rate limiting, quota management, token refresh, fallback strategies, and runtime state. The combo routing engine (`combo.ts`) is the core; supporting services handle resilience, accounting, and decision-making.
---
## Key Services
### Combo Routing Engine
- **`combo.ts`** (800 LOC) — Entry point for multi-model routing. **`handleComboChat()`** iterates through targets in order until success or all fail. **`resolveComboTargets()`** expands combo config into ordered `ResolvedComboTarget[]` (provider + model + account + credentials). Enforces per-target circuit breaker and fallback logic.
- **Strategies** (13 total): `priority` (ordered list), `weighted` (probabilistic), `fill-first` (fill quota first), `round-robin`, `P2C` (power of two choices), `random`, `least-used`, `cost-optimized`, `strict-random`, `auto`, `lkgp` (last known good provider), `context-optimized`, `context-relay`.
- **Circuit Breaker**: Per target, tracks consecutive failures; breaks after threshold, reopens on success or timeout.
### Quota & Rate Limiting
- **`rateLimitManager.ts`** — Enforces upstream rate limits (429, retry-after headers). Implements token bucket per API key + provider combo. Rejects requests exceeding limits before dispatch.
- **`usage.ts`** — Tracks per-request token/cost consumption. Syncs with `quotaSnapshots` table. Reports cumulative usage for analytics.
- **`quotaCache.ts`** — In-memory quota snapshots. Invalidated on write; pre-loaded at startup. Prevents DB thrashing on high-volume requests.
### Account & Token Management
- **`tokenRefresh.ts`** — Handles OAuth token expiration. Detects 401 responses, triggers refresh via provider OAuth endpoint, retries request with new token.
- **`accountFallback.ts`** — If account reaches quota/rate-limit, switches to alternate account (combo targets). Logs account switch event.
- **`sessionManager.ts`** — Manages request session state across retries. Tracks session ID, attempt count, fallback history.
### Request Routing & Intelligence
- **`wildcardRouter.ts`** — Matches wildcard routes in combo configs (e.g., `gpt-*` → all GPT models).
- **`intentClassifier.ts`** — Classifies request intent (chat, embedding, image, video, etc.) for intelligent routing.
- **`taskAwareRouter.ts`** — Routes based on task characteristics (reasoning-heavy → o1, code-gen → Cursor, long-context → Claude).
- **`thinkingBudget.ts`** — Allocates thinking tokens for o1/o3 models; enforces per-request budget.
- **`contextManager.ts`** — Injects routing context (system prompts, memory) into requests.
### Model Lifecycle & Fallback
- **`modelDeprecation.ts`** — Detects deprecated models (gpt-3.5, claude-2, etc.). Routes to successor models automatically.
- **`modelFamilyFallback.ts`** — T5 intra-family fallback: if `gpt-4-turbo` unavailable, tries `gpt-4-1106-preview`, then `gpt-4`.
- **`emergencyFallback.ts`** — Last-resort fallback when all combo targets fail. Routes to stable free provider (Qwen Code, Gemini CLI fallback).
### State & Detection
- **`workflowFSM.ts`** — Finite state machine for multi-turn workflows (prompt engineering → execution → validation).
- **`backgroundTaskDetector.ts`** — Detects long-running background tasks; routes to batch APIs or defers execution.
- **`ipFilter.ts`** — IP-based routing rules (geographic or access control).
- **`signatureCache.ts`** — Caches request signatures for duplicate detection and deduplication.
- **`volumeDetector.ts`** — Detects request volume spikes; triggers rate-limit escalation or load-shedding.
- **`contextHandoff.ts`** — Serializes/restores session context for agent handoff (A2A protocol).
### Auto-Routing & Adaptive
- **`autoCombo/`** — Auto-generates combo configs based on historical performance, cost, and latency.
- **`modelFamilyFallback.ts`** — Automatic fallback within model families (T5, GPT-4, Claude).
### Advanced Services
- **`promptInjectionGuard.ts`** (middleware) — Clones request, sanitizes user input, detects prompt injection patterns before dispatch
- **`costRules.ts`** (domain layer) — Cost-based routing decisions (cheapest-first, within budget)
- **`degradation.ts`** (domain layer) — Handles service degradation scenarios (provider down, quota exceeded)
- **`resilience.ts`** — Retry logic, exponential backoff, circuit breaker orchestration across all services
---
## Complexity Hotspots
| Module | Lines | Risk | Mitigation |
| ------------------------ | ----- | ---------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `combo.ts` | ~800 | High — routing logic, strategy dispatch, fallback ordering | Unit tests for each strategy, integration tests for combo sequences |
| `providerRegistry.ts` | 3000+ | High — 100+ provider configs, executor dispatch | Auto-validate via Zod at module load, split into provider-specific sub-modules |
| `rateLimitManager.ts` | ~300 | Medium — token bucket state, concurrent requests | Unit tests for bucket refill, edge cases (clock skew, parallel requests) |
| `modelFamilyFallback.ts` | ~200 | Medium — fallback chains, family detection | Test all family chains, ensure no circular fallbacks |
---
## Testing Strategy
Each service requires unit and integration tests. For authoritative coverage requirements and test execution guidelines, see [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests).
- **Unit tests** — Each service in isolation with mocked dependencies (combos, models, executors)
- **Integration tests** — Combo routing with real combo configs, verify target resolution and fallback behavior
- **E2E tests** — Full request flow: chat → combo routing → provider selection → response streaming
- **Chaos tests** — Simulate provider failures, rate limits, token expiration; verify graceful degradation
- **Benchmarks** — Measure routing latency, combo resolution time (target: <10ms for 50 targets)
---
## Performance Constraints
- **Combo resolution**: <10ms for typical configs (520 targets)
- **Rate limit checks**: <1ms (in-memory token bucket)
- **Model family fallback**: <5ms (cached family definitions)
- **Request routing dispatch**: <2ms (hot path, pre-computed strategy dispatch)
- **No blocking I/O** in routing hot path — all async, no awaits on DB queries outside context injection
---
## Anti-Patterns
- ❌ Synchronous DB calls in `combo.ts` hot path — pre-compute and cache
- ❌ Retry logic in handlers; use `retry()` from resilience service
- ❌ Direct provider config access; use `providerRegistry` getter functions
- ❌ Hardcoded fallback chains; define in `modelFamilyFallback.ts` instead
- ❌ State mutations across concurrent requests; use request-scoped context only
---
## Adding a New Service
1. Create `open-sse/services/[serviceName].ts` with clear responsibilities
2. Export main handler function and any constants
3. Add unit tests in `tests/unit/services/[serviceName].test.mjs`
4. Integrate into request pipeline in `handlers/chatCore.ts` (if routing-related) or expose via combo.ts
5. Update routing logic in `combo.ts` if service affects target selection or fallback
6. Document in this file (table, key decisions section)
---
## Key Decisions
- **Combo-first design**: All routing decisions go through combo engine; fallback strategies are combo targets, not ad-hoc logic
- **Service composition**: Small focused modules; combo.ts orchestrates them, not monolithic routing
- **Circuit breaker per-target**: Failures isolated to specific provider+account combo; other targets unaffected
- **Caching everywhere**: Models, providers, quotas, family fallbacks all pre-cached; invalidated on write
- **13 strategies** over hardcoded logic: Strategy pattern allows new routing logic without touching combo.ts core
---
## Review Focus
- New services must not add blocking I/O to routing hot path
- Combo target resolution under 10ms (measure with benchmarks)
- Circuit breaker state per-target, not global
- All fallback chains tested (no infinite loops)
- Coverage requirements: See [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests) (60% gate enforced in CI)

View File

@@ -0,0 +1,173 @@
/**
* Antigravity 429 classification and retry decision engine.
*
* CLIProxyAPI classifies 429 responses into 4 categories and makes nuanced
* retry decisions for each. OmniRoute previously had only 2 categories
* (free tier vs RPM). This module brings full parity.
*
* Categories:
* - unknown: Generic 429, exponential backoff
* - rate_limited: Per-minute rate limit, short backoff + same auth retry
* - quota_exhausted: Daily/plan quota gone, switch auth or long cooldown
* - soft_rate_limit: Temporary burst limit, instant retry
*
* Decisions:
* - soft_retry: Wait briefly, retry same auth
* - instant_retry_same_auth: Retry immediately on same auth
* - short_cooldown_switch_auth: 5min cooldown, try next account
* - full_quota_exhausted: 24h cooldown, skip this account
*/
export type Category = "unknown" | "rate_limited" | "quota_exhausted" | "soft_rate_limit";
export type DecisionKind =
| "soft_retry"
| "instant_retry_same_auth"
| "short_cooldown_switch_auth"
| "full_quota_exhausted";
export interface Decision {
kind: DecisionKind;
retryAfterMs: number | null;
reason: string;
}
const QUOTA_EXHAUSTED_KEYWORDS = ["quota_exhausted", "quota exhausted"];
const CREDITS_EXHAUSTED_KEYWORDS = [
"google_one_ai",
"insufficient credit",
"insufficient credits",
"not enough credit",
"not enough credits",
"credit exhausted",
"credits exhausted",
"credit balance",
"minimumcreditamountforusage",
"minimum credit amount for usage",
"minimum credit",
"resource has been exhausted",
];
const SHORT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
const INSTANT_RETRY_THRESHOLD_MS = 3 * 1000; // 3 seconds
const FULL_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours
export function classify429(errorMessage: string): Category {
const lower = (errorMessage || "").toLowerCase();
// Check for quota exhaustion first (most specific)
for (const kw of QUOTA_EXHAUSTED_KEYWORDS) {
if (lower.includes(kw)) return "quota_exhausted";
}
// Check for credits exhaustion (also quota-related)
for (const kw of CREDITS_EXHAUSTED_KEYWORDS) {
if (lower.includes(kw)) return "quota_exhausted";
}
// Check for RPM/rate limit indicators
if (
lower.includes("per minute") ||
lower.includes("rpm") ||
lower.includes("rate limit") ||
lower.includes("rate_limit") ||
lower.includes("too many requests")
) {
return "rate_limited";
}
// Check for free tier exhaustion
if (
lower.includes("free tier") ||
lower.includes("daily limit") ||
lower.includes("exhausted your capacity")
) {
return "quota_exhausted";
}
// Check for soft/burst limits
if (lower.includes("try again") || lower.includes("temporarily")) {
return "soft_rate_limit";
}
return "unknown";
}
export function decide429(category: Category, retryAfterMs: number | null): Decision {
switch (category) {
case "soft_rate_limit":
return {
kind:
retryAfterMs && retryAfterMs <= INSTANT_RETRY_THRESHOLD_MS
? "instant_retry_same_auth"
: "soft_retry",
retryAfterMs: retryAfterMs ?? 2000,
reason: "Soft rate limit — brief backoff",
};
case "rate_limited":
return {
kind:
retryAfterMs && retryAfterMs <= SHORT_COOLDOWN_MS
? "soft_retry"
: "short_cooldown_switch_auth",
retryAfterMs: retryAfterMs ?? 60_000,
reason: "RPM rate limit — switch auth if cooldown is long",
};
case "quota_exhausted":
return {
kind: "full_quota_exhausted",
retryAfterMs: retryAfterMs ?? FULL_QUOTA_COOLDOWN_MS,
reason: "Quota exhausted — skip this account",
};
default:
return {
kind: "soft_retry",
retryAfterMs: retryAfterMs ?? 5000,
reason: "Unknown 429 — generic backoff",
};
}
}
/**
* Track credits failure state per auth key.
* Auto-disables after repeated failures with 5h cooldown.
*/
const creditsFailureMap = new Map<
string,
{
count: number;
disabledUntil: number;
}
>();
const CREDITS_DISABLE_THRESHOLD = 3;
const CREDITS_COOLDOWN_MS = 5 * 60 * 60 * 1000; // 5 hours
export function recordCreditsFailure(authKey: string): boolean {
const state = creditsFailureMap.get(authKey) ?? { count: 0, disabledUntil: 0 };
state.count++;
if (state.count >= CREDITS_DISABLE_THRESHOLD) {
state.disabledUntil = Date.now() + CREDITS_COOLDOWN_MS;
creditsFailureMap.set(authKey, state);
return true; // disabled
}
creditsFailureMap.set(authKey, state);
return false;
}
export function isCreditsDisabled(authKey: string): boolean {
const state = creditsFailureMap.get(authKey);
if (!state) return false;
if (state.disabledUntil > Date.now()) return true;
// Cooldown expired, reset
creditsFailureMap.delete(authKey);
return false;
}
export { SHORT_COOLDOWN_MS, FULL_QUOTA_COOLDOWN_MS };

View File

@@ -0,0 +1,42 @@
/**
* Google One AI credits injection for Antigravity.
*
* When Antigravity returns a quota_exhausted 429, CLIProxyAPI retries the
* request with `enabledCreditTypes: ["GOOGLE_ONE_AI"]` injected into the
* body. This uses the user's Google One AI credit balance for the retry,
* which is often available on Pro accounts.
*
* Based on CLIProxyAPI's antigravity_executor.go line 268.
*/
import { isCreditsDisabled, recordCreditsFailure } from "./antigravity429Engine.ts";
/**
* Inject enabledCreditTypes into the request body for a credits retry.
* Returns a new body object with the field added.
*/
export function injectCreditsField(body: Record<string, unknown>): Record<string, unknown> {
return {
...body,
enabledCreditTypes: ["GOOGLE_ONE_AI"],
};
}
/**
* Determine if a credits retry should be attempted for this auth key.
* Returns false if credits are disabled (too many failures) or if the
* config flag is off.
*/
export function shouldRetryWithCredits(authKey: string, creditsEnabled: boolean): boolean {
if (!creditsEnabled) return false;
if (isCreditsDisabled(authKey)) return false;
return true;
}
/**
* Handle a credits retry failure. Tracks the failure and returns
* true if credits are now disabled for this auth key.
*/
export function handleCreditsFailure(authKey: string): boolean {
return recordCreditsFailure(authKey);
}

View File

@@ -0,0 +1,62 @@
/**
* Antigravity header scrubbing.
*
* Real Antigravity is a Node.js app. Its outbound HTTP requests never include
* proxy tracing headers, Stainless SDK headers, or Chromium Sec-Ch-* headers.
* Sending any of these reveals the request came through a third-party proxy.
*
* Based on CLIProxyAPI's ScrubProxyAndFingerprintHeaders (misc/header_utils.go).
*/
const HEADERS_TO_REMOVE = [
// Proxy tracing
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-forwarded-port",
"x-real-ip",
"forwarded",
"via",
// Client identity (Stainless SDK — Claude Code specific, not Antigravity)
"x-title",
"x-stainless-lang",
"x-stainless-package-version",
"x-stainless-os",
"x-stainless-arch",
"x-stainless-runtime",
"x-stainless-runtime-version",
"x-stainless-timeout",
"x-stainless-retry-count",
"x-stainless-helper-method",
"http-referer",
"referer",
// Browser / Chromium fingerprint (Electron clients, NOT Node.js)
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-dest",
"priority",
// Encoding: Antigravity (Node.js) sends "gzip, deflate, br" by default;
// Electron clients add "zstd" which is a fingerprint mismatch.
"accept-encoding",
];
/**
* Remove headers that reveal proxy infrastructure or non-native client identity
* from an outgoing request to Antigravity's upstream API.
*/
export function scrubProxyAndFingerprintHeaders(
headers: Record<string, string>
): Record<string, string> {
const cleaned: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (!HEADERS_TO_REMOVE.includes(key.toLowerCase())) {
cleaned[key] = value;
}
}
// Set the standard Node.js accept-encoding
cleaned["Accept-Encoding"] = "gzip, deflate, br";
return cleaned;
}

View File

@@ -0,0 +1,71 @@
import os from "node:os";
/**
* Antigravity and Gemini CLI header utilities.
*
* Generates User-Agent strings and API client headers that match
* the real Antigravity and Gemini CLI binaries.
*
* Based on CLIProxyAPI's misc/header_utils.go.
*/
const ANTIGRAVITY_VERSION = "1.21.9";
const GEMINI_CLI_VERSION = "0.31.0";
const GEMINI_SDK_VERSION = "1.41.0";
const NODE_VERSION = "v22.19.0";
function getPlatform(): string {
const p = os.platform();
switch (p) {
case "win32":
return "win32";
case "darwin":
return "darwin";
default:
return p; // "linux", etc.
}
}
function getArch(): string {
const a = os.arch();
switch (a) {
case "x64":
return "x64";
case "ia32":
return "x86";
case "arm64":
return "arm64";
default:
return a;
}
}
/**
* Antigravity User-Agent: "antigravity/VERSION darwin/arm64"
*
* Always claims darwin/arm64 regardless of actual server OS.
* Real Antigravity is a macOS desktop tool — most users are on macOS.
* Claiming linux/amd64 from a datacenter IP is MORE suspicious than
* darwin/arm64. Matches CLIProxyAPI's proven production behavior.
*/
export function antigravityUserAgent(): string {
return `antigravity/${ANTIGRAVITY_VERSION} darwin/arm64`;
}
/**
* Gemini CLI User-Agent: "GeminiCLI/VERSION/MODEL (OS; ARCH)"
* Example: "GeminiCLI/0.31.0/gemini-3-flash (darwin; arm64)"
*/
export function geminiCLIUserAgent(model: string): string {
return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${getPlatform()}; ${getArch()})`;
}
/**
* X-Goog-Api-Client header value matching the real Gemini SDK.
* Example: "google-genai-sdk/1.41.0 gl-node/v22.19.0"
*/
export function googApiClientHeader(): string {
return `google-genai-sdk/${GEMINI_SDK_VERSION} gl-node/${NODE_VERSION}`;
}
export { ANTIGRAVITY_VERSION, GEMINI_CLI_VERSION, GEMINI_SDK_VERSION };

View File

@@ -0,0 +1,50 @@
/**
* Sensitive word obfuscation for Antigravity requests.
*
* Obfuscates client tool names (OpenCode, Cursor, Claude Code, etc.) using
* zero-width joiners so Google's backend can't grep for them in request logs.
* Matching ZeroGravity's ZEROGRAVITY_SENSITIVE_WORDS and CLIProxyAPI's cloak system.
*/
const ZWJ = "\u200d";
const DEFAULT_WORDS = [
"opencode",
"open-code",
"cline",
"roo-cline",
"roo_cline",
"cursor",
"windsurf",
"aider",
"continue.dev",
"copilot",
"avante",
"codecompanion",
"claude code",
"claude-code",
"kilo code",
"kilocode",
"omniroute",
];
let words = [...DEFAULT_WORDS];
export function setAntigravitySensitiveWords(w: string[]): void {
words = w.length > 0 ? w : [...DEFAULT_WORDS];
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function obfuscateSensitiveWords(text: string): string {
if (!text || words.length === 0) return text;
let result = text;
for (const word of words) {
if (!word) continue;
const regex = new RegExp(escapeRegex(word), "gi");
result = result.replace(regex, (m) => (m.length <= 1 ? m : m[0] + ZWJ + m.slice(1)));
}
return result;
}

View File

@@ -0,0 +1,49 @@
/**
* Claude Code CCH (Client Content Hash) signing.
*
* Real Claude Code uses Bun/Zig to compute an xxHash64 integrity token over
* the serialized request body. The server verifies this to confirm the request
* came from a genuine Claude Code client.
*
* Algorithm:
* 1. Serialize request body with cch=00000 placeholder
* 2. xxHash64(body_bytes, seed) & 0xFFFFF
* 3. Zero-padded 5-char lowercase hex
* 4. Replace cch=00000 with computed value
*/
import xxhashInit from "xxhash-wasm";
const CCH_SEED = 0x6e52736ac806831en;
const CCH_PATTERN = /\bcch=([0-9a-f]{5});/;
let xxhashPromise: Promise<void> | null = null;
let xxhash64Fn: ((input: Uint8Array, seed: bigint) => bigint) | null = null;
async function ensureXxhash() {
if (xxhash64Fn) return;
if (!xxhashPromise) {
xxhashPromise = (async () => {
const hasher = await xxhashInit();
xxhash64Fn = hasher.h64Raw;
})();
}
return xxhashPromise;
}
export async function computeCCH(bodyBytes: Uint8Array): Promise<string> {
await ensureXxhash();
const hash = xxhash64Fn!(bodyBytes, CCH_SEED);
const masked = hash & 0xfffffn;
return masked.toString(16).padStart(5, "0");
}
export async function signRequestBody(bodyString: string): Promise<string> {
if (!CCH_PATTERN.test(bodyString)) return bodyString;
const encoder = new TextEncoder();
const bodyBytes = encoder.encode(bodyString);
const token = await computeCCH(bodyBytes);
return bodyString.replace(CCH_PATTERN, `cch=${token};`);
}
export { CCH_PATTERN };

View File

@@ -1,6 +1,17 @@
import { createHash, randomUUID } from "node:crypto";
import { getStainlessTimeoutSeconds } from "@/shared/utils/runtimeTimeouts";
import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts";
import { signRequestBody } from "./claudeCodeCCH.ts";
import { computeFingerprint, extractFirstUserMessageText } from "./claudeCodeFingerprint.ts";
import { remapToolNamesInRequest } from "./claudeCodeToolRemapper.ts";
import {
enforceThinkingTemperature,
disableThinkingIfToolChoiceForced,
enforceCacheControlLimit,
ensureCacheControlOnLastUserMessage,
} from "./claudeCodeConstraints.ts";
import { obfuscateInBody } from "./claudeCodeObfuscation.ts";
export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
@@ -8,10 +19,24 @@ export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS = 8092;
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION = "2023-06-01";
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA =
"claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24";
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.89 (external, sdk-cli)";
export const CLAUDE_CODE_COMPATIBLE_BILLING_HEADER =
"x-anthropic-billing-header: cc_version=2.1.89.728; cc_entrypoint=sdk-cli; cch=00000;";
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24,fast-mode-2025-04-01,redact-thinking-2025-06-20,token-efficient-tools-2025-02-19";
export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.87";
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = `claude-cli/${CLAUDE_CODE_COMPATIBLE_VERSION} (external, cli)`;
/**
* Build the billing header dynamically with fingerprint and CCH placeholder.
* The cch=00000 placeholder is later replaced by signRequestBody().
*/
export function buildBillingHeader(messages?: Array<{ role?: string; content?: unknown }>): string {
const msgText = extractFirstUserMessageText(messages);
const fp = computeFingerprint(msgText, CLAUDE_CODE_COMPATIBLE_VERSION);
return `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_COMPATIBLE_VERSION}.${fp}; cc_entrypoint=cli; cch=00000;`;
}
/** @deprecated Use buildBillingHeader() for dynamic fingerprint */
export const CLAUDE_CODE_COMPATIBLE_BILLING_HEADER = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_COMPATIBLE_VERSION}.000; cc_entrypoint=cli; cch=00000;`;
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS = getStainlessTimeoutSeconds(
process.env
);
type HeaderLike =
| Headers
@@ -97,17 +122,18 @@ export function buildClaudeCodeCompatibleHeaders(
"x-app": "cli",
"User-Agent": CLAUDE_CODE_COMPATIBLE_USER_AGENT,
"X-Stainless-Retry-Count": "0",
"X-Stainless-Timeout": "300",
"X-Stainless-Timeout": String(CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS),
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.74.0",
"X-Stainless-Package-Version": "0.80.0",
"X-Stainless-OS": "MacOS",
"X-Stainless-Arch": "arm64",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v25.8.1",
"X-Stainless-Runtime-Version": "v24.3.0",
"accept-language": "*",
"sec-fetch-mode": "cors",
"accept-encoding": "identity",
...(sessionId ? { "X-Claude-Code-Session-Id": sessionId } : {}),
"x-client-request-id": randomUUID(),
};
}
@@ -161,12 +187,18 @@ export function buildClaudeCodeCompatibleRequest({
: Array.isArray(normalized.messages)
? buildClaudeCodeCompatibleMessages(normalized.messages as MessageLike[])
: [];
const allMessages = (preparedClaudeBody?.messages || normalized.messages || []) as Array<{
role?: string;
content?: unknown;
}>;
const billingHeader = buildBillingHeader(allMessages);
const system = buildClaudeCodeCompatibleSystemBlocks({
messages: normalized.messages as MessageLike[],
systemBlocks: preparedClaudeBody?.system as Record<string, unknown>[] | undefined,
cwd,
now,
preserveCacheControl,
billingHeader,
});
const resolvedSessionId = sessionId || randomUUID();
const effort = resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model);
@@ -219,6 +251,72 @@ export function buildClaudeCodeCompatibleRequest({
};
}
/**
* Full Claude Code request processing pipeline.
*
* Applies all mechanisms that real Claude Code uses:
* 1. Build base request (system prompt, billing header, messages, tools)
* 2. Remap tool names to TitleCase
* 3. Enforce thinking temperature constraint (temp=1)
* 4. Disable thinking when tool_choice forces a specific tool
* 5. Enforce 4-block cache_control limit
* 6. Auto-inject cache_control on last user message
* 7. Obfuscate sensitive words in user messages
* 8. Serialize with CCH placeholder
* 9. Sign body with xxHash64 CCH attestation
*
* Returns { bodyString, headers } ready to send upstream.
*/
export async function buildAndSignClaudeCodeRequest(
options: BuildRequestOptions & { apiKey: string; enableObfuscation?: boolean }
): Promise<{ bodyString: string; headers: Record<string, string> }> {
const { apiKey, enableObfuscation = false, ...buildOptions } = options;
// Step 1: Build base request
const body = buildClaudeCodeCompatibleRequest(buildOptions);
// Step 2: Remap tool names
remapToolNamesInRequest(body);
// Step 3-4: Thinking constraints
enforceThinkingTemperature(body);
disableThinkingIfToolChoiceForced(body);
// Step 5-6: Cache control
enforceCacheControlLimit(body);
ensureCacheControlOnLastUserMessage(body);
// Step 7: Obfuscation (optional, per-provider setting)
if (enableObfuscation) {
obfuscateInBody(body);
}
// Step 8: Serialize with CCH placeholder
const serialized = JSON.stringify(body);
// Step 9: Sign with xxHash64
const bodyString = await signRequestBody(serialized);
// Build headers
const sessionId = options.sessionId || resolveClaudeCodeCompatibleSessionId();
const headers = buildClaudeCodeCompatibleHeaders(apiKey, options.stream ?? false, sessionId);
return { bodyString, headers };
}
/**
* Re-export for consumers that need to post-process SSE response chunks.
*/
export { remapToolNamesInResponse } from "./claudeCodeToolRemapper.ts";
export { signRequestBody } from "./claudeCodeCCH.ts";
export { computeFingerprint } from "./claudeCodeFingerprint.ts";
export { obfuscateSensitiveWords, setSensitiveWords } from "./claudeCodeObfuscation.ts";
export {
enforceThinkingTemperature,
disableThinkingIfToolChoiceForced,
enforceCacheControlLimit,
} from "./claudeCodeConstraints.ts";
export function resolveClaudeCodeCompatibleEffort(
sourceBody?: Record<string, unknown> | null,
normalizedBody?: Record<string, unknown> | null,
@@ -381,12 +479,14 @@ function buildClaudeCodeCompatibleSystemBlocks({
cwd,
now,
preserveCacheControl,
billingHeader,
}: {
messages: MessageLike[] | undefined;
systemBlocks?: Array<Record<string, unknown>> | undefined;
cwd: string;
now: Date;
preserveCacheControl: boolean;
billingHeader: string;
}) {
const customSystemBlocks =
Array.isArray(systemBlocks) && systemBlocks.length > 0
@@ -397,7 +497,8 @@ function buildClaudeCodeCompatibleSystemBlocks({
const blocks: Array<Record<string, unknown>> = [
{
type: "text",
text: CLAUDE_CODE_COMPATIBLE_BILLING_HEADER,
text: billingHeader,
cache_control: { type: "ephemeral" },
},
{
type: "text",

View File

@@ -0,0 +1,127 @@
/**
* Claude Code API constraints.
*
* Enforces Anthropic API requirements that real Claude Code handles:
* 1. temperature=1 when thinking is enabled
* 2. Disable thinking when tool_choice forces a specific tool
* 3. Enforce max 4 cache_control breakpoints
* 4. Normalize cache_control TTL ordering
*/
export function enforceThinkingTemperature(body: Record<string, unknown>): void {
const thinking = body.thinking as Record<string, unknown> | undefined;
if (thinking?.type === "enabled" || thinking?.type === "adaptive") {
body.temperature = 1;
}
}
export function disableThinkingIfToolChoiceForced(body: Record<string, unknown>): void {
const toolChoice = body.tool_choice as Record<string, unknown> | string | undefined;
if (!toolChoice) return;
const isForced =
toolChoice === "any" ||
(typeof toolChoice === "object" && (toolChoice.type === "any" || toolChoice.type === "tool"));
if (isForced && body.thinking) {
delete body.thinking;
}
}
const MAX_CACHE_CONTROL_BLOCKS = 4;
export function enforceCacheControlLimit(body: Record<string, unknown>): void {
let count = 0;
// Count in system blocks
const system = body.system as Array<Record<string, unknown>> | undefined;
if (Array.isArray(system)) {
for (const block of system) {
if (block.cache_control) count++;
}
}
// Count in messages
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (Array.isArray(messages)) {
for (const msg of messages) {
const content = msg.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block.cache_control) count++;
}
}
}
// Count in tools
const tools = body.tools as Array<Record<string, unknown>> | undefined;
if (Array.isArray(tools)) {
for (const tool of tools) {
if (tool.cache_control) count++;
}
}
if (count <= MAX_CACHE_CONTROL_BLOCKS) return;
// Strip excess cache_control blocks from the end (keep first 4)
let remaining = MAX_CACHE_CONTROL_BLOCKS;
if (Array.isArray(system)) {
for (const block of system) {
if (block.cache_control) {
if (remaining > 0) {
remaining--;
} else {
delete block.cache_control;
}
}
}
}
if (Array.isArray(messages)) {
for (const msg of messages) {
const content = msg.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block.cache_control) {
if (remaining > 0) {
remaining--;
} else {
delete block.cache_control;
}
}
}
}
}
if (Array.isArray(tools)) {
for (const tool of tools) {
if (tool.cache_control) {
if (remaining > 0) {
remaining--;
} else {
delete tool.cache_control;
}
}
}
}
}
export function ensureCacheControlOnLastUserMessage(body: Record<string, unknown>): void {
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(messages) || messages.length === 0) return;
// Find the last user message
for (let i = messages.length - 1; i >= 0; i--) {
if (String(messages[i].role) === "user") {
const content = messages[i].content;
if (Array.isArray(content) && content.length > 0) {
const lastBlock = content[content.length - 1] as Record<string, unknown>;
if (!lastBlock.cache_control) {
lastBlock.cache_control = { type: "ephemeral" };
}
}
break;
}
}
}

View File

@@ -0,0 +1,46 @@
/**
* Claude Code fingerprint computation.
*
* The billing header includes a 3-char fingerprint derived from:
* SHA256(SALT + msg[4] + msg[7] + msg[20] + version)[:3]
*
* This fingerprint is computed from the first user message text and
* included in cc_version=VERSION.FINGERPRINT in the billing header.
*/
import { createHash } from "node:crypto";
const FINGERPRINT_SALT = "59cf53e54c78";
export function computeFingerprint(firstUserMessageText: string, version: string): string {
const indices = [4, 7, 20];
const chars = indices.map((i) => firstUserMessageText[i] || "0").join("");
const input = `${FINGERPRINT_SALT}${chars}${version}`;
const hash = createHash("sha256").update(input).digest("hex");
return hash.slice(0, 3);
}
export function extractFirstUserMessageText(
messages: Array<{ role?: string; content?: unknown }> | undefined
): string {
if (!Array.isArray(messages)) return "";
for (const msg of messages) {
if (String(msg?.role).toLowerCase() !== "user") continue;
const content = msg?.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
for (const block of content) {
if (
block &&
typeof block === "object" &&
"text" in block &&
typeof (block as Record<string, unknown>).text === "string"
) {
return (block as Record<string, unknown>).text as string;
}
}
}
return "";
}
return "";
}

View File

@@ -0,0 +1,77 @@
/**
* Sensitive word obfuscation for Claude Code requests.
*
* Obfuscates configurable words in user messages to prevent detection
* by upstream content filters. Uses zero-width characters to break
* pattern matching while preserving readability.
*/
// Unicode zero-width joiner inserted between characters
const ZWJ = "\u200d";
const DEFAULT_SENSITIVE_WORDS = [
"opencode",
"open-code",
"cline",
"roo-cline",
"roo_cline",
"cursor",
"windsurf",
"aider",
"continue.dev",
"copilot",
"avante",
"codecompanion",
];
let sensitiveWords = [...DEFAULT_SENSITIVE_WORDS];
export function setSensitiveWords(words: string[]): void {
sensitiveWords = words.length > 0 ? words : [...DEFAULT_SENSITIVE_WORDS];
}
export function getSensitiveWords(): string[] {
return [...sensitiveWords];
}
function obfuscateWord(word: string): string {
if (word.length <= 1) return word;
// Insert ZWJ after first character
return word[0] + ZWJ + word.slice(1);
}
export function obfuscateSensitiveWords(text: string): string {
if (!text || sensitiveWords.length === 0) return text;
let result = text;
for (const word of sensitiveWords) {
if (!word) continue;
// Case-insensitive replacement
const regex = new RegExp(escapeRegex(word), "gi");
result = result.replace(regex, (match) => obfuscateWord(match));
}
return result;
}
export function obfuscateInBody(body: Record<string, unknown>): void {
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(messages)) return;
for (const msg of messages) {
if (String(msg.role) !== "user") continue;
const content = msg.content;
if (typeof content === "string") {
msg.content = obfuscateSensitiveWords(content);
} else if (Array.isArray(content)) {
for (const block of content as Array<Record<string, unknown>>) {
if (typeof block.text === "string") {
block.text = obfuscateSensitiveWords(block.text);
}
}
}
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,80 @@
/**
* Claude Code tool name remapping.
*
* Anthropic uses tool name fingerprinting to detect third-party clients.
* Real Claude Code uses TitleCase tool names (Bash, Read, Write, etc.)
* while third-party clients like OpenCode use lowercase.
*
* This module remaps tool names in both directions:
* - Request path: lowercase → TitleCase (before sending to Anthropic)
* - Response path: TitleCase → lowercase (for clients expecting lowercase)
*/
const TOOL_RENAME_MAP: Record<string, string> = {
bash: "Bash",
read: "Read",
write: "Write",
edit: "Edit",
glob: "Glob",
grep: "Grep",
task: "Task",
webfetch: "WebFetch",
todowrite: "TodoWrite",
todoread: "TodoRead",
question: "Question",
skill: "Skill",
multiedit: "MultiEdit",
notebook: "Notebook",
};
const REVERSE_MAP: Record<string, string> = {};
for (const [k, v] of Object.entries(TOOL_RENAME_MAP)) {
REVERSE_MAP[v] = k;
}
export function remapToolNamesInRequest(body: Record<string, unknown>): void {
// Remap tool definitions
const tools = body.tools as Array<Record<string, unknown>> | undefined;
if (Array.isArray(tools)) {
for (const tool of tools) {
const name = String(tool.name || "");
if (TOOL_RENAME_MAP[name]) {
tool.name = TOOL_RENAME_MAP[name];
}
}
}
// Remap tool_result references in messages
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (Array.isArray(messages)) {
for (const msg of messages) {
const content = msg.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block.type === "tool_use" && typeof block.name === "string") {
const mapped = TOOL_RENAME_MAP[block.name];
if (mapped) block.name = mapped;
}
}
}
}
// Remap tool_choice
const toolChoice = body.tool_choice as Record<string, unknown> | undefined;
if (toolChoice?.type === "tool" && typeof toolChoice.name === "string") {
const mapped = TOOL_RENAME_MAP[toolChoice.name];
if (mapped) toolChoice.name = mapped;
}
}
export function remapToolNamesInResponse(text: string): string {
// Replace TitleCase tool names back to lowercase in SSE chunks
for (const [titleCase, lower] of Object.entries(REVERSE_MAP)) {
// Match in "name":"ToolName" patterns
text = text.replaceAll(`"name":"${titleCase}"`, `"name":"${lower}"`);
text = text.replaceAll(`"name": "${titleCase}"`, `"name": "${lower}"`);
}
return text;
}
export { TOOL_RENAME_MAP, REVERSE_MAP };

View File

@@ -76,6 +76,50 @@ export function registerCodexConnection(connectionId: string, meta: CodexConnect
connectionRegistry.set(connectionId, meta);
}
function getCodexConnectionMeta(
connectionId: string,
connection?: Record<string, unknown>
): CodexConnectionMeta | null {
if (connection && typeof connection === "object") {
const providerSpecificData =
connection.providerSpecificData &&
typeof connection.providerSpecificData === "object" &&
!Array.isArray(connection.providerSpecificData)
? (connection.providerSpecificData as Record<string, unknown>)
: {};
const accessToken =
typeof connection.accessToken === "string" && connection.accessToken.trim().length > 0
? connection.accessToken
: null;
const workspaceId =
typeof providerSpecificData.workspaceId === "string" &&
providerSpecificData.workspaceId.trim().length > 0
? providerSpecificData.workspaceId
: undefined;
if (accessToken) {
const meta = { accessToken, ...(workspaceId ? { workspaceId } : {}) };
connectionRegistry.set(connectionId, meta);
return meta;
}
}
return connectionRegistry.get(connectionId) || null;
}
function getDominantResetAt(quota: {
window5h: { percentUsed: number; resetAt: string | null };
window7d: { percentUsed: number; resetAt: string | null };
}): string | null {
if (quota.window7d.percentUsed > quota.window5h.percentUsed) {
return quota.window7d.resetAt || quota.window5h.resetAt;
}
if (quota.window5h.percentUsed > quota.window7d.percentUsed) {
return quota.window5h.resetAt || quota.window7d.resetAt;
}
return quota.window7d.resetAt || quota.window5h.resetAt;
}
// ─── Core Fetcher ────────────────────────────────────────────────────────────
/**
@@ -85,7 +129,10 @@ export function registerCodexConnection(connectionId: string, meta: CodexConnect
* @param connectionId - Connection ID from the DB (used to look up credentials)
* @returns QuotaInfo or null if fetch fails / no credentials
*/
export async function fetchCodexQuota(connectionId: string): Promise<QuotaInfo | null> {
export async function fetchCodexQuota(
connectionId: string,
connection?: Record<string, unknown>
): Promise<QuotaInfo | null> {
// Check cache first
const cached = quotaCache.get(connectionId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
@@ -93,7 +140,7 @@ export async function fetchCodexQuota(connectionId: string): Promise<QuotaInfo |
}
// Look up credentials
const meta = connectionRegistry.get(connectionId);
const meta = getCodexConnectionMeta(connectionId, connection);
if (!meta?.accessToken) {
// No credentials registered — skip preflight gracefully
return null;
@@ -206,6 +253,10 @@ function parseCodexUsageResponse(data: unknown): CodexDualWindowQuota | null {
used: worstPercentUsed,
total: 100,
percentUsed: percentUsedNormalized,
resetAt: getDominantResetAt({
window5h: { percentUsed: usedPercent5h / 100, resetAt: resetAt5h },
window7d: { percentUsed: usedPercent7d / 100, resetAt: resetAt7d },
}),
window5h: { percentUsed: usedPercent5h / 100, resetAt: resetAt5h },
window7d: { percentUsed: usedPercent7d / 100, resetAt: resetAt7d },
limitReached,

File diff suppressed because it is too large Load Diff

View File

@@ -62,7 +62,7 @@ export function injectModelTag(messages: Message[], providerModel: string): Mess
if (lastAssistantIdx === -1) {
return [
...cleaned,
{ role: "assistant", content: `\n<omniModel>${providerModel}</omniModel>` },
{ role: "assistant", content: `<omniModel>${providerModel}</omniModel>` },
];
}
@@ -75,14 +75,14 @@ export function injectModelTag(messages: Message[], providerModel: string): Mess
// message with the tag rather than silently failing.
return [
...cleaned,
{ role: "assistant", content: `\n<omniModel>${providerModel}</omniModel>` },
{ role: "assistant", content: `<omniModel>${providerModel}</omniModel>` },
];
}
const tagged = [...cleaned];
tagged[lastAssistantIdx] = {
...msg,
content: `${msg.content}\n<omniModel>${providerModel}</omniModel>`,
content: `${msg.content}<omniModel>${providerModel}</omniModel>`,
};
return tagged;
}

View File

@@ -1,7 +1,7 @@
/**
* In-memory combo metrics tracker
* Tracks per-combo and per-model request counts, latency, success/failure rates
* Provides API for reading metrics from the dashboard
* Tracks per-combo, per-model, and per-target request counts, latency, success/failure rates.
* Provides API for reading metrics from the dashboard.
*/
interface ModelMetrics {
@@ -13,6 +13,16 @@ interface ModelMetrics {
lastUsedAt: string | null;
}
interface ComboTargetMetrics extends ModelMetrics {
executionKey: string;
stepId: string | null;
model: string;
provider: string | null;
providerId: string | null;
connectionId: string | null;
label: string | null;
}
interface ComboMetricsEntry {
totalRequests: number;
totalSuccesses: number;
@@ -23,33 +33,140 @@ interface ComboMetricsEntry {
lastUsedAt: string | null;
intentCounts: Record<string, number>;
byModel: Record<string, ModelMetrics>;
byTarget: Record<string, ComboTargetMetrics>;
}
interface ModelMetricsView extends ModelMetrics {
avgLatencyMs: number;
successRate: number;
}
interface ComboTargetMetricsView extends ComboTargetMetrics {
avgLatencyMs: number;
successRate: number;
}
interface ComboMetricsView extends ComboMetricsEntry {
avgLatencyMs: number;
successRate: number;
fallbackRate: number;
byModel: Record<
string,
ModelMetrics & {
avgLatencyMs: number;
successRate: number;
}
>;
byModel: Record<string, ModelMetricsView>;
byTarget: Record<string, ComboTargetMetricsView>;
}
export interface ComboRequestTargetMeta {
executionKey?: string | null;
stepId?: string | null;
provider?: string | null;
providerId?: string | null;
connectionId?: string | null;
label?: string | null;
}
function toNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function inferProvider(modelStr: string | null): string | null {
const model = toNonEmptyString(modelStr);
if (!model) return null;
const [provider] = model.split("/");
return toNonEmptyString(provider);
}
function createModelMetrics(): ModelMetrics {
return {
requests: 0,
successes: 0,
failures: 0,
totalLatencyMs: 0,
lastStatus: null,
lastUsedAt: null,
};
}
function createComboEntry(strategy: string): ComboMetricsEntry {
return {
totalRequests: 0,
totalSuccesses: 0,
totalFailures: 0,
totalFallbacks: 0,
totalLatencyMs: 0,
strategy,
lastUsedAt: null,
intentCounts: {},
byModel: {},
byTarget: {},
};
}
function applyMetricOutcome(
metric: ModelMetrics,
success: boolean,
latencyMs: number,
usedAt: string
): void {
metric.requests++;
metric.totalLatencyMs += latencyMs;
metric.lastUsedAt = usedAt;
if (success) {
metric.successes++;
metric.lastStatus = "ok";
return;
}
metric.failures++;
metric.lastStatus = "error";
}
function buildTargetMetric(
modelStr: string,
target: ComboRequestTargetMeta
): ComboTargetMetrics | null {
const executionKey = toNonEmptyString(target.executionKey) || toNonEmptyString(modelStr);
const model = toNonEmptyString(modelStr);
if (!executionKey || !model) return null;
return {
executionKey,
stepId: toNonEmptyString(target.stepId),
model,
provider: toNonEmptyString(target.provider) || inferProvider(model),
providerId: toNonEmptyString(target.providerId),
connectionId:
target.connectionId === null ? null : (toNonEmptyString(target.connectionId) ?? null),
label: target.label === null ? null : (toNonEmptyString(target.label) ?? null),
...createModelMetrics(),
};
}
function toMetricView<T extends ModelMetrics>(
metric: T
): T & {
avgLatencyMs: number;
successRate: number;
} {
return {
...metric,
avgLatencyMs: metric.requests > 0 ? Math.round(metric.totalLatencyMs / metric.requests) : 0,
successRate: metric.requests > 0 ? Math.round((metric.successes / metric.requests) * 100) : 0,
};
}
// In-memory store
const metrics = new Map<string, ComboMetricsEntry>();
/**
* Record a combo request result
* Record a combo request result.
* @param {string} comboName
* @param {string} modelStr - The model that handled the request (or null if all failed)
* @param {Object} options
* @param {boolean} options.success
* @param {number} options.latencyMs
* @param {number} options.fallbackCount - How many fallbacks occurred
* @param {string} [options.strategy] - "priority" or "weighted"
* @param {string} [options.strategy] - Routing strategy name
* @param {Object} [options.target] - Step/execution metadata for structured combos
*/
export function recordComboRequest(
comboName: string,
@@ -59,28 +176,27 @@ export function recordComboRequest(
latencyMs,
fallbackCount = 0,
strategy = "priority",
}: { success: boolean; latencyMs: number; fallbackCount?: number; strategy?: string }
target,
}: {
success: boolean;
latencyMs: number;
fallbackCount?: number;
strategy?: string;
target?: ComboRequestTargetMeta | null;
}
): void {
if (!metrics.has(comboName)) {
metrics.set(comboName, {
totalRequests: 0,
totalSuccesses: 0,
totalFailures: 0,
totalFallbacks: 0,
totalLatencyMs: 0,
strategy,
lastUsedAt: null,
intentCounts: {},
byModel: {},
});
metrics.set(comboName, createComboEntry(strategy));
}
const combo = metrics.get(comboName);
if (!combo) return;
const usedAt = new Date().toISOString();
combo.totalRequests++;
combo.totalLatencyMs += latencyMs;
combo.totalFallbacks += fallbackCount;
combo.lastUsedAt = new Date().toISOString();
combo.lastUsedAt = usedAt;
combo.strategy = strategy;
if (success) {
@@ -89,35 +205,36 @@ export function recordComboRequest(
combo.totalFailures++;
}
// Per-model tracking
if (modelStr) {
if (!combo.byModel[modelStr]) {
combo.byModel[modelStr] = {
requests: 0,
successes: 0,
failures: 0,
totalLatencyMs: 0,
lastStatus: null,
lastUsedAt: null,
};
}
const modelMetric = combo.byModel[modelStr];
modelMetric.requests++;
modelMetric.totalLatencyMs += latencyMs;
modelMetric.lastUsedAt = new Date().toISOString();
if (!modelStr) return;
if (success) {
modelMetric.successes++;
modelMetric.lastStatus = "ok";
} else {
modelMetric.failures++;
modelMetric.lastStatus = "error";
}
if (!combo.byModel[modelStr]) {
combo.byModel[modelStr] = createModelMetrics();
}
applyMetricOutcome(combo.byModel[modelStr], success, latencyMs, usedAt);
const targetMetric = buildTargetMetric(modelStr, target || {});
if (!targetMetric) return;
if (!combo.byTarget[targetMetric.executionKey]) {
combo.byTarget[targetMetric.executionKey] = targetMetric;
}
const existingTargetMetric = combo.byTarget[targetMetric.executionKey];
existingTargetMetric.stepId = targetMetric.stepId || existingTargetMetric.stepId;
existingTargetMetric.provider = targetMetric.provider || existingTargetMetric.provider;
existingTargetMetric.providerId = targetMetric.providerId || existingTargetMetric.providerId;
existingTargetMetric.connectionId =
target?.connectionId === null
? null
: (targetMetric.connectionId ?? existingTargetMetric.connectionId);
existingTargetMetric.label =
target?.label === null ? null : (targetMetric.label ?? existingTargetMetric.label);
applyMetricOutcome(existingTargetMetric, success, latencyMs, usedAt);
}
/**
* Get metrics for a specific combo
* Get metrics for a specific combo.
* @param {string} comboName
* @returns {Object|null}
*/
@@ -135,20 +252,19 @@ export function getComboMetrics(comboName: string): ComboMetricsView | null {
combo.totalRequests > 0 ? Math.round((combo.totalFallbacks / combo.totalRequests) * 100) : 0,
intentCounts: { ...combo.intentCounts },
byModel: Object.fromEntries(
Object.entries(combo.byModel).map(([model, m]) => [
model,
{
...m,
avgLatencyMs: m.requests > 0 ? Math.round(m.totalLatencyMs / m.requests) : 0,
successRate: m.requests > 0 ? Math.round((m.successes / m.requests) * 100) : 0,
},
Object.entries(combo.byModel).map(([model, metric]) => [model, toMetricView(metric)])
),
byTarget: Object.fromEntries(
Object.entries(combo.byTarget).map(([executionKey, metric]) => [
executionKey,
toMetricView(metric),
])
),
};
}
/**
* Get metrics for all combos
* Get metrics for all combos.
* @returns {Object} Map of comboName → metrics
*/
export function getAllComboMetrics(): Record<string, ComboMetricsView | null> {
@@ -164,17 +280,7 @@ export function getAllComboMetrics(): Record<string, ComboMetricsView | null> {
*/
export function recordComboIntent(comboName: string, intent: string): void {
if (!metrics.has(comboName)) {
metrics.set(comboName, {
totalRequests: 0,
totalSuccesses: 0,
totalFailures: 0,
totalFallbacks: 0,
totalLatencyMs: 0,
strategy: "priority",
lastUsedAt: null,
intentCounts: {},
byModel: {},
});
metrics.set(comboName, createComboEntry("priority"));
}
const combo = metrics.get(comboName);
@@ -184,14 +290,14 @@ export function recordComboIntent(comboName: string, intent: string): void {
}
/**
* Reset metrics for a specific combo
* Reset metrics for a specific combo.
*/
export function resetComboMetrics(comboName: string): void {
metrics.delete(comboName);
}
/**
* Reset all combo metrics
* Reset all combo metrics.
*/
export function resetAllComboMetrics(): void {
metrics.clear();

View File

@@ -6,7 +6,7 @@
*/
import { REGISTRY } from "../config/providerRegistry.ts";
import { getModelContextLimit } from "../../src/lib/modelsDevSync";
import { getModelContextLimit } from "../../src/lib/modelCapabilities";
// Default token limits per provider (fallbacks when not in registry)
const DEFAULT_LIMITS: Record<string, number> = {

View File

@@ -17,12 +17,16 @@ export function isEmptyContentResponse(responseBody: unknown): boolean {
const delta = firstChoice.delta as Record<string, unknown> | undefined;
const content = message?.content ?? delta?.content;
const reasoningContent = message?.reasoning_content ?? delta?.reasoning_content;
const hasToolCalls =
(Array.isArray(message?.tool_calls) && (message.tool_calls as unknown[]).length > 0) ||
(Array.isArray(delta?.tool_calls) && (delta.tool_calls as unknown[]).length > 0);
const hasContent = content !== null && content !== undefined && content !== "";
return !hasContent && !hasToolCalls;
const hasReasoning =
reasoningContent !== null && reasoningContent !== undefined && reasoningContent !== "";
return !hasContent && !hasReasoning && !hasToolCalls;
}
if (Array.isArray(body.content)) {

View File

@@ -71,6 +71,25 @@ function resolveProviderModelAlias(providerOrAlias, modelId) {
return aliases?.[modelId] || modelId;
}
/**
* Resolve a provider/model pair into canonical provider ID + provider-scoped model ID.
* Keeps provider-specific legacy aliases out of downstream capability and budget lookups.
*/
export function resolveCanonicalProviderModel(providerOrAlias, modelId) {
if (!modelId || typeof modelId !== "string") {
return {
provider: resolveProviderAlias(providerOrAlias),
model: modelId || null,
};
}
const provider = resolveProviderAlias(providerOrAlias);
return {
provider,
model: resolveProviderModelAlias(provider, modelId),
};
}
/**
* Parse model string: "alias/model" or "provider/model" or just alias
* Supports [1m] suffix for extended 1M context window (e.g. "claude-sonnet-4-6[1m]")

View File

@@ -1,92 +1,5 @@
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
import { parseModel } from "./model.ts";
// Conservative denylist fallback used when registry metadata is absent.
// Keep small and explicit to avoid false negatives.
const TOOL_CALLING_UNSUPPORTED_PATTERNS = ["gpt-oss-120b", "deepseek-reasoner"];
function getRegistryToolCallingFlag(providerIdOrAlias: string, modelId: string): boolean | null {
const providerAlias = PROVIDER_ID_TO_ALIAS[providerIdOrAlias] || providerIdOrAlias;
const models = PROVIDER_MODELS[providerAlias];
if (!Array.isArray(models)) return null;
const found = models.find((m) => m?.id === modelId);
if (!found) return null;
return typeof found.toolCalling === "boolean" ? found.toolCalling : null;
}
/**
* Returns whether a model should be considered safe for structured function/tool calling.
*
* Decision order:
* 1) Provider registry metadata (toolCalling flag) when available.
* 2) Conservative denylist fallback for known problematic model families.
* 3) Default true.
*/
export function supportsToolCalling(modelStr: string): boolean {
const parsed = parseModel(modelStr);
const provider = parsed.provider || parsed.providerAlias || "";
const model = parsed.model || modelStr;
if (provider) {
const fromRegistry = getRegistryToolCallingFlag(provider, model);
if (fromRegistry !== null) return fromRegistry;
}
const normalized = String(modelStr || "").toLowerCase();
if (!normalized) return false;
const blocked = TOOL_CALLING_UNSUPPORTED_PATTERNS.some((pattern) => {
if (normalized === pattern) return true;
if (normalized.endsWith(`/${pattern}`)) return true;
return normalized.includes(pattern);
});
return !blocked;
}
// Models that do NOT support reasoning/thinking parameters.
// AG (Antigravity) claude-sonnet-4-6 routes through a Google internal API
// that returns 400 if thinking params are included.
const REASONING_UNSUPPORTED_PATTERNS = [
"antigravity/claude-sonnet-4-6",
"antigravity/claude-sonnet-4-5",
"antigravity/claude-sonnet-4",
];
function getRegistryReasoningFlag(providerIdOrAlias: string, modelId: string): boolean | null {
const providerAlias = PROVIDER_ID_TO_ALIAS[providerIdOrAlias] || providerIdOrAlias;
const models = PROVIDER_MODELS[providerAlias];
if (!Array.isArray(models)) return null;
const found = models.find((m) => m?.id === modelId);
if (!found) return null;
return typeof found.supportsReasoning === "boolean" ? found.supportsReasoning : null;
}
/**
* Returns whether a model supports reasoning/thinking parameters.
*
* Decision order:
* 1) Provider registry metadata (supportsReasoning flag) when available.
* 2) Explicit denylist for known unsupported models (e.g. AG Claude Sonnet).
* 3) Default true (pass through — safe, provider will ignore if unsupported).
*/
export function supportsReasoning(modelStr: string): boolean {
const parsed = parseModel(modelStr);
const provider = parsed.provider || parsed.providerAlias || "";
const model = parsed.model || modelStr;
if (provider) {
const fromRegistry = getRegistryReasoningFlag(provider, model);
if (fromRegistry !== null) return fromRegistry;
}
const normalized = String(modelStr || "").toLowerCase();
if (!normalized) return true;
const blocked = REASONING_UNSUPPORTED_PATTERNS.some(
(pattern) =>
normalized === pattern || normalized.endsWith(`/${pattern}`) || normalized.includes(pattern)
);
return !blocked;
}
export {
getResolvedModelCapabilities,
supportsReasoning,
supportsToolCalling,
} from "../../src/lib/modelCapabilities.ts";

View File

@@ -11,7 +11,7 @@
* (commit 6cea566, Mar 8 2026).
*/
import { getModelContextLimit } from "../../src/lib/modelsDevSync";
import { getModelContextLimit } from "../../src/lib/modelCapabilities";
import { parseModel } from "./model.ts";
import { CONTEXT_OVERFLOW_REGEX } from "./errorClassifier.ts";

View File

@@ -9,6 +9,7 @@
*/
import { registerQuotaFetcher, type QuotaFetcher } from "./quotaPreflight.ts";
import { getSessionInfo } from "./sessionManager.ts";
export { registerQuotaFetcher };
export type { QuotaFetcher };
@@ -24,6 +25,55 @@ interface MonitorState {
stopped: boolean;
provider: string;
accountId: string;
connectionSnapshot: Record<string, unknown> | null;
sessionBound: boolean;
status: "starting" | "idle" | "healthy" | "warning" | "exhausted" | "error";
startedAt: number;
lastPolledAt: number | null;
lastSuccessAt: number | null;
lastErrorAt: number | null;
lastError: string | null;
lastQuotaPercent: number | null;
lastQuotaUsed: number | null;
lastQuotaTotal: number | null;
lastResetAt: string | null;
lastAlertAt: number | null;
nextPollDelayMs: number | null;
nextPollAt: number | null;
totalPolls: number;
totalAlerts: number;
consecutiveFailures: number;
}
export interface QuotaMonitorSnapshot {
sessionId: string;
provider: string;
accountId: string;
status: "starting" | "idle" | "healthy" | "warning" | "exhausted" | "error";
startedAt: string;
lastPolledAt: string | null;
lastSuccessAt: string | null;
lastErrorAt: string | null;
lastError: string | null;
lastQuotaPercent: number | null;
lastQuotaUsed: number | null;
lastQuotaTotal: number | null;
lastResetAt: string | null;
lastAlertAt: string | null;
nextPollDelayMs: number | null;
nextPollAt: string | null;
totalPolls: number;
totalAlerts: number;
consecutiveFailures: number;
}
export interface QuotaMonitorSummary {
active: number;
alerting: number;
exhausted: number;
errors: number;
statusCounts: Record<QuotaMonitorSnapshot["status"], number>;
byProvider: Record<string, number>;
}
const activeMonitors = new Map<string, MonitorState>();
@@ -45,47 +95,148 @@ function suppressedAlert(
provider: string,
accountId: string,
percentUsed: number
): void {
): boolean {
const key = `${sessionId}:${provider}:${accountId}`;
const last = alertSuppression.get(key) ?? 0;
if (Date.now() - last < ALERT_SUPPRESS_WINDOW_MS) return;
if (Date.now() - last < ALERT_SUPPRESS_WINDOW_MS) return false;
alertSuppression.set(key, Date.now());
console.warn(
`[QuotaMonitor] session=${sessionId} ${provider}/${accountId}: ${(percentUsed * 100).toFixed(1)}% quota used`
);
return true;
}
function toIsoTimestamp(value: number | null): string | null {
return typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : null;
}
function getMonitorStatus(percentUsed: number | null): MonitorState["status"] {
if (!Number.isFinite(percentUsed)) return "idle";
if ((percentUsed as number) >= EXHAUSTION_THRESHOLD) return "exhausted";
if ((percentUsed as number) >= WARN_THRESHOLD) return "warning";
return "healthy";
}
function toPublicSnapshot(sessionId: string, state: MonitorState): QuotaMonitorSnapshot {
return {
sessionId,
provider: state.provider,
accountId: state.accountId,
status: state.status,
startedAt: new Date(state.startedAt).toISOString(),
lastPolledAt: toIsoTimestamp(state.lastPolledAt),
lastSuccessAt: toIsoTimestamp(state.lastSuccessAt),
lastErrorAt: toIsoTimestamp(state.lastErrorAt),
lastError: state.lastError,
lastQuotaPercent: state.lastQuotaPercent,
lastQuotaUsed: state.lastQuotaUsed,
lastQuotaTotal: state.lastQuotaTotal,
lastResetAt: state.lastResetAt,
lastAlertAt: toIsoTimestamp(state.lastAlertAt),
nextPollDelayMs: state.nextPollDelayMs,
nextPollAt: toIsoTimestamp(state.nextPollAt),
totalPolls: state.totalPolls,
totalAlerts: state.totalAlerts,
consecutiveFailures: state.consecutiveFailures,
};
}
function sortSnapshots(snapshots: QuotaMonitorSnapshot[]): QuotaMonitorSnapshot[] {
const severityRank: Record<QuotaMonitorSnapshot["status"], number> = {
exhausted: 5,
warning: 4,
error: 3,
starting: 2,
idle: 1,
healthy: 0,
};
return [...snapshots].sort((left, right) => {
const severityDelta = severityRank[right.status] - severityRank[left.status];
if (severityDelta !== 0) return severityDelta;
const quotaDelta = (right.lastQuotaPercent ?? -1) - (left.lastQuotaPercent ?? -1);
if (quotaDelta !== 0) return quotaDelta;
return (
(right.lastPolledAt ? Date.parse(right.lastPolledAt) : 0) -
(left.lastPolledAt ? Date.parse(left.lastPolledAt) : 0)
);
});
}
function scheduleNextPoll(sessionId: string, intervalMs: number): void {
const state = activeMonitors.get(sessionId);
if (!state || state.stopped) return;
state.nextPollDelayMs = intervalMs;
state.nextPollAt = Date.now() + intervalMs;
const { provider, accountId } = state;
const timer = setTimeout(async () => {
const current = activeMonitors.get(sessionId);
if (!current || current.stopped) return;
if (current.sessionBound && !getSessionInfo(sessionId)) {
stopQuotaMonitor(sessionId);
return;
}
try {
const fetcher = quotaFetcherRegistry.get(provider);
if (!fetcher) {
current.status = current.lastQuotaPercent === null ? "idle" : current.status;
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
return;
}
const quota = await fetcher(accountId);
const percentUsed = quota?.percentUsed ?? 0;
current.lastPolledAt = Date.now();
current.totalPolls += 1;
const previousStatus = current.status;
const quota = await fetcher(accountId, current.connectionSnapshot || undefined);
const percentUsed =
quota && typeof quota.percentUsed === "number" && Number.isFinite(quota.percentUsed)
? quota.percentUsed
: null;
current.lastSuccessAt = Date.now();
current.lastError = null;
current.lastErrorAt = null;
current.consecutiveFailures = 0;
current.lastQuotaPercent = percentUsed;
current.lastQuotaUsed =
quota && typeof quota.used === "number" && Number.isFinite(quota.used) ? quota.used : null;
current.lastQuotaTotal =
quota && typeof quota.total === "number" && Number.isFinite(quota.total)
? quota.total
: null;
current.lastResetAt =
quota && typeof quota.resetAt === "string" && quota.resetAt.trim().length > 0
? quota.resetAt
: null;
current.status = getMonitorStatus(percentUsed);
if (percentUsed >= EXHAUSTION_THRESHOLD) {
suppressedAlert(sessionId, provider, accountId, percentUsed);
console.info(
`[QuotaMonitor] session=${sessionId}: marking ${accountId} for next-session cooldown`
);
if (percentUsed !== null && percentUsed >= EXHAUSTION_THRESHOLD) {
const emittedAlert = suppressedAlert(sessionId, provider, accountId, percentUsed);
if (emittedAlert) {
current.lastAlertAt = Date.now();
current.totalAlerts += 1;
}
if (emittedAlert || previousStatus !== "exhausted") {
console.info(
`[QuotaMonitor] session=${sessionId}: marking ${accountId} for next-session cooldown`
);
}
scheduleNextPoll(sessionId, CRITICAL_INTERVAL_MS);
} else if (percentUsed >= WARN_THRESHOLD) {
suppressedAlert(sessionId, provider, accountId, percentUsed);
} else if (percentUsed !== null && percentUsed >= WARN_THRESHOLD) {
const emittedAlert = suppressedAlert(sessionId, provider, accountId, percentUsed);
if (emittedAlert) {
current.lastAlertAt = Date.now();
current.totalAlerts += 1;
}
scheduleNextPoll(sessionId, CRITICAL_INTERVAL_MS);
} else {
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
}
} catch {
} catch (error) {
current.lastErrorAt = Date.now();
current.lastError = error instanceof Error ? error.message : String(error);
current.consecutiveFailures += 1;
current.status = "error";
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
}
}, intervalMs);
@@ -101,9 +252,40 @@ export function startQuotaMonitor(
connection: Record<string, unknown>
): void {
if (!isQuotaMonitorEnabled(connection)) return;
if (activeMonitors.has(sessionId)) return;
const current = activeMonitors.get(sessionId);
if (current && !current.stopped) {
if (current.provider === provider && current.accountId === accountId) {
current.connectionSnapshot = connection;
current.sessionBound = current.sessionBound || getSessionInfo(sessionId) !== null;
return;
}
stopQuotaMonitor(sessionId);
}
activeMonitors.set(sessionId, { timer: null, stopped: false, provider, accountId });
activeMonitors.set(sessionId, {
timer: null,
stopped: false,
provider,
accountId,
connectionSnapshot: connection,
sessionBound: getSessionInfo(sessionId) !== null,
status: "starting",
startedAt: Date.now(),
lastPolledAt: null,
lastSuccessAt: null,
lastErrorAt: null,
lastError: null,
lastQuotaPercent: null,
lastQuotaUsed: null,
lastQuotaTotal: null,
lastResetAt: null,
lastAlertAt: null,
nextPollDelayMs: null,
nextPollAt: null,
totalPolls: 0,
totalAlerts: 0,
consecutiveFailures: 0,
});
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
}
@@ -124,3 +306,52 @@ export function stopQuotaMonitor(sessionId: string): void {
export function getActiveMonitorCount(): number {
return activeMonitors.size;
}
export function getQuotaMonitorSnapshot(sessionId: string): QuotaMonitorSnapshot | null {
const state = activeMonitors.get(sessionId);
if (!state || state.stopped) return null;
return toPublicSnapshot(sessionId, state);
}
export function getQuotaMonitorSnapshots(): QuotaMonitorSnapshot[] {
const snapshots: QuotaMonitorSnapshot[] = [];
for (const [sessionId, state] of activeMonitors) {
if (state.stopped) continue;
snapshots.push(toPublicSnapshot(sessionId, state));
}
return sortSnapshots(snapshots);
}
export function getQuotaMonitorSummary(): QuotaMonitorSummary {
const snapshots = getQuotaMonitorSnapshots();
const statusCounts: Record<QuotaMonitorSnapshot["status"], number> = {
starting: 0,
idle: 0,
healthy: 0,
warning: 0,
exhausted: 0,
error: 0,
};
const byProvider: Record<string, number> = {};
for (const snapshot of snapshots) {
statusCounts[snapshot.status] += 1;
byProvider[snapshot.provider] = (byProvider[snapshot.provider] || 0) + 1;
}
return {
active: snapshots.length,
alerting: statusCounts.warning + statusCounts.exhausted,
exhausted: statusCounts.exhausted,
errors: statusCounts.error,
statusCounts,
byProvider,
};
}
export function clearQuotaMonitors(): void {
for (const sessionId of [...activeMonitors.keys()]) {
stopQuotaMonitor(sessionId);
}
alertSuppression.clear();
}

View File

@@ -11,15 +11,20 @@ export interface PreflightQuotaResult {
proceed: boolean;
reason?: string;
quotaPercent?: number;
resetAt?: string | null;
}
export interface QuotaInfo {
used: number;
total: number;
percentUsed: number;
resetAt?: string | null;
}
export type QuotaFetcher = (connectionId: string) => Promise<QuotaInfo | null>;
export type QuotaFetcher = (
connectionId: string,
connection?: Record<string, unknown>
) => Promise<QuotaInfo | null>;
const EXHAUSTION_THRESHOLD = 0.95;
const WARN_THRESHOLD = 0.8;
@@ -51,7 +56,7 @@ export async function preflightQuota(
let quota: QuotaInfo | null = null;
try {
quota = await fetcher(connectionId);
quota = await fetcher(connectionId, connection);
} catch {
return { proceed: true };
}
@@ -66,7 +71,12 @@ export async function preflightQuota(
console.info(
`[QuotaPreflight] ${provider}/${connectionId}: ${(percentUsed * 100).toFixed(1)}% used — switching`
);
return { proceed: false, reason: "quota_exhausted", quotaPercent: percentUsed };
return {
proceed: false,
reason: "quota_exhausted",
quotaPercent: percentUsed,
resetAt: quota.resetAt ?? null,
};
}
if (percentUsed >= WARN_THRESHOLD) {

View File

@@ -0,0 +1,44 @@
type JsonRecord = Record<string, unknown>;
export function isQwenThinkingActive(body: JsonRecord): boolean {
const thinking = body.thinking;
if (thinking === true || body.enable_thinking === true) {
return true;
}
return (
typeof thinking === "object" &&
thinking !== null &&
!Array.isArray(thinking) &&
(thinking as JsonRecord).type === "enabled"
);
}
export function isQwenThinkingToolChoiceIncompatible(toolChoice: unknown): boolean {
return toolChoice === "required" || (typeof toolChoice === "object" && toolChoice !== null);
}
export function sanitizeQwenThinkingToolChoice(
body: JsonRecord,
providerLabel = "Qwen"
): JsonRecord {
if (!isQwenThinkingActive(body)) {
return body;
}
const toolChoice = body.tool_choice;
if (!isQwenThinkingToolChoiceIncompatible(toolChoice)) {
return body;
}
const toolChoiceLabel = typeof toolChoice === "string" ? toolChoice : "object";
console.warn(
`[${providerLabel}] Neutralizing incompatible tool_choice ${toolChoiceLabel} to "auto" (thinking mode active)`
);
return {
...body,
tool_choice: "auto",
};
}

View File

@@ -85,7 +85,7 @@ function evictIfNeeded(): void {
* or execute the fetch function and cache the result.
*
* @param key - Cache key from computeCacheKey()
* @param ttlMs - TTL in milliseconds (0 to bypass cache)
* @param ttlMs - TTL in milliseconds (0 to bypass cache AND coalescing)
* @param fetchFn - Function to execute on cache miss
* @returns The cached or freshly fetched data
*/
@@ -94,6 +94,17 @@ export async function getOrCoalesce<T>(
ttlMs: number,
fetchFn: () => Promise<T>
): Promise<{ data: T; cached: boolean }> {
// When ttlMs === 0 the caller explicitly wants to bypass the cache.
// Skip both the cache lookup AND the inflight-coalescing step so every
// concurrent call gets its own independent upstream fetch. Without this
// guard, ttlMs=0 callers still get coalesced results and receive
// { cached: true } even though caching was explicitly disabled.
if (ttlMs <= 0) {
misses++;
const data = await fetchFn();
return { data, cached: false };
}
// 1. Check cache
const cached = cache.get(key) as CacheEntry<T> | undefined;
if (cached && cached.expiresAt > Date.now()) {
@@ -117,11 +128,8 @@ export async function getOrCoalesce<T>(
try {
const data = await promise;
// Store in cache
if (ttlMs > 0) {
evictIfNeeded();
cache.set(key, { data, expiresAt: Date.now() + ttlMs });
}
evictIfNeeded();
cache.set(key, { data, expiresAt: Date.now() + ttlMs });
return { data, cached: false };
} finally {

View File

@@ -13,8 +13,12 @@ export const ThinkingMode = {
ADAPTIVE: "adaptive", // Scale based on request complexity
};
import { capThinkingBudget, getDefaultThinkingBudget } from "@/shared/constants/modelSpecs";
import { supportsReasoning } from "./modelCapabilities.ts";
import {
capThinkingBudget,
getDefaultThinkingBudget,
getResolvedModelCapabilities,
supportsReasoning,
} from "@/lib/modelCapabilities";
// Effort → budget token mapping
export const EFFORT_BUDGETS = {
@@ -289,6 +293,9 @@ function applyAdaptiveBudget(body, cfg) {
*/
export function hasThinkingCapableModel(body) {
const model = body.model || "";
const resolved = getResolvedModelCapabilities(model);
if (resolved.supportsThinking === true) return true;
if (resolved.supportsThinking === false) return false;
return (
model.includes("claude") ||
model.includes("o1") ||

View File

@@ -45,6 +45,14 @@ const KIMI_CONFIG = {
apiVersion: "2023-06-01",
};
const CURSOR_USAGE_CONFIG = {
usageUrl: "https://www.cursor.com/api/usage",
userMetaUrl: "https://www.cursor.com/api/auth/me",
subscriptionUrl: "https://www.cursor.com/api/subscription",
clientVersion: "3.1.0",
userAgent: "Cursor/3.1.0",
};
type JsonRecord = Record<string, unknown>;
type UsageQuota = {
used: number;
@@ -185,6 +193,8 @@ export async function getUsageForProvider(connection) {
return await getIflowUsage(accessToken);
case "glm":
return await getGlmUsage(apiKey, providerSpecificData);
case "cursor":
return await getCursorUsage(accessToken);
default:
return { message: `Usage API not implemented for ${provider}` };
}
@@ -418,6 +428,178 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null):
return "GitHub Copilot";
}
function buildCursorUsageHeaders(accessToken: string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": CURSOR_USAGE_CONFIG.userAgent,
"x-cursor-client-version": CURSOR_USAGE_CONFIG.clientVersion,
"x-cursor-user-agent": CURSOR_USAGE_CONFIG.userAgent,
};
}
function getFirstPositiveNumber(...values: unknown[]): number {
for (const value of values) {
const parsed = toNumber(value, Number.NaN);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return 0;
}
function getCursorMonthlyRequestLimit(usageData: JsonRecord, subscriptionData: JsonRecord): number {
return getFirstPositiveNumber(
getFieldValue(subscriptionData, "team_max_monthly_requests", "teamMaxMonthlyRequests"),
getFieldValue(usageData, "team_max_request_usage", "teamMaxRequestUsage"),
getFieldValue(subscriptionData, "team_max_request_usage", "teamMaxRequestUsage"),
getFieldValue(usageData, "hard_limit", "hardLimit"),
getFieldValue(subscriptionData, "max_monthly_requests", "maxMonthlyRequests")
);
}
function getCursorOnDemandLimit(usageData: JsonRecord, subscriptionData: JsonRecord): number {
const onDemand = toRecord(getFieldValue(usageData, "on_demand", "onDemand"));
return getFirstPositiveNumber(
getFieldValue(onDemand, "max_requests", "maxRequests"),
getCursorMonthlyRequestLimit(usageData, subscriptionData)
);
}
function formatCursorQuota(
usedValue: unknown,
totalValue: unknown,
resetValue: unknown
): UsageQuota {
const total = Math.max(0, toNumber(totalValue, 0));
const rawUsed = Math.max(0, toNumber(usedValue, 0));
const used = total > 0 ? Math.min(rawUsed, total) : rawUsed;
const remaining = total > 0 ? Math.max(total - used, 0) : 0;
return {
used,
total,
remaining,
remainingPercentage: total > 0 ? clampPercentage((remaining / total) * 100) : 0,
resetAt: parseResetTime(resetValue),
unlimited: false,
};
}
function inferCursorPlanName(userMeta: JsonRecord, subscriptionData: JsonRecord): string {
const teamInfo = toRecord(getFieldValue(userMeta, "team_info", "teamInfo"));
const candidates = [
getFieldValue(userMeta, "plan", "plan"),
getFieldValue(userMeta, "subscription_type", "subscriptionType"),
getFieldValue(subscriptionData, "subscription_type", "subscriptionType"),
getFieldValue(subscriptionData, "plan", "plan"),
];
const planText = candidates.find((value) => typeof value === "string" && value.trim().length > 0);
const normalized = typeof planText === "string" ? planText.trim().toLowerCase() : "";
if (Object.keys(teamInfo).length > 0 || normalized.includes("team")) return "Cursor Team";
if (normalized.includes("enterprise")) return "Cursor Enterprise";
if (normalized.includes("pro")) return "Cursor Pro";
if (normalized.includes("free")) return "Cursor Free";
return "Cursor";
}
async function fetchCursorUsageDocument(url: string, accessToken: string) {
const response = await fetch(url, {
method: "GET",
headers: buildCursorUsageHeaders(accessToken),
});
const text = await response.text();
if (!response.ok) {
return {
ok: false,
status: response.status,
data: {} as JsonRecord,
text,
};
}
try {
const parsed = text ? JSON.parse(text) : {};
return {
ok: true,
status: response.status,
data: toRecord(parsed),
text,
};
} catch {
return {
ok: false,
status: response.status,
data: {} as JsonRecord,
text,
};
}
}
async function getCursorUsage(accessToken: string) {
try {
if (!accessToken) {
return {
message: "Cursor token expired or unavailable. Please re-authenticate the connection.",
};
}
const [usageSummary, userMeta, subscription] = await Promise.all([
fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.usageUrl, accessToken),
fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.userMetaUrl, accessToken),
fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.subscriptionUrl, accessToken),
]);
const authDenied = [usageSummary, userMeta, subscription].some(
(result) => result.status === 401 || result.status === 403
);
if (authDenied) {
return {
message:
"Cursor token expired or permission denied. Please re-authenticate the connection.",
};
}
const usageData = usageSummary.data;
const userMetaData = userMeta.data;
const subscriptionData = subscription.data;
const plan = inferCursorPlanName(userMetaData, subscriptionData);
const quotas: Record<string, UsageQuota> = {};
const totalUsed = getFieldValue(usageData, "num_requests_total", "numRequestsTotal");
const totalLimit = getCursorMonthlyRequestLimit(usageData, subscriptionData);
const totalReset =
getFieldValue(usageData, "reset_date", "resetDate") ||
getFieldValue(subscriptionData, "reset_date", "resetDate");
if (toNumber(totalUsed, 0) > 0 || totalLimit > 0) {
quotas.requests = formatCursorQuota(totalUsed, totalLimit, totalReset);
}
const onDemand = toRecord(getFieldValue(usageData, "on_demand", "onDemand"));
const onDemandUsed = getFieldValue(onDemand, "num_requests", "numRequests");
const onDemandLimit = getCursorOnDemandLimit(usageData, subscriptionData);
const onDemandReset =
getFieldValue(onDemand, "reset_date", "resetDate") ||
getFieldValue(usageData, "reset_date", "resetDate") ||
getFieldValue(subscriptionData, "reset_date", "resetDate");
if (toNumber(onDemandUsed, 0) > 0 || onDemandLimit > 0) {
quotas.on_demand = formatCursorQuota(onDemandUsed, onDemandLimit, onDemandReset);
}
if (Object.keys(quotas).length > 0) {
return { plan, quotas };
}
return { plan, message: "Cursor connected. Unable to parse quota data." };
} catch (error) {
return { message: `Unable to fetch Cursor usage: ${(error as Error).message}` };
}
}
// ── Gemini CLI subscription info cache ──────────────────────────────────────
// Prevents duplicate loadCodeAssist calls within the same quota cycle.
// Key: accessToken → { data, fetchedAt }
@@ -1352,6 +1534,11 @@ export const __testing = {
parseResetTime,
formatGitHubQuotaSnapshot,
inferGitHubPlanName,
buildCursorUsageHeaders,
formatCursorQuota,
getCursorMonthlyRequestLimit,
getCursorOnDemandLimit,
inferCursorPlanName,
getGeminiCliPlanLabel,
getAntigravityPlanLabel,
};

View File

@@ -42,6 +42,7 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
"contentMediaType",
"contentEncoding",
// Non-standard schema fields (not recognized by Gemini API)
"deprecated",
"optional",
// UI/Styling properties (from Cursor tools - NOT JSON Schema standard)
"cornerRadius",
@@ -185,9 +186,9 @@ function removeUnsupportedKeywords(obj, keywords) {
}
} else {
// Delete unsupported keys at current level
for (const keyword of keywords) {
if (keyword in obj) {
delete obj[keyword];
for (const key of Object.keys(obj)) {
if (keywords.includes(key) || key.startsWith("x-")) {
delete obj[key];
}
}
// Recurse into remaining values

View File

@@ -0,0 +1,124 @@
import { cleanJSONSchemaForAntigravity } from "./geminiHelper.ts";
type GeminiFunctionDeclaration = {
name: string;
description: string;
parameters: unknown;
};
type GeminiTool = {
functionDeclarations?: GeminiFunctionDeclaration[];
googleSearch?: Record<string, unknown>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function toGeminiGoogleSearchTool(tool: Record<string, unknown>): GeminiTool | null {
if (isRecord(tool.googleSearch)) {
return { googleSearch: tool.googleSearch };
}
if (tool.googleSearch !== undefined) {
return { googleSearch: {} };
}
if (isRecord(tool.google_search)) {
return { googleSearch: tool.google_search };
}
if (tool.google_search !== undefined) {
return { googleSearch: {} };
}
const toolType = typeof tool.type === "string" ? tool.type : "";
if (
toolType === "googleSearch" ||
toolType === "google_search" ||
toolType === "web_search" ||
toolType === "web_search_preview"
) {
return { googleSearch: {} };
}
return null;
}
export function buildGeminiTools(tools: unknown): GeminiTool[] | undefined {
if (!Array.isArray(tools) || tools.length === 0) {
return undefined;
}
const functionDeclarations: GeminiFunctionDeclaration[] = [];
let googleSearchTool: GeminiTool | null = null;
for (const rawTool of tools) {
if (!isRecord(rawTool)) {
continue;
}
const normalizedGoogleSearchTool = toGeminiGoogleSearchTool(rawTool);
if (normalizedGoogleSearchTool) {
googleSearchTool = normalizedGoogleSearchTool;
continue;
}
if (Array.isArray(rawTool.functionDeclarations)) {
for (const fn of rawTool.functionDeclarations) {
if (!isRecord(fn) || typeof fn.name !== "string" || !fn.name.trim()) {
continue;
}
functionDeclarations.push({
name: fn.name,
description: typeof fn.description === "string" ? fn.description : "",
parameters: cleanJSONSchemaForAntigravity(
fn.parameters || { type: "object", properties: {} }
),
});
}
continue;
}
if (typeof rawTool.name === "string" && rawTool.name.trim()) {
functionDeclarations.push({
name: rawTool.name,
description: typeof rawTool.description === "string" ? rawTool.description : "",
parameters: cleanJSONSchemaForAntigravity(
rawTool.input_schema || { type: "object", properties: {} }
),
});
continue;
}
if (rawTool.type === "function" && isRecord(rawTool.function)) {
const fn = rawTool.function;
if (typeof fn.name !== "string" || !fn.name.trim()) {
continue;
}
functionDeclarations.push({
name: fn.name,
description: typeof fn.description === "string" ? fn.description : "",
parameters: cleanJSONSchemaForAntigravity(
fn.parameters || { type: "object", properties: {} }
),
});
}
}
if (googleSearchTool && functionDeclarations.length > 0) {
console.warn(
`[GeminiTools] Removing ${functionDeclarations.length} functionDeclarations because googleSearch cannot be mixed with Gemini function tools`
);
}
if (googleSearchTool) {
return [googleSearchTool];
}
if (functionDeclarations.length > 0) {
return [{ functionDeclarations }];
}
return undefined;
}

View File

@@ -4,6 +4,6 @@
*/
import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts";
export function convertResponsesApiFormat(body) {
return openaiResponsesToOpenAIRequest(null, body, null, null);
export function convertResponsesApiFormat(body, credentials = null) {
return openaiResponsesToOpenAIRequest(null, body, null, credentials);
}

View File

@@ -6,6 +6,7 @@ import {
cleanJSONSchemaForAntigravity,
} from "../helpers/geminiHelper.ts";
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts";
/**
* Direct Claude → Gemini request translator.
@@ -168,22 +169,9 @@ export function claudeToGeminiRequest(model, body, stream) {
}
// ── Convert tools ──────────────────────────────────────────────
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
const functionDeclarations = [];
for (const tool of body.tools) {
if (tool.name) {
functionDeclarations.push({
name: tool.name,
description: tool.description || "",
parameters: cleanJSONSchemaForAntigravity(
tool.input_schema || { type: "object", properties: {} }
),
});
}
}
if (functionDeclarations.length > 0) {
result.tools = [{ functionDeclarations }];
}
const geminiTools = buildGeminiTools(body.tools);
if (geminiTools) {
result.tools = geminiTools;
}
// ── Thinking config ────────────────────────────────────────────

View File

@@ -4,11 +4,13 @@
* Responses API uses: { input: [...], instructions: "..." }
* Chat API uses: { messages: [...] }
*/
import { register } from "../registry.ts";
import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults";
import { FORMATS } from "../formats.ts";
import { generateToolCallId } from "../helpers/toolCallHelper.ts";
import { register } from "../registry.ts";
type JsonRecord = Record<string, unknown>;
const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore";
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
@@ -44,6 +46,8 @@ export function openaiResponsesToOpenAIRequest(
const root = toRecord(body);
if (root.input === undefined) return body;
const credentialRecord = toRecord(credentials);
const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData);
// Validate tool types — only function tools can be translated to Chat Completions
const tools = toArray(root.tools);
@@ -271,6 +275,9 @@ export function openaiResponsesToOpenAIRequest(
delete result.input;
delete result.instructions;
delete result.include;
if (storeEnabled && root.store !== undefined) {
result[RESPONSES_STORE_MARKER] = root.store;
}
delete result.store;
delete result.reasoning;
@@ -287,15 +294,18 @@ export function openaiToOpenAIResponsesRequest(
credentials: unknown
): unknown {
void stream;
void credentials;
const root = toRecord(body);
const credentialRecord = toRecord(credentials);
const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData);
const result: JsonRecord = {
model,
input: [],
stream: true,
store: false,
};
if (!storeEnabled) {
result.store = false;
}
const input = result.input as JsonRecord[];
@@ -514,10 +524,29 @@ export function openaiToOpenAIResponsesRequest(
}
// Pass through relevant fields
if (root.previous_response_id !== undefined) {
result.previous_response_id = root.previous_response_id;
}
if (root.prompt_cache_key !== undefined) {
result.prompt_cache_key = root.prompt_cache_key;
}
if (root.session_id !== undefined) {
result.session_id = root.session_id;
}
if (root.conversation_id !== undefined) {
result.conversation_id = root.conversation_id;
}
if (root.service_tier !== undefined) result.service_tier = root.service_tier;
if (root.temperature !== undefined) result.temperature = root.temperature;
if (root.max_tokens !== undefined) result.max_tokens = root.max_tokens;
if (root.top_p !== undefined) result.top_p = root.top_p;
if (storeEnabled) {
if (root[RESPONSES_STORE_MARKER] !== undefined) {
result.store = root[RESPONSES_STORE_MARKER];
} else if (root.store !== undefined) {
result.store = root.store;
}
}
return result;
}

View File

@@ -8,7 +8,7 @@ import {
capMaxOutputTokens,
capThinkingBudget,
getDefaultThinkingBudget,
} from "../../../src/shared/constants/modelSpecs.ts";
} from "../../../src/lib/modelCapabilities.ts";
import * as crypto from "crypto";
@@ -25,6 +25,7 @@ import {
generateSessionId,
cleanJSONSchemaForAntigravity,
} from "../helpers/geminiHelper.ts";
import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts";
type GeminiPart = Record<string, unknown>;
type GeminiContent = { role: string; parts: GeminiPart[] };
@@ -54,7 +55,10 @@ type GeminiRequest = {
generationConfig: GeminiGenerationConfig;
safetySettings: unknown;
systemInstruction?: GeminiContent;
tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>;
tools?: Array<{
functionDeclarations?: GeminiFunctionDeclaration[];
googleSearch?: Record<string, unknown>;
}>;
cachedContent?: string;
};
@@ -69,7 +73,10 @@ type CloudCodeEnvelope = {
contents: GeminiContent[];
systemInstruction?: GeminiContent;
generationConfig: GeminiGenerationConfig;
tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>;
tools?: Array<{
functionDeclarations?: GeminiFunctionDeclaration[];
googleSearch?: Record<string, unknown>;
}>;
safetySettings?: unknown;
toolConfig?: {
functionCallingConfig: { mode: string };
@@ -277,35 +284,9 @@ function openaiToGeminiBase(model, body, stream) {
}
// Convert tools
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
const functionDeclarations = [];
for (const t of body.tools) {
// Check if already in Anthropic/Claude format (no type field, direct name/description/input_schema)
if (t.name && t.input_schema) {
functionDeclarations.push({
name: t.name,
description: t.description || "",
parameters: cleanJSONSchemaForAntigravity(
t.input_schema || { type: "object", properties: {} }
),
});
}
// OpenAI format
else if (t.type === "function" && t.function) {
const fn = t.function;
functionDeclarations.push({
name: fn.name,
description: fn.description || "",
parameters: cleanJSONSchemaForAntigravity(
fn.parameters || { type: "object", properties: {} }
),
});
}
}
if (functionDeclarations.length > 0) {
result.tools = [{ functionDeclarations }];
}
const geminiTools = buildGeminiTools(body.tools);
if (geminiTools) {
result.tools = geminiTools;
}
// Convert response_format to Gemini's responseMimeType/responseSchema
@@ -437,7 +418,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
}
// Add toolConfig for Antigravity
if (geminiCLI.tools?.length > 0) {
if (geminiCLI.tools?.some((tool) => Array.isArray(tool.functionDeclarations))) {
envelope.request.toolConfig = {
functionCallingConfig: { mode: "VALIDATED" },
};
@@ -534,19 +515,9 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
// Convert Claude tools to Gemini functionDeclarations
if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) {
const functionDeclarations = [];
for (const tool of claudeRequest.tools) {
if (tool.name && tool.input_schema) {
const cleanedSchema = cleanJSONSchemaForAntigravity(tool.input_schema);
functionDeclarations.push({
name: tool.name,
description: tool.description || "",
parameters: cleanedSchema,
});
}
}
if (functionDeclarations.length > 0) {
envelope.request.tools = [{ functionDeclarations }];
const geminiTools = buildGeminiTools(claudeRequest.tools);
if (geminiTools) {
envelope.request.tools = geminiTools;
envelope.request.toolConfig = {
functionCallingConfig: { mode: "VALIDATED" },
};

View File

@@ -28,7 +28,7 @@ export function geminiToOpenAIResponse(chunk, state) {
choices: [
{
index: 0,
delta: { role: "assistant" },
delta: { role: "assistant", content: "" },
finish_reason: null,
},
],

View File

@@ -8,6 +8,9 @@
import crypto from "crypto";
import { v5 as uuidv5 } from "uuid";
const CURSOR_CLIENT_VERSION = "3.1.0";
const CURSOR_USER_AGENT = `Cursor/${CURSOR_CLIENT_VERSION}`;
/**
* Generate SHA-256 hash like generateHashed64Hex
* @param {string} input - Input string
@@ -112,11 +115,12 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"Content-Type": "application/connect+proto",
"User-Agent": "connect-es/1.6.1",
"User-Agent": CURSOR_USER_AGENT,
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
"x-client-key": clientKey,
"x-cursor-checksum": checksum,
"x-cursor-client-version": "1.1.3",
"x-cursor-client-version": CURSOR_CLIENT_VERSION,
"x-cursor-user-agent": CURSOR_USER_AGENT,
"x-cursor-config-version": crypto.randomUUID(),
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
"x-ghost-mode": ghostMode ? "true" : "false",

View File

@@ -165,6 +165,9 @@ export async function runWithProxyContext(proxyConfig, fn) {
async function patchedFetch(input: RequestInfo | URL, options: FetchWithDispatcherOptions = {}) {
if (options?.dispatcher) {
// When a dispatcher is present, we MUST use the undici library fetch
// to ensure version compatibility. Node 22 built-in fetch (undici v6)
// is incompatible with undici v8 dispatchers (missing onRequestStart, etc.)
return (undiciFetch as unknown as (...args: unknown[]) => Promise<Response>)(input, options);
}
@@ -206,7 +209,16 @@ async function patchedFetch(input: RequestInfo | URL, options: FetchWithDispatch
dispatcher: getDefaultDispatcher(),
});
} catch (dispatcherError) {
const msg = dispatcherError instanceof Error ? dispatcherError.message : String(dispatcherError);
const msg =
dispatcherError instanceof Error ? dispatcherError.message : String(dispatcherError);
// CAUTION: Do NOT fallback to native fetch if the error is a version mismatch (invalid onRequestStart)
// because the native fetch will definitely fail with the undici v8 dispatcher.
if (msg.includes("onRequestStart")) {
console.error(
`[ProxyFetch] Fatal version mismatch: Dispatcher (v8) vs Fetch (v6/native). Hardware upgrade or SOCKS5 config isolation required. Error: ${msg}`
);
throw dispatcherError;
}
// Only fallback for connection/dispatcher errors, not HTTP errors
if (msg.includes("fetch failed") || msg.includes("ECONNREFUSED") || msg.includes("UND_ERR")) {
console.warn(`[ProxyFetch] Undici dispatcher failed, falling back to native fetch: ${msg}`);

View File

@@ -261,6 +261,16 @@ function buildResponsesSummary(
let latestResponse: JsonRecord | null = null;
let usage: JsonRecord | null = null;
const textParts: string[] = [];
const buildOutputFromText = () =>
textParts.length > 0
? [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: textParts.join("") }],
},
]
: [];
for (const payload of payloads) {
const eventType = toString(payload.type);
@@ -292,11 +302,12 @@ function buildResponsesSummary(
const picked = completed || latestResponse;
if (picked && Object.keys(picked).length > 0) {
const pickedOutput = Array.isArray(picked.output) ? picked.output : [];
return {
id: toString(picked.id, `resp_${Date.now()}`),
object: "response",
model: toString(picked.model, fallbackModel || "unknown"),
output: Array.isArray(picked.output) ? picked.output : [],
output: pickedOutput.length > 0 ? pickedOutput : buildOutputFromText(),
usage: picked.usage ?? usage ?? null,
status: toString(picked.status, completed ? "completed" : "in_progress"),
created_at: toNumber(picked.created_at, Math.floor(Date.now() / 1000)),
@@ -308,16 +319,7 @@ function buildResponsesSummary(
id: `resp_${Date.now()}`,
object: "response",
model: fallbackModel || "unknown",
output:
textParts.length > 0
? [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: textParts.join("") }],
},
]
: [],
output: buildOutputFromText(),
usage: usage ?? null,
status: "completed",
created_at: Math.floor(Date.now() / 1000),

368
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.6.2",
"version": "3.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.6.2",
"version": "3.6.5",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -37,14 +37,15 @@
"ora": "^9.1.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react": "19.2.5",
"react-dom": "19.2.5",
"recharts": "^3.7.0",
"selfsigned": "^5.5.0",
"tsx": "^4.21.0",
"undici": "^8.0.2",
"undici": "^8.1.0",
"uuid": "^13.0.0",
"wreq-js": "^2.0.1",
"xxhash-wasm": "^1.1.0",
"yazl": "^3.3.1",
"zod": "^4.3.6",
"zustand": "^5.0.10"
@@ -69,7 +70,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.2.2",
"eslint-config-next": "16.2.3",
"husky": "^9.1.7",
"jsdom": "^29.0.1",
"lint-staged": "^16.2.7",
@@ -223,59 +224,37 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz",
"integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==",
"version": "5.1.10",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz",
"integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.1.1",
"@csstools/css-color-parser": "^4.0.2",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.2.7"
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz",
"integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==",
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz",
"integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7"
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
@@ -2532,15 +2511,16 @@
}
},
"node_modules/@lobehub/icons": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@lobehub/icons/-/icons-5.3.0.tgz",
"integrity": "sha512-W/nQ1JSBHwYLnHup/TN7ml35eSsYqBoG5vpqChjH83WsuIRLT1vSpkPCkHOJ7pvmJCXXCwTicAwdhUS0lmIPaA==",
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@lobehub/icons/-/icons-5.4.0.tgz",
"integrity": "sha512-HXuq3j8Ios5PKmqvjNVDe91neYIcAAGS5z2W679esR4elBILNUofOhw/+Sa3FFefh71fOSuAVvNpy/CLoqO3vA==",
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"antd-style": "^4.1.0",
"es-toolkit": "^1.45.1",
"lucide-react": "^0.469.0",
"polished": "^4.3.1"
},
@@ -2804,9 +2784,9 @@
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz",
"integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==",
"version": "16.2.3",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.3.tgz",
"integrity": "sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6504,12 +6484,12 @@
"peer": true
},
"node_modules/@types/node": {
"version": "25.5.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
"integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": "~7.19.0"
}
},
"node_modules/@types/parse-json": {
@@ -6557,17 +6537,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
"integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
"integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.58.0",
"@typescript-eslint/type-utils": "8.58.0",
"@typescript-eslint/utils": "8.58.0",
"@typescript-eslint/visitor-keys": "8.58.0",
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/type-utils": "8.58.2",
"@typescript-eslint/utils": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -6580,7 +6560,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.58.0",
"@typescript-eslint/parser": "^8.58.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -6596,16 +6576,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz",
"integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.0",
"@typescript-eslint/types": "8.58.0",
"@typescript-eslint/typescript-estree": "8.58.0",
"@typescript-eslint/visitor-keys": "8.58.0",
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2",
"debug": "^4.4.3"
},
"engines": {
@@ -6621,14 +6601,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz",
"integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz",
"integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.58.0",
"@typescript-eslint/types": "^8.58.0",
"@typescript-eslint/tsconfig-utils": "^8.58.2",
"@typescript-eslint/types": "^8.58.2",
"debug": "^4.4.3"
},
"engines": {
@@ -6643,14 +6623,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz",
"integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz",
"integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.0",
"@typescript-eslint/visitor-keys": "8.58.0"
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6661,9 +6641,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz",
"integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz",
"integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6678,15 +6658,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz",
"integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz",
"integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.0",
"@typescript-eslint/typescript-estree": "8.58.0",
"@typescript-eslint/utils": "8.58.0",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2",
"@typescript-eslint/utils": "8.58.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -6703,9 +6683,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz",
"integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz",
"integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6717,16 +6697,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz",
"integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz",
"integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.58.0",
"@typescript-eslint/tsconfig-utils": "8.58.0",
"@typescript-eslint/types": "8.58.0",
"@typescript-eslint/visitor-keys": "8.58.0",
"@typescript-eslint/project-service": "8.58.2",
"@typescript-eslint/tsconfig-utils": "8.58.2",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -6797,16 +6777,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz",
"integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz",
"integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.58.0",
"@typescript-eslint/types": "8.58.0",
"@typescript-eslint/typescript-estree": "8.58.0"
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6821,13 +6801,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz",
"integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz",
"integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.0",
"@typescript-eslint/types": "8.58.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -7185,16 +7165,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
"integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -7203,13 +7183,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.2",
"@vitest/spy": "4.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -7230,9 +7210,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
"integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7243,13 +7223,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
"integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.2",
"@vitest/utils": "4.1.4",
"pathe": "^2.0.3"
},
"funding": {
@@ -7257,14 +7237,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
"integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -7273,9 +7253,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -7283,13 +7263,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
"integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.2",
"@vitest/pretty-format": "4.1.4",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -7914,9 +7894,9 @@
}
},
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"version": "12.9.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
"integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -10160,13 +10140,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz",
"integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==",
"version": "16.2.3",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.3.tgz",
"integrity": "sha512-Dnkrylzjof/Az7iNoIQJqD18zTxQZcngir19KJaiRsMnnjpQSVoa6aEg/1Q4hQC+cW90uTlgQYadwL1CYNwFWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.2.2",
"@next/eslint-plugin-next": "16.2.3",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -13069,14 +13049,14 @@
}
},
"node_modules/jsdom": {
"version": "29.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
"integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
"version": "29.0.2",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
"@asamuzakjp/dom-selector": "^7.0.3",
"@asamuzakjp/css-color": "^5.1.5",
"@asamuzakjp/dom-selector": "^7.0.6",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@exodus/bytes": "^1.15.0",
@@ -13120,9 +13100,9 @@
}
},
"node_modules/jsdom/node_modules/undici": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -16537,9 +16517,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz",
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
"dev": true,
"license": "MIT",
"bin": {
@@ -17087,9 +17067,9 @@
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -17118,15 +17098,15 @@
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
"react": "^19.2.5"
}
},
"node_modules/react-draggable": {
@@ -19705,16 +19685,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz",
"integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz",
"integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
"@typescript-eslint/typescript-estree": "8.58.0",
"@typescript-eslint/utils": "8.58.0"
"@typescript-eslint/eslint-plugin": "8.58.2",
"@typescript-eslint/parser": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2",
"@typescript-eslint/utils": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -19755,18 +19735,18 @@
}
},
"node_modules/undici": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.0.2.tgz",
"integrity": "sha512-B9MeU5wuFhkFAuNeA19K2GDFcQXZxq33fL0nRy2Aq30wdufZbyyvxW3/ChaeipXVfy/wUweZyzovQGk39+9k2w==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz",
"integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"license": "MIT"
},
"node_modules/unified": {
@@ -20289,19 +20269,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.2",
"@vitest/mocker": "4.1.2",
"@vitest/pretty-format": "4.1.2",
"@vitest/runner": "4.1.2",
"@vitest/snapshot": "4.1.2",
"@vitest/spy": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -20329,10 +20309,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.2",
"@vitest/browser-preview": "4.1.2",
"@vitest/browser-webdriverio": "4.1.2",
"@vitest/ui": "4.1.2",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -20356,6 +20338,12 @@
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
@@ -20439,15 +20427,15 @@
}
},
"node_modules/wait-on": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz",
"integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz",
"integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==",
"dev": true,
"license": "MIT",
"dependencies": {
"axios": "^1.13.5",
"joi": "^18.0.2",
"lodash": "^4.17.23",
"axios": "^1.15.0",
"joi": "^18.1.2",
"lodash": "^4.18.1",
"minimist": "^1.2.8",
"rxjs": "^7.8.2"
},
@@ -20754,6 +20742,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/xxhash-wasm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
"integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==",
"license": "MIT"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -20968,7 +20962,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.6.2"
"version": "3.6.5"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.6.2",
"version": "3.6.5",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -116,14 +116,15 @@
"ora": "^9.1.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react": "19.2.5",
"react-dom": "19.2.5",
"recharts": "^3.7.0",
"selfsigned": "^5.5.0",
"tsx": "^4.21.0",
"undici": "^8.0.2",
"undici": "^8.1.0",
"uuid": "^13.0.0",
"wreq-js": "^2.0.1",
"xxhash-wasm": "^1.1.0",
"yazl": "^3.3.1",
"zod": "^4.3.6",
"zustand": "^5.0.10"
@@ -147,7 +148,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.2.2",
"eslint-config-next": "16.2.3",
"husky": "^9.1.7",
"jsdom": "^29.0.1",
"lint-staged": "^16.2.7",

View File

@@ -4,6 +4,10 @@ const dashboardPort = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
const dashboardBaseUrl = `http://localhost:${dashboardPort}`;
const webServerReadyUrl = `${dashboardBaseUrl}/api/monitoring/health`;
const playwrightServerMode = process.env.OMNIROUTE_PLAYWRIGHT_SERVER_MODE || "start";
const playwrightWebServerTimeout = Number.parseInt(
process.env.OMNIROUTE_PLAYWRIGHT_WEB_SERVER_TIMEOUT || "900000",
10
);
export default defineConfig({
testDir: "./tests/e2e",
@@ -33,6 +37,6 @@ export default defineConfig({
command: `node scripts/run-next-playwright.mjs ${playwrightServerMode}`,
url: webServerReadyUrl,
reuseExistingServer: !process.env.CI,
timeout: 300_000,
timeout: Number.isFinite(playwrightWebServerTimeout) ? playwrightWebServerTimeout : 900_000,
},
});

12
scratch.mjs Normal file
View File

@@ -0,0 +1,12 @@
import { test } from "node:test";
import assert from "node:assert";
import { providerNodesValidateRoute } from "./src/app/api/provider-nodes/validate/route.js";
const req = new Request("http://localhost/api/provider-nodes/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl: "", apiKey: "" }),
});
const res = await providerNodesValidateRoute.POST(req);
const data = await res.json();
console.dir(data, { depth: null });

View File

@@ -52,7 +52,7 @@ function runNextBuild() {
const child = spawn(process.execPath, [nextBin, "build"], {
cwd: projectRoot,
stdio: "inherit",
env: process.env,
env: resolveNextBuildEnv(process.env),
});
const forward = (signal) => {
@@ -74,6 +74,13 @@ function runNextBuild() {
});
}
export function resolveNextBuildEnv(baseEnv = process.env) {
return {
...baseEnv,
NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0",
};
}
export async function main() {
let moved = false;

View File

@@ -147,6 +147,10 @@ ensurePackage(
);
// removed better-sqlite3 to ensure ABI compatibility via electron-builder
const bundledSqlite = join(ELECTRON_STANDALONE_DIR, "node_modules", "better-sqlite3");
if (existsSync(bundledSqlite)) {
rmSync(bundledSqlite, { recursive: true, force: true });
}
console.log(
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`

View File

@@ -1,8 +1,9 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { existsSync, renameSync } from "node:fs";
import { join } from "node:path";
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync } from "node:fs";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import {
resolveRuntimePorts,
sanitizeColorEnv,
@@ -16,10 +17,19 @@ const cwd = process.cwd();
const appDir = join(cwd, "app");
const srcAppDir = join(cwd, "src", "app");
const appPage = join(appDir, "page.tsx");
const backupDir = join(cwd, "app.__qa_backup");
const defaultBackupDir = join(cwd, "app.__qa_backup");
const backupDir = resolvePlaywrightAppBackupDir({
cwd,
baseBackupExists: existsSync(defaultBackupDir),
appDirExists: existsSync(appDir),
});
const usingAlternativeBackupDir = backupDir !== defaultBackupDir;
const buildScript = join(cwd, "scripts", "build-next-isolated.mjs");
const standaloneServer = join(cwd, testDistDir(), "standalone", "server.js");
const buildIdFile = join(cwd, testDistDir(), "BUILD_ID");
const rootStaticDir = join(cwd, testDistDir(), "static");
const rootPublicDir = join(cwd, "public");
const standaloneStaticDir = join(cwd, testDistDir(), "standalone", ".next", "static");
const standalonePublicDir = join(cwd, testDistDir(), "standalone", "public");
let appDirMoved = false;
@@ -27,19 +37,90 @@ function testDistDir() {
return process.env.NEXT_DIST_DIR || ".next";
}
export function resolvePlaywrightAppBackupDir({
cwd,
baseBackupExists,
appDirExists,
pid = process.pid,
now = Date.now(),
}) {
const baseBackupDir = join(cwd, "app.__qa_backup");
if (!baseBackupExists || !appDirExists) {
return baseBackupDir;
}
return join(cwd, `app.__qa_backup.${pid}.${now}`);
}
function shouldMoveAppDir() {
return existsSync(appDir) && !existsSync(appPage) && existsSync(srcAppDir);
}
export function directoryHasEntries(dirPath) {
try {
return readdirSync(dirPath).length > 0;
} catch {
return false;
}
}
export function standaloneAssetsNeedSync({
standaloneServerPath,
rootStaticDirPath,
standaloneStaticDirPath,
}) {
return (
existsSync(standaloneServerPath) &&
existsSync(rootStaticDirPath) &&
!directoryHasEntries(standaloneStaticDirPath)
);
}
export function syncStandaloneRuntimeAssets({
standaloneServerPath,
rootStaticDirPath,
standaloneStaticDirPath,
rootPublicDirPath,
standalonePublicDirPath,
log = console,
}) {
if (!existsSync(standaloneServerPath)) return false;
let changed = false;
if (existsSync(rootPublicDirPath) && !directoryHasEntries(standalonePublicDirPath)) {
cpSync(rootPublicDirPath, standalonePublicDirPath, {
recursive: true,
force: true,
});
changed = true;
}
if (existsSync(rootStaticDirPath) && !directoryHasEntries(standaloneStaticDirPath)) {
mkdirSync(dirname(standaloneStaticDirPath), {
recursive: true,
});
cpSync(rootStaticDirPath, standaloneStaticDirPath, {
recursive: true,
force: true,
});
changed = true;
}
if (changed) {
log.log("[Playwright WebServer] Rehydrated standalone static/public assets");
}
return changed;
}
function prepareAppDir() {
if (!shouldMoveAppDir()) return;
if (existsSync(backupDir)) {
if (usingAlternativeBackupDir) {
console.warn(
"[Playwright WebServer] app.__qa_backup already exists; leaving app/ in place. " +
"If tests hit 404 on every route, clear app/ artifacts before running e2e."
"[Playwright WebServer] Existing app.__qa_backup detected; using a per-run backup dir instead."
);
return;
}
renameSync(appDir, backupDir);
@@ -55,14 +136,6 @@ function restoreAppDir() {
console.log("[Playwright WebServer] Restored app/ directory");
}
process.on("exit", restoreAppDir);
process.on("uncaughtException", (error) => {
restoreAppDir();
throw error;
});
prepareAppDir();
const bootstrapEnvVars = bootstrapEnv({ quiet: true });
const runtimePorts = resolveRuntimePorts(bootstrapEnvVars);
const testServerEnv = {
@@ -73,8 +146,17 @@ const testServerEnv = {
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK || "1",
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "1",
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "1",
...(process.env.OMNIROUTE_USE_TURBOPACK
? {
OMNIROUTE_USE_TURBOPACK: process.env.OMNIROUTE_USE_TURBOPACK,
}
: {}),
};
export function shouldUseWebpackForPlaywrightDev({ mode, env }) {
return mode === "dev" && env.OMNIROUTE_USE_TURBOPACK !== "1";
}
function runChild(command, args, env) {
return new Promise((resolve) => {
const child = spawn(command, args, {
@@ -100,7 +182,7 @@ function runChild(command, args, env) {
async function runBuildForStart() {
if (mode !== "start") return;
if (process.env.OMNIROUTE_PLAYWRIGHT_SKIP_BUILD === "1") return;
if (existsSync(buildIdFile)) return;
console.log("[Playwright WebServer] Building fresh standalone app for this run...");
const buildEnv = withRuntimePortEnv(testServerEnv, runtimePorts);
const result = await runChild(process.execPath, [buildScript], buildEnv);
@@ -115,18 +197,37 @@ async function runBuildForStart() {
}
}
await runBuildForStart();
if (mode === "start") {
if (existsSync(standaloneServer)) {
spawnWithForwardedSignals(process.execPath, [standaloneServer], {
stdio: "inherit",
env: {
...withRuntimePortEnv(testServerEnv, runtimePorts),
PORT: String(runtimePorts.dashboardPort),
HOSTNAME: process.env.HOSTNAME || "127.0.0.1",
},
});
} else {
export async function main() {
process.on("exit", restoreAppDir);
process.on("uncaughtException", (error) => {
restoreAppDir();
throw error;
});
prepareAppDir();
await runBuildForStart();
if (mode === "start") {
if (existsSync(standaloneServer)) {
syncStandaloneRuntimeAssets({
standaloneServerPath: standaloneServer,
rootStaticDirPath: rootStaticDir,
standaloneStaticDirPath: standaloneStaticDir,
rootPublicDirPath: rootPublicDir,
standalonePublicDirPath: standalonePublicDir,
});
spawnWithForwardedSignals(process.execPath, [standaloneServer], {
stdio: "inherit",
env: {
...withRuntimePortEnv(testServerEnv, runtimePorts),
PORT: String(runtimePorts.dashboardPort),
HOSTNAME: process.env.HOSTNAME || "127.0.0.1",
},
});
return;
}
const args = [
"./node_modules/next/dist/bin/next",
"start",
@@ -138,18 +239,26 @@ if (mode === "start") {
stdio: "inherit",
env: withRuntimePortEnv(testServerEnv, runtimePorts),
});
return;
}
} else {
const args = [
"./node_modules/next/dist/bin/next",
mode,
"--webpack",
"--port",
String(runtimePorts.dashboardPort),
];
if (shouldUseWebpackForPlaywrightDev({ mode, env: testServerEnv })) {
args.splice(2, 0, "--webpack");
}
spawnWithForwardedSignals(process.execPath, args, {
stdio: "inherit",
env: withRuntimePortEnv(testServerEnv, runtimePorts),
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -20,6 +20,10 @@ function formatShare(value: number) {
return formatPercent(value * 100, 1);
}
function formatPercentOrDash(value: number | null, digits = 1) {
return typeof value === "number" ? formatPercent(value, digits) : "n/a";
}
function formatLatency(value: number) {
return `${Math.round(value).toLocaleString()}ms`;
}
@@ -95,6 +99,7 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
),
[combo.usageSkew.modelDistribution]
);
const targetHealth = combo.targetHealth || [];
return (
<Card className="overflow-hidden p-0">
@@ -251,6 +256,73 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
</div>
</section>
</div>
{targetHealth.length > 0 ? (
<div className="border-t border-black/5 px-6 py-5 dark:border-white/5">
<div>
<div className="text-sm font-semibold text-text-main">Execution targets</div>
<div className="mt-1 text-xs text-text-muted">
Step-level runtime metrics and quota visibility for structured combo targets.
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{targetHealth.map((target) => (
<div
key={target.executionKey}
className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-text-main">
{target.label || target.model}
</div>
<div className="mt-1 text-xs text-text-muted">
{target.provider}
{target.connectionId ? ` · ${target.connectionId.slice(0, 8)}` : ""}
</div>
<div className="mt-1 text-[11px] text-text-muted">{target.stepId}</div>
</div>
<div className="flex items-center gap-2">
{target.lastStatus ? (
<Badge size="sm" variant={target.lastStatus === "ok" ? "success" : "error"}>
{target.lastStatus}
</Badge>
) : null}
<Badge size="sm" variant="default">
{target.requests} req
</Badge>
</div>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<DistributionBar
label="Success"
value={Math.max(target.successRate, 0) / 100}
meta={formatPercent(target.successRate, 0)}
/>
<DistributionBar
label="Latency"
value={target.avgLatencyMs > 0 ? 1 : 0}
meta={formatLatency(target.avgLatencyMs)}
/>
<DistributionBar
label="Quota"
value={Math.max(target.quotaRemainingPct || 0, 0) / 100}
meta={formatPercentOrDash(target.quotaRemainingPct)}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-text-muted">
<span>Quota scope: {target.quotaScope}</span>
{target.quotaTrend ? <span>Trend: {target.quotaTrend}</span> : null}
{target.quotaIsExhausted ? <span>Exhausted</span> : null}
</div>
</div>
))}
</div>
</div>
) : null}
</Card>
);
}

View File

@@ -1,161 +0,0 @@
import { useState, useEffect } from "react";
import { Modal, Input, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function AutoComboModal({ isOpen, onClose, onSave, combo, activeProviders = [] }) {
const t = useTranslations("combos");
const tc = useTranslations("common");
const [formData, setFormData] = useState({
name: "",
strategy: "auto",
candidatePool: [],
explorationRate: 0.05,
modePack: "ship-fast",
budgetCap: "",
});
useEffect(() => {
if (combo) {
// eslint-disable-next-line
setFormData({
name: combo.name || "",
strategy: combo.strategy || "auto",
candidatePool: combo.config?.candidatePool || [],
explorationRate: combo.config?.explorationRate ?? 0.05,
modePack: combo.config?.modePack || "ship-fast",
budgetCap: combo.config?.budgetCap || "",
});
} else {
setFormData({
name: "",
strategy: "auto",
candidatePool: [],
explorationRate: 0.05,
modePack: "ship-fast",
budgetCap: "",
});
}
}, [combo, isOpen]);
const handleSubmit = (e) => {
e.preventDefault();
onSave({
name: formData.name,
strategy: formData.strategy,
config: {
candidatePool: formData.candidatePool,
explorationRate: Number(formData.explorationRate),
modePack: formData.modePack,
budgetCap: formData.budgetCap ? Number(formData.budgetCap) : undefined,
},
});
};
const handleProviderToggle = (providerId) => {
setFormData((prev) => {
const pool = prev.candidatePool.includes(providerId)
? prev.candidatePool.filter((id) => id !== providerId)
: [...prev.candidatePool, providerId];
return { ...prev, candidatePool: pool };
});
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={combo ? "Edit Auto-Combo" : "Create Auto-Combo"}
>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input
label="Combo Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
pattern="^[a-zA-Z0-9_\/\.\-]+$"
disabled={!!combo} // Cannot change name if editing
/>
<div>
<label className="text-sm font-medium mb-1 block">Strategy</label>
<select
className="w-full text-sm rounded-lg border border-border bg-surface px-3 py-2 text-text-main focus:border-primary focus:outline-none"
value={formData.strategy}
onChange={(e) => setFormData({ ...formData, strategy: e.target.value })}
>
<option value="auto">Smart Auto-Routing</option>
<option value="lkgp">Last Known Good Provider (LKGP)</option>
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Candidate Pool</label>
<p className="text-xs text-text-muted mb-2">
Select which providers this engine evaluates.
</p>
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-2 border border-border rounded-lg">
{activeProviders.map((p) => (
<button
key={p.id}
type="button"
onClick={() => handleProviderToggle(p.id)}
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
formData.candidatePool.includes(p.id)
? "bg-primary border-primary text-white"
: "bg-surface border-border text-text-main"
}`}
>
{p.name || p.id}
</button>
))}
{activeProviders.length === 0 && (
<span className="text-xs text-text-muted">No active APIs found</span>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="Exploration Rate"
type="number"
step="0.01"
min="0"
max="1"
value={formData.explorationRate}
onChange={(e) => setFormData({ ...formData, explorationRate: e.target.value })}
/>
<div>
<label className="text-sm font-medium mb-1 block">Mode Pack</label>
<select
className="w-full text-sm rounded-lg border border-border bg-surface px-3 py-2 text-text-main focus:border-primary focus:outline-none"
value={formData.modePack}
onChange={(e) => setFormData({ ...formData, modePack: e.target.value })}
>
<option value="ship-fast">Ship Fast</option>
<option value="cost-saver">Cost Saver</option>
<option value="quality-first">Quality First</option>
<option value="offline-friendly">Offline Friendly</option>
</select>
</div>
</div>
<Input
label="Budget Cap ($ USD / request limit)"
type="number"
step="0.0001"
placeholder="Optional"
value={formData.budgetCap}
onChange={(e) => setFormData({ ...formData, budgetCap: e.target.value })}
/>
<div className="mt-4 flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
{tc("cancel")}
</Button>
<Button type="submit">{tc("save")}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -1,477 +1,5 @@
/**
* Dashboard Auto-Combo Panel — /dashboard/auto-combo
*
* Shows provider scores, scoring factors, exclusions, mode packs, and routing history.
*/
import { redirect } from "next/navigation";
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, Button } from "@/shared/components";
import AutoComboModal from "./AutoComboModal";
import { useNotificationStore } from "@/store/notificationStore";
interface ProviderScore {
provider: string;
model: string;
score: number;
factors: Record<string, number>;
}
interface ExclusionEntry {
provider: string;
excludedAt: string;
cooldownMs: number;
reason: string;
}
type AutoComboRecord = {
candidatePool?: unknown;
weights?: unknown;
};
type HealthRecord = {
providerHealth?: Record<string, { state?: string; lastFailure?: string | null }>;
circuitBreakers?: Array<{
provider?: string;
name?: string;
state?: string;
lastFailure?: string | null;
}>;
};
export default function AutoComboDashboard() {
const [scores, setScores] = useState<ProviderScore[]>([]);
const [exclusions, setExclusions] = useState<ExclusionEntry[]>([]);
const [incidentMode, setIncidentMode] = useState(false);
const [modePack, setModePack] = useState("ship-fast");
const notify = useNotificationStore();
const [combos, setCombos] = useState<any[]>([]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingCombo, setEditingCombo] = useState<any | null>(null);
const [activeProviders, setActiveProviders] = useState<any[]>([]);
const fetchCombos = useCallback(async () => {
try {
const res = await fetch("/api/combos");
if (res.ok) {
const payload = await res.json();
const allCombos = Array.isArray(payload?.combos) ? payload.combos : [];
const auto = allCombos.filter((c: any) => c.strategy === "auto" || c.strategy === "lkgp");
setCombos(auto);
// Refresh scores based on first auto combo found
const firstCombo = auto[0] || null;
const candidatePool = Array.isArray(firstCombo?.config?.candidatePool)
? firstCombo.config.candidatePool
: [];
const rawWeights =
firstCombo?.weights &&
typeof firstCombo.weights === "object" &&
!Array.isArray(firstCombo.weights)
? (firstCombo.weights as Record<string, unknown>)
: {};
const factors = Object.fromEntries(
Object.entries(rawWeights).map(([k, v]) => [k, typeof v === "number" ? v : 0])
);
const baseScore = candidatePool.length > 0 ? 1 / candidatePool.length : 0;
setScores(
candidatePool.map((provider) => ({
provider,
model: "auto",
score: baseScore,
factors,
}))
);
} else {
setScores([]);
}
} catch {
setScores([]);
}
}, []);
const fetchHealth = useCallback(async () => {
try {
const healthRes = await fetch("/api/monitoring/health");
if (healthRes.ok) {
const health = (await healthRes.json()) as HealthRecord;
const providerHealth =
health?.providerHealth && typeof health.providerHealth === "object"
? health.providerHealth
: {};
const breakersFromProviderHealth = Object.entries(providerHealth).map(
([provider, status]) => ({
provider,
state: status?.state || "CLOSED",
lastFailure: status?.lastFailure || null,
})
);
const breakersFromArray = Array.isArray(health?.circuitBreakers)
? health.circuitBreakers
: [];
const breakers =
breakersFromArray.length > 0
? breakersFromArray.map((breaker) => ({
provider: breaker.provider || breaker.name || "unknown",
state: breaker.state || "CLOSED",
lastFailure: breaker.lastFailure || null,
}))
: breakersFromProviderHealth;
const openBreakers = breakers.filter((breaker) => breaker.state === "OPEN");
setIncidentMode(openBreakers.length / Math.max(breakers.length, 1) > 0.5);
setExclusions(
openBreakers.map((breaker) => ({
provider: breaker.provider,
excludedAt: breaker.lastFailure || new Date().toISOString(),
cooldownMs: 5 * 60 * 1000,
reason: "Circuit breaker OPEN",
}))
);
} else {
setIncidentMode(false);
setExclusions([]);
}
} catch {
/* ignore */
}
}, []);
const fetchData = useCallback(async () => {
await Promise.all([fetchCombos(), fetchHealth()]);
// Fetch active providers for the Modal
try {
const pRes = await fetch("/api/providers");
if (pRes.ok) {
const pData = await pRes.json();
setActiveProviders(
(pData.connections || []).filter(
(c: any) => c.testStatus === "active" || c.testStatus === "success"
)
);
}
} catch {}
}, [fetchCombos, fetchHealth]);
useEffect(() => {
const id = setTimeout(fetchData, 0);
const interval = setInterval(fetchData, 30_000);
return () => {
clearTimeout(id);
clearInterval(interval);
};
}, [fetchData]);
const handleCreate = async (data: any) => {
try {
const res = await fetch("/api/combos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
await fetchCombos();
setShowCreateModal(false);
notify.success("Auto-Combo created successfully");
} else {
const err = await res.json();
notify.error(err.error?.message || err.error || "Failed to create combo");
}
} catch {
notify.error("Error creating combo");
}
};
const handleUpdate = async (id: string, data: any) => {
try {
const res = await fetch(`/api/combos/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
await fetchCombos();
setEditingCombo(null);
notify.success("Auto-Combo updated");
} else {
const err = await res.json();
notify.error("Failed to update: " + (err.error?.message || err.error));
}
} catch {
notify.error("Error updating combo");
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this auto-combo?")) return;
try {
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) {
setCombos(combos.filter((c) => c.id !== id));
notify.success("Auto-combo deleted");
}
} catch {
notify.error("Error deleting combo");
}
};
const FACTOR_LABELS: Record<string, string> = {
quota: "📊 Quota",
health: "💚 Health",
costInv: "💰 Cost",
latencyInv: "⚡ Latency",
taskFit: "🎯 Task Fit",
stability: "📈 Stability",
tierPriority: "🏷️ Tier",
};
const MODE_PACKS = [
{ id: "ship-fast", label: "🚀 Ship Fast" },
{ id: "cost-saver", label: "💰 Cost Saver" },
{ id: "quality-first", label: "🎯 Quality First" },
{ id: "offline-friendly", label: "📡 Offline Friendly" },
];
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-semibold"> Auto-Combo Engine</h1>
<p className="text-sm text-text-muted mt-1">
Smart routing automatically adapting to latency, health, and throughput
</p>
</div>
<Button icon="add" onClick={() => setShowCreateModal(true)}>
Create Auto-Combo
</Button>
</div>
{/* ──── CRUD Auto Combos List ──── */}
{combos.length > 0 && (
<Card className="mb-2">
<h2 className="text-lg font-semibold mb-4">Configured Auto-Combos</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{combos.map((combo) => (
<div
key={combo.id}
className="p-4 border rounded-lg bg-surface flex justify-between items-center"
>
<div>
<h3 className="font-semibold text-text-main flex items-center gap-2">
{combo.name}
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-500">
{combo.strategy}
</span>
</h3>
<p className="text-xs text-text-muted mt-1">
Pool: {combo.config?.candidatePool?.length || "All"} APIs | Pack:{" "}
{combo.config?.modePack || "fast"}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditingCombo(combo)}>
Edit
</Button>
<Button size="sm" variant="ghost" onClick={() => handleDelete(combo.id)}>
Delete
</Button>
</div>
</div>
))}
</div>
</Card>
)}
{/* Forms */}
{showCreateModal && (
<AutoComboModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSave={handleCreate}
activeProviders={activeProviders}
combo={null}
/>
)}
{editingCombo && (
<AutoComboModal
isOpen={!!editingCombo}
onClose={() => setEditingCombo(null)}
onSave={(data: any) => handleUpdate(editingCombo.id, data)}
activeProviders={activeProviders}
combo={editingCombo}
/>
)}
<Card>
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1">
<h2 className="text-lg font-semibold mb-4">Status Overview</h2>
<div className="flex flex-col gap-3">
<div
className={`p-4 rounded-lg border flex items-center justify-between ${
incidentMode
? "bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-300"
: "bg-green-500/10 border-green-500/30 text-green-700 dark:text-green-300"
}`}
>
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[24px]">
{incidentMode ? "warning" : "check_circle"}
</span>
<div>
<h3 className="font-semibold">
{incidentMode ? "Incident Mode" : "Normal Operation"}
</h3>
<p className="text-sm opacity-80">
{incidentMode
? "High circuit breaker trip rate detected"
: "All providers reporting healthy metrics"}
</p>
</div>
</div>
</div>
<div className="p-4 rounded-lg border border-border/50 bg-surface/30 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[24px] text-blue-500">tune</span>
<div>
<h3 className="font-semibold">Active Mode Pack</h3>
<p className="text-sm text-text-muted">
{MODE_PACKS.find((m) => m.id === modePack)?.label || modePack}
</p>
</div>
</div>
</div>
</div>
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold mb-4">Mode Pack</h2>
<div className="grid grid-cols-2 gap-2">
{MODE_PACKS.map((mp) => (
<button
key={mp.id}
onClick={() => setModePack(mp.id)}
className={`flex flex-col items-start p-3 rounded-lg border transition-all ${
modePack === mp.id
? "border-blue-500/50 bg-blue-500/5 ring-1 ring-blue-500/20"
: "border-border/50 hover:border-border hover:bg-surface/30"
}`}
>
<span className={`font-medium ${modePack === mp.id ? "text-blue-500" : ""}`}>
{mp.label}
</span>
</button>
))}
</div>
</div>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
leaderboard
</span>
</div>
<h3 className="text-lg font-semibold">Provider Scores</h3>
</div>
{scores.length === 0 ? (
<p className="text-sm text-text-muted py-4">
No auto-combo configured... Create one to see live provider scores.
</p>
) : (
<div className="space-y-3">
{scores.map((s) => (
<div
key={s.provider}
className="p-3 bg-surface/30 rounded-lg border border-border/50"
>
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-sm">
{s.provider} / {s.model}
</span>
<span className="font-bold text-lg text-blue-500">
{(s.score * 100).toFixed(0)}%
</span>
</div>
{/* Score Bar */}
<div className="h-1.5 bg-border/50 rounded-full overflow-hidden mb-3">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-1000"
style={{ width: `${s.score * 100}%` }}
/>
</div>
{/* Factor Breakdown */}
<div className="flex flex-wrap gap-2 text-[11px] text-text-muted">
{Object.entries(s.factors || {}).map(([key, val]) => (
<div
key={key}
className="px-2 py-0.5 rounded-full bg-black/5 dark:bg-white/5 border border-border/30"
>
{FACTOR_LABELS[key] || key}:{" "}
<span className="font-medium text-text-main">
{((val as number) * 100).toFixed(0)}%
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</Card>
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-red-500/10 text-red-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
block
</span>
</div>
<h3 className="text-lg font-semibold">Excluded Providers</h3>
</div>
{exclusions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[32px] mb-2 text-border">
verified
</span>
<p className="text-sm">No providers currently excluded.</p>
</div>
) : (
<div className="space-y-2">
{exclusions.map((e) => (
<div
key={e.provider}
className="p-3 bg-red-500/5 rounded-lg border border-red-500/20"
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="font-medium text-red-600 dark:text-red-400">
{e.provider}
</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-red-500/10 text-red-600 dark:text-red-400 font-medium">
Cooldown: {Math.round(e.cooldownMs / 60000)}m
</span>
</div>
<p className="text-xs text-text-muted mt-1.5 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">info</span>
{e.reason}
</p>
</div>
))}
</div>
)}
</Card>
</div>
</div>
);
export default function AutoComboRedirectPage() {
redirect("/dashboard/combos?filter=intelligent");
}

View File

@@ -0,0 +1,280 @@
"use client";
import { useMemo } from "react";
import Card from "@/shared/components/Card";
import {
DEFAULT_INTELLIGENT_WEIGHTS,
FACTOR_LABELS,
MODE_PACK_OPTIONS,
ROUTER_STRATEGY_OPTIONS,
normalizeIntelligentRoutingConfig,
} from "@/lib/combos/intelligentRouting";
function getI18nOrFallback(t: any, key: string, fallback: string) {
if (typeof t?.has === "function" && t.has(key)) return t(key);
return fallback;
}
function toProviderOptions(activeProviders: any[] = []) {
const uniqueProviders = new Map<string, { id: string; label: string; connectionCount: number }>();
activeProviders.forEach((provider) => {
const providerId =
typeof provider?.provider === "string" && provider.provider.trim().length > 0
? provider.provider
: typeof provider?.id === "string" && provider.id.trim().length > 0
? provider.id
: null;
if (!providerId) return;
const currentEntry = uniqueProviders.get(providerId);
const fallbackLabel =
typeof provider?.name === "string" && provider.name.trim().length > 0
? provider.name
: providerId;
uniqueProviders.set(providerId, {
id: providerId,
label: currentEntry?.label || fallbackLabel,
connectionCount: (currentEntry?.connectionCount || 0) + 1,
});
});
return [...uniqueProviders.values()].sort((a, b) => a.label.localeCompare(b.label));
}
export default function BuilderIntelligentStep({
t,
config,
onChange,
activeProviders,
}: {
t: any;
config: Record<string, unknown>;
onChange: (nextConfig: Record<string, unknown>) => void;
activeProviders: any[];
}) {
const normalizedConfig = normalizeIntelligentRoutingConfig(config);
const providerOptions = useMemo(() => toProviderOptions(activeProviders), [activeProviders]);
const updateConfig = (patch: Record<string, unknown>) => {
onChange({
...normalizedConfig,
...patch,
weights: {
...normalizedConfig.weights,
...(patch.weights || {}),
},
});
};
const toggleCandidateProvider = (providerId: string) => {
const nextCandidatePool = normalizedConfig.candidatePool.includes(providerId)
? normalizedConfig.candidatePool.filter((entry) => entry !== providerId)
: [...normalizedConfig.candidatePool, providerId];
updateConfig({ candidatePool: nextCandidatePool });
};
return (
<div className="flex flex-col gap-3">
<Card.Section>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-main">
{getI18nOrFallback(t, "builderIntelligentTitle", "Intelligent Routing Configuration")}
</h3>
<p className="text-xs text-text-muted mt-1">
{getI18nOrFallback(
t,
"builderIntelligentDesc",
"Configure the multi-factor scoring engine for this auto-routing combo."
)}
</p>
</div>
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-primary">
<span className="material-symbols-outlined text-[12px]">auto_awesome</span>
Intelligent
</span>
</div>
</Card.Section>
<Card.Section>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold text-text-main">
{getI18nOrFallback(t, "candidatePoolLabel", "Candidate Pool")}
</p>
<p className="text-[11px] text-text-muted mt-1">
{getI18nOrFallback(
t,
"candidatePoolHint",
"Select which providers this engine should evaluate. Leave empty to use all active providers."
)}
</p>
</div>
<span className="text-[10px] text-text-muted">
{normalizedConfig.candidatePool.length > 0
? `${normalizedConfig.candidatePool.length} selected`
: "All active providers"}
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{providerOptions.length === 0 && (
<span className="text-[11px] text-text-muted">
{getI18nOrFallback(t, "candidatePoolEmpty", "No active providers available yet.")}
</span>
)}
{providerOptions.map((provider) => {
const isSelected = normalizedConfig.candidatePool.includes(provider.id);
return (
<button
key={provider.id}
type="button"
onClick={() => toggleCandidateProvider(provider.id)}
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
isSelected
? "border-primary bg-primary/10 text-primary"
: "border-black/10 dark:border-white/10 text-text-main hover:border-primary/40 hover:bg-primary/5"
}`}
>
{provider.label}
<span className="ml-1 text-[10px] text-text-muted">
{provider.connectionCount} acct
{provider.connectionCount === 1 ? "" : "s"}
</span>
</button>
);
})}
</div>
</Card.Section>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Card.Section>
<label className="text-xs font-semibold text-text-main block mb-2">
{getI18nOrFallback(t, "modePackLabel", "Mode Pack")}
</label>
<select
value={normalizedConfig.modePack}
onChange={(event) => updateConfig({ modePack: event.target.value })}
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
>
{MODE_PACK_OPTIONS.map((modePack) => (
<option key={modePack.id} value={modePack.id}>
{modePack.label}
</option>
))}
</select>
</Card.Section>
<Card.Section>
<label className="text-xs font-semibold text-text-main block mb-2">
{getI18nOrFallback(t, "routerStrategyLabel", "Router Strategy")}
</label>
<select
value={normalizedConfig.routerStrategy}
onChange={(event) => updateConfig({ routerStrategy: event.target.value })}
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
>
{ROUTER_STRATEGY_OPTIONS.map((strategy) => (
<option key={strategy.id} value={strategy.id}>
{strategy.id === "rules"
? getI18nOrFallback(t, "strategyRules", strategy.label)
: strategy.label}
</option>
))}
</select>
</Card.Section>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Card.Section>
<label className="text-xs font-semibold text-text-main block">
{getI18nOrFallback(t, "explorationRateLabel", "Exploration Rate")}
</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={normalizedConfig.explorationRate}
onChange={(event) => updateConfig({ explorationRate: Number(event.target.value || 0) })}
className="mt-3 w-full accent-primary"
/>
<p className="text-[11px] text-text-muted mt-2">
{getI18nOrFallback(
t,
"explorationRateHint",
"{percent}% of requests can explore non-optimal providers."
).replace("{percent}", `${Math.round(normalizedConfig.explorationRate * 100)}`)}
</p>
</Card.Section>
<Card.Section>
<label className="text-xs font-semibold text-text-main block mb-2">
{getI18nOrFallback(t, "budgetCapLabel", "Budget Cap (USD / request)")}
</label>
<input
type="number"
min="0"
step="0.0001"
value={normalizedConfig.budgetCap ?? ""}
placeholder={getI18nOrFallback(t, "budgetCapPlaceholder", "No limit")}
onChange={(event) =>
updateConfig({
budgetCap: event.target.value ? Number(event.target.value) : undefined,
})
}
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
/>
</Card.Section>
</div>
<details className="rounded-lg border border-black/8 dark:border-white/8 bg-black/[0.02] dark:bg-white/[0.02] p-3">
<summary className="cursor-pointer text-xs font-semibold text-text-main">
{getI18nOrFallback(t, "advancedWeightsTitle", "Advanced: Scoring Weights")}
</summary>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
{Object.entries(normalizedConfig.weights).map(([weightKey, weightValue]) => (
<div
key={weightKey}
className="rounded-lg border border-black/6 dark:border-white/6 p-3"
>
<div className="flex items-center justify-between gap-2">
<label className="text-[11px] font-medium text-text-main">
{getI18nOrFallback(
t,
`weight${weightKey[0].toUpperCase()}${weightKey.slice(1)}`,
FACTOR_LABELS[weightKey as keyof typeof DEFAULT_INTELLIGENT_WEIGHTS]
)}
</label>
<span className="text-[11px] text-text-muted">
{Math.round(Number(weightValue) * 100)}%
</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={weightValue}
onChange={(event) =>
updateConfig({
weights: {
...normalizedConfig.weights,
[weightKey]: Number(event.target.value || 0),
},
})
}
className="mt-3 w-full accent-primary"
/>
</div>
))}
</div>
</details>
</div>
);
}

View File

@@ -0,0 +1,371 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Button from "@/shared/components/Button";
import Card from "@/shared/components/Card";
import { useNotificationStore } from "@/store/notificationStore";
import {
FACTOR_LABELS,
MODE_PACK_OPTIONS,
buildIntelligentProviderScores,
extractIntelligentHealthState,
normalizeIntelligentRoutingConfig,
} from "@/lib/combos/intelligentRouting";
function getI18nOrFallback(t: any, key: string, fallback: string) {
if (typeof t?.has === "function" && t.has(key)) return t(key);
return fallback;
}
function formatProviderLabel(providerId: string, activeProviders: any[] = []) {
const matchedConnection = activeProviders.find(
(provider) =>
provider?.provider === providerId ||
provider?.id === providerId ||
provider?.name === providerId
);
if (matchedConnection?.name && matchedConnection.name !== providerId) {
return `${matchedConnection.name} (${providerId})`;
}
return providerId;
}
export default function IntelligentComboPanel({
t,
combo,
allCombos,
activeProviders,
onComboUpdated,
}: {
t: any;
combo: any;
allCombos: any[];
activeProviders: any[];
onComboUpdated?: (combo: any) => void;
}) {
const notify = useNotificationStore();
const [incidentMode, setIncidentMode] = useState(false);
const [exclusions, setExclusions] = useState<any[]>([]);
const [savingModePack, setSavingModePack] = useState<string | null>(null);
const normalizedConfig = useMemo(
() => normalizeIntelligentRoutingConfig(combo?.config),
[combo?.config]
);
const providerScores = useMemo(() => buildIntelligentProviderScores(combo), [combo]);
const fetchHealth = useCallback(async () => {
try {
const response = await fetch("/api/monitoring/health");
if (!response.ok) {
setIncidentMode(false);
setExclusions([]);
return;
}
const health = await response.json();
const nextState = extractIntelligentHealthState(health);
setIncidentMode(nextState.incidentMode);
setExclusions(nextState.exclusions);
} catch {
setIncidentMode(false);
setExclusions([]);
}
}, []);
useEffect(() => {
const timeoutId = setTimeout(fetchHealth, 0);
const intervalId = setInterval(fetchHealth, 30_000);
return () => {
clearTimeout(timeoutId);
clearInterval(intervalId);
};
}, [fetchHealth]);
const handleModePackChange = async (modePackId: string) => {
if (!combo?.id || modePackId === normalizedConfig.modePack) return;
setSavingModePack(modePackId);
try {
const response = await fetch(`/api/combos/${combo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: {
...(combo?.config || {}),
modePack: modePackId,
},
}),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
throw new Error(
errorBody?.error?.message || errorBody?.error || "Failed to update mode pack"
);
}
const updatedCombo = await response.json();
onComboUpdated?.(updatedCombo);
notify.success(
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.").replace(
"{pack}",
modePackId
)
);
} catch (error: any) {
notify.error(error?.message || "Failed to update mode pack.");
} finally {
setSavingModePack(null);
}
};
return (
<Card
className="border-primary/10 bg-gradient-to-br from-primary/[0.04] via-transparent to-transparent"
padding="sm"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[18px]">
auto_awesome
</span>
<h2 className="text-lg font-semibold text-text-main">
{getI18nOrFallback(t, "intelligentPanelTitle", "Intelligent Routing Dashboard")}
</h2>
</div>
<p className="text-sm text-text-muted mt-1">
{getI18nOrFallback(
t,
"intelligentPanelDesc",
"Real-time scoring and health status for this auto-routing combo."
)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-text-muted">
<code className="rounded bg-black/5 dark:bg-white/5 px-2 py-1 text-text-main">
{combo?.name}
</code>
<span>{allCombos.length} intelligent combo(s)</span>
<span>
{normalizedConfig.candidatePool.length || activeProviders.length} providers in scope
</span>
</div>
</div>
<div
className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium ${
incidentMode
? "bg-amber-500/15 text-amber-600 dark:text-amber-300"
: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-300"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{incidentMode ? "warning" : "verified"}
</span>
{incidentMode
? getI18nOrFallback(t, "incidentMode", "Incident Mode")
: getI18nOrFallback(t, "normalOperation", "Normal Operation")}
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<Card.Section>
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-text-main">
{getI18nOrFallback(t, "statusOverview", "Status Overview")}
</p>
<p className="text-[11px] text-text-muted mt-1">
{incidentMode
? getI18nOrFallback(
t,
"highCircuitBreakerRate",
"High circuit breaker trip rate detected."
)
: getI18nOrFallback(
t,
"allProvidersHealthy",
"Providers are reporting healthy routing conditions."
)}
</p>
</div>
<div className="rounded-lg bg-black/5 dark:bg-white/5 px-3 py-2 text-right">
<p className="text-[10px] uppercase tracking-wide text-text-muted">Exclusions</p>
<p className="text-lg font-semibold text-text-main">{exclusions.length}</p>
</div>
</div>
</Card.Section>
<Card.Section>
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-text-main">
{getI18nOrFallback(t, "activeModePack", "Active Mode Pack")}
</p>
<p className="text-[11px] text-text-muted mt-1">
{getI18nOrFallback(
t,
"modePackHint",
"Switch presets to bias the routing engine without rebuilding the combo."
)}
</p>
</div>
{savingModePack && (
<span className="text-[11px] text-text-muted">Saving {savingModePack}</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 mt-3">
{MODE_PACK_OPTIONS.map((modePack) => {
const isActive = normalizedConfig.modePack === modePack.id;
return (
<Button
key={modePack.id}
variant={isActive ? "primary" : "secondary"}
size="sm"
onClick={() => handleModePackChange(modePack.id)}
disabled={Boolean(savingModePack)}
className="!justify-start"
>
<span className="material-symbols-outlined text-[14px]">{modePack.emoji}</span>
{modePack.label}
</Button>
);
})}
</div>
</Card.Section>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<Card.Section>
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-text-main">
{getI18nOrFallback(t, "providerScores", "Provider Scores")}
</p>
<p className="text-[11px] text-text-muted mt-1">
{normalizedConfig.candidatePool.length > 0
? `${normalizedConfig.candidatePool.length} providers currently ranked for this combo.`
: getI18nOrFallback(
t,
"allProvidersEvaluated",
"No candidate pool configured. All active providers are evaluated at runtime."
)}
</p>
</div>
</div>
<div className="mt-3 flex flex-col gap-2">
{providerScores.length === 0 ? (
<div className="rounded-lg border border-dashed border-black/10 dark:border-white/10 p-3 text-[11px] text-text-muted">
{getI18nOrFallback(
t,
"allProvidersEvaluated",
"No candidate pool configured. All active providers are evaluated at runtime."
)}
</div>
) : (
providerScores.map((entry) => {
const percentage = Math.round(entry.score * 100);
return (
<div
key={entry.provider}
className="rounded-lg border border-black/8 dark:border-white/8 bg-white/60 dark:bg-white/[0.03] p-3"
>
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold text-text-main">
{formatProviderLabel(entry.provider, activeProviders)}
</p>
<p className="text-[11px] text-text-muted mt-0.5">{entry.model}</p>
</div>
<span className="rounded-full bg-primary/10 px-2 py-1 text-[11px] font-semibold text-primary">
{percentage}%
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-black/8 dark:bg-white/8 overflow-hidden">
<div
className="h-full rounded-full bg-blue-500 transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{Object.entries(entry.factors).map(([factorKey, factorValue]) => (
<span
key={`${entry.provider}-${factorKey}`}
className="rounded-full bg-black/5 dark:bg-white/5 px-2 py-1 text-[10px] text-text-muted"
>
{FACTOR_LABELS[factorKey as keyof typeof FACTOR_LABELS]}{" "}
{Math.round(Number(factorValue) * 100)}%
</span>
))}
</div>
</div>
);
})
)}
</div>
</Card.Section>
<Card.Section>
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-text-main">
{getI18nOrFallback(t, "excludedProviders", "Excluded Providers")}
</p>
<p className="text-[11px] text-text-muted mt-1">
{getI18nOrFallback(
t,
"excludedProvidersHint",
"Providers with an OPEN circuit breaker are temporarily excluded from routing."
)}
</p>
</div>
</div>
<div className="mt-3 flex flex-col gap-2">
{exclusions.length === 0 ? (
<div className="rounded-lg border border-dashed border-emerald-500/20 bg-emerald-500/5 p-3 text-[11px] text-emerald-700 dark:text-emerald-300">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[14px]">verified</span>
{getI18nOrFallback(
t,
"noExcludedProviders",
"No providers are currently excluded."
)}
</div>
</div>
) : (
exclusions.map((exclusion) => (
<div
key={`${exclusion.provider}-${exclusion.excludedAt}`}
className="rounded-lg border border-amber-500/20 bg-amber-500/10 p-3"
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs font-semibold text-text-main">{exclusion.provider}</p>
<p className="text-[11px] text-text-muted mt-1">{exclusion.reason}</p>
</div>
<span className="rounded-full bg-amber-500/15 px-2 py-1 text-[10px] font-semibold text-amber-700 dark:text-amber-300">
{getI18nOrFallback(t, "cooldownMinutes", "Cooldown: {minutes}m").replace(
"{minutes}",
`${Math.ceil(exclusion.cooldownMs / 60000)}`
)}
</span>
</div>
</div>
))
)}
</div>
</Card.Section>
</div>
</div>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,8 @@ export default function HealthPage() {
const tc = useTranslations("common");
const tp = useTranslations("providers");
const [data, setData] = useState(null);
const [dbHealth, setDbHealth] = useState(null);
const [dbHealthError, setDbHealthError] = useState(null);
const [error, setError] = useState(null);
const [lastRefresh, setLastRefresh] = useState(null);
const [telemetry, setTelemetry] = useState(null);
@@ -50,6 +52,7 @@ export default function HealthPage() {
const [signatureCache, setSignatureCache] = useState(null);
const [degradation, setDegradation] = useState(null);
const [resetting, setResetting] = useState(false);
const [repairingDb, setRepairingDb] = useState(false);
const fetchHealth = useCallback(async () => {
try {
@@ -64,6 +67,18 @@ export default function HealthPage() {
}
}, []);
const fetchDbHealth = useCallback(async () => {
try {
const res = await fetch("/api/v1/db/health");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setDbHealth(json);
setDbHealthError(null);
} catch (err) {
setDbHealthError(err.message);
}
}, []);
// Fetch telemetry, cache, and signature cache stats
const fetchExtras = useCallback(async () => {
const results = await Promise.allSettled([
@@ -83,12 +98,14 @@ export default function HealthPage() {
useEffect(() => {
fetchHealth();
fetchExtras();
fetchDbHealth();
const interval = setInterval(() => {
fetchHealth();
fetchExtras();
fetchDbHealth();
}, 15000);
return () => clearInterval(interval);
}, [fetchHealth, fetchExtras]);
}, [fetchHealth, fetchExtras, fetchDbHealth]);
const handleResetHealth = async () => {
if (!confirm(t("resetConfirm"))) return;
@@ -106,6 +123,24 @@ export default function HealthPage() {
}
};
const handleRepairDb = async () => {
setRepairingDb(true);
try {
const res = await fetch("/api/v1/db/health", { method: "POST" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setDbHealth(json);
setDbHealthError(null);
await fetchHealth();
await fetchExtras();
} catch (err) {
console.error("Failed to repair database health:", err);
setDbHealthError(err.message);
} finally {
setRepairingDb(false);
}
};
const fmtMs = (ms) =>
ms != null ? t("millisecondsShort", { value: Math.round(ms) }) : t("notAvailable");
@@ -137,7 +172,15 @@ export default function HealthPage() {
);
}
const { system, providerHealth, providerSummary, rateLimitStatus, lockouts } = data;
const {
system,
providerHealth,
providerSummary,
rateLimitStatus,
lockouts,
sessions,
quotaMonitor,
} = data;
const cbEntries = Object.entries(providerHealth || {});
const lockoutEntries = Object.entries(lockouts || {});
@@ -159,6 +202,7 @@ export default function HealthPage() {
onClick={() => {
fetchHealth();
fetchExtras();
fetchDbHealth();
}}
className="p-2 rounded-lg bg-surface hover:bg-surface/80 text-text-muted hover:text-text-main transition-colors"
title={tc("refresh")}
@@ -190,6 +234,87 @@ export default function HealthPage() {
</span>
</div>
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<div
className={`flex items-center justify-center size-9 rounded-lg ${
dbHealth?.isHealthy
? "bg-green-500/10 text-green-500"
: "bg-amber-500/10 text-amber-500"
}`}
>
<span className="material-symbols-outlined text-[18px]">database</span>
</div>
<div>
<h2 className="text-lg font-semibold text-text-main">Database Health</h2>
<p className="text-sm text-text-muted">
Diagnose and repair stale quota/domain rows and broken combo references.
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-4">
<div className="rounded-xl border border-border bg-surface/50 p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Status</p>
<p
className={`mt-1 text-sm font-medium ${
dbHealth?.isHealthy ? "text-green-400" : "text-amber-400"
}`}
>
{dbHealth?.isHealthy ? "Healthy" : "Attention needed"}
</p>
</div>
<div className="rounded-xl border border-border bg-surface/50 p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Issues</p>
<p className="mt-1 text-sm font-medium text-text-main">
{dbHealth?.issues?.length ?? 0}
</p>
</div>
<div className="rounded-xl border border-border bg-surface/50 p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Repairs</p>
<p className="mt-1 text-sm font-medium text-text-main">
{dbHealth?.repairedCount ?? 0}
</p>
</div>
</div>
</div>
<div className="flex flex-col items-stretch gap-2 min-w-[180px]">
<button
onClick={handleRepairDb}
disabled={repairingDb}
className="px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{repairingDb ? "Repairing..." : "Run Auto-Repair"}
</button>
{dbHealth?.backupCreated && (
<p className="text-xs text-text-muted">
A repair backup was created before mutating.
</p>
)}
{dbHealthError && <p className="text-xs text-red-400">{dbHealthError}</p>}
</div>
</div>
{Array.isArray(dbHealth?.issues) && dbHealth.issues.length > 0 && (
<div className="mt-4 space-y-2">
{dbHealth.issues.map((issue, index) => (
<div
key={`${issue.table}-${issue.type}-${index}`}
className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-text-main">{issue.description}</p>
<span className="text-xs text-amber-400">{issue.count}</span>
</div>
<p className="text-xs text-text-muted mt-1">
{issue.table} · {issue.type}
</p>
</div>
))}
</div>
)}
</Card>
{/* System Info Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4">
@@ -273,6 +398,138 @@ export default function HealthPage() {
</Card>
</div>
{/* Session & Quota Observability */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<Card className="p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">groups</span>
Session Activity
</h2>
<span className="text-xs text-text-muted">{sessions?.activeCount ?? 0} active</span>
</div>
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Sticky-bound sessions</div>
<div className="text-2xl font-semibold text-text-main mt-1">
{sessions?.stickyBoundCount ?? 0}
</div>
</div>
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Sessions by API key</div>
<div className="text-2xl font-semibold text-text-main mt-1">
{Object.keys(sessions?.byApiKey || {}).length}
</div>
</div>
</div>
{sessions?.top?.length > 0 ? (
<div className="space-y-2">
{sessions.top.slice(0, 5).map((session: any) => (
<div
key={session.sessionId}
className="rounded-lg border border-border/30 bg-surface/20 p-3 flex items-center justify-between gap-3"
>
<div className="min-w-0">
<div className="font-mono text-xs text-text-main truncate">
{session.sessionId}
</div>
<div className="text-xs text-text-muted mt-1">
{session.requestCount} requests
{session.connectionId ? `${session.connectionId.slice(0, 8)}` : ""}
</div>
</div>
<div className="text-right text-xs text-text-muted shrink-0">
<div>{Math.round((session.idleMs || 0) / 1000)}s idle</div>
<div>{Math.round((session.ageMs || 0) / 1000)}s age</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-text-muted">No active sessions tracked yet.</p>
)}
</Card>
<Card className="p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">radar</span>
Quota Monitors
</h2>
<span className="text-xs text-text-muted">{quotaMonitor?.active ?? 0} active</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Alerting</div>
<div className="text-2xl font-semibold text-amber-400 mt-1">
{quotaMonitor?.alerting ?? 0}
</div>
</div>
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Exhausted</div>
<div className="text-2xl font-semibold text-red-400 mt-1">
{quotaMonitor?.exhausted ?? 0}
</div>
</div>
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Errors</div>
<div className="text-2xl font-semibold text-orange-400 mt-1">
{quotaMonitor?.errors ?? 0}
</div>
</div>
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Providers</div>
<div className="text-2xl font-semibold text-text-main mt-1">
{Object.keys(quotaMonitor?.byProvider || {}).length}
</div>
</div>
</div>
{quotaMonitor?.monitors?.length > 0 ? (
<div className="space-y-2">
{quotaMonitor.monitors.slice(0, 5).map((monitor: any) => (
<div
key={`${monitor.sessionId}:${monitor.accountId}`}
className="rounded-lg border border-border/30 bg-surface/20 p-3 flex items-center justify-between gap-3"
>
<div className="min-w-0">
<div className="text-sm font-medium text-text-main truncate">
{monitor.provider} {monitor.accountId.slice(0, 8)}
</div>
<div className="text-xs text-text-muted mt-1 truncate">
{monitor.sessionId} {monitor.status}
</div>
</div>
<div className="text-right text-xs shrink-0">
<div
className={
monitor.status === "exhausted"
? "text-red-400"
: monitor.status === "warning"
? "text-amber-400"
: monitor.status === "error"
? "text-orange-400"
: "text-text-main"
}
>
{typeof monitor.lastQuotaPercent === "number"
? `${Math.round(monitor.lastQuotaPercent * 100)}%`
: "—"}
</div>
<div className="text-text-muted">
{monitor.nextPollDelayMs
? `${Math.round(monitor.nextPollDelayMs / 1000)}s`
: "—"}
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-text-muted">No session quota monitors active.</p>
)}
</Card>
</div>
{/* Graceful Degradation Status */}
{degradation && degradation.features && degradation.features.length > 0 && (
<Card className="p-5" role="region" aria-label="Graceful Degradation Status">

View File

@@ -46,6 +46,7 @@ import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
import { maskEmail, pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import { getCodexRequestDefaults as _getCodexRequestDefaults } from "@/lib/providers/requestDefaults";
type CompatByProtocolMap = Partial<
Record<
@@ -462,6 +463,9 @@ interface ConnectionRowProps {
onToggleRateLimit: (enabled?: boolean) => void;
onToggleCodex5h?: (enabled?: boolean) => void;
onToggleCodexWeekly?: (enabled?: boolean) => void;
isCcCompatible?: boolean;
cliproxyapiEnabled?: boolean;
onToggleCliproxyapiMode?: (enabled?: boolean) => void;
onRetest: () => void;
isRetesting?: boolean;
onEdit: () => void;
@@ -535,6 +539,13 @@ interface EditCompatibleNodeModalProps {
const CC_COMPATIBLE_LABEL = "CC Compatible";
const CC_COMPATIBLE_DETAILS_TITLE = "CC Compatible Details";
const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
const CODEX_REASONING_STRENGTH_OPTIONS = [
{ value: "none", label: "None" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "XHigh" },
];
function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } {
const record =
@@ -547,6 +558,21 @@ function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly
};
}
/**
* UI adapter around the canonical getCodexRequestDefaults from requestDefaults.ts.
* Adds the "medium" fallback for reasoningEffort required by the connection form.
*/
function getCodexRequestDefaults(providerSpecificData: unknown): {
reasoningEffort: string;
serviceTier?: "priority";
} {
const defaults = _getCodexRequestDefaults(providerSpecificData);
return {
reasoningEffort: defaults.reasoningEffort ?? "medium",
...(defaults.serviceTier ? { serviceTier: defaults.serviceTier } : {}),
};
}
function compatProtocolLabelKey(protocol: string): string {
if (protocol === "openai") return "compatProtocolOpenAI";
if (protocol === "openai-responses") return "compatProtocolOpenAIResponses";
@@ -889,6 +915,7 @@ export default function ProviderDetailPage() {
const [headerImgError, setHeaderImgError] = useState(false);
const { copied, copy } = useCopyToClipboard();
const t = useTranslations("providers");
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
const notify = useNotificationStore();
const [proxyTarget, setProxyTarget] = useState(null);
const [proxyConfig, setProxyConfig] = useState(null);
@@ -1312,6 +1339,62 @@ export default function ProviderDetailPage() {
}
};
const [cpaProviderEnabled, setCpaProviderEnabled] = useState(false);
// Load upstream proxy config for this provider on mount
useEffect(() => {
if (!isCcCompatible) return;
fetch(`/api/settings`)
.then((r) => r.json())
.then((data) => {
// Check if this provider has CLIProxyAPI routing enabled
// The upstream_proxy_config is synced via the settings API
})
.catch(() => {});
// Also check via direct upstream proxy config lookup
fetch(`/api/upstream-proxy/${providerId}`)
.then((r) => {
if (!r.ok) return null;
return r.json();
})
.then((data) => {
if (data?.enabled && (data.mode === "cliproxyapi" || data.mode === "fallback")) {
setCpaProviderEnabled(true);
}
})
.catch(() => {});
}, [isCcCompatible, providerId]);
const handleToggleCliproxyapiMode = async (_connectionId, enabled) => {
try {
// Write to upstream_proxy_config table which resolveExecutorWithProxy reads
const res = await fetch(`/api/upstream-proxy/${providerId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: enabled ? "cliproxyapi" : "native",
enabled: enabled,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify.error(data.error || "Failed to update CLIProxyAPI routing");
return;
}
setCpaProviderEnabled(enabled);
notify.success(
enabled
? "Requests now route through CLIProxyAPI (deeper emulation)"
: "Requests now use native OmniRoute (direct)"
);
} catch {
notify.error("Failed to update CLIProxyAPI routing");
}
};
const handleToggleCodexLimit = async (connectionId, field, enabled) => {
try {
const target = connections.find((connection) => connection.id === connectionId);
@@ -2589,6 +2672,11 @@ export default function ProviderDetailPage() {
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
isCodex={providerId === "codex"}
isCcCompatible={isCcCompatible}
cliproxyapiEnabled={cpaProviderEnabled}
onToggleCliproxyapiMode={(enabled) =>
handleToggleCliproxyapiMode(conn.id, enabled)
}
onToggleCodex5h={(enabled) =>
handleToggleCodexLimit(conn.id, "use5h", enabled)
}
@@ -2625,11 +2713,7 @@ export default function ProviderDetailPage() {
setProxyTarget({
level: "key",
id: conn.id,
label: pickDisplayValue(
[conn.name, conn.email],
useEmailPrivacyStore.getState().emailsVisible,
conn.id
),
label: pickDisplayValue([conn.name, conn.email], emailsVisible, conn.id),
})
}
hasProxy={!!connProxyMap[conn.id]?.proxy}
@@ -2740,7 +2824,7 @@ export default function ProviderDetailPage() {
id: conn.id,
label: pickDisplayValue(
[conn.name, conn.email],
useEmailPrivacyStore.getState().emailsVisible,
emailsVisible,
conn.id
),
})
@@ -2915,7 +2999,9 @@ export default function ProviderDetailPage() {
{r.valid ? "check_circle" : "error"}
</span>
<div className="flex-1 min-w-0">
<span className="font-medium">{r.connectionName}</span>
<span className="font-medium">
{pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)}
</span>
</div>
{r.latencyMs !== undefined && (
<span className="text-text-muted font-mono tabular-nums">
@@ -4555,6 +4641,8 @@ function ConnectionRow({
connection,
isOAuth,
isCodex,
isCcCompatible,
cliproxyapiEnabled,
isFirst,
isLast,
onMoveUp,
@@ -4563,6 +4651,7 @@ function ConnectionRow({
onToggleRateLimit,
onToggleCodex5h,
onToggleCodexWeekly,
onToggleCliproxyapiMode,
onRetest,
isRetesting,
onEdit,
@@ -4654,6 +4743,7 @@ function ConnectionRow({
const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy);
const codex5hEnabled = normalizedCodexPolicy.use5h;
const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly;
const cliproxyapiDeepMode = !!cliproxyapiEnabled;
return (
<div
@@ -4743,6 +4833,27 @@ function ConnectionRow({
<span className="material-symbols-outlined text-[13px]">shield</span>
{rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")}
</button>
{isCcCompatible && (
<>
<span className="text-text-muted/30 select-none">|</span>
<button
onClick={() => onToggleCliproxyapiMode?.(!cliproxyapiDeepMode)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
cliproxyapiDeepMode
? "bg-indigo-500/15 text-indigo-500 hover:bg-indigo-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title={
cliproxyapiDeepMode
? "Using CLIProxyAPI for deeper Claude Code emulation (uTLS, multi-account, device profiles)"
: "Enable CLIProxyAPI backend for deeper Claude Code OAuth emulation"
}
>
<span className="material-symbols-outlined text-[13px]">swap_horiz</span>
CPA {cliproxyapiDeepMode ? "ON" : "OFF"}
</button>
</>
)}
{isCodex && (
<>
<span className="text-text-muted/30 select-none">|</span>
@@ -4932,6 +5043,9 @@ ConnectionRow.propTypes = {
onToggleRateLimit: PropTypes.func.isRequired,
onToggleCodex5h: PropTypes.func,
onToggleCodexWeekly: PropTypes.func,
isCcCompatible: PropTypes.bool,
cliproxyapiEnabled: PropTypes.bool,
onToggleCliproxyapiMode: PropTypes.func,
onRetest: PropTypes.func.isRequired,
isRetesting: PropTypes.bool,
onEdit: PropTypes.func.isRequired,
@@ -5005,6 +5119,7 @@ function AddApiKeyModal({
const defaultRegion = "us-central1";
const isGlm = provider === "glm";
const isQoder = provider === "qoder";
const isCloudflare = provider === "cloudflare-ai";
const [formData, setFormData] = useState({
name: "",
@@ -5015,6 +5130,7 @@ function AddApiKeyModal({
apiRegion: "international",
validationModelId: "",
customUserAgent: "",
accountId: "",
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
@@ -5047,7 +5163,7 @@ function AddApiKeyModal({
};
const handleSubmit = async () => {
if (!provider || !formData.apiKey) return;
if (!provider || (!isCompatible && !formData.apiKey)) return;
setSaving(true);
setSaveError(null);
@@ -5101,6 +5217,8 @@ function AddApiKeyModal({
providerSpecificData.region = formData.region;
} else if (isGlm) {
providerSpecificData.apiRegion = formData.apiRegion;
} else if (isCloudflare && formData.accountId.trim()) {
providerSpecificData.accountId = formData.accountId.trim();
}
const payload = {
@@ -5159,7 +5277,7 @@ function AddApiKeyModal({
<div className="pt-6">
<Button
onClick={handleValidate}
disabled={!formData.apiKey || validating || saving}
disabled={(!isCompatible && !formData.apiKey) || validating || saving}
variant="secondary"
>
{validating ? t("checking") : t("check")}
@@ -5251,6 +5369,15 @@ function AddApiKeyModal({
hint="ex: us-central1 ou europe-west4. Partner models usam a região global automaticamente."
/>
)}
{isCloudflare && (
<Input
label="Account ID"
value={formData.accountId}
onChange={(e) => setFormData({ ...formData, accountId: e.target.value })}
placeholder="Cloudflare Account ID"
hint="Find it in the Cloudflare dashboard URL or settings"
/>
)}
{isGlm && (
<div>
<label className="text-sm font-medium text-text-main mb-1 block">API Region</label>
@@ -5273,7 +5400,7 @@ function AddApiKeyModal({
fullWidth
disabled={
!formData.name ||
!formData.apiKey ||
(!isCompatible && !formData.apiKey) ||
saving ||
(usesBaseUrl && !formData.baseUrl.trim() && !defaultBaseUrl)
}
@@ -5326,6 +5453,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
validationModelId: "",
tag: "",
customUserAgent: "",
accountId: "",
codexReasoningEffort: "medium",
codexFastServiceTier: false,
codexOpenaiStoreEnabled: false,
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
@@ -5343,6 +5474,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
const defaultBaseUrl = getProviderBaseUrlDefault(connection?.provider);
const isVertex = connection?.provider === "vertex";
const isGlm = connection?.provider === "glm";
const isCloudflare = connection?.provider === "cloudflare-ai";
const isCodex = connection?.provider === "codex";
const defaultRegion = "us-central1";
useEffect(() => {
@@ -5354,6 +5487,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
const rawCustomUserAgent = connection.providerSpecificData?.customUserAgent;
const existingCustomUserAgent =
typeof rawCustomUserAgent === "string" ? rawCustomUserAgent : "";
const rawAccountId = connection.providerSpecificData?.accountId;
const existingAccountId = typeof rawAccountId === "string" ? rawAccountId : "";
const codexRequestDefaults = getCodexRequestDefaults(connection.providerSpecificData);
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
@@ -5365,6 +5501,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
validationModelId: (connection.providerSpecificData?.validationModelId as string) || "",
tag: (connection.providerSpecificData?.tag as string) || "",
customUserAgent: existingCustomUserAgent,
accountId: existingAccountId,
codexReasoningEffort: codexRequestDefaults.reasoningEffort,
codexFastServiceTier: codexRequestDefaults.serviceTier === "priority",
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
});
// Load existing extra keys from providerSpecificData
const existing = connection.providerSpecificData?.extraApiKeys;
@@ -5408,7 +5548,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
};
const handleValidate = async () => {
if (!connection?.provider || !formData.apiKey) return;
if (!connection?.provider || (!isCompatible && !formData.apiKey)) return;
setValidating(true);
setValidationResult(null);
try {
@@ -5506,6 +5646,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
updates.providerSpecificData.region = formData.region;
} else if (isGlm) {
updates.providerSpecificData.apiRegion = formData.apiRegion;
} else if (isCloudflare && formData.accountId.trim()) {
updates.providerSpecificData.accountId = formData.accountId.trim();
}
} else {
// Also persist tag for OAuth accounts
@@ -5513,6 +5655,14 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
...(connection.providerSpecificData || {}),
tag: formData.tag.trim() || undefined,
};
if (isCodex) {
updates.providerSpecificData.requestDefaults = {
reasoningEffort: formData.codexReasoningEffort,
...(formData.codexFastServiceTier ? { serviceTier: "priority" } : {}),
};
updates.providerSpecificData.openaiStoreEnabled =
formData.codexOpenaiStoreEnabled === true;
}
}
const error = (await onSave(updates)) as void | unknown;
if (error) {
@@ -5550,6 +5700,29 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
placeholder="e.g. personal, work, team-a"
hint="Used to group accounts in the provider view"
/>
{isCodex && (
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
<Select
label="Default thinking strength"
value={formData.codexReasoningEffort}
options={CODEX_REASONING_STRENGTH_OPTIONS}
onChange={(e) => setFormData({ ...formData, codexReasoningEffort: e.target.value })}
hint="Used when the client does not send a reasoning effort and the global Thinking Budget mode is passthrough."
/>
<Toggle
checked={formData.codexFastServiceTier}
onChange={(checked) => setFormData({ ...formData, codexFastServiceTier: checked })}
label="Codex Fast Service Tier"
description="When enabled, injects `service_tier=priority` for this connection if the client leaves the tier unset."
/>
<Toggle
checked={formData.codexOpenaiStoreEnabled}
onChange={(checked) => setFormData({ ...formData, codexOpenaiStoreEnabled: checked })}
label="OpenAI Responses Store"
description="Preserves `store`, `previous_response_id`, and adds a stable fallback `session_id` for long Codex sessions. Enable only when the upstream account accepts stored Responses."
/>
</div>
)}
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">{t("email")}</p>
@@ -5607,7 +5780,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
<div className="pt-6">
<Button
onClick={handleValidate}
disabled={!formData.apiKey || validating || saving}
disabled={(!isCompatible && !formData.apiKey) || validating || saving}
variant="secondary"
>
{validating ? t("checking") : t("check")}
@@ -5683,6 +5856,16 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
/>
)}
{isCloudflare && (
<Input
label="Account ID"
value={formData.accountId}
onChange={(e) => setFormData({ ...formData, accountId: e.target.value })}
placeholder="Cloudflare Account ID"
hint="Find it in the Cloudflare dashboard URL or settings"
/>
)}
{isGlm && (
<div>
<label className="text-sm font-medium text-text-main mb-1 block">API Region</label>

View File

@@ -23,6 +23,8 @@ import {
} from "@/shared/constants/providers";
import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
import { useTranslations } from "next-intl";
@@ -1569,6 +1571,7 @@ AddCcCompatibleModal.propTypes = {
function ProviderTestResultsView({ results }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
// Guard: never crash on malformed/null results (would trigger error boundary)
if (!results || typeof results !== "object") {
@@ -1636,7 +1639,9 @@ function ProviderTestResultsView({ results }) {
{r.valid ? "check_circle" : "error"}
</span>
<div className="flex-1 min-w-0">
<span className="font-medium">{r.connectionName}</span>
<span className="font-medium">
{pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)}
</span>
<span className="text-text-muted ml-1.5">({r.provider})</span>
</div>
{r.latencyMs !== undefined && (

View File

@@ -1,103 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Card } from "@/shared/components";
export default function CodexServiceTierTab() {
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<"" | "saved" | "error">("");
useEffect(() => {
fetch("/api/settings/codex-service-tier")
.then((res) => res.json())
.then((data) => {
setEnabled(Boolean(data.enabled));
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const save = async (nextEnabled: boolean) => {
setEnabled(nextEnabled);
setSaving(true);
setStatus("");
try {
const res = await fetch("/api/settings/codex-service-tier", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: nextEnabled }),
});
if (res.ok) {
setStatus("saved");
setTimeout(() => setStatus(""), 2000);
} else {
setStatus("error");
setEnabled(!nextEnabled);
}
} catch {
setStatus("error");
setEnabled(!nextEnabled);
} finally {
setSaving(false);
}
};
return (
<Card>
<div className="flex items-center gap-3 mb-5">
<div className="p-2 rounded-lg bg-sky-500/10 text-sky-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
bolt
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Codex Fast Service Tier</h3>
<p className="text-sm text-text-muted">
Inject `service_tier=priority` into Codex requests when the client leaves it unset.
</p>
</div>
{status === "saved" && (
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span>
Saved
</span>
)}
{status === "error" && (
<span className="text-xs font-medium text-rose-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">error</span>
Failed to save
</span>
)}
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30">
<div>
<p className="text-sm font-medium">Force fast tier for Codex</p>
<p className="text-xs text-text-muted mt-0.5">
Off by default. Applies only to Codex requests and does not override an explicit tier.
Codex fast mode is sent upstream as `service_tier=priority`.
</p>
</div>
<button
onClick={() => save(!enabled)}
disabled={loading || saving}
className={`relative inline-flex h-6 w-11 items-center rounded-full border transition-colors ${
enabled
? "bg-sky-500 border-sky-500"
: "bg-black/10 border-black/10 dark:bg-white/10 dark:border-white/10"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
</Card>
);
}

View File

@@ -31,10 +31,15 @@ export default function ComboDefaultsTab() {
handoffThreshold: 0.85,
handoffModel: "",
maxMessagesForSummary: 30,
stickyRoundRobinLimit: 3,
});
const [providerOverrides, setProviderOverrides] = useState<any>({});
const [newOverrideProvider, setNewOverrideProvider] = useState("");
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<{ type: "success" | "error" | ""; message: string }>({
type: "",
message: "",
});
const t = useTranslations("settings");
const tc = useTranslations("common");
const strategyOptions = ROUTING_STRATEGIES.map((strategy) => ({
@@ -54,27 +59,75 @@ export default function ComboDefaultsTab() {
];
useEffect(() => {
fetch("/api/settings/combo-defaults")
.then((res) => res.json())
.then((data) => {
if (data.comboDefaults) {
setComboDefaults((prev) => ({ ...prev, ...data.comboDefaults }));
}
if (data.providerOverrides) setProviderOverrides(data.providerOverrides);
Promise.all([
fetch("/api/settings/combo-defaults").then((res) => res.json()),
fetch("/api/settings").then((res) => res.json()),
])
.then(([comboData, settingsData]) => {
setComboDefaults((prev) => ({
...prev,
...(comboData.comboDefaults || {}),
strategy:
settingsData.fallbackStrategy ?? comboData.comboDefaults?.strategy ?? prev.strategy,
stickyRoundRobinLimit:
settingsData.stickyRoundRobinLimit ??
comboData.comboDefaults?.stickyRoundRobinLimit ??
prev.stickyRoundRobinLimit,
}));
if (comboData.providerOverrides) setProviderOverrides(comboData.providerOverrides);
})
.catch((err) => console.error("Failed to fetch combo defaults:", err));
}, []);
const showStatus = (type: "success" | "error", message: string) => {
setStatus({ type, message });
setTimeout(() => setStatus({ type: "", message: "" }), 2500);
};
const syncGlobalRoutingSettings = async (patch: Record<string, unknown>) => {
const keys = Object.keys(patch);
if (keys.length === 0) return true;
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) {
throw new Error("Failed to sync global routing settings");
}
return true;
};
const saveComboDefaults = async () => {
setSaving(true);
try {
await fetch("/api/settings/combo-defaults", {
const { stickyRoundRobinLimit, ...comboDefaultsPayload } = comboDefaults;
const settingsPatch: Record<string, unknown> = {};
if (comboDefaults.strategy) {
settingsPatch.fallbackStrategy = comboDefaults.strategy;
}
if (comboDefaults.strategy === "round-robin" && stickyRoundRobinLimit !== undefined) {
settingsPatch.stickyRoundRobinLimit = stickyRoundRobinLimit;
}
const comboDefaultsRes = await fetch("/api/settings/combo-defaults", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comboDefaults, providerOverrides }),
body: JSON.stringify({ comboDefaults: comboDefaultsPayload, providerOverrides }),
});
if (!comboDefaultsRes.ok) {
throw new Error("Failed to save combo defaults");
}
await syncGlobalRoutingSettings(settingsPatch);
showStatus("success", t("savedSuccessfully"));
} catch (err) {
console.error("Failed to save combo defaults:", err);
showStatus("error", t("errorOccurred"));
} finally {
setSaving(false);
}
@@ -103,8 +156,38 @@ export default function ComboDefaultsTab() {
tune
</span>
</div>
<h3 className="text-lg font-semibold">{t("comboDefaultsTitle")}</h3>
<h3 className="text-lg font-semibold">
{translateOrFallback(t, "comboDefaultsTitle", "Default Routing & Combo Settings")}
</h3>
<span className="text-xs text-text-muted ml-auto">{t("globalComboConfig")}</span>
{status.message && (
<span
className={`text-xs font-medium ml-2 ${
status.type === "success" ? "text-emerald-500" : "text-red-500"
}`}
>
{status.message}
</span>
)}
</div>
<div className="mb-4 rounded-lg border border-blue-500/20 bg-blue-500/5 p-3">
<p className="text-xs font-medium text-blue-700 dark:text-blue-300">
{translateOrFallback(t, "routingAdvancedGuideTitle", "Advanced routing guidance")}
</p>
<p className="text-xs text-text-muted mt-1">
{translateOrFallback(
t,
"routingAdvancedGuideHint1",
"Use Fill First for predictable priority, Round Robin for fairness, and P2C for latency resilience."
)}
</p>
<p className="text-xs text-text-muted">
{translateOrFallback(
t,
"routingAdvancedGuideHint2",
"If providers vary in quality or cost, start with Cost Opt for background work and Least Used for balanced wear."
)}
</p>
</div>
<div className="mb-4 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3">
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
@@ -130,7 +213,15 @@ export default function ComboDefaultsTab() {
key={s.value}
role="tab"
aria-selected={comboDefaults.strategy === s.value}
onClick={() => setComboDefaults((prev) => ({ ...prev, strategy: s.value }))}
onClick={async () => {
setComboDefaults((prev) => ({ ...prev, strategy: s.value }));
try {
await syncGlobalRoutingSettings({ fallbackStrategy: s.value });
} catch (error) {
console.error("Failed to sync fallback strategy:", error);
showStatus("error", t("errorOccurred"));
}
}}
className={cn(
"px-2 py-1 rounded text-xs font-medium transition-all flex items-center justify-center gap-0.5",
comboDefaults.strategy === s.value
@@ -145,6 +236,35 @@ export default function ComboDefaultsTab() {
</div>
</div>
{comboDefaults.strategy === "round-robin" && (
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div>
<p className="text-sm font-medium">{t("stickyLimit")}</p>
<p className="text-xs text-text-muted">{t("stickyLimitDesc")}</p>
</div>
<Input
type="number"
min="1"
max="10"
value={comboDefaults.stickyRoundRobinLimit || 3}
onChange={async (e) => {
const nextLimit = parseInt(e.target.value) || 3;
setComboDefaults((prev) => ({
...prev,
stickyRoundRobinLimit: nextLimit,
}));
try {
await syncGlobalRoutingSettings({ stickyRoundRobinLimit: nextLimit });
} catch (error) {
console.error("Failed to sync sticky round robin limit:", error);
showStatus("error", t("errorOccurred"));
}
}}
className="w-20 text-center"
/>
</div>
)}
{/* Numeric settings */}
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/50">
{numericSettings.map(({ key, label, min, max, step }) => (

View File

@@ -0,0 +1,366 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Card, Input } from "@/shared/components";
import { useTranslations } from "next-intl";
interface WildcardAlias {
pattern: string;
target: string;
}
type AliasMode = "exact" | "wildcard";
function translateOrFallback(
t: ReturnType<typeof useTranslations>,
key: string,
fallback: string
): string {
return typeof t.has === "function" && t.has(key) ? t(key) : fallback;
}
export default function ModelAliasesUnified() {
const [wildcardAliases, setWildcardAliases] = useState<WildcardAlias[]>([]);
const [builtInAliases, setBuiltInAliases] = useState<Record<string, string>>({});
const [customAliases, setCustomAliases] = useState<Record<string, string>>({});
const [aliasMode, setAliasMode] = useState<AliasMode>("exact");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<{ type: "success" | "error" | ""; message: string }>({
type: "",
message: "",
});
const [fromValue, setFromValue] = useState("");
const [toValue, setToValue] = useState("");
const t = useTranslations("settings");
const builtInEntries = Object.entries(builtInAliases);
const customEntries = Object.entries(customAliases);
useEffect(() => {
const loadAliases = async () => {
try {
const [settingsRes, aliasesRes] = await Promise.all([
fetch("/api/settings"),
fetch("/api/settings/model-aliases"),
]);
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const aliasesData = aliasesRes.ok ? await aliasesRes.json() : {};
setWildcardAliases(settingsData.wildcardAliases || []);
setBuiltInAliases(aliasesData.builtIn || {});
setCustomAliases(aliasesData.custom || {});
} catch (error) {
console.error("Failed to load model aliases:", error);
} finally {
setLoading(false);
}
};
loadAliases();
}, []);
const showStatus = (type: "success" | "error", message: string) => {
setStatus({ type, message });
setTimeout(() => setStatus({ type: "", message: "" }), 2500);
};
const addExactAlias = async () => {
if (!fromValue.trim() || !toValue.trim()) return;
setSaving(true);
try {
const res = await fetch("/api/settings/model-aliases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: fromValue.trim(), to: toValue.trim() }),
});
if (!res.ok) throw new Error("Failed to save exact alias");
const data = await res.json();
setCustomAliases(data.custom || {});
setFromValue("");
setToValue("");
showStatus("success", translateOrFallback(t, "saved", "Saved"));
} catch (error) {
console.error("Failed to save exact alias:", error);
showStatus("error", translateOrFallback(t, "errorOccurred", "An error occurred"));
} finally {
setSaving(false);
}
};
const removeExactAlias = async (from: string) => {
setSaving(true);
try {
const res = await fetch("/api/settings/model-aliases", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from }),
});
if (!res.ok) throw new Error("Failed to remove exact alias");
const data = await res.json();
setCustomAliases(data.custom || {});
showStatus("success", translateOrFallback(t, "saved", "Saved"));
} catch (error) {
console.error("Failed to remove exact alias:", error);
showStatus("error", translateOrFallback(t, "errorOccurred", "An error occurred"));
} finally {
setSaving(false);
}
};
const addWildcardAlias = async () => {
if (!fromValue.trim() || !toValue.trim()) return;
setSaving(true);
try {
const updated = [...wildcardAliases, { pattern: fromValue.trim(), target: toValue.trim() }];
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ wildcardAliases: updated }),
});
if (!res.ok) throw new Error("Failed to save wildcard alias");
setWildcardAliases(updated);
setFromValue("");
setToValue("");
showStatus("success", translateOrFallback(t, "saved", "Saved"));
} catch (error) {
console.error("Failed to save wildcard alias:", error);
showStatus("error", translateOrFallback(t, "errorOccurred", "An error occurred"));
} finally {
setSaving(false);
}
};
const removeWildcardAlias = async (index: number) => {
setSaving(true);
try {
const updated = wildcardAliases.filter((_, currentIndex) => currentIndex !== index);
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ wildcardAliases: updated }),
});
if (!res.ok) throw new Error("Failed to remove wildcard alias");
setWildcardAliases(updated);
showStatus("success", translateOrFallback(t, "saved", "Saved"));
} catch (error) {
console.error("Failed to remove wildcard alias:", error);
showStatus("error", translateOrFallback(t, "errorOccurred", "An error occurred"));
} finally {
setSaving(false);
}
};
const handleAdd = async () => {
if (aliasMode === "wildcard") {
await addWildcardAlias();
return;
}
await addExactAlias();
};
return (
<Card>
<div className="flex items-center gap-3 mb-5">
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
swap_horiz
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">
{translateOrFallback(t, "modelAliasesTitle", "Model Aliases")}
</h3>
<p className="text-sm text-text-muted">
{translateOrFallback(
t,
"modelAliasesDesc",
"Remap model names using exact matches or wildcard patterns."
)}
</p>
</div>
{status.message && (
<span
className={`text-xs font-medium flex items-center gap-1 ${
status.type === "success" ? "text-emerald-500" : "text-red-500"
}`}
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{status.type === "success" ? "check_circle" : "error"}
</span>
{status.message}
</span>
)}
</div>
<div className="mb-5 rounded-lg border border-border/30 bg-surface/20 p-4">
<div className="flex flex-wrap gap-2 mb-3">
{[
{
value: "exact",
label: translateOrFallback(t, "exactMatchMode", "Exact Match"),
},
{
value: "wildcard",
label: translateOrFallback(t, "wildcardPatternMode", "Wildcard Pattern"),
},
].map((mode) => (
<button
key={mode.value}
type="button"
onClick={() => setAliasMode(mode.value as AliasMode)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
aliasMode === mode.value
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main"
}`}
>
{mode.label}
</button>
))}
</div>
<p className="text-xs text-text-muted mb-3">
{aliasMode === "exact"
? translateOrFallback(
t,
"exactMatchModeDesc",
"Use exact aliases for deprecated or renamed model IDs."
)
: translateOrFallback(
t,
"wildcardPatternModeDesc",
"Use wildcard aliases with * and ? when a family of models should map to one target."
)}
</p>
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr_auto] gap-2 items-end">
<Input
label={aliasMode === "exact" ? t("deprecatedModelId") : t("pattern")}
placeholder={
aliasMode === "exact" ? t("deprecatedModelId") : t("aliasPatternPlaceholder")
}
value={fromValue}
onChange={(e) => setFromValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void handleAdd()}
disabled={loading || saving}
/>
<div className="hidden md:flex items-center justify-center pb-2 text-text-muted">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
arrow_forward
</span>
</div>
<Input
label={aliasMode === "exact" ? t("newModelId") : t("targetModel")}
placeholder={aliasMode === "exact" ? t("newModelId") : t("aliasTargetPlaceholder")}
value={toValue}
onChange={(e) => setToValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void handleAdd()}
disabled={loading || saving}
/>
<Button
variant="primary"
size="sm"
onClick={() => void handleAdd()}
disabled={loading || saving || !fromValue.trim() || !toValue.trim()}
className="md:mb-[2px]"
>
{t("add")}
</Button>
</div>
</div>
<div className="mb-4">
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
{translateOrFallback(t, "customAliases", "Custom Aliases")}
</p>
<div className="rounded-lg border border-border/30 divide-y divide-border/20">
{customEntries.length === 0 ? (
<div className="px-4 py-3 text-sm text-text-muted">
{translateOrFallback(
t,
"noExactAliasesConfigured",
"No exact-match aliases configured."
)}
</div>
) : (
customEntries.map(([from, to]) => (
<div key={from} className="flex items-center gap-3 px-4 py-2.5">
<code className="text-xs text-red-400/80 flex-1 truncate">{from}</code>
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<code className="text-xs text-emerald-400/80 flex-1 truncate">{to}</code>
<button
type="button"
onClick={() => void removeExactAlias(from)}
disabled={saving}
className="p-1 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 transition-all"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))
)}
</div>
</div>
<div className="mb-4">
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
{translateOrFallback(t, "wildcardRulesTitle", "Wildcard Rules")}
</p>
<div className="rounded-lg border border-border/30 divide-y divide-border/20">
{wildcardAliases.length === 0 ? (
<div className="px-4 py-3 text-sm text-text-muted">
{translateOrFallback(
t,
"noWildcardAliasesConfigured",
"No wildcard aliases configured."
)}
</div>
) : (
wildcardAliases.map((alias, index) => (
<div
key={`${alias.pattern}-${alias.target}-${index}`}
className="flex items-center gap-3 px-4 py-2.5"
>
<code className="text-xs text-purple-400 flex-1 truncate">{alias.pattern}</code>
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<code className="text-xs text-emerald-400/80 flex-1 truncate">{alias.target}</code>
<button
type="button"
onClick={() => void removeWildcardAlias(index)}
disabled={saving}
className="p-1 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 transition-all"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))
)}
</div>
</div>
<details className="group">
<summary className="text-xs font-medium text-text-muted uppercase tracking-wider cursor-pointer flex items-center gap-1 mb-2">
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
chevron_right
</span>
{translateOrFallback(t, "builtInAliases", "Built-in Aliases")} ({builtInEntries.length})
</summary>
<div className="rounded-lg border border-border/30 divide-y divide-border/20 max-h-60 overflow-y-auto">
{builtInEntries.map(([from, to]) => (
<div key={from} className="flex items-center gap-3 px-4 py-2 opacity-60">
<code className="text-xs text-red-400/60 flex-1 truncate">{from}</code>
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<code className="text-xs text-emerald-400/60 flex-1 truncate">{to}</code>
<span className="material-symbols-outlined text-[14px] text-text-muted">lock</span>
</div>
))}
</div>
</details>
</Card>
);
}

View File

@@ -1,46 +1,24 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Input, Button } from "@/shared/components";
import FallbackChainsEditor from "./FallbackChainsEditor";
import {
ROUTING_STRATEGIES,
SETTINGS_FALLBACK_STRATEGY_VALUES,
} from "@/shared/constants/routingStrategies";
import { useEffect, useState } from "react";
import { Button, Card } from "@/shared/components";
import { useTranslations } from "next-intl";
const STRATEGIES = ROUTING_STRATEGIES.filter((strategy) =>
SETTINGS_FALLBACK_STRATEGY_VALUES.includes(strategy.value)
).map((strategy) => ({
value: strategy.value,
labelKey: strategy.labelKey,
descKey: strategy.settingsDescKey,
icon: strategy.icon,
}));
import FallbackChainsEditor from "./FallbackChainsEditor";
export default function RoutingTab() {
const [settings, setSettings] = useState<any>({
fallbackStrategy: "fill-first",
alwaysPreserveClientCache: "auto",
});
const [loading, setLoading] = useState(true);
const [aliases, setAliases] = useState([]);
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" });
const [newPattern, setNewPattern] = useState("");
const [newTarget, setNewTarget] = useState("");
const t = useTranslations("settings");
const strategyHintKeyByValue = STRATEGIES.reduce<Record<string, string>>((acc, strategy) => {
acc[strategy.value] = strategy.descKey;
return acc;
}, {});
useEffect(() => {
fetch("/api/settings")
.then((res) => res.json())
.then((data) => {
setSettings(data);
setAliases(data.wildcardAliases || []);
setLoading(false);
})
.catch(() => setLoading(false));
@@ -61,100 +39,8 @@ export default function RoutingTab() {
}
};
const addAlias = async () => {
if (!newPattern.trim() || !newTarget.trim()) return;
const updated = [...aliases, { pattern: newPattern.trim(), target: newTarget.trim() }];
await updateSetting({ wildcardAliases: updated });
setAliases(updated);
setNewPattern("");
setNewTarget("");
};
const removeAlias = async (idx) => {
const updated = aliases.filter((_, i) => i !== idx);
await updateSetting({ wildcardAliases: updated });
setAliases(updated);
};
return (
<div className="flex flex-col gap-6">
{/* Strategy Selection */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
route
</span>
</div>
<h3 className="text-lg font-semibold">{t("routingStrategy")}</h3>
</div>
<div className="mb-4 rounded-lg border border-blue-500/20 bg-blue-500/5 p-3">
<p className="text-xs font-medium text-blue-700 dark:text-blue-300">
{t("routingAdvancedGuideTitle")}
</p>
<p className="text-xs text-text-muted mt-1">{t("routingAdvancedGuideHint1")}</p>
<p className="text-xs text-text-muted">{t("routingAdvancedGuideHint2")}</p>
</div>
<div
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-2 mb-4"
style={{ gridAutoRows: "1fr" }}
>
{STRATEGIES.map((s) => (
<button
key={s.value}
onClick={() => updateSetting({ fallbackStrategy: s.value })}
disabled={loading}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border text-center transition-all ${
settings.fallbackStrategy === s.value
? "border-blue-500/50 bg-blue-500/5 ring-1 ring-blue-500/20"
: "border-border/50 hover:border-border hover:bg-surface/30"
}`}
>
<span
className={`material-symbols-outlined text-[24px] ${
settings.fallbackStrategy === s.value ? "text-blue-400" : "text-text-muted"
}`}
>
{s.icon}
</span>
<div>
<p
className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}
>
{t(s.labelKey)}
</p>
<p className="text-xs text-text-muted mt-0.5">{t(s.descKey)}</p>
</div>
</button>
))}
</div>
{settings.fallbackStrategy === "round-robin" && (
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div>
<p className="text-sm font-medium">{t("stickyLimit")}</p>
<p className="text-xs text-text-muted">{t("stickyLimitDesc")}</p>
</div>
<Input
type="number"
min="1"
max="10"
value={settings.stickyRoundRobinLimit || 3}
onChange={(e) => updateSetting({ stickyRoundRobinLimit: parseInt(e.target.value) })}
disabled={loading}
className="w-20 text-center"
/>
</div>
)}
<p className="text-xs text-text-muted italic pt-3 border-t border-border/30 mt-3">
{t(strategyHintKeyByValue[settings.fallbackStrategy] || "fillFirstDesc")}
</p>
</Card>
{/* Adaptive Volume Routing */}
<Card>
<div className="flex items-start justify-between gap-4">
<div className="flex gap-3">
@@ -188,7 +74,6 @@ export default function RoutingTab() {
</div>
</Card>
{/* LKGP Toggle */}
<Card>
<div className="flex items-start justify-between gap-4">
<div className="flex gap-3">
@@ -268,77 +153,8 @@ export default function RoutingTab() {
</div>
</Card>
{/* Wildcard Aliases */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
alt_route
</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("modelAliases")}</h3>
<p className="text-sm text-text-muted">{t("modelAliasesDesc")}</p>
</div>
</div>
{aliases.length > 0 && (
<div className="flex flex-col gap-1.5 mb-4">
{aliases.map((a, i) => (
<div
key={i}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
>
<div className="flex min-w-0 items-center gap-2 text-sm">
<span className="font-mono text-purple-400 break-all">{a.pattern}</span>
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<span className="font-mono text-text-main break-all">{a.target}</span>
</div>
<button
onClick={() => removeAlias(i)}
className="shrink-0 text-text-muted hover:text-red-400 transition-colors"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
</div>
)}
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-end">
<div className="flex-1">
<Input
label={t("pattern")}
placeholder={t("aliasPatternPlaceholder")}
value={newPattern}
onChange={(e) => setNewPattern(e.target.value)}
/>
</div>
<div className="flex-1">
<Input
label={t("targetModel")}
placeholder={t("aliasTargetPlaceholder")}
value={newTarget}
onChange={(e) => setNewTarget(e.target.value)}
/>
</div>
<Button
size="sm"
variant="primary"
onClick={addAlias}
className="mb-[2px] sm:w-auto w-full"
>
{t("add")}
</Button>
</div>
</Card>
{/* Fallback Chains */}
<FallbackChainsEditor />
{/* Client Cache Control */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">

Some files were not shown because too many files have changed in this diff Show More