diff --git a/.antigravitycli/0db0ca8e-3c51-48d9-83e2-da62c6f0a02b.json b/.antigravitycli/0db0ca8e-3c51-48d9-83e2-da62c6f0a02b.json deleted file mode 120000 index 263f1a6d6e..0000000000 --- a/.antigravitycli/0db0ca8e-3c51-48d9-83e2-da62c6f0a02b.json +++ /dev/null @@ -1 +0,0 @@ -/home/diegosouzapw/.gemini/config/projects/0db0ca8e-3c51-48d9-83e2-da62c6f0a02b.json \ No newline at end of file diff --git a/.env.example b/.env.example index 16a61c2444..252ee8d853 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,6 @@ # │ Reference: docs/ENVIRONMENT.md for full details and usage scenarios. │ # └─────────────────────────────────────────────────────────────────────────────┘ - # ═══════════════════════════════════════════════════════════════════════════════ # 1. REQUIRED SECRETS — Must be set before first run! # ═══════════════════════════════════════════════════════════════════════════════ @@ -62,7 +61,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false # Default: redis://localhost:6379 (or redis://redis:6379 in Docker) REDIS_URL=redis://localhost:6379 - # ═══════════════════════════════════════════════════════════════════════════════ # 3. NETWORK & PORTS # ═══════════════════════════════════════════════════════════════════════════════ @@ -159,6 +157,11 @@ OMNIROUTE_USE_TURBOPACK=1 # Values: production | development | Default: production NODE_ENV=production +# Container runtime — controls startup script behavior (permissions, advice). +# Values: docker | podman | Default: docker +# Set to "podman" when running under rootless Podman so the entrypoint +# gives the correct fix instructions (podman unshare chown vs sudo chown). +CONTAINER_HOST=docker # ═══════════════════════════════════════════════════════════════════════════════ # 4. SECURITY & AUTHENTICATION @@ -238,7 +241,6 @@ ALLOW_API_KEY_REVEAL=false # When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable. # OUTBOUND_SSRF_GUARD_ENABLED=true - # ═══════════════════════════════════════════════════════════════════════════════ # 5. INPUT SANITIZATION & PII PROTECTION (FASE-01) # ═══════════════════════════════════════════════════════════════════════════════ @@ -271,7 +273,6 @@ ALLOW_API_KEY_REVEAL=false # PII_RESPONSE_SANITIZATION=false # PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response - # ═══════════════════════════════════════════════════════════════════════════════ # 6. TOOL & ROUTING POLICIES # ═══════════════════════════════════════════════════════════════════════════════ @@ -291,7 +292,6 @@ ALLOW_API_KEY_REVEAL=false # Default: 5000 | Minimum: 1000 # OMNIROUTE_PAYLOAD_RULES_RELOAD_MS=5000 - # ═══════════════════════════════════════════════════════════════════════════════ # 7. URLS & CLOUD SYNC # ═══════════════════════════════════════════════════════════════════════════════ @@ -365,7 +365,6 @@ NEXT_PUBLIC_CLOUD_URL= #OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota #OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit - # ═══════════════════════════════════════════════════════════════════════════════ # 8. OUTBOUND PROXY (Upstream Provider Calls) # ═══════════════════════════════════════════════════════════════════════════════ @@ -393,7 +392,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Used by: open-sse/services/claudeTurnstileSolver.ts # OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS=false - # ═══════════════════════════════════════════════════════════════════════════════ # 9. CLI TOOL INTEGRATION # ═══════════════════════════════════════════════════════════════════════════════ @@ -423,7 +421,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # CLI_QODER_BIN=qoder # CLI_QWEN_BIN=qwen - # ═══════════════════════════════════════════════════════════════════════════════ # 10. INTERNAL AGENT & MCP INTEGRATIONS # ═══════════════════════════════════════════════════════════════════════════════ @@ -473,6 +470,12 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Default: 70 PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70 +# Gap (ms) between consecutive OAuth quota fetches in a bulk provider-limits sync. +# OAuth providers are fetched one at a time with this spacing so a single host +# never bursts simultaneous usage/refresh requests to the same upstream. Set to 0 +# to opt out (restores fully concurrent fetches). Default: 1500 +PROVIDER_LIMITS_SYNC_SPACING_MS=1500 + # 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. @@ -554,7 +557,6 @@ PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70 # Default: ~/.gemini/antigravity-cli/antigravity-oauth-token #AGY_TOKEN_FILE= - # ═══════════════════════════════════════════════════════════════════════════════ # 11. OAUTH PROVIDER CREDENTIALS # ═══════════════════════════════════════════════════════════════════════════════ @@ -701,7 +703,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # CLI_USER_ID= # legacy alias for OMNIROUTE_USER_ID # SERVER_URL= # legacy alias for OMNIROUTE_SERVER - # ═══════════════════════════════════════════════════════════════════════════════ # 12. PROVIDER USER-AGENT OVERRIDES # ═══════════════════════════════════════════════════════════════════════════════ @@ -737,7 +738,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts. # CODEX_CLIENT_VERSION=0.132.0 - # ═══════════════════════════════════════════════════════════════════════════════ # 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection) # ═══════════════════════════════════════════════════════════════════════════════ @@ -766,7 +766,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" #KIMI_CLI_VERSION=1.36.0 #KIMI_CODING_DEVICE_ID= - # ═══════════════════════════════════════════════════════════════════════════════ # 14. API KEY PROVIDERS # ═══════════════════════════════════════════════════════════════════════════════ @@ -789,7 +788,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # OpenAI/Mistral/Together/Fireworks/NVIDIA configured via Dashboard → Providers # also work for embeddings. - # ═══════════════════════════════════════════════════════════════════════════════ # 15. TIMEOUT SETTINGS # ═══════════════════════════════════════════════════════════════════════════════ @@ -868,7 +866,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # Default: 30000 (30 seconds) # SHUTDOWN_TIMEOUT_MS=30000 - # ═══════════════════════════════════════════════════════════════════════════════ # 16. LOGGING # ═══════════════════════════════════════════════════════════════════════════════ @@ -942,7 +939,6 @@ APP_LOG_TO_FILE=true # Default: 100000 # PROXY_LOGS_TABLE_MAX_ROWS=100000 - # ═══════════════════════════════════════════════════════════════════════════════ # 17. MEMORY OPTIMIZATION (Low-RAM / Docker) # ═══════════════════════════════════════════════════════════════════════════════ @@ -1022,7 +1018,6 @@ APP_LOG_TO_FILE=true # Used by: open-sse/utils/usageTracking.ts # USAGE_TOKEN_BUFFER=100 - # ═══════════════════════════════════════════════════════════════════════════════ # 18. PRICING SYNC # ═══════════════════════════════════════════════════════════════════════════════ @@ -1038,7 +1033,6 @@ APP_LOG_TO_FILE=true # Comma-separated data sources. Default: litellm # PRICING_SYNC_SOURCES=litellm - # ═══════════════════════════════════════════════════════════════════════════════ # 19. MODEL SYNC (Dev) # ═══════════════════════════════════════════════════════════════════════════════ @@ -1047,7 +1041,6 @@ APP_LOG_TO_FILE=true # Default: 86400 (24 hours) # MODELS_DEV_SYNC_INTERVAL=86400 - # ═══════════════════════════════════════════════════════════════════════════════ # 20. PROVIDER-SPECIFIC SETTINGS # ═══════════════════════════════════════════════════════════════════════════════ @@ -1128,7 +1121,6 @@ APP_LOG_TO_FILE=true # Used by: open-sse/config/providerRegistry.ts — allows Docker service names. # LOCAL_HOSTNAMES=omlx,mlx-audio - # ═══════════════════════════════════════════════════════════════════════════════ # 21. PROXY HEALTH # ═══════════════════════════════════════════════════════════════════════════════ @@ -1154,7 +1146,6 @@ APP_LOG_TO_FILE=true # Used by: src/lib/tokenHealthCheck.ts. Default: 3000. # HEALTHCHECK_STAGGER_MS=3000 - # ═══════════════════════════════════════════════════════════════════════════════ # 22. DEBUGGING # ═══════════════════════════════════════════════════════════════════════════════ @@ -1214,7 +1205,6 @@ APP_LOG_TO_FILE=true # Enable E2E test mode — relaxes auth and enables test harness hooks. # NEXT_PUBLIC_OMNIROUTE_E2E_MODE=true - # ═══════════════════════════════════════════════════════════════════════════════ # 23. GITHUB INTEGRATION (Issue Reporting) # ═══════════════════════════════════════════════════════════════════════════════ @@ -1232,7 +1222,6 @@ APP_LOG_TO_FILE=true # GITHUB_ISSUES_TOKEN when unset. # GITHUB_TOKEN= - # ═══════════════════════════════════════════════════════════════════════════════ # 24. PROVIDER QUOTAS, TUNNELS & SANDBOXED SKILLS # ═══════════════════════════════════════════════════════════════════════════════ @@ -1337,7 +1326,6 @@ APP_LOG_TO_FILE=true # SKILLS_SANDBOX_NETWORK_ENABLED=0 # SKILLS_ALLOWED_SANDBOX_IMAGES= - # ═══════════════════════════════════════════════════════════════════════════════ # 25. TEST & E2E # ═══════════════════════════════════════════════════════════════════════════════ @@ -1359,6 +1347,12 @@ APP_LOG_TO_FILE=true # Disable the OAuth token healthcheck loop during tests (default: true). # OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK=true +# Exclude specific providers from the PROACTIVE token-refresh sweep (comma-separated, +# case-insensitive). Targeted alternative to OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: keeps +# rotating-cascade providers (Codex/OpenAI share one Auth0 family) on the reactive 401 +# path only, while short-TTL providers like Kimi-coding keep being refreshed proactively. +# OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS=codex,openai + # Silence healthcheck noise in Playwright stdout (default: true). # OMNIROUTE_HIDE_HEALTHCHECK_LOGS=true @@ -1448,8 +1442,8 @@ INSPECTOR_MASK_SECRETS=true INSPECTOR_LLM_HOSTS_EXTRA= INSPECTOR_INTERNAL_INGEST_TOKEN= # Quota Sharing (Group B — planos 16+22) -QUOTA_STORE_DRIVER=sqlite # sqlite | redis -# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis) -# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo) -# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa -# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos +QUOTA_STORE_DRIVER=sqlite # sqlite | redis +# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis) +# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo) +# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa +# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos diff --git a/.gitignore b/.gitignore index a0142644e0..6f31e0d47e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,18 @@ _ideia/ _mono_repo/ _references/ _tasks/ +.agents/** +.claude/** +.gemini/** +.config/** +.data/** +.logs/** +.tests/** +.coverage/** +.dist/** +.next/** +.build/** +.out/** # Memory Bank and Cursor rules (local-only AI agent context) diff --git a/.i18n-state.json b/.i18n-state.json deleted file mode 100644 index b544343cf4..0000000000 --- a/.i18n-state.json +++ /dev/null @@ -1,219 +0,0 @@ -{ - "sources": { - "CLAUDE.md": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "locales": { - "pt-BR": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "301b997e936b1d476e6094042666b96b33f42a4372fd2d9ccf904aacbfd7f023", - "updated_at": "2026-05-22T20:13:39.165Z" - }, - "az": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "c26844ec50b2abfbb002767fb5c6c9c3982ec65789435bbc134bf1a7b50bf84a", - "updated_at": "2026-05-22T20:13:39.166Z" - }, - "bn": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "0b1416ec3b5af8cc6415d12a9ff12ce078acca4a7bb52a7422ebde3b6ae22832", - "updated_at": "2026-05-22T20:13:39.166Z" - }, - "ar": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "bc747c5ea2a387dd37dea9dc1337d9734f2ec0da38a7134573d9743e3f4d2aef", - "updated_at": "2026-05-22T20:13:39.167Z" - }, - "cs": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "e37dce45820b53be9d3d5ead08cadb8d13e4c77597b3009bb0be4e485917f5c6", - "updated_at": "2026-05-22T20:13:39.167Z" - }, - "da": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "645c382bebe0931eaad6747e33667fd094b92b3f4042549b953db798de6b1f46", - "updated_at": "2026-05-22T20:13:39.168Z" - }, - "de": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "b6558f82eb67676baf4d78946a8d1e1b808f7763e5ebaf9f57948cbd1641dc0f", - "updated_at": "2026-05-22T20:13:39.168Z" - }, - "es": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "afb2a2dfa74a74c3105b0ac70181d33f5eefd201efe7edb6aba0740864639fe5", - "updated_at": "2026-05-22T20:13:39.169Z" - }, - "fa": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "58966769aeb71d23c55cf4aeb452f2bbe32036df8fba1d7596f3e861897d507b", - "updated_at": "2026-05-22T20:13:39.169Z" - }, - "fi": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "04cf72a3b370edcb3d54361c6cc250b3af227ba183376827006c7998839740aa", - "updated_at": "2026-05-22T20:13:39.169Z" - }, - "fr": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "8dca6de87c88e04396f1139ac8b6328d188defaa5abc99eeb4026f53de772ba1", - "updated_at": "2026-05-22T20:13:39.170Z" - }, - "he": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "0308a2c8c5a6b6261474a76c160f3c0658c75f56a633a57bd7300b83ccb32c9d", - "updated_at": "2026-05-22T20:13:39.170Z" - }, - "hi": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "a3e902c3b1812a41ccb14c9f16d2cf2e42b8c005a5d557043e582d48eda024c4", - "updated_at": "2026-05-22T20:13:39.170Z" - }, - "gu": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "1bc2419db39c7c7960b065222a3e3990e9e53c4d3427ec02422e3e506aa53733", - "updated_at": "2026-05-22T20:13:39.171Z" - }, - "hu": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "ec30f54810f8b5b84e3ac8bf7bc8d05acc8616d71801b672ec02f0bc225cf607", - "updated_at": "2026-05-22T20:13:39.171Z" - }, - "id": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "aebd8fdad7ec4857aa1b958cda37f59681846525207d71d98d729775031afb94", - "updated_at": "2026-05-22T20:13:39.172Z" - }, - "in": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "ac70a1282877f8c4f792e9235f7a6fbce3cbec4a424ce4947074bb210fe12d67", - "updated_at": "2026-05-22T20:13:39.172Z" - }, - "it": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "260f10dd121441d08e15a8d511789d8f8f7a788995305ab5e8dc6d0c1a3db06e", - "updated_at": "2026-05-22T20:13:39.173Z" - }, - "ja": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "83871c13e99d13d7928409b2aef182f55fe36fbb2425f8fa4bd0df0466afed28", - "updated_at": "2026-05-22T20:13:39.173Z" - }, - "mr": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "0c35c5261dd3b6c6b729d14653f95cc112f3ed9829d2d41b61e5a960abc68556", - "updated_at": "2026-05-22T20:13:39.173Z" - }, - "ko": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "4ea4079b37d90e90e9a32d0da290e04e38bcd6426512b50ca7be7139354bc329", - "updated_at": "2026-05-22T20:13:39.174Z" - }, - "ms": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "0e8031cd987766a69afc7aaf7c3560a57736d37e7238ddccfb848d6fbeb3f212", - "updated_at": "2026-05-22T20:13:39.174Z" - }, - "nl": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "27e554c3a9b86db1f04539fcac18d3e75e6599d4de9f5ac0cf789a136b5737d4", - "updated_at": "2026-05-22T20:13:39.174Z" - }, - "phi": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "f955af44cc0e8e87a2a7b12313f0dfad9aae1a50d7855e74c8155df3601279ad", - "updated_at": "2026-05-22T20:13:39.175Z" - }, - "no": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "de4e3d940ae485c85da13db4471fcc68926ec53e660d92633f01293c5db0a04d", - "updated_at": "2026-05-22T20:13:39.175Z" - }, - "pl": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "de5be8961e5184431404d0021e31c3ef0857f7439fbd1c71ddc0c6828bed86bb", - "updated_at": "2026-05-22T20:13:39.175Z" - }, - "ro": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "19d98b22bc77c1870b749fad6e62a41c6ef53f3cad303d1c471fba4bf7012797", - "updated_at": "2026-05-22T20:13:39.175Z" - }, - "ru": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "25e0c70ec25bd24b073b9d693299c3c3d32d66a410569362719e9c9ecacd759d", - "updated_at": "2026-05-22T20:13:39.176Z" - }, - "pt": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "0d31647b21af967d8d4e9ecbcfc4901a66db377a6853d7b59296a2d73f0cab12", - "updated_at": "2026-05-22T20:13:39.176Z" - }, - "sk": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "9d5d0ce1c51da4959d1c306969bff0bf36d3a3492ccdbdbd82e4f4d951f32c8b", - "updated_at": "2026-05-22T20:13:39.176Z" - }, - "sw": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "11cd3dc7a605eebddf4a34c13de66c713d67adb32e4242962a65c71e5a034c7e", - "updated_at": "2026-05-22T20:13:39.177Z" - }, - "sv": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "93b679feb6415315ab96e08298217e66be84265a277e267ecefd25b2cef04026", - "updated_at": "2026-05-22T20:13:39.177Z" - }, - "ta": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "55f3df128513a1b4a8cc3f58eb84769814589d80e1c8b8f03ab0635750a76d2d", - "updated_at": "2026-05-22T20:13:39.177Z" - }, - "te": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "81019604dd1fb38dfda55c1b8cf5cf8243347be08221a5e1eb11e8c76e095fab", - "updated_at": "2026-05-22T20:13:39.178Z" - }, - "tr": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "b67efd07b179be016b388dff4b8979597c0a7b2db602629cef539dbec14913ab", - "updated_at": "2026-05-22T20:13:39.178Z" - }, - "th": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "a793a12dc0cba5e3641cd544f63ab23e9cfe1b80568a08770a81ffd890287eda", - "updated_at": "2026-05-22T20:13:39.179Z" - }, - "ur": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "6c03049aee6d85b6fd4a3bf9b89a6204e461d3f05e9cb767e36c80af796452df", - "updated_at": "2026-05-22T20:13:39.179Z" - }, - "vi": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "4d35c6898f98913a02551dcfbf4d3748b2461e2d8206d292f292acacf0fc7133", - "updated_at": "2026-05-22T20:13:39.180Z" - }, - "zh-CN": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "d3f574c244d157c1fe1b9fc86192d1565df2dc6343ac9ef63c0fd3f91d97f492", - "updated_at": "2026-05-22T20:13:39.180Z" - }, - "uk-UA": { - "source_hash": "c808d01e42e9aaaede590048dc327347c9e814b152c6f45f54be6df64f4f19df", - "target_hash": "ffe6357e38d56ba2595e8fb8e21db4ae91bf03d10fca50a2b722e0d0bb6c01d9", - "updated_at": "2026-05-22T20:13:39.181Z" - } - } - }, - "docs/architecture/ARCHITECTURE.md": { - "source_hash": "f9b4f17a1b0331fb5500768943438411ed88a38278381680caacc37c90bfb869", - "locales": { - "pt-BR": { - "source_hash": "f9b4f17a1b0331fb5500768943438411ed88a38278381680caacc37c90bfb869", - "target_hash": "e320c6172a88a0f3b698ba1a2e9e938424ece66625765395837bcf55cc29454e", - "updated_at": "2026-05-22T20:13:39.183Z" - } - } - } - } -} diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index caa4ab6868..0000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Debug Dev Server", - "type": "node", - "request": "launch", - "runtimeExecutable": "${env:HOME}/.nvm/versions/node/v22.22.2/bin/node", - "program": "${workspaceFolder}/scripts/run-next.mjs", - "args": ["dev"], - "console": "integratedTerminal", - "skipFiles": ["/**", "node_modules/**"] - }, - { - "name": "Debug Prod Server", - "type": "node", - "request": "launch", - "program": "${workspaceFolder}/scripts/run-next.mjs", - "args": ["start"], - "console": "integratedTerminal", - "skipFiles": ["/**", "node_modules/**"] - }, - { - "name": "Attach to Running Server", - "type": "node", - "request": "attach", - "port": 9229, - "skipFiles": ["/**", "node_modules/**"] - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json index b5113dde4d..c6b72dd90d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,7 +36,18 @@ "**/node_modules/**": true, "**/.next/**": true, "**/coverage/**": true, - "**/_tasks/**": true + "**/_tasks/**": true, + "**/.git/objects/**": true, + "**/dist/**": true, + "**/build/**": true, + "**/out/**": true, + "**/.cache/**": true, + "**/.turbo/**": true, + "**/OmniRoute-*/**": true, + "**/*-merge-*/**": true, + "**/*-worktree-*/**": true, + "**/*-issues-*/**": true, + "**/*-reorg*/**": true }, "search.exclude": { "**/_references": true, @@ -45,6 +56,14 @@ "**/node_modules": true, "**/.next": true, "**/coverage": true, - "**/_tasks": true + "**/_tasks": true, + "**/dist": true, + "**/build": true, + "**/out": true, + "**/OmniRoute-*": true, + "**/*-merge-*": true, + "**/*-worktree-*": true, + "**/*-issues-*": true, + "**/*-reorg*": true } } diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 3b1f496ef4..c6cc851058 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -595,7 +595,7 @@ export function mapRawModelToModelV2( video: outMods.has("video"), pdf: outMods.has("pdf"), }, - interleaved: false, + interleaved: Boolean(caps.thinking), }, cost: { input: 0, @@ -845,7 +845,7 @@ export function mapComboToModelV2( video: modalityAllHave(memberOutMods, "video"), pdf: modalityAllHave(memberOutMods, "pdf"), }, - interleaved: false, + interleaved: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)), }; return { diff --git a/CHANGELOG.md b/CHANGELOG.md index eb83657aba..56b4b2d113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,49 @@ --- +## [3.8.10] — 2026-06-04 + +OAuth resilience & observability release: spaced/sequential quota sync for OAuth accounts, a per-provider proactive-refresh skip list to keep short-TTL providers (Kimi) alive without re-exposing the Codex Auth0 cascade, token-expiry visibility on the provider cards, a new provider-stats dashboard, plus a wide batch of provider fixes (DeepSeek-web tool calls, Antigravity, Qoder, MiniMax, GitHub Copilot, Fireworks, llama.cpp, t3.chat-web, Kiro, Kilocode) and Podman deployment support. + +### ✨ New Features + +- **dashboard:** new Provider Stats page + `/api/provider-stats` endpoint — per-provider and per-model aggregates from `call_logs` plus live combo/telemetry/tool-latency overlays. (#3175 — thanks @pizzav-xyz / @diegosouzapw) +- **metrics:** cross-request TTFT and gap-after-tool-call latency tracking, aggregated per provider. (#3173 — thanks @pizzav-xyz / @diegosouzapw) +- **quota:** show the OAuth token expiry on provider cards (small, blue, informative — "Token expires in …" / "Token expired"). (#3178 — thanks @diegosouzapw) +- **responses:** strip `previous_response_id` for stateless Responses upstreams, with an auto/strip/preserve setting + UI so stateless clients (e.g. VS Code Custom Endpoint) keep context. (#3143 — thanks @JxnLexn) +- **deploy:** Podman/rootless deployment support (contrib units + `CONTAINER_HOST` hint) and larger upload body-size limits for `/v1/files`. (#3128 — thanks @hartmark) + +### 🔧 Bug Fixes + +- **usage:** sequential + spaced OAuth quota sync (`PROVIDER_LIMITS_SYNC_SPACING_MS`) so a host no longer bursts simultaneous usage/refresh requests; reactive forced re-mint after a 401 on the per-card refresh (recovers imported accounts); a genuine 401 now surfaces a re-authenticate hint. (#3156 — thanks @diegosouzapw) +- **healthcheck:** per-provider proactive-refresh skip list (`OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS`) — keep rotating-cascade providers (Codex/OpenAI) reactive-only while short-TTL providers (Kimi-coding) keep refreshing proactively. (#3159 — thanks @diegosouzapw) +- **providers:** on `?refresh=true` with no remote models, don't resurface the just-cleared synced cache into the local-catalog fallback. (#3181 — thanks @diegosouzapw) +- **providers:** use synced models as the authoritative local catalog across all providers (even on connections that didn't run the sync). (#3148 — thanks @herjarsa) +- **web-tools:** parse bare-JSON tool calls for DeepSeek-web with fuzzy tool-name matching scoped to the requested tools. (#3157 — thanks @wilsonicdev) +- **responses:** normalize `image_url` parts across every Responses input path (message content, replayed output items, `function_call_output`) to avoid upstream 400s. (#3150 — thanks @wilsonicdev) +- **antigravity:** dynamic upstream model resolution via the MITM alias table (server-only executor), with a guard against corrupted alias values. (#3144 — thanks @herjarsa) +- **qoder:** bifurcate validation by token type — PAT (`pt-`) → Cosy, regular API key → dashscope — matching the executor's routing. (#3149 — thanks @herjarsa) +- **api-manager:** preserve API key expiration in local time (the `datetime-local` input no longer silently shifts to UTC) + a clear button. (#3146 — thanks @xz-dev) +- **opencode-plugin:** map `caps.thinking → ModelV2.capabilities.interleaved` for single models and combos. (#3138 — thanks @mrmm) +- **kiro:** optional `targetProvider` on the social-OAuth exchange so Kiro-based providers can reuse the social login flow. (#3176 — thanks @pizzav-xyz) +- **misc:** broaden the DeepSeek reasoning-replay regex (`-free` / `zen/deepseek-v4`), export `ProviderProfile`, and guard a non-string directory entry in the binary manager. (#3177 — thanks @pizzav-xyz) +- **providerRegistry:** point kilocode at the OpenAI format + default executor (matching its sibling `kilo-gateway`). (#3166 — thanks @androw) +- **fireworks:** preserve fully-qualified router/model IDs so Fire Pass router IDs (`accounts/fireworks/routers/...`) are no longer double-prefixed into an upstream 404. (#3133 — thanks @KooshaPari) +- **llama-cpp:** route requests to the configured local baseUrl instead of OpenAI's API (which returned an OpenAI-worded 401). (#3136 — thanks @tjengbudi) +- **t3-chat-web:** parse cookies + convexSessionId from the single stored credential so t3.chat web connections work (the executor previously read fields the credential pipeline never produced). (#3007 — thanks @minhtran162) +- **minimax:** stop capping MiniMax-M3 / MiniMax-M2.7 `max_tokens` at the 8192 default — add the M3 model spec (512K output) and make model-spec lookups case-insensitive. (#3141 — thanks @totaltube) +- **github-copilot:** discover the model catalog live from `api.githubcopilot.com/models` so Import Models refreshes and only entitled models are listed (with fallback to the static catalog). (#3120, #3121 — thanks @gabrielmoreira) +- **combo:** invalidate the nested-combo cache on combo edits so removed targets/models stop being served within the 10s window; log the resolved DATA_DIR at startup to diagnose multi-replica volume mismatches. (#3147 — thanks @ViFigueiredo) +- **providers:** resolve web-provider alias collisions. (thanks @diegosouzapw) + +### 📝 Maintenance + +- **deps:** bump hono from 4.12.18 to 4.12.23. (#3179 — thanks @dependabot) +- **ci(electron):** make the macOS-arm64 smoke step best-effort (headless GPU crash). (#3137 — thanks @diegosouzapw) +- **chore(release):** open the v3.8.10 development cycle. (thanks @diegosouzapw) + +--- + ## [3.8.9] — 2026-06-03 ### ✨ New Features diff --git a/ONBOARDING.md b/ONBOARDING.md deleted file mode 100644 index e0cba2070d..0000000000 --- a/ONBOARDING.md +++ /dev/null @@ -1,72 +0,0 @@ -# Welcome to OmniRoute - -## How We Use Claude - -Based on diegosouzapw's usage over the last 30 days: - -Work Type Breakdown: -Build Feature ████████████████████ 50% -Plan Design ██████████░░░░░░░░░░ 25% -Improve Quality ██████░░░░░░░░░░░░░░ 15% -Write Docs ████░░░░░░░░░░░░░░░░ 10% - -Top Skills & Commands: -_no slash commands captured in this window_ - -Top MCP Servers: -_no MCP usage captured in this window_ - -## Your Setup Checklist - -### Codebases - -- [ ] omniroute — https://github.com/diegosouzapw/omniroute -- [ ] OpenCode_Ecosystem (fork) — https://github.com/diegosouzapw/OpenCode_Ecosystem -- [ ] OpenCode_Ecosystem (upstream) — https://github.com/MarceloClaro/OpenCode_Ecosystem - -### MCP Servers to Activate - -- [ ] _none required from current usage. If you'll be working on OmniRoute itself, ask the team about the project's own embedded MCP server at `/api/mcp/stream`._ - -### Skills to Know About - -- _no skills surfaced from usage data. The team's workflow leaned heavily on direct file edits, git/gh CLI, and subagent dispatch for parallel work — Claude figures these out from context._ - -## Team Tips - -- **Read `CLAUDE.md` first.** It has hard rules that override defaults — e.g. never write raw SQL in routes (use `src/lib/db/` modules), never add `Co-Authored-By: Claude` to commits, error responses must go through `buildErrorBody()` / `sanitizeErrorMessage()`. -- **Subagents for parallel work.** When tasks are independent, dispatch multiple sonnet subagents in one message instead of doing them yourself. Always audit `git diff` after a subagent finishes — its summary describes intent, not necessarily the result. -- **Conventional Commits** for everything: `feat(scope):`, `fix(scope):`, `chore(scope):`, `docs:`. Scopes used here include `db`, `sse`, `oauth`, `dashboard`, `api`, `agents`, `plugin`, `skills`, `commands`. -- **Run the full validation suite before declaring done** — `npm run check` (lint + tests) at minimum; `npm run test:coverage` if you changed production code. Hard gate: 75/75/75/70 (statements/lines/functions/branches). -- **Husky pre-push runs unit tests.** Don't `--no-verify` past it without explicit approval — the project documents this as a hard rule. - -## Get Started - -- Clone the repo and run `npm install` (auto-generates `.env` from `.env.example`). -- Generate secrets: `openssl rand -base64 48` for `JWT_SECRET`, `openssl rand -hex 32` for `API_KEY_SECRET`. Paste into `.env`. -- `npm run dev` → dashboard at `http://localhost:20128`. -- Read `docs/architecture/REPOSITORY_MAP.md` for the file layout, then `docs/architecture/ARCHITECTURE.md` for how requests flow. -- For a first PR: pick something from `_tasks/` if there's a backlog, or grep for `// TODO` and pick a small one. Run `npm run check` before opening the PR. - - diff --git a/README.md b/README.md index ab938f7545..5bfa7ec184 100644 --- a/README.md +++ b/README.md @@ -540,6 +540,22 @@ devbox run npm run dev 📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) — Compose profiles, Caddy HTTPS, Cloudflare tunnels. +**🦭 Podman** + +```bash +# 1. Build the image +podman build --target runner-base -t omniroute:base . + +# 2. Fix data directory permissions for rootless Podman +mkdir -p data && podman unshare chown 1000:1000 ./data + +# 3. Set runtime in .env, then run (see contrib/podman/ for Quadlet) +echo "CONTAINER_HOST=podman" >> .env +podman compose --profile base up -d +``` + +📖 [Podman Guide](contrib/podman/README.md) — Quadlet setup, podman-compose, Quadlet. +
@@ -779,6 +795,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo | Document | Description | | ---------------------------------------------------- | -------------------------------------------------------------- | | [Docker Guide](docs/guides/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags | +| [Podman Guide](contrib/podman/README.md) | Quadlet systemd integration, podman-compose, SELinux | | [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | | [Fly.io Deployment](docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md) | Deploy to Fly.io with persistent storage | | [Termux Guide](docs/guides/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux | diff --git a/contrib/podman/README.md b/contrib/podman/README.md new file mode 100644 index 0000000000..7ebc6fc9b0 --- /dev/null +++ b/contrib/podman/README.md @@ -0,0 +1,128 @@ +# Podman Deployment + +Run OmniRoute with Podman via **Quadlet** (systemd integration) or **podman compose**. + +--- + +## Option A: Quadlet (recommended) + +### 1. Build the image + +```bash +cd /path/to/omniroute +podman build --target runner-base -t omniroute:base . +# For web-cookie providers (gemini-web, claude-web, claude-turnstile): +podman build --target runner-web -t omniroute:web . +# For CLI tool support: +podman build --target runner-cli -t omniroute:cli . +``` + +### 2. Copy Quadlet files to the systemd directory + +```bash +mkdir -p ~/.config/containers/systemd/omniroute +cp contrib/podman/*.container ~/.config/containers/systemd/omniroute/ +cp contrib/podman/*.network ~/.config/containers/systemd/omniroute/ +cp contrib/podman/*.volume ~/.config/containers/systemd/omniroute/ +``` + +### 3. Mount the project .env for secrets + +Edit `~/.config/containers/systemd/omniroute/omniroute.container` and +uncomment/replace the `EnvironmentFile` line with the absolute path to +your project `.env`: + +``` +EnvironmentFile=/home/USER/code/docker/OmniRoute/.env +``` + +Make sure `CONTAINER_HOST=podman` is set in that `.env`. + +Alternatively, edit the env vars directly in the `.container` file. + +### 4. Reload systemd and start + +```bash +systemctl --user daemon-reload +systemctl --user start omniroute-redis +systemctl --user start omniroute +``` + +### 5. Verify + +```bash +systemctl --user status omniroute +curl http://localhost:20128/v1/models +``` + +To follow logs: + +```bash +journalctl --user -u omniroute -f +``` + +### 6. Enable on boot + +```bash +systemctl --user enable omniroute-redis +systemctl --user enable omniroute +``` + +--- + +## Option B: podman compose + +The project's `docker-compose.yml` now works with both Docker and Podman. +Just set `CONTAINER_HOST=podman` in `.env` before starting. + +### 1. Prepare the data directory + +Rootless Podman maps container UIDs into a subordinate range. The +`node` user (UID 1000) inside the container maps to a different UID +on the host, so it cannot write to `./data` owned by your host user. + +Fix the ownership **before** starting: + +```bash +mkdir -p data +podman unshare chown 1000:1000 ./data +``` + +### 2. Set the runtime in `.env` + +Make sure `.env` contains: + +```env +CONTAINER_HOST=podman +``` + +### 3. Start + +```bash +podman compose --profile base up -d +``` + +### Profiles + +Same profiles as `docker compose`: + +| Profile | Command | +| ------------------------------ | ---------------------------------------------------- | +| `base` (no CLIs) | `podman compose --profile base up -d` | +| `web` (+Chromium/Playwright) | `podman compose --profile web up -d` | +| `cli` (+CLI tools) | `podman compose --profile cli up -d` | +| `host` (host-mounted binaries) | `podman compose --profile host up -d` | +| `cliproxyapi` (sidecar) | `podman compose --profile cliproxyapi up -d` | + +--- + +## How it works + +The `docker-compose.yml` uses fully-qualified image names +(`docker.io/library/redis:7-alpine`) and flat variable expansions so it +works with both Docker and Podman without a separate compose file. + +The entrypoint script (`check-permissions.sh`) reads `CONTAINER_HOST` +from `.env` to give the correct fix instructions: +- **docker**: `sudo chown -R ... ./data` +- **podman**: `podman unshare chown 1000:1000 ./data` diff --git a/contrib/podman/omniroute-redis.container b/contrib/podman/omniroute-redis.container new file mode 100644 index 0000000000..326ac92559 --- /dev/null +++ b/contrib/podman/omniroute-redis.container @@ -0,0 +1,26 @@ +[Unit] +Description=OmniRoute Redis (rate-limiter backend) +After=network-online.target +Wants=network-online.target + +[Container] +Image=docker.io/redis:7-alpine +ContainerName=omniroute-redis + +Network=omniroute.network +NetworkAlias=redis + +Volume=omniroute-redis.volume:/data + +Exec=redis-server --save 60 1 --loglevel warning + +HealthCmd=/bin/sh -c "redis-cli ping | grep -q PONG" +HealthInterval=10s +HealthRetries=3 +HealthTimeout=5s + +[Service] +Restart=always + +[Install] +WantedBy=default.target diff --git a/contrib/podman/omniroute-redis.volume b/contrib/podman/omniroute-redis.volume new file mode 100644 index 0000000000..e14c448891 --- /dev/null +++ b/contrib/podman/omniroute-redis.volume @@ -0,0 +1,3 @@ +# Persistent Redis data +[Volume] +Label=omniroute diff --git a/contrib/podman/omniroute.container b/contrib/podman/omniroute.container new file mode 100644 index 0000000000..846e681a27 --- /dev/null +++ b/contrib/podman/omniroute.container @@ -0,0 +1,50 @@ +[Unit] +Description=OmniRoute — Unified AI Proxy +After=network-online.target +Wants=network-online.target +Requires=omniroute-redis.service +After=omniroute-redis.service + +[Container] +# Use this image if your building locally +Image=localhost/omniroute:base +# Use this image if you are pulling from Docker Hub +# Image=diegosouzapw/omniroute:base + +ContainerName=omniroute + +Network=omniroute.network +PublishPort=20128:20128 +PublishPort=20129:20129 + +Volume=omniroute.volume:/app/data:Z + +# Use a bind mount if you need direct host access to the data: +# Volume=/home/USER/code/docker/OmniRoute/data:/app/data:Z + +Environment=DATA_DIR=/app/data +Environment=PORT=20128 +Environment=DASHBOARD_PORT=20128 +Environment=API_PORT=20129 +Environment=API_HOST=0.0.0.0 +Environment=REDIS_URL=redis://redis:6379 +Environment=JWT_SECRET=change-me-to-a-random-base64-string +Environment=API_KEY_SECRET=change-me-to-a-random-base64-string +Environment=INITIAL_PASSWORD=change-me-to-a-random-hex-string +Environment=NODE_ENV=production +Environment=REQUIRE_API_KEY=true + +# Load additional secrets (API keys, OAuth creds) from the project .env: +# EnvironmentFile=%h/code/docker/OmniRoute/.env + +HealthCmd=node /app/healthcheck.mjs +HealthInterval=30s +HealthRetries=3 +HealthTimeout=5s +HealthStartPeriod=15s + +[Service] +Restart=always + +[Install] +WantedBy=default.target diff --git a/contrib/podman/omniroute.network b/contrib/podman/omniroute.network new file mode 100644 index 0000000000..4f618e9943 --- /dev/null +++ b/contrib/podman/omniroute.network @@ -0,0 +1,2 @@ +[Network] +Label=omniroute diff --git a/contrib/podman/omniroute.volume b/contrib/podman/omniroute.volume new file mode 100644 index 0000000000..d0d3ae8cbc --- /dev/null +++ b/contrib/podman/omniroute.volume @@ -0,0 +1,3 @@ +# OmniRoute persistent data (SQLite DB, logs, backups) +[Volume] +Label=omniroute diff --git a/devbox.json b/devbox.json deleted file mode 100644 index 87fd4a2357..0000000000 --- a/devbox.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "name": "omniroute", - "version": "3.8.2", - "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", - "packages": [ - "nodejs_22", - ], - "shell": { - "init_hook": [ - "echo 'Welcome to OmniRoute dev environment'", - ], - "hooks": { - "on_create": "npm install" - } - } -} diff --git a/devbox.lock b/devbox.lock deleted file mode 100644 index ae5d932cd6..0000000000 --- a/devbox.lock +++ /dev/null @@ -1,26 +0,0 @@ -{ - "lockfile_version": "1", - "packages": { - "eslint": { - "resolved": "github:NixOS/nixpkgs/6dedf69f94d03cbe7bdde106f2d4c23ae2a853bf#eslint", - "source": "nixpkg" - }, - "github:NixOS/nixpkgs/nixpkgs-unstable": { - "last_modified": "2026-05-22T01:51:30Z", - "resolved": "github:NixOS/nixpkgs/6dedf69f94d03cbe7bdde106f2d4c23ae2a853bf?lastModified=1779414690" - }, - "nodejs_22": { - "plugin_version": "0.0.2", - "resolved": "github:NixOS/nixpkgs/6dedf69f94d03cbe7bdde106f2d4c23ae2a853bf#nodejs_22", - "source": "nixpkg" - }, - "pnpm": { - "resolved": "github:NixOS/nixpkgs/6dedf69f94d03cbe7bdde106f2d4c23ae2a853bf#pnpm", - "source": "nixpkg" - }, - "typescript": { - "resolved": "github:NixOS/nixpkgs/6dedf69f94d03cbe7bdde106f2d4c23ae2a853bf#typescript", - "source": "nixpkg" - } - } -} diff --git a/docker-compose.yml b/docker-compose.yml index 8a249e335f..e30f4c0d2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ x-common: &common environment: - DATA_DIR=/app/data # Must match the volume mount below - PORT=${PORT:-20128} - - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} + - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} - REDIS_URL=${REDIS_URL:-redis://redis:6379} @@ -43,7 +43,7 @@ x-common: &common services: # ── Redis (Rate Limiter Backend) ────────────────────────────────── redis: - image: redis:7-alpine + image: docker.io/library/redis:7-alpine container_name: omniroute-redis restart: unless-stopped ports: @@ -66,7 +66,7 @@ services: target: runner-base image: omniroute:base ports: - - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" profiles: - base @@ -83,7 +83,7 @@ services: target: runner-web image: omniroute:web ports: - - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" profiles: - web @@ -97,7 +97,7 @@ services: target: runner-cli image: omniroute:cli ports: - - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" volumes: - ./data:/app/data @@ -116,12 +116,12 @@ services: target: runner-base image: omniroute:base ports: - - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" environment: - DATA_DIR=/app/data - PORT=${PORT:-20128} - - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} + - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} - CLI_MODE=host diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 8d813ea526..89f7968ef9 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 73d9da67fd..3461875e4d 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 73d9da67fd..3461875e4d 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 5ddd912d40..9242a74359 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index 483d32503d..325e28e7a6 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index dc8e62974c..1a8e491c17 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 2fa62b0e48..90445e09a7 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 9290d2fc5d..f4f5d27807 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index b3c02ef5ec..0f93651755 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 0316fffb8e..6f26b40958 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 941023cbd2..8e54d78c42 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 1def3ed544..a6caf3e7f9 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index bd1bf55274..4447f1a274 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 677dcce598..1e78f828b3 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 8612e6d80a..b005468f67 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index e1a9c1ae77..dca5461e8b 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index d9f79c90cb..605a8895b7 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index c1ef920607..17703c984f 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 8e770461ee..8bf9c38366 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 3c6407f541..0ba8b00b7c 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index af8e27de0f..71e9564821 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index e58a6e0cd4..d8f8cb2ed4 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index f6e37359fb..04fb8e7692 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index 2e2b5b04f1..73b7bc4f5f 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 2e28617805..97a0b4e6ea 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 1f66b35766..947a301e05 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index bebdacc5c2..5cb2163a4e 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 0592522bfe..e22a033244 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 6387aa3f5d..5823515864 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 5b6368d1cb..9bfb7479a8 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 45e56f92e2..264c15d459 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 010a5181be..ba65e04af0 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 7fc8f13f92..8f926685ab 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 0654f98f37..a3c681eb05 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 7b769d11a3..fc4413b994 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 0915802f7e..9304df4180 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index f392b89bdc..d6cb2a0ccf 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 9c5c8858e9..89e8526c08 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 222b46f47b..22b814a53d 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 2144352d09..eefcd9ee09 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index d79a366b57..730f389926 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.10] — Unreleased + +--- + ## [3.8.9] — Unreleased --- diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c3be99fc53..67e2b6156a 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -350,6 +350,7 @@ detection above). | `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | Compression algorithm/profile. Disable values: `0`, `false`, `off`. | | `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. | +| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). | | `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. | | `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(unset)_ | `src/lib/config/runtimeSettings.ts` | Force background tasks on under automated test detection. Set `1` to override the test heuristic. | | `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | Budget reset check cadence (ms). Floor `10000`. | @@ -877,6 +878,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. | | `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. | | `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. | +| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` under rootless Podman so the fix instructions use `podman unshare chown` instead of `sudo chown`. | | `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. | | `QUOTA_STORE_REDIS_URL` | _(unset)_ | `src/lib/quota/storeFactory.ts` | Redis connection string used when `QUOTA_STORE_DRIVER=redis` (e.g. `redis://localhost:6379`). | | `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | Pool saturation ratio (0..1); at/above it the pool enters strict mode (no borrowing). | @@ -909,6 +911,7 @@ value below unset in production deployments. | `OMNIROUTE_E2E_PASSWORD` | falls back to `INITIAL_PASSWORD` | `scripts/dev/run-next-playwright.mjs` | Admin password injected into the Playwright environment. | | `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | Disable the local healthcheck poll during Playwright runs. | | `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | Disable the OAuth token healthcheck loop during tests. | +| `OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS` | _(unset)_ | `src/lib/tokenHealthCheck.ts` | Comma-separated providers excluded from the proactive token-refresh sweep (e.g. `codex,openai`). Targeted alternative to fully disabling the healthcheck — short-TTL providers keep refreshing while cascade providers stay reactive-only. | | `OMNIROUTE_HIDE_HEALTHCHECK_LOGS` | `true` | `scripts/dev/run-next-playwright.mjs` | Silence healthcheck noise in Playwright stdout. | | `OMNIROUTE_PLAYWRIGHT_SKIP_BUILD` | `0` | `scripts/dev/run-next-playwright.mjs` | Skip the Next.js production build before Playwright starts (CI optimization). | | `OMNIROUTE_SKIP_UNINSTALL_HOOK` | `0` | `scripts/build/uninstall.mjs` | Skip the OmniRoute uninstall hook (used by CI to keep `node_modules` intact). | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index c67478f664..38341c8d90 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.9 + version: 3.8.10 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, diff --git a/electron/package.json b/electron/package.json index 660a4760e8..a8da362d8e 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.9", + "version": "3.8.10", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/next.config.mjs b/next.config.mjs index ce4252d2bf..1dc08a6dcf 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -104,6 +104,10 @@ const nextConfig = { serverActions: { bodySizeLimit: process.env.OMNIROUTE_SERVER_ACTIONS_BODY_LIMIT || "50mb", }, + // Next.js proxy (middleware) has a default 10MB body clone limit. File + // uploads (OpenAI-compatible /v1/files) routinely exceed this. Match the + // 512 MB server-side cap; tune via env if needed. + proxyClientMaxBodySize: process.env.NEXT_PROXY_BODY_LIMIT || "512mb", }, outputFileTracingRoot: projectRoot, outputFileTracingIncludes: { diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index fb304d7964..c0a89b8e6a 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -112,6 +112,12 @@ export interface RegistryEntry { modelsUrl?: string; /** Prefix to prepend to model IDs before upstream API calls (e.g. "accounts/fireworks/models/") */ modelIdPrefix?: string; + /** + * Additional already-qualified model ID prefixes that must NOT receive `modelIdPrefix` + * (e.g. Fireworks router IDs "accounts/fireworks/routers/"). Prevents double-prefixing + * fully-qualified IDs that legitimately differ from `modelIdPrefix`. See issue #3133. + */ + acceptedModelIdPrefixes?: string[]; chatPath?: string; clientVersion?: string; timeoutMs?: number; @@ -1861,8 +1867,8 @@ const _REGISTRY_EAGER: Record = { kilocode: { id: "kilocode", alias: "kc", - format: "openrouter", - executor: "openrouter", + format: "openai", + executor: "default", baseUrl: "https://api.kilo.ai/api/openrouter/chat/completions", modelsUrl: "https://api.kilo.ai/api/openrouter/models", authType: "oauth", @@ -2437,7 +2443,9 @@ const _REGISTRY_EAGER: Record = { huggingchat: { id: "huggingchat", - alias: "hc", + // Distinct alias: "hc" belongs to the hackclub provider; huggingchat is + // addressed by its own id to avoid the alias collision. + alias: "huggingchat", format: "openai", executor: "huggingchat", baseUrl: "https://huggingface.co/chat/conversation", @@ -3035,6 +3043,7 @@ const _REGISTRY_EAGER: Record = { modelsUrl: "https://api.fireworks.ai/v1/accounts/fireworks/models?filter=supports_serverless=true", modelIdPrefix: "accounts/fireworks/models/", + acceptedModelIdPrefixes: ["accounts/fireworks/models/", "accounts/fireworks/routers/"], authType: "apikey", authHeader: "bearer", models: [ @@ -3863,7 +3872,9 @@ const _REGISTRY_EAGER: Record = { "kimi-web": { id: "kimi-web", - alias: "kimi", + // Distinct alias: the primary "kimi" provider (dedicated KimiExecutor) keeps + // the short "kimi" alias; this web/cookie variant is addressed by its own id. + alias: "kimi-web", format: "openai", executor: "kimi-web", baseUrl: "https://kimi.moonshot.cn/api/chat", @@ -3891,7 +3902,9 @@ const _REGISTRY_EAGER: Record = { "qwen-web": { id: "qwen-web", - alias: "qw", + // Distinct alias: the primary "qwen" provider keeps the short "qw" alias; + // this web/cookie variant is addressed by its own id. + alias: "qwen-web", format: "openai", executor: "qwen-web", baseUrl: "https://chat.qwen.ai/api/chat/completions", diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 791e8cc273..d4e588b95e 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -29,6 +29,7 @@ import { handleCreditsFailure, } from "../services/antigravityCredits.ts"; import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; +import { getMitmAlias } from "@/lib/db/models"; import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; import { resolveAntigravityVersion } from "../services/antigravityVersion.ts"; import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts"; @@ -401,12 +402,38 @@ function flushAntigravitySSEText( * Strip provider prefixes (e.g. "antigravity/model" → "model"). * Ensures the model name sent to the upstream API never contains a routing prefix. */ -function cleanModelName(model: string): string { +async function cleanModelName(model: string): Promise { if (!model) return model; - let clean = model.includes("/") ? model.split("/").pop()! : model; - clean = resolveAntigravityModelId(clean); - // Normalize bare Pro IDs to the Low tier (matching OpenClaw convention). - // The upstream API requires an explicit tier suffix; bare IDs cause errors. + const stripped = model.includes("/") ? model.split("/").pop()! : model; + let clean = stripped; + + // 1. Check dynamic MITM aliases first (authoritative after first sync). + // Built during model sync — contains ONLY currently-available models. + // Obsolete/removed models are automatically excluded. + try { + const mitmAliases = await getMitmAlias("antigravity"); + if (mitmAliases && typeof mitmAliases === "object") { + const aliases = mitmAliases as Record; + const raw = aliases[stripped]; + // Only honor string aliases; corrupted/non-string DB values fall through + // to the static alias resolution below (never return undefined here). + if (typeof raw === "string" && raw) { + // Strip the "antigravity/" prefix if present; use the raw model ID otherwise. + const PREFIX = "antigravity/"; + clean = raw.startsWith(PREFIX) ? raw.slice(PREFIX.length) : raw; + } + } + } catch { + // DB not available (build phase, transient error) — fall through to static aliases + } + + // 2. Fall back to static aliases if MITM didn't resolve + if (clean === stripped) { + clean = resolveAntigravityModelId(clean); + } + + // 3. Normalize bare Pro IDs to the Low tier (matching OpenClaw convention). + // The upstream API requires an explicit tier suffix; bare IDs cause errors. if (BARE_PRO_IDS.has(clean)) { clean = `${clean}-low`; } @@ -610,7 +637,7 @@ export class AntigravityExecutor extends BaseExecutor { return resp as unknown as never; } - const upstreamModel = cleanModelName(model); + const upstreamModel = await cleanModelName(model); const isClaude = upstreamModel.toLowerCase().includes("claude"); const baseBody = bodyRecord; const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel) diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 2cb4bd9423..55b10aa2da 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -1031,7 +1031,8 @@ export class DeepSeekWebExecutor extends BaseExecutor { await cleanupFn(); const { content: cleanedContent, toolCalls } = parseToolCallsFromText( content, - `call-${Date.now()}` + `call-${Date.now()}`, + requestedTools ); return buildToolAwareResult({ stream: stream !== false, diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 8595e82d39..0d0e9989b0 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -205,6 +205,7 @@ export class DefaultExecutor extends BaseExecutor { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeOpenAIChatUrl(baseUrl); } + case "llama-cpp": case "lm-studio": case "modal": case "reka": @@ -510,8 +511,16 @@ export class DefaultExecutor extends BaseExecutor { const entry = getRegistryEntry(this.provider); if (entry?.modelIdPrefix) { const body = withDefaults as Record; - if (typeof body.model === "string" && !body.model.startsWith(entry.modelIdPrefix)) { - body.model = `${entry.modelIdPrefix}${body.model}`; + if (typeof body.model === "string") { + // Skip prepending when the model already carries the canonical prefix OR any + // other accepted fully-qualified prefix (e.g. Fireworks router IDs). #3133. + const acceptedPrefixes = [entry.modelIdPrefix, ...(entry.acceptedModelIdPrefixes ?? [])]; + const alreadyQualified = acceptedPrefixes.some((prefix) => + (body.model as string).startsWith(prefix) + ); + if (!alreadyQualified) { + body.model = `${entry.modelIdPrefix}${body.model}`; + } } } } diff --git a/open-sse/executors/t3-chat-web.ts b/open-sse/executors/t3-chat-web.ts index 610d3cf647..027e5e75d5 100644 --- a/open-sse/executors/t3-chat-web.ts +++ b/open-sse/executors/t3-chat-web.ts @@ -32,6 +32,9 @@ const TSS_ACCEPT = "application/x-tss-framed, application/x-ndjson, application/ // ─── Types ─────────────────────────────────────────────────────────────────── export interface T3ChatCredentials { + /** Parsed Cookie header value, guaranteed to include convex-session-id when present. */ + cookieHeader: string; + /** Raw cookies portion (without the synthesized convex-session-id suffix). */ cookies: string; /** convex-session-id — stored as a cookie by t3.chat, sent in the Cookie header */ convexSessionId: string; @@ -39,13 +42,69 @@ export interface T3ChatCredentials { // ─── Helpers ───────────────────────────────────────────────────────────────── -function validateCredentials(creds: unknown): creds is T3ChatCredentials { - const raw = typeof creds === "object" && creds !== null ? (creds as Record) : {}; +/** + * Parse the single stored credential into a structured t3.chat cookie object. + * + * The credential pipeline (`src/sse/services/auth.ts`) stores the single pasted + * string as `credentials.apiKey` (fallback `accessToken`) — it never produces + * `cookies`/`convexSessionId` fields. So we parse the raw string here, mirroring + * the validator in `src/lib/providers/validation.ts` (#3007). + * + * Accepted forms: + * (a) "convex-session-id=abc; sessionToken=xyz" — plain Cookie header + * (b) full Cookie header already containing convex-session-id=... + * (c) "cookies=\nconvexSessionId=" — structured form + */ +export function parseT3Credentials(creds: unknown): T3ChatCredentials { + const rawCreds = + typeof creds === "object" && creds !== null ? (creds as Record) : {}; + const raw = String(rawCreds.apiKey ?? rawCreds.accessToken ?? "").trim(); + if (!raw) { + return { cookieHeader: "", cookies: "", convexSessionId: "" }; + } + + let cookieHeader = raw; + let convexSessionId = ""; + + if (raw.includes("convexSessionId") || raw.includes("convex-session-id")) { + // Structured / multi-part format: split on separators and pull out the id. + const parts = raw.split(/[,;\n]/).map((s) => s.trim()); + const cookieParts: string[] = []; + for (const part of parts) { + if (part.startsWith("convexSessionId=") || part.startsWith("convex-session-id=")) { + convexSessionId = part.split("=").slice(1).join("="); + } else if (part.startsWith("cookies=")) { + cookieParts.push(part.slice("cookies=".length)); + } else if (part.includes("=")) { + cookieParts.push(part); + } + } + if (cookieParts.length) cookieHeader = cookieParts.join("; "); + } + + // Synthesize the final Cookie header, appending convex-session-id only when it + // was provided separately and isn't already embedded in the header. + const finalCookie = + convexSessionId && !cookieHeader.includes("convex-session-id") + ? `${cookieHeader}; convex-session-id=${convexSessionId}` + : cookieHeader; + + // Derive convexSessionId from an embedded header form (b) for validation. + if (!convexSessionId) { + const m = finalCookie.match(/convex-session-id=([^;]+)/); + if (m) convexSessionId = m[1].trim(); + } + + return { cookieHeader: finalCookie, cookies: cookieHeader, convexSessionId }; +} + +export function validateT3Credentials(creds: T3ChatCredentials | null | undefined): boolean { + if (!creds) return false; return ( - typeof raw.cookies === "string" && - raw.cookies.length > 0 && - typeof raw.convexSessionId === "string" && - raw.convexSessionId.length > 0 + typeof creds.cookieHeader === "string" && + creds.cookieHeader.length > 0 && + typeof creds.convexSessionId === "string" && + creds.convexSessionId.length > 0 ); } @@ -62,17 +121,6 @@ function buildErrorResponse(status: number, message: string): Response { ); } -/** - * Build Cookie header value. Includes the convex-session-id as a cookie - * (confirmed from live capture: t3.chat sets convex-session-id as a cookie, - * not as a separate header). - */ -function buildCookieHeader(cookies: string, convexSessionId: string): string { - const base = cookies.trim(); - if (base.includes("convex-session-id")) return base; - return `${base}; convex-session-id=${convexSessionId}`; -} - /** * Build standard TanStack Start headers matching live captured traffic. * The x-deployment-id header is optional but helps CDN routing. @@ -275,7 +323,8 @@ export class T3ChatWebExecutor extends BaseExecutor { signal?: AbortSignal ): Promise { try { - if (!validateCredentials(credentials)) return false; + const parsed = parseT3Credentials(credentials); + if (!validateT3Credentials(parsed)) return false; // Probe: HEAD to t3.chat base — confirms site reachable and cookies accepted. // 200/302/404 all indicate reachability; 5xx = down. @@ -283,7 +332,7 @@ export class T3ChatWebExecutor extends BaseExecutor { method: "HEAD", headers: { "User-Agent": USER_AGENT, - Cookie: buildCookieHeader(credentials.cookies, credentials.convexSessionId), + Cookie: parsed.cookieHeader, }, signal, }); @@ -299,19 +348,15 @@ export class T3ChatWebExecutor extends BaseExecutor { role: string; content: string | unknown; }>; - const rawCreds = credentials as unknown as Record; - - // 1. Validate credentials - if (!validateCredentials(rawCreds)) { - const missing = !rawCreds.cookies - ? "cookies" - : !rawCreds.convexSessionId - ? "convexSessionId" - : "both fields"; + // 1. Parse + validate credentials. The credential pipeline stores the single + // pasted string as `apiKey` (fallback `accessToken`); parse out the Cookie + // header + convex-session-id (#3007) instead of expecting pre-structured fields. + const parsed = parseT3Credentials(credentials); + if (!validateT3Credentials(parsed)) { return { response: buildErrorResponse( 400, - `t3.chat credentials invalid: missing or empty ${missing}. Both 'cookies' and 'convexSessionId' are required.` + "t3.chat credentials invalid: paste your full Cookie header (including convex-session-id) from t3.chat." ), url: `${SERVER_FN_PREFIX}...`, headers: {}, @@ -319,10 +364,7 @@ export class T3ChatWebExecutor extends BaseExecutor { }; } - const cookieHeader = buildCookieHeader( - rawCreds.cookies as string, - rawCreds.convexSessionId as string - ); + const cookieHeader = parsed.cookieHeader; const headers = buildServerFnHeaders(cookieHeader); try { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 1d8e449434..dc0286ff50 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -24,6 +24,7 @@ import { runWithOnPersist, } from "../services/tokenRefresh.ts"; import { createRequestLogger } from "../utils/requestLogger.ts"; +import { applyResponsesPreviousResponseIdPolicy } from "../utils/responsesStatePolicy.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../config/defaultThinkingSignature.ts"; import { @@ -1373,24 +1374,33 @@ const PROXY_CONFIG_CACHE_TTL = 10_000; */ let _combosPromise: Promise | null = null; let _combosCacheTs = 0; +let _combosCacheVersionSnapshot = -1; const COMBOS_CACHE_TTL = 10_000; async function getCombosCached(): Promise { const now = Date.now(); + const { getCombos, getCombosCacheVersion } = await import("@/lib/localDb"); + const version = getCombosCacheVersion(); + // A combo write (create/update/delete/reorder) bumps the shared version via + // invalidateDbCache("combos"); when it no longer matches our snapshot we drop + // the cached promise so the nested-combo expansion stops serving removed + // targets/models within the 10s TTL window (#3147). + if (version !== _combosCacheVersionSnapshot) { + clearCombosCache(); + } if (_combosPromise && now - _combosCacheTs < COMBOS_CACHE_TTL) { return _combosPromise; } _combosCacheTs = now; - _combosPromise = (async () => { - const { getCombos } = await import("@/lib/localDb"); - return getCombos(); - })(); + _combosCacheVersionSnapshot = version; + _combosPromise = getCombos(); return _combosPromise; } export function clearCombosCache() { _combosPromise = null; _combosCacheTs = 0; + _combosCacheVersionSnapshot = -1; } export function clearUpstreamProxyConfigCache(providerId?: string) { @@ -3512,6 +3522,14 @@ export async function handleChatCore({ } translatedBody.model = finalModelToUpstream; + const previousResponseIdPolicy = applyResponsesPreviousResponseIdPolicy(translatedBody, { + mode: settings.responsesPreviousResponseIdMode, + sourceFormat, + targetFormat, + credentials, + }); + translatedBody = previousResponseIdPolicy.body as typeof translatedBody; + // #1789: Prevent output_config.effort from overriding effort encoded in model name (Codex) if (provider === "codex" || provider?.startsWith("codex")) { const hasEffortSuffix = finalModelToUpstream.match(/-(low|medium|high|xhigh)$/i); diff --git a/open-sse/package.json b/open-sse/package.json index d847eff1f0..d3d67c1c43 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.9", + "version": "3.8.10", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 69e069efde..140317964a 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -29,7 +29,7 @@ import { } from "../../src/shared/utils/classify429"; import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints"; -type ProviderProfile = { +export type ProviderProfile = { baseCooldownMs: number; useUpstreamRetryHints: boolean; /** Issue #2100 follow-up. Stored override; undefined → per-provider default. */ diff --git a/open-sse/services/githubCopilotModels.ts b/open-sse/services/githubCopilotModels.ts new file mode 100644 index 0000000000..b24a57c3e8 --- /dev/null +++ b/open-sse/services/githubCopilotModels.ts @@ -0,0 +1,134 @@ +/** + * GitHub Copilot live model discovery (#3120, #3121). + * + * The `github` (Copilot) provider previously shipped a STATIC hardcoded model + * catalog in `providerRegistry.ts` and had no discovery source, so "Import + * Models" could never refresh the list (#3120) and advertised models the + * account is not entitled to (e.g. gemini previews), which fail upstream with + * `400 ... not supported` when tested (#3121). + * + * Copilot exposes its per-account catalog at `https://api.githubcopilot.com/models`, + * authenticated with the Copilot bearer token + the standard Copilot chat + * headers. The response shape is `{ data: [{ id, name, model_picker_enabled, + * policy, capabilities, ... }] }`. We map `data[].id` into managed models. Only + * entitled models appear in the live response, so parsing it directly gives the + * entitlement filtering #3121 needs. + * + * A safe fallback to the existing static catalog is preserved for + * offline/unauthed/failed refresh so the import flow never breaks. + */ +import { getGitHubCopilotChatHeaders } from "../config/providerHeaderProfiles.ts"; + +export const GITHUB_COPILOT_MODELS_URL = "https://api.githubcopilot.com/models"; + +export type GitHubCopilotModel = { + id: string; + name: string; + owned_by: string; +}; + +type RawRecord = Record; + +function asRecord(value: unknown): RawRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Parse a Copilot `/models` response into managed model rows. Only ids present + * in the live response are returned, which is exactly the entitlement filter + * #3121 requires. + */ +export function parseGitHubCopilotModels(data: unknown): GitHubCopilotModel[] { + const payload = asRecord(data); + const items = Array.isArray(payload.data) + ? (payload.data as unknown[]) + : Array.isArray(payload.models) + ? (payload.models as unknown[]) + : []; + + const seen = new Set(); + const models: GitHubCopilotModel[] = []; + + for (const value of items) { + const item = asRecord(value); + const id = toNonEmptyString(item.id) || toNonEmptyString(item.model); + if (!id || seen.has(id)) continue; + seen.add(id); + const name = toNonEmptyString(item.name) || toNonEmptyString(item.display_name) || id; + models.push({ id, name, owned_by: "github" }); + } + + return models; +} + +export type FetchGitHubCopilotModelsOptions = { + /** Copilot bearer token (copilotToken; falls back to GitHub accessToken upstream). */ + token: string | null | undefined; + /** Injectable fetch (defaults to global fetch). */ + fetchImpl?: typeof fetch; + /** Static catalog to fall back to when live discovery is unavailable. */ + fallbackModels?: Array<{ id: string; name?: string }>; +}; + +export type GitHubCopilotModelsResult = { + models: GitHubCopilotModel[]; + /** "api" = live discovery; "fallback" = static catalog (offline/unauthed/error). */ + source: "api" | "fallback"; +}; + +function toFallbackResult( + fallbackModels: Array<{ id: string; name?: string }> | undefined +): GitHubCopilotModelsResult { + const models = (fallbackModels || []) + .map((model) => { + const id = toNonEmptyString(model.id); + if (!id) return null; + return { id, name: toNonEmptyString(model.name) || id, owned_by: "github" }; + }) + .filter((model): model is GitHubCopilotModel => Boolean(model)); + return { models, source: "fallback" }; +} + +/** + * Discover the Copilot model catalog live, falling back to the static catalog + * when no token is available or the upstream request fails. + */ +export async function fetchGitHubCopilotModels( + options: FetchGitHubCopilotModelsOptions +): Promise { + const { token, fetchImpl = fetch, fallbackModels } = options; + + if (!toNonEmptyString(token)) { + return toFallbackResult(fallbackModels); + } + + try { + const response = await fetchImpl(GITHUB_COPILOT_MODELS_URL, { + method: "GET", + headers: { + ...getGitHubCopilotChatHeaders("application/json"), + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + return toFallbackResult(fallbackModels); + } + + const data = await response.json(); + const models = parseGitHubCopilotModels(data); + if (models.length === 0) { + return toFallbackResult(fallbackModels); + } + return { models, source: "api" }; + } catch { + // Network/parse failure — never break the import flow. + return toFallbackResult(fallbackModels); + } +} diff --git a/open-sse/services/reasoningCache.ts b/open-sse/services/reasoningCache.ts index 7bdabbf6da..fee766ec65 100644 --- a/open-sse/services/reasoningCache.ts +++ b/open-sse/services/reasoningCache.ts @@ -45,7 +45,8 @@ const REASONING_REPLAY_MODEL_PATTERNS = [ /deepseek-r1/i, /deepseek-reasoner/i, /deepseek-chat/i, - /deepseek[-/]v4[-.](flash|pro)/i, + /deepseek[-/]v4[-.](flash|pro)(-free)?/i, + /zen\/deepseek-v4/i, /kimi-k2/i, /qwq/i, /qwen.*think/i, diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts index 679345c656..c1f938a38b 100644 --- a/open-sse/services/responsesInputSanitizer.ts +++ b/open-sse/services/responsesInputSanitizer.ts @@ -55,11 +55,64 @@ function sanitizeInputItemId(record: JsonRecord): JsonRecord { return next; } +function imageUrlToText(value: unknown): string { + if (typeof value === "string") return value; + const record = toRecord(value); + return typeof record?.url === "string" ? record.url : ""; +} + +function sanitizeContentPart(part: unknown, role: string): unknown { + const record = toRecord(part); + if (!record) return part; + + if (record.type === "image_url") { + const url = imageUrlToText(record.image_url); + if (role === "user") { + const next: JsonRecord = { type: "input_image", image_url: url }; + const image = toRecord(record.image_url); + if (image?.detail !== undefined) next.detail = image.detail; + return next; + } + return { type: "output_text", text: url ? `[Image: ${url}]` : "[Image]" }; + } + + if (role === "assistant" && record.type === "input_image") { + const url = imageUrlToText(record.image_url); + return { type: "output_text", text: url ? `[Image: ${url}]` : "[Image]" }; + } + + return part; +} + +function sanitizeMessageContent(record: JsonRecord): JsonRecord { + if (!Array.isArray(record.content)) return record; + + const role = typeof record.role === "string" ? record.role.toLowerCase() : ""; + const content = record.content.map((part) => sanitizeContentPart(part, role)); + return { ...record, content }; +} + +function sanitizeOutputContent(record: JsonRecord): JsonRecord { + if (!Array.isArray(record.output)) return record; + + // Some clients replay previous Responses output items inside the next + // Responses input. In that shape OpenAI validates `input[n].output[m].type` + // against output content part types, so legacy Chat-style `image_url` parts + // must be normalized here too, not only in message.content. + const role = record.type === "function_call_output" ? "user" : "assistant"; + const output = record.output.map((part) => sanitizeContentPart(part, role)); + return { ...record, output }; +} + function sanitizeInputItem(item: unknown): unknown { const record = toRecord(item); if (!record) return item; let next = sanitizeInputItemId(record); + if (isResponsesMessageItem(next)) { + next = sanitizeMessageContent(next); + } + next = sanitizeOutputContent(next); if ( (next.type === "function_call" || next.type === "function_call_output") && typeof next.name === "string" && diff --git a/open-sse/services/sessionManager.ts b/open-sse/services/sessionManager.ts index 6452cae763..28612f6c56 100644 --- a/open-sse/services/sessionManager.ts +++ b/open-sse/services/sessionManager.ts @@ -12,6 +12,8 @@ interface SessionEntry { lastActive: number; requestCount: number; connectionId: string | null; + /** Timestamp when the last tool_calls finish_reason was emitted (cross-request TTFT tracking) */ + lastToolFinishAt?: number; } interface SessionFingerprintOptions { @@ -162,6 +164,29 @@ export function touchSession(sessionId: string | null, connectionId: string | nu } } +/** + * Persist the tool-finish timestamp so Request 2 (the follow-up after tool execution) + * can measure cross-request TTFT. + */ +export function markToolFinish(sessionId: string | null): void { + if (!sessionId) return; + const session = sessions.get(sessionId); + if (session) session.lastToolFinishAt = Date.now(); +} + +/** + * Consume the previously persisted tool-finish timestamp (one-shot: clears after read). + * Returns null if no pending timestamp or session not found. + */ +export function consumeToolFinishTime(sessionId: string | null): number | null { + if (!sessionId) return null; + const session = sessions.get(sessionId); + if (!session?.lastToolFinishAt) return null; + const ts = session.lastToolFinishAt; + session.lastToolFinishAt = undefined; + return ts; +} + /** * Get session info (for sticky routing decisions) */ diff --git a/open-sse/services/toolLatencyTracker.ts b/open-sse/services/toolLatencyTracker.ts new file mode 100644 index 0000000000..85af5fa918 --- /dev/null +++ b/open-sse/services/toolLatencyTracker.ts @@ -0,0 +1,89 @@ +/** + * Tracks per-provider tool call latency metrics. + * Resets on server restart. + */ + +interface ToolLatencyEntry { + totalRequests: number; + ttftAfterToolSum: number; + gapAfterToolSum: number; + ttftCount: number; + gapCount: number; +} + +const metrics = new Map(); + +export function recordToolLatency( + provider: string, + ttftAfterToolMs: number | null, + gapAfterToolMs: number | null +): void { + if (!provider) return; + + if (!metrics.has(provider)) { + metrics.set(provider, { + totalRequests: 0, + ttftAfterToolSum: 0, + gapAfterToolSum: 0, + ttftCount: 0, + gapCount: 0, + }); + } + + const entry = metrics.get(provider)!; + entry.totalRequests++; + + if (ttftAfterToolMs != null && ttftAfterToolMs >= 0) { + entry.ttftAfterToolSum += ttftAfterToolMs; + entry.ttftCount++; + } + + if (gapAfterToolMs != null && gapAfterToolMs >= 0) { + entry.gapAfterToolSum += gapAfterToolMs; + entry.gapCount++; + } +} + +export function getToolLatencyByProvider(): Record< + string, + { + avgTtftAfterToolMs: number; + avgGapAfterToolMs: number; + measurementCount: number; + } +> { + const result: Record = {}; + for (const [provider, entry] of metrics) { + result[provider] = { + avgTtftAfterToolMs: + entry.ttftCount > 0 ? Math.round(entry.ttftAfterToolSum / entry.ttftCount) : 0, + avgGapAfterToolMs: + entry.gapCount > 0 ? Math.round(entry.gapAfterToolSum / entry.gapCount) : 0, + measurementCount: entry.totalRequests, + }; + } + return result; +} + +export function recordToolTtft(provider: string, ttftMs: number): void { + if (!provider || ttftMs < 0) return; + + if (!metrics.has(provider)) { + metrics.set(provider, { + totalRequests: 0, + ttftAfterToolSum: 0, + gapAfterToolSum: 0, + ttftCount: 0, + gapCount: 0, + }); + } + + const entry = metrics.get(provider)!; + entry.totalRequests++; + entry.ttftAfterToolSum += ttftMs; + entry.ttftCount++; +} + +export function resetToolLatency(): void { + metrics.clear(); +} diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 4129d3a808..a4b099492b 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -13,7 +13,7 @@ type JsonRecord = Record; const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore"; const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary"; -// Forward-compatible regex: matches web_search, web_search_20250305, and any future versioned names. +// Forward-compatible regex: matches web_search, web_search_20250305, and future versioned names. const WEB_SEARCH_TOOL_TYPES = /^web_search/; // tool_search is a Responses API built-in sent by newer Codex clients; it has no Chat Completions // equivalent and must be silently dropped (not rejected with 400). @@ -34,6 +34,12 @@ function toString(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; } +function imageUrlToText(value: unknown): string { + if (typeof value === "string") return value; + const record = toRecord(value); + return toString(record.url); +} + function normalizeResponsesReasoningEffort(value: unknown): string { const effort = toString(value).toLowerCase(); return effort === "max" ? "xhigh" : effort; @@ -492,6 +498,9 @@ export function openaiToOpenAIResponsesRequest( const contentItem = toRecord(contentValue); if (contentItem.type === "text") { outputContent.push({ type: "output_text", text: toString(contentItem.text) }); + } else if (contentItem.type === "image_url") { + const url = imageUrlToText(contentItem.image_url); + outputContent.push({ type: "output_text", text: url ? `[Image: ${url}]` : "[Image]" }); } else if (contentItem.type === "thinking" || contentItem.type === "redacted_thinking") { // Reasoning already moved above continue; diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts index 78caf326c7..406ddaa490 100644 --- a/open-sse/translator/webTools.ts +++ b/open-sse/translator/webTools.ts @@ -23,6 +23,306 @@ interface OpenAIToolDef { const TOOL_BLOCK_RE = /\s*([\s\S]*?)\s*<\/tool>/g; +interface ToolParseCandidate { + raw: string; + start: number; + end: number; + requireRequestedTool: boolean; +} + +interface RequestedToolName { + original: string; + normalized: string; +} + +function toRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function getRequestedToolNames(tools: unknown): RequestedToolName[] { + if (!Array.isArray(tools)) return []; + const names: RequestedToolName[] = []; + const seen = new Set(); + for (const tool of tools) { + const record = toRecord(tool); + const fn = toRecord(record?.function); + const name = typeof fn?.name === "string" ? fn.name.trim() : ""; + if (!name || seen.has(name)) continue; + seen.add(name); + names.push({ original: name, normalized: normalizeToolName(name) }); + } + return names; +} + +function normalizeToolName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function levenshteinDistance(a: string, b: string): number { + if (a === b) return 0; + if (!a) return b.length; + if (!b) return a.length; + let previous = Array.from({ length: b.length + 1 }, (_, i) => i); + let current = Array(b.length + 1); + for (let i = 1; i <= a.length; i += 1) { + current[0] = i; + for (let j = 1; j <= b.length; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost); + } + const temp = previous; + previous = current; + current = temp; + } + return previous[b.length]; +} + +function scoreToolName(emitted: string, requested: RequestedToolName): number { + if (emitted === requested.original) return 1; + const normalized = normalizeToolName(emitted); + if (!normalized || !requested.normalized) return 0; + if (normalized === requested.normalized) return 0.98; + + const shorter = Math.min(normalized.length, requested.normalized.length); + const longer = Math.max(normalized.length, requested.normalized.length); + if (shorter >= 4) { + if (normalized.includes(requested.normalized) || requested.normalized.includes(normalized)) { + return 0.86 - (longer - shorter) / Math.max(longer, 1) / 4; + } + } + + const distance = levenshteinDistance(normalized, requested.normalized); + const similarity = 1 - distance / Math.max(longer, 1); + return similarity >= 0.72 ? similarity : 0; +} + +function resolveRequestedToolName(emitted: string, requestedTools: RequestedToolName[]): string | null { + if (requestedTools.length === 0) return emitted; + + let best: { name: string; score: number } | null = null; + let secondBest = 0; + for (const requested of requestedTools) { + const score = scoreToolName(emitted, requested); + if (!best || score > best.score) { + secondBest = best?.score ?? 0; + best = { name: requested.original, score }; + } else if (score > secondBest) { + secondBest = score; + } + } + + if (!best || best.score < 0.72) return null; + // Avoid correcting to an arbitrary tool when the fuzzy match is ambiguous. + if (best.score < 0.98 && best.score - secondBest < 0.08) return null; + return best.name; +} + +function stripCodeFence(value: string): string { + return value + .trim() + .replace(/^```(?:json|javascript|js|python)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); +} + +function convertSingleQuotedStrings(value: string): string { + let result = ""; + let inSingle = false; + let inDouble = false; + let escaped = false; + + for (const ch of value) { + if (escaped) { + result += ch === '"' && inSingle ? '\\"' : ch; + escaped = false; + continue; + } + + if (ch === "\\") { + result += ch; + escaped = true; + continue; + } + + if (ch === '"') { + if (inSingle) { + result += '\\"'; + } else { + inDouble = !inDouble; + result += ch; + } + continue; + } + + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + result += '"'; + continue; + } + + result += ch; + } + + return result; +} + +function replacePythonLiterals(value: string): string { + let result = ""; + let inString = false; + let escaped = false; + let token = ""; + + const flushToken = () => { + if (token === "True") result += "true"; + else if (token === "False") result += "false"; + else if (token === "None") result += "null"; + else result += token; + token = ""; + }; + + for (const ch of value) { + if (escaped) { + if (token) flushToken(); + result += ch; + escaped = false; + continue; + } + + if (ch === "\\") { + if (token) flushToken(); + result += ch; + escaped = inString; + continue; + } + + if (ch === '"') { + if (token) flushToken(); + inString = !inString; + result += ch; + continue; + } + + if (!inString && /[A-Za-z]/.test(ch)) { + token += ch; + continue; + } + + if (token) flushToken(); + result += ch; + } + + if (token) flushToken(); + return result; +} + +function normalizeLooseJson(value: string): string { + return replacePythonLiterals(convertSingleQuotedStrings(value)) + .replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_-]*)(\s*:)/g, '$1"$2"$3') + .replace(/,\s*([}\]])/g, "$1"); +} + +function parseLooseJsonObject(raw: string): Record | null { + const trimmed = stripCodeFence(raw); + for (const candidate of [trimmed, normalizeLooseJson(trimmed)]) { + try { + return toRecord(JSON.parse(candidate)); + } catch { + // Try the next, more permissive form. + } + } + return null; +} + +function findBareJsonCandidates(text: string): ToolParseCandidate[] { + const candidates: ToolParseCandidate[] = []; + let start = -1; + let depth = 0; + let quote: '"' | "'" | "" = ""; + let escaped = false; + + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + + if (depth === 0 && ch !== "{") { + continue; + } + + if (escaped) { + escaped = false; + continue; + } + + if (quote) { + if (ch === "\\") { + escaped = true; + } else if (ch === quote) { + quote = ""; + } + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + + if (ch === "{") { + if (depth === 0) start = i; + depth += 1; + continue; + } + + if (ch === "}" && depth > 0) { + depth -= 1; + if (depth === 0 && start >= 0) { + const raw = text.slice(start, i + 1); + if (/[{,]\s*["']?(name|command)["']?\s*:/i.test(raw) && /[{,]\s*["']?arguments["']?\s*:/i.test(raw)) { + candidates.push({ raw, start, end: i + 1, requireRequestedTool: true }); + } + start = -1; + } + } + } + + return candidates; +} + +function rangesOverlap(a: { start: number; end: number }, b: { start: number; end: number }): boolean { + return a.start < b.end && b.start < a.end; +} + +function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string { + let content = text; + const sorted = [...ranges].sort((a, b) => b.start - a.start); + for (const range of sorted) { + const lineStart = content.lastIndexOf("\n", range.start - 1) + 1; + const nextLineBreak = content.indexOf("\n", range.end); + const lineEnd = nextLineBreak === -1 ? content.length : nextLineBreak; + const beforeOnLine = content.slice(lineStart, range.start); + const afterOnLine = content.slice(range.end, lineEnd); + const removeWholeLine = beforeOnLine.trim() === "" && afterOnLine.trim() === ""; + const start = removeWholeLine ? lineStart : range.start; + const end = removeWholeLine && nextLineBreak !== -1 ? nextLineBreak + 1 : removeWholeLine ? lineEnd : range.end; + content = `${content.slice(0, start)}${content.slice(end)}`; + } + return content.replace(/\n{3,}/g, "\n\n").trim(); +} + +function toArgumentsString(value: unknown): string { + if (value === undefined) return "{}"; + if (typeof value === "string") { + const parsed = parseLooseJsonObject(value); + return parsed ? JSON.stringify(parsed) : value; + } + try { + return JSON.stringify(value); + } catch { + return "{}"; + } +} + /** * Serialize an OpenAI `tools` array into a system-prompt block that instructs the * web UI model how to invoke a tool (emit a `{...}` block). Returns an @@ -59,6 +359,8 @@ export function serializeToolsToPrompt(tools: unknown): string { /** * Parse `{...}` blocks out of upstream text into OpenAI `tool_calls`. + * When a requested `tools[]` set is provided, also accepts bare JSON tool-call + * objects emitted by web models that ignored the `` wrapper contract. * Returns the content with the blocks stripped, plus the tool calls (or null when * there are none). `arguments` is always a JSON *string*, matching the OpenAI API. * @@ -67,43 +369,69 @@ export function serializeToolsToPrompt(tools: unknown): string { */ export function parseToolCallsFromText( text: string, - idSeed = "call" + idSeed = "call", + requestedTools?: unknown ): { content: string; toolCalls: OpenAIToolCall[] | null } { - if (typeof text !== "string" || !text.includes("")) { + const requestedToolNames = getRequestedToolNames(requestedTools); + const canParseBareJson = requestedToolNames.length > 0; + if (typeof text !== "string" || (!text.includes("") && !canParseBareJson)) { return { content: text ?? "", toolCalls: null }; } - const toolCalls: OpenAIToolCall[] = []; - let match: RegExpExecArray | null; + const candidates: ToolParseCandidate[] = []; + const toolBlockRanges: Array<{ start: number; end: number }> = []; + + let blockMatch: RegExpExecArray | null; TOOL_BLOCK_RE.lastIndex = 0; - while ((match = TOOL_BLOCK_RE.exec(text)) !== null) { - const raw = match[1].trim(); - let parsed: { name?: unknown; arguments?: unknown } | null = null; - try { - parsed = JSON.parse(raw); - } catch { - parsed = null; - } - const name = parsed && typeof parsed.name === "string" ? parsed.name : null; - if (!name) continue; - let args = "{}"; - if (parsed && parsed.arguments !== undefined) { - args = - typeof parsed.arguments === "string" - ? parsed.arguments - : JSON.stringify(parsed.arguments); + while ((blockMatch = TOOL_BLOCK_RE.exec(text)) !== null) { + const range = { start: blockMatch.index, end: TOOL_BLOCK_RE.lastIndex }; + toolBlockRanges.push(range); + candidates.push({ + raw: blockMatch[1].trim(), + start: range.start, + end: range.end, + requireRequestedTool: false, + }); + } + + if (canParseBareJson) { + for (const candidate of findBareJsonCandidates(text)) { + if (!toolBlockRanges.some((range) => rangesOverlap(range, candidate))) { + candidates.push(candidate); + } } + } + + candidates.sort((a, b) => a.start - b.start); + + const toolCalls: OpenAIToolCall[] = []; + const acceptedRanges: Array<{ start: number; end: number }> = []; + for (const candidate of candidates) { + const parsed = parseLooseJsonObject(candidate.raw); + const emittedName = + parsed && typeof parsed.name === "string" + ? parsed.name + : parsed && typeof parsed.command === "string" + ? parsed.command + : null; + if (!emittedName) continue; + const name = + resolveRequestedToolName(emittedName, requestedToolNames) || + (candidate.requireRequestedTool ? null : emittedName); + if (!name || (candidate.requireRequestedTool && requestedToolNames.length === 0)) continue; + const args = toArgumentsString(parsed?.arguments); toolCalls.push({ id: `${idSeed}_${toolCalls.length}`, type: "function", function: { name, arguments: args }, }); + acceptedRanges.push({ start: candidate.start, end: candidate.end }); } if (toolCalls.length === 0) { return { content: text, toolCalls: null }; } - const content = text.replace(TOOL_BLOCK_RE, "").replace(/\n{3,}/g, "\n\n").trim(); + const content = stripRanges(text, acceptedRanges); return { content, toolCalls }; } diff --git a/open-sse/utils/responsesStatePolicy.ts b/open-sse/utils/responsesStatePolicy.ts new file mode 100644 index 0000000000..2e073c387f --- /dev/null +++ b/open-sse/utils/responsesStatePolicy.ts @@ -0,0 +1,75 @@ +import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; +import { + DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE, + RESPONSES_PREVIOUS_RESPONSE_ID_MODES, + type ResponsesPreviousResponseIdMode, +} from "@/shared/constants/responsesPreviousResponseId"; +import { FORMATS } from "../translator/formats.ts"; + +type JsonRecord = Record; + +type ApplyResponsesPreviousResponseIdPolicyOptions = { + mode: unknown; + sourceFormat?: unknown; + targetFormat?: unknown; + credentials?: unknown; +}; + +const MODE_SET = new Set(RESPONSES_PREVIOUS_RESPONSE_ID_MODES); + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +export function normalizeResponsesPreviousResponseIdMode( + value: unknown +): ResponsesPreviousResponseIdMode { + if (typeof value === "string" && MODE_SET.has(value)) { + return value as ResponsesPreviousResponseIdMode; + } + return DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE; +} + +export function shouldStripPreviousResponseId({ + mode, + sourceFormat, + targetFormat, + credentials, +}: ApplyResponsesPreviousResponseIdPolicyOptions): boolean { + const normalizedMode = normalizeResponsesPreviousResponseIdMode(mode); + if (normalizedMode === "preserve") return false; + if (normalizedMode === "strip") return true; + + const isResponsesSource = sourceFormat === FORMATS.OPENAI_RESPONSES; + const isResponsesTarget = targetFormat === FORMATS.OPENAI_RESPONSES; + if (!isResponsesSource && !isResponsesTarget) return false; + + // `previous_response_id` is only safe when the upstream actually keeps + // Responses state. OmniRoute defaults to stateless upstream calls, so auto + // strips unless the connection explicitly opts into OpenAI Responses storage. + const providerSpecificData = toRecord(toRecord(credentials).providerSpecificData); + return !isOpenAIResponsesStoreEnabled(providerSpecificData); +} + +export function applyResponsesPreviousResponseIdPolicy( + body: unknown, + options: ApplyResponsesPreviousResponseIdPolicyOptions +): { body: unknown; stripped: boolean; mode: ResponsesPreviousResponseIdMode } { + const mode = normalizeResponsesPreviousResponseIdMode(options.mode); + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { body, stripped: false, mode }; + } + + const record = body as JsonRecord; + if (!Object.hasOwn(record, "previous_response_id")) { + return { body, stripped: false, mode }; + } + + if (!shouldStripPreviousResponseId({ ...options, mode })) { + return { body, stripped: false, mode }; + } + + const next = { ...record }; + delete next.previous_response_id; + return { body: next, stripped: true, mode }; +} diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 42d226c684..407b0600f1 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -29,6 +29,12 @@ import { extractThinkingFromContent, } from "../handlers/responseSanitizer.ts"; import { buildErrorBody } from "./error.ts"; +import { recordToolLatency } from "../services/toolLatencyTracker.ts"; +import { + generateSessionId, + markToolFinish, + consumeToolFinishTime, +} from "../services/sessionManager.ts"; /** * Race a response body read against a timeout. @@ -796,6 +802,21 @@ export function createSSEStream(options: StreamOptions = {}) { const passthroughResponsesReasoningSummarySeen = new Set(); const streamStartedAt = Date.now(); + let lastToolCallChunkTime: number | null = null; + let toolFinishTime: number | null = null; + let contentAfterToolSeen = false; + + // Cross-request tool latency: fingerprint the session from the request body + // so Request 2 can pick up the tool-finish timestamp left by Request 1. + const sessionId = generateSessionId(body as Parameters[0], { + provider: provider ?? undefined, + connectionId: connectionId ?? undefined, + }); + let pendingToolFinishTime: number | null = null; + try { + pendingToolFinishTime = consumeToolFinishTime(sessionId); + } catch {} + // Guard against duplicate [DONE] events — ensures exactly one per stream let doneSent = false; const providerPayloadCollector = createStructuredSSECollector({ @@ -1637,6 +1658,7 @@ export function createSSEStream(options: StreamOptions = {}) { // T18: Track if we saw tool calls & accumulate for call log if (delta?.tool_calls && delta.tool_calls.length > 0) { passthroughHasToolCalls = true; + lastToolCallChunkTime = Date.now(); for (const tc of delta.tool_calls) { // Key by index first — id only appears on the first delta in OpenAI streaming let key: string; @@ -1670,8 +1692,25 @@ export function createSSEStream(options: StreamOptions = {}) { } const content = delta?.content || delta?.reasoning_content; - if (content && typeof content === "string") { + if (typeof content === "string") { totalContentLength += content.length; + + if (!contentAfterToolSeen) { + const toolTs = toolFinishTime || pendingToolFinishTime; + const lastChunkTs = lastToolCallChunkTime; + if (toolTs || lastChunkTs) { + contentAfterToolSeen = true; + const now = Date.now(); + try { + recordToolLatency( + provider || "unknown", + toolTs ? now - toolTs : null, + lastChunkTs ? now - lastChunkTs : null + ); + } catch {} + pendingToolFinishTime = null; + } + } } { const guarded = applyTextualToolCallStreamingGuard( @@ -1693,6 +1732,13 @@ export function createSSEStream(options: StreamOptions = {}) { const isFinishChunk = parsed.choices?.[0]?.finish_reason; + if (isFinishChunk && passthroughHasToolCalls) { + toolFinishTime = Date.now(); + try { + markToolFinish(sessionId); + } catch {} + } + // T18: Normalize finish_reason to 'tool_calls' if tool calls were used if ( isFinishChunk && @@ -1785,6 +1831,16 @@ export function createSSEStream(options: StreamOptions = {}) { continue; } + if (parsed.choices?.[0]?.delta?.tool_calls) { + lastToolCallChunkTime = Date.now(); + } + if (parsed.choices?.[0]?.finish_reason === "tool_calls") { + toolFinishTime = Date.now(); + try { + markToolFinish(sessionId); + } catch {} + } + // Track content length and accumulate for call log (from raw provider chunk, so content is never missed) // Do this before translation so we capture content regardless of translator output shape @@ -1882,6 +1938,27 @@ export function createSSEStream(options: StreamOptions = {}) { } } + const translateHasContent = + typeof parsed.delta?.text === "string" || + typeof parsed.choices?.[0]?.delta?.content === "string" || + typeof parsed.choices?.[0]?.delta?.reasoning_content === "string"; + if (translateHasContent && !contentAfterToolSeen) { + const toolTs = toolFinishTime || pendingToolFinishTime; + const lastChunkTs = lastToolCallChunkTime; + if (toolTs || lastChunkTs) { + contentAfterToolSeen = true; + const now = Date.now(); + try { + recordToolLatency( + provider || "unknown", + toolTs ? now - toolTs : null, + lastChunkTs ? now - lastChunkTs : null + ); + } catch {} + pendingToolFinishTime = null; + } + } + // Extract usage const extracted = extractUsage(parsed); if (extracted) state.usage = extracted; // Keep original usage for logging diff --git a/package-lock.json b/package-lock.json index a759f45d45..1f73572364 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.9", + "version": "3.8.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.9", + "version": "3.8.10", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -12409,9 +12409,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index 03c72b2184..4a69df2325 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.9", + "version": "3.8.10", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { diff --git a/recent_issues.jsonl b/recent_issues.jsonl deleted file mode 100644 index 5f339d1f8d..0000000000 --- a/recent_issues.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"author":{"id":"MDQ6VXNlcjE0OTIxOTgz","is_bot":false,"login":"oyi77","name":"Paijo"},"body":"# Comprehensive Documentation Site Overhaul\n\n## 🎯 Objective\nTransform OmniRoute's scattered documentation into a comprehensive, structured documentation site with interactive features, advanced search, and professional organization inspired by industry best practices.\n\n## 📋 Problem Statement\nCurrent documentation system limitations:\n- **Scattered Organization**: Markdown files spread across repository with limited navigation\n- **No Dedicated Site**: Lack of professional documentation portal\n- **Limited Discoverability**: Poor search and feature discovery\n- **Static Content**: No interactive elements or API testing\n- **Inconsistent Quality**: Varied formatting and structure\n\n## ✨ Proposed Solution\n\n### New Documentation Structure\n\n\n### Key Features\n- **Interactive API Documentation**: Swagger/OpenAPI with try-it-now functionality\n- **Advanced Search**: Algolia/DocSearch with autocomplete and filtering\n- **Versioned Content**: Clear version tags and migration guides\n- **Code Examples**: Runnable examples with copy-to-clipboard\n- **Visual Aids**: Interactive Mermaid diagrams and architecture visualizations\n- **Responsive Design**: Mobile-friendly with dark mode support\n- **Analytics**: Usage tracking and feedback system\n\n### Technology Stack\n- **Framework**: Next.js (consistent with OmniRoute dashboard)\n- **Content**: MDX (Markdown + React components)\n- **Styling**: Tailwind CSS (matches OmniRoute UI)\n- **Search**: Algolia DocSearch or Fuse.js\n- **Deployment**: Vercel with preview deployments\n\n## 📈 Expected Benefits\n- **50% increase** in documentation page views\n- **30% reduction** in support requests for basic questions\n- **40% increase** in time spent on documentation pages\n- **90% positive** feedback on documentation quality\n- **25% increase** in community contributions\n\n## 🔧 Implementation Plan\n\n### Phase 1: Foundation (2 weeks)\n- Set up Next.js documentation framework\n- Design component library with OmniRoute theme\n- Implement MDX processing pipeline\n- Set up CI/CD pipeline with preview deployments\n\n### Phase 2: Content Migration (4 weeks)\n- Audit and categorize existing documentation\n- Create automated migration tools\n- Migrate core content with quality validation\n- Implement redirects from old URLs\n\n### Phase 3: Advanced Features (3 weeks)\n- Implement search functionality with Algolia\n- Add interactive API documentation\n- Create versioning system\n- Implement analytics and feedback\n\n### Phase 4: Testing & Launch (2 weeks)\n- Comprehensive testing (unit, integration, E2E)\n- Performance optimization\n- Accessibility validation\n- Final review and official launch\n\n## 📊 Success Metrics\n- All content successfully migrated and validated\n- Search functionality achieves ≥90% relevance in test queries\n- Accessibility compliance (WCAG 2.1 AA) verified\n- Performance metrics meet targets (Lighthouse ≥90)\n- 50% increase in documentation engagement\n\n## 🎯 Definition of Done\n- Complete content migration with validation\n- All interactive features fully functional\n- Search system operational with ≥90% relevance\n- Accessibility compliance verified\n- Performance targets achieved\n- Analytics and feedback system implemented\n- Comprehensive documentation completed\n\n## 📝 Additional Context\nThis overhaul will create a professional documentation portal that improves user onboarding, feature discovery, and overall satisfaction while maintaining consistency with OmniRoute's brand and technical stack. The new site will serve as the primary resource for users, developers, and contributors.","comments":[],"createdAt":"2026-05-04T20:54:55Z","labels":[],"number":1958,"title":"feat: Comprehensive Documentation Site Overhaul"} -{"author":{"id":"MDQ6VXNlcjE0OTIxOTgz","is_bot":false,"login":"oyi77","name":"Paijo"},"body":"# Manifest Routing Integration - Tier Resolution & Specificity Detection\n\n## 🎯 Objective\nEnhance OmniRoute's combo routing system by integrating Manifest's sophisticated tier resolution and specificity detection logic to create a more intelligent, context-aware routing engine that optimizes for both performance and cost.\n\n## 📋 Problem Statement\nThe current combo routing system, while functional with 13 strategies, lacks:\n- **Tier Resolution**: Provider classification based on performance, cost, and capabilities\n- **Specificity Detection**: Content complexity analysis for optimal route selection\n- **Adaptive Strategies**: Dynamic adjustment based on real-time metrics and query requirements\n\n## ✨ Proposed Solution\n\n### Core Components\n1. **Tier Resolution System**\n - 3-level classification: static rules → heuristics → specificity-based\n - Dynamic tier assignment based on real-time provider metrics\n - Integration with existing provider health monitoring\n\n2. **Specificity Detection**\n - Query complexity scoring (0-100 scale)\n - Content-type analysis for optimal provider matching\n - Historical performance data integration\n\n3. **Adaptive Combo Strategies**\n - New auto-optimized strategy with dynamic adjustment\n - Tier-aware fallback logic\n - Cost-performance balancing algorithms\n\n### Integration Points\n- **PR #1918 Auto-Assessment**: Leverage existing health monitoring and performance data\n- **Existing Combo System**: Maintain backward compatibility with current strategies\n- **Metrics Pipeline**: Feed routing decisions into analytics for continuous improvement\n\n## 📈 Expected Benefits\n- **15-25%** improvement in routing success rates for complex queries\n- **10-20%** reduction in API costs through optimal provider utilization\n- **30% faster** routing decisions via optimized tier selection\n- **Enhanced reliability** with smarter, context-aware fallback logic\n\n## 🔧 Implementation Plan\n\n### Phase 1: Foundation (2-3 weeks)\n- Research Manifest's tier resolution algorithms\n- Design system architecture with integration points\n- Implement basic tier classification framework\n- Set up test infrastructure and benchmarks\n\n### Phase 2: Core Implementation (4-5 weeks)\n- Implement 3-tier resolution system (static/heuristic/specificity)\n- Develop specificity scoring algorithm\n- Create adaptive combo strategies\n- Integrate with PR #1918 auto-assessment system\n\n### Phase 3: Optimization & Testing (3 weeks)\n- Performance tuning and benchmarking\n- Comprehensive test suite (unit, integration, e2e)\n- Documentation and examples\n- Gradual rollout and monitoring\n\n## 📊 Success Metrics\n- Routing success rate improvement (A/B testing)\n- Cost reduction verified through usage analytics\n- Performance benchmarks showing latency improvements\n- User satisfaction surveys on routing reliability\n\n## 🔍 Related Work\n- PR #1918: Auto-Assessment and Self-Healing Combo Engine\n- Manifest routing engine analysis\n- Current combo strategies documentation\n\n## 🎯 Definition of Done\n- Tier resolution achieves ≥95% accuracy in classification\n- Specificity detection shows ≥90% correlation with manual scoring\n- Adaptive strategies demonstrate ≥15% improvement in benchmarks\n- Integration with auto-assessment system verified\n- All performance benchmarks meet targets\n- Comprehensive documentation completed\n\n## 📝 Additional Context\nThis enhancement builds upon PR #1918's auto-assessment capabilities, adding intelligent tier-based routing and query-specific optimization to create a sophisticated, cost-effective routing system that maintains OmniRoute's commitment to performance and reliability.","comments":[],"createdAt":"2026-05-04T20:54:10Z","labels":[],"number":1957,"title":"feat: Manifest Routing Integration - Tier Resolution & Specificity Detection"} -{"author":{"id":"U_kgDODmAPNQ","is_bot":false,"login":"akatylutir","name":""},"body":"### OmniRoute Version\n\n3.7.8\n\n### Installation Method\n\nnpm (global)\n\n### Operating System\n\nWindows\n\n### OS Version\n\nwindows 11 pro, 24H2\n\n### Node.js Version\n\nv22.22.2\n\n### Provider(s) Involved\n\n_No response_\n\n### Model(s) Involved\n\n_No response_\n\n### Client Tool\n\n_No response_\n\n### Description\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Steps to Reproduce\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Expected Behavior\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Actual Behavior\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Test Impact\n\nNeeds a new unit test\n\n### Error Logs / Output\n\n```shell\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n```\n\n### Screenshots\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Additional Context\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Validation Plan\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4","comments":[{"id":"IC_kwDORPf6ys8AAAABBKraWA","author":{"login":"kilo-code-bot"},"authorAssociation":"NONE","body":"This issue appears to be a duplicate of https://github.com/diegosouzapw/OmniRoute/issues/1883.\n\n> **\\[BUG\\] Onboarding button unresponsive after upgrade from 2.x to 3.x — CSP blocks eval** (#1883)\n\nSimilarity score: 90%\n\n*This comment was generated by Kilo Auto-Triage.*","createdAt":"2026-05-04T18:00:58Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1955#issuecomment-4373273176","viewerDidAuthor":false},{"id":"IC_kwDORPf6ys8AAAABBK1BzA","author":{"login":"vladislavsartakov90-svg"},"authorAssociation":"NONE","body":"I solved the problem, install version 3.7.5","createdAt":"2026-05-04T18:24:23Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1955#issuecomment-4373430732","viewerDidAuthor":false}],"createdAt":"2026-05-04T18:00:51Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrFQ","name":"bug","description":"Something isn't working","color":"d73a4a"},{"id":"LA_kwDORPf6ys8AAAACZXajUw","name":"kilo-triaged","description":"Auto-generated label by Kilo","color":"faf74f"},{"id":"LA_kwDORPf6ys8AAAACZ7FLsw","name":"kilo-duplicate","description":"Auto-generated label by Kilo","color":"faf74f"}],"number":1955,"title":"[BUG] Button Start Onboarding not working"} -{"author":{"id":"U_kgDOEMaoiA","is_bot":false,"login":"vladislavsartakov90-svg","name":""},"body":"### OmniRoute Version\n\n3.7.8\n\n### Installation Method\n\nnpm (global)\n\n### Operating System\n\nWindows\n\n### OS Version\n\nwindows 10 \n\n### Node.js Version\n\nv24.15.0\n\n### Provider(s) Involved\n\nall\n\n### Model(s) Involved\n\nall\n\n### Client Tool\n\nall\n\n### Description\n\nI launched omniroute in cmd, the website opens, but the button doesn't work. I tried changing ports, but that didn't help. I also tried using AI, but that didn't help either.\n\n### Steps to Reproduce\n\na\n\n### Expected Behavior\n\na\n\n### Actual Behavior\n\na\n\n### Test Impact\n\nNeeds a new unit test\n\n### Error Logs / Output\n\n```shell\na\n```\n\n### Screenshots\n\n\"Image\"\n\n\"Image\"\n\n\"Image\"\n\n### Additional Context\n\na\n\n### Validation Plan\n\na","comments":[{"id":"IC_kwDORPf6ys8AAAABBKpIzA","author":{"login":"kilo-code-bot"},"authorAssociation":"NONE","body":"This issue appears to be a duplicate of https://github.com/diegosouzapw/OmniRoute/issues/1703.\n\n> **\\[BUG\\]** (#1703)\n\nSimilarity score: 93%\n\n*This comment was generated by Kilo Auto-Triage.*","createdAt":"2026-05-04T17:54:38Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1954#issuecomment-4373235916","viewerDidAuthor":false},{"id":"IC_kwDORPf6ys8AAAABBKtw3g","author":{"login":"vladislavsartakov90-svg"},"authorAssociation":"NONE","body":"node 22.22.2 not work\n","createdAt":"2026-05-04T18:06:24Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1954#issuecomment-4373311710","viewerDidAuthor":false}],"createdAt":"2026-05-04T17:54:31Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrFQ","name":"bug","description":"Something isn't working","color":"d73a4a"},{"id":"LA_kwDORPf6ys8AAAACZXajUw","name":"kilo-triaged","description":"Auto-generated label by Kilo","color":"faf74f"},{"id":"LA_kwDORPf6ys8AAAACZ7FLsw","name":"kilo-duplicate","description":"Auto-generated label by Kilo","color":"faf74f"}],"number":1954,"title":"[BUG]"} -{"author":{"id":"MDQ6VXNlcjM3Nzc3MjYx","is_bot":false,"login":"uwuclxdy","name":"cloudy"},"body":"### Problem / Use Case\n\nOmniRoute currently tries all accounts from lets say Codex provider, even though some of them have no more usage available.\n\n### Proposed Solution\n\nMake the already existing buttons \"track 5-hour quota\" and \"track weekly quota\" on `/dashboard/providers/codex` page would make OmniRoute check the quota of the account before attempting to send a query and skip it if on 0%. This could work for other providers like Claude Code in the same way.\n\n### Alternatives Considered\n\n_No response_\n\n### Acceptance Criteria\n\nHave accounts without any usage left skipped automatically when quota tracking is turned on.\n\n### Area\n\nProxy / Routing\n\n### Related Provider(s)\n\nCodex, Claude Code...\n\n### Additional Context\n\n_No response_\n\n### Expected Test Plan\n\n_No response_","comments":[],"createdAt":"2026-05-04T17:21:30Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrLw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":1952,"title":"[Feature] Skip (Codex) accounts that have the weekly or 5h quota exhausted"} -{"author":{"id":"MDQ6VXNlcjU5MzM0MTUz","is_bot":false,"login":"hoangchung0701","name":""},"body":"### Problem / Use Case\n\nCould please show how much quota on model like:\n- Sesssion: x/y request remain\n- Weekly: x/y request remain\n\n### Proposed Solution\n\nNo\n\n### Alternatives Considered\n\n_No response_\n\n### Acceptance Criteria\n\nno\n\n### Area\n\nAnalytics / Usage Tracking\n\n### Related Provider(s)\n\n_No response_\n\n### Additional Context\n\n_No response_\n\n### Expected Test Plan\n\n_No response_","comments":[],"createdAt":"2026-05-04T17:12:44Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrLw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":1951,"title":"[Feature] Show more detail about quota"} -{"author":{"id":"U_kgDODGQfGw","is_bot":false,"login":"Destruction13","name":""},"body":"### Problem / Use Case\n\nOmniRoute supports a very large provider ecosystem, but Devin AI is not currently available as a built-in provider.\n\nThis matters because Devin is a strong coding-focused agent for longer-running engineering tasks, and OmniRoute already positions itself as a universal gateway for coding agents and model providers. Right now, users who want to evaluate or route work to Devin cannot do that from the same OmniRoute endpoint or include Devin in combos/fallback chains.\n\n## Why the current custom-provider path is not enough\n\nAs far as I understand, OmniRoute's generic custom provider support is for OpenAI-compatible and Anthropic-compatible endpoints.\n\nDevin does expose an official API, but it is session-based and organization-scoped rather than a drop-in `/chat/completions`-compatible endpoint. Because of that, Devin likely needs a first-class provider adapter instead of a generic custom endpoint configuration.\n\n## Use cases\n\n- Route repo-level coding, debugging, and refactor tasks to Devin from the same OmniRoute setup used for Codex, Claude Code, Gemini CLI, etc.\n- Use Devin as a higher-latency but higher-capability fallback for complex implementation tasks.\n- Compare Devin against other coding-oriented providers inside the same OmniRoute combo/routing policy.\n- Evaluate Devin inside OmniRoute without reconfiguring clients every time.\n\n### Proposed Solution\n\n## Proposed solution\n\nAdd Devin as a built-in provider with a custom executor.\n\nHigh-level implementation sketch:\n\n1. Register a new `devin` provider in `src/shared/constants/providers.ts`.\n2. Add Devin provider/model metadata in `open-sse/config/providerRegistry.ts`.\n3. Implement a custom executor in `open-sse/executors/` that:\n - authenticates with Devin service-user credentials\n - targets the organization API under `https://api.devin.ai/v3/organizations/{org_id}`\n - creates a new Devin session for a fresh OmniRoute request\n - sends follow-up messages to an existing Devin session when conversation continuity is available\n - polls session state and/or session messages until a stable assistant result is available\n - maps the final Devin output back into OmniRoute's OpenAI-compatible response shape\n4. Add dashboard/provider settings for:\n - `DEVIN_API_KEY` (service-user credential)\n - `DEVIN_ORG_ID`\n - optional defaults such as session tags, repo selection, or ACU limits\n5. Make the provider eligible for direct selection and combo routing.\n\n### Alternatives Considered\n\n_No response_\n\n### Acceptance Criteria\n\n## Acceptance criteria\n\n- Devin appears in the Providers UI as a connectable provider.\n- A user can store Devin credentials and validate the connection.\n- OmniRoute can send a basic text request to Devin and return a usable assistant response through `/v1/chat/completions` and/or `/v1/responses`.\n- Follow-up prompts can either reuse a Devin session or the limitation is explicitly documented for MVP behavior.\n- Devin can be targeted directly in combos.\n- Common Devin session states/errors (for example approval-needed, quota/usage exhaustion, or suspended sessions) are surfaced as clear OmniRoute errors.\n- Documentation explains setup, limitations, and expected latency/behavior differences versus normal token-streaming LLM providers.\n\n## Suggested MVP scope\n\nMVP can be text-only and non-streaming/polling-based if needed.\n\nNot required for the first version:\n- embeddings\n- image/audio endpoints\n- tool-calling parity\n- full real-time streaming parity with standard LLM providers\n\n### Area\n\nProvider Support\n\n### Related Provider(s)\n\n_No response_\n\n### Additional Context\n\n## Additional context\n\nThis would also be useful for users who want to evaluate Devin during onboarding or trial access without changing their entire client setup.\n\n### Expected Test Plan\n\n_No response_","comments":[],"createdAt":"2026-05-04T17:00:59Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrLw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":1950,"title":"[Feature] devin.ai"} -{"author":{"id":"MDQ6VXNlcjc1MzQ1NDc=","is_bot":false,"login":"chbndrhnns","name":"Johannes Rueschel"},"body":"### OmniRoute Version\n\n3.7.8\n\n### Installation Method\n\nnpm (global)\n\n### Operating System\n\nmacOS\n\n### OS Version\n\n26.4.1\n\n### Node.js Version\n\n24.15.0\n\n### Provider(s) Involved\n\n-\n\n### Model(s) Involved\n\n-\n\n### Client Tool\n\n-\n\n### Description\n\n\n```\n❯ pnpm upgrade -g omniroute\nDownloading @ngrok/ngrok-darwin-universal@1.7.0: 7.97 MB/7.97 MB, done\nDownloading @swc/core-darwin-arm64@1.15.33: 9.16 MB/9.16 MB, done\n WARN  2 deprecated subdependencies found: intersection-observer@0.12.2, prebuild-install@7.1.3\nDownloading omniroute@3.7.8: 50.87 MB/50.87 MB, done\nPackages: +29 -25\n+++++++++++++++++++++++++++++-------------------------\nProgress: resolved 988, reused 873, downloaded 27, added 29, done\n WARN  Issues with peer dependencies found\n.\n└─┬ @lobehub/ui 5.10.0\n └─┬ @emoji-mart/react 1.1.1\n └── ✕ unmet peer react@\"^16.8 || ^17 || ^18\": found 19.2.5\n\n/Users/jo/Library/pnpm/global/5:\n- omniroute 3.7.4\n+ omniroute 3.7.8\n\n╭ Warning ──────────────────────────────────────────────────────────────────────────────────────╮\n│ │\n│ Ignored build scripts: @swc/core@1.15.33, omniroute@3.7.8. │\n│ Run \"pnpm approve-builds -g\" to pick which dependencies should be allowed to run scripts. │\n│ │\n╰───────────────────────────────────────────────────────────────────────────────────────────────╯\nDone in 58.1s using pnpm v10.33.0\n❯ pnpm approve-builds -g\n✔ Choose which packages to build (Press to select, to toggle all, to invert selection) · @swc/core, omniroute\n✔ The next packages will now be built: @swc/core, omniroute.\nDo you approve? (y/N) · true\n.pnpm/@swc+core@1.15.33_@swc+helpers@0.5.21/node_modules/@swc/core: Running postinstall script, done in 45ms\n.pnpm/omniroute@3.7.8_@lobehub+ui@5.10.0_@types+react@19.2.14_antd@6.3.7_react-dom@19.2.5_rea_81be994467029f641d72db51e6e26349/node_modules/omniroute: Running postinstall script...\n.pnpm/omniroute@3.7.8_@lobehub+ui@5.10.0_@types+react@19.2.14_antd@6.3.7_react-dom@19.2.5_rea_81be994467029f641d72db51e6e26349/node_modules/omniroute: Running postinstall script, done in 772ms\n❯\n❯ omniroute\n\n ____ _ ____ _\n / __ \\\\ (_) __ \\\\ | |\n | | | |_ __ ___ _ __ _| |__) |___ _ _| |_ ___\n | | | | '_ ` _ \\\\| '_ \\\\ | _ // _ \\\\| | | | __/ _ \\\\\n | |__| | | | | | | | | | | | \\\\ \\\\ (_) | |_| | || __/\n \\\\____/|_| |_| |_|_| |_|_|_| \\\\_\\\\___/ \\\\__,_|\\\\__\\\\___|\n\n ⏳ Starting server...\n\n▲ Next.js 16.2.4\n- Local: http://localhost:20128\n- Network: http://0.0.0.0:20128\n✓ Ready in 0ms\n\n ✔ OmniRoute is running!\n\n Dashboard: http://localhost:20128\n API Base: http://localhost:20128/v1\n\n Point your CLI tool (Cursor, Cline, Codex) to:\n http://localhost:20128/v1\n\n Press Ctrl+C to stop\n\n[CREDENTIALS] No external credentials file found, using defaults.\n[DB] Could not preserve critical DB state before recreation: Could not locate the bindings file. Tried:\n → /Users/jo/Library/pnpm/global/5/.pnpm/omniroute@3.7.8_@lobehub+ui@5.10.0_@types+react@19.2.14_antd@6.3.7_react-dom@19.2.5_rea_81be994467029f641d72db51e6e26349/node_modules/omniroute/app/node_modules/better-sqlite3/build/better_sqlite3.node\n```\n\n### Steps to Reproduce\n\n1. `pnpm upgrade -g omniroute`\n2. `pnpm approve-builds -g`\n3. `omniroute`\n\n### Expected Behavior\n\nCan upgrade without manual installation procedure\n\n### Actual Behavior\n\nNeed to go to /Users/jo/Library/pnpm/global/5/.pnpm/omniroute@3.7.8_@lobehub+ui@5.10.0_@types+react@19.2.14_antd@6.3.7_react-dom@19.2.5_rea_81be994467029f641d72db51e6e26349/node_modules/omniroute/app\n\nNeed to run `npm install wreq-js@2.3.0 better-sqlite3@12.6.2 --no-save`\n\n### Test Impact\n\nNeeds a new e2e test\n\n### Error Logs / Output\n\n```shell\n\n```\n\n### Screenshots\n\n_No response_\n\n### Additional Context\n\n_No response_\n\n### Validation Plan\n\n_No response_","comments":[],"createdAt":"2026-05-04T11:52:34Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrFQ","name":"bug","description":"Something isn't working","color":"d73a4a"}],"number":1943,"title":"[BUG] pnpm install still requires manual better-sqlite3 installation"} -{"author":{"id":"MDQ6VXNlcjE0OTIxOTgz","is_bot":false,"login":"oyi77","name":"Paijo"},"body":"## Summary\n\nOmniRoute v3.7.8 contains **two different encryption key derivation implementations** in the same package that produce incompatible ciphertexts. The health-check/token-refresh thread uses the old dynamic-salt derivation while the main API uses the new static-salt derivation, causing:\n\n1. **Persistent decrypt failures** — tokens encrypted by the health-check thread cannot be decrypted by the main API\n2. **Re-encryption loop** — the health-check thread continuously re-encrypts tokens with a dynamic salt, undoing any fix applied with the static salt\n3. **CPU spike during crash-loop** — when tokens are encrypted with dynamic salt, the main API fails to decrypt them, causing a cascade of errors and 50% CPU usage\n\n## Root Cause\n\nTwo different key derivation functions exist in the built package:\n\n### Old code (chunk 42476) — Dynamic Salt\n```javascript\nfunction k() {\n if (null !== i) return i;\n let a = process.env.STORAGE_ENCRYPTION_KEY;\n let b = (0, f.createHash)(\"sha256\").update(a).digest().slice(0, 16);\n // ^^^^^^^^^^^^^ DYNAMIC — derived from the key itself\n try { i = (0, f.scryptSync)(a, b, 32); }\n // ...\n}\n```\n\n### New code (chunk 56197) — Static Salt\n```javascript\nfunction l() {\n if (null !== j) return j;\n let a = process.env.STORAGE_ENCRYPTION_KEY;\n let b = \"omniroute-field-encryption-v1\";\n // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ STATIC — hardcoded string\n try { j = (0, g.scryptSync)(a, b, 32); }\n // ...\n}\n```\n\nThese produce **different derived keys** from the same `STORAGE_ENCRYPTION_KEY`, making ciphertexts from one undecryptable by the other.\n\nThe decrypt function in `42476.js` has a fallback that tries the static-salt key when the dynamic-salt key fails, but the **encrypt function** only uses the dynamic-salt key. So the health-check thread encrypts with dynamic salt, the main API tries to decrypt with the dynamic-salt key (different derivation → fails), then falls back to static salt (also fails for newly-encrypted tokens).\n\n## Impact\n\n- **Severity: HIGH** — Causes production outages when tokens become undecryptable\n- **CPU**: 50% CPU usage during crash-loop (from repeated decrypt failures triggering health-check)\n- **Data**: All encrypted tokens become unreadable until manually fixed\n- **Recovery**: Requires manual database fix script to re-encrypt all tokens with static salt\n\n## Reproduction\n\n1. Set `STORAGE_ENCRYPTION_KEY` in server.env\n2. Add OAuth providers — tokens get encrypted with static salt\n3. Health-check thread runs — re-encrypts tokens with dynamic salt\n4. Main API attempts to decrypt — fails (dynamic-salt ciphertext produces different derived key)\n5. Error cascade: decrypt failures → health-check triggers → re-encrypts with dynamic salt → more failures\n6. CPU spikes to 50%, API responses fail for encrypted fields\n\n## Evidence\n\n```bash\n# Check encryption status of active tokens\nnode -e \"\nconst db = require('better-sqlite3')('/home/openclaw/.omniroute/storage.sqlite');\nconst rows = db.prepare('SELECT id, access_token, refresh_token FROM provider_connections WHERE is_active = 1').all();\nlet static = 0, dynamic = 0;\nfor (const row of rows) {\n for (const col of ['access_token', 'refresh_token']) {\n const val = row[col];\n if (!val || val === 'revoked') continue;\n try {\n const buf = Buffer.from(val, 'base64');\n const saltLen = buf.readUInt8(0);\n const salt = buf.slice(1, 1 + saltLen).toString('utf8');\n if (salt === 'omniroute-field-encryption-v1') static++;\n else dynamic++;\n } catch(e) {}\n }\n}\nconsole.log('Static salt:', static, '| Dynamic salt:', dynamic);\ndb.close();\n\"\n\n# Output after health-check thread runs:\n# Static: 0 | Dynamic: 205 (= ALL tokens re-encrypted with dynamic salt)\n\n# Output after running fix-encryption-static.cjs:\n# Static: 539 | Dynamic: 0 (= ALL tokens fixed back to static salt)\n```\n\n## Workaround\n\nWe created a script (`fix-encryption-static.cjs`) that:\n1. Reads all encrypted tokens from the database\n2. Decrypts them using the static-salt key (falling back to dynamic-salt key if static fails)\n3. Re-encrypts them with the static-salt key\n4. Writes the updated tokens back to the database\n\nThis script runs via cron every 5 minutes to counteract the health-check thread re-encryption.\n\n## Suggested Fix\n\n1. **Unify key derivation**: Remove the old dynamic-salt code path and use only the static-salt derivation (`\"omniroute-field-encryption-v1\"`) everywhere\n2. **Add migration**: On startup, scan for tokens encrypted with the dynamic salt and re-encrypt them with the static salt\n3. **Remove the fallback**: The current fallback in the decrypt function (try dynamic, then try static) masks the issue. After migration, only the static salt key should exist.\n\n## Environment\n\n- OmniRoute v3.7.8\n- Node v22.22.1\n- Ubuntu 22.04\n- 1,239 models configured\n- 306 active provider connections\n- STORAGE_ENCRYPTION_KEY set via server.env","comments":[{"id":"IC_kwDORPf6ys8AAAABBLxsAA","author":{"login":"soyelmismo"},"authorAssociation":"NONE","body":"how can i disable the encryption its so annoying.\n","createdAt":"2026-05-04T20:56:21Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1941#issuecomment-4374424576","viewerDidAuthor":false}],"createdAt":"2026-05-04T11:47:27Z","labels":[],"number":1941,"title":"[BUG] Dual encryption key derivation (dynamic vs static salt) causes persistent decrypt failures and 50% CPU"} -{"author":{"id":"MDQ6VXNlcjEwODk3NDc4","is_bot":false,"login":"JxnLexn","name":"Jan Leon"},"body":"### OmniRoute Version\n\n3.7.8\n\n### Installation Method\n\nDocker / Docker Compose\n\n### Operating System\n\nLinux\n\n### OS Version\n\n_No response_\n\n### Node.js Version\n\n_No response_\n\n### Provider(s) Involved\n\nCodex\n\n### Model(s) Involved\n\nGPT-5.5\n\n### Client Tool\n\nVisual Studio Code (Github Copilot Chat)\n\n### Description\n\nThe costs and the analytics page show costs of $0.00 even though many tokens are being used and the prices per token are set. It was working before, but for the past few days it has stopped working. I am using a combo, but it has worked before using a combo. \nOn the costs page it also shows a fallback rate of 100%, even tough there was never a fallback to another model.\n\n### Steps to Reproduce\n\n1. Use a model/tokens\n2. Go to cost or analytics\n3. See $0.00 altough prices are set\n\n### Expected Behavior\n\nShow estimated costs\n\n### Actual Behavior\n\nDid not show estimated costs, instead $0.00\n\n### Test Impact\n\nUnsure\n\n### Error Logs / Output\n\n```shell\n\n```\n\n### Screenshots\n\n**Costs:**\n\"Image\"\n\n**Analytics:**\n\"Image\"\n\n**Fallback Rate:**\n\"Image\"\n\n**Provider Breakdown:**\n\"Image\"\n\n**Model Breakdown:**\n\"Image\"\n\n**Costs Pricing:**\n\"Image\"\n\n\n### Additional Context\n\n_No response_\n\n### Validation Plan\n\n_No response_","comments":[],"createdAt":"2026-05-04T09:57:53Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrFQ","name":"bug","description":"Something isn't working","color":"d73a4a"}],"number":1934,"title":"[BUG] Est. Cost $0.00"} diff --git a/scripts/check-permissions.sh b/scripts/check-permissions.sh index a58d046351..0c25116673 100755 --- a/scripts/check-permissions.sh +++ b/scripts/check-permissions.sh @@ -8,11 +8,18 @@ if [ -n "$OMNIROUTE_MEMORY_MB" ]; then export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=${OMNIROUTE_MEMORY_MB}" fi -if [ -d "/app/data" ] && [ ! -w "/app/data" ]; then - echo "WARNING: /app/data is not writable by the current user (UID $(id -u))." - echo "Run this on the Docker host to fix:" - echo " sudo chown -R $(id -u):$(id -g) /app/data" - echo " chmod -R u+rwX ./data" +DATA_PATH="${DATA_DIR:-/app/data}" +if [ -d "$DATA_PATH" ] && [ ! -w "$DATA_PATH" ]; then + echo "WARNING: $DATA_PATH is not writable by the current user (UID $(id -u))." + if [ "${CONTAINER_HOST:-}" = "podman" ]; then + echo "Rootless Podman maps container UIDs into a subordinate range." + echo "Run this on the host to fix (using the host-side bind-mount path):" + echo " podman unshare chown -R $(id -u):$(id -g) " + else + echo "Run this on the Docker host to fix (using the host-side bind-mount path):" + echo " sudo chown -R $(id -u):$(id -g) " + echo " chmod -R u+rwX " + fi fi exec "$@" diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 8b155975cf..43da508ffb 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -30,6 +30,16 @@ import { const MAX_KEY_NAME_LENGTH = 200; const MAX_SELECTED_MODELS = 500; +function toLocalDateTimeInputValue(value: string | null | undefined): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( + date.getHours() + )}:${pad(date.getMinutes())}`; +} + // Debounce hook for search optimization function useDebouncedValue(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); @@ -2128,15 +2138,32 @@ const PermissionsModal = memo(function PermissionsModal({ Key will automatically stop working after this date.

- { - const val = e.target.value; - setExpiresAt(val ? new Date(val).toISOString() : ""); - }} - className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main" - /> +
+ { + const val = e.target.value; + if (!val) { + setExpiresAt(""); + return; + } + const date = new Date(val); + if (!Number.isNaN(date.getTime())) { + setExpiresAt(date.toISOString()); + } + }} + className="min-w-0 flex-1 px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main" + /> + +
{/* Management Access */}
diff --git a/src/app/(dashboard)/dashboard/provider-stats/page.tsx b/src/app/(dashboard)/dashboard/provider-stats/page.tsx new file mode 100644 index 0000000000..9be2cb0a5a --- /dev/null +++ b/src/app/(dashboard)/dashboard/provider-stats/page.tsx @@ -0,0 +1,499 @@ +"use client"; + +/** + * Provider Stats Dashboard + * + * Shows per-provider and per-model statistics aggregated from call_logs, + * plus in-memory combo metrics and telemetry data. + */ + +import { useState, useEffect, useCallback, Fragment } from "react"; +import { Card } from "@/shared/components"; + +interface ProviderStat { + provider: string; + totalRequests: number; + successfulRequests: number; + avgLatencyMs: number; + totalTokensIn: number; + totalTokensOut: number; +} + +interface ModelStat { + provider: string; + model: string; + requests: number; + avgLatencyMs: number; + successfulRequests: number; +} + +interface ToolLatencyStat { + avgTtftAfterToolMs: number; + avgGapAfterToolMs: number; + measurementCount: number; +} + +type SortKey = "totalRequests" | "successfulRequests" | "avgLatencyMs" | "totalTokensIn" | "totalTokensOut" | "avgTtftAfterToolMs" | "avgGapAfterToolMs"; +type SortDir = "asc" | "desc"; + +function formatNumber(n: number | null): string { + if (n == null) return "0"; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toString(); +} + +function formatLatency(ms: number | null): string { + if (ms == null) return "—"; + if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.round(ms)}ms`; +} + +function successRate(successful: number, total: number): string { + if (total === 0) return "—"; + return `${((successful / total) * 100).toFixed(1)}%`; +} + +export default function ProviderStatsPage() { + const [data, setData] = useState<{ + providers: ProviderStat[]; + models: ModelStat[]; + comboMetrics: Record; + telemetry: Record; + toolLatency: Record; + } | null>(null); + const [error, setError] = useState(null); + const [lastRefresh, setLastRefresh] = useState(null); + const [sortKey, setSortKey] = useState("totalRequests"); + const [sortDir, setSortDir] = useState("desc"); + const [expandedProvider, setExpandedProvider] = useState(null); + + const fetchData = useCallback(async () => { + try { + const res = await fetch("/api/provider-stats"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + setData(json); + setError(null); + setLastRefresh(new Date()); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } + }, []); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 30000); + return () => clearInterval(interval); + }, [fetchData]); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir(sortDir === "desc" ? "asc" : "desc"); + } else { + setSortKey(key); + setSortDir("desc"); + } + }; + + // Compute summary stats + const totalRequests = data?.providers.reduce((s, p) => s + (p.totalRequests || 0), 0) ?? 0; + const totalSuccessful = data?.providers.reduce((s, p) => s + (p.successfulRequests || 0), 0) ?? 0; + const avgLatency = + data?.providers.length + ? Math.round( + data.providers.reduce((s, p) => s + (p.avgLatencyMs || 0), 0) / data.providers.length + ) + : 0; + const activeProviders = data?.providers.length ?? 0; + + // Sorted providers + const sortedProviders = [...(data?.providers ?? [])].sort((a, b) => { + if (sortKey === "avgTtftAfterToolMs" || sortKey === "avgGapAfterToolMs") { + const aLatency = data?.toolLatency?.[a.provider] ?? null; + const bLatency = data?.toolLatency?.[b.provider] ?? null; + const va = aLatency ? (aLatency[sortKey] as number) ?? 0 : 0; + const vb = bLatency ? (bLatency[sortKey] as number) ?? 0 : 0; + return sortDir === "desc" ? vb - va : va - vb; + } + const va = (a[sortKey] as number) ?? 0; + const vb = (b[sortKey] as number) ?? 0; + return sortDir === "desc" ? vb - va : va - vb; + }); + + // Group models by provider + const modelsByProvider = new Map(); + for (const m of data?.models ?? []) { + if (!modelsByProvider.has(m.provider)) modelsByProvider.set(m.provider, []); + modelsByProvider.get(m.provider)!.push(m); + } + + // Sort helper icon + const SortIcon = ({ column }: { column: SortKey }) => { + if (sortKey !== column) return null; + return ( + + {sortDir === "desc" ? "arrow_downward" : "arrow_upward"} + + ); + }; + + if (!data && !error) { + return ( +
+
+
+

Loading provider stats...

+
+
+ ); + } + + if (error && !data) { + return ( +
+
+ error +

Failed to load provider stats: {error}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ {lastRefresh && ( + + Updated {lastRefresh.toLocaleTimeString()} + + )} + +
+ + {/* Summary Cards */} +
+ +
+
+ analytics +
+ Total Requests +
+

{formatNumber(totalRequests)}

+
+ + +
+
+ timer +
+ Avg Latency +
+

{formatLatency(avgLatency)}

+
+ + +
+
+ check_circle +
+ Success Rate +
+

+ {successRate(totalSuccessful, totalRequests)} +

+
+ + +
+
+ dns +
+ Active Providers +
+

{activeProviders}

+
+
+ + {/* Provider Table */} + +
+

+ table_chart + Provider Breakdown +

+ {activeProviders} providers +
+ +
+ + + + + + + + + + + + + + + + {sortedProviders.map((p) => { + const isExpanded = expandedProvider === p.provider; + const models = modelsByProvider.get(p.provider) ?? []; + const rate = p.totalRequests > 0 ? (p.successfulRequests / p.totalRequests) * 100 : 0; + return ( + + + setExpandedProvider(isExpanded ? null : models.length > 0 ? p.provider : null) + } + > + + + + + + + + + + + + {isExpanded && models.length > 0 && ( + + + + )} + + ); + })} + {sortedProviders.length === 0 && ( + + + + )} + +
Provider handleSort("totalRequests")} + > + Requests + handleSort("successfulRequests")} + > + Success + Rate handleSort("avgLatencyMs")} + > + Avg Latency + handleSort("totalTokensIn")} + > + Tokens In + handleSort("totalTokensOut")} + > + Tokens Out + handleSort("avgTtftAfterToolMs")} + > + TTFT After Tool + handleSort("avgGapAfterToolMs")} + > + Gap After Tool + +
+ {p.provider} + + {formatNumber(p.totalRequests)} + + {formatNumber(p.successfulRequests)} + + = 99 + ? "text-green-500" + : rate >= 95 + ? "text-amber-500" + : "text-red-500" + } + > + {rate.toFixed(1)}% + + + {formatLatency(p.avgLatencyMs)} + + {formatNumber(p.totalTokensIn)} + + {formatNumber(p.totalTokensOut)} + + {formatLatency(data?.toolLatency?.[p.provider]?.avgTtftAfterToolMs ?? null)} + + {formatLatency(data?.toolLatency?.[p.provider]?.avgGapAfterToolMs ?? null)} + + {models.length > 0 && ( + + chevron_right + + )} +
+
+ + + + + + + + + + + + {models.map((m) => { + const mRate = + m.requests > 0 ? (m.successfulRequests / m.requests) * 100 : 0; + return ( + + + + + + + + ); + })} + +
ModelRequestsSuccessRateAvg Latency +
+ {m.model} + + {formatNumber(m.requests)} + + {formatNumber(m.successfulRequests)} + + = 99 + ? "text-green-500" + : mRate >= 95 + ? "text-amber-500" + : "text-red-500" + } + > + {mRate.toFixed(1)}% + + + {formatLatency(m.avgLatencyMs)} + +
+
+
+ No provider data recorded yet. +
+
+
+ + {/* In-Memory Metrics */} + {data?.comboMetrics && Object.keys(data.comboMetrics).length > 0 && ( + +
+ + + + + + + + + + + + {Object.entries(data.comboMetrics).map(([name, m]: [string, any]) => ( + + + + + + + + ))} + +
ComboRequestsAvg TTFTAvg TotalSuccess
{name} + {formatNumber(m.requestCount ?? m.totalRequests ?? 0)} + + {formatLatency(m.avgTtft ?? m.ttftMs ?? null)} + + {formatLatency(m.avgLatency ?? m.avgTotalMs ?? null)} + + {typeof m.successRate === "number" ? `${(m.successRate * 100).toFixed(1)}%` : "—"} +
+
+
+ )} + + {/* Telemetry Phase Breakdown */} + {data?.telemetry && Object.keys(data.telemetry).length > 0 && ( + +
+ {Object.entries(data.telemetry).map(([key, val]: [string, any]) => { + if (val == null || typeof val === "object") return null; + return ( +
+

+ {key.replace(/([A-Z])/g, " $1").replace(/_/g, " ")} +

+

+ {typeof val === "number" ? formatNumber(val) : String(val)} +

+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/ai/page.tsx b/src/app/(dashboard)/dashboard/settings/ai/page.tsx index 0770715dda..21b16f5de3 100644 --- a/src/app/(dashboard)/dashboard/settings/ai/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/ai/page.tsx @@ -4,6 +4,7 @@ import { useTranslations } from "next-intl"; import ThinkingBudgetTab from "../components/ThinkingBudgetTab"; import VisionBridgeSettingsTab from "../components/VisionBridgeSettingsTab"; import SystemPromptTab from "../components/SystemPromptTab"; +import ResponsesStatePolicyTab from "../components/ResponsesStatePolicyTab"; import CodexFastTierTab from "../components/CodexFastTierTab"; import ClaudeFastModeTab from "../components/ClaudeFastModeTab"; import MemorySkillsTab from "../components/MemorySkillsTab"; @@ -17,6 +18,7 @@ export default function SettingsAiPage() { + diff --git a/src/app/(dashboard)/dashboard/settings/components/ResponsesStatePolicyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResponsesStatePolicyTab.tsx new file mode 100644 index 0000000000..3494631920 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/ResponsesStatePolicyTab.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { Card, Select } from "@/shared/components"; +import { + DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE, + RESPONSES_PREVIOUS_RESPONSE_ID_MODES, + type ResponsesPreviousResponseIdMode, +} from "@/shared/constants/responsesPreviousResponseId"; + +const MODE_SET = new Set(RESPONSES_PREVIOUS_RESPONSE_ID_MODES); + +function normalizeMode(value: unknown): ResponsesPreviousResponseIdMode { + return typeof value === "string" && MODE_SET.has(value) + ? (value as ResponsesPreviousResponseIdMode) + : DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE; +} + +export default function ResponsesStatePolicyTab() { + const [mode, setMode] = useState( + DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE + ); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState<"" | "saved" | "error">(""); + const t = useTranslations("settings"); + + useEffect(() => { + let cancelled = false; + fetch("/api/settings") + .then((res) => res.json()) + .then((data) => { + if (cancelled) return; + setMode(normalizeMode(data?.responsesPreviousResponseIdMode)); + setLoading(false); + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const save = async (nextMode: ResponsesPreviousResponseIdMode) => { + if (saving || loading || nextMode === mode) return; + const previousMode = mode; + setMode(nextMode); + setSaving(true); + setStatus(""); + + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ responsesPreviousResponseIdMode: nextMode }), + }); + if (!res.ok) { + setMode(previousMode); + setStatus("error"); + return; + } + setStatus("saved"); + setTimeout(() => setStatus(""), 2000); + } catch { + setMode(previousMode); + setStatus("error"); + } finally { + setSaving(false); + } + }; + + return ( + +
+
+ +
+
+

{t("responsesStateTitle")}

+

{t("responsesStateDesc")}

+
+
+ {status === "saved" && ( + + check_circle{" "} + {t("saved")} + + )} + {status === "error" && ( + + error{" "} + {t("responsesStateSaveError")} + + )} +
+
+ +