Compare commits

..

24 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza 531d49fa00 Merge pull request #1186 from diegosouzapw/release/v3.6.4
Build Electron Desktop App / Validate version (push) Failing after 32s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
chore(release): v3.6.4 — Auto-Combo Unification, LKGP Standalone, Combo Builder v2, Composite Tiers
2026-04-12 20:05:27 -03:00
diegosouzapw b17c9a067a test: fix stream assertions and resolve CI env flakes
Fixes test expectations for non-streaming requests expecting 'Accept: application/json' instead of undefined. Resolves API_KEY_SECRET missing in unit tests post security hardening. Adds robust Playwright waits to combo E2E testing.
2026-04-12 19:46:39 -03:00
diegosouzapw 4d67a4a811 chore(release): bump to v3.6.4 — security fixes, changelog, docs, version sync 2026-04-12 19:20:02 -03:00
diegosouzapw c286fdc96a fix(auth): require admin auth for backup and translator routes
Protect database backup, export, restore, and translator save endpoints
with authentication checks to block unauthenticated data access and
state changes.

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

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

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

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

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

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

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

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

## Tests
- 16 new test suites + 12 existing test updates
- 86 files changed, +8318 -1378 lines
2026-04-12 10:34:10 -03:00
diegosouzapw f15576fd98 build(deps): bump electron-builder to 26.8.1 to resolve tar CVEs 2026-04-11 19:38:18 -03:00
Diego Rodrigues de Sa e Souza 6b64702054 Merge pull request #1164 from diegosouzapw/release/v3.6.3
Build Electron Desktop App / Validate version (push) Failing after 31s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
chore(release): v3.6.3 — Fix cloudflare config, prompt cache payloads, and openai-compatible validation
2026-04-11 18:51:03 -03:00
diegosouzapw 0887d544ce fix: ABI-mismatched sqlite native binding crash on Windows (#1163) 2026-04-11 18:25:19 -03:00
diegosouzapw af57d67320 docs: include ENVIRONMENT.md, UNINSTALL.md and sync ignore rules 2026-04-11 18:07:42 -03:00
diegosouzapw 4d460109dd chore(release): v3.6.3 — Fix cloudflare config, prompt cache payloads, and openai-compatible validation 2026-04-11 18:00:20 -03:00
diegosouzapw 10288f87c1 docs: add dependabot bumps to changelog for v3.6.3 2026-04-11 17:18:43 -03:00
diegosouzapw 05bfe0a7b2 chore: bump version to 3.6.3 for release branches 2026-04-11 17:15:32 -03:00
dependabot[bot] 6e521af3b3 deps: bump the development group with 7 updates (#1162)
Integrated into release/v3.6.3
2026-04-11 17:14:50 -03:00
dependabot[bot] d833cc9c54 deps: bump the production group with 3 updates (#1161)
Integrated into release/v3.6.3
2026-04-11 17:14:47 -03:00
dependabot[bot] 4e0d926f61 deps: bump electron from 40.8.5 to 41.2.0 in /electron (#1158)
Integrated into release/v3.6.3
2026-04-11 17:14:43 -03:00
dependabot[bot] 9e4ce3ae72 chore(deps): bump actions/download-artifact from 4 to 8 (#1157)
Integrated into release/v3.6.3
2026-04-11 17:14:40 -03:00
dependabot[bot] 7a8e6f8e8c chore(deps): bump docker/build-push-action from 6 to 7 (#1156)
Integrated into release/v3.6.3
2026-04-11 17:14:37 -03:00
diegosouzapw e137d63886 ci: skip snyk on dependabot to fix empty token failures 2026-04-11 16:51:33 -03:00
diegosouzapw 5e16ef73f9 fix(workflows): repair CHANGELOG extraction regex in generate-release 2026-04-11 16:47:41 -03:00
179 changed files with 14166 additions and 3684 deletions
+1 -1
View File
@@ -205,7 +205,7 @@ git pull origin main
VERSION=$(node -p "require('./package.json').version")
# Extracts the changelog section for this version
NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^## \\[[0-9]+/{if(flag) exit} flag" CHANGELOG.md | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
NOTES=$(awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[[0-9]+/{if(flag) exit} flag' CHANGELOG.md | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -z "$NOTES" ]; then NOTES="OmniRoute v$VERSION Release"; fi
git tag -a "v$VERSION" -m "Release v$VERSION"
+530 -101
View File
@@ -1,114 +1,312 @@
# OmniRoute environment contract
# This file reflects actual runtime usage in the current codebase.
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ OmniRoute — .env Contract │
# │ This file documents EVERY environment variable read by the runtime. │
# │ Copy to .env and adjust values. Lines starting with # are commented out │
# │ (optional / off-by-default). Uncomment only what you need. │
# │ Reference: docs/ENVIRONMENT.md for full details and usage scenarios. │
# └─────────────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════
# REQUIRED SECRETS — Generate strong values!
# ═══════════════════════════════════════════════════
# Generate with: openssl rand -base64 48
# ═══════════════════════════════════════════════════════════════════════════════
# 1. REQUIRED SECRETS — Must be set before first run!
# ═══════════════════════════════════════════════════════════════════════════════
# These secrets are critical for security. Generate strong, unique values.
# JWT signing key for dashboard session tokens.
# Used by: src/lib/auth — signs/verifies all authenticated session cookies.
# Generate: openssl rand -base64 48
JWT_SECRET=
# Generate with: openssl rand -hex 32
# Encryption key for API keys stored in the database.
# Used by: src/lib/db/apiKeys.ts — encrypts API key values at rest in SQLite.
# Generate: openssl rand -hex 32
API_KEY_SECRET=
# Initial admin password — CHANGE THIS before first use!
INITIAL_PASSWORD=123456
# Initial admin login password — CHANGE THIS before first use!
# Used by: bootstrap only — sets the initial dashboard password on first boot.
# After first login you can change it from Dashboard → Settings → Security.
# Default: CHANGEME (insecure, for local dev only)
INITIAL_PASSWORD=CHANGEME
# ═══════════════════════════════════════════════════════════════════════════════
# 2. STORAGE & DATABASE
# ═══════════════════════════════════════════════════════════════════════════════
# OmniRoute uses SQLite for all persistence. These variables control where
# data lives, encryption, and cleanup policies.
# Base directory for all persistent data (SQLite DB, logs, backups).
# Used by: src/lib/db/core.ts — resolves the SQLite database file path.
# Default: ~/.omniroute/ | Override for Docker or custom installations.
# DATA_DIR=/var/lib/omniroute
# Storage (SQLite)
STORAGE_DRIVER=sqlite
# Generate with: openssl rand -hex 32
# Encryption key for SQLite database encryption at rest.
# Used by: src/lib/db/encryption.ts — encrypts the entire SQLite database.
# Generate: openssl rand -hex 32 | Leave empty to disable DB encryption.
STORAGE_ENCRYPTION_KEY=
# Version tag for the encryption key — allows future key rotation.
# Used by: scripts/bootstrap-env.mjs, electron/main.js — persists key version.
# Default: v1 | Increment when rotating STORAGE_ENCRYPTION_KEY.
STORAGE_ENCRYPTION_KEY_VERSION=v1
APP_LOG_RETENTION_DAYS=90
CALL_LOG_RETENTION_DAYS=90
SQLITE_MAX_SIZE_MB=2048
SQLITE_CLEAN_LEGACY_FILES=true
# Automatic SQLite backup on startup.
# Used by: src/lib/db/backup.ts — creates a timestamped backup before migrations.
# Default: false (backups enabled) | Set true to skip backup on every restart.
DISABLE_SQLITE_AUTO_BACKUP=false
# Recommended runtime variables
# Canonical/base port (keeps backward compatibility)
# ═══════════════════════════════════════════════════════════════════════════════
# 3. NETWORK & PORTS
# ═══════════════════════════════════════════════════════════════════════════════
# OmniRoute can run on a single port (default) or split Dashboard/API ports.
# Canonical port for both Dashboard UI and API (single-port mode).
# Used by: src/lib/runtime/ports.ts — base port for the Next.js server.
# Default: 20128
PORT=20128
# Optional split ports:
# Split-port mode: serve Dashboard and API on separate ports for network isolation.
# Used by: src/lib/runtime/ports.ts — overrides PORT for each service.
# API_PORT=20129
# API_HOST=0.0.0.0
# DASHBOARD_PORT=20128
# Optional Docker production host publish ports:
# Docker production port mappings (docker-compose.prod.yml only).
# These set the HOST-side published ports. Container ports use PORT/API_PORT.
# PROD_DASHBOARD_PORT=20130
# PROD_API_PORT=20131
# Runtime override used by Electron and wrapped environments.
# OMNIROUTE_PORT takes precedence over PORT when running inside wrappers.
# Used by: src/lib/runtime/ports.ts — preserves canonical port in Electron.
# OMNIROUTE_PORT=20128
# Environment mode — affects Next.js behavior, logging verbosity, and caching.
# Values: production | development | Default: production
NODE_ENV=production
INSTANCE_NAME=omniroute
# Recommended security and ops variables
# ═══════════════════════════════════════════════════════════════════════════════
# 4. SECURITY & AUTHENTICATION
# ═══════════════════════════════════════════════════════════════════════════════
# Salt for generating unique machine IDs (fingerprint diversification).
# Used by: src/lib/auth — combined with hardware identifiers for machine-id hash.
# Default: endpoint-proxy-salt | Change per-deployment for isolation.
MACHINE_ID_SALT=endpoint-proxy-salt
AUTH_COOKIE_SECURE=false
REQUIRE_API_KEY=false
ALLOW_API_KEY_REVEAL=false
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
# Input Sanitizer (FASE-01 — prompt injection & PII protection)
# Set true when running behind HTTPS (reverse proxy with TLS termination).
# Used by: src/lib/auth — sets the Secure flag on session cookies.
# Default: false | MUST be true in any non-localhost deployment.
AUTH_COOKIE_SECURE=false
# Require an API key for all /v1/* proxy endpoints.
# Used by: API middleware — rejects unauthenticated requests to the proxy API.
# Default: false | Set true for multi-user/public deployments.
REQUIRE_API_KEY=false
# Allow revealing full API key values in the Dashboard UI.
# Used by: Dashboard providers page — controls show/hide of key values.
# Default: false | Security risk if enabled on shared instances.
ALLOW_API_KEY_REVEAL=false
# Comma-separated API key IDs that skip request logging (GDPR/compliance).
# Used by: src/lib/compliance/index.ts — suppresses logs for specific keys.
# NO_LOG_API_KEY_IDS=key_abc123,key_def456
# Maximum request body size in bytes (rejects larger payloads).
# Used by: src/shared/middleware/bodySizeGuard.ts — prevents oversized uploads.
# Default: 10485760 (10 MB)
# MAX_BODY_SIZE_BYTES=10485760
# CORS configuration — controls which origins can call the API.
# Used by: Next.js middleware — sets Access-Control-Allow-Origin header.
# Default: * (all origins) | Restrict for production security.
# CORS_ORIGIN=https://your-domain.com
# ═══════════════════════════════════════════════════════════════════════════════
# 5. INPUT SANITIZATION & PII PROTECTION (FASE-01)
# ═══════════════════════════════════════════════════════════════════════════════
# Multi-layer defense: request-side injection guard + response-side PII sanitizer.
# ── Request-Side: Prompt Injection Guard ──
# Scans incoming messages for prompt injection patterns before routing.
# Used by: src/middleware/promptInjectionGuard.ts
# INPUT_SANITIZER_ENABLED=true
# INPUT_SANITIZER_MODE=warn # warn | block | redact
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = strip patterns
# Legacy alias for INPUT_SANITIZER_MODE (same effect).
# INJECTION_GUARD_MODE=warn
# PII detection in incoming requests (emails, phone numbers, SSNs, etc.).
# Used by: src/middleware/promptInjectionGuard.ts — extends injection guard.
# PII_REDACTION_ENABLED=false
# Cloud sync variables
# Must point to this running instance so internal sync jobs can call /api/sync/cloud.
# Server-side preferred variables:
# ── Response-Side: PII Sanitizer ──
# Scans LLM responses for leaked PII before returning to the client.
# Used by: src/lib/piiSanitizer.ts
# PII_RESPONSE_SANITIZATION=false
# PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response
# ═══════════════════════════════════════════════════════════════════════════════
# 6. TOOL & ROUTING POLICIES
# ═══════════════════════════════════════════════════════════════════════════════
# Tool policy mode — controls which tools LLMs can invoke via function calling.
# Used by: src/lib/toolPolicy.ts — enforces allowlist/denylist on tool_choice.
# Values: allowlist | denylist | disabled | Default: disabled
# TOOL_POLICY_MODE=disabled
# ═══════════════════════════════════════════════════════════════════════════════
# 7. URLS & CLOUD SYNC
# ═══════════════════════════════════════════════════════════════════════════════
# URLs used for internal sync jobs, OAuth callbacks, and cloud relay.
# Internal base URL — used by server-side sync jobs to call /api/sync/cloud.
# Used by: src/lib/cloudSync.ts, src/lib/initCloudSync.ts
# Default: http://localhost:20128
BASE_URL=http://localhost:20128
# Cloud relay URL — premium feature for remote config sync.
# Used by: src/lib/cloudSync.ts — pushes/pulls settings from OmniRoute Cloud.
CLOUD_URL=
# Backward-compatible/public variables:
# NEXT_PUBLIC_BASE_URL is also used as the OAuth redirect_uri origin when running behind a
# reverse proxy (e.g., nginx). Set this to your public-facing URL so OAuth callbacks work.
# Example: NEXT_PUBLIC_BASE_URL=https://omniroute.example.com
# Timeout for cloud sync HTTP requests in milliseconds.
# Used by: src/lib/cloudSync.ts — fetchWithTimeout wrapper.
# Default: 12000 (12 seconds)
# CLOUD_SYNC_TIMEOUT_MS=12000
# Public-facing base URL — CRITICAL for reverse proxy / OAuth callback setups.
# Used by: OAuth redirect_uri computation, Dashboard UI links, cloud/model sync.
# Set to your public URL when behind nginx/Caddy (e.g., https://omniroute.example.com).
# Default: http://localhost:20128
NEXT_PUBLIC_BASE_URL=http://localhost:20128
# Public cloud URL — client-side mirror of CLOUD_URL.
NEXT_PUBLIC_CLOUD_URL=
# Optional outbound proxy variables for upstream provider calls
# Lowercase variants are also supported: http_proxy, https_proxy, all_proxy, no_proxy
# SOCKS5 proxy support
# Legacy alias — fallback for NEXT_PUBLIC_BASE_URL in sync schedulers.
# NEXT_PUBLIC_APP_URL=http://localhost:20128
# ═══════════════════════════════════════════════════════════════════════════════
# 8. OUTBOUND PROXY (Upstream Provider Calls)
# ═══════════════════════════════════════════════════════════════════════════════
# Route upstream LLM API calls through an HTTP/SOCKS5 proxy.
# Useful for corporate egress, geo-routing, or IP masking.
# Enable SOCKS5 proxy support in both server and client components.
# Used by: open-sse/executors — wraps fetch() calls through the proxy agent.
ENABLE_SOCKS5_PROXY=true
NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Standard proxy variables (lowercase variants also supported).
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
# ALL_PROXY=socks5://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1
# TLS fingerprint spoofing (opt-in) — mimics Chrome 124 TLS handshake via wreq-js
# Reduces risk of JA3/JA4 fingerprint-based blocking by providers (e.g., Google)
# Requires wreq-js to be installed (included in dependencies)
# TLS fingerprint spoofing (opt-in) — mimics Chrome 124 TLS handshake via wreq-js.
# Reduces risk of JA3/JA4 fingerprint-based blocking by providers (e.g., Google).
# Used by: open-sse/executors — replaces Node.js default TLS fingerprint.
# ENABLE_TLS_FINGERPRINT=true
# Optional CLI runtime overrides (Docker/host integration)
# ═══════════════════════════════════════════════════════════════════════════════
# 9. CLI TOOL INTEGRATION
# ═══════════════════════════════════════════════════════════════════════════════
# Control how OmniRoute discovers and launches CLI sidecars (Claude, Codex, etc.).
# Used by: src/shared/services/cliRuntime.ts
# CLI discovery mode: auto = search PATH | manual = use explicit paths below.
# CLI_MODE=auto
# CLI_EXTRA_PATHS=/host-cli/bin
# Additional PATH entries for finding CLI binaries (colon-separated).
# CLI_EXTRA_PATHS=/host-cli/bin:/usr/local/bin
# Home directory override for reading CLI config files (~/.claude, etc.).
# CLI_CONFIG_HOME=/root
# Allow OmniRoute to write CLI config files (token refresh, etc.).
# CLI_ALLOW_CONFIG_WRITES=true
# Override binary paths for individual CLI tools.
# CLI_CLAUDE_BIN=claude
# CLI_CODEX_BIN=codex
# CLI_DROID_BIN=droid
# CLI_OPENCLAW_BIN=openclaw
# CLI_CURSOR_BIN=agent
# CLI_CLINE_BIN=cline
# CLI_ROO_BIN=roo
# CLI_CONTINUE_BIN=cn
# CLI_QODER_BIN=qoder
# Internal agent / tool integrations (optional)
# Used by the MCP server, A2A skills, and CLI sidecars when they need to call
# the running OmniRoute instance explicitly instead of relying on localhost.
# ═══════════════════════════════════════════════════════════════════════════════
# 10. INTERNAL AGENT & MCP INTEGRATIONS
# ═══════════════════════════════════════════════════════════════════════════════
# Used by MCP server, A2A skills, and CLI sidecars to call the running instance.
# Explicit base URL for MCP/A2A tools to reach OmniRoute (overrides localhost auto-detect).
# Used by: open-sse/mcp-server/server.ts, src/lib/a2a/
# OMNIROUTE_BASE_URL=http://localhost:20128
# API key for internal tool calls (MCP tools, A2A skills).
# OMNIROUTE_API_KEY=
# API key ID for MCP audit logging.
# Used by: open-sse/mcp-server/audit.ts — tags audit events with a key identity.
# OMNIROUTE_API_KEY_ID=
# Legacy alias for OMNIROUTE_API_KEY.
# ROUTER_API_KEY=
# Enforce scope-based access control on MCP tool calls.
# Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes.
# OMNIROUTE_MCP_ENFORCE_SCOPES=false
# Comma-separated scopes granted to this MCP connection.
# Full list: admin, combos, health, models, routing, budget, metrics, pricing, memory, skills
# OMNIROUTE_MCP_SCOPES=admin,combos,health
# Model catalog sync interval in hours.
# Used by: src/shared/services/modelSyncScheduler.ts — periodic model refresh.
# Default: 24
# MODEL_SYNC_INTERVAL_HOURS=24
# ═══════════════════════════════════════════════════
# OAUTH PROVIDER CREDENTIALS
# ═══════════════════════════════════════════════════
# These are the built-in default credentials that work for localhost setups.
# For remote/VPS deployments, register your own credentials at each provider.
# The sync-env script will auto-populate these in your .env if missing.
#
# These can also be overridden via data/provider-credentials.json where supported.
# Provider limits sync interval in minutes (rate limit windows, quotas).
# Used by: src/server-init.ts — polls provider health endpoints.
# Default: 70
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
# Disable all background services (sync, pricing, model refresh).
# Used by: src/instrumentation-node.ts, src/lib/initCloudSync.ts
# Useful for: CI builds, test environments, or resource-constrained containers.
# OMNIROUTE_DISABLE_BACKGROUND_SERVICES=false
# Flag set by bootstrap script after initial setup is complete.
# Used by: src/app/(dashboard)/dashboard/page.tsx — shows setup wizard vs. dashboard.
# OMNIROUTE_BOOTSTRAPPED=false
# Allow request body to override the Antigravity project field.
# Used by: open-sse/executors/antigravity.ts — escape hatch for multi-project setups.
# OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=0
# ═══════════════════════════════════════════════════════════════════════════════
# 11. OAUTH PROVIDER CREDENTIALS
# ═══════════════════════════════════════════════════════════════════════════════
# Built-in default credentials for localhost development.
# For remote/VPS deployments, register your own at each provider's developer console.
# The bootstrap-env script auto-populates these in .env if missing.
# Can also be overridden via data/provider-credentials.json where supported.
# ── Claude Code (Anthropic) ──
CLAUDE_OAUTH_CLIENT_ID=9d1c250a-e61b-44d9-88ed-5944d1962f5e
# Custom redirect URI override for Claude OAuth callback.
# CLAUDE_CODE_REDIRECT_URI=https://platform.claude.com/oauth/code/callback
# ── Codex / OpenAI ──
CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
@@ -143,23 +341,41 @@ QODER_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW
# 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 +386,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 +406,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 +431,259 @@ 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)
# 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
+4 -3
View File
@@ -124,6 +124,7 @@ jobs:
run: npx is-my-node-vulnerable || true
- name: Run Snyk Vulnerability checks
if: github.actor != 'dependabot[bot]'
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
@@ -222,7 +223,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 +253,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 +394,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
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
echo "Publishing Docker image: $IMAGE_NAME:$VERSION"
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
target: runner-base
+4
View File
@@ -99,6 +99,10 @@ docs/*
!docs/MCP-SERVER.md
!docs/CLI-TOOLS.md
!docs/COVERAGE_PLAN.md
!docs/ENVIRONMENT.md
!docs/UNINSTALL.md
!docs/I18N.md
!docs/FLY_IO_DEPLOYMENT_GUIDE.md
# open-sse tests
+5
View File
@@ -28,6 +28,8 @@ scripts/
.vscode/
.agents/
.env*
app/.env
app/.env*
eslint.config.mjs
prettier.config.mjs
postcss.config.mjs
@@ -67,3 +69,6 @@ clipr/
omnirouteCloud/
omnirouteSite/
vscode-extension/
# Root-level underscore-prefixed directories (private/draft — never publish)
/_*/
+2 -1
View File
@@ -16,5 +16,6 @@
"javascript:S3776": {
"level": "off"
}
}
},
"git.ignoreLimitWarning": true
}
+106
View File
@@ -4,6 +4,112 @@
---
## [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
+12 -9
View File
@@ -244,6 +244,8 @@ Developers pay $20200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve
- **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI
- **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next
- **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**)
- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets
- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use
- **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard
</details>
@@ -2219,6 +2221,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 +2233,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
+3 -3
View File
@@ -20,9 +20,9 @@ If you discover a security vulnerability in OmniRoute, please report it responsi
| Version | Support Status |
| ------- | -------------- |
| 3.4.x | ✅ Active |
| 3.0.x | ✅ Security |
| < 3.0.0 | ❌ Unsupported |
| 3.6.x | ✅ Active |
| 3.5.x | ✅ Security |
| < 3.5.0 | ❌ Unsupported |
---
+7 -4
View File
@@ -2,7 +2,7 @@
🌐 **Languages:** 🇺🇸 [English](ARCHITECTURE.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/ARCHITECTURE.md) | 🇪🇸 [Español](i18n/es/ARCHITECTURE.md) | 🇫🇷 [Français](i18n/fr/ARCHITECTURE.md) | 🇮🇹 [Italiano](i18n/it/ARCHITECTURE.md) | 🇷🇺 [Русский](i18n/ru/ARCHITECTURE.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/ARCHITECTURE.md) | 🇩🇪 [Deutsch](i18n/de/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](i18n/in/ARCHITECTURE.md) | 🇹🇭 [ไทย](i18n/th/ARCHITECTURE.md) | 🇺🇦 [Українська](i18n/uk-UA/ARCHITECTURE.md) | 🇸🇦 [العربية](i18n/ar/ARCHITECTURE.md) | 🇯🇵 [日本語](i18n/ja/ARCHITECTURE.md) | 🇻🇳 [Tiếng Việt](i18n/vi/ARCHITECTURE.md) | 🇧🇬 [Български](i18n/bg/ARCHITECTURE.md) | 🇩🇰 [Dansk](i18n/da/ARCHITECTURE.md) | 🇫🇮 [Suomi](i18n/fi/ARCHITECTURE.md) | 🇮🇱 [עברית](i18n/he/ARCHITECTURE.md) | 🇭🇺 [Magyar](i18n/hu/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/ARCHITECTURE.md) | 🇰🇷 [한국어](i18n/ko/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/ARCHITECTURE.md) | 🇳🇱 [Nederlands](i18n/nl/ARCHITECTURE.md) | 🇳🇴 [Norsk](i18n/no/ARCHITECTURE.md) | 🇵🇹 [Português (Portugal)](i18n/pt/ARCHITECTURE.md) | 🇷🇴 [Română](i18n/ro/ARCHITECTURE.md) | 🇵🇱 [Polski](i18n/pl/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](i18n/sk/ARCHITECTURE.md) | 🇸🇪 [Svenska](i18n/sv/ARCHITECTURE.md) | 🇵🇭 [Filipino](i18n/phi/ARCHITECTURE.md) | 🇨🇿 [Čeština](i18n/cs/ARCHITECTURE.md)
_Last updated: 2026-04-11_
_Last updated: 2026-04-12_
## Executive Summary
@@ -14,7 +14,9 @@ Core capabilities:
- OpenAI-compatible API surface for CLI/tools (100+ providers, 16 executors)
- Request/response translation across provider formats
- Model combo fallback (multi-model sequence)
- Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers`
- Account-level fallback (multi-account per provider)
- Quota preflight and quota-aware P2C account selection in the main chat path
- OAuth + API-key provider connection management (13 OAuth modules)
- Embedding generation via `/v1/embeddings` (6 providers, 9 models)
- Image generation via `/v1/images/generations` (10+ providers, 20+ models)
@@ -45,6 +47,7 @@ Core capabilities:
- Domain state persistence (SQLite write-through cache for fallbacks, budgets, lockouts, circuit breakers)
- Policy engine for centralized request evaluation (lockout → budget → fallback)
- Request telemetry with p50/p95/p99 latency aggregation
- Combo target telemetry and historical combo target health via `combo_execution_key` / `combo_step_id`
- Correlation ID (X-Request-Id) for end-to-end tracing
- Compliance audit logging with opt-out per API key
- Eval framework for LLM quality assurance
@@ -89,15 +92,15 @@ Main pages under `src/app/(dashboard)/dashboard/`:
- `/dashboard` — quick start + provider overview
- `/dashboard/endpoint` — endpoint proxy + MCP + A2A + API endpoint tabs
- `/dashboard/providers` — provider connections and credentials
- `/dashboard/combos` — combo strategies, templates, model routing rules, manual persisted ordering
- `/dashboard/combos` — combo strategies, templates, step-based builder, model routing rules, manual persisted ordering
- `/dashboard/costs` — cost aggregation and pricing visibility
- `/dashboard/analytics` — usage analytics and evaluations
- `/dashboard/analytics` — usage analytics, evaluations, combo target health
- `/dashboard/limits` — quota/rate controls
- `/dashboard/cli-tools` — CLI onboarding, runtime detection, config generation
- `/dashboard/agents` — detected ACP agents + custom agent registration
- `/dashboard/media` — image/video/music playground
- `/dashboard/search-tools` — search provider testing and history
- `/dashboard/health` — uptime, circuit breakers, rate limits
- `/dashboard/health` — uptime, circuit breakers, rate limits, quota-monitored sessions
- `/dashboard/logs` — request/proxy/audit/console logs
- `/dashboard/settings` — system settings tabs (general, routing, combo defaults, etc.)
- `/dashboard/api-manager` — API key lifecycle and model permissions
+659
View File
@@ -0,0 +1,659 @@
# Environment Variables Reference
> Complete reference for every environment variable recognized by OmniRoute.
> For a quick-start template, see [`.env.example`](../.env.example).
---
## Table of Contents
- [1. Required Secrets](#1-required-secrets)
- [2. Storage & Database](#2-storage--database)
- [3. Network & Ports](#3-network--ports)
- [4. Security & Authentication](#4-security--authentication)
- [5. Input Sanitization & PII Protection](#5-input-sanitization--pii-protection)
- [6. Tool & Routing Policies](#6-tool--routing-policies)
- [7. URLs & Cloud Sync](#7-urls--cloud-sync)
- [8. Outbound Proxy](#8-outbound-proxy)
- [9. CLI Tool Integration](#9-cli-tool-integration)
- [10. Internal Agent & MCP Integrations](#10-internal-agent--mcp-integrations)
- [11. OAuth Provider Credentials](#11-oauth-provider-credentials)
- [12. Provider User-Agent Overrides](#12-provider-user-agent-overrides)
- [13. CLI Fingerprint Compatibility](#13-cli-fingerprint-compatibility)
- [14. API Key Providers](#14-api-key-providers)
- [15. Timeout Settings](#15-timeout-settings)
- [16. Logging](#16-logging)
- [17. Memory Optimization](#17-memory-optimization)
- [18. Pricing Sync](#18-pricing-sync)
- [19. Model Sync (Dev)](#19-model-sync-dev)
- [20. Provider-Specific Settings](#20-provider-specific-settings)
- [21. Proxy Health](#21-proxy-health)
- [22. Debugging](#22-debugging)
- [23. GitHub Integration](#23-github-integration)
- [Deployment Scenarios](#deployment-scenarios)
- [Audit: Removed / Dead Variables](#audit-removed--dead-variables)
---
## 1. Required Secrets
These **must** be set before the first run. Without them, the application will either refuse to start or operate with insecure defaults.
| Variable | Required | Default | Source File | Description |
| ------------------ | -------- | -------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `JWT_SECRET` | **Yes** | _(none)_ | `src/lib/auth` | Signs/verifies all dashboard session cookies (JWT). Generate with `openssl rand -base64 48`. |
| `API_KEY_SECRET` | **Yes** | _(none)_ | `src/lib/db/apiKeys.ts` | AES encryption key for API key values at rest in SQLite. Generate with `openssl rand -hex 32`. |
| `INITIAL_PASSWORD` | **Yes** | `123456` | Bootstrap script | Sets the initial admin dashboard password. **Change before first use.** After login, change via Dashboard → Settings → Security. |
### Generation Commands
```bash
# Generate all three secrets at once:
echo "JWT_SECRET=$(openssl rand -base64 48)"
echo "API_KEY_SECRET=$(openssl rand -hex 32)"
echo "INITIAL_PASSWORD=$(openssl rand -base64 16)"
```
> [!CAUTION]
> Never commit `.env` files with real secrets to version control. The `.gitignore` already excludes `.env`, but verify before pushing.
---
## 2. Storage & Database
OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These variables control data location, encryption, and lifecycle.
| Variable | Default | Source File | Description |
| -------------------------------- | -------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | Root directory for SQLite DB, backups, and data files. Override for Docker volumes or custom paths. |
| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. |
| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. |
| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips the automatic database backup that runs before migrations on every startup. |
### Scenarios
| Scenario | Configuration |
| --------------------- | -------------------------------------------------------------------------------- |
| **Local development** | Leave all defaults. DB lives at `~/.omniroute/omniroute.db`. |
| **Docker** | `DATA_DIR=/data` + mount a volume at `/data`. |
| **Encrypted at rest** | Set `STORAGE_ENCRYPTION_KEY` + keep backups of the key! Losing it = losing data. |
| **CI/Testing** | `DATA_DIR=/tmp/omniroute-test` — ephemeral, no encryption needed. |
---
## 3. Network & Ports
| Variable | Default | Source File | Description |
| --------------------- | ------------ | -------------------------- | -------------------------------------------------------------------------------------- |
| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). |
| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. |
| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. |
| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. |
| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. |
| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. |
| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. |
| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. |
### Port Modes
```
┌─────────────────────────── Single Port (default) ──────────────────────────┐
│ PORT=20128 │
│ → Dashboard: http://localhost:20128 │
│ → API: http://localhost:20128/v1/chat/completions │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────── Split Ports ─────────────────────────────────────┐
│ DASHBOARD_PORT=20128 │
│ API_PORT=20129 │
│ API_HOST=0.0.0.0 │
│ → Dashboard: http://localhost:20128 │
│ → API: http://0.0.0.0:20129/v1/chat/completions │
│ Use case: Expose API to LAN while restricting Dashboard to localhost. │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────── Docker Production ──────────────────────────────┐
│ PROD_DASHBOARD_PORT=443 PROD_API_PORT=8443 │
│ → Maps container ports to host ports in docker-compose.prod.yml. │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 4. Security & Authentication
| Variable | Default | Source File | Description |
| ---------------------- | --------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt combined with hardware identifiers for machine fingerprinting. Change per-deployment for isolation. |
| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Sets the `Secure` flag on session cookies. **Must be `true`** when running behind HTTPS. |
| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. |
| `ALLOW_API_KEY_REVEAL` | `false` | Dashboard providers page | Allows revealing full API key values in the Dashboard UI. Security risk on shared instances. |
| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). |
| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. |
| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. |
### Hardening Checklist
```bash
# Production security minimum:
AUTH_COOKIE_SECURE=true # Requires HTTPS
REQUIRE_API_KEY=true # Authenticate all proxy calls
ALLOW_API_KEY_REVEAL=false # Never expose keys in UI
CORS_ORIGIN=https://your.domain.com
MAX_BODY_SIZE_BYTES=5242880 # 5 MB limit
```
---
## 5. Input Sanitization & PII Protection
OmniRoute provides a two-layer defense: request-side injection scanning and response-side PII stripping.
### Request-Side: Prompt Injection Guard
| Variable | Default | Source File | Description |
| ------------------------- | --------- | ---------------------------------------- | ------------------------------------------------------------------------------------------- |
| `INPUT_SANITIZER_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | Enable scanning of incoming messages for prompt injection patterns. |
| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | `warn` = log only, `block` = reject request with 400, `redact` = strip suspicious patterns. |
| `INJECTION_GUARD_MODE` | _(unset)_ | `src/middleware/promptInjectionGuard.ts` | Legacy alias for `INPUT_SANITIZER_MODE` — same behavior. |
| `PII_REDACTION_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | Detect PII (emails, phones, SSNs) in incoming requests. |
### Response-Side: PII Sanitizer
| Variable | Default | Source File | Description |
| -------------------------------- | -------- | ------------------------- | ----------------------------------------------------------------------- |
| `PII_RESPONSE_SANITIZATION` | `false` | `src/lib/piiSanitizer.ts` | Scan LLM responses for leaked PII before returning to client. |
| `PII_RESPONSE_SANITIZATION_MODE` | `redact` | `src/lib/piiSanitizer.ts` | `redact` = mask PII, `warn` = log only, `block` = drop entire response. |
### Scenarios
| Scenario | Configuration |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| **Enterprise compliance** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=block`, `PII_REDACTION_ENABLED=true`, `PII_RESPONSE_SANITIZATION=true` |
| **Monitoring only** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=warn` — logs but never blocks |
| **Personal use** | Leave all disabled — zero overhead |
---
## 6. Tool & Routing Policies
| Variable | Default | Source File | Description |
| ------------------ | ---------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `TOOL_POLICY_MODE` | `disabled` | `src/lib/toolPolicy.ts` | Controls LLM tool/function-calling access. `allowlist` = only listed tools, `denylist` = all except listed, `disabled` = no restrictions. |
---
## 7. URLs & Cloud Sync
| Variable | Default | Source File | Description |
| ----------------------- | ------------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | Server-side URL for internal sync jobs to call `/api/sync/cloud`. |
| `CLOUD_URL` | _(empty)_ | `src/lib/cloudSync.ts` | Cloud relay endpoint URL (premium feature). |
| `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | HTTP timeout for cloud sync requests. |
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth, Dashboard, sync | Public-facing URL for OAuth redirect_uri, Dashboard links. **Must match your public URL behind reverse proxy.** |
| `NEXT_PUBLIC_CLOUD_URL` | _(empty)_ | Client-side | Client-side mirror of `CLOUD_URL`. |
| `NEXT_PUBLIC_APP_URL` | _(unset)_ | `src/shared/services/cloudSyncScheduler.ts` | Legacy fallback for `NEXT_PUBLIC_BASE_URL`. |
> [!IMPORTANT]
> When deploying behind a reverse proxy (nginx, Caddy), `NEXT_PUBLIC_BASE_URL` **must** be set to your public URL (e.g., `https://omniroute.example.com`). Without this, OAuth callbacks will fail because the redirect_uri won't match.
---
## 8. Outbound Proxy
Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress control, geo-routing, or IP masking.
| Variable | Default | Source File | Description |
| --------------------------------- | --------- | -------------------- | ----------------------------------------------------------------------------------- |
| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. |
| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | Client-side | Client-side awareness of SOCKS5 availability. |
| `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. |
| `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. |
| `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). |
| `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. |
| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. |
### Scenarios
| Scenario | Configuration |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| **SOCKS5 through SSH tunnel** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` |
| **Corporate HTTP proxy** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` |
| **Anti-fingerprint** | `ENABLE_TLS_FINGERPRINT=true` — requires `wreq-js` (included) |
---
## 9. CLI Tool Integration
Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, etc.).
| Variable | Default | Source File | Description |
| ------------------------- | ---------- | ----------------------------------- | -------------------------------------------------------------------------- |
| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = search system PATH; `manual` = use explicit paths only. |
| `CLI_EXTRA_PATHS` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Additional PATH entries for CLI binary discovery (colon-separated). |
| `CLI_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override home directory for reading CLI configs (`~/.claude`, `~/.codex`). |
| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | Allow OmniRoute to write CLI config files (token refresh, session data). |
| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Custom path to Claude CLI binary. |
| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Custom path to Codex CLI binary. |
| `CLI_DROID_BIN` | `droid` | `src/shared/services/cliRuntime.ts` | Custom path to Droid CLI binary. |
| `CLI_OPENCLAW_BIN` | `openclaw` | `src/shared/services/cliRuntime.ts` | Custom path to OpenClaw CLI binary. |
| `CLI_CURSOR_BIN` | `agent` | `src/shared/services/cliRuntime.ts` | Custom path to Cursor agent binary. |
| `CLI_CLINE_BIN` | `cline` | `src/shared/services/cliRuntime.ts` | Custom path to Cline CLI binary. |
| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Custom path to Continue CLI binary. |
| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. |
### Docker Example
```bash
# Mount host binaries into the container and tell OmniRoute where they are:
CLI_EXTRA_PATHS=/host-cli/bin
CLI_CONFIG_HOME=/root
CLI_ALLOW_CONFIG_WRITES=true
CLI_CLAUDE_BIN=/host-cli/bin/claude
```
---
## 10. Internal Agent & MCP Integrations
| Variable | Default | Source File | Description |
| --------------------------------------- | ----------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `OMNIROUTE_BASE_URL` | auto-detect | `open-sse/mcp-server/server.ts` | Explicit URL for MCP/A2A tools to reach OmniRoute. Overrides localhost auto-detection. |
| `OMNIROUTE_API_KEY` | _(unset)_ | MCP/A2A modules | API key for internal MCP tool and A2A skill calls. |
| `OMNIROUTE_API_KEY_ID` | _(unset)_ | `open-sse/mcp-server/audit.ts` | Key ID for MCP audit log attribution. |
| `ROUTER_API_KEY` | _(unset)_ | Legacy | Legacy alias for `OMNIROUTE_API_KEY`. |
| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `false` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. |
| `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. |
| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Model catalog sync interval in hours. |
| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Provider rate-limit and quota polling interval. |
| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. |
| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. |
| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. |
### OAuth CLI Bridge (Internal)
| Variable | Default | Source File | Description |
| ------------------- | ----------- | ------------------------------- | ----------------------------------------- |
| `OMNIROUTE_SERVER` | auto-detect | `src/lib/oauth/config/index.ts` | Server URL for CLI↔OmniRoute auth bridge. |
| `OMNIROUTE_TOKEN` | _(unset)_ | `src/lib/oauth/config/index.ts` | Auth token for CLI bridge. |
| `OMNIROUTE_USER_ID` | `cli` | `src/lib/oauth/config/index.ts` | User ID for CLI bridge sessions. |
| `SERVER_URL` | _(unset)_ | `src/lib/oauth/config/index.ts` | Legacy alias for `OMNIROUTE_SERVER`. |
| `CLI_TOKEN` | _(unset)_ | `src/lib/oauth/config/index.ts` | Legacy alias for `OMNIROUTE_TOKEN`. |
| `CLI_USER_ID` | _(unset)_ | `src/lib/oauth/config/index.ts` | Legacy alias for `OMNIROUTE_USER_ID`. |
---
## 11. OAuth Provider Credentials
Built-in credentials for **localhost development**. For remote deployments, register your own at each provider's developer console.
| Variable | Provider | Notes |
| --------------------------------- | ----------------------- | --------------------------------------------------------------------------------- |
| `CLAUDE_OAUTH_CLIENT_ID` | Claude Code (Anthropic) | Public client — no secret needed. |
| `CLAUDE_CODE_REDIRECT_URI` | Claude Code | Override redirect URI. Default: `https://platform.claude.com/oauth/code/callback` |
| `CODEX_OAUTH_CLIENT_ID` | Codex / OpenAI | Public client. |
| `GEMINI_OAUTH_CLIENT_ID` | Gemini (Google) | Requires matching `_SECRET`. |
| `GEMINI_OAUTH_CLIENT_SECRET` | Gemini (Google) | — |
| `GEMINI_CLI_OAUTH_CLIENT_ID` | Gemini CLI | Usually same as Gemini. |
| `GEMINI_CLI_OAUTH_CLIENT_SECRET` | Gemini CLI | — |
| `QWEN_OAUTH_CLIENT_ID` | Qwen (Alibaba) | Public client. |
| `KIMI_CODING_OAUTH_CLIENT_ID` | Kimi Coding (Moonshot) | Public client. |
| `ANTIGRAVITY_OAUTH_CLIENT_ID` | Antigravity (Google) | Requires matching `_SECRET`. |
| `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — |
| `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | Public client. |
| `QODER_OAUTH_CLIENT_SECRET` | Qoder | — |
| `QODER_OAUTH_AUTHORIZE_URL` | Qoder | Set to enable Qoder OAuth. |
| `QODER_OAUTH_TOKEN_URL` | Qoder | — |
| `QODER_OAUTH_USERINFO_URL` | Qoder | — |
| `QODER_OAUTH_CLIENT_ID` | Qoder | — |
| `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | Direct API key fallback (bypasses OAuth). |
| `QODER_CLI_WORKSPACE` | Qoder | Workspace ID for Qoder CLI. |
| `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias for `QODER_CLI_WORKSPACE`. |
> [!WARNING]
> **Google OAuth** (Antigravity, Gemini CLI) credentials **only work on localhost**. For remote servers:
>
> 1. Go to [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials)
> 2. Create an OAuth 2.0 Client ID (type: "Web application")
> 3. Add your server URL as Authorized redirect URI
> 4. Replace the credential values in `.env`.
---
## 12. Provider User-Agent Overrides
Override the `User-Agent` header sent to each upstream provider. This is dynamically resolved at runtime by the executor base class:
```
process.env[`${PROVIDER_ID}_USER_AGENT`]
```
> **Source:** `open-sse/executors/base.ts``buildHeaders()`
| Variable | Default Value | When to Update |
| ------------------------ | -------------------------------------------- | ----------------------------------------- |
| `CLAUDE_USER_AGENT` | `claude-cli/1.0.83 (external, cli)` | When Anthropic releases a new CLI version |
| `CODEX_USER_AGENT` | `codex-cli/0.92.0 (Windows 10.0.26100; x64)` | When OpenAI updates the Codex CLI |
| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.26.7` | When GitHub Copilot Chat updates |
| `ANTIGRAVITY_USER_AGENT` | `antigravity/1.104.0 darwin/arm64` | When Antigravity IDE updates |
| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates |
| `QODER_USER_AGENT` | `Qoder-Cli` | When Qoder CLI updates |
| `QWEN_USER_AGENT` | `QwenCode/0.12.3 (linux; x64)` | When Qwen Code updates |
| `CURSOR_USER_AGENT` | `connect-es/1.6.1` | When Cursor updates |
| `GEMINI_CLI_USER_AGENT` | `google-api-nodejs-client/9.15.1` | When Google API client updates |
> [!TIP]
> You can add User-Agent overrides for **any** provider using the pattern `{PROVIDER_ID}_USER_AGENT`. The executor dynamically constructs the env var name.
---
## 13. CLI Fingerprint Compatibility
When enabled, OmniRoute reorders HTTP headers and JSON body fields to match the exact signature of official CLI tools. This reduces the risk of account flagging while preserving your proxy IP.
**Source:** `open-sse/config/cliFingerprints.ts`, `open-sse/executors/base.ts`
### Per-Provider
| Variable | Effect |
| -------------------------- | --------------------------------------- |
| `CLI_COMPAT_CODEX=1` | Mimics Codex CLI request signature |
| `CLI_COMPAT_CLAUDE=1` | Mimics Claude Code request signature |
| `CLI_COMPAT_GITHUB=1` | Mimics GitHub Copilot request signature |
| `CLI_COMPAT_ANTIGRAVITY=1` | Mimics Antigravity request signature |
| `CLI_COMPAT_KIRO=1` | Mimics Kiro IDE request signature |
| `CLI_COMPAT_CURSOR=1` | Mimics Cursor request signature |
| `CLI_COMPAT_KIMI_CODING=1` | Mimics Kimi Coding request signature |
| `CLI_COMPAT_KILOCODE=1` | Mimics Kilo Code request signature |
| `CLI_COMPAT_CLINE=1` | Mimics Cline request signature |
| `CLI_COMPAT_QWEN=1` | Mimics Qwen Code request signature |
### Global
| Variable | Effect |
| ------------------ | --------------------------------------------------------------- |
| `CLI_COMPAT_ALL=1` | Enable fingerprint compatibility for **all** providers at once. |
> [!NOTE]
> This feature works alongside the User-Agent overrides (§12). The fingerprint system handles header ordering and body field ordering, while User-Agent overrides handle the specific UA string. Both can be enabled independently.
---
## 14. API Key Providers
API keys for providers that use direct authentication. **Preferred setup:** Dashboard → Providers → Add API Key.
Setting via environment variables is an alternative for Docker or headless deployments.
Recognized pattern: `{PROVIDER_ID}_API_KEY`
| Variable | Provider |
| -------------------- | ------------------- |
| `DEEPSEEK_API_KEY` | DeepSeek |
| `GROQ_API_KEY` | Groq |
| `XAI_API_KEY` | xAI (Grok) |
| `MISTRAL_API_KEY` | Mistral AI |
| `PERPLEXITY_API_KEY` | Perplexity |
| `TOGETHER_API_KEY` | Together AI |
| `FIREWORKS_API_KEY` | Fireworks AI |
| `CEREBRAS_API_KEY` | Cerebras |
| `COHERE_API_KEY` | Cohere |
| `NVIDIA_API_KEY` | NVIDIA NIM |
| `NEBIUS_API_KEY` | Nebius (embeddings) |
> [!TIP]
> Keys set via the Dashboard are stored encrypted in SQLite and take precedence over environment variables.
---
## 15. Timeout Settings
All values are in **milliseconds**. Centralized resolution in `src/shared/utils/runtimeTimeouts.ts`.
### Timeout Hierarchy
```
REQUEST_TIMEOUT_MS (global override)
├─→ FETCH_TIMEOUT_MS (upstream provider calls, default: 600000)
│ ├─→ FETCH_HEADERS_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS)
│ ├─→ FETCH_BODY_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS)
│ ├─→ TLS_CLIENT_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS)
│ ├── FETCH_CONNECT_TIMEOUT_MS (independent, default: 30000)
│ └── FETCH_KEEPALIVE_TIMEOUT_MS (independent, default: 4000)
├─→ STREAM_IDLE_TIMEOUT_MS (inherits from REQUEST_TIMEOUT_MS, default: 600000)
└─→ API_BRIDGE_PROXY_TIMEOUT_MS (inherits from REQUEST_TIMEOUT_MS, default: 30000)
├─→ API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS (derived, default: 300000)
├── API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS (default: 60000)
├── API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS (default: 5000)
└── API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS (default: 0 = disabled)
```
| Variable | Default | Description |
| ---------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------- |
| `REQUEST_TIMEOUT_MS` | _(unset)_ | Global shortcut — overrides both `FETCH_TIMEOUT_MS` and `STREAM_IDLE_TIMEOUT_MS` defaults. |
| `FETCH_TIMEOUT_MS` | `600000` | Total HTTP request timeout for upstream provider calls. |
| `STREAM_IDLE_TIMEOUT_MS` | `600000` | Max silence between SSE chunks before aborting. Extended-thinking models rarely pause >90s. |
| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. |
| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. |
| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. |
| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. |
| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. |
| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. |
| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | Overall server request timeout for the bridge. |
| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Time to send response headers via the bridge. |
| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Bridge keep-alive idle timeout. |
| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). |
| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. |
### Scenarios
| Scenario | Configuration |
| -------------------------------- | ------------------------------------------------------ |
| **Long-running code generation** | `REQUEST_TIMEOUT_MS=900000` (15 min) |
| **Fast-fail for production API** | `API_BRIDGE_PROXY_TIMEOUT_MS=10000` |
| **Extended thinking models** | `STREAM_IDLE_TIMEOUT_MS=300000` (5 min between chunks) |
---
## 16. Logging
The logging system writes to both stdout and rotated log files. All configuration is read by `src/lib/logEnv.ts`.
| Variable | Default | Description |
| --------------------------- | -------------------------- | ---------------------------------------------------------------------------- |
| `APP_LOG_LEVEL` | `info` | Minimum log level: `debug`, `info`, `warn`, `error`. |
| `APP_LOG_FORMAT` | `text` | Output format: `text` (human-readable) or `json` (structured). |
| `APP_LOG_TO_FILE` | `true` | Write logs to file alongside stdout. |
| `APP_LOG_FILE_PATH` | `logs/application/app.log` | Log file path (relative to project root or `DATA_DIR`). |
| `APP_LOG_MAX_FILE_SIZE` | `50M` | Max file size before rotation. Accepts: `50M`, `1G`, `512K`, or plain bytes. |
| `APP_LOG_RETENTION_DAYS` | `7` | Days to keep rotated application log files. |
| `APP_LOG_MAX_FILES` | `20` | Maximum rotated log file backups. |
| `CALL_LOG_RETENTION_DAYS` | `7` | Days to keep request/call log entries in the database. |
| `CALL_LOG_MAX_ENTRIES` | `10000` | Max call log entries in the in-memory buffer. |
| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. |
| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `proxy_logs` SQLite table before pruning. |
---
## 17. Memory Optimization
| Variable | Default | Description |
| -------------------------- | ------------------------------- | ---------------------------------------------------------------------- |
| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. |
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. |
| `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. |
| `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. |
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max cached temperature=0 responses. |
| `SEMANTIC_CACHE_MAX_BYTES` | `4194304` (4 MB) | Max total semantic cache size. |
| `SEMANTIC_CACHE_TTL_MS` | `1800000` (30 min) | Semantic cache entry TTL. |
| `STREAM_HISTORY_MAX` | `50` | Max recent stream events in the Dashboard live view buffer. |
| `CONTEXT_LENGTH_DEFAULT` | `128000` | Global fallback max context length for models without explicit config. |
| `USAGE_TOKEN_BUFFER` | `100` | Extra token headroom reserved when tracking usage quotas. |
### Low-RAM Docker Example
```bash
OMNIROUTE_MEMORY_MB=128
PROMPT_CACHE_MAX_SIZE=20
PROMPT_CACHE_MAX_BYTES=524288 # 512 KB
SEMANTIC_CACHE_MAX_SIZE=25
SEMANTIC_CACHE_MAX_BYTES=1048576 # 1 MB
STREAM_HISTORY_MAX=10
```
---
## 18. Pricing Sync
Automatic model pricing data synchronization from external sources.
| Variable | Default | Source File | Description |
| ----------------------- | ------------- | ------------------------ | ----------------------------- |
| `PRICING_SYNC_ENABLED` | `false` | `src/lib/pricingSync.ts` | Opt-in periodic pricing sync. |
| `PRICING_SYNC_INTERVAL` | `86400` (24h) | `src/lib/pricingSync.ts` | Sync interval in seconds. |
| `PRICING_SYNC_SOURCES` | `litellm` | `src/lib/pricingSync.ts` | Comma-separated data sources. |
---
## 19. Model Sync (Dev)
| Variable | Default | Source File | Description |
| -------------------------- | ------------- | -------------------------- | -------------------------------------------------------- |
| `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. |
---
## 20. Provider-Specific Settings
| Variable | Default | Source File | Description |
| ----------------------------------------- | ------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------- |
| `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter model catalog cache TTL. |
| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. |
| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. |
| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. |
| `CLOUDFLARED_BIN` | auto-detect | `src/lib/cloudflaredTunnel.ts` | Custom path to `cloudflared` binary. |
| `SEARCH_CACHE_TTL_MS` | `300000` (5 min) | `open-sse/services/searchCache.ts` | TTL for search API (Perplexity, Brave, etc.) response caching. |
| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | Allow multiple simultaneous connections per OpenAI-compatible provider. |
| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | Enable experimental Claude Code compatible provider endpoint. |
| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). |
| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. |
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). |
---
## 21. Proxy Health
| Variable | Default | Source File | Description |
| ---------------------------- | ---------------- | --------------------------------------- | ----------------------------------------------------- |
| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. |
| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. |
| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. |
---
## 22. Debugging
> [!CAUTION]
> These variables produce **verbose output** and may leak sensitive data. **Never enable in production.**
| Variable | Default | Source File | Description |
| -------------------------------- | --------- | ----------------------------------------- | -------------------------------------------------------------- |
| `CURSOR_PROTOBUF_DEBUG` | _(unset)_ | `open-sse/utils/cursorProtobuf.ts` | Set `1` to dump Cursor protobuf decode/encode details. |
| `CURSOR_STREAM_DEBUG` | _(unset)_ | `open-sse/executors/cursor.ts` | Set `1` to dump raw Cursor SSE stream data. |
| `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. |
| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Set `true` to enable E2E test mode (relaxed auth, test hooks). |
---
## 23. GitHub Integration
Allow users to report issues directly from the Dashboard.
| Variable | Default | Source File | Description |
| --------------------- | --------- | --------------------------------------- | ------------------------------------------------------- |
| `GITHUB_ISSUES_REPO` | _(unset)_ | `src/app/api/v1/issues/report/route.ts` | Repository in `owner/repo` format. |
| `GITHUB_ISSUES_TOKEN` | _(unset)_ | `src/app/api/v1/issues/report/route.ts` | GitHub Personal Access Token with `issues:write` scope. |
---
## Deployment Scenarios
### Minimal Local Development
```bash
JWT_SECRET=$(openssl rand -base64 48)
API_KEY_SECRET=$(openssl rand -hex 32)
INITIAL_PASSWORD=dev123
PORT=20128
NODE_ENV=development
```
### Docker Production
```bash
JWT_SECRET=<generated>
API_KEY_SECRET=<generated>
INITIAL_PASSWORD=<generated>
STORAGE_ENCRYPTION_KEY=<generated>
DATA_DIR=/data
PORT=20128
API_PORT=20129
NODE_ENV=production
AUTH_COOKIE_SECURE=true
REQUIRE_API_KEY=true
NEXT_PUBLIC_BASE_URL=https://omniroute.example.com
BASE_URL=http://localhost:20128
OMNIROUTE_MEMORY_MB=512
CORS_ORIGIN=https://your-frontend.example.com
```
### Air-Gapped / CI
```bash
JWT_SECRET=test-jwt-secret-for-ci
API_KEY_SECRET=test-api-key-secret-for-ci
INITIAL_PASSWORD=testpass
NODE_ENV=production
OMNIROUTE_DISABLE_BACKGROUND_SERVICES=true
APP_LOG_TO_FILE=false
```
### VPS with Reverse Proxy (nginx + Cloudflare)
```bash
JWT_SECRET=<generated>
API_KEY_SECRET=<generated>
STORAGE_ENCRYPTION_KEY=<generated>
PORT=20128
AUTH_COOKIE_SECURE=true
REQUIRE_API_KEY=true
NEXT_PUBLIC_BASE_URL=https://omniroute.example.com
BASE_URL=http://127.0.0.1:20128
CORS_ORIGIN=https://omniroute.example.com
ENABLE_TLS_FINGERPRINT=true
CLI_COMPAT_ALL=1
```
---
## Audit: Removed / Dead Variables
The following variables appeared in previous versions of `.env.example` but have **no runtime references** in the current codebase. They have been removed:
| Variable | Reason |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `STORAGE_DRIVER=sqlite` | Never read by any source file. SQLite is the only supported driver — no selection needed. |
| `INSTANCE_NAME=omniroute` | Present in old docs/env templates but unused at runtime. May return in a future multi-instance feature. |
| `SQLITE_MAX_SIZE_MB=2048` | Not referenced in source code. Database size is not artificially limited. |
| `SQLITE_CLEAN_LEGACY_FILES=true` | Not referenced in source code. Legacy cleanup was likely removed. |
| `CLI_ROO_BIN` | Not registered in `src/shared/services/cliRuntime.ts`. |
| `CLI_KIMI_CODING_BIN` | Not registered in `src/shared/services/cliRuntime.ts` (Kimi Coding uses OAuth, not a CLI binary). |
| `IFLOW_OAUTH_CLIENT_ID` / `IFLOW_OAUTH_CLIENT_SECRET` | Not referenced anywhere in source code. |
### Default Value Corrections
| Variable | Old `.env.example` Value | Actual Code Default | Fixed |
| ------------------------- | ------------------------ | ------------------- | ------------------------------------------------------ |
| `APP_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Removed misleading value; documented `7` as default |
| `CALL_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Removed misleading value; documented `7` as default |
+8 -1
View File
@@ -18,6 +18,13 @@ Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI)
Create model routing combos with 13 strategies: priority, weighted, round-robin, random, least-used, cost-optimized, strict-random, auto, fill-first, p2c, lkgp, context-optimized, and **context-relay**. Each combo chains multiple models with automatic fallback and includes quick templates and readiness checks.
Recent combo improvements:
- **Structured combo builder** — create each step by selecting provider, model, and exact account/connection
- **Repeated provider support** — reuse the same provider many times in one combo as long as the `(provider, model, connection)` tuple is unique
- **Combo target health** — analytics and health surfaces now distinguish individual combo targets/steps instead of collapsing everything into model strings
- **Composite tier ordering**`defaultTier -> fallbackTier` now influences runtime execution/fallback order for top-level combo steps
![Combos Dashboard](screenshots/02-combos.png)
---
@@ -32,7 +39,7 @@ Comprehensive usage analytics with token consumption, cost estimates, activity h
## 🏥 System Health
Real-time monitoring: uptime, memory, version, latency percentiles (p50/p95/p99), cache statistics, and provider circuit breaker states.
Real-time monitoring: uptime, memory, version, latency percentiles (p50/p95/p99), cache statistics, provider circuit breaker states, active quota-monitored sessions, and combo target health.
![Health Dashboard](screenshots/04-health.png)
+155
View File
@@ -0,0 +1,155 @@
# OmniRoute — Uninstall Guide
🌐 **Languages:** 🇺🇸 [English](UNINSTALL.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/UNINSTALL.md) | 🇪🇸 [Español](i18n/es/UNINSTALL.md) | 🇫🇷 [Français](i18n/fr/UNINSTALL.md) | 🇮🇹 [Italiano](i18n/it/UNINSTALL.md) | 🇷🇺 [Русский](i18n/ru/UNINSTALL.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/UNINSTALL.md) | 🇩🇪 [Deutsch](i18n/de/UNINSTALL.md) | 🇮🇳 [हिन्दी](i18n/in/UNINSTALL.md) | 🇹🇭 [ไทย](i18n/th/UNINSTALL.md) | 🇺🇦 [Українська](i18n/uk-UA/UNINSTALL.md) | 🇸🇦 [العربية](i18n/ar/UNINSTALL.md) | 🇯🇵 [日本語](i18n/ja/UNINSTALL.md) | 🇻🇳 [Tiếng Việt](i18n/vi/UNINSTALL.md) | 🇧🇬 [Български](i18n/bg/UNINSTALL.md) | 🇩🇰 [Dansk](i18n/da/UNINSTALL.md) | 🇫🇮 [Suomi](i18n/fi/UNINSTALL.md) | 🇮🇱 [עברית](i18n/he/UNINSTALL.md) | 🇭🇺 [Magyar](i18n/hu/UNINSTALL.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/UNINSTALL.md) | 🇰🇷 [한국어](i18n/ko/UNINSTALL.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/UNINSTALL.md) | 🇳🇱 [Nederlands](i18n/nl/UNINSTALL.md) | 🇳🇴 [Norsk](i18n/no/UNINSTALL.md) | 🇵🇹 [Português (Portugal)](i18n/pt/UNINSTALL.md) | 🇷🇴 [Română](i18n/ro/UNINSTALL.md) | 🇵🇱 [Polski](i18n/pl/UNINSTALL.md) | 🇸🇰 [Slovenčina](i18n/sk/UNINSTALL.md) | 🇸🇪 [Svenska](i18n/sv/UNINSTALL.md) | 🇵🇭 [Filipino](i18n/phi/UNINSTALL.md) | 🇨🇿 [Čeština](i18n/cs/UNINSTALL.md)
This guide covers how to cleanly remove OmniRoute from your system.
---
## Quick Uninstall (v3.6.2+)
OmniRoute provides two built-in scripts for clean removal:
### Keep Your Data
```bash
npm run uninstall
```
This removes the OmniRoute application but **preserves** your database, configurations, API keys, and provider settings in `~/.omniroute/`. Use this if you plan to reinstall later and want to keep your setup.
### Full Removal
```bash
npm run uninstall:full
```
This removes the application **and permanently erases** all data:
- Database (`storage.sqlite`)
- Provider configurations and API keys
- Backup files
- Log files
- All files in the `~/.omniroute/` directory
> ⚠️ **Warning:** `npm run uninstall:full` is irreversible. All your provider connections, combos, API keys, and usage history will be permanently deleted.
---
## Manual Uninstall
### NPM Global Install
```bash
# Remove the global package
npm uninstall -g omniroute
# (Optional) Remove data directory
rm -rf ~/.omniroute
```
### pnpm Global Install
```bash
pnpm uninstall -g omniroute
rm -rf ~/.omniroute
```
### Docker
```bash
# Stop and remove the container
docker stop omniroute
docker rm omniroute
# Remove the volume (deletes all data)
docker volume rm omniroute-data
# (Optional) Remove the image
docker rmi diegosouzapw/omniroute:latest
```
### Docker Compose
```bash
# Stop and remove containers
docker compose down
# Also remove volumes (deletes all data)
docker compose down -v
```
### Electron Desktop App
**Windows:**
- Open `Settings → Apps → OmniRoute → Uninstall`
- Or run the NSIS uninstaller from the install directory
**macOS:**
- Drag `OmniRoute.app` from `/Applications` to Trash
- Remove data: `rm -rf ~/Library/Application Support/omniroute`
**Linux:**
- Remove the AppImage file
- Remove data: `rm -rf ~/.omniroute`
### Source Install (git clone)
```bash
# Remove the cloned directory
rm -rf /path/to/omniroute
# (Optional) Remove data directory
rm -rf ~/.omniroute
```
---
## Data Directories
OmniRoute stores data in the following locations by default:
| Platform | Default Path | Override |
| ------------- | ----------------------------- | ------------------------- |
| Linux | `~/.omniroute/` | `DATA_DIR` env var |
| macOS | `~/.omniroute/` | `DATA_DIR` env var |
| Windows | `%APPDATA%/omniroute/` | `DATA_DIR` env var |
| Docker | `/app/data/` (mounted volume) | `DATA_DIR` env var |
| XDG-compliant | `$XDG_CONFIG_HOME/omniroute/` | `XDG_CONFIG_HOME` env var |
### Files in the data directory
| File/Directory | Description |
| -------------------- | ------------------------------------------------- |
| `storage.sqlite` | Main database (providers, combos, settings, keys) |
| `storage.sqlite-wal` | SQLite write-ahead log (temporary) |
| `storage.sqlite-shm` | SQLite shared memory (temporary) |
| `call_logs/` | Request payload archives |
| `backups/` | Automatic database backups |
| `log.txt` | Legacy request log (optional) |
---
## Verify Complete Removal
After uninstalling, verify there are no remaining files:
```bash
# Check for global npm package
npm list -g omniroute 2>/dev/null
# Check for data directory
ls -la ~/.omniroute/ 2>/dev/null
# Check for running processes
pgrep -f omniroute
```
If any process is still running, stop it:
```bash
pkill -f omniroute
```
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.6.2
version: 3.6.4
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,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.6.2",
"version": "3.6.4",
"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",
+1
View File
@@ -40,6 +40,7 @@ const eslintConfig = [
"node_modules/**",
// VS Code extension and its large test fixtures
"vscode-extension/**",
"_mono_repo/**",
// Electron app
"electron/**",
// Docs
+2 -2
View File
@@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
**Current version:** 3.6.0
**Current version:** 3.6.4
## Tech Stack
@@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
└── .env.example # Environment variable template
```
## Key Features (v3.6.0)
## Key Features (v3.6.4)
### Core Proxy
- **60+ AI providers** with automatic format translation
+8 -6
View File
@@ -67,10 +67,10 @@ const nextConfig = {
//
// We use two strategies:
// 1. Exact-name externals for all known server-side packages.
// 2. Hash-strip catch-all: any require('<name>-<16hexchars>' strips the
// suffix and falls through to the real package name.
// 2. Hash-strip catch-all: any require('<name>-<16hexchars>[/subpath]')
// strips the hash suffix and falls through to the real package name.
//
const HASH_PATTERN = /^(.+)-[0-9a-f]{16}$/;
const HASH_PATTERN = /^(.+)-[0-9a-f]{16}(\/.*)?$/;
const KNOWN_EXTERNALS = new Set([
"better-sqlite3",
@@ -102,13 +102,15 @@ const nextConfig = {
if (KNOWN_EXTERNALS.has(request)) {
return callback(null, `commonjs ${request}`);
}
// Case 2: Hash-suffixed name — strip hash, use base name
// Case 2: Hash-suffixed name — strip hash, preserve subpath
// e.g. "better-sqlite3-90e2652d1716b047" → "better-sqlite3"
// "zod-dcb22c6336e0bc69" → "zod"
// "zod-dcb22c6336e0bc69/v3" → "zod/v3"
// "zod-dcb22c6336e0bc69/v4-mini" → "zod/v4-mini"
const hashMatch = request?.match?.(HASH_PATTERN);
if (hashMatch) {
const baseName = hashMatch[1];
return callback(null, `commonjs ${baseName}`);
const resolved = hashMatch[2] ? `${hashMatch[1]}${hashMatch[2]}` : hashMatch[1];
return callback(null, `commonjs ${resolved}`);
}
callback();
},
+2
View File
@@ -14,6 +14,8 @@ export interface RegistryModel {
id: string;
name: string;
toolCalling?: boolean;
supportsReasoning?: boolean;
supportsVision?: boolean;
targetFormat?: string;
unsupportedParams?: readonly string[];
/** Maximum context window in tokens */
+1 -3
View File
@@ -206,9 +206,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;
}
+1 -1
View File
@@ -179,7 +179,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.
+2 -2
View File
@@ -13,7 +13,7 @@ interface ServiceAccount {
const TOKEN_CACHE = new Map<string, { token: string; expiresAt: number }>();
function parseSAFromApiKey(apiKey: string): ServiceAccount {
export function parseSAFromApiKey(apiKey: string): ServiceAccount {
try {
return JSON.parse(apiKey);
} catch {
@@ -21,7 +21,7 @@ function parseSAFromApiKey(apiKey: string): ServiceAccount {
}
}
async function getAccessToken(sa: ServiceAccount): Promise<string> {
export async function getAccessToken(sa: ServiceAccount): Promise<string> {
if (!sa.client_email || !sa.private_key) {
throw new Error(
"Service Account JSON is missing required fields (client_email or private_key)"
+9 -1
View File
@@ -479,6 +479,8 @@ export async function handleChatCore({
comboName,
comboStrategy = null,
isCombo = false,
comboStepId = null,
comboExecutionKey = null,
disableEmergencyFallback = false,
}) {
let { provider, model, extendedContext } = modelInfo;
@@ -746,6 +748,8 @@ export async function handleChatCore({
sourceFormat,
targetFormat,
comboName,
comboStepId,
comboExecutionKey,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
noLog: noLogEnabled,
@@ -808,6 +812,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 +957,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
+15 -4
View File
@@ -12,6 +12,11 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
getComboModelProvider,
getComboModelString,
getComboStepTarget,
} from "../../src/lib/combos/steps.ts";
import {
MCP_TOOLS,
@@ -105,11 +110,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),
};
});
}
+7 -2
View File
@@ -18,6 +18,11 @@
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 +85,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),
}));
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.6.2",
"version": "3.6.4",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",
+53 -2
View File
@@ -76,6 +76,50 @@ export function registerCodexConnection(connectionId: string, meta: CodexConnect
connectionRegistry.set(connectionId, meta);
}
function getCodexConnectionMeta(
connectionId: string,
connection?: Record<string, unknown>
): CodexConnectionMeta | null {
if (connection && typeof connection === "object") {
const providerSpecificData =
connection.providerSpecificData &&
typeof connection.providerSpecificData === "object" &&
!Array.isArray(connection.providerSpecificData)
? (connection.providerSpecificData as Record<string, unknown>)
: {};
const accessToken =
typeof connection.accessToken === "string" && connection.accessToken.trim().length > 0
? connection.accessToken
: null;
const workspaceId =
typeof providerSpecificData.workspaceId === "string" &&
providerSpecificData.workspaceId.trim().length > 0
? providerSpecificData.workspaceId
: undefined;
if (accessToken) {
const meta = { accessToken, ...(workspaceId ? { workspaceId } : {}) };
connectionRegistry.set(connectionId, meta);
return meta;
}
}
return connectionRegistry.get(connectionId) || null;
}
function getDominantResetAt(quota: {
window5h: { percentUsed: number; resetAt: string | null };
window7d: { percentUsed: number; resetAt: string | null };
}): string | null {
if (quota.window7d.percentUsed > quota.window5h.percentUsed) {
return quota.window7d.resetAt || quota.window5h.resetAt;
}
if (quota.window5h.percentUsed > quota.window7d.percentUsed) {
return quota.window5h.resetAt || quota.window7d.resetAt;
}
return quota.window7d.resetAt || quota.window5h.resetAt;
}
// ─── Core Fetcher ────────────────────────────────────────────────────────────
/**
@@ -85,7 +129,10 @@ export function registerCodexConnection(connectionId: string, meta: CodexConnect
* @param connectionId - Connection ID from the DB (used to look up credentials)
* @returns QuotaInfo or null if fetch fails / no credentials
*/
export async function fetchCodexQuota(connectionId: string): Promise<QuotaInfo | null> {
export async function fetchCodexQuota(
connectionId: string,
connection?: Record<string, unknown>
): Promise<QuotaInfo | null> {
// Check cache first
const cached = quotaCache.get(connectionId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
@@ -93,7 +140,7 @@ export async function fetchCodexQuota(connectionId: string): Promise<QuotaInfo |
}
// Look up credentials
const meta = connectionRegistry.get(connectionId);
const meta = getCodexConnectionMeta(connectionId, connection);
if (!meta?.accessToken) {
// No credentials registered — skip preflight gracefully
return null;
@@ -206,6 +253,10 @@ function parseCodexUsageResponse(data: unknown): CodexDualWindowQuota | null {
used: worstPercentUsed,
total: 100,
percentUsed: percentUsedNormalized,
resetAt: getDominantResetAt({
window5h: { percentUsed: usedPercent5h / 100, resetAt: resetAt5h },
window7d: { percentUsed: usedPercent7d / 100, resetAt: resetAt7d },
}),
window5h: { percentUsed: usedPercent5h / 100, resetAt: resetAt5h },
window7d: { percentUsed: usedPercent7d / 100, resetAt: resetAt7d },
limitReached,
+562 -151
View File
File diff suppressed because it is too large Load Diff
+175 -69
View File
@@ -1,7 +1,7 @@
/**
* In-memory combo metrics tracker
* Tracks per-combo and per-model request counts, latency, success/failure rates
* Provides API for reading metrics from the dashboard
* Tracks per-combo, per-model, and per-target request counts, latency, success/failure rates.
* Provides API for reading metrics from the dashboard.
*/
interface ModelMetrics {
@@ -13,6 +13,16 @@ interface ModelMetrics {
lastUsedAt: string | null;
}
interface ComboTargetMetrics extends ModelMetrics {
executionKey: string;
stepId: string | null;
model: string;
provider: string | null;
providerId: string | null;
connectionId: string | null;
label: string | null;
}
interface ComboMetricsEntry {
totalRequests: number;
totalSuccesses: number;
@@ -23,33 +33,140 @@ interface ComboMetricsEntry {
lastUsedAt: string | null;
intentCounts: Record<string, number>;
byModel: Record<string, ModelMetrics>;
byTarget: Record<string, ComboTargetMetrics>;
}
interface ModelMetricsView extends ModelMetrics {
avgLatencyMs: number;
successRate: number;
}
interface ComboTargetMetricsView extends ComboTargetMetrics {
avgLatencyMs: number;
successRate: number;
}
interface ComboMetricsView extends ComboMetricsEntry {
avgLatencyMs: number;
successRate: number;
fallbackRate: number;
byModel: Record<
string,
ModelMetrics & {
avgLatencyMs: number;
successRate: number;
}
>;
byModel: Record<string, ModelMetricsView>;
byTarget: Record<string, ComboTargetMetricsView>;
}
export interface ComboRequestTargetMeta {
executionKey?: string | null;
stepId?: string | null;
provider?: string | null;
providerId?: string | null;
connectionId?: string | null;
label?: string | null;
}
function toNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function inferProvider(modelStr: string | null): string | null {
const model = toNonEmptyString(modelStr);
if (!model) return null;
const [provider] = model.split("/");
return toNonEmptyString(provider);
}
function createModelMetrics(): ModelMetrics {
return {
requests: 0,
successes: 0,
failures: 0,
totalLatencyMs: 0,
lastStatus: null,
lastUsedAt: null,
};
}
function createComboEntry(strategy: string): ComboMetricsEntry {
return {
totalRequests: 0,
totalSuccesses: 0,
totalFailures: 0,
totalFallbacks: 0,
totalLatencyMs: 0,
strategy,
lastUsedAt: null,
intentCounts: {},
byModel: {},
byTarget: {},
};
}
function applyMetricOutcome(
metric: ModelMetrics,
success: boolean,
latencyMs: number,
usedAt: string
): void {
metric.requests++;
metric.totalLatencyMs += latencyMs;
metric.lastUsedAt = usedAt;
if (success) {
metric.successes++;
metric.lastStatus = "ok";
return;
}
metric.failures++;
metric.lastStatus = "error";
}
function buildTargetMetric(
modelStr: string,
target: ComboRequestTargetMeta
): ComboTargetMetrics | null {
const executionKey = toNonEmptyString(target.executionKey) || toNonEmptyString(modelStr);
const model = toNonEmptyString(modelStr);
if (!executionKey || !model) return null;
return {
executionKey,
stepId: toNonEmptyString(target.stepId),
model,
provider: toNonEmptyString(target.provider) || inferProvider(model),
providerId: toNonEmptyString(target.providerId),
connectionId:
target.connectionId === null ? null : (toNonEmptyString(target.connectionId) ?? null),
label: target.label === null ? null : (toNonEmptyString(target.label) ?? null),
...createModelMetrics(),
};
}
function toMetricView<T extends ModelMetrics>(
metric: T
): T & {
avgLatencyMs: number;
successRate: number;
} {
return {
...metric,
avgLatencyMs: metric.requests > 0 ? Math.round(metric.totalLatencyMs / metric.requests) : 0,
successRate: metric.requests > 0 ? Math.round((metric.successes / metric.requests) * 100) : 0,
};
}
// In-memory store
const metrics = new Map<string, ComboMetricsEntry>();
/**
* Record a combo request result
* Record a combo request result.
* @param {string} comboName
* @param {string} modelStr - The model that handled the request (or null if all failed)
* @param {Object} options
* @param {boolean} options.success
* @param {number} options.latencyMs
* @param {number} options.fallbackCount - How many fallbacks occurred
* @param {string} [options.strategy] - "priority" or "weighted"
* @param {string} [options.strategy] - Routing strategy name
* @param {Object} [options.target] - Step/execution metadata for structured combos
*/
export function recordComboRequest(
comboName: string,
@@ -59,28 +176,27 @@ export function recordComboRequest(
latencyMs,
fallbackCount = 0,
strategy = "priority",
}: { success: boolean; latencyMs: number; fallbackCount?: number; strategy?: string }
target,
}: {
success: boolean;
latencyMs: number;
fallbackCount?: number;
strategy?: string;
target?: ComboRequestTargetMeta | null;
}
): void {
if (!metrics.has(comboName)) {
metrics.set(comboName, {
totalRequests: 0,
totalSuccesses: 0,
totalFailures: 0,
totalFallbacks: 0,
totalLatencyMs: 0,
strategy,
lastUsedAt: null,
intentCounts: {},
byModel: {},
});
metrics.set(comboName, createComboEntry(strategy));
}
const combo = metrics.get(comboName);
if (!combo) return;
const usedAt = new Date().toISOString();
combo.totalRequests++;
combo.totalLatencyMs += latencyMs;
combo.totalFallbacks += fallbackCount;
combo.lastUsedAt = new Date().toISOString();
combo.lastUsedAt = usedAt;
combo.strategy = strategy;
if (success) {
@@ -89,35 +205,36 @@ export function recordComboRequest(
combo.totalFailures++;
}
// Per-model tracking
if (modelStr) {
if (!combo.byModel[modelStr]) {
combo.byModel[modelStr] = {
requests: 0,
successes: 0,
failures: 0,
totalLatencyMs: 0,
lastStatus: null,
lastUsedAt: null,
};
}
const modelMetric = combo.byModel[modelStr];
modelMetric.requests++;
modelMetric.totalLatencyMs += latencyMs;
modelMetric.lastUsedAt = new Date().toISOString();
if (!modelStr) return;
if (success) {
modelMetric.successes++;
modelMetric.lastStatus = "ok";
} else {
modelMetric.failures++;
modelMetric.lastStatus = "error";
}
if (!combo.byModel[modelStr]) {
combo.byModel[modelStr] = createModelMetrics();
}
applyMetricOutcome(combo.byModel[modelStr], success, latencyMs, usedAt);
const targetMetric = buildTargetMetric(modelStr, target || {});
if (!targetMetric) return;
if (!combo.byTarget[targetMetric.executionKey]) {
combo.byTarget[targetMetric.executionKey] = targetMetric;
}
const existingTargetMetric = combo.byTarget[targetMetric.executionKey];
existingTargetMetric.stepId = targetMetric.stepId || existingTargetMetric.stepId;
existingTargetMetric.provider = targetMetric.provider || existingTargetMetric.provider;
existingTargetMetric.providerId = targetMetric.providerId || existingTargetMetric.providerId;
existingTargetMetric.connectionId =
target?.connectionId === null
? null
: (targetMetric.connectionId ?? existingTargetMetric.connectionId);
existingTargetMetric.label =
target?.label === null ? null : (targetMetric.label ?? existingTargetMetric.label);
applyMetricOutcome(existingTargetMetric, success, latencyMs, usedAt);
}
/**
* Get metrics for a specific combo
* Get metrics for a specific combo.
* @param {string} comboName
* @returns {Object|null}
*/
@@ -135,20 +252,19 @@ export function getComboMetrics(comboName: string): ComboMetricsView | null {
combo.totalRequests > 0 ? Math.round((combo.totalFallbacks / combo.totalRequests) * 100) : 0,
intentCounts: { ...combo.intentCounts },
byModel: Object.fromEntries(
Object.entries(combo.byModel).map(([model, m]) => [
model,
{
...m,
avgLatencyMs: m.requests > 0 ? Math.round(m.totalLatencyMs / m.requests) : 0,
successRate: m.requests > 0 ? Math.round((m.successes / m.requests) * 100) : 0,
},
Object.entries(combo.byModel).map(([model, metric]) => [model, toMetricView(metric)])
),
byTarget: Object.fromEntries(
Object.entries(combo.byTarget).map(([executionKey, metric]) => [
executionKey,
toMetricView(metric),
])
),
};
}
/**
* Get metrics for all combos
* Get metrics for all combos.
* @returns {Object} Map of comboName metrics
*/
export function getAllComboMetrics(): Record<string, ComboMetricsView | null> {
@@ -164,17 +280,7 @@ export function getAllComboMetrics(): Record<string, ComboMetricsView | null> {
*/
export function recordComboIntent(comboName: string, intent: string): void {
if (!metrics.has(comboName)) {
metrics.set(comboName, {
totalRequests: 0,
totalSuccesses: 0,
totalFailures: 0,
totalFallbacks: 0,
totalLatencyMs: 0,
strategy: "priority",
lastUsedAt: null,
intentCounts: {},
byModel: {},
});
metrics.set(comboName, createComboEntry("priority"));
}
const combo = metrics.get(comboName);
@@ -184,14 +290,14 @@ export function recordComboIntent(comboName: string, intent: string): void {
}
/**
* Reset metrics for a specific combo
* Reset metrics for a specific combo.
*/
export function resetComboMetrics(comboName: string): void {
metrics.delete(comboName);
}
/**
* Reset all combo metrics
* Reset all combo metrics.
*/
export function resetAllComboMetrics(): void {
metrics.clear();
+1 -1
View File
@@ -6,7 +6,7 @@
*/
import { REGISTRY } from "../config/providerRegistry.ts";
import { getModelContextLimit } from "../../src/lib/modelsDevSync";
import { getModelContextLimit } from "../../src/lib/modelCapabilities";
// Default token limits per provider (fallbacks when not in registry)
const DEFAULT_LIMITS: Record<string, number> = {
+5 -1
View File
@@ -17,12 +17,16 @@ export function isEmptyContentResponse(responseBody: unknown): boolean {
const delta = firstChoice.delta as Record<string, unknown> | undefined;
const content = message?.content ?? delta?.content;
const reasoningContent = message?.reasoning_content ?? delta?.reasoning_content;
const hasToolCalls =
(Array.isArray(message?.tool_calls) && (message.tool_calls as unknown[]).length > 0) ||
(Array.isArray(delta?.tool_calls) && (delta.tool_calls as unknown[]).length > 0);
const hasContent = content !== null && content !== undefined && content !== "";
return !hasContent && !hasToolCalls;
const hasReasoning =
reasoningContent !== null && reasoningContent !== undefined && reasoningContent !== "";
return !hasContent && !hasReasoning && !hasToolCalls;
}
if (Array.isArray(body.content)) {
+19
View File
@@ -71,6 +71,25 @@ function resolveProviderModelAlias(providerOrAlias, modelId) {
return aliases?.[modelId] || modelId;
}
/**
* Resolve a provider/model pair into canonical provider ID + provider-scoped model ID.
* Keeps provider-specific legacy aliases out of downstream capability and budget lookups.
*/
export function resolveCanonicalProviderModel(providerOrAlias, modelId) {
if (!modelId || typeof modelId !== "string") {
return {
provider: resolveProviderAlias(providerOrAlias),
model: modelId || null,
};
}
const provider = resolveProviderAlias(providerOrAlias);
return {
provider,
model: resolveProviderModelAlias(provider, modelId),
};
}
/**
* Parse model string: "alias/model" or "provider/model" or just alias
* Supports [1m] suffix for extended 1M context window (e.g. "claude-sonnet-4-6[1m]")
+5 -92
View File
@@ -1,92 +1,5 @@
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
import { parseModel } from "./model.ts";
// Conservative denylist fallback used when registry metadata is absent.
// Keep small and explicit to avoid false negatives.
const TOOL_CALLING_UNSUPPORTED_PATTERNS = ["gpt-oss-120b", "deepseek-reasoner"];
function getRegistryToolCallingFlag(providerIdOrAlias: string, modelId: string): boolean | null {
const providerAlias = PROVIDER_ID_TO_ALIAS[providerIdOrAlias] || providerIdOrAlias;
const models = PROVIDER_MODELS[providerAlias];
if (!Array.isArray(models)) return null;
const found = models.find((m) => m?.id === modelId);
if (!found) return null;
return typeof found.toolCalling === "boolean" ? found.toolCalling : null;
}
/**
* Returns whether a model should be considered safe for structured function/tool calling.
*
* Decision order:
* 1) Provider registry metadata (toolCalling flag) when available.
* 2) Conservative denylist fallback for known problematic model families.
* 3) Default true.
*/
export function supportsToolCalling(modelStr: string): boolean {
const parsed = parseModel(modelStr);
const provider = parsed.provider || parsed.providerAlias || "";
const model = parsed.model || modelStr;
if (provider) {
const fromRegistry = getRegistryToolCallingFlag(provider, model);
if (fromRegistry !== null) return fromRegistry;
}
const normalized = String(modelStr || "").toLowerCase();
if (!normalized) return false;
const blocked = TOOL_CALLING_UNSUPPORTED_PATTERNS.some((pattern) => {
if (normalized === pattern) return true;
if (normalized.endsWith(`/${pattern}`)) return true;
return normalized.includes(pattern);
});
return !blocked;
}
// Models that do NOT support reasoning/thinking parameters.
// AG (Antigravity) claude-sonnet-4-6 routes through a Google internal API
// that returns 400 if thinking params are included.
const REASONING_UNSUPPORTED_PATTERNS = [
"antigravity/claude-sonnet-4-6",
"antigravity/claude-sonnet-4-5",
"antigravity/claude-sonnet-4",
];
function getRegistryReasoningFlag(providerIdOrAlias: string, modelId: string): boolean | null {
const providerAlias = PROVIDER_ID_TO_ALIAS[providerIdOrAlias] || providerIdOrAlias;
const models = PROVIDER_MODELS[providerAlias];
if (!Array.isArray(models)) return null;
const found = models.find((m) => m?.id === modelId);
if (!found) return null;
return typeof found.supportsReasoning === "boolean" ? found.supportsReasoning : null;
}
/**
* Returns whether a model supports reasoning/thinking parameters.
*
* Decision order:
* 1) Provider registry metadata (supportsReasoning flag) when available.
* 2) Explicit denylist for known unsupported models (e.g. AG Claude Sonnet).
* 3) Default true (pass through safe, provider will ignore if unsupported).
*/
export function supportsReasoning(modelStr: string): boolean {
const parsed = parseModel(modelStr);
const provider = parsed.provider || parsed.providerAlias || "";
const model = parsed.model || modelStr;
if (provider) {
const fromRegistry = getRegistryReasoningFlag(provider, model);
if (fromRegistry !== null) return fromRegistry;
}
const normalized = String(modelStr || "").toLowerCase();
if (!normalized) return true;
const blocked = REASONING_UNSUPPORTED_PATTERNS.some(
(pattern) =>
normalized === pattern || normalized.endsWith(`/${pattern}`) || normalized.includes(pattern)
);
return !blocked;
}
export {
getResolvedModelCapabilities,
supportsReasoning,
supportsToolCalling,
} from "../../src/lib/modelCapabilities.ts";
+1 -1
View File
@@ -11,7 +11,7 @@
* (commit 6cea566, Mar 8 2026).
*/
import { getModelContextLimit } from "../../src/lib/modelsDevSync";
import { getModelContextLimit } from "../../src/lib/modelCapabilities";
import { parseModel } from "./model.ts";
import { CONTEXT_OVERFLOW_REGEX } from "./errorClassifier.ts";
+245 -14
View File
@@ -9,6 +9,7 @@
*/
import { registerQuotaFetcher, type QuotaFetcher } from "./quotaPreflight.ts";
import { getSessionInfo } from "./sessionManager.ts";
export { registerQuotaFetcher };
export type { QuotaFetcher };
@@ -24,6 +25,55 @@ interface MonitorState {
stopped: boolean;
provider: string;
accountId: string;
connectionSnapshot: Record<string, unknown> | null;
sessionBound: boolean;
status: "starting" | "idle" | "healthy" | "warning" | "exhausted" | "error";
startedAt: number;
lastPolledAt: number | null;
lastSuccessAt: number | null;
lastErrorAt: number | null;
lastError: string | null;
lastQuotaPercent: number | null;
lastQuotaUsed: number | null;
lastQuotaTotal: number | null;
lastResetAt: string | null;
lastAlertAt: number | null;
nextPollDelayMs: number | null;
nextPollAt: number | null;
totalPolls: number;
totalAlerts: number;
consecutiveFailures: number;
}
export interface QuotaMonitorSnapshot {
sessionId: string;
provider: string;
accountId: string;
status: "starting" | "idle" | "healthy" | "warning" | "exhausted" | "error";
startedAt: string;
lastPolledAt: string | null;
lastSuccessAt: string | null;
lastErrorAt: string | null;
lastError: string | null;
lastQuotaPercent: number | null;
lastQuotaUsed: number | null;
lastQuotaTotal: number | null;
lastResetAt: string | null;
lastAlertAt: string | null;
nextPollDelayMs: number | null;
nextPollAt: string | null;
totalPolls: number;
totalAlerts: number;
consecutiveFailures: number;
}
export interface QuotaMonitorSummary {
active: number;
alerting: number;
exhausted: number;
errors: number;
statusCounts: Record<QuotaMonitorSnapshot["status"], number>;
byProvider: Record<string, number>;
}
const activeMonitors = new Map<string, MonitorState>();
@@ -45,47 +95,148 @@ function suppressedAlert(
provider: string,
accountId: string,
percentUsed: number
): void {
): boolean {
const key = `${sessionId}:${provider}:${accountId}`;
const last = alertSuppression.get(key) ?? 0;
if (Date.now() - last < ALERT_SUPPRESS_WINDOW_MS) return;
if (Date.now() - last < ALERT_SUPPRESS_WINDOW_MS) return false;
alertSuppression.set(key, Date.now());
console.warn(
`[QuotaMonitor] session=${sessionId} ${provider}/${accountId}: ${(percentUsed * 100).toFixed(1)}% quota used`
);
return true;
}
function toIsoTimestamp(value: number | null): string | null {
return typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : null;
}
function getMonitorStatus(percentUsed: number | null): MonitorState["status"] {
if (!Number.isFinite(percentUsed)) return "idle";
if ((percentUsed as number) >= EXHAUSTION_THRESHOLD) return "exhausted";
if ((percentUsed as number) >= WARN_THRESHOLD) return "warning";
return "healthy";
}
function toPublicSnapshot(sessionId: string, state: MonitorState): QuotaMonitorSnapshot {
return {
sessionId,
provider: state.provider,
accountId: state.accountId,
status: state.status,
startedAt: new Date(state.startedAt).toISOString(),
lastPolledAt: toIsoTimestamp(state.lastPolledAt),
lastSuccessAt: toIsoTimestamp(state.lastSuccessAt),
lastErrorAt: toIsoTimestamp(state.lastErrorAt),
lastError: state.lastError,
lastQuotaPercent: state.lastQuotaPercent,
lastQuotaUsed: state.lastQuotaUsed,
lastQuotaTotal: state.lastQuotaTotal,
lastResetAt: state.lastResetAt,
lastAlertAt: toIsoTimestamp(state.lastAlertAt),
nextPollDelayMs: state.nextPollDelayMs,
nextPollAt: toIsoTimestamp(state.nextPollAt),
totalPolls: state.totalPolls,
totalAlerts: state.totalAlerts,
consecutiveFailures: state.consecutiveFailures,
};
}
function sortSnapshots(snapshots: QuotaMonitorSnapshot[]): QuotaMonitorSnapshot[] {
const severityRank: Record<QuotaMonitorSnapshot["status"], number> = {
exhausted: 5,
warning: 4,
error: 3,
starting: 2,
idle: 1,
healthy: 0,
};
return [...snapshots].sort((left, right) => {
const severityDelta = severityRank[right.status] - severityRank[left.status];
if (severityDelta !== 0) return severityDelta;
const quotaDelta = (right.lastQuotaPercent ?? -1) - (left.lastQuotaPercent ?? -1);
if (quotaDelta !== 0) return quotaDelta;
return (
(right.lastPolledAt ? Date.parse(right.lastPolledAt) : 0) -
(left.lastPolledAt ? Date.parse(left.lastPolledAt) : 0)
);
});
}
function scheduleNextPoll(sessionId: string, intervalMs: number): void {
const state = activeMonitors.get(sessionId);
if (!state || state.stopped) return;
state.nextPollDelayMs = intervalMs;
state.nextPollAt = Date.now() + intervalMs;
const { provider, accountId } = state;
const timer = setTimeout(async () => {
const current = activeMonitors.get(sessionId);
if (!current || current.stopped) return;
if (current.sessionBound && !getSessionInfo(sessionId)) {
stopQuotaMonitor(sessionId);
return;
}
try {
const fetcher = quotaFetcherRegistry.get(provider);
if (!fetcher) {
current.status = current.lastQuotaPercent === null ? "idle" : current.status;
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
return;
}
const quota = await fetcher(accountId);
const percentUsed = quota?.percentUsed ?? 0;
current.lastPolledAt = Date.now();
current.totalPolls += 1;
const previousStatus = current.status;
const quota = await fetcher(accountId, current.connectionSnapshot || undefined);
const percentUsed =
quota && typeof quota.percentUsed === "number" && Number.isFinite(quota.percentUsed)
? quota.percentUsed
: null;
current.lastSuccessAt = Date.now();
current.lastError = null;
current.lastErrorAt = null;
current.consecutiveFailures = 0;
current.lastQuotaPercent = percentUsed;
current.lastQuotaUsed =
quota && typeof quota.used === "number" && Number.isFinite(quota.used) ? quota.used : null;
current.lastQuotaTotal =
quota && typeof quota.total === "number" && Number.isFinite(quota.total)
? quota.total
: null;
current.lastResetAt =
quota && typeof quota.resetAt === "string" && quota.resetAt.trim().length > 0
? quota.resetAt
: null;
current.status = getMonitorStatus(percentUsed);
if (percentUsed >= EXHAUSTION_THRESHOLD) {
suppressedAlert(sessionId, provider, accountId, percentUsed);
console.info(
`[QuotaMonitor] session=${sessionId}: marking ${accountId} for next-session cooldown`
);
if (percentUsed !== null && percentUsed >= EXHAUSTION_THRESHOLD) {
const emittedAlert = suppressedAlert(sessionId, provider, accountId, percentUsed);
if (emittedAlert) {
current.lastAlertAt = Date.now();
current.totalAlerts += 1;
}
if (emittedAlert || previousStatus !== "exhausted") {
console.info(
`[QuotaMonitor] session=${sessionId}: marking ${accountId} for next-session cooldown`
);
}
scheduleNextPoll(sessionId, CRITICAL_INTERVAL_MS);
} else if (percentUsed >= WARN_THRESHOLD) {
suppressedAlert(sessionId, provider, accountId, percentUsed);
} else if (percentUsed !== null && percentUsed >= WARN_THRESHOLD) {
const emittedAlert = suppressedAlert(sessionId, provider, accountId, percentUsed);
if (emittedAlert) {
current.lastAlertAt = Date.now();
current.totalAlerts += 1;
}
scheduleNextPoll(sessionId, CRITICAL_INTERVAL_MS);
} else {
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
}
} catch {
} catch (error) {
current.lastErrorAt = Date.now();
current.lastError = error instanceof Error ? error.message : String(error);
current.consecutiveFailures += 1;
current.status = "error";
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
}
}, intervalMs);
@@ -101,9 +252,40 @@ export function startQuotaMonitor(
connection: Record<string, unknown>
): void {
if (!isQuotaMonitorEnabled(connection)) return;
if (activeMonitors.has(sessionId)) return;
const current = activeMonitors.get(sessionId);
if (current && !current.stopped) {
if (current.provider === provider && current.accountId === accountId) {
current.connectionSnapshot = connection;
current.sessionBound = current.sessionBound || getSessionInfo(sessionId) !== null;
return;
}
stopQuotaMonitor(sessionId);
}
activeMonitors.set(sessionId, { timer: null, stopped: false, provider, accountId });
activeMonitors.set(sessionId, {
timer: null,
stopped: false,
provider,
accountId,
connectionSnapshot: connection,
sessionBound: getSessionInfo(sessionId) !== null,
status: "starting",
startedAt: Date.now(),
lastPolledAt: null,
lastSuccessAt: null,
lastErrorAt: null,
lastError: null,
lastQuotaPercent: null,
lastQuotaUsed: null,
lastQuotaTotal: null,
lastResetAt: null,
lastAlertAt: null,
nextPollDelayMs: null,
nextPollAt: null,
totalPolls: 0,
totalAlerts: 0,
consecutiveFailures: 0,
});
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
}
@@ -124,3 +306,52 @@ export function stopQuotaMonitor(sessionId: string): void {
export function getActiveMonitorCount(): number {
return activeMonitors.size;
}
export function getQuotaMonitorSnapshot(sessionId: string): QuotaMonitorSnapshot | null {
const state = activeMonitors.get(sessionId);
if (!state || state.stopped) return null;
return toPublicSnapshot(sessionId, state);
}
export function getQuotaMonitorSnapshots(): QuotaMonitorSnapshot[] {
const snapshots: QuotaMonitorSnapshot[] = [];
for (const [sessionId, state] of activeMonitors) {
if (state.stopped) continue;
snapshots.push(toPublicSnapshot(sessionId, state));
}
return sortSnapshots(snapshots);
}
export function getQuotaMonitorSummary(): QuotaMonitorSummary {
const snapshots = getQuotaMonitorSnapshots();
const statusCounts: Record<QuotaMonitorSnapshot["status"], number> = {
starting: 0,
idle: 0,
healthy: 0,
warning: 0,
exhausted: 0,
error: 0,
};
const byProvider: Record<string, number> = {};
for (const snapshot of snapshots) {
statusCounts[snapshot.status] += 1;
byProvider[snapshot.provider] = (byProvider[snapshot.provider] || 0) + 1;
}
return {
active: snapshots.length,
alerting: statusCounts.warning + statusCounts.exhausted,
exhausted: statusCounts.exhausted,
errors: statusCounts.error,
statusCounts,
byProvider,
};
}
export function clearQuotaMonitors(): void {
for (const sessionId of [...activeMonitors.keys()]) {
stopQuotaMonitor(sessionId);
}
alertSuppression.clear();
}
+13 -3
View File
@@ -11,15 +11,20 @@ export interface PreflightQuotaResult {
proceed: boolean;
reason?: string;
quotaPercent?: number;
resetAt?: string | null;
}
export interface QuotaInfo {
used: number;
total: number;
percentUsed: number;
resetAt?: string | null;
}
export type QuotaFetcher = (connectionId: string) => Promise<QuotaInfo | null>;
export type QuotaFetcher = (
connectionId: string,
connection?: Record<string, unknown>
) => Promise<QuotaInfo | null>;
const EXHAUSTION_THRESHOLD = 0.95;
const WARN_THRESHOLD = 0.8;
@@ -51,7 +56,7 @@ export async function preflightQuota(
let quota: QuotaInfo | null = null;
try {
quota = await fetcher(connectionId);
quota = await fetcher(connectionId, connection);
} catch {
return { proceed: true };
}
@@ -66,7 +71,12 @@ export async function preflightQuota(
console.info(
`[QuotaPreflight] ${provider}/${connectionId}: ${(percentUsed * 100).toFixed(1)}% used — switching`
);
return { proceed: false, reason: "quota_exhausted", quotaPercent: percentUsed };
return {
proceed: false,
reason: "quota_exhausted",
quotaPercent: percentUsed,
resetAt: quota.resetAt ?? null,
};
}
if (percentUsed >= WARN_THRESHOLD) {
+9 -2
View File
@@ -13,8 +13,12 @@ export const ThinkingMode = {
ADAPTIVE: "adaptive", // Scale based on request complexity
};
import { capThinkingBudget, getDefaultThinkingBudget } from "@/shared/constants/modelSpecs";
import { supportsReasoning } from "./modelCapabilities.ts";
import {
capThinkingBudget,
getDefaultThinkingBudget,
getResolvedModelCapabilities,
supportsReasoning,
} from "@/lib/modelCapabilities";
// Effort → budget token mapping
export const EFFORT_BUDGETS = {
@@ -289,6 +293,9 @@ function applyAdaptiveBudget(body, cfg) {
*/
export function hasThinkingCapableModel(body) {
const model = body.model || "";
const resolved = getResolvedModelCapabilities(model);
if (resolved.supportsThinking === true) return true;
if (resolved.supportsThinking === false) return false;
return (
model.includes("claude") ||
model.includes("o1") ||
@@ -8,7 +8,7 @@ import {
capMaxOutputTokens,
capThinkingBudget,
getDefaultThinkingBudget,
} from "../../../src/shared/constants/modelSpecs.ts";
} from "../../../src/lib/modelCapabilities.ts";
import * as crypto from "crypto";
+164 -177
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.6.2",
"version": "3.6.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.6.2",
"version": "3.6.4",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -37,8 +37,8 @@
"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",
@@ -69,7 +69,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 +223,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 +2510,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 +2783,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 +6483,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 +6536,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.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
"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.1",
"@typescript-eslint/type-utils": "8.58.1",
"@typescript-eslint/utils": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -6580,7 +6559,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.58.0",
"@typescript-eslint/parser": "^8.58.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -6596,16 +6575,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.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz",
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"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.1",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1",
"debug": "^4.4.3"
},
"engines": {
@@ -6621,14 +6600,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.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz",
"integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.58.0",
"@typescript-eslint/types": "^8.58.0",
"@typescript-eslint/tsconfig-utils": "^8.58.1",
"@typescript-eslint/types": "^8.58.1",
"debug": "^4.4.3"
},
"engines": {
@@ -6643,14 +6622,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.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz",
"integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.0",
"@typescript-eslint/visitor-keys": "8.58.0"
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6661,9 +6640,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.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz",
"integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6678,15 +6657,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.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz",
"integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==",
"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.1",
"@typescript-eslint/typescript-estree": "8.58.1",
"@typescript-eslint/utils": "8.58.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -6703,9 +6682,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.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz",
"integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6717,16 +6696,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.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz",
"integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==",
"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.1",
"@typescript-eslint/tsconfig-utils": "8.58.1",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -6797,16 +6776,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.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz",
"integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==",
"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.1",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6821,13 +6800,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.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz",
"integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.0",
"@typescript-eslint/types": "8.58.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -7185,16 +7164,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 +7182,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 +7209,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 +7222,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 +7236,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 +7252,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 +7262,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"
},
@@ -10160,13 +10139,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 +13048,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",
@@ -16537,9 +16516,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 +17066,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 +17097,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 +19684,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.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz",
"integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==",
"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.1",
"@typescript-eslint/parser": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1",
"@typescript-eslint/utils": "8.58.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -19764,9 +19743,9 @@
}
},
"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 +20268,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 +20308,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 +20337,12 @@
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
@@ -20439,15 +20426,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"
},
@@ -20968,7 +20955,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.6.2"
"version": "3.6.4"
}
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.6.2",
"version": "3.6.4",
"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,8 +116,8 @@
"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",
@@ -147,7 +147,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",
+5 -1
View File
@@ -4,6 +4,10 @@ const dashboardPort = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
const dashboardBaseUrl = `http://localhost:${dashboardPort}`;
const webServerReadyUrl = `${dashboardBaseUrl}/api/monitoring/health`;
const playwrightServerMode = process.env.OMNIROUTE_PLAYWRIGHT_SERVER_MODE || "start";
const playwrightWebServerTimeout = Number.parseInt(
process.env.OMNIROUTE_PLAYWRIGHT_WEB_SERVER_TIMEOUT || "900000",
10
);
export default defineConfig({
testDir: "./tests/e2e",
@@ -33,6 +37,6 @@ export default defineConfig({
command: `node scripts/run-next-playwright.mjs ${playwrightServerMode}`,
url: webServerReadyUrl,
reuseExistingServer: !process.env.CI,
timeout: 300_000,
timeout: Number.isFinite(playwrightWebServerTimeout) ? playwrightWebServerTimeout : 900_000,
},
});
+12
View File
@@ -0,0 +1,12 @@
import { test } from "node:test";
import assert from "node:assert";
import { providerNodesValidateRoute } from "./src/app/api/provider-nodes/validate/route.js";
const req = new Request("http://localhost/api/provider-nodes/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl: "", apiKey: "" }),
});
const res = await providerNodesValidateRoute.POST(req);
const data = await res.json();
console.dir(data, { depth: null });
+8 -1
View File
@@ -52,7 +52,7 @@ function runNextBuild() {
const child = spawn(process.execPath, [nextBin, "build"], {
cwd: projectRoot,
stdio: "inherit",
env: process.env,
env: resolveNextBuildEnv(process.env),
});
const forward = (signal) => {
@@ -74,6 +74,13 @@ function runNextBuild() {
});
}
export function resolveNextBuildEnv(baseEnv = process.env) {
return {
...baseEnv,
NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0",
};
}
export async function main() {
let moved = false;
+4
View File
@@ -147,6 +147,10 @@ ensurePackage(
);
// removed better-sqlite3 to ensure ABI compatibility via electron-builder
const bundledSqlite = join(ELECTRON_STANDALONE_DIR, "node_modules", "better-sqlite3");
if (existsSync(bundledSqlite)) {
rmSync(bundledSqlite, { recursive: true, force: true });
}
console.log(
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`
+140 -31
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { existsSync, renameSync } from "node:fs";
import { join } from "node:path";
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync } from "node:fs";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import {
resolveRuntimePorts,
sanitizeColorEnv,
@@ -16,10 +17,19 @@ const cwd = process.cwd();
const appDir = join(cwd, "app");
const srcAppDir = join(cwd, "src", "app");
const appPage = join(appDir, "page.tsx");
const backupDir = join(cwd, "app.__qa_backup");
const defaultBackupDir = join(cwd, "app.__qa_backup");
const backupDir = resolvePlaywrightAppBackupDir({
cwd,
baseBackupExists: existsSync(defaultBackupDir),
appDirExists: existsSync(appDir),
});
const usingAlternativeBackupDir = backupDir !== defaultBackupDir;
const buildScript = join(cwd, "scripts", "build-next-isolated.mjs");
const standaloneServer = join(cwd, testDistDir(), "standalone", "server.js");
const buildIdFile = join(cwd, testDistDir(), "BUILD_ID");
const rootStaticDir = join(cwd, testDistDir(), "static");
const rootPublicDir = join(cwd, "public");
const standaloneStaticDir = join(cwd, testDistDir(), "standalone", ".next", "static");
const standalonePublicDir = join(cwd, testDistDir(), "standalone", "public");
let appDirMoved = false;
@@ -27,19 +37,90 @@ function testDistDir() {
return process.env.NEXT_DIST_DIR || ".next";
}
export function resolvePlaywrightAppBackupDir({
cwd,
baseBackupExists,
appDirExists,
pid = process.pid,
now = Date.now(),
}) {
const baseBackupDir = join(cwd, "app.__qa_backup");
if (!baseBackupExists || !appDirExists) {
return baseBackupDir;
}
return join(cwd, `app.__qa_backup.${pid}.${now}`);
}
function shouldMoveAppDir() {
return existsSync(appDir) && !existsSync(appPage) && existsSync(srcAppDir);
}
export function directoryHasEntries(dirPath) {
try {
return readdirSync(dirPath).length > 0;
} catch {
return false;
}
}
export function standaloneAssetsNeedSync({
standaloneServerPath,
rootStaticDirPath,
standaloneStaticDirPath,
}) {
return (
existsSync(standaloneServerPath) &&
existsSync(rootStaticDirPath) &&
!directoryHasEntries(standaloneStaticDirPath)
);
}
export function syncStandaloneRuntimeAssets({
standaloneServerPath,
rootStaticDirPath,
standaloneStaticDirPath,
rootPublicDirPath,
standalonePublicDirPath,
log = console,
}) {
if (!existsSync(standaloneServerPath)) return false;
let changed = false;
if (existsSync(rootPublicDirPath) && !directoryHasEntries(standalonePublicDirPath)) {
cpSync(rootPublicDirPath, standalonePublicDirPath, {
recursive: true,
force: true,
});
changed = true;
}
if (existsSync(rootStaticDirPath) && !directoryHasEntries(standaloneStaticDirPath)) {
mkdirSync(dirname(standaloneStaticDirPath), {
recursive: true,
});
cpSync(rootStaticDirPath, standaloneStaticDirPath, {
recursive: true,
force: true,
});
changed = true;
}
if (changed) {
log.log("[Playwright WebServer] Rehydrated standalone static/public assets");
}
return changed;
}
function prepareAppDir() {
if (!shouldMoveAppDir()) return;
if (existsSync(backupDir)) {
if (usingAlternativeBackupDir) {
console.warn(
"[Playwright WebServer] app.__qa_backup already exists; leaving app/ in place. " +
"If tests hit 404 on every route, clear app/ artifacts before running e2e."
"[Playwright WebServer] Existing app.__qa_backup detected; using a per-run backup dir instead."
);
return;
}
renameSync(appDir, backupDir);
@@ -55,14 +136,6 @@ function restoreAppDir() {
console.log("[Playwright WebServer] Restored app/ directory");
}
process.on("exit", restoreAppDir);
process.on("uncaughtException", (error) => {
restoreAppDir();
throw error;
});
prepareAppDir();
const bootstrapEnvVars = bootstrapEnv({ quiet: true });
const runtimePorts = resolveRuntimePorts(bootstrapEnvVars);
const testServerEnv = {
@@ -73,8 +146,17 @@ const testServerEnv = {
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK || "1",
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "1",
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "1",
...(process.env.OMNIROUTE_USE_TURBOPACK
? {
OMNIROUTE_USE_TURBOPACK: process.env.OMNIROUTE_USE_TURBOPACK,
}
: {}),
};
export function shouldUseWebpackForPlaywrightDev({ mode, env }) {
return mode === "dev" && env.OMNIROUTE_USE_TURBOPACK !== "1";
}
function runChild(command, args, env) {
return new Promise((resolve) => {
const child = spawn(command, args, {
@@ -100,7 +182,7 @@ function runChild(command, args, env) {
async function runBuildForStart() {
if (mode !== "start") return;
if (process.env.OMNIROUTE_PLAYWRIGHT_SKIP_BUILD === "1") return;
if (existsSync(buildIdFile)) return;
console.log("[Playwright WebServer] Building fresh standalone app for this run...");
const buildEnv = withRuntimePortEnv(testServerEnv, runtimePorts);
const result = await runChild(process.execPath, [buildScript], buildEnv);
@@ -115,18 +197,37 @@ async function runBuildForStart() {
}
}
await runBuildForStart();
if (mode === "start") {
if (existsSync(standaloneServer)) {
spawnWithForwardedSignals(process.execPath, [standaloneServer], {
stdio: "inherit",
env: {
...withRuntimePortEnv(testServerEnv, runtimePorts),
PORT: String(runtimePorts.dashboardPort),
HOSTNAME: process.env.HOSTNAME || "127.0.0.1",
},
});
} else {
export async function main() {
process.on("exit", restoreAppDir);
process.on("uncaughtException", (error) => {
restoreAppDir();
throw error;
});
prepareAppDir();
await runBuildForStart();
if (mode === "start") {
if (existsSync(standaloneServer)) {
syncStandaloneRuntimeAssets({
standaloneServerPath: standaloneServer,
rootStaticDirPath: rootStaticDir,
standaloneStaticDirPath: standaloneStaticDir,
rootPublicDirPath: rootPublicDir,
standalonePublicDirPath: standalonePublicDir,
});
spawnWithForwardedSignals(process.execPath, [standaloneServer], {
stdio: "inherit",
env: {
...withRuntimePortEnv(testServerEnv, runtimePorts),
PORT: String(runtimePorts.dashboardPort),
HOSTNAME: process.env.HOSTNAME || "127.0.0.1",
},
});
return;
}
const args = [
"./node_modules/next/dist/bin/next",
"start",
@@ -138,18 +239,26 @@ if (mode === "start") {
stdio: "inherit",
env: withRuntimePortEnv(testServerEnv, runtimePorts),
});
return;
}
} else {
const args = [
"./node_modules/next/dist/bin/next",
mode,
"--webpack",
"--port",
String(runtimePorts.dashboardPort),
];
if (shouldUseWebpackForPlaywrightDev({ mode, env: testServerEnv })) {
args.splice(2, 0, "--webpack");
}
spawnWithForwardedSignals(process.execPath, args, {
stdio: "inherit",
env: withRuntimePortEnv(testServerEnv, runtimePorts),
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
@@ -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");
}
@@ -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>
);
}
@@ -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
+141 -1
View File
@@ -137,7 +137,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 || {});
@@ -273,6 +281,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">
@@ -5005,6 +5005,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 +5016,7 @@ function AddApiKeyModal({
apiRegion: "international",
validationModelId: "",
customUserAgent: "",
accountId: "",
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
@@ -5047,7 +5049,7 @@ function AddApiKeyModal({
};
const handleSubmit = async () => {
if (!provider || !formData.apiKey) return;
if (!provider || (!isCompatible && !formData.apiKey)) return;
setSaving(true);
setSaveError(null);
@@ -5101,6 +5103,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 +5163,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 +5255,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 +5286,7 @@ function AddApiKeyModal({
fullWidth
disabled={
!formData.name ||
!formData.apiKey ||
(!isCompatible && !formData.apiKey) ||
saving ||
(usesBaseUrl && !formData.baseUrl.trim() && !defaultBaseUrl)
}
@@ -5326,6 +5339,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
validationModelId: "",
tag: "",
customUserAgent: "",
accountId: "",
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
@@ -5343,6 +5357,7 @@ 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 defaultRegion = "us-central1";
useEffect(() => {
@@ -5354,6 +5369,8 @@ 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 : "";
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
@@ -5365,6 +5382,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
validationModelId: (connection.providerSpecificData?.validationModelId as string) || "",
tag: (connection.providerSpecificData?.tag as string) || "",
customUserAgent: existingCustomUserAgent,
accountId: existingAccountId,
});
// Load existing extra keys from providerSpecificData
const existing = connection.providerSpecificData?.extraApiKeys;
@@ -5408,7 +5426,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 +5524,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
@@ -5607,7 +5627,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 +5703,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>
@@ -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">
@@ -14,13 +14,14 @@ import AppearanceTab from "./components/AppearanceTab";
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
import CodexServiceTierTab from "./components/CodexServiceTierTab";
import SystemPromptTab from "./components/SystemPromptTab";
import ModelAliasesTab from "./components/ModelAliasesTab";
import ModelAliasesUnified from "./components/ModelAliasesUnified";
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
import CacheSettingsTab from "./components/CacheSettingsTab";
import MemorySkillsTab from "./components/MemorySkillsTab";
import ModelsDevSyncTab from "./components/ModelsDevSyncTab";
import ResilienceTab from "./components/ResilienceTab";
import CliproxyapiSettingsTab from "./components/CliproxyapiSettingsTab";
import ModelRoutingSection from "@/shared/components/ModelRoutingSection";
const tabs = [
{ id: "general", labelKey: "general", icon: "settings" },
@@ -105,8 +106,9 @@ export default function SettingsPage() {
{activeTab === "routing" && (
<div className="flex flex-col gap-6">
<RoutingTab />
<ModelRoutingSection />
<ComboDefaultsTab />
<ModelAliasesTab />
<ModelAliasesUnified />
<BackgroundDegradationTab />
</div>
)}
@@ -51,6 +51,14 @@ export default function BudgetTelemetryCards() {
<span className="text-text-muted">{t("totalRequests")}</span>
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Active sessions</span>
<span className="font-mono">{telemetry.sessions?.activeCount ?? 0}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Quota alerts</span>
<span className="font-mono">{telemetry.quotaMonitor?.alerting ?? 0}</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
+7 -7
View File
@@ -63,17 +63,17 @@ export async function GET() {
],
},
{
id: "auto-combo",
name: "Auto-Managed Model Combos",
id: "intelligent-routing",
name: "Intelligent Model Combos",
description:
"Self-healing model chains that dynamically adapt to provider " +
"health and quota availability. Uses a scoring function based on " +
"quota, health, cost, latency, task fitness, and stability.",
tags: ["combo", "auto", "self-healing", "adaptive"],
"Self-healing model chains with auto and LKGP routing. " +
"Adapts to provider health, quota, latency, and cost using " +
"the unified combos dashboard intelligent routing controls.",
tags: ["combo", "intelligent-routing", "self-healing", "adaptive"],
examples: [
"Create an auto-managed combo for coding tasks",
"Switch to cost-saver mode",
"Show the Auto-Combo scoring breakdown",
"Show the intelligent routing scoring breakdown",
],
},
{
@@ -5,6 +5,7 @@
*/
import { NextResponse } from "next/server";
import { getComboModelProvider } from "@/lib/combos/steps";
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
@@ -18,7 +19,12 @@ export async function GET() {
]);
const health = healthRes.status === "fulfilled" ? await healthRes.value.json() : {};
const combos = combosRes.status === "fulfilled" ? await combosRes.value.json() : [];
const combosPayload = combosRes.status === "fulfilled" ? await combosRes.value.json() : [];
const combos = Array.isArray(combosPayload)
? combosPayload
: Array.isArray(combosPayload?.combos)
? combosPayload.combos
: [];
// Build provider scores from circuit breaker state
const breakers: any[] = health?.circuitBreakers || [];
@@ -29,8 +35,10 @@ export async function GET() {
if (Array.isArray(combos)) {
for (const combo of combos) {
for (const model of combo.models || combo.data?.models || []) {
allProviders.add(model.provider);
providerScores.set(model.provider, (providerScores.get(model.provider) || 0) + 1);
const provider = getComboModelProvider(model);
if (!provider) continue;
allProviders.add(provider);
providerScores.set(provider, (providerScores.get(provider) || 0) + 1);
}
}
}
+27 -7
View File
@@ -9,6 +9,8 @@ import {
} from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { validateCompositeTiersConfig } from "@/lib/combos/compositeTiers";
import { normalizeComboModels } from "@/lib/combos/steps";
import { validateComboDAG } from "@omniroute/open-sse/services/combo.ts";
import { updateComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -53,7 +55,31 @@ export async function PUT(request, { params }) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
const currentCombo = await getComboById(id);
if (!currentCombo) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
const allCombos = await getCombos();
const comboName = validation.data.name || currentCombo.name;
const body = validation.data.models
? {
...validation.data,
models: normalizeComboModels(validation.data.models, {
comboName,
allCombos,
}),
}
: validation.data;
const nextComboState = {
...currentCombo,
...body,
name: comboName,
};
const compositeValidation = validateCompositeTiersConfig(nextComboState);
if (!compositeValidation.success) {
return NextResponse.json({ error: compositeValidation.error }, { status: 400 });
}
// Check if name already exists (exclude current combo)
if (body.name) {
@@ -65,10 +91,8 @@ export async function PUT(request, { params }) {
// Validate nested combo DAG (no circular references, max depth)
if (body.models) {
const allCombos = await getCombos();
// Update the combo in the list temporarily for validation
const updatedCombos = allCombos.map((c) => (c.id === id ? { ...c, ...body } : c));
const comboName = body.name || (await getComboById(id))?.name;
if (comboName) {
try {
validateComboDAG(comboName, updatedCombos);
@@ -80,10 +104,6 @@ export async function PUT(request, { params }) {
const combo = await updateCombo(id, body);
if (!combo) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
// Auto sync to Cloud if enabled
await syncToCloudIfEnabled();
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { getComboBuilderOptions } from "@/lib/combos/builderOptions";
export async function GET() {
try {
const options = await getComboBuilderOptions();
return NextResponse.json(options);
} catch (error) {
console.log("Error fetching combo builder options:", error);
return NextResponse.json({ error: "Failed to fetch combo builder options" }, { status: 500 });
}
}
+23 -4
View File
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
import { getCombos, createCombo, getComboByName, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { validateCompositeTiersConfig } from "@/lib/combos/compositeTiers";
import { normalizeComboModels } from "@/lib/combos/steps";
import { validateComboDAG } from "@omniroute/open-sse/services/combo.ts";
import { createComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -27,7 +29,20 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name, models, strategy, config } = validation.data;
const allCombos = await getCombos();
const normalizedModels = normalizeComboModels(validation.data.models, {
comboName: validation.data.name,
allCombos,
});
const comboInput = {
...validation.data,
models: normalizedModels,
};
const { name, strategy, config } = comboInput;
const compositeValidation = validateCompositeTiersConfig(comboInput);
if (!compositeValidation.success) {
return NextResponse.json({ error: compositeValidation.error }, { status: 400 });
}
// Check if name already exists
const existing = await getComboByName(name);
@@ -36,16 +51,20 @@ export async function POST(request) {
}
// Validate nested combo DAG (no circular references, max depth)
const allCombos = await getCombos();
// Temporarily add the new combo to validate its graph
const tempCombo = { name, models: models || [], strategy, config };
const tempCombo = {
...comboInput,
name,
strategy,
config,
};
try {
validateComboDAG(name, [...allCombos, tempCombo]);
} catch (dagError) {
return NextResponse.json({ error: dagError.message }, { status: 400 });
}
const combo = await createCombo({ name, models: models || [], strategy, config });
const combo = await createCombo(comboInput);
// Auto sync to Cloud if enabled
await syncToCloudIfEnabled();
+41 -17
View File
@@ -1,16 +1,29 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { buildComboTestRequestBody, extractComboTestResponseText } from "@/lib/combos/testHealth";
import { getComboByName } from "@/lib/localDb";
import { getComboByName, getCombos } from "@/lib/localDb";
import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo.ts";
import { testComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
async function testComboModel(modelStr, internalUrl) {
function buildComboTestResult(target, partial = {}) {
return {
model: target.modelStr,
provider: target.provider,
stepId: target.stepId,
executionKey: target.executionKey,
connectionId: target.connectionId,
label: target.label,
...partial,
};
}
async function testComboTarget(target, internalUrl) {
const startTime = Date.now();
try {
// Send a minimal but real chat request through the same internal
// endpoint an external OpenAI-compatible client would use.
const testBody = buildComboTestRequestBody(modelStr);
const testBody = buildComboTestRequestBody(target.modelStr);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
@@ -27,6 +40,7 @@ async function testComboModel(modelStr, internalUrl) {
// Force a fresh execution path so combo tests cannot be satisfied by
// OmniRoute's semantic cache or other request reuse layers.
"X-OmniRoute-No-Cache": "true",
...(target.connectionId ? { "X-OmniRoute-Connection": target.connectionId } : {}),
"X-Request-Id": `combo-test-${randomUUID()}`,
},
body: JSON.stringify(testBody),
@@ -48,16 +62,15 @@ async function testComboModel(modelStr, internalUrl) {
const responseText = extractComboTestResponseText(responseBody);
if (!responseText) {
return {
model: modelStr,
return buildComboTestResult(target, {
status: "error",
statusCode: res.status,
error: "Provider returned HTTP 200 but no text content.",
latencyMs,
};
});
}
return { model: modelStr, status: "ok", latencyMs, responseText };
return buildComboTestResult(target, { status: "ok", latencyMs, responseText });
}
let errorMsg = "";
@@ -68,21 +81,19 @@ async function testComboModel(modelStr, internalUrl) {
errorMsg = res.statusText;
}
return {
model: modelStr,
return buildComboTestResult(target, {
status: "error",
statusCode: res.status,
error: errorMsg,
latencyMs,
};
});
} catch (error) {
const latencyMs = Date.now() - startTime;
return {
model: modelStr,
return buildComboTestResult(target, {
status: "error",
error: error.name === "AbortError" ? "Timeout (20s)" : error.message,
latencyMs,
};
});
}
}
@@ -119,22 +130,35 @@ export async function POST(request) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
const models = (combo.models || []).map((m) => (typeof m === "string" ? m : m.model));
const allCombos = await getCombos();
const targets = resolveNestedComboTargets(combo, allCombos);
if (models.length === 0) {
if (targets.length === 0) {
return NextResponse.json({ error: "Combo has no models" }, { status: 400 });
}
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
const results = await Promise.all(
models.map((modelStr) => testComboModel(modelStr, internalUrl))
targets.map((target) => testComboTarget(target, internalUrl))
);
const resolvedBy = results.find((result) => result.status === "ok")?.model || null;
const resolvedResult = results.find((result) => result.status === "ok") || null;
const resolvedBy = resolvedResult?.model || null;
return NextResponse.json({
comboName,
strategy: combo.strategy || "priority",
resolvedBy,
resolvedByExecutionKey: resolvedResult?.executionKey || null,
resolvedByTarget: resolvedResult
? {
model: resolvedResult.model,
provider: resolvedResult.provider,
stepId: resolvedResult.stepId,
executionKey: resolvedResult.executionKey,
connectionId: resolvedResult.connectionId,
label: resolvedResult.label,
}
: null,
results,
testedAt: new Date().toISOString(),
});
+8 -2
View File
@@ -1,14 +1,20 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { getDbInstance, SQLITE_FILE } from "@/lib/db/core";
import fs from "fs";
import path from "path";
import os from "os";
import { isAuthenticated } from "@/shared/utils/apiAuth";
/**
* GET /api/db-backups/exportAll
* Exports the entire database + settings as a ZIP archive
* Security: Requires admin authentication.
*/
export async function GET() {
export async function GET(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
if (!SQLITE_FILE) {
return NextResponse.json(
+20 -4
View File
@@ -1,12 +1,18 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { listDbBackups, restoreDbBackup, backupDbFile } from "@/lib/localDb";
import { dbBackupRestoreSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
/**
* PUT /api/db-backups Trigger a manual backup snapshot.
* Security: Requires admin authentication.
*/
export async function PUT() {
export async function PUT(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const result = backupDbFile("manual");
if (!result) {
@@ -21,8 +27,13 @@ export async function PUT() {
/**
* GET /api/db-backups List available database backups.
* Security: Requires admin authentication.
*/
export async function GET() {
export async function GET(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const backups = await listDbBackups();
return NextResponse.json({ backups });
@@ -35,8 +46,13 @@ export async function GET() {
/**
* POST /api/db-backups Restore a specific backup.
* Body: { backupId: "db_2026-02-11T14-00-00-000Z_pre-write.json" }
* Security: Requires admin authentication.
*/
export async function POST(request) {
export async function POST(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let rawBody;
try {
rawBody = await request.json();
+23 -51
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getProviderConnections, getSettings } from "@/lib/localDb";
import { buildHealthPayload } from "@/lib/monitoring/observability";
import { APP_CONFIG } from "@/shared/constants/config";
import { AI_PROVIDERS } from "@/shared/constants/providers";
@@ -15,67 +16,38 @@ export async function GET() {
const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager");
const { getAllModelLockouts } = await import("@omniroute/open-sse/services/accountFallback");
const { getInflightCount } = await import("@omniroute/open-sse/services/requestDedup.ts");
const { getQuotaMonitorSummary, getQuotaMonitorSnapshots } =
await import("@omniroute/open-sse/services/quotaMonitor.ts");
const { getActiveSessions, getAllActiveSessionCountsByKey } =
await import("@omniroute/open-sse/services/sessionManager.ts");
const settings = await getSettings();
const connections = await getProviderConnections();
const circuitBreakers = getAllCircuitBreakerStatuses();
const rateLimitStatus = getAllRateLimitStatus();
const lockouts = getAllModelLockouts();
const quotaMonitorSummary = getQuotaMonitorSummary();
const quotaMonitorMonitors = getQuotaMonitorSnapshots();
const activeSessions = getActiveSessions();
const activeSessionsByKey = getAllActiveSessionCountsByKey();
const { getAllHealthStatuses } = await import("@/lib/localHealthCheck");
// System info
const system = {
version: APP_CONFIG.version,
nodeVersion: process.version,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
pid: process.pid,
platform: process.platform,
};
// Provider health summary (circuitBreakers is an Array of { name, state, ... })
const providerHealth = {};
for (const cb of circuitBreakers) {
// Skip test circuit breakers (leftover from unit tests)
if (cb.name.startsWith("test-") || cb.name.startsWith("test_")) continue;
providerHealth[cb.name] = {
state: cb.state,
failures: cb.failureCount || 0,
lastFailure: cb.lastFailureTime,
};
}
const configuredProviders = new Set(connections.map((c: any) => c.provider));
const activeProviders = new Set(
connections.filter((c: any) => c.isActive !== false).map((c: any) => c.provider)
);
return NextResponse.json({
status: "healthy",
timestamp: new Date().toISOString(),
system,
providerHealth,
providerSummary: {
catalogCount: Object.keys(AI_PROVIDERS).length,
configuredCount: configuredProviders.size,
activeCount: activeProviders.size,
monitoredCount: Object.keys(providerHealth).length,
},
localProviders: getAllHealthStatuses(),
const payload = buildHealthPayload({
appVersion: APP_CONFIG.version,
catalogCount: Object.keys(AI_PROVIDERS).length,
settings,
connections,
circuitBreakers,
rateLimitStatus,
lockouts,
dedup: {
inflightRequests: getInflightCount(),
},
cryptography: {
status:
process.env.STORAGE_ENCRYPTION_KEY && process.env.STORAGE_ENCRYPTION_KEY.length >= 32
? "healthy"
: "missing_or_invalid",
provider: "aes-256-gcm",
},
setupComplete: settings?.setupComplete || false,
localProviders: getAllHealthStatuses(),
inflightRequests: getInflightCount(),
quotaMonitorSummary,
quotaMonitorMonitors,
activeSessions,
activeSessionsByKey,
});
return NextResponse.json(payload);
} catch (error) {
console.error("[API] GET /api/monitoring/health error:", error);
return NextResponse.json({ status: "error", error: "Health check failed" }, { status: 500 });
+5 -7
View File
@@ -9,17 +9,15 @@ import path from "path";
import yaml from "js-yaml";
let cachedSpec: { data: any; mtime: number } | null = null;
const OPENAPI_SPEC_CANDIDATES = [
path.join(process.cwd(), "docs", "openapi.yaml"),
path.join(process.cwd(), "app", "docs", "openapi.yaml"),
];
export async function GET() {
try {
// Try multiple locations for the spec file
const candidates = [
path.join(process.cwd(), "docs", "openapi.yaml"),
path.join(process.cwd(), "app", "docs", "openapi.yaml"),
];
let specPath = "";
for (const p of candidates) {
for (const p of OPENAPI_SPEC_CANDIDATES) {
if (fs.existsSync(p)) {
specPath = p;
break;
+9 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { buildTelemetryPayload } from "@/lib/monitoring/observability";
import { getTelemetrySummary } from "@/shared/utils/requestTelemetry";
export async function GET(request) {
@@ -6,7 +7,14 @@ export async function GET(request) {
const { searchParams } = new URL(request.url);
const windowMs = parseInt(searchParams.get("windowMs") || "300000", 10);
const summary = getTelemetrySummary(windowMs);
return NextResponse.json(summary);
const { getQuotaMonitorSummary } = await import("@omniroute/open-sse/services/quotaMonitor.ts");
const { getActiveSessions } = await import("@omniroute/open-sse/services/sessionManager.ts");
const payload = buildTelemetryPayload({
summary,
quotaMonitorSummary: getQuotaMonitorSummary(),
activeSessions: getActiveSessions(),
});
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
+7 -2
View File
@@ -1,10 +1,15 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { translatorSaveSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
export async function POST(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
+250 -23
View File
@@ -3,6 +3,8 @@ import { z } from "zod";
import { getDbInstance } from "@/lib/db/core";
import { getComboById, getCombos } from "@/lib/db/combos";
import { getQuotaSnapshots } from "@/lib/db/quotaSnapshots";
import { getComboMetrics } from "@omniroute/open-sse/services/comboMetrics.ts";
import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo.ts";
import type {
ComboHealthMetrics,
ComboHealthResponse,
@@ -10,13 +12,11 @@ import type {
UtilizationTimeRange,
} from "@/shared/types/utilization";
type ComboModelNode = string | { model?: string | null };
type ComboRecord = {
id?: string;
name?: string;
strategy?: string;
models?: ComboModelNode[];
models?: unknown[];
};
type ModelUsageRow = {
@@ -45,6 +45,40 @@ type ProviderHealth = {
trend: "improving" | "stable" | "declining";
};
type ResolvedComboTargetView = {
stepId: string;
executionKey: string;
modelStr: string;
provider: string;
connectionId: string | null;
label: string | null;
};
type RuntimeTargetMetricView = {
requests?: number;
successRate?: number;
avgLatencyMs?: number;
lastStatus?: "ok" | "error" | null;
lastUsedAt?: string | null;
};
type HistoricalTargetUsageRow = {
combo_execution_key: string | null;
combo_step_id: string | null;
status: number | null;
duration: number | null;
timestamp: string | null;
};
type HistoricalTargetMetricView = {
stepId: string | null;
requests: number;
successRate: number;
avgLatencyMs: number;
lastStatus: "ok" | "error" | null;
lastUsedAt: string | null;
};
const querySchema = z.object({
range: z.enum(["1h", "24h", "7d", "30d"]),
comboId: z
@@ -84,25 +118,15 @@ function toSafeNumber(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function normalizeComboModels(models: ComboModelNode[] | undefined): string[] {
if (!Array.isArray(models)) return [];
return models
.map((entry) => {
if (typeof entry === "string") return entry;
if (entry && typeof entry === "object" && typeof entry.model === "string") {
return entry.model;
}
return "";
})
.filter((entry): entry is string => entry.trim().length > 0);
}
function extractProvider(model: string): string {
const [provider] = model.split("/");
return provider?.trim() || "unknown";
}
function toNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function calculateGini(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
@@ -198,6 +222,50 @@ function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): P
};
}
function buildConnectionHealth(
provider: string,
connectionId: string,
snapshots: QuotaSnapshotRow[]
): ProviderHealth | null {
if (snapshots.length === 0) return null;
const ordered = [...snapshots].sort((left, right) => {
const leftView = left as unknown as QuotaSnapshotView;
const rightView = right as unknown as QuotaSnapshotView;
return (leftView.createdAt || "").localeCompare(rightView.createdAt || "");
});
const firstSnapshot = ordered.find((entry) => {
const snapshotView = entry as unknown as QuotaSnapshotView;
return (
snapshotView.remainingPercentage !== null && snapshotView.remainingPercentage !== undefined
);
});
const lastSnapshot = [...ordered].reverse().find((entry) => {
const snapshotView = entry as unknown as QuotaSnapshotView;
return (
snapshotView.remainingPercentage !== null && snapshotView.remainingPercentage !== undefined
);
});
const firstRemaining =
(firstSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? 0;
const lastRemaining =
(lastSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? 0;
const delta = lastRemaining - firstRemaining;
let trend: ProviderHealth["trend"] = "stable";
if (delta >= 5) trend = "improving";
if (delta <= -5) trend = "declining";
return {
provider: `${provider}:${connectionId}`,
remainingPct: roundNumber(lastRemaining),
isExhausted:
(ordered[ordered.length - 1] as unknown as QuotaSnapshotView | undefined)?.isExhausted === 1,
trend,
};
}
function buildUsageSkew(
comboName: string,
comboModels: string[],
@@ -298,13 +366,168 @@ function buildQuotaHealth(providers: string[], since: string): ComboHealthMetric
};
}
function buildComboHealth(combo: ComboRecord, since: string): ComboHealthMetrics | null {
function getHistoricalTargetMetrics(
comboName: string,
since: string
): Map<string, HistoricalTargetMetricView> {
const db = getDbInstance();
const rows = db
.prepare(
`SELECT
combo_execution_key,
combo_step_id,
status,
duration,
timestamp
FROM call_logs
WHERE combo_name = ?
AND timestamp >= ?
AND COALESCE(NULLIF(combo_execution_key, ''), NULLIF(combo_step_id, '')) IS NOT NULL
ORDER BY combo_execution_key ASC, combo_step_id ASC, timestamp DESC`
)
.all(comboName, since) as HistoricalTargetUsageRow[];
const metrics = new Map<
string,
{
stepId: string | null;
requests: number;
successCount: number;
totalLatencyMs: number;
lastStatus: "ok" | "error" | null;
lastUsedAt: string | null;
}
>();
for (const row of rows) {
const executionKey =
toNonEmptyString(row.combo_execution_key) || toNonEmptyString(row.combo_step_id);
if (!executionKey) continue;
let metric = metrics.get(executionKey);
if (!metric) {
const statusCode = toSafeNumber(row.status);
metric = {
stepId: toNonEmptyString(row.combo_step_id),
requests: 0,
successCount: 0,
totalLatencyMs: 0,
lastStatus: statusCode > 0 ? (statusCode < 400 ? "ok" : "error") : null,
lastUsedAt: toNonEmptyString(row.timestamp),
};
metrics.set(executionKey, metric);
}
metric.requests += 1;
metric.totalLatencyMs += toSafeNumber(row.duration);
if (toSafeNumber(row.status) >= 200 && toSafeNumber(row.status) < 400) {
metric.successCount += 1;
}
if (!metric.stepId) {
metric.stepId = toNonEmptyString(row.combo_step_id);
}
}
return new Map(
Array.from(metrics.entries()).map(([executionKey, metric]) => [
executionKey,
{
stepId: metric.stepId,
requests: metric.requests,
successRate:
metric.requests > 0 ? Math.round((metric.successCount / metric.requests) * 100) : 0,
avgLatencyMs: metric.requests > 0 ? Math.round(metric.totalLatencyMs / metric.requests) : 0,
lastStatus: metric.lastStatus,
lastUsedAt: metric.lastUsedAt,
},
])
);
}
function buildTargetHealth(
comboName: string,
targets: ResolvedComboTargetView[],
since: string
): NonNullable<ComboHealthMetrics["targetHealth"]> {
const comboMetrics = getComboMetrics(comboName);
const historicalMetrics = getHistoricalTargetMetrics(comboName, since);
return targets.map((target) => {
const historicalMetric =
historicalMetrics.get(target.executionKey) || historicalMetrics.get(target.stepId) || null;
const runtimeMetric =
historicalMetric === null
? ((comboMetrics?.byTarget?.[target.executionKey] ||
comboMetrics?.byTarget?.[target.stepId] ||
null) as RuntimeTargetMetricView | null)
: null;
let quotaRemainingPct: number | null = null;
let quotaIsExhausted: boolean | null = null;
let quotaTrend: "improving" | "stable" | "declining" | null = null;
let quotaScope: "connection" | "provider" | "none" = "none";
if (target.connectionId) {
const connectionHealth = buildConnectionHealth(
target.provider,
target.connectionId,
getQuotaSnapshots({
provider: target.provider,
connectionId: target.connectionId,
since,
})
);
if (connectionHealth) {
quotaRemainingPct = connectionHealth.remainingPct;
quotaIsExhausted = connectionHealth.isExhausted;
quotaTrend = connectionHealth.trend;
quotaScope = "connection";
}
}
if (quotaScope === "none") {
const providerSnapshots = getQuotaSnapshots({ provider: target.provider, since });
const providerHealth = buildProviderHealth(target.provider, providerSnapshots);
if (providerSnapshots.length > 0) {
quotaRemainingPct = providerHealth.remainingPct;
quotaIsExhausted = providerHealth.isExhausted;
quotaTrend = providerHealth.trend;
quotaScope = "provider";
}
}
return {
executionKey: target.executionKey,
stepId: target.stepId,
model: target.modelStr,
provider: target.provider,
connectionId: target.connectionId,
label: target.label,
requests: toSafeNumber(historicalMetric?.requests ?? runtimeMetric?.requests),
successRate: toSafeNumber(historicalMetric?.successRate ?? runtimeMetric?.successRate),
avgLatencyMs: toSafeNumber(historicalMetric?.avgLatencyMs ?? runtimeMetric?.avgLatencyMs),
lastStatus: historicalMetric?.lastStatus ?? runtimeMetric?.lastStatus ?? null,
lastUsedAt: historicalMetric?.lastUsedAt ?? runtimeMetric?.lastUsedAt ?? null,
quotaRemainingPct,
quotaIsExhausted,
quotaTrend,
quotaScope,
};
});
}
function buildComboHealth(
combo: ComboRecord,
since: string,
allCombos: ComboRecord[]
): ComboHealthMetrics | null {
const comboId = typeof combo.id === "string" ? combo.id : "";
const comboName = typeof combo.name === "string" ? combo.name : "";
if (!comboId || !comboName) return null;
const models = normalizeComboModels(combo.models);
const providers = Array.from(new Set(models.map(extractProvider)));
const targets = resolveNestedComboTargets(combo, allCombos) as ResolvedComboTargetView[];
const models = targets.map((target) => target.modelStr);
const providers = Array.from(new Set(targets.map((target) => target.provider)));
return {
comboId,
@@ -314,6 +537,7 @@ function buildComboHealth(combo: ComboRecord, since: string): ComboHealthMetrics
? combo.strategy
: "priority",
models,
targetHealth: buildTargetHealth(comboName, targets, since),
quotaHealth: buildQuotaHealth(providers, since),
usageSkew: buildUsageSkew(comboName, models, since),
performance: buildPerformance(comboName, since),
@@ -340,21 +564,24 @@ export async function GET(request: Request) {
const { range, comboId } = parsedQuery.data;
const since = getRangeStartIso(range);
const allCombos = (await getCombos()) as ComboRecord[];
let combos: ComboRecord[] = [];
if (comboId) {
const combo = (await getComboById(comboId)) as ComboRecord | null;
const combo =
allCombos.find((entry) => entry.id === comboId) ||
((await getComboById(comboId)) as ComboRecord | null);
if (!combo) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
combos = [combo];
} else {
combos = (await getCombos()) as ComboRecord[];
combos = allCombos;
}
const response: ComboHealthResponse = {
timeRange: range,
combos: combos
.map((combo) => buildComboHealth(combo, since))
.map((combo) => buildComboHealth(combo, since, allCombos))
.filter((combo): combo is ComboHealthMetrics => combo !== null),
};
+22 -5
View File
@@ -1,6 +1,8 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { PROVIDER_MODELS } from "@/shared/constants/models";
import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models";
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
import { getSyncedCapabilities } from "@/lib/modelsDevSync";
/**
* Handle CORS preflight
@@ -21,18 +23,21 @@ export async function OPTIONS() {
*/
export async function GET() {
try {
getSyncedCapabilities();
const models = [];
// Built-in models (hardcoded defaults)
for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) {
for (const model of providerModels) {
const resolved = getResolvedModelCapabilities({ provider, model: model.id });
models.push({
name: `models/${provider}/${model.id}`,
displayName: model.name || model.id,
description: `${provider} model: ${model.name || model.id}`,
supportedGenerationMethods: ["generateContent"],
inputTokenLimit: 128000,
outputTokenLimit: 8192,
inputTokenLimit: resolved.maxInputTokens || resolved.contextWindow || 128000,
outputTokenLimit: resolved.maxOutputTokens || 8192,
...(resolved.supportsThinking === true ? { thinking: true } : {}),
});
}
}
@@ -76,14 +81,26 @@ export async function GET() {
continue;
const m = model as Record<string, unknown>;
if (m.isHidden === true) continue;
const resolved = getResolvedModelCapabilities({
provider: providerId,
model: String(m.id),
});
models.push({
name: `models/${providerId}/${m.id}`,
displayName: m.name || m.id,
...(typeof m.description === "string" ? { description: m.description } : {}),
supportedGenerationMethods: ["generateContent"],
inputTokenLimit: typeof m.inputTokenLimit === "number" ? m.inputTokenLimit : 128000,
outputTokenLimit: typeof m.outputTokenLimit === "number" ? m.outputTokenLimit : 8192,
...(m.supportsThinking === true ? { thinking: true } : {}),
inputTokenLimit:
typeof m.inputTokenLimit === "number"
? m.inputTokenLimit
: resolved.maxInputTokens || resolved.contextWindow || 128000,
outputTokenLimit:
typeof m.outputTokenLimit === "number"
? m.outputTokenLimit
: resolved.maxOutputTokens || 8192,
...(m.supportsThinking === true || resolved.supportsThinking === true
? { thinking: true }
: {}),
});
}
}
-1
View File
@@ -4,7 +4,6 @@
(e.g. Next.js route groups like "(dashboard)"). Explicit @source
directives ensure all utility classes in route groups are included. */
@source "../app/(dashboard)";
@source "../../open-sse";
@source not "../../*.sqlite*";
@source not "../../.claude*";
@source not "../../.claude-memory";
+10 -2
View File
@@ -11,6 +11,7 @@
/**
* @typedef {import('./types.js').Combo} Combo
*/
import { getComboStepTarget, getComboStepWeight } from "@/lib/combos/steps";
/** @type {Map<string, number>} Persistent round-robin counters per combo */
const roundRobinCounters = new Map();
@@ -30,7 +31,12 @@ export function resolveComboModel(combo: any, context: any = {}) {
}
// Normalize models to { model, weight } format
const normalized = models.map((m) => (typeof m === "string" ? { model: m, weight: 1 } : m));
const normalized = models
.map((entry) => ({
model: getComboStepTarget(entry) || "",
weight: getComboStepWeight(entry) || 1,
}))
.filter((entry) => entry.model);
const strategy = combo.strategy || "priority";
@@ -93,6 +99,8 @@ export function resolveComboModel(combo: any, context: any = {}) {
* @returns {string[]} Remaining models in order
*/
export function getComboFallbacks(combo, primaryIndex) {
const models = (combo.models || []).map((m) => (typeof m === "string" ? m : m.model));
const models = (combo.models || [])
.map((entry) => getComboStepTarget(entry))
.filter((entry): entry is string => !!entry);
return [...models.slice(primaryIndex + 1), ...models.slice(0, primaryIndex)];
}
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "لوحة القيادة",
"providers": "المزوّدون",
"combos": "المجموعات",
"autoCombo": "Auto Combo",
"usage": "الاستخدام",
"analytics": "التحليلات",
"costs": "التكاليف",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "التكاليف",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "المكالمات لكل حساب قبل التبديل",
"modelAliases": "الأسماء المستعارة النموذجية",
"modelAliasesTitle": "أسماء بديلة للنماذج",
"modelAliasesDesc": "أنماط أحرف البدل لإعادة تعيين أسماء النماذج • استخدم * و؟",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "إضافة اسم بديل مخصص",
"deprecatedModelId": "معرف النموذج المهمل",
"newModelId": "معرف النموذج الجديد",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "مترجم",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "أعد تشغيل الخادم وسيتم استخدام كلمة المرور الجديدة",
"backToLogin": "العودة إلى تسجيل الدخول",
"forgotPassword": "هل نسيت كلمة المرور؟",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "إذن",
"Content-Disposition": "التصرف في المحتوى",
"waitingForAuthorization": "في انتظار الترخيص...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "دردشة بسيطة",
"streaming": "الجري",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Табло",
"providers": "Доставчици",
"combos": "Комбота",
"autoCombo": "Auto Combo",
"usage": "Използване",
"analytics": "Анализ",
"costs": "Разходи",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Разходи",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Обаждания на акаунт преди превключване",
"modelAliases": "Псевдоними на модела",
"modelAliasesTitle": "Псевдоними на модели",
"modelAliasesDesc": "Шаблони за заместващи символи за пренасочване на имена на модели • Използвайте * и ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Добави потребителски псевдоним",
"deprecatedModelId": "Остарял ID на модел",
"newModelId": "Нов ID на модел",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Преводач",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Рестартирайте сървъра - той ще използва новата парола",
"backToLogin": "Назад към Вход",
"forgotPassword": "Забравена парола?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Упълномощаване",
"Content-Disposition": "Съдържание-разположение",
"waitingForAuthorization": "Чака се оторизация...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Обикновен чат",
"streaming": "Поточно предаване",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Nástěnka",
"providers": "Poskytovatelé",
"combos": "Komba",
"autoCombo": "Auto Kombo",
"usage": "Použití",
"analytics": "Analytika",
"costs": "Náklady",
@@ -998,7 +997,19 @@
"auto": "Auto Kombo",
"autoDesc": "Samoopravný inteligentní směrovací pool (Optimalizovaný výkon)",
"lkgp": "Režim LKGP",
"lkgpDesc": "Poslední známý dobrý poskytovatel (Predikovatelná odolnost)"
"lkgpDesc": "Poslední známý dobrý poskytovatel (Predikovatelná odolnost)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Náklady",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Volání účtem před přepnutím",
"modelAliases": "Aliasy modelů",
"modelAliasesTitle": "Aliasy modelů",
"modelAliasesDesc": "Vzory zástupných znaků pro přemapování názvů modelů • Použijte * a ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Přidat vlastní alias",
"deprecatedModelId": "ID zastupovaného modelu",
"newModelId": "Nové ID modelu",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Poslední známý dobrý poskytovatel (Predikovatelná odolnost)",
"maintenance": "Údržba",
"purgeExpiredLogs": "Vymazat expirované protokoly",
"purgeLogsFailed": "Nepodařilo se vymazat protokoly"
"purgeLogsFailed": "Nepodařilo se vymazat protokoly",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Překladatel",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Restartujte server - použije se nové heslo.",
"backToLogin": "Zpět na přihlášení",
"forgotPassword": "Zapomněli jste heslo?",
"defaultPasswordHint": "Výchozí heslo: 123456 (pokud nebylo nastaveno INITIAL_PASSWORD)",
"defaultPasswordHint": "Výchozí heslo: CHANGEME (pokud nebylo nastaveno INITIAL_PASSWORD)",
"Authorization": "Autorizace",
"Content-Disposition": "Obsah-Dispozice",
"waitingForAuthorization": "Čekám na autorizaci...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Opravte chybu příkaz nenalezen",
"setupGuideCommandMissingDesc": "Ujistěte se, že CLI příkaz existuje v PATH, otevřete novou relaci terminálu a znovu spusťte tlačítko Obnovit."
},
"autoCombo": {
"title": "Auto-Kombo Modul",
"statusNormal": "Normální",
"statusIncident": "Režim Incidentu",
"modePack": "Balíček režimů",
"providerScores": "Skóre poskytovatelů",
"noAutoCombo": "Není nastaveno žádné automatické kombo.",
"excludedProviders": "Vyloučení poskytovatelé",
"noExclusions": "V současné době nejsou vyloučeni žádní poskytovatelé.",
"factorQuota": "Kvóta",
"factorHealth": "Zdraví",
"factorCost": "Náklady",
"factorLatency": "Latence",
"factorTaskFit": "Přizpůsobení úkolu",
"factorStability": "Stabilita",
"factorTierPriority": "Priorita úrovně",
"factorTierPriorityDesc": "Preferuje účty s vyššími kvótami (Ultra/Pro před Free)",
"scoreFactorBreakdown": "Faktory bodování",
"modePackShipFast": "Rychlá doprava",
"modePackCostSaver": "Úspora nákladů",
"modePackQualityFirst": "Kvalita na prvním místě",
"modePackOfflineFriendly": "Offline přátelské"
},
"templateNames": {
"simple-chat": "Jednoduchý chat",
"streaming": "Streamování",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Dashboard",
"providers": "Udbydere",
"combos": "Combos",
"autoCombo": "Auto Combo",
"usage": "Brug",
"analytics": "Analytics",
"costs": "Omkostninger",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Omkostninger",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Opkald pr. konto før skift",
"modelAliases": "Model aliaser",
"modelAliasesTitle": "Model Aliaser",
"modelAliasesDesc": "Wildcard-mønstre til omlægning af modelnavne • Brug * og ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Tilføj Tilpasset Alias",
"deprecatedModelId": "Forældet model-ID",
"newModelId": "Nyt model-ID",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Oversætter",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Genstart serveren - den vil bruge den nye adgangskode",
"backToLogin": "Tilbage til Login",
"forgotPassword": "Glemt adgangskode?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorisation",
"Content-Disposition": "Indhold-Disposition",
"waitingForAuthorization": "Venter på godkendelse...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simpel chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Dashboard",
"providers": "Anbieter",
"combos": "Kombinationen",
"autoCombo": "Auto Combo",
"usage": "Nutzung",
"analytics": "Analytik",
"costs": "Kosten",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Kosten",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Anrufe pro Konto vor dem Wechsel",
"modelAliases": "Modell-Aliase",
"modelAliasesTitle": "Modell-Aliase",
"modelAliasesDesc": "Platzhaltermuster zum Neuzuordnen von Modellnamen • Verwenden Sie * und ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Benutzerdefinierten Alias hinzufügen",
"deprecatedModelId": "Veraltete Modell-ID",
"newModelId": "Neue Modell-ID",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Übersetzer",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Starten Sie den Server neu er verwendet das neue Passwort",
"backToLogin": "Zurück zum Anmelden",
"forgotPassword": "Passwort vergessen?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorisierung",
"Content-Disposition": "Inhaltliche Disposition",
"waitingForAuthorization": "Warte auf Autorisierung...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Einfacher Chat",
"streaming": "Streaming",
+83 -33
View File
@@ -143,7 +143,6 @@
"dashboard": "Dashboard",
"providers": "Providers",
"combos": "Combos",
"autoCombo": "Auto Combo",
"usage": "Usage",
"analytics": "Analytics",
"costs": "Costs",
@@ -934,6 +933,12 @@
"warningRoundRobinSingleModel": "Round-robin is most useful with at least 2 models.",
"warningCostOptimizedPartialPricing": "Only {priced} of {total} models have pricing. Routing may be partially cost-aware.",
"warningCostOptimizedNoPricing": "No pricing data found for this combo. Cost-optimized may route unexpectedly.",
"filterAll": "All",
"filterIntelligent": "Intelligent",
"filterDeterministic": "Deterministic",
"filterEmptyTitle": "No combos match this strategy filter.",
"filterEmptyIntelligentDescription": "Create an auto or LKGP combo to populate the intelligent routing dashboard.",
"filterEmptyDeterministicDescription": "Only auto and LKGP combos exist right now. Switch back to All or create a deterministic combo.",
"readinessTitle": "Ready to save?",
"readinessDescription": "Review the checklist before creating or updating this combo.",
"readinessCheckName": "Combo name is valid",
@@ -951,6 +956,44 @@
"applyRecommendations": "Apply recommendations",
"recommendationsUpdated": "Recommendations updated for {strategy}.",
"recommendationsApplied": "Recommendations applied to this combo.",
"intelligentPanelTitle": "Intelligent Routing Dashboard",
"intelligentPanelDesc": "Real-time scoring and health status for this auto-routing combo.",
"statusOverview": "Status Overview",
"normalOperation": "Normal Operation",
"allProvidersHealthy": "Providers are reporting healthy routing conditions.",
"incidentMode": "Incident Mode",
"highCircuitBreakerRate": "High circuit breaker trip rate detected.",
"activeModePack": "Active Mode Pack",
"modePackUpdated": "Mode pack updated to {pack}.",
"modePackHint": "Switch presets to bias the routing engine without rebuilding the combo.",
"providerScores": "Provider Scores",
"allProvidersEvaluated": "No candidate pool configured. All active providers are evaluated at runtime.",
"excludedProviders": "Excluded Providers",
"excludedProvidersHint": "Providers with an OPEN circuit breaker are temporarily excluded from routing.",
"noExcludedProviders": "No providers are currently excluded.",
"cooldownMinutes": "Cooldown: {minutes}m",
"builderIntelligentTitle": "Intelligent Routing Configuration",
"builderIntelligentDesc": "Configure the multi-factor scoring engine for this auto-routing combo.",
"candidatePoolLabel": "Candidate Pool",
"candidatePoolHint": "Select which providers this engine should evaluate. Leave empty to use all active providers.",
"candidatePoolEmpty": "No active providers available yet.",
"candidatePoolAllProviders": "All providers",
"modePackLabel": "Mode Pack",
"routerStrategyLabel": "Router Strategy",
"strategyRules": "Rules (6-Factor Scoring)",
"explorationRateLabel": "Exploration Rate",
"explorationRateHint": "{percent}% of requests can explore non-optimal providers.",
"budgetCapLabel": "Budget Cap (USD / request)",
"budgetCapPlaceholder": "No limit",
"advancedWeightsTitle": "Advanced: Scoring Weights",
"weightQuota": "Quota",
"weightHealth": "Health",
"weightCostInv": "Cost",
"weightLatencyInv": "Latency",
"weightTaskFit": "Task Fit",
"weightStability": "Stability",
"weightTierPriority": "Tier",
"reviewIntelligentTitle": "Intelligent Routing Config",
"strategyRecommendations": {
"priority": {
"title": "Fail-safe baseline",
@@ -1004,10 +1047,22 @@
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), Qoder (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding.",
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"auto": "Intelligent Auto",
"autoDesc": "Self-healing smart routing pool with multi-factor scoring",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Prioritizes the last provider that successfully completed a request",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Costs",
@@ -1779,9 +1834,6 @@
"email": "Email",
"healthCheckMinutes": "Health Check (min)",
"healthCheckHint": "Proactive token refresh interval. 0 = disabled.",
"filterModels": "Filter models...",
"showModel": "Show model",
"hideModel": "Hide model",
"selectAllModels": "Select all",
"deselectAllModels": "Deselect all",
"modelsActiveCount": "{active}/{total} active",
@@ -2061,7 +2113,7 @@
"stickyLimitDesc": "Calls per account before switching",
"modelAliases": "Model Aliases",
"modelAliasesTitle": "Model Aliases",
"modelAliasesDesc": "Wildcard patterns to remap model names • Use * and ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Add Custom Alias",
"deprecatedModelId": "Deprecated model ID",
"newModelId": "New model ID",
@@ -2314,7 +2366,28 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Translator",
@@ -2732,7 +2805,7 @@
"restartServerWithNewPassword": "Restart the server - it will use the new password",
"backToLogin": "Back to Login",
"forgotPassword": "Forgot password?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Authorization",
"Content-Disposition": "Content-Disposition",
"waitingForAuthorization": "Waiting for authorization...",
@@ -3046,29 +3119,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Panel de control",
"providers": "Proveedores",
"combos": "Combos",
"autoCombo": "Auto Combo",
"usage": "Uso",
"analytics": "Analítica",
"costs": "Costos",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Costos",
@@ -1948,7 +1959,7 @@
"stickyLimitDesc": "Llamadas por cuenta antes de cambiar",
"modelAliases": "Alias de modelo",
"modelAliasesTitle": "Aliases de Modelo",
"modelAliasesDesc": "Patrones comodín para reasignar nombres de modelos • Utilice * y ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Agregar Alias Personalizado",
"deprecatedModelId": "ID del modelo obsoleto",
"newModelId": "Nuevo ID del modelo",
@@ -2237,7 +2248,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Traductor",
@@ -2655,7 +2689,7 @@
"restartServerWithNewPassword": "Reinicia el servidor; usará la nueva contraseña",
"backToLogin": "Volver a iniciar sesión",
"forgotPassword": "¿Olvidaste tu contraseña?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorización",
"Content-Disposition": "Disposición de contenido",
"waitingForAuthorization": "Waiting for authorization...",
@@ -2965,29 +2999,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Kojelauta",
"providers": "Palveluntarjoajat",
"combos": "Yhdistelmät",
"autoCombo": "Auto Combo",
"usage": "Käyttö",
"analytics": "Analytics",
"costs": "Kustannukset",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Kustannukset",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Puhelut tilikohtaisesti ennen vaihtamista",
"modelAliases": "Mallin aliakset",
"modelAliasesTitle": "Mallialias",
"modelAliasesDesc": "Jokerimerkkikuviot mallien nimien yhdistämiseen • Käytä * ja ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Lisää Mukautettu Alias",
"deprecatedModelId": "Vanhentunut malli-ID",
"newModelId": "Uusi malli-ID",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Kääntäjä",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Käynnistä palvelin uudelleen - se käyttää uutta salasanaa",
"backToLogin": "Takaisin kirjautumiseen",
"forgotPassword": "Unohditko salasanan?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Valtuutus",
"Content-Disposition": "Sisältö-sijoittelu",
"waitingForAuthorization": "Odotetaan valtuutusta...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Yksinkertainen chat",
"streaming": "Suoratoisto",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Tableau de bord",
"providers": "Fournisseurs",
"combos": "Combinaisons",
"autoCombo": "Auto Combo",
"usage": "Utilisation",
"analytics": "Analyse",
"costs": "Coûts",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Coûts",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Appels par compte avant de changer",
"modelAliases": "Alias de modèle",
"modelAliasesTitle": "Alias de Modèles",
"modelAliasesDesc": "Modèles génériques pour remapper les noms de modèles • Utilisez * et ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Ajouter un Alias Personnalisé",
"deprecatedModelId": "ID du modèle obsolète",
"newModelId": "Nouvel ID de modèle",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Traducteur",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Redémarrez le serveur - il utilisera le nouveau mot de passe",
"backToLogin": "Retour à la connexion",
"forgotPassword": "Mot de passe oublié ?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorisation",
"Content-Disposition": "Disposition du contenu",
"waitingForAuthorization": "Waiting for authorization...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "לוח מחוונים",
"providers": "ספקים",
"combos": "שילובים",
"autoCombo": "Auto Combo",
"usage": "שימוש",
"analytics": "אנליטיקס",
"costs": "עלויות",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "עלויות",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "שיחות לכל חשבון לפני המעבר",
"modelAliases": "כינויי דגם",
"modelAliasesTitle": "כינויי מודלים",
"modelAliasesDesc": "תבניות תווים כלליים למיפוי מחדש של שמות מודלים • השתמש ב-* וב-?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "הוסף כינוי מותאם אישית",
"deprecatedModelId": "מזהה מודל מיושן",
"newModelId": "מזהה מודל חדש",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "מתרגם",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "הפעל מחדש את השרת - הוא ישתמש בסיסמה החדשה",
"backToLogin": "חזרה לכניסה",
"forgotPassword": "שכחת סיסמה?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "הרשאה",
"Content-Disposition": "תוכן-נטייה",
"waitingForAuthorization": "ממתין לאישור...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "צ'אט פשוט",
"streaming": "סטרימינג",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "डैशबोर्ड",
"providers": "प्रदाता",
"combos": "संयोजन",
"autoCombo": "Auto Combo",
"usage": "उपयोग",
"analytics": "विश्लेषिकी",
"costs": "लागत",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "लागत",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "स्विच करने से पहले प्रति खाता कॉल",
"modelAliases": "मॉडल उपनाम",
"modelAliasesTitle": "Model Alias",
"modelAliasesDesc": "मॉडल नामों को रीमैप करने के लिए वाइल्डकार्ड पैटर्न • * और ? का उपयोग करें",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Kustom Alias Tambahkan",
"deprecatedModelId": "ID model yang tidak digunakan lagi",
"newModelId": "ID model baru",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "अनुवादक",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "सर्वर को पुनरारंभ करें - यह नए पासवर्ड का उपयोग करेगा",
"backToLogin": "लॉगइन पर वापस जाएँ",
"forgotPassword": "पासवर्ड भूल गए?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Authorization",
"Content-Disposition": "Content-Disposition",
"waitingForAuthorization": "Waiting for authorization...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Irányítópult",
"providers": "Szolgáltatók",
"combos": "Kombók",
"autoCombo": "Auto Combo",
"usage": "Használat",
"analytics": "Analytics",
"costs": "Költségek",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Költségek",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Hívások fiókonként váltás előtt",
"modelAliases": "Modell álnevek",
"modelAliasesTitle": "Modell aliasok",
"modelAliasesDesc": "Helyettesítő karakter minták a modellnevek újratervezéséhez • Használja a * és a ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Egyéni Alias Hozzáadása",
"deprecatedModelId": "Elavult modell ID",
"newModelId": "Új modell ID",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Fordító",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Indítsa újra a szervert - az új jelszót fogja használni",
"backToLogin": "Vissza a Bejelentkezéshez",
"forgotPassword": "Elfelejtetted a jelszavad?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Engedélyezés",
"Content-Disposition": "Tartalom-Diszpozíció",
"waitingForAuthorization": "Várakozás az engedélyezésre...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Egyszerű csevegés",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Dasbor",
"providers": "Penyedia",
"combos": "kombo",
"autoCombo": "Auto Combo",
"usage": "Penggunaan",
"analytics": "Analisis",
"costs": "Biaya",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Biaya",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Panggilan per akun sebelum beralih",
"modelAliases": "Alias Model",
"modelAliasesTitle": "Alias Model",
"modelAliasesDesc": "Pola karakter pengganti untuk memetakan ulang nama model • Gunakan * dan ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Tambah Alias Kustom",
"deprecatedModelId": "ID model yang sudah usang",
"newModelId": "ID model baru",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Penerjemah",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Mulai ulang server - server akan menggunakan kata sandi baru",
"backToLogin": "Kembali ke Masuk",
"forgotPassword": "Lupa kata sandi?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Otorisasi",
"Content-Disposition": "Disposisi Konten",
"waitingForAuthorization": "Waiting for authorization...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Pannello di controllo",
"providers": "Fornitori",
"combos": "Combinazioni",
"autoCombo": "Auto Combo",
"usage": "Utilizzo",
"analytics": "Analitica",
"costs": "Costi",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Costi",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Chiamate per account prima del cambio",
"modelAliases": "Alias del modello",
"modelAliasesTitle": "Alias dei Modelli",
"modelAliasesDesc": "Modelli di caratteri jolly per rimappare i nomi dei modelli • Utilizzare * e ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Aggiungi Alias Personalizzato",
"deprecatedModelId": "ID modello deprecato",
"newModelId": "Nuovo ID modello",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Traduttore",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Riavvia il server: utilizzerà la nuova password",
"backToLogin": "Torna all'accesso",
"forgotPassword": "Ha dimenticato la password?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorizzazione",
"Content-Disposition": "Disposizione del contenuto",
"waitingForAuthorization": "Waiting for authorization...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "ダッシュボード",
"providers": "プロバイダー",
"combos": "コンボ",
"autoCombo": "Auto Combo",
"usage": "使用法",
"analytics": "分析",
"costs": "コスト",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "コスト",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "切り替える前のアカウントごとの通話数",
"modelAliases": "モデルのエイリアス",
"modelAliasesTitle": "モデルエイリアス",
"modelAliasesDesc": "モデル名を再マッピングするワイルドカード パターン • * と ? を使用します。",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "カスタムエイリアスを追加",
"deprecatedModelId": "非推奨モデルID",
"newModelId": "新しいモデルID",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "翻訳者",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "サーバーを再起動すると新しいパスワードが適用されます",
"backToLogin": "ログインに戻る",
"forgotPassword": "パスワードをお忘れですか?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "認可",
"Content-Disposition": "コンテンツの配置",
"waitingForAuthorization": "Waiting for authorization...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "대시보드",
"providers": "공급자",
"combos": "콤보",
"autoCombo": "Auto Combo",
"usage": "사용법",
"analytics": "분석",
"costs": "비용",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "비용",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "전환 전 계정당 통화",
"modelAliases": "모델 별칭",
"modelAliasesTitle": "모델 별칭",
"modelAliasesDesc": "모델 이름을 다시 매핑하는 와일드카드 패턴 • * 및 ?를 사용합니다.",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "사용자 지정 별칭 추가",
"deprecatedModelId": "사용 중단된 모델 ID",
"newModelId": "새 모델 ID",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "번역기",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "서버를 다시 시작하세요. 새 비밀번호가 사용됩니다.",
"backToLogin": "로그인으로 돌아가기",
"forgotPassword": "비밀번호를 잊으셨나요?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "승인",
"Content-Disposition": "컨텐츠 처리",
"waitingForAuthorization": "Waiting for authorization...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Papan pemuka",
"providers": "Pembekal",
"combos": "Kombo",
"autoCombo": "Auto Combo",
"usage": "Penggunaan",
"analytics": "Analitis",
"costs": "Kos",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Kos",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Panggilan setiap akaun sebelum bertukar",
"modelAliases": "Alias Model",
"modelAliasesTitle": "Alias Model",
"modelAliasesDesc": "Corak kad liar untuk memetakan semula nama model • Gunakan * dan ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Tambah Alias Tersuai",
"deprecatedModelId": "ID model yang ditamatkan",
"newModelId": "ID model baharu",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Penterjemah",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Mulakan semula pelayan - ia akan menggunakan kata laluan baharu",
"backToLogin": "Kembali ke Log Masuk",
"forgotPassword": "Lupa kata laluan?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Keizinan",
"Content-Disposition": "Kandungan-Pelupusan",
"waitingForAuthorization": "Menunggu kebenaran...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Sembang Mudah",
"streaming": "Penstriman",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Dashboard",
"providers": "Aanbieders",
"combos": "Combo's",
"autoCombo": "Auto Combo",
"usage": "Gebruik",
"analytics": "Analyses",
"costs": "Kosten",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Kosten",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Gesprekken per account voordat u overstapt",
"modelAliases": "Modelaliassen",
"modelAliasesTitle": "Model Aliases",
"modelAliasesDesc": "Jokertekenpatronen om modelnamen opnieuw toe te wijzen • Gebruik * en ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Aangepast Alias Toevoegen",
"deprecatedModelId": "Verouderd model-ID",
"newModelId": "Nieuw model-ID",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Vertaler",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Start de server opnieuw op. Deze gebruikt het nieuwe wachtwoord",
"backToLogin": "Terug naar Inloggen",
"forgotPassword": "Wachtwoord vergeten?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorisatie",
"Content-Disposition": "Inhoud-dispositie",
"waitingForAuthorization": "Wachten op toestemming...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Eenvoudig chatten",
"streaming": "Streamen",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Dashbord",
"providers": "Leverandører",
"combos": "Combos",
"autoCombo": "Auto Combo",
"usage": "Bruk",
"analytics": "Analytics",
"costs": "Kostnader",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Kostnader",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Anrop per konto før bytte",
"modelAliases": "Modellaliaser",
"modelAliasesTitle": "Modellaliaser",
"modelAliasesDesc": "Jokertegnmønstre for å tilordne modellnavn på nytt • Bruk * og ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Legg til Tilpasset Alias",
"deprecatedModelId": "Utdatert modell-ID",
"newModelId": "Ny modell-ID",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Oversetter",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Start serveren på nytt - den vil bruke det nye passordet",
"backToLogin": "Tilbake til pålogging",
"forgotPassword": "Glemt passord?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorisasjon",
"Content-Disposition": "Innhold-Disposisjon",
"waitingForAuthorization": "Venter på autorisasjon...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Enkel chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Dashboard",
"providers": "Mga provider",
"combos": "Mga combo",
"autoCombo": "Auto Combo",
"usage": "Paggamit",
"analytics": "Analytics",
"costs": "Mga gastos",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Mga gastos",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Mga tawag sa bawat account bago lumipat",
"modelAliases": "Mga Alyas ng Modelo",
"modelAliasesTitle": "Mga Alias ng Model",
"modelAliasesDesc": "Mga pattern ng wildcard para i-remap ang mga pangalan ng modelo • Gamitin ang * at ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Magdagdag ng Custom na Alias",
"deprecatedModelId": "Deprecated na Model ID",
"newModelId": "Bagong Model ID",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Tagasalin",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "I-restart ang server - gagamitin nito ang bagong password",
"backToLogin": "Bumalik sa Login",
"forgotPassword": "Nakalimutan ang password?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Awtorisasyon",
"Content-Disposition": "Nilalaman-Disposisyon",
"waitingForAuthorization": "Naghihintay ng awtorisasyon...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simpleng Chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Pulpit nawigacyjny",
"providers": "Dostawcy",
"combos": "Kombinacje",
"autoCombo": "Auto Combo",
"usage": "Użycie",
"analytics": "Analityka",
"costs": "Koszty",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Koszty",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Połączenia na konto przed zmianą",
"modelAliases": "Aliasy modeli",
"modelAliasesTitle": "Aliasy modeli",
"modelAliasesDesc": "Wzorce wieloznaczne do zmiany nazw modeli • Użyj * i ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Dodaj niestandardowy alias",
"deprecatedModelId": "Przestarzały ID modelu",
"newModelId": "Nowy ID modelu",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Tłumacz",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Zrestartuj serwer - będzie używał nowego hasła",
"backToLogin": "Powrót do logowania",
"forgotPassword": "Zapomniałeś hasła?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autoryzacja",
"Content-Disposition": "Dyspozycja treści",
"waitingForAuthorization": "Waiting for authorization...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",
+38 -32
View File
@@ -143,7 +143,6 @@
"dashboard": "Painel",
"providers": "Provedores",
"combos": "Combos",
"autoCombo": "Auto Combo",
"usage": "Uso",
"analytics": "Análises",
"costs": "Custos",
@@ -1007,7 +1006,19 @@
"auto": "Auto Combo",
"autoDesc": "Pool de roteamento inteligente (Otimizado)",
"lkgp": "Modo LKGP",
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)"
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Custos",
@@ -1632,7 +1643,7 @@
"adding": "Adicionando...",
"importingModelsTitle": "Importando Modelos",
"copyModel": "Copiar modelo",
"filterModels": "Filtrar modelos",
"filterModels": "Filtrar modelos...",
"modelsActive": "ativos",
"showModel": "Mostrar modelo",
"hideModel": "Ocultar modelo",
@@ -1770,9 +1781,6 @@
"email": "Email",
"healthCheckMinutes": "Health Check (min)",
"healthCheckHint": "Intervalo proativo de renovação de token. 0 = desativado.",
"filterModels": "Filtrar modelos...",
"showModel": "Mostrar modelo",
"hideModel": "Ocultar modelo",
"selectAllModels": "Selecionar todos",
"deselectAllModels": "Desmarcar todos",
"modelsActiveCount": "{active}/{total} ativos",
@@ -2010,7 +2018,7 @@
"stickyLimitDesc": "Chamadas por conta antes de trocar",
"modelAliases": "Aliases de Modelo",
"modelAliasesTitle": "Aliases de Modelo",
"modelAliasesDesc": "Padrões coringa para remapear nomes de modelos • Use * e ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Adicionar Alias Personalizado",
"deprecatedModelId": "ID do modelo depreciado",
"newModelId": "Novo ID do modelo",
@@ -2305,7 +2313,28 @@
"clearLkgpCache": "Clear LKGP Cache",
"lkgpCacheCleared": "LKGP cache cleared successfully",
"lkgpCacheClearFailed": "Failed to clear LKGP cache",
"maintenance": "Maintenance"
"maintenance": "Maintenance",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Tradutor",
@@ -2723,7 +2752,7 @@
"restartServerWithNewPassword": "Reinicie o servidor - ele usará a nova senha",
"backToLogin": "Voltar para o Login",
"forgotPassword": "Esqueceu a senha?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorização",
"Content-Disposition": "Disposição de conteúdo",
"waitingForAuthorization": "Aguardando autorização...",
@@ -3033,29 +3062,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Bate-papo simples",
"streaming": "Transmissão",
+40 -32
View File
@@ -143,7 +143,6 @@
"dashboard": "Painel",
"providers": "Provedores",
"combos": "Combos",
"autoCombo": "Auto Combo",
"usage": "Uso",
"analytics": "Análise",
"costs": "Custos",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Pool de roteamento inteligente (Otimizado)",
"lkgp": "Modo LKGP",
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)"
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Custos",
@@ -1623,7 +1634,7 @@
"adding": "Adicionando...",
"importingModelsTitle": "Importando Modelos",
"copyModel": "Copiar modelo",
"filterModels": "Filtrar modelos",
"filterModels": "Filtrar modelos...",
"modelsActive": "ativos",
"showModel": "Mostrar modelo",
"hideModel": "Ocultar modelo",
@@ -1761,9 +1772,6 @@
"email": "E-mail",
"healthCheckMinutes": "Verificação de integridade (min)",
"healthCheckHint": "Intervalo de atualização de token proativo. 0 = desabilitado.",
"filterModels": "Filtrar modelos...",
"showModel": "Mostrar modelo",
"hideModel": "Ocultar modelo",
"selectAllModels": "Selecionar todos",
"deselectAllModels": "Desmarcar todos",
"modelsActiveCount": "{active}/{total} ativos",
@@ -2001,7 +2009,7 @@
"stickyLimitDesc": "Chamadas por conta antes de mudar",
"modelAliases": "Aliases de modelo",
"modelAliasesTitle": "Aliases de Modelo",
"modelAliasesDesc": "Padrões curinga para remapear nomes de modelos • Use * e ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Adicionar Alias Personalizado",
"deprecatedModelId": "ID do modelo depreciado",
"newModelId": "Novo ID do modelo",
@@ -2290,7 +2298,30 @@
"clearLkgpCache": "Clear LKGP Cache",
"lkgpCacheCleared": "LKGP cache cleared successfully",
"lkgpCacheClearFailed": "Failed to clear LKGP cache",
"maintenance": "Maintenance"
"maintenance": "Maintenance",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Tradutor",
@@ -2708,7 +2739,7 @@
"restartServerWithNewPassword": "Reinicie o servidor - ele usará a nova senha",
"backToLogin": "Voltar ao login",
"forgotPassword": "Esqueceu a senha?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorização",
"Content-Disposition": "Disposição de conteúdo",
"waitingForAuthorization": "Waiting for authorization...",
@@ -3018,29 +3049,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Tabloul de bord",
"providers": "Furnizorii",
"combos": "Combo-uri",
"autoCombo": "Auto Combo",
"usage": "Utilizare",
"analytics": "Analytics",
"costs": "Costuri",
@@ -998,7 +997,19 @@
"auto": "Auto Combo",
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
"lkgp": "LKGP Mode",
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Costuri",
@@ -1943,7 +1954,7 @@
"stickyLimitDesc": "Apeluri pe cont înainte de a comuta",
"modelAliases": "Aliasuri de model",
"modelAliasesTitle": "Aliasuri de model",
"modelAliasesDesc": "Modele wildcard pentru a remapa numele modelelor • Utilizați * și ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Adaugă Alias Personalizat",
"deprecatedModelId": "ID model depreciat",
"newModelId": "ID model nou",
@@ -2232,7 +2243,30 @@
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
"maintenance": "Maintenance",
"purgeExpiredLogs": "Purge Expired Logs",
"purgeLogsFailed": "Failed to purge logs"
"purgeLogsFailed": "Failed to purge logs",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Traducător",
@@ -2650,7 +2684,7 @@
"restartServerWithNewPassword": "Reporniți serverul - va folosi noua parolă",
"backToLogin": "Înapoi la Logare",
"forgotPassword": "Ai uitat parola?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"defaultPasswordHint": "Default password: CHANGEME (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorizare",
"Content-Disposition": "Conținut-Dispoziție",
"waitingForAuthorization": "Se așteaptă autorizația...",
@@ -2960,29 +2994,6 @@
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
},
"templateNames": {
"simple-chat": "Chat simplu",
"streaming": "Streaming",
+39 -28
View File
@@ -143,7 +143,6 @@
"dashboard": "Панель управления",
"providers": "Провайдеры",
"combos": "Комбо",
"autoCombo": "Автокомбо",
"usage": "Использование",
"analytics": "Аналитика",
"costs": "Затраты",
@@ -1022,7 +1021,19 @@
"auto": "Авто",
"autoDesc": "Автоматическая стратегия - система сама выбирает подходящее поведение.",
"lkgp": "LKGP",
"lkgpDesc": "Маршрутизация по последней известной хорошей политике"
"lkgpDesc": "Маршрутизация по последней известной хорошей политике",
"wizardGuideTitle": "Getting Started with Combos",
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
"wizardGuideHint": "or click + Create Combo above",
"createFirstCombo": "Create Your First Combo",
"wizardStep1Title": "Name Your Combo",
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
"wizardStep2Title": "Add Models",
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
"wizardStep3Title": "Choose Strategy",
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
"wizardStep4Title": "Review & Save",
"wizardStep4Desc": "Review your configuration and activate the combo"
},
"costs": {
"title": "Затраты",
@@ -1986,7 +1997,7 @@
"stickyLimitDesc": "Звонки на аккаунт до переключения",
"modelAliases": "Псевдонимы моделей",
"modelAliasesTitle": "Псевдонимы моделей",
"modelAliasesDesc": "Шаблоны подстановочных знаков для переназначения названий моделей • Используйте * и ?",
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
"addCustomAlias": "Добавить пользовательский псевдоним",
"deprecatedModelId": "Устаревший ID модели",
"newModelId": "Новый ID модели",
@@ -2261,7 +2272,30 @@
"skillsComingSoon": "Маркетплейс навыков скоро появится.",
"memorySkillsTitle": "Память и навыки",
"memorySkillsDesc": "Постоянный контекст и возможности",
"days": "Дни"
"days": "Дни",
"contextRelay": "Context Relay",
"contextRelayDesc": "Hands off context between providers with automatic summarization",
"contextOpt": "Context Optimized",
"contextOptDesc": "Routes based on context window requirements and conversation length",
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
"weightedDesc": "Distributes traffic by percentage weights across providers",
"modelRoutingTitle": "Model Routing Rules",
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
"addRule": "Add Rule",
"routeToCombo": "Route to Combo",
"selectCombo": "Select combo...",
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
"deleteRoutingRule": "Delete this model routing rule?",
"exactMatchMode": "Exact Match",
"wildcardPatternMode": "Wildcard Pattern",
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
"noExactAliasesConfigured": "No exact-match aliases configured.",
"wildcardRulesTitle": "Wildcard Rules",
"noWildcardAliasesConfigured": "No wildcard aliases configured."
},
"translator": {
"title": "Переводчик",
@@ -2679,7 +2713,7 @@
"restartServerWithNewPassword": "Перезагрузите сервер - он будет использовать новый пароль.",
"backToLogin": "Вернуться к входу",
"forgotPassword": "Забыли пароль?",
"defaultPasswordHint": "Пароль по умолчанию: 123456 (если не задан INITIAL_PASSWORD)",
"defaultPasswordHint": "Пароль по умолчанию: CHANGEME (если не задан INITIAL_PASSWORD)",
"Authorization": "Авторизация",
"Content-Disposition": "Содержание-Расположение",
"waitingForAuthorization": "Ожидание авторизации...",
@@ -2989,29 +3023,6 @@
"setupGuideCommandMissingTitle": "Исправить 'command not found'",
"setupGuideCommandMissingDesc": "Убедитесь, что команда CLI есть в PATH, откройте новый терминал и снова нажмите «Обновить»."
},
"autoCombo": {
"title": "Движок автокомбо",
"statusNormal": "Нормальный",
"statusIncident": "Режим инцидента",
"modePack": "Пакет режимов",
"providerScores": "Оценки провайдеров",
"noAutoCombo": "Автокомбо не настроено.",
"excludedProviders": "Исключённые провайдеры",
"noExclusions": "Сейчас нет исключённых провайдеров.",
"factorQuota": "Квота",
"factorHealth": "Состояние",
"factorCost": "Стоимость",
"factorLatency": "Задержка",
"factorTaskFit": "Соответствие задаче",
"factorStability": "Стабильность",
"factorTierPriority": "Приоритет тарифа",
"factorTierPriorityDesc": "Предпочитает аккаунты с более высоким тарифом (Ultra/Pro выше Free)",
"scoreFactorBreakdown": "Факторы оценки",
"modePackShipFast": "Быстрое выполнение",
"modePackCostSaver": "Экономный режим",
"modePackQualityFirst": "Сначала качество",
"modePackOfflineFriendly": "Подходит для офлайна"
},
"memory": {
"title": "Память",
"description": "Постоянная кросс-сессионная память для диалогов",

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