mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-30 11:52:13 +03:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaec3279ab | ||
|
|
195c313628 | ||
|
|
eb62d6368b | ||
|
|
4655a8504d | ||
|
|
44e4ae2d94 | ||
|
|
5fa5be2136 | ||
|
|
3e83402470 | ||
|
|
e7c2998cf9 | ||
|
|
9ef3dcd581 | ||
|
|
8cfd26c21e | ||
|
|
2d88469761 | ||
|
|
f3e53a108b | ||
|
|
a2436bc50b | ||
|
|
cd56ea7040 | ||
|
|
7efa672976 | ||
|
|
0856854c81 | ||
|
|
488f9653e6 | ||
|
|
48467bc514 | ||
|
|
56f7a5baae | ||
|
|
ee5a1f0e7a | ||
|
|
625bcf105c | ||
|
|
3e3ea37aba | ||
|
|
5049cc6e1d | ||
|
|
b142145a4e | ||
|
|
7eb3056856 | ||
|
|
d8e9c16d83 | ||
|
|
a58db791e1 | ||
|
|
fa2cfe36d4 | ||
|
|
b8c9880a2e | ||
|
|
3b4e7b0e5f | ||
|
|
ed27ef4cee | ||
|
|
1e2b7e728d | ||
|
|
1e9fbd216f | ||
|
|
139cd337a3 | ||
|
|
531d49fa00 | ||
|
|
b17c9a067a | ||
|
|
4d67a4a811 | ||
|
|
c286fdc96a | ||
|
|
b65caf82b4 | ||
|
|
25bd04e400 | ||
|
|
9944d136e4 | ||
|
|
816db26a75 | ||
|
|
25815ae53d | ||
|
|
ea61d00cf7 | ||
|
|
f15576fd98 | ||
|
|
6b64702054 | ||
|
|
0887d544ce | ||
|
|
af57d67320 | ||
|
|
4d460109dd | ||
|
|
10288f87c1 | ||
|
|
05bfe0a7b2 | ||
|
|
6e521af3b3 | ||
|
|
d833cc9c54 | ||
|
|
4e0d926f61 | ||
|
|
9e4ce3ae72 | ||
|
|
7a8e6f8e8c | ||
|
|
e137d63886 | ||
|
|
5e16ef73f9 |
@@ -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"
|
||||
|
||||
648
.env.example
648
.env.example
@@ -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
|
||||
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
/_*/
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -16,5 +16,6 @@
|
||||
"javascript:S3776": {
|
||||
"level": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
|
||||
161
AGENTS.md
161
AGENTS.md
@@ -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
|
||||
|
||||
154
CHANGELOG.md
154
CHANGELOG.md
@@ -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
225
CLAUDE.md
Normal 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)
|
||||
23
README.md
23
README.md
@@ -244,6 +244,8 @@ Developers pay $20–200/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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
659
docs/ENVIRONMENT.md
Normal 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 |
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
---
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
155
docs/UNINSTALL.md
Normal file
155
docs/UNINSTALL.md
Normal 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
|
||||
```
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -40,6 +40,7 @@ const eslintConfig = [
|
||||
"node_modules/**",
|
||||
// VS Code extension and its large test fixtures
|
||||
"vscode-extension/**",
|
||||
"_mono_repo/**",
|
||||
// Electron app
|
||||
"electron/**",
|
||||
// Docs
|
||||
|
||||
4
llm.txt
4
llm.txt
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || {},
|
||||
};
|
||||
|
||||
70
open-sse/mcp-server/__tests__/dbHealthTool.test.ts
Normal file
70
open-sse/mcp-server/__tests__/dbHealthTool.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,9 @@ export {
|
||||
getSessionSnapshotInput,
|
||||
getSessionSnapshotOutput,
|
||||
getSessionSnapshotTool,
|
||||
dbHealthCheckInput,
|
||||
dbHealthCheckOutput,
|
||||
dbHealthCheckTool,
|
||||
cacheStatsInput,
|
||||
cacheStatsOutput,
|
||||
cacheStatsTool,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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_check — Diagnose 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
134
open-sse/services/AGENTS.md
Normal 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 (5–20 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)
|
||||
173
open-sse/services/antigravity429Engine.ts
Normal file
173
open-sse/services/antigravity429Engine.ts
Normal 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 };
|
||||
42
open-sse/services/antigravityCredits.ts
Normal file
42
open-sse/services/antigravityCredits.ts
Normal 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);
|
||||
}
|
||||
62
open-sse/services/antigravityHeaderScrub.ts
Normal file
62
open-sse/services/antigravityHeaderScrub.ts
Normal 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;
|
||||
}
|
||||
71
open-sse/services/antigravityHeaders.ts
Normal file
71
open-sse/services/antigravityHeaders.ts
Normal 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 };
|
||||
50
open-sse/services/antigravityObfuscation.ts
Normal file
50
open-sse/services/antigravityObfuscation.ts
Normal 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;
|
||||
}
|
||||
49
open-sse/services/claudeCodeCCH.ts
Normal file
49
open-sse/services/claudeCodeCCH.ts
Normal 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 };
|
||||
@@ -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",
|
||||
|
||||
127
open-sse/services/claudeCodeConstraints.ts
Normal file
127
open-sse/services/claudeCodeConstraints.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
open-sse/services/claudeCodeFingerprint.ts
Normal file
46
open-sse/services/claudeCodeFingerprint.ts
Normal 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 "";
|
||||
}
|
||||
77
open-sse/services/claudeCodeObfuscation.ts
Normal file
77
open-sse/services/claudeCodeObfuscation.ts
Normal 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, "\\$&");
|
||||
}
|
||||
80
open-sse/services/claudeCodeToolRemapper.ts
Normal file
80
open-sse/services/claudeCodeToolRemapper.ts
Normal 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 };
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
44
open-sse/services/qwenThinking.ts
Normal file
44
open-sse/services/qwenThinking.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
124
open-sse/translator/helpers/geminiToolsSanitizer.ts
Normal file
124
open-sse/translator/helpers/geminiToolsSanitizer.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: "assistant" },
|
||||
delta: { role: "assistant", content: "" },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
368
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
package.json
11
package.json
@@ -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",
|
||||
|
||||
@@ -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
12
scratch.mjs
Normal 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 });
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) || "."}`
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
280
src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx
Normal file
280
src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
371
src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx
Normal file
371
src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx
Normal 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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user