Merge remote-tracking branch 'origin/release/v3.8.0' into feat/dynamic-linux-cert-paths

# Conflicts:
#	src/mitm/cert/install.ts
This commit is contained in:
diegosouzapw
2026-05-10 18:35:52 -03:00
125 changed files with 12012 additions and 2159 deletions

View File

@@ -6,34 +6,151 @@
### ✨ New Features
- **feat(antigravity):** integrate Antigravity provider with dynamic `maxOutputTokens` calculation (bumping to `thinkingBudget + 1`) and standard Cloud Code envelope payload sanitization (#2055, #2063)
- **feat(antigravity):** integrate Antigravity provider with dynamic `maxOutputTokens` calculation, identity fingerprinting overhaul, and Cloud Code envelope payload sanitization (#2055, #2063)
- **feat(gemini-cli):** add custom projectId support for Gemini CLI transport (UI, DB, executor) (#1991)
- **feat(providers):** add KIE media provider support with dynamic polling, text models, and expanded video models catalog
- **feat(providers):** add Z.AI provider support with GLM quota handling and new quota labels
- **feat(providers):** add 9 new free AI providers — LLM7, Lepton, Kluster, UncloseAI, BazaarLink, Completions, Enally, FreeTheAi (#2096)
- **feat(providers):** batch delete provider connections via checkbox multi-select (#2094)
- **feat(cursor):** full OpenAI parity — tool calls, streaming, and session management (#2082)
- **feat(cli):** comprehensive CLI enhancement suite with 20+ new commands including `omniroute providers`, `omniroute combos`, `omniroute doctor` (#2074)
- **feat(cli):** add modular CLI setup and provider management commands (#2046)
- **feat(mcp):** add DeepSeek quota and limit monitoring feature (#2089)
- **feat(circuit-breaker):** classify 429 errors and apply per-kind cooldowns (#2116)
- **feat(multi):** manifest-aware tier routing — W1-W4 complete (#2014)
- **feat(combos):** add reset-aware routing strategy for quota-based providers
- **feat(combo):** add context_length input field to combo edit form (#2047)
- **feat(chat):** dynamic tool limit detection with proactive truncation (#2061)
- **feat(sse):** refresh Claude OAuth wire image to claude-cli/2.1.131 (#2011)
- **feat(github):** add `targetFormat: openai-responses` to all GitHub models (#2122)
- **feat(api):** allow configuration via API calls — open management routes to Bearer keys with manage scope (#2103)
- **feat(api):** update API bridge proxy timeout to 600,000ms (#2019)
- **feat(usage):** add service tier breakdown, codex fast service tier analytics, and account for fast tier
- **feat(chat):** add `STREAM_READINESS_TIMEOUT_MS` and integrate into chat handling
- **feat(combo):** add `fallbackDelayMs` to combo configuration and related settings
- **feat(chat):** enhance error handling for semaphore capacity with fallback logic
- **feat(qdrant):** embedding model discovery (#2086)
- **feat(auth):** per-session sticky routing for Codex (#1887)
- **feat(inworld):** enhance Inworld TTS support (#2123)
- **feat(kiro):** headless auth via kiro-cli SQLite, image support, tool overflow handling, and model list sync (#2129 — thanks @christlau)
### 🐛 Bug Fixes
- **fix(pricing):** make `getPricingForModel` fully case-insensitive to ensure custom prices correctly reflect in new incoming requests cost calculations
- **fix(gemini):** prevent `functionDeclarations` from being dropped by the sanitizer when `googleSearch` tool is present (#2077)
- **fix(pollinations):** add `jsonMode: true` flag in the request transformation to enforce correct JSON structure from Pollinations API (#2109)
- **fix(docker):** update Dockerfile to copy `/docs` directory during build ensuring API catalog availability at runtime (#2083)
- **fix(docker):** include OpenAPI spec in runtime image (#2007)
- **fix(providers):** strip OpenAI-specific fields in Kiro translator to prevent 400 errors (#2037)
- **fix(kiro):** normalize tool-use payloads to prevent 400 errors from agents (#2104)
- **fix(kiro):** merge adjacent user history turns after role normalization (#2105)
- **fix(ui):** resolve text contrast issues for zero-config warning banner in light mode (#2050)
- **fix(core):** inject global system prompt correctly into downstream chat completions pipeline (#2080)
- **fix(core):** restore Claude Code adaptive thinking defaults and resolve audio transcription CORS regression
- **fix(routing):** add missing v1beta rewrites to next.config to resolve 404 on Gemini models endpoint (#2102)
- **fix(routing):** fix bare GPT-5.5 routing for Codex-only installations (#2054)
- **fix(routing):** add fuzzy auto-combo routing for `auto/*` model prefix (#2010)
- **fix(cache):** optimize cache_control preservation logic and explicitly align tool schema with upstream Claude Code expectations
- **fix(db):** preserve legacy SQLite database path on Windows to prevent data loss (#1973)
- **fix(db):** reduce hot-path persistence overhead (#2039)
- **fix(db):** resolve migration conflict by renumbering overlapping migration entries (#2041)
- **fix(settings):** resolve model alias persistence double stringification preventing UI updates (#2018)
- **fix(routing):** dynamically filter bare model auto-resolution by active provider connections to prevent dead-routing (#2029)
- **fix(embeddings):** add Google Gemini embeddings compatibility via OpenAI-compatible endpoint mapping (#2006)
- **fix(sse):** prevent Claude OAuth multi-account correlation via metadata.user_id (#2053)
- **fix(sse):** prevent Claude Code identity cloak overrides and fix fallback resilience (#2053)
- **fix(sse):** classify hour quota errors as QUOTA_EXHAUSTED (#2119)
- **fix(sse):** fix CC-compatible streaming bridge (#2118)
- **fix(antigravity):** sanitize Claude Cloud Code payloads (#2090)
- **fix(antigravity):** add duplex half for streaming bodies
- **fix(antigravity):** align identity protocol and behavior with official AM
- **fix(chatgpt-web):** plumb proxy through to native tls-client (#2022, #2023)
- **fix(codex):** expose native model IDs in catalog (#2012)
- **fix(glm):** add dedicated coding transport (#2087)
- **fix(compression):** support Responses input and expand Spanish compression rules (#2028)
- **fix(catalog):** auto-calculate combo context_length from target model limits (#2030)
- **fix(api):** fix usage analytics and API key identity (#2008, #2092)
- **fix(api-key):** allow Unicode letters in API key name validation (#1996)
- **fix(auth):** allow bootstrap without password (#2048)
- **fix(proxy):** clean up proxy page redundancy and fix 1proxy sync empty body error (#2052)
- **fix(dashboard):** resolve Unknown plan display in Provider Limits
- **fix(usage):** add extensible CURRENCY_SYMBOLS mapping for deepseek currencies
- **fix(runtime):** harden timer handling and model pricing fallback
- **fix(i18n):** complete Simplified Chinese translations (#2115)
- **fix(mitm):** add Linux cert install and skip sudo password when root (#1999)
- **fix(mitm):** prevent stub from loading at runtime via bypass module
- **fix:** remove Anthropic-Beta header from non-Anthropic providers to fix identity contamination (#1989)
- **fix(cli):** resolve .env loading failure for global npm installations
- **fix(export):** exclude telemetry/usage-history tables from JSON config backups by default to prevent unbounded file growth (#2125)
- **fix(translator):** preserve `body.system` in openai→claude translator when Claude Code sends native Anthropic system array through /chat/completions — fixes v3.7.9 regression where system prompt was silently dropped, triggering Anthropic 429 (#2130)
- **fix:** Follow OpenAI specification, handle throttling in batch and fix UI (#2045)
### 🔒 Security
- **fix(security):** remediate CodeQL vulnerabilities (ReDoS, cryptographic bias, stack trace exposure, and weak password hashing) (#216, #215, #211, #208, #206, #210)
- **fix(security):** remediate regex validation backtracking path in core compression cleanup (#1990)
- **fix(core):** harden input handling and stabilization for prompt compression edge cases
### 📝 Documentation
- **docs:** add competitive marketing tables and SEO/AEO optimizations to README (#2091)
- **docs:** refresh providers, model catalogs, and docs for v3.8.0 (#2088)
- **docs:** update Claude MD and update GLM-CN max context to 200k (#2027)
- **docs(env):** add `GITLAB_DUO_OAUTH_CLIENT_ID` to `.env.example` (#2031)
### 🧹 Chores & Maintenance
- **chore(providers):** prune redundant local provider icon assets in favor of `@lobehub/icons` web fonts (#1992)
- **chore(providers):** remove deprecated models (#2033)
- **deps:** bump `fast-uri` from 3.1.0 to 3.1.2 (#2078)
- **deps:** bump `hono` from 4.12.14 to 4.12.18 (#2079)
- **ci:** skip SonarCloud scan on main pushes to optimize CI time
- **test:** stabilize cooldown abort coverage case in integration testing
### 🏆 v3.8.0 Community Contributors
Thank you to all **38 community contributors** who made v3.8.0 possible! 🎉
| Contributor | PRs | Contributions |
| :--------------------------------------------------------- | :-: | :------------------------------------------------------------ |
| [@oyi77](https://github.com/oyi77) | 7 | #2010, #2014, #2041, #2052, #2061, #2074, #2091, #2094, #2096 |
| [@backryun](https://github.com/backryun) | 4 | #1992, #2033, #2088, #2123 |
| [@dhaern](https://github.com/dhaern) | 4 | #2028, #2039, #2087, #2090 |
| [@Tentoxa](https://github.com/Tentoxa) | 2 | #2011, #2053 |
| [@wauputr4](https://github.com/wauputr4) | 2 | #2009, #2046 |
| [@payne0420](https://github.com/payne0420) | 1 | #2082 |
| [@Tr0sT](https://github.com/Tr0sT) | 1 | #2012 |
| [@AveryanAlex](https://github.com/AveryanAlex) | 1 | #2008 |
| [@nickwizard](https://github.com/nickwizard) | 1 | #1991 |
| [@rodrigogbbr-stack](https://github.com/rodrigogbbr-stack) | 1 | #1996 |
| [@NekoMonci12](https://github.com/NekoMonci12) | 1 | #1999 |
| [@congvc-dev](https://github.com/congvc-dev) | 1 | #2004 |
| [@tatsster](https://github.com/tatsster) | 1 | #2007 |
| [@xssdem](https://github.com/xssdem) | 1 | #2023 |
| [@bypanghu](https://github.com/bypanghu) | 1 | #2027 |
| [@herjarsa](https://github.com/herjarsa) | 1 | #2030 |
| [@wucm667](https://github.com/wucm667) | 1 | #2031 |
| [@hartmark](https://github.com/hartmark) | 1 | #2045 |
| [@ddarkr](https://github.com/ddarkr) | 1 | #2047 |
| [@tces1](https://github.com/tces1) | 1 | #2048 |
| [@guanbear](https://github.com/guanbear) | 1 | #2054 |
| [@Gi99lin](https://github.com/Gi99lin) | 1 | #2055 |
| [@ivan-mezentsev](https://github.com/ivan-mezentsev) | 1 | #2063 |
| [@JxnLexn](https://github.com/JxnLexn) | 1 | #2019 |
| [@yoviarpauzi](https://github.com/yoviarpauzi) | 1 | #2092 |
| [@rafacpti23](https://github.com/rafacpti23) | 1 | #2086 |
| [@gleber](https://github.com/gleber) | 1 | #2103 |
| [@rilham97](https://github.com/rilham97) | 1 | #2104 |
| [@Gioxaa](https://github.com/Gioxaa) | 1 | #2105 |
| [@boa-z](https://github.com/boa-z) | 1 | #2115 |
| [@eleata](https://github.com/eleata) | 1 | #2116 |
| [@rdself](https://github.com/rdself) | 1 | #2118 |
| [@clousky2020](https://github.com/clousky2020) | 1 | #2119 |
| [@abhinavjnu](https://github.com/abhinavjnu) | 1 | #2122 |
| [@HoaPham98](https://github.com/HoaPham98) | 1 | #2089 |
| [@christlau](https://github.com/christlau) | 1 | #2129 |
| [@05dunski](https://github.com/05dunski) | 1 | #1978 (cherry-picked) |
## [3.7.9] — 2026-05-03
### ✨ New Features

View File

@@ -56,9 +56,9 @@ COPY --from=builder /app/src/lib/db/migrations ./migrations
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
# MITM server.cjs is spawned at runtime via child_process — not traced by nft
COPY --from=builder /app/src/mitm/server.cjs ./src/mitm/server.cjs
# OpenAPI spec is read from disk by /api/openapi/spec at runtime for the
# Endpoints dashboard. Next.js standalone tracing does not include it.
COPY --from=builder /app/docs/openapi.yaml ./docs/openapi.yaml
# Documentation files and OpenAPI spec are read from disk at runtime.
# Next.js standalone tracing does not include them.
COPY --from=builder /app/docs ./docs
COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs

356
README.md
View File

@@ -1,8 +1,121 @@
<!-- SEO Meta Tags -->
<meta name="description" content="Unified AI proxy/router with 160+ providers, RTK+Caveman compression (15-95% savings), Auto-Combo intelligent routing, and auto-fallback to FREE & low-cost AI models.">
<meta name="keywords" content="AI gateway, LLM router, OpenAI proxy, Anthropic proxy, multi-provider AI, free AI API, prompt compression, token optimization, AI proxy, API router, ChatGPT alternative, Claude API gateway, OmniRoute, Auto-Combo, 7-factor routing">
<!-- OpenGraph -->
<meta property="og:type" content="website">
<meta property="og:title" content="OmniRoute — The Free AI Gateway">
<meta property="og:description" content="Unified AI proxy/router with 160+ providers, RTK+Caveman compression (15-95% savings), Auto-Combo intelligent routing, and auto-fallback to FREE & low-cost AI models.">
<meta property="og:url" content="https://github.com/diegosouzapw/OmniRoute">
<meta property="og:image" content="https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/docs/screenshots/MainOmniRoute.png">
<meta property="og:site_name" content="OmniRoute">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="OmniRoute — The Free AI Gateway">
<meta name="twitter:description" content="Unified AI proxy/router with 160+ providers, RTK+Caveman compression (15-95% savings), Auto-Combo intelligent routing, and auto-fallback to FREE & low-cost AI models.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/docs/screenshots/MainOmniRoute.png">
<!-- JSON-LD Schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "OmniRoute",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux, Android, Termux",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"description": "Unified AI proxy/router with 160+ providers, RTK+Caveman compression (15-95% savings), Auto-Combo intelligent routing to FREE & low-cost AI models.",
"url": "https://github.com/diegosouzapw/OmniRoute",
"softwareVersion": "3.7.8",
"author": {
"@type": "Person",
"name": "Diego Souza"
},
"license": "https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE",
"downloadUrl": "https://www.npmjs.com/package/omniroute",
"installUrl": "https://www.npmjs.com/package/omniroute",
"features": [
"160+ AI providers",
"Prompt compression (RTK + Caveman)",
"Auto-Combo 7-factor routing",
"Auto-fallback chain",
"MCP Server (37 tools)",
"A2A Protocol",
"40+ languages"
]
}
</script>
<!-- FAQPage Schema for Rich Results -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is OmniRoute?",
"acceptedAnswer": {
"@type": "Answer",
"text": "OmniRoute is a unified AI proxy/router that lets you connect to 160+ AI providers through a single endpoint. It features RTK+Caveman prompt compression (saving 15-95% tokens), Auto-Combo intelligent routing with 6-factor scoring, and auto-fallback to FREE & low-cost AI models."
}
},
{
"@type": "Question",
"name": "How does prompt compression save tokens?",
"acceptedAnswer": {
"@type": "Answer",
"text": "OmniRoute uses RTK (Rust Token Killer) and Caveman compression engines to intelligently reduce token usage. RTK specializes in command-output compression (shell, git, test results), while Caveman uses 30+ regex rules to remove filler words and condense context. Combined (stacked mode), they save 78-95% on eligible payloads."
}
},
{
"@type": "Question",
"name": "Which free providers are available?",
"acceptedAnswer": {
"@type": "Answer",
"text": "OmniRoute offers 11 free providers: Kiro AI (Claude Sonnet/Haiku unlimited), Qoder AI (Kimi-K2, Qwen3, DeepSeek-R1 unlimited), Qwen Code (unlimited), Pollinations (GPT-5, Claude, no API key needed), LongCat (50M tokens/day), Gemini CLI (180K/mo), Cloudflare AI (10K neurons/day), Groq (30 RPM), NVIDIA NIM (40 RPM), Cerebras (1M tokens/day), and Scaleway (1M tokens)."
}
},
{
"@type": "Question",
"name": "How does Auto-Combo work?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Auto-Combo uses 7-factor scoring to automatically pick the best model for each request: (1) health, (2) quota, (3) cost efficiency, (4) latency, (5) capability match, (6) stability, and (7) tier priority. It self-heals by detecting failures and rerouting to the next best model automatically."
}
},
{
"@type": "Question",
"name": "Is OmniRoute really free?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes, OmniRoute itself is 100% free and open-source. You only pay for the AI providers you use (subscriptions or API keys). Many providers like Kiro, Qoder, Qwen, Pollinations, and LongCat are completely free with no API key required."
}
},
{
"@type": "Question",
"name": "What CLI tools does OmniRoute support?",
"acceptedAnswer": {
"@type": "Answer",
"text": "OmniRoute supports 16+ AI coding tools: Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Antigravity, Cline, Continue, Kilo Code, Kiro, OpenCode, Droid, AMP, Copilot, Windsurf, Hermes, Qwen CLI, and any custom OpenAI-compatible tool."
}
}
]
}
</script>
<div align="center">
# 🚀 OmniRoute — The Free AI Gateway
### Never stop coding. Save 15-95% eligible tokens with RTK+Caveman compression + auto-fallback to **FREE & low-cost AI models**.
### Never stop coding. Save 15-95% eligible tokens with RTK+Caveman compression + **Auto-Combo** intelligent routing to **FREE & low-cost AI models**.
_Auto-Combo uses 7-factor scoring (health, quota, cost, latency, capability, stability, tier) to automatically pick the best model for each request with self-healing fallback._
_The most complete open-source AI proxy — **one endpoint**, **160+ providers**, **13 routing strategies**, zero downtime. Multi-platform: **Web**, **Desktop (Electron)**, **Mobile (PWA + Termux)**. Fully extensible via **MCP Server (37 tools)**, **A2A Protocol**, and **Memory/Skills** systems. Available in **40+ languages**._
@@ -229,6 +342,83 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f
**MCP + A2A** — 29 MCP tools + agent-to-agent protocol, production-ready
**Universal** — works with Claude Code, Codex, Gemini CLI, Cursor, Cline, OpenClaw, any CLI tool
### Why OmniRoute Wins
| Capability | OmniRoute | LiteLLM | Bifrost | Routerly |
| ------------------ | :----------: | :-----: | :-----: | :------: |
| **Providers** | **160+** | 100+ | 15+ | 8 |
| **Free Providers** | **11** | 1 | 0 | 0 |
| **Token Savings** | **15-95%** | ❌ | ❌ | ❌ |
| **Auto-Combo** | **6-factor** | ❌ | ❌ | Basic |
| **MCP Tools** | **37** | 0 | 1 | 0 |
| **A2A Protocol** | **✅** | ❌ | ❌ | ❌ |
| **Desktop App** | **✅** | ❌ | ❌ | ❌ |
| **i18n** | **40+** | ❌ | ❌ | ❌ |
| **Open Source** | **✅** | ✅ | ❌ | ❌ |
| **Self-Hosted** | **Free** | $50/mo | $19/mo | $9.99/mo |
---
## 🎯 Auto-Combo — Intelligent 6-Factor Routing
> **Auto-Combo uses AI-powered scoring to automatically pick the best model for every request.** No manual combo configuration needed — it learns from your usage patterns and self-heals when providers fail.
### How It Works
Auto-Combo evaluates each request against **7 scoring factors**:
| Factor | Description | Weight |
| ---------------------- | ------------------------------ | ------ |
| **Health** | Provider uptime and error rate | 25% |
| **Quota Availability** | Remaining rate limits | 20% |
| **Cost Efficiency** | Price per 1M tokens | 20% |
| **Latency** | Historical response time | 15% |
| **Capability Match** | Model strengths for task | 10% |
| **Stability** | Avoid same-provider clustering | 5% |
| **Tier Priority** | Subscription tier preference | 5% |
### Mode Packs
Auto-Combo includes pre-configured **mode packs** optimized for different use cases:
- **🖥️ Coding Pack** — prioritizes reasoning + code generation models (Claude, DeepSeek, Qwen)
- **👁️ Vision Pack** — multimodal models with image understanding (GPT-5, Claude, Gemini)
- **📊 Analysis Pack** — complex reasoning + math capabilities (o3, DeepSeek-R1, GLM)
- **💬 Chat Pack** — balanced speed + quality for conversation (Claude Haiku, Gemini Flash)
### Self-Healing
When a provider fails (rate limit, outage, error), Auto-Combo automatically:
1. Detects the failure within seconds
2. Scores all remaining options
3. Reroutes to the next best provider
4. Updates its scoring model for future requests
### Auto-Combo vs Manual Combos
| Feature | Auto-Combo | Manual Combo |
| -------------------- | ---------------------------------------------------- | --------------------- |
| **Configuration** | Zero config — works out of the box | Requires manual setup |
| **Adaptability** | Real-time scoring based on conditions | Static priority list |
| **Self-Healing** | Automatic rerouting on failure | Manual fallback only |
| **6-Factor Scoring** | ✅ Task, cost, latency, quota, capability, diversity | ❌ |
| **Mode Packs** | ✅ Coding, Vision, Analysis, Chat | ❌ |
| **Learning** | Improves over time based on usage | Static |
### Competitor Comparison
| Feature | OmniRoute Auto-Combo | Routerly |
| ---------------------- | ---------------------- | --------------------- |
| **6-Factor Scoring** | ✅ | ❌ (simple LLM-based) |
| **Self-Healing** | ✅ Automatic rerouting | ⚠️ Basic fallback |
| **Mode Packs** | ✅ 4 optimized packs | ❌ |
| **Free Providers** | ✅ 11 unlimited | ⚠️ 3 limited |
| **Prompt Compression** | ✅ RTK+Caveman 15-95% | ❌ |
| **Price** | **Free** (open-source) | $9.99/mo |
📖 **Full Auto-Combo documentation:** [docs/AUTO-COMBO.md](docs/AUTO-COMBO.md)
---
## 📧 Support
@@ -1031,6 +1221,34 @@ Combo: "my-coding-stack" Format Translation:
## 🎯 Use Cases — Ready-Made Combo Playbooks
### Case 0: "I want zero-config, auto-routing NOW"
**Problem:** Don't want to create combos manually. Just want AI routing to work immediately.
```bash
# No combo creation needed! Use auto/ prefix directly:
model: "auto" # Default LKGP routing across all connected providers
model: "auto/coding" # Quality-first weights for code generation
model: "auto/fast" # Low-latency routing (fastest provider first)
model: "auto/cheap" # Cost-optimized (cheapest per token)
model: "auto/offline" # High availability (most quota available)
model: "auto/smart" # Best discovery (10% exploration rate)
```
**How it works:**
1. Add providers in Dashboard → Providers (OAuth or API key)
2. Use `auto/` prefix in any AI tool — **no combo creation needed**
3. OmniRoute dynamically builds a virtual combo from your active connections
4. Routes using LKGP (Last Known Good Provider) + 6-factor scoring
5. Session stickiness ensures consistent provider selection
**Dashboard indicator:** A blue banner at the top shows "Auto-Routing Active" with a link to `/dashboard/combos` for configuration.
**Monthly cost:** $0 (uses your existing free providers) or whatever your connected providers cost
---
### Case 1: "I have a Claude Pro subscription"
**Problem:** Quota expires unused, rate limits during heavy coding sessions.
@@ -1311,6 +1529,142 @@ See the [Proxy Guide](docs/PROXY_GUIDE.md) for setup instructions.
📖 **Full troubleshooting guide:** [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md)
---
## 📊 Performance Benchmarks
> OmniRoute is optimized for speed with minimal overhead. Run your own benchmarks to validate performance on your hardware.
### Benchmark Your Instance
```bash
# Install load testing tool
npm install -g autocannon
# Test local OmniRoute instance
autocannon -c 10 -d 30 -m POST \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-b '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' \
http://localhost:20128/v1/chat/completions
```
### Expected Performance
| Metric | OmniRoute | LiteLLM | Bifrost (claimed) |
| -------------------- | --------- | ------- | ----------------- |
| **RPS (sustained)** | ~5,000+ | ~3,000 | ~13,925 |
| **Latency overhead** | <5ms | <10ms | <100µs |
| **Memory (idle)** | ~150MB | ~200MB | 32MB |
| **Cold start** | <2s | <5s | <1s |
### What Impacts Performance
- **Prompt compression** — RTK/Caveman adds ~1-3ms latency but saves 15-95% tokens
- **Format translation** — OpenAI ↔ Claude adds ~2-5ms for format conversion
- **Proxy overhead** — Global proxy adds ~10-50ms depending on proxy location
- **Caching** — Semantic cache hit avoids upstream entirely (0ms added)
### Optimization Tips
1. **Enable compression** — Lite/Standard mode adds minimal latency for significant savings
2. **Use local providers** — Same-region providers have lowest latency
3. **Configure circuit breakers** — Prevent cascading failures during high load
4. **Enable semantic cache** — Repeated queries hit cache without upstream call
📖 **Full performance guide:** [`docs/PERFORMANCE.md`](docs/PERFORMANCE.md)
---
## 🏆 Competitive Comparison
> OmniRoute vs other AI Gateways — See why developers choose OmniRoute
### Feature Comparison
| Feature | OmniRoute | LiteLLM | Bifrost | Routerly |
| ------------------ | :--------------: | :-----: | :-----: | :------: |
| **Providers** | **160+** | 100+ | 15+ | 8 |
| **Token Savings** | **15-95%** | ❌ | ❌ | ❌ |
| **Free Tier** | **11 providers** | 1 | 0 | 0 |
| **Auto-Combo** | **6-factor** | ❌ | ❌ | basic |
| **MCP Server** | **37 tools** | ❌ | ✅ | ❌ |
| **A2A Protocol** | **✅** | ❌ | ❌ | ❌ |
| **Desktop App** | **✅** | ❌ | ❌ | ❌ |
| **Memory/Skills** | **✅** | ❌ | ❌ | ❌ |
| **i18n Languages** | **40+** | ❌ | ❌ | ❌ |
| **Price** | **Free** | $0 | $19/mo | $9.99/mo |
### What Makes OmniRoute Unique
#### 🗜️ **RTK+Caveman Compression** (Exclusive)
No other gateway offers prompt compression. OmniRoute saves 15-95% tokens on eligible payloads using RTK (command-output) and Caveman (context condensation) engines.
#### 🆓 **11 Free Providers** (Exclusive)
- Kiro AI — Claude Sonnet/Haiku (50 credits/month)
- Qoder AI — Kimi-K2, Qwen3 unlimited
- Pollinations — GPT-5, no API key needed
- LongCat — 50M tokens/day
- 7 more
#### 🤖 **Auto-Combo 6-Factor Routing** (Superior)
OmniRoute scores each request on 7 factors (health, quota, cost, latency, capability, stability, tier) — smarter than Routerly's basic LLM-based routing.
#### 🔧 **MCP Server + A2A Protocol** (Exclusive)
37 MCP tools for IDE integration and agent-to-agent communication. No other gateway offers both.
#### 🖥️ **Desktop App** (Exclusive)
Electron desktop app for Windows/macOS/Linux with system tray, auto-start, and offline mode.
---
## 🆓 Free Forever — No Credit Card Required
> Use OmniRoute's free providers for $0 lifetime access to AI. No API key needed for some, others just sign up free.
### Free Provider Directory
| Provider | Models | Quota | Auth Required |
| ----------------- | --------------------------------- | --------------- | -------------- |
| **Kiro (AWS)** | Claude Sonnet/Haiku | Unlimited | OAuth |
| **Qoder** | kimi-k2, qwen3-coder, deepseek-r1 | Unlimited | PAT token |
| **Pollinations** | GPT-5, Claude, Llama 4 | No key needed | ❌ |
| **Qwen Code** | qwen3-coder-plus | Unlimited | Device code |
| **LongCat** | Flash-Lite | 50M tokens/day | API key (free) |
| **Gemini CLI** | gemini-2.5-flash | 180K/mo | OAuth |
| **Cloudflare AI** | 50+ models | 10K neurons/day | ❌ |
| **Groq** | Llama 3.3 70B | 30 RPM | API key (free) |
| **NVIDIA NIM** | 129 models | ~40 RPM | API key (free) |
| **Cerebras** | Qwen3 235B | 1M tokens/day | API key (free) |
| **Scaleway** | Qwen3 235B | 1M tokens | API key (free) |
### Total Free Capacity
- **~31,000+ requests/day** combined
- **~32B+ tokens/month**
- **500+ models**
- **$0 forever**
### Quick Start with Free Stack
```bash
# Point any tool to OmniRoute's free endpoint
Base URL: http://localhost:20128/v1
API Key: any-string
Model: auto # Auto-Combo picks best free model
```
Or use the built-in **Free Stack** combo in `/dashboard/combos` — round-robins all free providers automatically.
📖 **Full free provider guide:** [`docs/FREE_TIERS.md`](docs/FREE_TIERS.md)
---
## 🛠️ Tech Stack
<details>

2822
bin/cli-commands.mjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,67 @@ if (args.includes("--help") || args.includes("-h")) {
omniroute --no-open Don't open browser automatically
omniroute --mcp Start MCP server (stdio transport for IDEs)
omniroute reset-encrypted-columns Reset encrypted credentials (recovery)
\x1b[1mServer Management:\x1b[0m
omniroute serve Start the OmniRoute server
omniroute stop Stop the running server
omniroute restart Restart the server
omniroute dashboard Open dashboard in browser
omniroute open Alias for dashboard (same as dashboard)
\x1b[1mCLI Integration Suite:\x1b[0m
omniroute setup Interactive wizard to configure CLI tools
omniroute doctor Run health diagnostics
omniroute status Show comprehensive status
omniroute logs Stream request logs (--json, --search, --follow)
omniroute config show Display current configuration
\x1b[1mProvider & Keys:\x1b[0m
omniroute provider list List available providers
omniroute provider add Add OmniRoute as provider
omniroute keys add Add API key for provider
omniroute keys list List configured API keys
omniroute keys remove Remove API key
\x1b[1mModels & Combos:\x1b[0m
omniroute models List available models (--json, --search)
omniroute models <prov> Filter models by provider
omniroute combo list List routing combos
omniroute combo switch Switch active combo
omniroute combo create Create new combo
omniroute combo delete Delete a combo
\x1b[1mBackup & Restore:\x1b[0m
omniroute backup Create backup of config & DB
omniroute restore Restore from backup (list or specify timestamp)
\x1b[1mMonitoring:\x1b[0m
omniroute health Detailed health (breakers, cache, memory)
omniroute quota Show provider quota usage
omniroute cache Show cache status
omniroute cache clear Clear semantic/signature cache
\x1b[1mProtocols:\x1b[0m
omniroute mcp status MCP server status
omniroute mcp restart Restart MCP server
omniroute a2a status A2A server status
omniroute a2a card Show A2A agent card
\x1b[1mTunnels & Network:\x1b[0m
omniroute tunnel list List active tunnels
omniroute tunnel create Create tunnel (cloudflare/tailscale/ngrok)
omniroute tunnel stop Stop a tunnel
\x1b[1mEnvironment:\x1b[0m
omniroute env show Show environment variables
omniroute env get <key> Get specific env var
omniroute env set <k> <v> Set env var (temporary)
\x1b[1mTools & Utils:\x1b[0m
omniroute test Test provider connectivity
omniroute update Check for updates
omniroute completion Generate shell completion
omniroute --help Show this help
omniroute --version Show version
@@ -160,6 +221,24 @@ if (args.includes("--version") || args.includes("-v")) {
process.exit(0);
}
// ── CLI Integration Suite subcommands ───────────────────────────────────────
const subcommands = [
"setup", "doctor", "status", "logs", "provider", "config", "test", "update",
"serve", "stop", "restart",
"keys", "models", "combo",
"completion", "dashboard",
"backup", "restore", "quota", "health",
"cache", "mcp", "a2a", "tunnel",
"env", "open"
];
const subcommand = args[0];
if (subcommands.includes(subcommand)) {
const { runSubcommand } = await import("./cli-commands.mjs");
await runSubcommand(subcommand, args.slice(1));
process.exit(0);
}
// ── reset-encrypted-columns subcommand ──────────────────────────────────────
// Recovery tool for users who lost STORAGE_ENCRYPTION_KEY after upgrade (#1622)
if (args.includes("reset-encrypted-columns")) {

View File

@@ -1,8 +1,79 @@
# OmniRoute Auto-Combo Engine
> Self-managing model chains with adaptive scoring
> Self-managing model chains with adaptive scoring + zero-config auto-routing
## How It Works
## Zero-Config Auto-Routing (`auto/` prefix)
> **NEW:** No combo creation required. Use `auto/` prefix directly in any client.
### Quick Examples
| Model ID | Variant | Behavior |
| -------------- | ------- | ------------------------------------------------------------------------ |
| `auto` | default | All connected providers, LKGP strategy, balanced weights |
| `auto/coding` | coding | Quality-first weights, suitable for code generation |
| `auto/fast` | fast | Low-latency weighted selection |
| `auto/cheap` | cheap | Cost-optimized routing (lowest cost first) |
| `auto/offline` | offline | Favors providers with highest quota availability |
| `auto/smart` | smart | Quality-first + higher exploration rate (10%) for better model discovery |
| `auto/lkgp` | lkgp | Explicit LKGP (same as default `auto`) |
**How to use:**
```bash
# Any IDE or CLI tool that supports OpenAI format
Base URL: http://localhost:20128/v1
API Key: <your-endpoint-key>
# In your code/config, set model to:
model: "auto" # balanced default
model: "auto/coding" # best for coding tasks
model: "auto/fast" # fastest available
model: "auto/cheap" # cheapest per token
```
**What happens:**
1. OmniRoute detects `auto/` prefix in `src/sse/handlers/chat.ts`
2. Queries all **active provider connections** from the database
3. Filters to those with valid credentials (API key or OAuth token)
4. Determines the model per connection (`connection.defaultModel` or provider's first model)
5. Builds a **virtual combo** in-memory (not stored in DB)
6. Routes using the selected variant's weight profile + LKGP strategy
**Key properties:**
-**Always-on:** No toggle, no combo creation, no configuration needed
-**Dynamic:** Reflects current connected providers automatically
-**Session stickiness:** LKGP ensures last successful provider is prioritized
-**Multi-account aware:** Each provider connection becomes a separate candidate
-**No DB writes:** Virtual combo exists only for the request, zero persistence overhead
**Behind the scenes:**
```txt
Request: { model: "auto/coding" }
src/sse/handlers/chat.ts detects prefix
createVirtualAutoCombo('coding') → candidatePool from active connections
handleComboChat (same engine as persisted combos)
Auto-scoring selects best provider/model per request
```
**Implementation files:**
| File | Purpose |
| --------------------------------------------------------- | ----------------------------------------- |
| `open-sse/services/autoCombo/autoPrefix.ts` | Prefix parser (`parseAutoPrefix`) |
| `open-sse/services/autoCombo/virtualFactory.ts` | Creates virtual `AutoComboConfig` objects |
| `open-sse/services/autoCombo/providerRegistryAccessor.ts` | Test hook for mocking provider registry |
| `src/sse/handlers/chat.ts` | Integration: auto prefix short-circuit |
| `src/shared/constants/providers.ts` | `SYSTEM_PROVIDERS.auto` system entry |
## How It Works (Persisted Auto-Combos)
The Auto-Combo Engine dynamically selects the best provider/model for each request using a **6-factor scoring function**:

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -17,6 +17,7 @@ export interface AudioProvider {
authType: string;
authHeader: string;
format?: string;
supportedFormats?: string[];
async?: boolean;
models: AudioModel[];
}
@@ -124,8 +125,8 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "tts-1", name: "TTS 1" },
{ id: "tts-1-hd", name: "TTS 1 HD" },
{ id: "tts-1", name: "TTS 1" },
{ id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS" },
],
},
@@ -226,8 +227,9 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
authType: "apikey",
authHeader: "basic",
format: "inworld",
supportedFormats: ["mp3", "wav", "opus", "pcm"],
models: [
{ id: "inworld-tts-1.5-max", name: "Inworld TTS 1.5 Max" },
{ id: "inworld-tts-2", name: "Inworld TTS 2" },
{ id: "inworld-tts-1.5-mini", name: "Inworld TTS 1.5 Mini" },
],
},
@@ -242,8 +244,8 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
authHeader: "x-api-key",
format: "cartesia",
models: [
{ id: "sonic-2", name: "Sonic 2" },
{ id: "sonic-3", name: "Sonic 3" },
{ id: "sonic-2", name: "Sonic 2" },
],
},
@@ -297,6 +299,7 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "xiaomi-mimo-tts",
supportedFormats: ["mp3", "wav"],
models: [
{ id: "mimo-v2.5-tts", name: "MiMo V2.5 TTS" },
{ id: "mimo-v2.5-tts-voicedesign", name: "MiMo V2.5 Voice Design" },

View File

@@ -105,6 +105,18 @@ export const ERROR_RULES: ErrorRule[] = [
backoff: true,
reason: "rate_limit_exceeded",
},
{
id: "hour_quota_exceeded",
text: "hour quota",
backoff: true,
reason: "quota_exhausted",
},
{
id: "quota_has_been_exceeded",
text: "quota has been exceeded",
backoff: true,
reason: "quota_exhausted",
},
{
id: "quota_exceeded",
text: "quota exceeded",

View File

@@ -186,17 +186,3 @@ export function getCursorRegistryHeaders(
"User-Agent": getCursorUserAgent(version),
};
}
export function getCursorUsageHeaders(
accessToken: string,
version = CURSOR_REGISTRY_VERSION
): Record<string, string> {
const userAgent = getCursorUserAgent(version);
return {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": userAgent,
"x-cursor-client-version": version,
"x-cursor-user-agent": userAgent,
};
}

View File

@@ -553,20 +553,24 @@ export const REGISTRY: Record<string, RegistryEntry> = {
defaultContextLength: 128000,
headers: getGitHubCopilotChatHeaders(),
models: [
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
{ id: "gpt-5-mini", name: "GPT-5 Mini", targetFormat: "openai-responses" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex", targetFormat: "openai-responses" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", targetFormat: "openai-responses" },
{ id: "gpt-5.4", name: "GPT-5.4", targetFormat: "openai-responses" },
{ id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
{ id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (Full ID)" },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "claude-opus-4.7", name: "Claude Opus 4.7" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash" },
{ id: "oswe-vscode-prime", name: "Raptor Mini" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", targetFormat: "openai-responses" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", targetFormat: "openai-responses" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", targetFormat: "openai-responses" },
{
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5 (Full ID)",
targetFormat: "openai-responses",
},
{ id: "claude-opus-4.6", name: "Claude Opus 4.6", targetFormat: "openai-responses" },
{ id: "claude-opus-4.7", name: "Claude Opus 4.7", targetFormat: "openai-responses" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", targetFormat: "openai-responses" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash", targetFormat: "openai-responses" },
{ id: "oswe-vscode-prime", name: "Raptor Mini", targetFormat: "openai-responses" },
//{ id: "?", name: "Goldeneye" },
],
},
@@ -586,14 +590,26 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authUrl: "https://prod.us-east-1.auth.desktop.kiro.dev",
},
models: [
{ id: "claude-opus-4.7", name: "Claude Opus 4.7" },
{ id: "auto-kiro", name: "Auto (Kiro picks best model)" },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
//{ id: "?", name: "DeepSeek V3.2" },
//{ id: "?", name: "MiniMax M2.5" },
//{ id: "?", name: "GLM-5" },
{ id: "claude-3.7-sonnet", name: "Claude 3.7 Sonnet" },
// Dash aliases — Claude Code sends dashes, Kiro API uses dots
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
// Non-Claude models on Kiro subscription
{ id: "deepseek-3.2", name: "DeepSeek V3.2" },
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{ id: "minimax-m2.1", name: "MiniMax M2.1" },
{ id: "glm-5", name: "GLM-5" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
],
},

View File

@@ -6,11 +6,9 @@ export const RUNWAYML_SUPPORTED_VIDEO_MODELS = [
{ id: "gen4_turbo", name: "Gen-4 Turbo" },
{ id: "veo3.1", name: "Veo 3.1" },
{ id: "veo3.1_fast", name: "Veo 3.1 Fast" },
{ id: "veo3", name: "Veo 3" },
{ id: "gen3a_turbo", name: "Gen-3 Alpha Turbo" },
];
export const RUNWAYML_IMAGE_REQUIRED_MODELS = new Set(["gen4_turbo", "gen3a_turbo"]);
export const RUNWAYML_IMAGE_REQUIRED_MODELS = new Set(["gen4_turbo"]);
export function normalizeRunwayBaseUrl(baseUrl?: string | null) {
const normalized = String(baseUrl || "")

View File

@@ -42,7 +42,9 @@ export class PollinationsExecutor extends BaseExecutor {
}
transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any {
// Pollinations uses provider aliases directly: "openai", "claude", "gemini", etc.
if (typeof body === "object" && body !== null) {
body.jsonMode = true;
}
return body;
}
}

View File

@@ -428,7 +428,21 @@ async function handleHuggingFaceTtsSpeech(providerConfig, body, modelId, token)
* POST { text, voiceId, modelId, audioConfig } → JSON { audioContent: "<base64>" }
* Docs: https://docs.inworld.ai/api-reference/ttsAPI/texttospeech/synthesize-speech
*/
const INWORLD_AUDIO_FORMATS = {
mp3: { audioEncoding: "MP3", mimeType: "audio/mpeg" },
wav: { audioEncoding: "WAV", mimeType: "audio/wav" },
opus: { audioEncoding: "OPUS", mimeType: "audio/opus" },
pcm: { audioEncoding: "PCM", mimeType: "audio/pcm" },
};
async function handleInworldSpeech(providerConfig, body, modelId, token) {
const requestedFormat =
typeof body.response_format === "string" ? body.response_format.toLowerCase() : "mp3";
const audioFormat = INWORLD_AUDIO_FORMATS[requestedFormat];
if (!audioFormat) {
return errorResponse(400, "Inworld TTS supports response_format mp3, wav, opus, or pcm only");
}
const res = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: {
@@ -440,7 +454,7 @@ async function handleInworldSpeech(providerConfig, body, modelId, token) {
voiceId: body.voice || undefined,
modelId,
audioConfig: {
audioEncoding: body.response_format === "wav" ? "LINEAR16" : "MP3",
audioEncoding: audioFormat.audioEncoding,
},
}),
});
@@ -452,7 +466,10 @@ async function handleInworldSpeech(providerConfig, body, modelId, token) {
const data = await res.json();
// Decode base64 audioContent to binary
const audioBuffer = Uint8Array.from(atob(data.audioContent ?? ""), (c) => c.charCodeAt(0));
const mimeType = body.response_format === "wav" ? "audio/wav" : "audio/mpeg";
const mimeType =
typeof data.contentType === "string" && data.contentType
? data.contentType
: audioFormat.mimeType;
return new Response(audioBuffer, {
status: 200,

View File

@@ -32,6 +32,7 @@ import {
import {
COOLDOWN_MS,
HTTP_STATUS,
FETCH_BODY_TIMEOUT_MS,
MAX_TOOLS_LIMIT,
PROVIDER_MAX_TOKENS,
STREAM_IDLE_TIMEOUT_MS,
@@ -574,6 +575,146 @@ function normalizeNonStreamingEventPayload(rawBody: string, contentType: string)
return rawBody;
}
const NON_STREAMING_SSE_TERMINAL_TYPES = new Set([
"message_stop",
"response.completed",
"response.done",
"response.cancelled",
"response.canceled",
"response.failed",
"response.incomplete",
]);
type NonStreamingSseTerminalState = {
currentEvent: string;
pendingLine: string;
};
function processNonStreamingSseTerminalLine(
state: NonStreamingSseTerminalState,
rawLine: string
): boolean {
const trimmed = rawLine.trim();
if (!trimmed || trimmed.startsWith(":")) {
if (!trimmed) state.currentEvent = "";
return false;
}
if (trimmed.startsWith("event:")) {
state.currentEvent = trimmed.slice(6).trim();
return false;
}
if (!trimmed.startsWith("data:")) return false;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") return true;
if (!data) return false;
try {
const parsed = JSON.parse(data);
const eventType =
parsed && typeof parsed === "object" && typeof parsed.type === "string"
? parsed.type
: state.currentEvent;
return NON_STREAMING_SSE_TERMINAL_TYPES.has(eventType);
} catch {
// Keep reading malformed data so the parser can report a useful upstream error.
return false;
}
}
function appendNonStreamingSseTerminalSignal(
state: NonStreamingSseTerminalState,
chunk: string
): boolean {
const lines = `${state.pendingLine}${chunk}`.split(/\r?\n/);
state.pendingLine = lines.pop() ?? "";
for (const rawLine of lines) {
if (processNonStreamingSseTerminalLine(state, rawLine)) return true;
}
return false;
}
function createBodyTimeoutError(timeoutMs: number): Error {
const err = new Error(`Response body read timeout after ${timeoutMs}ms`);
err.name = "BodyTimeoutError";
return err;
}
function readStreamChunkWithTimeout(
reader: ReadableStreamDefaultReader<Uint8Array>,
timeoutMs: number
): Promise<ReadableStreamReadResult<Uint8Array>> {
if (timeoutMs <= 0) return reader.read();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(createBodyTimeoutError(timeoutMs)), timeoutMs);
reader.read().then(
(value) => {
clearTimeout(timeout);
resolve(value);
},
(error) => {
clearTimeout(timeout);
reject(error);
}
);
});
}
async function readNonStreamingResponseBody(
response: Response,
contentType: string,
upstreamStream: boolean
): Promise<string> {
if (
!upstreamStream ||
!response.body ||
(!contentType.includes("text/event-stream") && !contentType.includes("application/x-ndjson"))
) {
return withBodyTimeout<string>(response.text());
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const terminalState: NonStreamingSseTerminalState = {
currentEvent: "",
pendingLine: "",
};
let rawBody = "";
const deadline = FETCH_BODY_TIMEOUT_MS > 0 ? Date.now() + FETCH_BODY_TIMEOUT_MS : 0;
try {
while (true) {
const timeoutMs = deadline > 0 ? deadline - Date.now() : 0;
if (deadline > 0 && timeoutMs <= 0) {
throw createBodyTimeoutError(FETCH_BODY_TIMEOUT_MS);
}
const { done, value } = await readStreamChunkWithTimeout(reader, timeoutMs);
if (done) break;
if (!value) continue;
const decodedChunk = decoder.decode(value, { stream: true });
rawBody += decodedChunk;
if (appendNonStreamingSseTerminalSignal(terminalState, decodedChunk)) {
await reader.cancel("non-streaming bridge consumed terminal SSE event").catch(() => {});
break;
}
}
} catch (error) {
await reader.cancel(error).catch(() => {});
throw error;
} finally {
rawBody += decoder.decode();
reader.releaseLock();
}
return rawBody;
}
function getHeaderValueCaseInsensitive(
headers: Record<string, unknown> | Headers | null | undefined,
targetName: string
@@ -602,14 +743,39 @@ function toFiniteNumberOrNull(value: unknown): number | null {
return null;
}
function isSemaphoreTimeoutError(error: unknown): error is Error & { code: string } {
function isSemaphoreCapacityError(error: unknown): error is Error & { code: string } {
return (
!!error &&
typeof error === "object" &&
(error as { code?: unknown }).code === "SEMAPHORE_TIMEOUT"
((error as { code?: unknown }).code === "SEMAPHORE_TIMEOUT" ||
(error as { code?: unknown }).code === "SEMAPHORE_QUEUE_FULL")
);
}
function createStreamingErrorResult(statusCode: number, message: string, code?: string) {
const errorBody = buildErrorBody(statusCode, message);
if (code) {
errorBody.error.code = code;
}
const body = `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`;
return {
success: false as const,
status: statusCode,
error: message,
response: new Response(body, {
status: statusCode,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
}),
};
}
function wrapReadableStreamWithFinalize<T>(
readable: ReadableStream<T>,
finalize: () => void
@@ -2959,7 +3125,12 @@ export async function handleChatCore({
const status = rawResult.response.status;
const statusText = rawResult.response.statusText;
const headers = new Headers(rawResult.response.headers);
const payload = await withBodyTimeout<string>(rawResult.response.text());
const contentType = (headers.get("content-type") || "").toLowerCase();
const payload = await readNonStreamingResponseBody(
rawResult.response,
contentType,
upstreamStream
);
acquireAccountSemaphoreRelease();
return {
@@ -2969,7 +3140,11 @@ export async function handleChatCore({
_dedupSnapshot: {
status,
statusText,
headers: Array.from(headers.entries()),
headers: (() => {
const arr: [string, string][] = [];
headers.forEach((v, k) => arr.push([k, v]));
return arr;
})(),
payload,
},
};
@@ -3051,16 +3226,13 @@ export async function handleChatCore({
);
} catch (error) {
trackPendingRequest(model, provider, connectionId, false);
if (isSemaphoreTimeoutError(error)) {
if (isSemaphoreCapacityError(error)) {
appendRequestLog({
model,
provider,
connectionId,
status: `FAILED ${error.code}`,
}).catch(() => {});
if (isCombo) {
throw error;
}
const failureMessage = error.message || "Semaphore timeout";
persistAttemptLogs({
status: HTTP_STATUS.RATE_LIMITED,
@@ -3071,7 +3243,14 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(HTTP_STATUS.RATE_LIMITED, error.code);
return createErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage);
const result = stream
? createStreamingErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage, error.code)
: createErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage);
return {
...result,
errorType: "account_semaphore_capacity",
errorCode: error.code,
};
}
const failureStatus =
error.name === "AbortError"
@@ -3220,6 +3399,8 @@ export async function handleChatCore({
let statusCode = providerResponse.status;
let message = "";
let retryAfterMs: number | null = null;
let upstreamErrorCode: string | undefined;
let upstreamErrorType: string | undefined;
if (upstreamErrorParsed) {
statusCode = parsedStatusCode;
@@ -3231,6 +3412,8 @@ export async function handleChatCore({
message = details.message;
retryAfterMs = details.retryAfterMs;
upstreamErrorBody = details.responseBody;
upstreamErrorCode = details.errorCode;
upstreamErrorType = details.errorType;
}
// T06/T10/T36: classify provider errors and persist terminal account states.
@@ -3399,7 +3582,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "model_unavailable");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} catch {
persistAttemptLogs({
@@ -3411,7 +3600,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "model_unavailable");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} else {
persistAttemptLogs({
@@ -3423,7 +3618,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "model_unavailable");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} else if (isContextOverflowError(statusCode, message)) {
const familyCandidates = getModelFamily(currentModel).filter(
@@ -3459,7 +3660,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "context_overflow");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} catch {
persistAttemptLogs({
@@ -3471,7 +3678,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "context_overflow");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} else {
persistAttemptLogs({
@@ -3483,7 +3696,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "context_overflow");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} else {
persistAttemptLogs({
@@ -3566,7 +3785,13 @@ export async function handleChatCore({
}
if (!emergencyFallbackServed) {
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
}
// ── End T5 ───────────────────────────────────────────────────────────────
@@ -3578,7 +3803,11 @@ export async function handleChatCore({
const contentType = (providerResponse.headers.get("content-type") || "").toLowerCase();
let responseBody;
let responsePayloadFormat = targetFormat;
const rawBody = await withBodyTimeout<string>(providerResponse.text());
const rawBody = await readNonStreamingResponseBody(
providerResponse,
contentType,
upstreamStream
);
const normalizedProviderPayload = normalizePayloadForLog(rawBody);
const looksLikeSSE =
contentType.includes("text/event-stream") ||
@@ -4038,9 +4267,11 @@ export async function handleChatCore({
const responseHeaders: Record<string, string> = {
...Object.fromEntries(
Array.from(providerResponse.headers.entries()).filter(
([k]) => k.toLowerCase() !== "content-type"
)
(() => {
const arr: [string, string][] = [];
providerResponse.headers.forEach((v, k) => arr.push([k, v]));
return arr;
})().filter(([k]) => k.toLowerCase() !== "content-type")
),
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",

View File

@@ -22,9 +22,19 @@ function readSSEEvents(rawSSE) {
}
try {
const data = JSON.parse(payload);
if (
currentEvent &&
data &&
typeof data === "object" &&
!Array.isArray(data) &&
typeof data.type !== "string"
) {
data.type = currentEvent;
}
events.push({
event: currentEvent || undefined,
data: JSON.parse(payload),
data,
});
} catch {
// Ignore malformed SSE events and continue best-effort parsing.
@@ -41,12 +51,21 @@ function readSSEEvents(rawSSE) {
}
if (line.startsWith("event:")) {
// Some relays omit the blank separator between Claude events. Flush the
// previous event before accepting the next event name.
if (currentData.length > 0) flush();
currentEvent = line.slice(6).trim();
continue;
}
if (line.startsWith("data:")) {
currentData.push(line.slice(5).trimStart());
const dataLine = line.slice(5).trimStart();
if (dataLine.trim() === "[DONE]") {
flush();
currentEvent = "";
continue;
}
currentData.push(dataLine);
}
}

View File

@@ -22,10 +22,14 @@ import {
getCircuitBreaker,
STATE,
} from "../../src/shared/utils/circuitBreaker";
import { classify429FromError, type FailureKind } from "../../src/shared/utils/classify429";
import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints";
type ProviderProfile = {
baseCooldownMs: number;
useUpstreamRetryHints: boolean;
/** Issue #2100 follow-up. Stored override; undefined → per-provider default. */
useUpstream429BreakerHints?: boolean;
maxBackoffSteps: number;
failureThreshold: number;
resetTimeoutMs: number;
@@ -182,6 +186,9 @@ function buildProviderProfile(
return {
baseCooldownMs: connectionCooldown.baseCooldownMs,
useUpstreamRetryHints: connectionCooldown.useUpstreamRetryHints,
// Issue #2100 follow-up: propagate stored override (boolean | undefined)
// so the runtime resolver picks user setting first, then per-provider default.
useUpstream429BreakerHints: connectionCooldown.useUpstream429BreakerHints,
maxBackoffSteps: connectionCooldown.maxBackoffSteps,
failureThreshold: providerBreaker.failureThreshold,
resetTimeoutMs: providerBreaker.resetTimeoutMs,
@@ -532,9 +539,23 @@ function configureProviderBreaker(
if (!provider) return null;
const resolvedProfile = { ...getProviderProfile(provider), ...(profile ?? {}) };
// Issue #2100 follow-up: resolve useUpstream429BreakerHints from the
// provider profile (stored override) or fall back to per-provider default.
// Stored value type is `boolean | undefined` — never `null` after PATCH.
const userValue = resolvedProfile.useUpstream429BreakerHints;
const useHints = resolveUseUpstream429BreakerHints(provider, userValue);
return getCircuitBreaker(provider, {
failureThreshold: resolvedProfile.failureThreshold ?? resolvedProfile.circuitBreakerThreshold,
resetTimeout: resolvedProfile.resetTimeoutMs ?? resolvedProfile.circuitBreakerReset,
...(useHints
? {
cooldownByKind: {
rate_limit: 60_000,
quota_exhausted: 3_600_000,
} satisfies Partial<Record<FailureKind, number>>,
classifyError: classify429FromError,
}
: {}),
});
}
@@ -755,6 +776,8 @@ export function classifyErrorText(errorText) {
lower.includes("quota depleted") ||
lower.includes("quota will reset") ||
lower.includes("your quota will reset") ||
lower.includes("quota has been exceeded") ||
lower.includes("hour quota") ||
lower.includes("billing")
) {
return RateLimitReason.QUOTA_EXHAUSTED;
@@ -1038,7 +1061,9 @@ export function checkFallbackError(
status === HTTP_STATUS.FORBIDDEN &&
provider &&
getProviderCategory(provider) === "apikey" &&
!errorStr.toLowerCase().includes("has not been used in project")
!errorStr.toLowerCase().includes("has not been used in project") &&
!errorStr.toLowerCase().includes("hour quota") &&
!errorStr.toLowerCase().includes("quota has been exceeded")
) {
return buildRetryableFallback(RateLimitReason.AUTH_ERROR);
}

View File

@@ -0,0 +1,54 @@
export type AutoVariant = "coding" | "fast" | "cheap" | "offline" | "smart" | "lkgp";
export interface AutoPrefixParseResult {
valid: boolean;
variant?: AutoVariant;
error?: string;
}
const VALID_VARIANTS: AutoVariant[] = ["coding", "fast", "cheap", "offline", "smart", "lkgp"];
/**
* Parses a model name to determine if it's an auto-prefixed model and extracts the variant.
*
* Examples:
* - "auto" -> { valid: true, variant: undefined } (default)
* - "auto/coding" -> { valid: true, variant: "coding" }
* - "auto/lkgp" -> { valid: true, variant: "lkgp" }
* - "auto/" -> { valid: true, variant: undefined } (default)
* - "autocoding" -> { valid: false, error: "Invalid auto prefix format" }
* - "otherModel" -> { valid: false, error: "Not an auto-prefixed model" }
*/
export function parseAutoPrefix(model: string | null | undefined): AutoPrefixParseResult {
// Guard against null/undefined (called with non-string inputs)
if (typeof model !== "string") {
return { valid: false, error: "Not an auto-prefixed model" };
}
if (!model.startsWith("auto")) {
return { valid: false, error: "Not an auto-prefixed model" };
}
const parts = model.split("/");
if (parts.length === 1) {
if (parts[0] === "auto") {
return { valid: true, variant: undefined }; // Default auto
} else {
return { valid: false, error: "Invalid auto prefix format" };
}
}
if (parts.length === 2) {
if (parts[0] !== "auto") {
return { valid: false, error: "Invalid auto prefix format" };
}
const variantStr: string = parts[1];
if (variantStr === "" || VALID_VARIANTS.includes(variantStr as AutoVariant)) {
return { valid: true, variant: variantStr === "" ? undefined : (variantStr as AutoVariant) };
} else {
return { valid: false, error: `Invalid auto variant: ${variantStr}` };
}
}
return { valid: false, error: "Invalid auto prefix format" };
}

View File

@@ -0,0 +1,9 @@
/**
* Provides access to the provider REGISTRY. Used to enable mocking in tests.
* The REGISTRY contains provider configuration including models and costs.
*/
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry";
export function getProviderRegistry() {
return REGISTRY;
}

View File

@@ -0,0 +1,141 @@
import { AutoComboConfig, SelectionResult } from "./engine";
import { MODE_PACKS } from "./modePacks";
import { DEFAULT_WEIGHTS, ScoringWeights } from "./scoring";
import { AutoVariant } from "./autoPrefix";
import { getProviderConnections } from "@/lib/db/providers";
import { getProviderRegistry } from "./providerRegistryAccessor";
import type { ConnectionFields } from "@/lib/db/encryption";
import { log } from "@omniroute/open-sse/utils/logger";
/** Minimal connection shape needed for virtual auto-combo factory */
interface VirtualFactoryConn extends ConnectionFields {
id: string;
provider: string;
defaultModel?: string;
oauthExpiresAt?: number | string; // timestamp or ISO string
}
export interface VirtualAutoComboCandidate {
provider: string;
connectionId: string;
model: string;
modelStr: string; // e.g., 'openai/gpt-4o'
costPer1MTokens: number; // from providerRegistry
}
/**
* Creates a virtual AutoCombo configuration dynamically based on connected providers and a specified variant.
* This combo is not persisted in the DB.
*/
export async function createVirtualAutoCombo(
variant: AutoVariant | undefined
): Promise<AutoComboConfig> {
const connections = (await getProviderConnections({ isActive: true })) as VirtualFactoryConn[];
const validConnections = connections.filter((conn) => {
const hasApiKey = !!conn.apiKey;
let expiresAt: number;
if (typeof conn.oauthExpiresAt === "string") {
expiresAt = new Date(conn.oauthExpiresAt).getTime();
} else {
expiresAt = Number(conn.oauthExpiresAt) || 0;
}
const hasOAuthToken = !!conn.oauthToken && new Date(expiresAt) > new Date();
return hasApiKey || hasOAuthToken;
});
if (validConnections.length === 0) {
log.warn("AUTO", "No connected providers with valid credentials for virtual auto-combo");
const emptyPool: string[] = [];
return {
id: `virtual-auto-${variant || "default"}`,
name: `Auto ${variant || "Default"}`,
type: "auto" as const,
candidatePool: emptyPool,
weights: { ...DEFAULT_WEIGHTS },
explorationRate: 0.05,
routerStrategy: "lkgp",
config: {
candidatePool: emptyPool,
weights: { ...DEFAULT_WEIGHTS },
explorationRate: 0.05,
routingStrategy: "lkgp",
},
models: emptyPool,
};
}
const candidatePool: VirtualAutoComboCandidate[] = [];
for (const conn of validConnections) {
const providerInfo = getProviderRegistry()[conn.provider];
if (!providerInfo) continue; // Skip unknown providers
let modelId: string | undefined = conn.defaultModel;
if (!modelId) {
const firstModel = providerInfo.models[0];
modelId = firstModel?.id;
}
if (!modelId) continue; // Skip providers without a model
candidatePool.push({
provider: conn.provider,
connectionId: conn.id,
model: modelId,
modelStr: `${conn.provider}/${modelId}`,
costPer1MTokens: 0, // Not used in virtual auto-combo (LKGP uses session stickiness)
});
}
let weights: ScoringWeights = { ...DEFAULT_WEIGHTS };
let explorationRate = 0.05; // Default exploration rate
let routerStrategy = "lkgp"; // All auto variants use LKGP
switch (variant) {
case "coding":
weights = { ...MODE_PACKS["quality-first"] };
break;
case "fast":
weights = { ...MODE_PACKS["ship-fast"] };
break;
case "cheap":
weights = { ...MODE_PACKS["cost-saver"] };
break;
case "offline":
weights = { ...MODE_PACKS["offline-friendly"] };
break;
case "smart":
weights = { ...MODE_PACKS["quality-first"] };
explorationRate = 0.1; // Override default exploration rate
break;
case "lkgp":
// LKGP is default for all auto variants, this variant just explicitly names it.
// Use default weights.
break;
case undefined: // Default auto
// Use default weights
break;
}
const pool = candidatePool.map((c) => c.modelStr);
return {
id: `virtual-auto-${variant || "default"}`,
name: `Auto ${variant || "Default"}`,
type: "auto",
// Root-level fields for AutoComboConfig type compatibility
candidatePool: pool,
weights: weights,
explorationRate: explorationRate,
routerStrategy: routerStrategy,
// Nested config for combo router's auto-type handler
// (reads via: combo?.autoConfig || combo?.config?.auto || combo?.config || {})
config: {
candidatePool: pool,
weights: weights,
explorationRate: explorationRate,
routingStrategy: routerStrategy,
},
// models array so resolveComboTargets doesn't get an empty array
models: pool,
};
}

View File

@@ -9,11 +9,7 @@ import {
} from "../config/antigravityUpstream.ts";
import { isUserCallableAntigravityModelId } from "../config/antigravityModelAliases.ts";
import { getGlmQuotaUrl } from "../config/glmProvider.ts";
import {
CURSOR_REGISTRY_VERSION,
getCursorUsageHeaders,
getGitHubCopilotInternalUserHeaders,
} from "../config/providerHeaderProfiles.ts";
import { getGitHubCopilotInternalUserHeaders } from "../config/providerHeaderProfiles.ts";
import { safePercentage } from "@/shared/utils/formatting";
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts";
@@ -72,17 +68,22 @@ const KIMI_CONFIG = {
apiVersion: "2023-06-01",
};
const CURSOR_USAGE_CONFIG = {
usageUrl: "https://www.cursor.com/api/usage",
userMetaUrl: "https://www.cursor.com/api/auth/me",
subscriptionUrl: "https://www.cursor.com/api/subscription",
clientVersion: CURSOR_REGISTRY_VERSION,
};
const NANOGPT_CONFIG = {
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
};
// Cursor dashboard usage API config
// The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS
// session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and
// rejects requests without a matching Origin/Referer (Invalid origin for state-changing request).
const CURSOR_USAGE_CONFIG = {
usageUrl: "https://cursor.com/api/dashboard/get-current-period-usage",
origin: "https://cursor.com",
referer: "https://cursor.com/dashboard/spending",
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
};
const MINIMAX_USAGE_CONFIG = {
minimax: {
usageUrls: [
@@ -156,6 +157,12 @@ function getGlmTokenQuotaName(
return existingQuotas.session ? "weekly" : "session";
}
function getGlmQuotaDisplayName(quotaName: string): string {
if (quotaName === "session") return "5 Hours Quota";
if (quotaName === "weekly") return "Weekly Quota";
return quotaName;
}
function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unknown {
const obj = toRecord(source);
return obj[snakeKey] ?? obj[camelKey] ?? null;
@@ -635,6 +642,13 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string,
remaining,
remainingPercentage: remaining,
resetAt,
displayName: getGlmQuotaDisplayName(quotaName),
details: Array.isArray(src.models)
? src.models.map((m: any) => ({
name: String(m.model || ""),
used: toNumber(m.percentage, 0),
}))
: [],
unlimited: false,
};
continue;
@@ -846,6 +860,143 @@ async function getNanoGptUsage(apiKey: string) {
}
}
/**
* Decode the `sub` claim of a Cursor JWT (the WorkOS user id).
* Returns null if the token is not a parseable JWT.
*/
function decodeCursorJwtSub(token: string): string | null {
if (!token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
try {
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
while (payload.length % 4 !== 0) payload += "=";
const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
const sub = decoded?.sub;
return typeof sub === "string" && sub.length > 0 ? sub : null;
} catch {
return null;
}
}
/**
* Cursor Pro Plan Usage
* Fetches current-billing-cycle spend from the cursor.com dashboard API and exposes three
* windows that mirror the cursor.com/dashboard/spending UI: Total / Auto + Composer / API.
*/
async function getCursorUsage(accessToken: string, providerSpecificData?: unknown) {
if (!accessToken) {
return { message: "Cursor access token missing. Re-import the connection from Cursor IDE." };
}
const storedUserId = (() => {
const raw = toRecord(providerSpecificData).userId;
return typeof raw === "string" && raw.length > 0 ? raw : null;
})();
const userId = storedUserId || decodeCursorJwtSub(accessToken);
if (!userId) {
return {
message: "Cursor token missing user id. Re-import the connection from Cursor IDE.",
};
}
try {
const response = await fetch(CURSOR_USAGE_CONFIG.usageUrl, {
method: "POST",
redirect: "manual",
headers: {
Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`,
Origin: CURSOR_USAGE_CONFIG.origin,
Referer: CURSOR_USAGE_CONFIG.referer,
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": CURSOR_USAGE_CONFIG.userAgent,
},
body: "{}",
});
// 3xx redirect to WorkOS authkit means the session cookie was rejected.
if (response.status >= 300 && response.status < 400) {
return {
plan: "Cursor",
message: "Cursor session expired. Re-import the token from Cursor IDE.",
};
}
if (!response.ok) {
const errorText = (await response.text()).slice(0, 200);
if (response.status === 401 || response.status === 403) {
return {
plan: "Cursor",
message: "Cursor session unauthorized. Re-import the token from Cursor IDE.",
};
}
return {
plan: "Cursor",
message: `Cursor usage endpoint error (${response.status}): ${errorText}`,
};
}
const data = toRecord(await response.json());
const planUsage = toRecord(data.planUsage);
if (Object.keys(planUsage).length === 0) {
return {
plan: "Cursor",
message: "Cursor connected. No active plan usage returned.",
};
}
const limitCents = Math.max(0, toNumber(planUsage.limit, 0));
const totalSpendCents = Math.max(0, toNumber(planUsage.totalSpend, 0));
const autoPercentUsed = clampPercentage(toNumber(planUsage.autoPercentUsed, 0));
const apiPercentUsed = clampPercentage(toNumber(planUsage.apiPercentUsed, 0));
const totalPercentUsed = clampPercentage(toNumber(planUsage.totalPercentUsed, 0));
// billingCycleEnd is a numeric-string in ms; coerce so parseResetTime sees a number.
const billingCycleEndMs = toNumber(data.billingCycleEnd, 0);
const resetAt = billingCycleEndMs > 0 ? parseResetTime(billingCycleEndMs) : null;
// Convert cents → dollars rounded to 2 decimal places.
const toDollars = (cents: number) => Math.round(cents) / 100;
const limitDollars = toDollars(limitCents);
const buildWindow = (percentUsed: number, usedCentsOverride?: number): UsageQuota => {
const usedCents =
typeof usedCentsOverride === "number"
? usedCentsOverride
: Math.round((limitCents * percentUsed) / 100);
const used = toDollars(Math.min(usedCents, limitCents));
const remaining = toDollars(Math.max(limitCents - Math.min(usedCents, limitCents), 0));
return {
used,
total: limitDollars,
remaining,
remainingPercentage: clampPercentage(100 - percentUsed),
resetAt,
unlimited: false,
};
};
const quotas: Record<string, UsageQuota> = {
Total: buildWindow(totalPercentUsed, totalSpendCents),
"Auto + Composer": buildWindow(autoPercentUsed),
API: buildWindow(apiPercentUsed),
};
return {
plan: "Cursor Pro",
quotas,
};
} catch (error) {
return {
plan: "Cursor",
message: `Cursor connected. Unable to fetch usage: ${(error as Error).message}`,
};
}
}
/**
* Get usage data for a provider connection
* @param {Object} connection - Provider connection with accessToken
@@ -865,6 +1016,8 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
return await getClaudeUsage(accessToken);
case "codex":
return await getCodexUsage(accessToken, providerSpecificData);
case "cursor":
return await getCursorUsage(accessToken, providerSpecificData);
case "kiro":
case "amazon-q":
return await getKiroUsage(accessToken, providerSpecificData);
@@ -876,6 +1029,7 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
return await getQoderUsage(accessToken);
case "glm":
case "glm-cn":
case "zai":
case "glmt":
return await getGlmUsage(apiKey, {
...(providerSpecificData || {}),
@@ -886,8 +1040,6 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
return await getMiniMaxUsage(apiKey, provider);
case "crof":
return await getCrofUsage(apiKey);
case "cursor":
return await getCursorUsage(accessToken);
case "bailian-coding-plan":
return await getBailianCodingPlanUsage(id, apiKey, providerSpecificData);
case "nanogpt":
@@ -1120,172 +1272,6 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null):
return "GitHub Copilot";
}
function buildCursorUsageHeaders(accessToken: string): Record<string, string> {
return getCursorUsageHeaders(accessToken, CURSOR_USAGE_CONFIG.clientVersion);
}
function getFirstPositiveNumber(...values: unknown[]): number {
for (const value of values) {
const parsed = toNumber(value, Number.NaN);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return 0;
}
function getCursorMonthlyRequestLimit(usageData: JsonRecord, subscriptionData: JsonRecord): number {
return getFirstPositiveNumber(
getFieldValue(subscriptionData, "team_max_monthly_requests", "teamMaxMonthlyRequests"),
getFieldValue(usageData, "team_max_request_usage", "teamMaxRequestUsage"),
getFieldValue(subscriptionData, "team_max_request_usage", "teamMaxRequestUsage"),
getFieldValue(usageData, "hard_limit", "hardLimit"),
getFieldValue(subscriptionData, "max_monthly_requests", "maxMonthlyRequests")
);
}
function getCursorOnDemandLimit(usageData: JsonRecord, subscriptionData: JsonRecord): number {
const onDemand = toRecord(getFieldValue(usageData, "on_demand", "onDemand"));
return getFirstPositiveNumber(
getFieldValue(onDemand, "max_requests", "maxRequests"),
getCursorMonthlyRequestLimit(usageData, subscriptionData)
);
}
function formatCursorQuota(
usedValue: unknown,
totalValue: unknown,
resetValue: unknown
): UsageQuota {
const total = Math.max(0, toNumber(totalValue, 0));
const rawUsed = Math.max(0, toNumber(usedValue, 0));
const used = total > 0 ? Math.min(rawUsed, total) : rawUsed;
const remaining = total > 0 ? Math.max(total - used, 0) : 0;
return {
used,
total,
remaining,
remainingPercentage: total > 0 ? clampPercentage((remaining / total) * 100) : 0,
resetAt: parseResetTime(resetValue),
unlimited: false,
};
}
function inferCursorPlanName(userMeta: JsonRecord, subscriptionData: JsonRecord): string {
const teamInfo = toRecord(getFieldValue(userMeta, "team_info", "teamInfo"));
const candidates = [
getFieldValue(userMeta, "plan", "plan"),
getFieldValue(userMeta, "subscription_type", "subscriptionType"),
getFieldValue(subscriptionData, "subscription_type", "subscriptionType"),
getFieldValue(subscriptionData, "plan", "plan"),
];
const planText = candidates.find((value) => typeof value === "string" && value.trim().length > 0);
const normalized = typeof planText === "string" ? planText.trim().toLowerCase() : "";
if (Object.keys(teamInfo).length > 0 || normalized.includes("team")) return "Cursor Team";
if (normalized.includes("enterprise")) return "Cursor Enterprise";
if (normalized.includes("pro")) return "Cursor Pro";
if (normalized.includes("free")) return "Cursor Free";
return "Cursor";
}
async function fetchCursorUsageDocument(url: string, accessToken: string) {
const response = await fetch(url, {
method: "GET",
headers: buildCursorUsageHeaders(accessToken),
});
const text = await response.text();
if (!response.ok) {
return {
ok: false,
status: response.status,
data: {} as JsonRecord,
text,
};
}
try {
const parsed = text ? JSON.parse(text) : {};
return {
ok: true,
status: response.status,
data: toRecord(parsed),
text,
};
} catch {
return {
ok: false,
status: response.status,
data: {} as JsonRecord,
text,
};
}
}
async function getCursorUsage(accessToken: string) {
try {
if (!accessToken) {
return {
message: "Cursor token expired or unavailable. Please re-authenticate the connection.",
};
}
const [usageSummary, userMeta, subscription] = await Promise.all([
fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.usageUrl, accessToken),
fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.userMetaUrl, accessToken),
fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.subscriptionUrl, accessToken),
]);
const authDenied = [usageSummary, userMeta, subscription].some(
(result) => result.status === 401 || result.status === 403
);
if (authDenied) {
return {
message:
"Cursor token expired or permission denied. Please re-authenticate the connection.",
};
}
const usageData = usageSummary.data;
const userMetaData = userMeta.data;
const subscriptionData = subscription.data;
const plan = inferCursorPlanName(userMetaData, subscriptionData);
const quotas: Record<string, UsageQuota> = {};
const totalUsed = getFieldValue(usageData, "num_requests_total", "numRequestsTotal");
const totalLimit = getCursorMonthlyRequestLimit(usageData, subscriptionData);
const totalReset =
getFieldValue(usageData, "reset_date", "resetDate") ||
getFieldValue(subscriptionData, "reset_date", "resetDate");
if (toNumber(totalUsed, 0) > 0 || totalLimit > 0) {
quotas.requests = formatCursorQuota(totalUsed, totalLimit, totalReset);
}
const onDemand = toRecord(getFieldValue(usageData, "on_demand", "onDemand"));
const onDemandUsed = getFieldValue(onDemand, "num_requests", "numRequests");
const onDemandLimit = getCursorOnDemandLimit(usageData, subscriptionData);
const onDemandReset =
getFieldValue(onDemand, "reset_date", "resetDate") ||
getFieldValue(usageData, "reset_date", "resetDate") ||
getFieldValue(subscriptionData, "reset_date", "resetDate");
if (toNumber(onDemandUsed, 0) > 0 || onDemandLimit > 0) {
quotas.on_demand = formatCursorQuota(onDemandUsed, onDemandLimit, onDemandReset);
}
if (Object.keys(quotas).length > 0) {
return { plan, quotas };
}
return { plan, message: "Cursor connected. Unable to parse quota data." };
} catch (error) {
return { message: `Unable to fetch Cursor usage: ${(error as Error).message}` };
}
}
// ── Gemini CLI subscription info cache ──────────────────────────────────────
// Prevents duplicate loadCodeAssist calls within the same quota cycle.
// Key: accessToken → { data, fetchedAt }
@@ -2445,11 +2431,6 @@ export const __testing = {
parseResetTime,
formatGitHubQuotaSnapshot,
inferGitHubPlanName,
buildCursorUsageHeaders,
formatCursorQuota,
getCursorMonthlyRequestLimit,
getCursorOnDemandLimit,
inferCursorPlanName,
getGeminiCliPlanLabel,
getAntigravityPlanLabel,
};

View File

@@ -209,19 +209,15 @@ export function buildGeminiTools(
}
}
if (googleSearchTool && functionDeclarations.length > 0) {
console.warn(
`[GeminiTools] Removing ${functionDeclarations.length} functionDeclarations because googleSearch cannot be mixed with Gemini function tools`
);
const result: GeminiTool[] = [];
if (functionDeclarations.length > 0) {
result.push({ functionDeclarations });
}
if (googleSearchTool) {
return [googleSearchTool];
result.push(googleSearchTool);
}
if (functionDeclarations.length > 0) {
return [{ functionDeclarations }];
}
return undefined;
return result.length > 0 ? result : undefined;
}

View File

@@ -1,6 +1,6 @@
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { CLAUDE_SYSTEM_PROMPT } from "../../config/constants.ts";
// CLAUDE_SYSTEM_PROMPT import removed — no longer injected unconditionally (#1966/#2130)
import { supportsXHighEffort } from "../../config/providerModels.ts";
import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts";
import { sanitizeToolId } from "../helpers/schemaCoercion.ts";
@@ -313,11 +313,29 @@ export function openaiToClaudeRequest(model, body, stream) {
}
// System messages and cache_control
// Fix #2130: Preserve body.system when present (Claude Code sends system as native
// Anthropic array through the /chat/completions endpoint). Without this, the system
// prompt is silently dropped when no role="system" messages exist in body.messages.
if (systemParts.length > 0) {
const systemText = systemParts.join("\n");
result.system = [
{ type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } },
];
const systemBlock = {
type: "text",
text: systemText,
cache_control: { type: "ephemeral", ttl: "1h" },
};
// Merge with existing body.system if present
if (Array.isArray(body.system)) {
result.system = [...body.system, systemBlock];
} else if (typeof body.system === "string" && body.system.length > 0) {
result.system = [{ type: "text", text: body.system }, systemBlock];
} else {
result.system = [systemBlock];
}
} else if (body.system) {
// No role="system" messages, but body.system exists — pass through as-is
result.system = Array.isArray(body.system)
? body.system
: [{ type: "text", text: String(body.system) }];
}
// Thinking configuration

View File

@@ -53,6 +53,7 @@ function convertMessages(messages, tools, model) {
let pendingUserContent = [];
let pendingAssistantContent = [];
let pendingToolResults = [];
let pendingImages: Array<{ format: string; source: { bytes: string } }> = [];
let currentRole = null;
const flushPending = () => {
@@ -62,11 +63,13 @@ function convertMessages(messages, tools, model) {
userInputMessage: {
content: string;
modelId: string;
images?: Array<{ format: string; source: { bytes: string } }>;
userInputMessageContext?: {
toolResults?: Array<Record<string, unknown>>;
tools?: Array<Record<string, unknown>>;
};
};
_toolDocs?: string;
} = {
userInputMessage: {
content: content,
@@ -80,11 +83,20 @@ function convertMessages(messages, tools, model) {
};
}
// Attach images to userInputMessage (NOT userInputMessageContext)
if (pendingImages.length > 0) {
userMsg.userInputMessage.images = pendingImages;
}
// Add tools to first user message
if (tools && tools.length > 0 && history.length === 0) {
if (!userMsg.userInputMessage.userInputMessageContext) {
userMsg.userInputMessage.userInputMessageContext = {};
}
// Kiro API rejects requests with tool descriptions > ~10000 chars.
// Move long descriptions to system prompt (same approach as kiro-gateway).
const TOOL_DESC_MAX = 10000;
const toolDocs: string[] = [];
userMsg.userInputMessage.userInputMessageContext.tools = tools.map((t) => {
const name = t.function?.name || t.name;
let description = t.function?.description || t.description || "";
@@ -93,6 +105,11 @@ function convertMessages(messages, tools, model) {
description = `Tool: ${name}`;
}
if (description.length > TOOL_DESC_MAX) {
toolDocs.push(`## Tool: ${name}\n\n${description}`);
description = `[Full documentation in system prompt under '## Tool: ${name}']`;
}
return {
toolSpecification: {
name,
@@ -105,12 +122,17 @@ function convertMessages(messages, tools, model) {
},
};
});
// Attach tool docs to message so buildKiroPayload can prepend to content
if (toolDocs.length > 0) {
userMsg._toolDocs = toolDocs.join("\n\n---\n\n");
}
}
history.push(userMsg);
currentMessage = userMsg;
pendingUserContent = [];
pendingToolResults = [];
pendingImages = [];
} else if (currentRole === "assistant") {
const content = pendingAssistantContent.join("\n\n").trim() || "...";
const assistantMsg = {
@@ -149,6 +171,24 @@ function convertMessages(messages, tools, model) {
.map((c) => c.text || "");
content = textParts.join("\n");
// Extract images (OpenAI image_url and Anthropic image formats)
for (const block of msg.content) {
if (block.type === "image_url") {
const url: string = block.image_url?.url || "";
if (url.startsWith("data:")) {
// data:image/jpeg;base64,<data>
const [header, bytes] = url.split(",", 2);
const mediaType = header.split(";")[0].replace("data:", ""); // e.g. "image/jpeg"
const format = mediaType.split("/")[1] || "jpeg";
if (bytes) pendingImages.push({ format, source: { bytes } });
}
} else if (block.type === "image" && block.source?.type === "base64") {
const format = (block.source.media_type || "image/jpeg").split("/")[1] || "jpeg";
if (block.source.data)
pendingImages.push({ format, source: { bytes: block.source.data } });
}
}
// Check for tool_result blocks
const toolResultBlocks = msg.content.filter((c) => c.type === "tool_result");
if (toolResultBlocks.length > 0) {
@@ -338,13 +378,19 @@ function convertMessages(messages, tools, model) {
* Build Kiro payload from OpenAI format
*/
export function buildKiroPayload(model, body, stream, credentials) {
// Normalize model name: Claude Code sends dashes (claude-sonnet-4-6),
// Kiro API expects dots (claude-sonnet-4.6). Convert trailing version segment.
const normalizedModel = model.replace(
/^(claude-(?:opus|sonnet|haiku|3-\d+)-\d+)-(\d+)$/,
"$1.$2"
);
const messages = body.messages || [];
const tools = body.tools || [];
const maxTokens = body.max_tokens ?? body.max_completion_tokens ?? 32000;
const temperature = body.temperature;
const topP = body.top_p;
const { history, currentMessage } = convertMessages(messages, tools, model);
const { history, currentMessage } = convertMessages(messages, tools, normalizedModel);
const profileArn = credentials?.providerSpecificData?.profileArn || "";
@@ -352,6 +398,12 @@ export function buildKiroPayload(model, body, stream, credentials) {
const timestamp = new Date().toISOString();
finalContent = `[Context: Current time is ${timestamp}]\n\n${finalContent}`;
// Prepend tool documentation for tools with long descriptions (moved from toolSpecification)
const toolDocs = (currentMessage as { _toolDocs?: string } | null)?._toolDocs;
if (toolDocs) {
finalContent = `# Tool Documentation\n\n${toolDocs}\n\n---\n\n${finalContent}`;
}
const payload: {
conversationState: {
chatTriggerType: string;
@@ -361,6 +413,7 @@ export function buildKiroPayload(model, body, stream, credentials) {
content: string;
modelId: string;
origin: string;
images?: Array<{ format: string; source: { bytes: string } }>;
userInputMessageContext?: Record<string, unknown>;
};
};
@@ -379,8 +432,11 @@ export function buildKiroPayload(model, body, stream, credentials) {
currentMessage: {
userInputMessage: {
content: finalContent,
modelId: model,
modelId: normalizedModel,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.images?.length && {
images: currentMessage.userInputMessage.images,
}),
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext,
}),

View File

@@ -129,6 +129,8 @@ export async function parseUpstreamError(response, provider = null) {
let message = "";
let retryAfterMs = null;
let responseBody = null;
let errorCode = undefined;
let errorType = undefined;
try {
const text = await response.text();
@@ -138,6 +140,8 @@ export async function parseUpstreamError(response, provider = null) {
try {
const json = JSON.parse(text);
message = json.error?.message || json.message || json.error || text;
errorCode = json.error?.code || json.code;
errorType = json.error?.type || json.type;
} catch {
message = text;
}
@@ -192,6 +196,8 @@ export async function parseUpstreamError(response, provider = null) {
return {
statusCode: response.status,
message: messageStr,
errorCode,
errorType,
retryAfterMs,
responseBody,
responseHeaders,
@@ -208,19 +214,34 @@ export async function parseUpstreamError(response, provider = null) {
export function createErrorResult(
statusCode: number,
message: string,
retryAfterMs: number | null = null
retryAfterMs: number | null = null,
errorCode?: string,
errorType?: string
) {
const body = buildErrorBody(statusCode, message);
if (errorCode) {
(body.error as any).code = errorCode;
}
if (errorType) {
(body.error as any).type = errorType;
}
const result: {
success: false;
status: number;
error: string;
errorType?: string;
response: Response;
retryAfterMs?: number;
} = {
success: false,
status: statusCode,
error: message,
response: errorResponse(statusCode, message),
errorType,
response: new Response(JSON.stringify(body), {
status: statusCode,
headers: { "Content-Type": "application/json" },
}),
};
// Add retryAfterMs if available (for Antigravity quota errors)

View File

@@ -66,6 +66,20 @@ function hasUsefulJsonPayload(payload: unknown): boolean {
return hasUsefulValue(payload);
}
function hasAcceptedStreamStartPayload(payload: unknown, eventType = ""): boolean {
if (!isRecord(payload)) return false;
// Anthropic/Claude streams can legitimately start with lifecycle frames and
// then spend a long time thinking before the first text/tool delta arrives.
// Treating the start frame as readiness prevents false 504s while ping-only
// zombie streams still fail below.
const type = typeof payload.type === "string" ? payload.type : eventType;
if (type === "message_start" && isRecord(payload.message)) return true;
if (type === "content_block_start" && isRecord(payload.content_block)) return true;
return false;
}
export function hasUsefulStreamContent(text: string): boolean {
const lines = text.split(/\r?\n/);
@@ -88,6 +102,60 @@ export function hasUsefulStreamContent(text: string): boolean {
return false;
}
type StreamReadinessSignalState = {
currentEvent: string;
pendingLine: string;
};
function processStreamReadinessLine(state: StreamReadinessSignalState, line: string): boolean {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(":")) {
if (!trimmed) state.currentEvent = "";
return false;
}
if (trimmed.startsWith("event:")) {
state.currentEvent = trimmed.slice(6).trim();
return false;
}
if (/^(?:ping|keepalive)$/i.test(state.currentEvent)) return false;
if (!trimmed.startsWith("data:")) return false;
const data = trimmed.slice(5).trim();
if (!data || data === "[DONE]") return false;
try {
const parsed = JSON.parse(data);
return (
hasUsefulJsonPayload(parsed) || hasAcceptedStreamStartPayload(parsed, state.currentEvent)
);
} catch {
return data.length > 0;
}
}
function appendStreamReadinessSignal(state: StreamReadinessSignalState, chunk: string): boolean {
const lines = `${state.pendingLine}${chunk}`.split(/\r?\n/);
state.pendingLine = lines.pop() ?? "";
for (const line of lines) {
if (processStreamReadinessLine(state, line)) return true;
}
return false;
}
export function hasStreamReadinessSignal(text: string): boolean {
const state: StreamReadinessSignalState = {
currentEvent: "",
pendingLine: "",
};
if (appendStreamReadinessSignal(state, text)) return true;
if (state.pendingLine) return processStreamReadinessLine(state, state.pendingLine);
return false;
}
function createErrorResponse(
status: number,
message: string,
@@ -170,7 +238,10 @@ export async function ensureStreamReadiness(
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
const decoder = new TextDecoder();
let bufferedText = "";
const readinessState: StreamReadinessSignalState = {
currentEvent: "",
pendingLine: "",
};
const startedAt = Date.now();
const deadline = startedAt + options.timeoutMs;
let handedOffReader = false;
@@ -245,9 +316,9 @@ export async function ensureStreamReadiness(
if (!readResult.value) continue;
chunks.push(readResult.value);
bufferedText += decoder.decode(readResult.value, { stream: true });
const decodedChunk = decoder.decode(readResult.value, { stream: true });
if (hasUsefulStreamContent(bufferedText)) {
if (appendStreamReadinessSignal(readinessState, decodedChunk)) {
options.log?.debug?.(
"STREAM",
`Stream readiness confirmed in ${Date.now() - startedAt}ms (${options.provider || "provider"}/${options.model || "unknown"})`

View File

@@ -118,6 +118,85 @@ function checkI18nMirrorFile(fileName, sourcePath) {
}
}
/**
* Check i18n CHANGELOG mirrors by verifying that all version sections from the
* root CHANGELOG exist in each translation. Unlike strict mirror files (llm.txt),
* CHANGELOG translations have translated section headings (e.g. "Security" →
* "Segurança"), so byte-for-byte comparison is intentionally skipped.
*
* Validates:
* 1. File exists in each locale
* 2. Has the i18n mirror separator (---)
* 3. Contains all version sections (## [X.Y.Z]) from root, in the same order
* 4. Body is non-empty and within a reasonable size tolerance of the source
*/
function checkI18nChangelogFile(sourcePath) {
const fileName = "CHANGELOG.md";
if (!fs.existsSync(i18nDocsPath)) {
fail("docs/i18n directory is missing");
return;
}
const sourceContent = readText(sourcePath);
const sourceBody = normalizeMirrorBody(stripTopHeading(sourceContent));
const sourceVersions = extractChangelogSections(sourceContent);
const locales = fs
.readdirSync(i18nDocsPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
let checked = 0;
for (const locale of locales) {
const targetPath = path.join(i18nDocsPath, locale, fileName);
if (!fs.existsSync(targetPath)) {
fail(`docs/i18n/${locale}/${fileName} is missing`);
continue;
}
const targetContent = readText(targetPath);
const body = extractI18nMirrorBody(targetContent);
if (body === null) {
fail(`docs/i18n/${locale}/${fileName} is missing the i18n mirror separator`);
continue;
}
const normalizedBody = normalizeMirrorBody(body);
if (normalizedBody.length === 0) {
fail(`docs/i18n/${locale}/${fileName} has empty body after separator`);
continue;
}
// Verify all version sections from root exist in the translation
const targetVersions = extractChangelogSections(targetContent);
const missingVersions = sourceVersions.filter((v) => !targetVersions.includes(v));
if (missingVersions.length > 0) {
fail(
`docs/i18n/${locale}/${fileName} is missing version sections: ${missingVersions.slice(0, 3).join(", ")}${missingVersions.length > 3 ? ` (+${missingVersions.length - 3} more)` : ""}`
);
continue;
}
// Verify body size is within 25% tolerance of source (translations may
// expand or shrink, but drastic size differences indicate stale content)
const sizeDiff = Math.abs(normalizedBody.length - sourceBody.length) / sourceBody.length;
if (sizeDiff > 0.25) {
fail(
`docs/i18n/${locale}/${fileName} body size differs by ${(sizeDiff * 100).toFixed(0)}% from root (expected within 25%)`
);
continue;
}
checked += 1;
}
if (checked > 0) {
console.log(
`[docs-sync] ${fileName} i18n translations validated: ${checked} locales (version sections + size check)`
);
}
}
try {
const packageJson = JSON.parse(readText(packageJsonPath));
const packageVersion = packageJson.version;
@@ -161,8 +240,10 @@ try {
}
}
// llm.txt mirrors must be exact copies (no translation)
checkI18nMirrorFile("llm.txt", llmPath);
checkI18nMirrorFile("CHANGELOG.md", changelogPath);
// CHANGELOG.md mirrors are translations — check version sections and size, not exact content
checkI18nChangelogFile(changelogPath);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}

View File

@@ -61,6 +61,7 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
".env.example",
"LICENSE",
"README.md",
"bin/cli-commands.mjs",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
@@ -84,6 +85,7 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
];
export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [
"bin/cli/",
"open-sse/mcp-server/schemas/",
"open-sse/mcp-server/tools/",
"src/shared/contracts/",
@@ -95,6 +97,8 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"app/server.js",
"app/server-ws.mjs",
"app/responses-ws-proxy.mjs",
"bin/cli-commands.mjs",
"bin/cli/index.mjs",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",

View File

@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
interface AutoRoutingStats {
totalRequests: number;
variantBreakdown: Record<string, number>;
avgSelectionScore: number;
topProviders: Array<{ provider: string; count: number }>;
explorationRate: number;
lkgpHitRate: number;
}
export default function AutoRoutingAnalyticsTab() {
const [stats, setStats] = useState<AutoRoutingStats | null>(null);
const [loading, setLoading] = useState(true);
const t = useTranslations("analytics");
useEffect(() => {
fetch("/api/analytics/auto-routing")
.then((res) => res.json())
.then((data) => {
setStats(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
if (loading) {
return (
<Card>
<div className="animate-pulse space-y-4">
<div className="h-4 bg-border rounded w-1/4"></div>
<div className="h-20 bg-border rounded"></div>
</div>
</Card>
);
}
if (!stats) {
return (
<Card>
<div className="text-center py-8 text-text-muted">
No auto-routing analytics available. Make requests using the auto/ prefix to see metrics.
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
<span className="material-symbols-outlined text-[20px]">auto_awesome</span>
</div>
<div>
<p className="text-sm text-text-muted">Total Auto Requests</p>
<p className="text-2xl font-bold">{stats.totalRequests.toLocaleString()}</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[20px]">target</span>
</div>
<div>
<p className="text-sm text-text-muted">Avg Selection Score</p>
<p className="text-2xl font-bold">{(stats.avgSelectionScore * 100).toFixed(1)}%</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[20px]">explore</span>
</div>
<div>
<p className="text-sm text-text-muted">Exploration Rate</p>
<p className="text-2xl font-bold">{(stats.explorationRate * 100).toFixed(1)}%</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">history</span>
</div>
<div>
<p className="text-sm text-text-muted">LKGP Hit Rate</p>
<p className="text-2xl font-bold">{(stats.lkgpHitRate * 100).toFixed(1)}%</p>
</div>
</div>
</Card>
</div>
{/* Variant Breakdown */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Requests by Variant</h3>
<div className="space-y-3">
{Object.entries(stats.variantBreakdown).map(([variant, count]) => {
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
return (
<div key={variant} className="flex items-center gap-3">
<div className="w-32 text-sm font-medium capitalize">{variant || "default"}</div>
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<div className="w-20 text-sm text-text-muted text-right">
{count.toLocaleString()} ({percentage.toFixed(1)}%)
</div>
</div>
);
})}
</div>
</Card>
{/* Top Providers */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Top Routed Providers</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 font-medium">Provider</th>
<th className="text-right py-2 px-3 font-medium">Requests</th>
<th className="text-right py-2 px-3 font-medium">Share</th>
</tr>
</thead>
<tbody>
{stats.topProviders.map((provider, index) => {
const percentage =
stats.totalRequests > 0 ? (provider.count / stats.totalRequests) * 100 : 0;
return (
<tr key={provider.provider} className="border-b border-border/50">
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<span className="text-text-muted">#{index + 1}</span>
<span className="font-medium">{provider.provider}</span>
</div>
</td>
<td className="text-right py-2 px-3">{provider.count.toLocaleString()}</td>
<td className="text-right py-2 px-3 text-text-muted">
{percentage.toFixed(1)}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import CompressionAnalyticsTab from "./CompressionAnalyticsTab";
import DiversityScoreCard from "./components/DiversityScoreCard";
import ProviderUtilizationTab from "./ProviderUtilizationTab";
import ComboHealthTab from "./ComboHealthTab";
import AutoRoutingAnalyticsTab from "./AutoRoutingAnalyticsTab";
import { useTranslations } from "next-intl";
export default function AnalyticsPage() {
@@ -21,6 +22,8 @@ export default function AnalyticsPage() {
utilization: t("utilizationDescription"),
comboHealth: t("comboHealthDescription"),
compression: t("compressionAnalyticsDescription"),
autoRouting:
"Auto-routing analytics — variant usage, provider selection, and LKGP performance.",
};
return (
@@ -42,6 +45,7 @@ export default function AnalyticsPage() {
{ value: "utilization", label: t("utilization") },
{ value: "comboHealth", label: t("comboHealth") },
{ value: "compression", label: t("compressionAnalyticsTitle") },
{ value: "autoRouting", label: "Auto-Routing" },
]}
value={activeTab}
onChange={setActiveTab}

View File

@@ -23,6 +23,7 @@ type MediaModelConfig = { id: string; name: string };
type MediaProviderConfig = {
id: string;
authType: string;
supportedFormats?: string[];
models: MediaModelConfig[];
};
type ProviderModelGroup = {
@@ -172,8 +173,147 @@ const VOICE_PRESETS: Record<string, { id: string; label: string }[]> = {
{ id: "aura-orion-en", label: "Orion (EN)" },
],
inworld: [
{ id: "Eva", label: "Eva (EN)" },
{ id: "Abby", label: "Abby (EN)" },
{ id: "Alex", label: "Alex (EN)" },
{ id: "Amina", label: "Amina (EN)" },
{ id: "Anjali", label: "Anjali (EN)" },
{ id: "Arjun", label: "Arjun (EN)" },
{ id: "Ashley", label: "Ashley (EN)" },
{ id: "Avery", label: "Avery (EN)" },
{ id: "Bianca", label: "Bianca (EN)" },
{ id: "Blake", label: "Blake (EN)" },
{ id: "Brandon", label: "Brandon (EN)" },
{ id: "Brian", label: "Brian (EN)" },
{ id: "Callum", label: "Callum (EN)" },
{ id: "Carter", label: "Carter (EN)" },
{ id: "Cedric", label: "Cedric (EN)" },
{ id: "Celeste", label: "Celeste (EN)" },
{ id: "Chloe", label: "Chloe (EN)" },
{ id: "Claire", label: "Claire (EN)" },
{ id: "Clive", label: "Clive (EN)" },
{ id: "Conrad", label: "Conrad (EN)" },
{ id: "Craig", label: "Craig (EN)" },
{ id: "Damon", label: "Damon (EN)" },
{ id: "Darlene", label: "Darlene (EN)" },
{ id: "Deborah", label: "Deborah (EN)" },
{ id: "Dennis", label: "Dennis (EN)" },
{ id: "Derek", label: "Derek (EN)" },
{ id: "Dominus", label: "Dominus (EN)" },
{ id: "Duncan", label: "Duncan (EN)" },
{ id: "Edward", label: "Edward (EN)" },
{ id: "Eleanor", label: "Eleanor (EN)" },
{ id: "Elliot", label: "Elliot (EN)" },
{ id: "Ethan", label: "Ethan (EN)" },
{ id: "Evan", label: "Evan (EN)" },
{ id: "Evelyn", label: "Evelyn (EN)" },
{ id: "Felix", label: "Felix (EN)" },
{ id: "Gareth", label: "Gareth (EN)" },
{ id: "Graham", label: "Graham (EN)" },
{ id: "Hades", label: "Hades (EN)" },
{ id: "Hamish", label: "Hamish (EN)" },
{ id: "Hana", label: "Hana (EN)" },
{ id: "Hank", label: "Hank (EN)" },
{ id: "James", label: "James (EN)" },
{ id: "Jason", label: "Jason (EN)" },
{ id: "Jessica", label: "Jessica (EN)" },
{ id: "Jonah", label: "Jonah (EN)" },
{ id: "Kelsey", label: "Kelsey (EN)" },
{ id: "Lauren", label: "Lauren (EN)" },
{ id: "Levi", label: "Levi (EN)" },
{ id: "Liam", label: "Liam (EN)" },
{ id: "Loretta", label: "Loretta (EN)" },
{ id: "Lucian", label: "Lucian (EN)" },
{ id: "Luna", label: "Luna (EN)" },
{ id: "Malcolm", label: "Malcolm (EN)" },
{ id: "Marcus", label: "Marcus (EN)" },
{ id: "Mark", label: "Mark (EN)" },
{ id: "Marlene", label: "Marlene (EN)" },
{ id: "Mia", label: "Mia (EN)" },
{ id: "Miranda", label: "Miranda (EN)" },
{ id: "Mortimer", label: "Mortimer (EN)" },
{ id: "Nadia", label: "Nadia (EN)" },
{ id: "Naomi", label: "Naomi (EN)" },
{ id: "Nate", label: "Nate (EN)" },
{ id: "Oliver", label: "Oliver (EN)" },
{ id: "Olivia", label: "Olivia (EN)" },
{ id: "Pippa", label: "Pippa (EN)" },
{ id: "Pixie", label: "Pixie (EN)" },
{ id: "Reed", label: "Reed (EN)" },
{ id: "Riley", label: "Riley (EN)" },
{ id: "Ronald", label: "Ronald (EN)" },
{ id: "Rupert", label: "Rupert (EN)" },
{ id: "Saanvi", label: "Saanvi (EN)" },
{ id: "Sarah", label: "Sarah (EN)" },
{ id: "Sebastian", label: "Sebastian (EN)" },
{ id: "Selene", label: "Selene (EN)" },
{ id: "Serena", label: "Serena (EN)" },
{ id: "Simon", label: "Simon (EN)" },
{ id: "Snik", label: "Snik (EN)" },
{ id: "Sophie", label: "Sophie (EN)" },
{ id: "Tessa", label: "Tessa (EN)" },
{ id: "Theodore", label: "Theodore (EN)" },
{ id: "Timothy", label: "Timothy (EN)" },
{ id: "Trevor", label: "Trevor (EN)" },
{ id: "Tristan", label: "Tristan (EN)" },
{ id: "Tyler", label: "Tyler (EN)" },
{ id: "Veronica", label: "Veronica (EN)" },
{ id: "Victor", label: "Victor (EN)" },
{ id: "Victoria", label: "Victoria (EN)" },
{ id: "Vinny", label: "Vinny (EN)" },
{ id: "Wendy", label: "Wendy (EN)" },
{ id: "Aanya", label: "Aanya (HI)" },
{ id: "Aarav", label: "Aarav (HI)" },
{ id: "Manoj", label: "Manoj (HI)" },
{ id: "Riya", label: "Riya (HI)" },
{ id: "Alain", label: "Alain (FR)" },
{ id: "Étienne", label: "Étienne (FR)" },
{ id: "Hélène", label: "Hélène (FR)" },
{ id: "Mathieu", label: "Mathieu (FR)" },
{ id: "Asuka", label: "Asuka (JP)" },
{ id: "Haruto", label: "Haruto (JP)" },
{ id: "Hina", label: "Hina (JP)" },
{ id: "Satoshi", label: "Satoshi (JP)" },
{ id: "Beatriz", label: "Beatriz (PT)" },
{ id: "Heitor", label: "Heitor (PT)" },
{ id: "Maitê", label: "Maitê (PT)" },
{ id: "Mariana", label: "Mariana (PT)" },
{ id: "Murilo", label: "Murilo (PT)" },
{ id: "Camila", label: "Camila (ES)" },
{ id: "Diego", label: "Diego (ES)" },
{ id: "Lupita", label: "Lupita (ES)" },
{ id: "Mateo", label: "Mateo (ES)" },
{ id: "Mauricio", label: "Mauricio (ES)" },
{ id: "Miguel", label: "Miguel (ES)" },
{ id: "Rafael", label: "Rafael (ES)" },
{ id: "Sofia", label: "Sofia (ES)" },
{ id: "Dmitry", label: "Dmitry (RU)" },
{ id: "Elena", label: "Elena (RU)" },
{ id: "Nikolai", label: "Nikolai (RU)" },
{ id: "Svetlana", label: "Svetlana (RU)" },
{ id: "Erik", label: "Erik (NL)" },
{ id: "Katrien", label: "Katrien (NL)" },
{ id: "Lennart", label: "Lennart (NL)" },
{ id: "Lore", label: "Lore (NL)" },
{ id: "Gianni", label: "Gianni (IT)" },
{ id: "Orietta", label: "Orietta (IT)" },
{ id: "Hyunwoo", label: "Hyunwoo (KO)" },
{ id: "Minji", label: "Minji (KO)" },
{ id: "Seojun", label: "Seojun (KO)" },
{ id: "Yoona", label: "Yoona (KO)" },
{ id: "Jing", label: "Jing (ZH)" },
{ id: "Mei", label: "Mei (ZH)" },
{ id: "Ming", label: "Ming (ZH)" },
{ id: "Xiaoyin", label: "Xiaoyin (ZH)" },
{ id: "Xinyi", label: "Xinyi (ZH)" },
{ id: "Yichen", label: "Yichen (ZH)" },
{ id: "Johanna", label: "Johanna (DE)" },
{ id: "Josef", label: "Josef (DE)" },
{ id: "Nour", label: "Nour (AR)" },
{ id: "Omar", label: "Omar (AR)" },
{ id: "Oren", label: "Oren (HE)" },
{ id: "Yael", label: "Yael (HE)" },
{ id: "Szymon", label: "Szymon (PL)" },
{ id: "Wojciech", label: "Wojciech (PL)" },
],
"xiaomi-mimo": [
{ id: "冰糖", label: "冰糖 (Chinese Female)" },
@@ -188,12 +328,10 @@ const VOICE_PRESETS: Record<string, { id: string; label: string }[]> = {
};
const SPEECH_FORMATS = ["mp3", "wav", "opus", "flac", "pcm"];
const SPEECH_FORMATS_BY_PROVIDER: Record<string, string[]> = {
"xiaomi-mimo": ["mp3", "wav"],
};
function getSpeechFormats(providerId: string): string[] {
return SPEECH_FORMATS_BY_PROVIDER[providerId] || SPEECH_FORMATS;
const providerFormats = AUDIO_SPEECH_PROVIDERS[providerId]?.supportedFormats;
return providerFormats?.length ? providerFormats : SPEECH_FORMATS;
}
function getVoiceList(providerId: string) {

View File

@@ -0,0 +1,478 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, Badge } from "@/shared/components";
import { useTranslations } from "next-intl";
interface CloudAgentTask {
id: string;
provider: string;
status: "pending" | "running" | "waiting_approval" | "completed" | "failed" | "cancelled";
description: string;
createdAt: string;
updatedAt: string;
result?: string;
error?: string;
plan?: string;
messages: Array<{ role: string; content: string; timestamp: string }>;
}
const CLOUD_AGENTS = [
{
id: "jules",
name: "Jules",
provider: "Google",
description: "Google's autonomous coding agent",
icon: "🟡",
color: "bg-yellow-500/10 text-yellow-600",
},
{
id: "devin",
name: "Devin",
provider: "Cognition",
description: "Cognition's AI software engineer",
icon: "🔵",
color: "bg-blue-500/10 text-blue-600",
},
{
id: "codex-cloud",
name: "Codex Cloud",
provider: "OpenAI",
description: "OpenAI's cloud-based coding agent",
icon: "⚡",
color: "bg-emerald-500/10 text-emerald-600",
},
];
export default function CloudAgentsPage() {
const [tasks, setTasks] = useState<CloudAgentTask[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [selectedTask, setSelectedTask] = useState<CloudAgentTask | null>(null);
const [newTask, setNewTask] = useState({
provider: "jules",
description: "",
});
const [messageInput, setMessageInput] = useState("");
const t = useTranslations("cloudAgents");
const fetchTasks = useCallback(async () => {
try {
const res = await fetch("/api/v1/agents/tasks");
if (res.ok) {
const data = await res.json();
setTasks(data.tasks || []);
}
} catch (err) {
console.error("Failed to fetch tasks:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const handleCreateTask = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true);
try {
const res = await fetch("/api/v1/agents/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: newTask.provider,
description: newTask.description,
}),
});
if (res.ok) {
const data = await res.json();
setTasks((prev) => [data.task, ...prev]);
setNewTask({ provider: "jules", description: "" });
}
} catch (err) {
console.error("Failed to create task:", err);
} finally {
setCreating(false);
}
};
const handleSendMessage = async () => {
if (!selectedTask || !messageInput.trim()) return;
try {
const res = await fetch(`/api/v1/agents/tasks/${selectedTask.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "message",
message: messageInput,
}),
});
if (res.ok) {
const data = await res.json();
setSelectedTask(data.task);
setTasks((prev) => prev.map((task) => (task.id === selectedTask.id ? data.task : task)));
setMessageInput("");
}
} catch (err) {
console.error("Failed to send message:", err);
}
};
const handleApprovePlan = async () => {
if (!selectedTask) return;
try {
const res = await fetch(`/api/v1/agents/tasks/${selectedTask.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "approve" }),
});
if (res.ok) {
const data = await res.json();
setSelectedTask(data.task);
setTasks((prev) => prev.map((t) => (t.id === selectedTask.id ? data.task : t)));
}
} catch (err) {
console.error("Failed to approve plan:", err);
}
};
const handleCancelTask = async (taskId: string) => {
try {
const res = await fetch(`/api/v1/agents/tasks/${taskId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "cancel" }),
});
if (res.ok) {
const data = await res.json();
setTasks((prev) => prev.map((t) => (t.id === taskId ? data.task : t)));
if (selectedTask?.id === taskId) {
setSelectedTask(data.task);
}
}
} catch (err) {
console.error("Failed to cancel task:", err);
}
};
const handleDeleteTask = async (taskId: string) => {
try {
const res = await fetch(`/api/v1/agents/tasks/${taskId}`, {
method: "DELETE",
});
if (res.ok) {
setTasks((prev) => prev.filter((t) => t.id !== taskId));
if (selectedTask?.id === taskId) {
setSelectedTask(null);
}
}
} catch (err) {
console.error("Failed to delete task:", err);
}
};
const getStatusBadge = (status: string) => {
const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: "bg-zinc-500/10 text-zinc-500", label: t("statusPending") },
running: { color: "bg-blue-500/10 text-blue-500", label: t("statusRunning") },
waiting_approval: {
color: "bg-amber-500/10 text-amber-600",
label: t("statusWaitingApproval"),
},
completed: { color: "bg-emerald-500/10 text-emerald-600", label: t("statusCompleted") },
failed: { color: "bg-red-500/10 text-red-500", label: t("statusFailed") },
cancelled: { color: "bg-zinc-500/10 text-zinc-400", label: t("statusCancelled") },
};
const s = statusMap[status] || statusMap.pending;
return (
<span
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${s.color}`}
>
{status === "running" && <span className="animate-pulse"></span>}
{s.label}
</span>
);
};
const getAgentInfo = (providerId: string) => {
return CLOUD_AGENTS.find((a) => a.id === providerId) || CLOUD_AGENTS[0];
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
<p className="text-sm text-text-muted">{t("loading")}</p>
</div>
);
}
return (
<div className="flex flex-col gap-6 p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-text-muted mt-1">{t("description")}</p>
</div>
</div>
<Card className="border-purple-500/20 bg-purple-500/5">
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="text-sm font-semibold text-text-main">{t("aboutTitle")}</h2>
<p className="text-sm text-text-muted mt-1">{t("aboutDescription")}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{CLOUD_AGENTS.map((agent) => (
<div
key={agent.id}
className="rounded-lg border border-purple-500/15 bg-purple-500/5 p-3"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{agent.icon}</span>
<p className="text-sm font-medium text-text-main">{agent.name}</p>
</div>
<p className="text-xs text-text-muted">{agent.description}</p>
<p className="text-[10px] text-purple-500 mt-1">{agent.provider}</p>
</div>
))}
</div>
<div className="rounded-lg border border-purple-500/15 bg-surface/40 p-3 text-sm text-text-muted">
<span className="font-medium text-text-main">{t("howItWorksTitle")}</span>
<span className="ml-1">{t("howItWorksDesc")}</span>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
<span className="material-symbols-outlined text-[20px]">add_task</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("newTaskTitle")}</h3>
<p className="text-sm text-text-muted">{t("newTaskDescription")}</p>
</div>
</div>
<form onSubmit={handleCreateTask} className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">
{t("selectAgent")}
</label>
<select
value={newTask.provider}
onChange={(e) => setNewTask({ ...newTask, provider: e.target.value })}
className="w-full rounded-lg border border-border/50 bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
{CLOUD_AGENTS.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name} ({agent.provider})
</option>
))}
</select>
</div>
</div>
<div>
<Input
label={t("taskDescription")}
placeholder={t("taskDescriptionPlaceholder")}
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
required
/>
</div>
<div className="flex justify-end">
<Button type="submit" variant="primary" loading={creating}>
<span className="material-symbols-outlined text-[16px] mr-1">rocket_launch</span>
{t("startTask")}
</Button>
</div>
</form>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">{t("tasks")}</h2>
{tasks.length === 0 ? (
<div className="text-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[40px] mb-2">assignment</span>
<p>{t("noTasks")}</p>
</div>
) : (
tasks.map((task) => {
const agent = getAgentInfo(task.provider);
return (
<Card
key={task.id}
className={`cursor-pointer transition-all hover:border-primary/30 ${
selectedTask?.id === task.id ? "border-primary ring-1 ring-primary/20" : ""
}`}
onClick={() => setSelectedTask(task)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-lg">{agent.icon}</span>
<div>
<p className="text-sm font-medium text-text-main line-clamp-1">
{task.description || t("untitledTask")}
</p>
<p className="text-xs text-text-muted">
{agent.name} {new Date(task.createdAt).toLocaleString()}
</p>
</div>
</div>
{getStatusBadge(task.status)}
</div>
</Card>
);
})
)}
</div>
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">{t("taskDetail")}</h2>
{selectedTask ? (
<Card className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{getAgentInfo(selectedTask.provider).icon}</span>
<div>
<p className="font-medium">{getAgentInfo(selectedTask.provider).name}</p>
<p className="text-xs text-text-muted">
{t("created")}: {new Date(selectedTask.createdAt).toLocaleString()}
</p>
</div>
</div>
{getStatusBadge(selectedTask.status)}
</div>
{selectedTask.status === "waiting_approval" && selectedTask.plan && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-amber-600">
description
</span>
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
{t("planReady")}
</span>
</div>
<pre className="text-xs text-text-muted whitespace-pre-wrap bg-black/5 dark:bg-white/5 rounded p-2 max-h-32 overflow-auto">
{selectedTask.plan}
</pre>
<div className="flex gap-2 mt-2">
<Button variant="primary" size="sm" onClick={handleApprovePlan}>
<span className="material-symbols-outlined text-[14px] mr-1">check</span>
{t("approvePlan")}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => handleCancelTask(selectedTask.id)}
>
{t("rejectPlan")}
</Button>
</div>
</div>
)}
{selectedTask.messages.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">{t("conversation")}</p>
<div className="flex flex-col gap-2 max-h-64 overflow-auto">
{selectedTask.messages.map((msg, i) => (
<div
key={i}
className={`p-2 rounded-lg text-xs ${
msg.role === "assistant"
? "bg-purple-500/10 text-text-main"
: "bg-surface/40 text-text-main"
}`}
>
<span className="font-medium capitalize">{msg.role}: </span>
{msg.content}
</div>
))}
</div>
</div>
)}
{selectedTask.result && (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-emerald-600">
check_circle
</span>
<span className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
{t("result")}
</span>
</div>
<pre className="text-xs text-text-muted whitespace-pre-wrap">
{selectedTask.result}
</pre>
</div>
)}
{selectedTask.error && (
<div className="rounded-lg border border-red-500/20 bg-red-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-red-500">
error
</span>
<span className="text-sm font-medium text-red-600">{t("error")}</span>
</div>
<p className="text-xs text-text-muted">{selectedTask.error}</p>
</div>
)}
{selectedTask.status === "running" && (
<div className="flex gap-2">
<Input
placeholder={t("sendMessagePlaceholder")}
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSendMessage()}
className="flex-1"
/>
<Button variant="primary" onClick={handleSendMessage}>
<span className="material-symbols-outlined text-[16px]">send</span>
</Button>
</div>
)}
<div className="flex justify-between pt-3 border-t border-border/30">
<Button
variant="ghost"
size="sm"
onClick={() => handleCancelTask(selectedTask.id)}
disabled={["completed", "failed", "cancelled"].includes(selectedTask.status)}
>
<span className="material-symbols-outlined text-[14px] mr-1">cancel</span>
{t("cancel")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTask(selectedTask.id)}
className="text-red-500 hover:text-red-400"
>
<span className="material-symbols-outlined text-[14px] mr-1">delete</span>
{t("delete")}
</Button>
</div>
</Card>
) : (
<div className="text-center py-8 text-text-muted border border-dashed border-border/50 rounded-lg">
<span className="material-symbols-outlined text-[40px] mb-2">touch_app</span>
<p>{t("selectTaskPrompt")}</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -15,7 +15,7 @@ function getI18nOrFallback(t: any, key: string, fallback: string) {
return fallback;
}
function toProviderOptions(activeProviders: any[] = []) {
function toProviderOptions(activeProviders: any[] = [], candidatePool: string[] = []) {
const uniqueProviders = new Map<string, { id: string; label: string; connectionCount: number }>();
activeProviders.forEach((provider) => {
@@ -41,6 +41,16 @@ function toProviderOptions(activeProviders: any[] = []) {
});
});
candidatePool.forEach((poolId) => {
if (!uniqueProviders.has(poolId)) {
uniqueProviders.set(poolId, {
id: poolId,
label: `${poolId} (Offline/Deleted)`,
connectionCount: 0,
});
}
});
return [...uniqueProviders.values()].sort((a, b) => a.label.localeCompare(b.label));
}
@@ -56,7 +66,10 @@ export default function BuilderIntelligentStep({
activeProviders: any[];
}) {
const normalizedConfig = normalizeIntelligentRoutingConfig(config);
const providerOptions = useMemo(() => toProviderOptions(activeProviders), [activeProviders]);
const providerOptions = useMemo(
() => toProviderOptions(activeProviders, normalizedConfig.candidatePool),
[activeProviders, normalizedConfig.candidatePool]
);
const updateConfig = (patch: Record<string, unknown>) => {
onChange({
@@ -64,7 +77,7 @@ export default function BuilderIntelligentStep({
...patch,
weights: {
...normalizedConfig.weights,
...(patch.weights || {}),
...((patch.weights as Record<string, number>) || {}),
},
});
};

View File

@@ -509,6 +509,7 @@ interface ConnectionRowConnection {
expiresAt?: string;
tokenExpiresAt?: string;
maxConcurrent?: number | null;
authType?: string;
}
interface ConnectionRowProps {
@@ -3024,7 +3025,7 @@ export default function ProviderDetailPage() {
{selectedIds.size > 0 && (
<Button
variant="destructive"
variant="danger"
size="sm"
icon="delete"
loading={batchDeleting}
@@ -3154,7 +3155,7 @@ export default function ProviderDetailPage() {
{selectedIds.size > 0 && (
<Button
variant="destructive"
variant="danger"
size="sm"
icon="delete"
loading={batchDeleting}

View File

@@ -11,6 +11,7 @@ import {
IMAGE_ONLY_PROVIDER_IDS,
VIDEO_PROVIDER_IDS,
isClaudeCodeCompatibleProvider,
CLOUD_AGENT_PROVIDERS,
} from "@/shared/constants/providers";
import { useRouter } from "next/navigation";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
@@ -488,6 +489,13 @@ export default function ProvidersPage() {
searchQuery
);
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
const cloudAgentProviderEntries = filterConfiguredProviderEntries(
cloudAgentProviderEntriesAll,
showConfiguredOnly,
searchQuery
);
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
const upstreamProxyEntries = filterConfiguredProviderEntries(
upstreamProxyEntriesAll,
@@ -970,6 +978,51 @@ export default function ProvidersPage() {
</div>
)}
{/* Cloud Agent Providers */}
{cloudAgentProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
{t("cloudAgentProviders")}{" "}
<span
className="size-2.5 rounded-full bg-violet-500"
title={t("cloudAgentProviders")}
/>
<ProviderCountBadge {...countConfigured(cloudAgentProviderEntriesAll)} />
</h2>
<button
onClick={() => handleBatchTest("cloud-agent")}
disabled={!!testingMode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
testingMode === "cloud-agent"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title={t("testAll")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "cloud-agent" ? "sync" : "play_arrow"}
</span>
{testingMode === "cloud-agent" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{cloudAgentProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
key={providerId}
providerId={providerId}
provider={provider}
stats={stats}
authType={displayAuthType}
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
/>
)
)}
</div>
</div>
)}
{/* Local / Self-Hosted Providers */}
{localProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">

View File

@@ -16,6 +16,9 @@ type RequestQueueSettings = {
type ConnectionCooldownProfileSettings = {
baseCooldownMs: number;
useUpstreamRetryHints: boolean;
// Issue #2100 follow-up. Optional / undefined when unset; the per-provider
// default in src/shared/utils/providerHints.ts resolves at runtime.
useUpstream429BreakerHints?: boolean;
maxBackoffSteps: number;
};
@@ -360,6 +363,48 @@ function ConnectionCooldownCard({
}))
}
/>
<div className="flex flex-col gap-1">
<label className="flex items-center justify-between gap-2 text-sm">
<span className="text-text-muted">Use upstream 429 hints for breaker cooldown</span>
<select
className="rounded border border-border-default bg-surface-1 px-2 py-1 text-sm font-mono"
value={
current.useUpstream429BreakerHints === true
? "on"
: current.useUpstream429BreakerHints === false
? "off"
: "default"
}
onChange={(e) => {
const v = e.target.value;
const next: boolean | undefined =
v === "on" ? true : v === "off" ? false : undefined;
setDraft((prev) => {
const profile = { ...prev[key] };
if (next === undefined) {
delete (profile as { useUpstream429BreakerHints?: boolean })
.useUpstream429BreakerHints;
} else {
(
profile as { useUpstream429BreakerHints?: boolean }
).useUpstream429BreakerHints = next;
}
return { ...prev, [key]: profile };
});
}}
>
<option value="default">Default (per provider)</option>
<option value="on">Always on</option>
<option value="off">Always off</option>
</select>
</label>
<p className="text-xs text-text-muted">
Apply Retry-After / quota-exhausted signals from 429 responses to circuit-breaker
cooldown duration. Default uses a per-provider policy: direct cloud providers
default on; reverse-proxy / self-hosted / CLI-backed providers default off.
Independent of &quot;Use upstream retry hints&quot;.
</p>
</div>
<NumberField
label="Max backoff steps"
value={current.maxBackoffSteps}
@@ -381,6 +426,16 @@ function ConnectionCooldownCard({
{current.useUpstreamRetryHints ? "Yes" : "No"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Use upstream 429 hints (breaker)</span>
<span className="font-mono text-text-main">
{current.useUpstream429BreakerHints === true
? "Yes"
: current.useUpstream429BreakerHints === false
? "No"
: "Default"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Max backoff steps</span>
<span className="font-mono text-text-main">{current.maxBackoffSteps}</span>
@@ -414,7 +469,26 @@ function ConnectionCooldownCard({
setEditing(false);
}}
onSave={async () => {
await onSave(draft);
// Build PATCH-ready payload: convert undefined useUpstream429BreakerHints
// to explicit null sentinel so the server treats it as unset (not as
// partial-merge "leave unchanged"). JSON.stringify drops undefined keys.
const payload = {
oauth: {
...draft.oauth,
useUpstream429BreakerHints:
draft.oauth.useUpstream429BreakerHints === undefined
? (null as unknown as boolean | undefined)
: draft.oauth.useUpstream429BreakerHints,
},
apikey: {
...draft.apikey,
useUpstream429BreakerHints:
draft.apikey.useUpstream429BreakerHints === undefined
? (null as unknown as boolean | undefined)
: draft.apikey.useUpstream429BreakerHints,
},
};
await onSave(payload as typeof draft);
setEditing(false);
}}
/>

View File

@@ -15,6 +15,8 @@ export default function RoutingTab() {
alwaysPreserveClientCache: "auto",
antigravitySignatureCacheMode: "enabled",
cliCompatProviders: [],
autoRoutingEnabled: true,
autoRoutingDefaultVariant: "lkgp",
});
const [loading, setLoading] = useState(true);
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
@@ -395,6 +397,81 @@ export default function RoutingTab() {
))}
</div>
</Card>
<Card>
<div className="flex items-start justify-between gap-4">
<div className="flex gap-3">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500 h-fit">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
auto_awesome
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Zero-Config Auto-Routing</h3>
<p className="text-sm text-text-muted mt-1">
Enable automatic provider selection using the auto/ prefix. When enabled, requests
to auto, auto/coding, auto/fast, etc. will dynamically route across all connected
providers.
</p>
</div>
</div>
<div className="pt-1">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={settings.autoRoutingEnabled !== false}
onChange={(e) => updateSetting({ autoRoutingEnabled: e.target.checked })}
disabled={loading}
/>
<div className="w-11 h-6 bg-border peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border/30">
<label className="block text-sm font-medium mb-2">Default Auto Variant</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{[
{ value: "lkgp", label: "LKGP", desc: "Last Known Good Provider" },
{ value: "coding", label: "Coding", desc: "Quality-first for code" },
{ value: "fast", label: "Fast", desc: "Low-latency routing" },
{ value: "cheap", label: "Cheap", desc: "Cost-optimized" },
{ value: "offline", label: "Offline", desc: "High availability" },
{ value: "smart", label: "Smart", desc: "Best discovery (10% explore)" },
].map((option) => (
<button
key={option.value}
onClick={() => updateSetting({ autoRoutingDefaultVariant: option.value })}
disabled={loading}
className={`p-2 rounded-lg border text-left transition-all ${
settings.autoRoutingDefaultVariant === option.value
? "border-indigo-500/50 bg-indigo-500/5 ring-1 ring-indigo-500/20"
: "border-border/50 hover:border-border hover:bg-surface/30"
}`}
>
<div className="flex items-center gap-1">
<span
className={`material-symbols-outlined text-[14px] ${
settings.autoRoutingDefaultVariant === option.value
? "text-indigo-400"
: "text-text-muted"
}`}
>
{settings.autoRoutingDefaultVariant === option.value
? "check_circle"
: "radio_button_unchecked"}
</span>
<span
className={`text-xs font-medium ${settings.autoRoutingDefaultVariant === option.value ? "text-indigo-400" : ""}`}
>
{option.label}
</span>
</div>
</button>
))}
</div>
</div>
</Card>
</div>
);
}

View File

@@ -26,6 +26,11 @@ const QUOTA_LABEL_MAP: Record<string, string> = {
"search-prime": "Web Search",
"web-reader": "Web Reader",
zread: "Zread",
"5 Hours Quota": "5 Hours",
"Weekly Quota": "Weekly",
"Monthly Tools": "Monthly Tools",
tokens: "Tokens",
time_limit: "Time Limit",
};
const GLM_QUOTA_ORDER: Record<string, number> = {

View File

@@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import { getDbInstance } from "@/lib/db/core";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export const dynamic = "force-dynamic";
/**
* GET /api/analytics/auto-routing
* Returns auto-routing usage statistics and metrics.
*/
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const db = getDbInstance();
// Query usage_logs for auto/ prefix requests
const totalRequests = db
.prepare(
`
SELECT COUNT(*) as count
FROM usage_logs
WHERE model = 'auto' OR model LIKE 'auto/%'
`
)
.get() as { count: number };
// Variant breakdown
const variantRows = db
.prepare(
`
SELECT
CASE
WHEN model = 'auto' THEN 'default'
WHEN model LIKE 'auto/%' THEN SUBSTR(model, 6)
ELSE 'other'
END as variant,
COUNT(*) as count
FROM usage_logs
WHERE model = 'auto' OR model LIKE 'auto/%'
GROUP BY variant
ORDER BY count DESC
`
)
.all() as Array<{ variant: string; count: number }>;
const variantBreakdown: Record<string, number> = {};
variantRows.forEach((row) => {
variantBreakdown[row.variant] = row.count;
});
// Top providers (from LKGP cache or usage logs)
const topProviders = db
.prepare(
`
SELECT provider, COUNT(*) as count
FROM usage_logs
WHERE model = 'auto' OR model LIKE 'auto/%'
GROUP BY provider
ORDER BY count DESC
LIMIT 10
`
)
.all() as Array<{ provider: string; count: number }>;
return NextResponse.json({
totalRequests: totalRequests.count,
variantBreakdown,
topProviders,
});
} catch (error) {
console.error("Auto-routing analytics error:", error);
return NextResponse.json({
totalRequests: 0,
variantBreakdown: {},
topProviders: [],
});
}
}

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { deleteMemory, getMemory } from "@/lib/memory/store";
export async function DELETE(request: Request, props: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await props.params;
const success = await deleteMemory(id);
@@ -16,6 +20,9 @@ export async function DELETE(request: Request, props: { params: Promise<{ id: st
}
export async function GET(request: Request, props: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await props.params;
const memory = await getMemory(id);

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { listMemories, createMemory } from "@/lib/memory/store";
import { MemoryType } from "@/lib/memory/types";
import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination";
@@ -16,6 +17,9 @@ const createMemorySchema = z.object({
});
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const url = new URL(request.url);
const { searchParams } = url;
@@ -68,6 +72,9 @@ export async function GET(request: Request) {
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const rawBody = await request.json();
const validation = validateBody(createMemorySchema, rawBody);

View File

@@ -6,6 +6,7 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import {
updateModelComboMapping,
deleteModelComboMapping,
@@ -21,7 +22,10 @@ const updateMappingSchema = z.object({
description: z.string().max(1000).optional(),
});
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
const mapping = await getModelComboMappingById(id);
@@ -35,6 +39,9 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
}
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
const rawBody = await request.json();
@@ -58,7 +65,10 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
}
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
const deleted = await deleteModelComboMapping(id);

View File

@@ -6,6 +6,7 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getModelComboMappings, createModelComboMapping } from "@/lib/localDb";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
@@ -17,7 +18,10 @@ const createMappingSchema = z.object({
description: z.string().max(1000).optional().default(""),
});
export async function GET() {
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const mappings = await getModelComboMappings();
return NextResponse.json({ mappings });
@@ -30,6 +34,9 @@ export async function GET() {
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const rawBody = await request.json();
const validation = validateBody(createMappingSchema, rawBody);

View File

@@ -58,22 +58,33 @@ export async function POST(request: Request) {
cursorService.validateImportToken(accessToken.trim(), machineId?.trim())
);
// Try to extract user info from token
const userInfo = cursorService.extractUserInfo(tokenData.accessToken);
// Try to extract user info from token (JWT decode, no API call)
const jwtInfo = cursorService.extractUserInfo(tokenData.accessToken);
// Save to database
// Best-effort fetch real profile (email + name) from cursor.com using the
// same WorkOS session cookie format we use for usage limits.
const profile = jwtInfo?.userId
? await runWithProxyContext(proxy, () =>
cursorService.fetchUserInfo(tokenData.accessToken, jwtInfo.userId)
)
: null;
const email = profile?.email || jwtInfo?.email || null;
// Save to database (no `name` — let the dashboard fall back to email so the
// privacy mask toggle applies, matching the codex/claude rendering).
const connection: any = await createProviderConnection({
provider: "cursor",
authType: "oauth",
accessToken: tokenData.accessToken,
refreshToken: null, // Cursor doesn't have public refresh endpoint
expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(),
email: userInfo?.email || null,
email,
providerSpecificData: {
machineId: tokenData.machineId,
authMethod: "imported",
provider: "Imported",
userId: userInfo?.userId,
userId: jwtInfo?.userId,
},
testStatus: "active",
});

View File

@@ -1,14 +1,22 @@
import { NextResponse } from "next/server";
import { readFile, readdir } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { createProviderConnection, isCloudEnabled, resolveProxyForProvider } from "@/models";
import { syncToCloud } from "@/lib/cloudSync";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { KiroService } from "@/lib/oauth/services/kiro";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
/**
* GET /api/oauth/kiro/auto-import
* Auto-detect and extract Kiro refresh token from AWS SSO cache.
*
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-5).
* Auto-import Kiro credentials from kiro-cli's SQLite database.
* Supports both personal Builder ID and enterprise SSO (IDC/profileArn).
*
* Falls back to ~/.aws/sso/cache if kiro-cli SQLite is not found.
*
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key.
*/
export async function GET(request: Request) {
if (await isAuthRequired(request)) {
@@ -17,79 +25,249 @@ export async function GET(request: Request) {
}
}
const { searchParams } = new URL(request.url);
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";
// Try kiro-cli SQLite first
const sqliteResult = await tryKiroCliSqlite();
if (sqliteResult.found) {
return await saveAndRespond(sqliteResult, targetProvider, request);
}
// Fall back to ~/.aws/sso/cache (social auth / manual token)
const cacheResult = await tryAwsSsoCache(targetProvider);
if (cacheResult.found) {
return await saveAndRespond(cacheResult, targetProvider, request);
}
return NextResponse.json({
found: false,
error:
"Kiro credentials not found. " +
"Run `kiro-cli login --use-device-flow` then retry, " +
"or use the Import Token option in the dashboard.",
triedPaths: [sqliteResult.triedPath, cacheResult.triedPath].filter(Boolean),
});
}
// ── kiro-cli SQLite reader ────────────────────────────────────────────────────
async function tryKiroCliSqlite(): Promise<{
found: boolean;
triedPath?: string;
refreshToken?: string;
accessToken?: string;
expiresAt?: string;
clientId?: string;
clientSecret?: string;
region?: string;
profileArn?: string;
source?: string;
}> {
const dbPath = join(homedir(), ".local/share/kiro-cli/data.sqlite3");
let Database: any;
try {
const { searchParams } = new URL(request.url);
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";
const providerLabel = targetProvider === "amazon-q" ? "Amazon Q" : "Kiro";
const cachePath = join(homedir(), ".aws/sso/cache");
Database = (await import("better-sqlite3")).default;
} catch {
return { found: false, triedPath: dbPath };
}
// Try to read cache directory
let files;
try {
files = await readdir(cachePath);
} catch (error) {
return NextResponse.json({
found: false,
error: `AWS SSO cache not found. Please login to ${providerLabel} first.`,
});
}
// Look for kiro-auth-token.json or any .json file with refreshToken
let refreshToken = null;
let foundFile = null;
// First try kiro-auth-token.json
const preferredTokenFile =
targetProvider === "amazon-q" ? "amazon-q-auth-token.json" : "kiro-auth-token.json";
if (files.includes(preferredTokenFile)) {
try {
const content = await readFile(join(cachePath, preferredTokenFile), "utf-8");
const data = JSON.parse(content);
if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) {
refreshToken = data.refreshToken;
foundFile = preferredTokenFile;
}
} catch (error) {
// Continue to search other files
}
}
// If not found, search all .json files
if (!refreshToken) {
for (const file of files) {
if (!file.endsWith(".json")) continue;
let db: any;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
} catch {
return { found: false, triedPath: dbPath };
}
try {
// Read OIDC token (access + refresh token)
const tokenKeys = ["kirocli:odic:token", "kirocli:oidc:token"];
let tokenData: any = null;
for (const key of tokenKeys) {
const row = db.prepare("SELECT value FROM auth_kv WHERE key = ?").get(key) as
| { value: string }
| undefined;
if (row?.value) {
try {
const content = await readFile(join(cachePath, file), "utf-8");
const data = JSON.parse(content);
// Look for Kiro refresh token (starts with aorAAAAAG)
if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) {
refreshToken = data.refreshToken;
foundFile = file;
break;
}
} catch (error) {
// Skip invalid JSON files
continue;
tokenData = JSON.parse(row.value);
break;
} catch {
// continue
}
}
}
if (!refreshToken) {
return NextResponse.json({
found: false,
error: `${providerLabel} token not found in AWS SSO cache. Please login to ${providerLabel} first.`,
});
if (!tokenData?.refresh_token) {
return { found: false, triedPath: dbPath };
}
// Read device registration (client_id + client_secret)
const regKeys = ["kirocli:odic:device-registration", "kirocli:oidc:device-registration"];
let regData: any = null;
for (const key of regKeys) {
const row = db.prepare("SELECT value FROM auth_kv WHERE key = ?").get(key) as
| { value: string }
| undefined;
if (row?.value) {
try {
regData = JSON.parse(row.value);
break;
} catch {
// continue
}
}
}
// Read profileArn from state table (enterprise SSO / IDC)
let profileArn: string | undefined;
try {
const profileRow = db
.prepare("SELECT value FROM state WHERE key = 'api.codewhisperer.profile'")
.get() as { value: string } | undefined;
if (profileRow?.value) {
const profileData = JSON.parse(profileRow.value);
profileArn = profileData.arn || profileData.profileArn;
}
} catch {
// state table may not exist for personal Builder ID accounts
}
const region = tokenData.region || regData?.region || "us-east-1";
const expiresAt = tokenData.expires_at
? new Date(tokenData.expires_at).toISOString()
: new Date(Date.now() + 3600 * 1000).toISOString();
return {
found: true,
source: "kiro-cli-sqlite",
refreshToken: tokenData.refresh_token,
accessToken: tokenData.access_token,
expiresAt,
clientId: regData?.client_id,
clientSecret: regData?.client_secret,
region,
profileArn,
};
} finally {
db.close();
}
}
// ── ~/.aws/sso/cache fallback ─────────────────────────────────────────────────
async function tryAwsSsoCache(targetProvider: string): Promise<{
found: boolean;
triedPath?: string;
refreshToken?: string;
source?: string;
}> {
const { readFile, readdir } = await import("fs/promises");
const cachePath = join(homedir(), ".aws/sso/cache");
const preferredFile =
targetProvider === "amazon-q" ? "amazon-q-auth-token.json" : "kiro-auth-token.json";
let files: string[];
try {
files = await readdir(cachePath);
} catch {
return { found: false, triedPath: cachePath };
}
// Try preferred file first, then scan all
const ordered = [
preferredFile,
...files.filter((f) => f !== preferredFile && f.endsWith(".json")),
];
for (const file of ordered) {
try {
const content = await readFile(join(cachePath, file), "utf-8");
const data = JSON.parse(content);
if (data.refreshToken?.startsWith("aorAAAAAG")) {
return { found: true, refreshToken: data.refreshToken, source: file };
}
} catch {
// skip
}
}
return { found: false, triedPath: cachePath };
}
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────
async function saveAndRespond(
result: Awaited<ReturnType<typeof tryKiroCliSqlite>>,
targetProvider: string,
request: Request
) {
try {
const kiroService = new KiroService();
const proxy = await resolveProxyForProvider(targetProvider);
// If we have a refresh token but no valid access token, refresh now
let accessToken = result.accessToken;
let refreshToken = result.refreshToken!;
let expiresAt = result.expiresAt;
let profileArn = result.profileArn;
const providerSpecificData: Record<string, any> = {
authMethod: result.source === "kiro-cli-sqlite" ? "kiro-cli" : "imported",
provider: result.source === "kiro-cli-sqlite" ? "kiro-cli SQLite" : "AWS SSO Cache",
};
if (result.clientId) providerSpecificData.clientId = result.clientId;
if (result.clientSecret) providerSpecificData.clientSecret = result.clientSecret;
if (result.region) providerSpecificData.region = result.region;
if (profileArn) providerSpecificData.profileArn = profileArn;
// Refresh token to get a fresh access token and confirm it works
const refreshed = await runWithProxyContext(proxy, () =>
kiroService.refreshToken(refreshToken, providerSpecificData)
);
accessToken = refreshed.accessToken;
refreshToken = refreshed.refreshToken || refreshToken;
expiresAt = new Date(Date.now() + (refreshed.expiresIn || 3600) * 1000).toISOString();
// profileArn may come back from social auth refresh
if (refreshed.profileArn && !profileArn) {
profileArn = refreshed.profileArn;
providerSpecificData.profileArn = profileArn;
}
const email = kiroService.extractEmailFromJWT(accessToken);
await createProviderConnection({
provider: targetProvider,
authType: "oauth",
accessToken,
refreshToken,
expiresAt,
email: email || null,
providerSpecificData,
testStatus: "active",
} as any);
if (isCloudEnabled()) {
const machineId = await getConsistentMachineId();
await syncToCloud(machineId).catch(() => {});
}
return NextResponse.json({
found: true,
refreshToken,
source: foundFile,
source: result.source,
email: email || null,
profileArn: profileArn || null,
region: result.region || null,
message: "Kiro credentials imported successfully.",
});
} catch (error) {
console.log("Kiro auto-import error:", error);
return NextResponse.json({ found: false, error: error.message }, { status: 500 });
} catch (error: any) {
console.error("[kiro auto-import] save error:", error);
return NextResponse.json(
{ found: false, error: `Import failed: ${error.message}` },
{ status: 500 }
);
}
}

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import {
getPricing,
getPricingWithSources,
@@ -14,6 +15,9 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
* Get current pricing configuration (merged user + defaults)
*/
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const includeSources = new URL(request.url).searchParams.get("includeSources") === "1";
if (includeSources) {
@@ -34,6 +38,9 @@ export async function GET(request: Request) {
* Body: { provider: { model: { input: number, output: number, cached: number, ... } } }
*/
export async function PATCH(request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody;
try {
rawBody = await request.json();
@@ -70,6 +77,9 @@ export async function PATCH(request) {
* Query params: ?provider=xxx&model=yyy (optional)
*/
export async function DELETE(request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider");

View File

@@ -7,10 +7,14 @@
*/
import { NextRequest, NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { pricingSyncRequestSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function POST(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
@@ -43,7 +47,10 @@ export async function POST(request: NextRequest) {
}
}
export async function GET() {
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { getSyncStatus } = await import("@/lib/pricingSync");
return NextResponse.json(getSyncStatus());
@@ -53,7 +60,10 @@ export async function GET() {
}
}
export async function DELETE() {
export async function DELETE(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { clearSyncedPricing } = await import("@/lib/pricingSync");
clearSyncedPricing();

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { validateClaudeCodeCompatibleProvider } from "@/lib/providers/validation";
import {
@@ -41,6 +42,9 @@ function sanitizeAuditBaseUrl(baseUrl: string) {
// POST /api/provider-nodes/validate - Validate API key against base URL
export async function POST(request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const auditContext = getAuditRequestContext(request);
let rawBody;
try {

View File

@@ -854,8 +854,10 @@ export async function GET(
return localCatalog.map((model) => ({
id: model.id,
name: model.name || model.id,
...(model.apiFormat ? { apiFormat: model.apiFormat } : {}),
...(model.supportedEndpoints ? { supportedEndpoints: model.supportedEndpoints } : {}),
...((model as any).apiFormat ? { apiFormat: (model as any).apiFormat } : {}),
...((model as any).supportedEndpoints
? { supportedEndpoints: (model as any).supportedEndpoints }
: {}),
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
}));
};
@@ -902,7 +904,7 @@ export async function GET(
}
) => {
const status = getSafeOutboundFetchErrorStatus(error);
if (status === 400) return null;
if (status === 400 || status === 503 || status === 504) return null;
return buildDiscoveryFallbackResponse(warnings);
};
@@ -1852,8 +1854,10 @@ export async function GET(
models: localCatalog.map((m) => ({
id: m.id,
name: m.name || m.id,
...(m.apiFormat ? { apiFormat: m.apiFormat } : {}),
...(m.supportedEndpoints ? { supportedEndpoints: m.supportedEndpoints } : {}),
...((m as any).apiFormat ? { apiFormat: (m as any).apiFormat } : {}),
...((m as any).supportedEndpoints
? { supportedEndpoints: (m as any).supportedEndpoints }
: {}),
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
})),
source: "local_catalog",

View File

@@ -23,6 +23,7 @@ import {
isClaudeExtraUsageBlockEnabled,
} from "@/lib/providers/claudeExtraUsage";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
function normalizeCodexLimitPolicy(
incoming: unknown,
@@ -61,9 +62,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
// Hide sensitive fields
const revealKeys = isApiKeyRevealEnabled();
// Hide or mask sensitive fields
const result: Record<string, any> = { ...connection };
delete result.apiKey;
if (!revealKeys) {
result.apiKey = result.apiKey ? maskStoredApiKey(result.apiKey) : undefined;
}
delete result.accessToken;
delete result.refreshToken;
delete result.idToken;

View File

@@ -27,6 +27,7 @@ import {
} from "@/lib/providers/requestDefaults";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isManagedProviderConnectionId } from "@/lib/providers/catalog";
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
// GET /api/providers - List all connections
export async function GET(request: Request) {
@@ -35,11 +36,12 @@ export async function GET(request: Request) {
try {
const connections = await getProviderConnections();
const revealKeys = isApiKeyRevealEnabled();
// Hide sensitive fields
// Hide or mask sensitive fields
const safeConnections = connections.map((c) => ({
...c,
apiKey: undefined,
apiKey: revealKeys ? c.apiKey : c.apiKey ? maskStoredApiKey(c.apiKey) : undefined,
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { getProviderNodeById } from "@/models";
import {
@@ -24,6 +25,9 @@ function sanitizeAuditUrl(url: string | null | undefined) {
// POST /api/providers/validate - Validate API key with provider
export async function POST(request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const auditContext = getAuditRequestContext(request);
let rawBody;
try {

View File

@@ -9,6 +9,7 @@ import {
} from "@/lib/resilience/settings";
import { updateResilienceSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { resetAllCircuitBreakers } from "@/shared/utils/circuitBreaker";
type JsonRecord = Record<string, unknown>;
@@ -196,6 +197,20 @@ export async function PATCH(request) {
});
await syncRuntimeSettings(nextResilience);
// Issue #2100 follow-up: detect transitions in useUpstream429BreakerHints
// and reset breakers so the registry stops serving cached options.
// Compared on STORED override transition (boolean | undefined) so that
// `null` (PATCH input) → undefined (stored) is correctly detected as
// "unset request" when the previous stored value was a boolean.
const breakerHintsChanged =
currentResilience.connectionCooldown.oauth.useUpstream429BreakerHints !==
nextResilience.connectionCooldown.oauth.useUpstream429BreakerHints ||
currentResilience.connectionCooldown.apikey.useUpstream429BreakerHints !==
nextResilience.connectionCooldown.apikey.useUpstream429BreakerHints;
if (breakerHintsChanged) {
resetAllCircuitBreakers();
}
return NextResponse.json({
ok: true,
requestQueue: nextResilience.requestQueue,

View File

@@ -21,6 +21,11 @@ export async function GET(request: Request) {
}
try {
const url = new URL(request.url);
// Telemetry/history tables grow indefinitely and inflate backups.
// Exclude them by default — opt-in with ?includeHistory=true (#2125).
const includeHistory = url.searchParams.get("includeHistory") === "true";
const rawSettings = await getSettings();
// REDACT sensitive security keys to maintain Zero-Trust posture
@@ -33,27 +38,30 @@ export async function GET(request: Request) {
const combos = await getCombos();
const apiKeys = await getApiKeys();
const db = getDbInstance();
const usageHistory = db.prepare("SELECT * FROM usage_history").all();
const domainCostHistory = db.prepare("SELECT * FROM domain_cost_history").all();
const domainBudgets = db.prepare("SELECT * FROM domain_budgets").all();
const exportData = {
const exportData: Record<string, unknown> = {
settings: safeSettings,
providerConnections,
providerNodes,
combos,
apiKeys,
usageHistory,
domainCostHistory,
domainBudgets,
// Metadata to identify export version
_meta: {
exportedAt: new Date().toISOString(),
version: "omniroute-v3-legacy-export",
includesHistory: includeHistory,
},
};
// Only include telemetry/history tables when explicitly requested.
// These tables (usage_history, domain_cost_history, domain_budgets) can contain
// thousands of rows and make the config backup grow to many MBs.
if (includeHistory) {
const db = getDbInstance();
exportData.usageHistory = db.prepare("SELECT * FROM usage_history").all();
exportData.domainCostHistory = db.prepare("SELECT * FROM domain_cost_history").all();
exportData.domainBudgets = db.prepare("SELECT * FROM domain_budgets").all();
}
return new NextResponse(JSON.stringify(exportData, null, 2), {
status: 200,
headers: {

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getApiKeys } from "@/lib/db/apiKeys";
import { getDbInstance } from "@/lib/db/core";
@@ -261,6 +262,9 @@ function computeActivityStreak(activityMap: Record<string, number>): number {
}
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const range = searchParams.get("range") || "30d";
@@ -555,6 +559,7 @@ export async function GET(request: Request) {
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
LOWER(provider) as provider,
LOWER(model) as model,
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
@@ -563,6 +568,7 @@ export async function GET(request: Request) {
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM usage_history
${whereClause}
GROUP BY serviceTier, LOWER(provider), LOWER(model)
`

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getUsageStats } from "@/lib/usageDb";
export async function GET() {
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const stats = await getUsageStats();
return NextResponse.json(stats);

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getRecentLogs } from "@/lib/usageDb";
export async function GET() {
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const logs = await getRecentLogs(200);
return NextResponse.json(logs);

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getRecentLogs } from "@/lib/usageDb";
export async function GET() {
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const logs = await getRecentLogs(200);
return NextResponse.json(logs);

View File

@@ -0,0 +1,186 @@
import { NextRequest, NextResponse } from "next/server";
import { extractApiKey } from "@/sse/services/auth";
import { getAgent } from "@/lib/cloudAgent/registry";
import { getCloudAgentTaskById, updateCloudAgentTask } from "@/lib/cloudAgent/db";
import { z } from "zod";
import pino from "pino";
const logger = pino({ name: "cloud-agents-api" });
function getCorsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
export async function OPTIONS() {
return new NextResponse(null, { headers: getCorsHeaders() });
}
const ApproveSchema = z.object({
action: z.literal("approve"),
});
const MessageSchema = z.object({
action: z.literal("message"),
message: z.string().min(1),
});
const CancelSchema = z.object({
action: z.literal("cancel"),
});
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const task = getCloudAgentTaskById(id);
if (!task) {
return NextResponse.json(
{ error: "Task not found" },
{ status: 404, headers: getCorsHeaders() }
);
}
const apiKey = extractApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "API key required" },
{ status: 401, headers: getCorsHeaders() }
);
}
const agent = getAgent(task.provider_id);
if (agent && task.external_id) {
try {
const statusResult = await agent.getStatus(task.external_id, { apiKey });
updateCloudAgentTask(id, {
status: statusResult.status,
result: statusResult.result ? JSON.stringify(statusResult.result) : null,
activities: JSON.stringify(statusResult.activities),
error: statusResult.error || null,
completed_at:
statusResult.status === "completed" || statusResult.status === "failed"
? new Date().toISOString()
: null,
});
} catch (err) {
console.error("Failed to sync task status:", err);
}
}
const updatedTask = getCloudAgentTaskById(id);
return NextResponse.json(
{
data: {
id: updatedTask!.id,
providerId: updatedTask!.provider_id,
externalId: updatedTask!.external_id,
status: updatedTask!.status,
prompt: updatedTask!.prompt,
source: JSON.parse(updatedTask!.source),
options: JSON.parse(updatedTask!.options),
result: updatedTask!.result ? JSON.parse(updatedTask!.result) : null,
activities: JSON.parse(updatedTask!.activities),
error: updatedTask!.error,
createdAt: updatedTask!.created_at,
updatedAt: updatedTask!.updated_at,
completedAt: updatedTask!.completed_at,
},
},
{ headers: getCorsHeaders() }
);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const task = getCloudAgentTaskById(id);
if (!task) {
return NextResponse.json(
{ error: "Task not found" },
{ status: 404, headers: getCorsHeaders() }
);
}
const apiKey = extractApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "API key required" },
{ status: 401, headers: getCorsHeaders() }
);
}
let validated;
if (body.action === "approve") {
validated = ApproveSchema.parse(body);
} else if (body.action === "message") {
validated = MessageSchema.parse(body);
} else if (body.action === "cancel") {
validated = CancelSchema.parse(body);
} else {
return NextResponse.json(
{ error: "Invalid action" },
{ status: 400, headers: getCorsHeaders() }
);
}
const agent = getAgent(task.provider_id);
if (!agent) {
return NextResponse.json(
{ error: "Agent not found" },
{ status: 500, headers: getCorsHeaders() }
);
}
if (validated.action === "approve") {
if (!task.external_id) {
return NextResponse.json(
{ error: "No external task to approve" },
{ status: 400, headers: getCorsHeaders() }
);
}
await agent.approvePlan(task.external_id, { apiKey });
updateCloudAgentTask(id, { status: "running" });
} else if (validated.action === "message") {
if (!task.external_id) {
return NextResponse.json(
{ error: "No external task to message" },
{ status: 400, headers: getCorsHeaders() }
);
}
const activity = await agent.sendMessage(task.external_id, validated.message, { apiKey });
const activities = JSON.parse(task.activities);
activities.push(activity);
updateCloudAgentTask(id, { activities: JSON.stringify(activities) });
} else if (validated.action === "cancel") {
updateCloudAgentTask(id, { status: "cancelled" });
}
return NextResponse.json({ success: true }, { headers: getCorsHeaders() });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.errors },
{ status: 400, headers: getCorsHeaders() }
);
}
logger.error({ err: error }, "Failed to process task action");
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from "next/server";
import { extractApiKey } from "@/sse/services/auth";
import { getAgent } from "@/lib/cloudAgent/registry";
import {
insertCloudAgentTask,
getCloudAgentTaskById,
getAllCloudAgentTasks,
getCloudAgentTasksByProvider,
getCloudAgentTasksByStatus,
updateCloudAgentTask,
deleteCloudAgentTask,
} from "@/lib/cloudAgent/db";
import { CreateCloudAgentTaskSchema } from "@/lib/cloudAgent/types";
import { CLOUD_AGENT_PROVIDERS } from "@/shared/constants/providers";
import { z } from "zod";
import pino from "pino";
const logger = pino({ name: "cloud-agents-api" });
function getCorsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
export async function OPTIONS() {
return new NextResponse(null, { headers: getCorsHeaders() });
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const providerId = searchParams.get("provider");
const status = searchParams.get("status");
const limit = parseInt(searchParams.get("limit") || "50", 10);
let tasks;
if (providerId) {
tasks = getCloudAgentTasksByProvider(providerId, limit);
} else if (status) {
tasks = getCloudAgentTasksByStatus(status, limit);
} else {
tasks = getAllCloudAgentTasks(limit);
}
return NextResponse.json(
{
data: tasks.map((t) => ({
id: t.id,
providerId: t.provider_id,
externalId: t.external_id,
status: t.status,
prompt: t.prompt,
source: JSON.parse(t.source),
options: JSON.parse(t.options),
result: t.result ? JSON.parse(t.result) : null,
activities: JSON.parse(t.activities),
error: t.error,
createdAt: t.created_at,
updatedAt: t.updated_at,
completedAt: t.completed_at,
})),
},
{ headers: getCorsHeaders() }
);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validated = CreateCloudAgentTaskSchema.parse(body);
const apiKey = extractApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "API key required" },
{ status: 401, headers: getCorsHeaders() }
);
}
const agent = getAgent(validated.providerId);
if (!agent) {
return NextResponse.json(
{ error: `Unknown provider: ${validated.providerId}` },
{ status: 400, headers: getCorsHeaders() }
);
}
const task = await agent.createTask(
{
prompt: validated.prompt,
source: validated.source,
options: validated.options || {},
},
{ apiKey }
);
insertCloudAgentTask({
id: task.id,
provider_id: task.providerId,
external_id: task.externalId || null,
status: task.status,
prompt: task.prompt,
source: JSON.stringify(task.source),
options: JSON.stringify(task.options),
result: null,
activities: JSON.stringify(task.activities),
error: null,
created_at: task.createdAt,
updated_at: task.updatedAt,
completed_at: null,
});
return NextResponse.json(
{
data: {
id: task.id,
providerId: task.providerId,
externalId: task.externalId,
status: task.status,
prompt: task.prompt,
source: task.source,
options: task.options,
createdAt: task.createdAt,
},
},
{ status: 201, headers: getCorsHeaders() }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.errors },
{ status: 400, headers: getCorsHeaders() }
);
}
logger.error({ err: error }, "Failed to create cloud agent task");
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const taskId = searchParams.get("id");
if (!taskId) {
return NextResponse.json(
{ error: "Task ID required" },
{ status: 400, headers: getCorsHeaders() }
);
}
deleteCloudAgentTask(taskId);
return NextResponse.json({ success: true }, { headers: getCorsHeaders() });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -158,6 +158,7 @@
"noLockouts": "No Lockouts",
"webSearchDesc": "Web Search Desc",
"audioProvidersHeading": "Audio Providers Heading",
"cloudAgentProviders": "Cloud Agent Providers",
"minutesAgo": "Minutes Ago",
"a": "A",
"liveAutoRefreshing": "Live Auto Refreshing",
@@ -668,6 +669,7 @@
"playground": "Playground",
"searchTools": "Search Tools",
"agents": "Agents",
"cloudAgents": "Cloud Agents",
"memory": "Memory",
"skills": "Skills",
"docs": "Docs",
@@ -4851,6 +4853,42 @@
"settingsRoutingLink": "Settings/Routing",
"openSettings": "Settings"
},
"cloudAgents": {
"title": "Cloud Agents",
"description": "Manage autonomous coding agents (Jules, Devin, Codex Cloud)",
"loading": "Loading tasks...",
"aboutTitle": "About Cloud Agents",
"aboutDescription": "Cloud agents are remote AI coding assistants that can execute tasks autonomously. They work differently from local CLI agents - you interact with them through OmniRoute's API.",
"howItWorksTitle": "How it works:",
"howItWorksDesc": "Create a task → Agent analyzes and proposes a plan → You approve → Agent executes → Results returned",
"newTaskTitle": "Create New Task",
"newTaskDescription": "Start a new task with a cloud agent",
"selectAgent": "Select Agent",
"taskDescription": "Task Description",
"taskDescriptionPlaceholder": "Describe what you want the agent to do...",
"startTask": "Start Task",
"tasks": "Tasks",
"taskDetail": "Task Detail",
"noTasks": "No tasks yet. Create one to get started.",
"untitledTask": "Untitled Task",
"created": "Created",
"conversation": "Conversation",
"result": "Result",
"error": "Error",
"planReady": "Plan Ready for Approval",
"approvePlan": "Approve Plan",
"rejectPlan": "Reject & Cancel",
"sendMessagePlaceholder": "Send a message to the agent...",
"cancel": "Cancel",
"delete": "Delete",
"selectTaskPrompt": "Select a task to view details",
"statusPending": "Pending",
"statusRunning": "Running",
"statusWaitingApproval": "Waiting Approval",
"statusCompleted": "Completed",
"statusFailed": "Failed",
"statusCancelled": "Cancelled"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",

File diff suppressed because it is too large Load Diff

View File

@@ -136,11 +136,15 @@ export async function registerNodejs(): Promise<void> {
}
try {
const [{ migrateCodexConnectionDefaultsFromLegacySettings }, { seedDefaultModelAliases }] =
await Promise.all([
import("@/lib/providers/codexConnectionDefaults"),
import("@/lib/modelAliasSeed"),
]);
const [
{ migrateCodexConnectionDefaultsFromLegacySettings },
{ startSessionAccountAffinityCleanup },
{ seedDefaultModelAliases },
] = await Promise.all([
import("@/lib/providers/codexConnectionDefaults"),
import("@/lib/db/sessionAccountAffinity"),
import("@/lib/modelAliasSeed"),
]);
let settings = await getSettings();
const passwordState = await ensurePersistentManagementPasswordHash({
logger: console,
@@ -161,6 +165,7 @@ export async function registerNodejs(): Promise<void> {
console.log(
`[STARTUP] Model alias seed: applied=${seededModelAliases.applied.length}, skipped=${seededModelAliases.skipped.length}, failed=${seededModelAliases.failed.length}`
);
startSessionAccountAffinityCleanup();
const migration = await migrateCodexConnectionDefaultsFromLegacySettings();
if (migration.migrated) {

View File

@@ -22,7 +22,22 @@ function isOpenAiCompatiblePath(pathname: string): boolean {
return OPENAI_COMPAT_PATHS.some((pattern) => pattern.test(pathname));
}
function requestWantsStreaming(req: IncomingMessage): boolean {
const accept = String(req.headers.accept || "").toLowerCase();
if (accept.includes("text/event-stream")) return true;
const pathname = (req.url || "/").split("?")[0] || "/";
return /^\/(?:v1\/)?(?:responses|chat\/completions)(?:\/|$)/.test(pathname);
}
function getProxyTimeoutMs(req: IncomingMessage): number {
if (!requestWantsStreaming(req)) return API_BRIDGE_TIMEOUTS.proxyTimeoutMs;
return Math.max(API_BRIDGE_TIMEOUTS.proxyTimeoutMs, API_BRIDGE_TIMEOUTS.serverRequestTimeoutMs);
}
function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: number): void {
const proxyTimeoutMs = getProxyTimeoutMs(req);
const targetReq = http.request(
{
hostname: "127.0.0.1",
@@ -33,9 +48,14 @@ function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort:
...req.headers,
host: `127.0.0.1:${dashboardPort}`,
},
timeout: API_BRIDGE_TIMEOUTS.proxyTimeoutMs,
timeout: proxyTimeoutMs,
},
(targetRes) => {
const contentType = String(targetRes.headers["content-type"] || "").toLowerCase();
if (contentType.includes("text/event-stream")) {
targetReq.setTimeout(0);
}
res.writeHead(targetRes.statusCode || 502, targetRes.headers);
targetRes.pipe(res);
}
@@ -48,7 +68,7 @@ function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort:
res.end(
JSON.stringify({
error: "api_bridge_timeout",
detail: `Proxy request timed out after ${API_BRIDGE_TIMEOUTS.proxyTimeoutMs}ms`,
detail: `Proxy request timed out after ${proxyTimeoutMs}ms`,
})
);
});

View File

@@ -0,0 +1,148 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class CodexCloudAgent extends CloudAgentBase {
readonly providerId = "codex-cloud";
readonly baseUrl = "https://api.openai.com/v1";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
repository_context: params.source.repoUrl,
};
if (params.source.branch) {
body.branch = params.source.branch;
}
if (params.options.environment) {
body.environment = {
setup: params.options.environment,
};
}
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.id,
status: this.mapStatus(data.status || "pending"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}`, {
headers: {
Authorization: `Bearer ${credentials.apiKey}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.status || "pending");
const activities: CloudAgentActivity[] = [];
if (data.subagents) {
for (const subagent of data.subagents) {
activities.push({
id: this.generateActivityId(),
type: "command",
content: `Subagent: ${subagent.name} - ${subagent.status}`,
timestamp: new Date().toISOString(),
});
}
}
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && (data.result || data.pr_url)) {
result = {
prUrl: data.pr_url || data.result?.pr_url,
commitMessage: data.result?.commit_message,
summary: data.result?.summary,
duration: data.elapsed_time,
};
}
return {
status,
externalId,
result,
activities,
error: data.error || data.error_message,
};
}
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
throw new Error("Codex Cloud does not support plan approval - it auto-plans");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}/followup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
_credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
return [];
}
}

View File

@@ -0,0 +1,137 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class DevinAgent extends CloudAgentBase {
readonly providerId = "devin";
readonly baseUrl = "https://api.devin.ai/v1";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
repo_url: params.source.repoUrl,
};
if (params.source.branch) {
body.branch = params.source.branch;
}
const response = await fetch(`${this.baseUrl}/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.id,
status: this.mapStatus(data.status || "created"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, {
headers: {
Authorization: `Bearer ${credentials.apiKey}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.status || "created");
const activities: CloudAgentActivity[] = (data.messages || []).map(
(msg: Record<string, unknown>) => ({
id: this.generateActivityId(),
type: "message" as const,
content: (msg.content as string) || "",
timestamp: (msg.created_at as string) || new Date().toISOString(),
})
);
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && data.output) {
result = {
prUrl: data.pr_url,
summary: data.output,
duration: data.duration,
};
}
return {
status,
externalId,
result,
activities,
error: data.error,
};
}
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
throw new Error("Devin does not support plan approval - it auto-plans");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify({ content: message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
_credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
return [];
}
}

View File

@@ -0,0 +1,170 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class JulesAgent extends CloudAgentBase {
readonly providerId = "jules";
readonly baseUrl = "https://jules.googleapis.com/v1alpha";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
source: {
repository: {
owner: params.source.repoUrl.split("/").filter(Boolean).slice(-2, -1)[0] || "",
name: params.source.repoName,
},
branch: params.source.branch || "main",
},
};
if (params.options.autoCreatePr) {
body.automationMode = "AUTO_CREATE_PR";
}
const response = await fetch(`${this.baseUrl}/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": credentials.apiKey,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.name?.split("/").pop() || taskId,
status: this.mapStatus(data.state || "pending"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, _credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, {
headers: {
"X-Goog-Api-Key": _credentials.apiKey,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.state || "pending");
const activities: CloudAgentActivity[] = (data.activities || []).map(
(act: Record<string, unknown>) => ({
id: this.generateActivityId(),
type: act.type as CloudAgentActivity["type"],
content: (act.description as string) || "",
timestamp: (act.timestamp as string) || new Date().toISOString(),
})
);
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && data.outputs) {
result = {
prUrl: data.outputs.prUrl,
commitMessage: data.outputs.commitMessage,
summary: data.outputs.summary,
};
}
return {
status,
externalId,
result,
activities,
error: data.error,
};
}
async approvePlan(externalId: string, credentials: AgentCredentials): Promise<void> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}:approvePlan`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": credentials.apiKey,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules approve plan failed: ${response.status} ${error}`);
}
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}:sendMessage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": credentials.apiKey,
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
const response = await fetch(`${this.baseUrl}/sources`, {
headers: {
"X-Goog-Api-Key": credentials.apiKey,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules list sources failed: ${response.status} ${error}`);
}
const data = await response.json();
return (data.sources || []).map((source: Record<string, unknown>) => ({
name: source.name as string,
url: `https://github.com/${source.repoOwner}/${source.repoName}`,
branch: source.defaultBranch as string | undefined,
}));
}
}

View File

@@ -0,0 +1,95 @@
import type {
CloudAgentTask,
CloudAgentStatus,
CloudAgentSource,
CloudAgentResult,
CloudAgentActivity,
} from "./types.ts";
export interface AgentCredentials {
apiKey: string;
baseUrl?: string;
}
export interface CreateTaskParams {
prompt: string;
source: CloudAgentSource;
options: {
autoCreatePr?: boolean;
planApprovalRequired?: boolean;
environment?: Record<string, string>;
};
}
export interface GetStatusResult {
status: CloudAgentStatus;
externalId?: string;
result?: CloudAgentResult;
activities: CloudAgentActivity[];
error?: string;
}
export abstract class CloudAgentBase {
abstract readonly providerId: string;
abstract readonly baseUrl: string;
abstract createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask>;
abstract getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult>;
abstract approvePlan(externalId: string, credentials: AgentCredentials): Promise<void>;
abstract sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity>;
abstract listSources(
credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]>;
protected mapStatus(status: string): CloudAgentStatus {
const statusLower = status.toLowerCase();
if (statusLower.includes("completed") || statusLower.includes("done")) {
return "completed";
}
if (statusLower.includes("failed") || statusLower.includes("error")) {
return "failed";
}
if (statusLower.includes("cancelled") || statusLower.includes("canceled")) {
return "cancelled";
}
if (
statusLower.includes("running") ||
statusLower.includes("active") ||
statusLower.includes("executing")
) {
return "running";
}
if (
statusLower.includes("pending") ||
statusLower.includes("queued") ||
statusLower.includes("waiting")
) {
return "queued";
}
if (statusLower.includes("approval") || statusLower.includes("plan")) {
return "awaiting_approval";
}
return "queued";
}
protected generateTaskId(): string {
return `task_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
protected generateActivityId(): string {
return `act_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
}

145
src/lib/cloudAgent/db.ts Normal file
View File

@@ -0,0 +1,145 @@
import { getDbInstance } from "@/lib/db/core.ts";
export interface CloudAgentTaskRow {
id: string;
provider_id: string;
external_id: string | null;
status: string;
prompt: string;
source: string;
options: string;
result: string | null;
activities: string;
error: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
}
export function createCloudAgentTaskTable(): void {
const db = getDbInstance();
db.exec(`
CREATE TABLE IF NOT EXISTS cloud_agent_tasks (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
external_id TEXT,
status TEXT NOT NULL DEFAULT 'queued',
prompt TEXT NOT NULL,
source TEXT NOT NULL,
options TEXT DEFAULT '{}',
result TEXT,
activities TEXT DEFAULT '[]',
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_provider
ON cloud_agent_tasks(provider_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_status
ON cloud_agent_tasks(status)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_created
ON cloud_agent_tasks(created_at DESC)
`);
}
export function insertCloudAgentTask(task: CloudAgentTaskRow): void {
const db = getDbInstance();
db.prepare(
`
INSERT INTO cloud_agent_tasks (
id, provider_id, external_id, status, prompt, source,
options, result, activities, error, created_at, updated_at, completed_at
) VALUES (
@id, @provider_id, @external_id, @status, @prompt, @source,
@options, @result, @activities, @error, @created_at, @updated_at, @completed_at
)
`
).run(task);
}
// Whitelist of allowed columns for update operations
const ALLOWED_UPDATE_COLUMNS = new Set([
"status",
"prompt",
"source",
"options",
"result",
"activities",
"error",
"completed_at",
]);
export function updateCloudAgentTask(
id: string,
updates: Partial<Omit<CloudAgentTaskRow, "id">>
): void {
const db = getDbInstance();
// Validate keys against whitelist to prevent SQL injection
const validUpdates: Partial<Omit<CloudAgentTaskRow, "id">> = {};
for (const [key, value] of Object.entries(updates)) {
if (ALLOWED_UPDATE_COLUMNS.has(key)) {
(validUpdates as Record<string, unknown>)[key] = value;
}
}
const fields = Object.keys(validUpdates)
.map((key) => `${key} = @${key}`)
.join(", ");
if (!fields) return; // No valid updates
db.prepare(
`
UPDATE cloud_agent_tasks
SET ${fields}, updated_at = datetime('now')
WHERE id = @id
`
).run({ id, ...validUpdates });
}
export function getCloudAgentTaskById(id: string): CloudAgentTaskRow | null {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks WHERE id = ?")
.get(id) as CloudAgentTaskRow | null;
}
export function getCloudAgentTasksByProvider(providerId: string, limit = 50): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare(
"SELECT * FROM cloud_agent_tasks WHERE provider_id = ? ORDER BY created_at DESC LIMIT ?"
)
.all(providerId, limit) as CloudAgentTaskRow[];
}
export function getCloudAgentTasksByStatus(status: string, limit = 50): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks WHERE status = ? ORDER BY created_at DESC LIMIT ?")
.all(status, limit) as CloudAgentTaskRow[];
}
export function getAllCloudAgentTasks(limit = 100): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks ORDER BY created_at DESC LIMIT ?")
.all(limit) as CloudAgentTaskRow[];
}
export function deleteCloudAgentTask(id: string): void {
const db = getDbInstance();
db.prepare("DELETE FROM cloud_agent_tasks WHERE id = ?").run(id);
}

View File

@@ -0,0 +1,8 @@
export * from "./types.ts";
export * from "./baseAgent.ts";
export * from "./registry.ts";
export * from "./db.ts";
import { createCloudAgentTaskTable } from "./db.ts";
createCloudAgentTaskTable();

View File

@@ -0,0 +1,25 @@
import type { CloudAgentBase } from "./baseAgent.ts";
import { JulesAgent } from "./agents/jules.ts";
import { DevinAgent } from "./agents/devin.ts";
import { CodexCloudAgent } from "./agents/codex.ts";
const AGENTS: Record<string, CloudAgentBase> = {
jules: new JulesAgent(),
devin: new DevinAgent(),
"codex-cloud": new CodexCloudAgent(),
};
export function getAgent(providerId: string): CloudAgentBase | null {
return AGENTS[providerId] || null;
}
export function getAvailableAgents(): string[] {
return Object.keys(AGENTS);
}
export function isCloudAgentProvider(providerId: string): boolean {
return providerId in AGENTS;
}
export { JulesAgent, DevinAgent, CodexCloudAgent };
export type { CloudAgentBase } from "./baseAgent.ts";

111
src/lib/cloudAgent/types.ts Normal file
View File

@@ -0,0 +1,111 @@
import { z } from "zod";
export const CLOUD_AGENT_STATUS = {
QUEUED: "queued",
RUNNING: "running",
AWAITING_APPROVAL: "awaiting_approval",
COMPLETED: "completed",
FAILED: "failed",
CANCELLED: "cancelled",
} as const;
export type CloudAgentStatus = (typeof CLOUD_AGENT_STATUS)[keyof typeof CLOUD_AGENT_STATUS];
export const CloudAgentStatusSchema = z.enum([
"queued",
"running",
"awaiting_approval",
"completed",
"failed",
"cancelled",
]);
export interface CloudAgentSource {
repoName: string;
repoUrl: string;
branch?: string;
}
export interface CloudAgentResult {
prUrl?: string;
prNumber?: number;
commitMessage?: string;
diffUrl?: string;
summary?: string;
duration?: number;
cost?: number;
}
export interface CloudAgentActivity {
id: string;
type: "plan" | "command" | "code_change" | "message" | "error" | "completion";
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export interface CloudAgentTask {
id: string;
providerId: "jules" | "devin" | "codex-cloud";
externalId?: string;
status: CloudAgentStatus;
prompt: string;
source: CloudAgentSource;
options: {
autoCreatePr?: boolean;
planApprovalRequired?: boolean;
environment?: Record<string, string>;
};
result?: CloudAgentResult;
activities: CloudAgentActivity[];
error?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
}
export const CloudAgentSourceSchema = z.object({
repoName: z.string().min(1),
repoUrl: z.string().url(),
branch: z.string().optional(),
});
export const CloudAgentResultSchema = z.object({
prUrl: z.string().url().optional(),
prNumber: z.number().int().positive().optional(),
commitMessage: z.string().optional(),
diffUrl: z.string().url().optional(),
summary: z.string().optional(),
duration: z.number().int().positive().optional(),
cost: z.number().positive().optional(),
});
export const CloudAgentActivitySchema = z.object({
id: z.string(),
type: z.enum(["plan", "command", "code_change", "message", "error", "completion"]),
content: z.string(),
timestamp: z.string().datetime(),
metadata: z.record(z.unknown()).optional(),
});
export const CloudAgentTaskOptionsSchema = z.object({
autoCreatePr: z.boolean().optional(),
planApprovalRequired: z.boolean().optional(),
environment: z.record(z.string()).optional(),
});
export const CreateCloudAgentTaskSchema = z.object({
providerId: z.enum(["jules", "devin", "codex-cloud"]),
prompt: z.string().min(1).max(10000),
source: CloudAgentSourceSchema,
options: CloudAgentTaskOptionsSchema.optional(),
});
export const UpdateCloudAgentTaskSchema = z.object({
id: z.string().min(1),
action: z.enum(["approve", "reject", "cancel", "message"]),
message: z.string().optional(),
});
export type CreateCloudAgentTaskInput = z.infer<typeof CreateCloudAgentTaskSchema>;
export type UpdateCloudAgentTaskInput = z.infer<typeof UpdateCloudAgentTaskSchema>;

View File

@@ -133,6 +133,12 @@ const RENAMED_MIGRATION_COMPATIBILITY = [
toVersion: "039",
toName: "compression_cache_stats",
},
{
fromVersion: "041",
fromName: "session_account_affinity",
toVersion: "050",
toName: "session_account_affinity",
},
] as const;
const LEGACY_VERSION_SLOT_MIGRATIONS = [
@@ -144,6 +150,15 @@ const LEGACY_VERSION_SLOT_MIGRATIONS = [
{ version: "033", name: "provider_connections_block_extra_usage" },
] as const;
const SUPERSEDED_DUPLICATE_MIGRATIONS = [
{
version: "041",
name: "session_account_affinity",
supersededByVersion: "050",
supersededByName: "session_account_affinity",
},
] as const;
const PHYSICAL_SCHEMA_SENTINELS = [
{ version: "028", tableName: "batches", description: "batches table" },
{ version: "024", tableName: "sync_tokens", description: "sync_tokens table" },
@@ -198,6 +213,34 @@ function getMigrationFiles(): Array<{ version: string; name: string; path: strin
.filter(Boolean) as Array<{ version: string; name: string; path: string }>;
}
function filterSupersededDuplicateMigrations(
files: Array<{ version: string; name: string; path: string }>
): Array<{ version: string; name: string; path: string }> {
return files.filter((file) => {
const superseded = SUPERSEDED_DUPLICATE_MIGRATIONS.find(
(migration) => migration.version === file.version && migration.name === file.name
);
if (!superseded) {
return true;
}
const hasReplacement = files.some(
(candidate) =>
candidate.version === superseded.supersededByVersion &&
candidate.name === superseded.supersededByName
);
if (!hasReplacement) {
return true;
}
console.warn(
`[Migration] Ignoring superseded duplicate migration ${file.version}_${file.name}; ` +
`${superseded.supersededByVersion}_${superseded.supersededByName} is the canonical slot.`
);
return false;
});
}
/**
* Get list of already-applied migration versions.
*/
@@ -286,6 +329,9 @@ function isSchemaAlreadyApplied(
case "040":
return hasColumn(db, "proxy_registry", "source");
case "041":
if (migration.name === "session_account_affinity") {
return hasTable(db, "session_account_affinity");
}
return (
hasColumn(db, "compression_analytics", "actual_prompt_tokens") &&
hasColumn(db, "compression_analytics", "actual_completion_tokens") &&
@@ -667,7 +713,7 @@ export function runMigrations(db: Database.Database, options?: { isNewDb?: boole
const isNewDb = options?.isNewDb === true;
ensureMigrationsTable(db);
const files = getMigrationFiles();
const files = filterSupersededDuplicateMigrations(getMigrationFiles());
rehomeLegacyVersionSlotMigrations(db, files);
reconcileRenumberedMigrations(db, files);
const applied = getAppliedVersions(db);
@@ -779,7 +825,7 @@ export function runMigrations(db: Database.Database, options?: { isNewDb?: boole
);
} else if (migration.version === "032") {
applyApiKeyLifecycleMigration(db);
} else if (migration.version === "041") {
} else if (migration.version === "041" && migration.name === "compression_receipts") {
applyCompressionReceiptsMigration(db);
} else if (migration.version === "042") {
applyCompressionCombosMigration(db, migration.path);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS session_account_affinity (
session_key TEXT NOT NULL,
provider TEXT NOT NULL,
connection_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
PRIMARY KEY (session_key, provider)
);
CREATE INDEX IF NOT EXISTS idx_saa_provider ON session_account_affinity(provider);
CREATE INDEX IF NOT EXISTS idx_saa_last_seen ON session_account_affinity(last_seen_at);

View File

@@ -1,7 +1,49 @@
// Stubbed functions for session account affinity (PR 1887 pending)
import { getDbInstance } from "./core";
export function getSessionAccountAffinity(sessionKey: string, provider: string): any {
return null;
export interface SessionAccountAffinity {
sessionKey: string;
provider: string;
connectionId: string;
createdAt: number;
lastSeenAt: number;
}
interface SessionAccountAffinityRow {
session_key: string;
provider: string;
connection_id: string;
created_at: number;
last_seen_at: number;
}
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
let cleanupTimer: NodeJS.Timeout | null = null;
function rowToAffinity(row: SessionAccountAffinityRow): SessionAccountAffinity {
return {
sessionKey: row.session_key,
provider: row.provider,
connectionId: row.connection_id,
createdAt: row.created_at,
lastSeenAt: row.last_seen_at,
};
}
export function getSessionAccountAffinity(
sessionKey: string,
provider: string
): SessionAccountAffinity | null {
const db = getDbInstance();
const row = db
.prepare(
`SELECT session_key, provider, connection_id, created_at, last_seen_at
FROM session_account_affinity
WHERE session_key = ? AND provider = ?`
)
.get(sessionKey, provider) as SessionAccountAffinityRow | undefined;
return row ? rowToAffinity(row) : null;
}
export function upsertSessionAccountAffinity(
@@ -9,23 +51,72 @@ export function upsertSessionAccountAffinity(
provider: string,
connectionId: string,
now: number = Date.now()
): void {}
): void {
const db = getDbInstance();
db.prepare(
`INSERT INTO session_account_affinity
(session_key, provider, connection_id, created_at, last_seen_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_key, provider) DO UPDATE SET
connection_id = excluded.connection_id,
last_seen_at = excluded.last_seen_at`
).run(sessionKey, provider, connectionId, now, now);
}
export function touchSessionAccountAffinity(
sessionKey: string,
provider: string,
now: number = Date.now()
): void {}
export function deleteSessionAccountAffinity(sessionKey: string, provider: string): void {}
export function cleanupStaleSessionAccountAffinities(
ttlMs: number = 30 * 60 * 1000,
now: number = Date.now()
): number {
return 0;
): void {
const db = getDbInstance();
db.prepare(
`UPDATE session_account_affinity
SET last_seen_at = ?
WHERE session_key = ? AND provider = ?`
).run(now, sessionKey, provider);
}
export function startSessionAccountAffinityCleanup(): void {}
export function deleteSessionAccountAffinity(sessionKey: string, provider: string): void {
const db = getDbInstance();
db.prepare("DELETE FROM session_account_affinity WHERE session_key = ? AND provider = ?").run(
sessionKey,
provider
);
}
export function stopSessionAccountAffinityCleanupForTests(): void {}
export function cleanupStaleSessionAccountAffinities(
ttlMs: number = DEFAULT_TTL_MS,
now: number = Date.now()
): number {
const db = getDbInstance();
const cutoff = now - ttlMs;
const result = db
.prepare("DELETE FROM session_account_affinity WHERE last_seen_at < ?")
.run(cutoff);
return Number(result.changes || 0);
}
export function startSessionAccountAffinityCleanup(): void {
if (cleanupTimer) return;
try {
cleanupStaleSessionAccountAffinities();
} catch (error) {
console.warn("[SESSION_AFFINITY] Startup cleanup failed:", error);
}
cleanupTimer = setInterval(() => {
try {
cleanupStaleSessionAccountAffinities();
} catch (error) {
console.warn("[SESSION_AFFINITY] Periodic cleanup failed:", error);
}
}, CLEANUP_INTERVAL_MS);
cleanupTimer.unref?.();
}
export function stopSessionAccountAffinityCleanupForTests(): void {
if (!cleanupTimer) return;
clearInterval(cleanupTimer);
cleanupTimer = null;
}

View File

@@ -271,24 +271,51 @@ export async function getPricingWithSources(): Promise<{
export async function getPricingForModel(provider: string, model: string) {
const pricing = await getPricing();
if (pricing[provider]?.[model]) return pricing[provider][model];
const { PROVIDER_ID_TO_ALIAS } = await import("@omniroute/open-sse/config/providerModels");
// Check if provider is an ID -> map to ALIAS
const alias = PROVIDER_ID_TO_ALIAS[provider];
if (alias && pricing[alias]) return pricing[alias][model] || null;
const findKeyInsensitive = (obj: Record<string, any> | undefined | null, key: string) => {
if (!obj || !key) return undefined;
const lowerKey = key.toLowerCase();
for (const [k, v] of Object.entries(obj)) {
if (k.toLowerCase() === lowerKey) return v;
}
return undefined;
};
// Check if provider is an ALIAS -> map to ID (search values)
for (const [id, mappedAlias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (mappedAlias === provider && pricing[id]?.[model]) {
return pricing[id][model];
const pLower = (provider || "").toLowerCase();
let providerPricing = findKeyInsensitive(pricing, pLower);
if (!providerPricing) {
const alias = findKeyInsensitive(PROVIDER_ID_TO_ALIAS, pLower);
if (alias) providerPricing = findKeyInsensitive(pricing, alias);
}
if (!providerPricing) {
for (const [id, mappedAlias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (typeof mappedAlias === "string" && mappedAlias.toLowerCase() === pLower) {
providerPricing = findKeyInsensitive(pricing, id);
if (providerPricing) break;
}
}
}
const np = provider?.replace(/-cn$/, "");
if (np && np !== provider && pricing[np]) return pricing[np][model] || null;
if (!providerPricing) {
const np = pLower.replace(/-cn$/, "");
if (np && np !== pLower) {
providerPricing = findKeyInsensitive(pricing, np);
}
}
return null;
if (!providerPricing) return null;
const mLower = (model || "").toLowerCase();
let modelPricing = findKeyInsensitive(providerPricing, mLower);
if (!modelPricing) {
const hyphenModel = mLower.replace(/\./g, "-");
modelPricing = findKeyInsensitive(providerPricing, hyphenModel);
}
return modelPricing || null;
}
export async function updatePricing(pricingData: PricingByProvider) {

View File

@@ -15,7 +15,7 @@ import { getProviderNodes } from "@/lib/localDb";
type ValidatedEmbeddingBody = Record<string, unknown> & { model: string };
interface EmbeddingHandlerOptions {
export interface EmbeddingHandlerOptions {
clientRawRequest?: {
endpoint: string;
body: Record<string, unknown>;

View File

@@ -372,3 +372,13 @@ export {
} from "./db/oneproxy";
export type { OneproxyProxyRecord, OneproxyStats } from "./db/oneproxy";
export {
getSessionAccountAffinity,
upsertSessionAccountAffinity,
touchSessionAccountAffinity,
deleteSessionAccountAffinity,
cleanupStaleSessionAccountAffinities,
startSessionAccountAffinityCleanup,
stopSessionAccountAffinityCleanupForTests,
} from "./db/sessionAccountAffinity";

View File

@@ -143,8 +143,10 @@ export class CursorService {
const decoded = JSON.parse(
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()
);
const email =
typeof decoded.email === "string" && decoded.email.includes("@") ? decoded.email : null;
return {
email: decoded.email || decoded.sub,
email,
userId: decoded.sub || decoded.user_id,
};
}
@@ -155,6 +157,41 @@ export class CursorService {
return null;
}
/**
* Fetch real user profile from cursor.com using the same WorkOS-session cookie
* format that powers the dashboard. Returns null on any failure so the import
* flow can fall back to whatever it can extract from the JWT.
*/
async fetchUserInfo(
accessToken: string,
userId: string
): Promise<{ email: string | null; name: string | null; sub: string | null } | null> {
if (!accessToken || !userId) return null;
try {
const response = await fetch("https://cursor.com/api/auth/me", {
method: "GET",
redirect: "manual",
headers: {
Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`,
Origin: "https://cursor.com",
Referer: "https://cursor.com/dashboard",
Accept: "application/json",
"User-Agent": getCursorUserAgent(this.config.clientVersion),
},
});
if (!response.ok) return null;
const data = (await response.json()) as Record<string, unknown>;
return {
email: typeof data.email === "string" ? data.email : null,
name: typeof data.name === "string" ? data.name : null,
sub: typeof data.sub === "string" ? data.sub : null,
};
} catch {
return null;
}
}
/**
* Get token storage path instructions for user
*/

View File

@@ -1,6 +1,7 @@
import {
APIKEY_PROVIDERS,
AUDIO_ONLY_PROVIDERS,
CLOUD_AGENT_PROVIDERS,
FREE_PROVIDERS,
LOCAL_PROVIDERS,
OAUTH_PROVIDERS,
@@ -21,7 +22,8 @@ export type StaticProviderCatalogCategory =
| "search"
| "audio"
| "upstream-proxy"
| "apikey";
| "apikey"
| "cloud-agent";
export interface ProviderCatalogMetadata {
id: string;
@@ -133,6 +135,12 @@ export const STATIC_PROVIDER_CATALOG_GROUPS: Record<
displayAuthType: "apikey",
toggleAuthType: "apikey",
},
"cloud-agent": {
category: "cloud-agent",
providers: CLOUD_AGENT_PROVIDERS as ProviderRecord,
displayAuthType: "apikey",
toggleAuthType: "apikey",
},
};
export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCategory[] = [
@@ -143,6 +151,7 @@ export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCate
"search",
"audio",
"upstream-proxy",
"cloud-agent",
"apikey",
];

View File

@@ -14,6 +14,17 @@ export interface RequestQueueSettings {
export interface ConnectionCooldownProfileSettings {
baseCooldownMs: number;
useUpstreamRetryHints: boolean;
/**
* Issue #2100 follow-up: opt-in toggle for upstream 429 hint trust at the
* circuit-breaker cooldown layer (independent of `useUpstreamRetryHints`
* which controls retry scheduling).
*
* Stored shape is intentionally optional / `boolean | undefined`: when
* unset, the per-provider default from `providerHints.ts` applies.
* Normalize/merge MUST preserve `undefined` — do not coerce via
* `toBoolean(value, fallback)`.
*/
useUpstream429BreakerHints?: boolean;
maxBackoffSteps: number;
}
@@ -155,7 +166,26 @@ function normalizeConnectionCooldownProfile(
fallback: ConnectionCooldownProfileSettings
): ConnectionCooldownProfileSettings {
const record = asRecord(next);
return {
// useUpstream429BreakerHints uses a 3-state input contract:
// - boolean → user override, store as-is
// - null → explicit unset sentinel, drop key so the per-provider
// default in `providerHints.ts` resolves at runtime
// - omitted → leave existing fallback value unchanged (partial-merge)
// Never coerce via `toBoolean(value, fallback)` because that would
// collapse the unset state.
const hasHintsKey = Object.prototype.hasOwnProperty.call(record, "useUpstream429BreakerHints");
const rawHints = record.useUpstream429BreakerHints;
let useUpstream429BreakerHints: boolean | undefined;
if (!hasHintsKey) {
useUpstream429BreakerHints = fallback.useUpstream429BreakerHints;
} else if (rawHints === null) {
useUpstream429BreakerHints = undefined;
} else if (typeof rawHints === "boolean") {
useUpstream429BreakerHints = rawHints;
} else {
useUpstream429BreakerHints = fallback.useUpstream429BreakerHints;
}
const out: ConnectionCooldownProfileSettings = {
baseCooldownMs: toInteger(record.baseCooldownMs, fallback.baseCooldownMs, {
min: 0,
max: 24 * 60 * 60 * 1000,
@@ -166,6 +196,11 @@ function normalizeConnectionCooldownProfile(
max: 32,
}),
};
// Only attach the key when defined — preserves omission across round-trips.
if (useUpstream429BreakerHints !== undefined) {
out.useUpstream429BreakerHints = useUpstream429BreakerHints;
}
return out;
}
function normalizeLegacyConnectionCooldownProfile(

View File

@@ -47,6 +47,7 @@ interface ProviderConnectionLike {
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
"glm",
"glm-cn",
"zai",
"glmt",
"minimax",
"minimax-cn",
@@ -297,7 +298,9 @@ async function fetchLiveProviderLimitsWithOptions(
connection: ProviderConnectionLike;
usage: JsonRecord;
}> {
let connection = (await getProviderConnectionById(connectionId)) as ProviderConnectionLike | null;
let connection = (await getProviderConnectionById(
connectionId
)) as unknown as ProviderConnectionLike | null;
if (!connection) {
throw withStatus(new Error("Connection not found"), 404);
}
@@ -435,7 +438,7 @@ export async function syncAllProviderLimits(
}> {
const { source = "manual", concurrency = 5 } = options;
const connections = (
(await getProviderConnections({ isActive: true })) as ProviderConnectionLike[]
(await getProviderConnections({ isActive: true })) as unknown as ProviderConnectionLike[]
).filter(isSupportedUsageConnection);
const cacheEntries: Array<{ connectionId: string; entry: ProviderLimitsCacheEntry }> = [];
const caches: Record<string, ProviderLimitsCacheEntry> = {};

View File

@@ -53,6 +53,14 @@ export function classifyRoute(rawPath: string, method: string = "GET"): RouteCla
};
}
if (normalizedPath === "/dashboard/onboarding") {
return {
routeClass: "PUBLIC",
reason: "setup_wizard",
normalizedPath,
};
}
if (normalizedPath.startsWith("/dashboard")) {
return {
routeClass: "MANAGEMENT",

View File

@@ -28,7 +28,9 @@ export type ClassificationReason =
| "public_prefix"
| "public_readonly_prefix"
| "dashboard_prefix"
| "setup_wizard"
| "client_api_v1"
| "client_api_mcp"
| "client_api_alias"
| "client_api_codex_alias"
| "client_api_double_prefix"

View File

@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
const AUTO_ROUTING_DISMISSED_KEY = "auto-routing-banner-dismissed";
export default function AutoRoutingBanner() {
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
try {
const dismissed = localStorage.getItem(AUTO_ROUTING_DISMISSED_KEY);
if (dismissed === "true") {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsDismissed(true);
}
} catch {
// localStorage unavailable (SSR or private mode) — do nothing
}
}, []);
const handleDismiss = () => {
try {
localStorage.setItem(AUTO_ROUTING_DISMISSED_KEY, "true");
} catch {
// ignore localStorage errors (private mode, quotas)
}
setIsDismissed(true);
};
if (isDismissed) return null;
return (
<div
role="banner"
aria-label="Auto-routing mode active"
className="relative overflow-hidden rounded-lg border-l-4 border-blue-500 bg-blue-50/50 p-4 my-4 dark:bg-blue-950/30 transition-colors"
>
<div className="flex items-start gap-3">
<div className="flex items-center gap-2">
<span className="inline-flex h-2 w-2 animate-pulse rounded-full bg-blue-500" />
<span className="font-semibold text-sm text-blue-700 dark:text-blue-300">
Auto-Routing Active
</span>
</div>
<div className="text-sm leading-relaxed text-text-muted">
OmniRoute is automatically routing requests using combo-based strategies.
<span className="block sm:inline sm:ml-1">
View or change your routing configuration on the{" "}
<a
href="/dashboard/combos"
className="text-blue-600 hover:text-blue-800 underline dark:text-blue-400 dark:hover:text-blue-300"
>
Combos page
</a>
.
</span>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss auto-routing banner"
className="ml-auto flex-shrink-0 rounded-md p-1 text-text-muted hover:bg-blue-100 hover:text-blue-700 dark:hover:bg-blue-900/50 dark:hover:text-blue-300 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
);
}

View File

@@ -65,8 +65,12 @@ export default function Card({
);
}
interface CardSectionProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
// Sub-component: Bordered section inside Card
Card.Section = function CardSection({ children, className, ...props }) {
Card.Section = function CardSection({ children, className, ...props }: CardSectionProps) {
return (
<div
className={cn(
@@ -82,8 +86,12 @@ Card.Section = function CardSection({ children, className, ...props }) {
);
};
interface CardRowProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
// Sub-component: Hoverable row inside Card
Card.Row = function CardRow({ children, className, ...props }) {
Card.Row = function CardRow({ children, className, ...props }: CardRowProps) {
return (
<div
className={cn(
@@ -99,8 +107,18 @@ Card.Row = function CardRow({ children, className, ...props }) {
);
};
interface CardListItemProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
actions?: React.ReactNode;
}
// Sub-component: List item with hover actions (macOS style)
Card.ListItem = function CardListItem({ children, actions, className, ...props }) {
Card.ListItem = function CardListItem({
children,
actions,
className,
...props
}: CardListItemProps) {
return (
<div
className={cn(

View File

@@ -7,6 +7,7 @@ import Breadcrumbs from "../Breadcrumbs";
import NotificationToast from "../NotificationToast";
import MaintenanceBanner from "../MaintenanceBanner";
import { useIsElectron } from "@/shared/hooks/useElectron";
import AutoRoutingBanner from "../AutoRoutingBanner";
const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed";
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
@@ -77,6 +78,7 @@ export default function DashboardLayout({ children }) {
>
<Header onMenuClick={() => setSidebarOpen(true)} />
{!isE2EMode && <MaintenanceBanner />}
<AutoRoutingBanner />
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar p-4 sm:p-6 lg:p-10">
<div className="max-w-7xl mx-auto w-full">
<Breadcrumbs />

View File

@@ -1880,6 +1880,20 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean {
return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId);
}
// ── System Providers (virtual, not user-connectable) ──────────────────────────
export const SYSTEM_PROVIDERS = {
auto: {
id: "auto",
alias: "auto",
name: "Auto (Zero-Config)",
icon: "auto_awesome",
color: "#6366F1",
textIcon: "Auto",
systemOnly: true,
description: "Zero-config auto-routing with LKGP across all connected providers",
},
};
// All providers (combined)
export const AI_PROVIDERS = {
...FREE_PROVIDERS,
@@ -1890,6 +1904,8 @@ export const AI_PROVIDERS = {
...SEARCH_PROVIDERS,
...AUDIO_ONLY_PROVIDERS,
...UPSTREAM_PROXY_PROVIDERS,
...CLOUD_AGENT_PROVIDERS,
...SYSTEM_PROVIDERS, // <-- system providers included
};
export type AiProviderId = keyof typeof AI_PROVIDERS;
@@ -1946,9 +1962,11 @@ export const USAGE_SUPPORTED_PROVIDERS = [
"github",
"codex",
"claude",
"cursor",
"kimi-coding",
"glm",
"glm-cn",
"zai",
"glmt",
"minimax",
"minimax-cn",
@@ -1968,3 +1986,4 @@ validateProviders(LOCAL_PROVIDERS, "LOCAL_PROVIDERS");
validateProviders(SEARCH_PROVIDERS, "SEARCH_PROVIDERS");
validateProviders(AUDIO_ONLY_PROVIDERS, "AUDIO_ONLY_PROVIDERS");
validateProviders(UPSTREAM_PROXY_PROVIDERS, "UPSTREAM_PROXY_PROVIDERS");
validateProviders(CLOUD_AGENT_PROVIDERS, "CLOUD_AGENT_PROVIDERS");

View File

@@ -14,6 +14,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"limits",
"cli-tools",
"agents",
"cloud-agents",
"memory",
"skills",
"translator",
@@ -69,6 +70,7 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
const CLI_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "cli-tools", href: "/dashboard/cli-tools", i18nKey: "cliToolsShort", icon: "terminal" },
{ id: "agents", href: "/dashboard/agents", i18nKey: "agents", icon: "smart_toy" },
{ id: "cloud-agents", href: "/dashboard/cloud-agents", i18nKey: "cloudAgents", icon: "cloud" },
{ id: "memory", href: "/dashboard/memory", i18nKey: "memory", icon: "psychology" },
{ id: "skills", href: "/dashboard/skills", i18nKey: "skills", icon: "auto_fix_high" },
];

View File

@@ -19,6 +19,7 @@ import {
deleteCircuitBreakerState,
deleteAllCircuitBreakerStates,
} from "../../lib/db/domainState";
import type { FailureKind } from "./classify429";
const STATE = {
CLOSED: "CLOSED",
@@ -34,6 +35,25 @@ interface CircuitBreakerOptions {
halfOpenRequests?: number;
onStateChange?: ((name: string, oldState: string, newState: string) => void) | null;
isFailure?: (error: unknown) => boolean;
/**
* Per-failure-kind cooldown override (Issue #2100).
*
* When set, `_timeUntilReset()` and `_shouldAttemptReset()` use
* `cooldownByKind[lastFailureKind]` instead of `resetTimeout` whenever
* the last failure had a known kind. Use this to give a longer cooldown
* to `quota_exhausted` (period-end may be hours away) than to
* `rate_limit` (typically 60s).
*/
cooldownByKind?: Partial<Record<FailureKind, number>>;
/**
* Optional classifier called on `execute()` errors (Issue #2100).
* Returns the kind to record. When omitted, all failures are recorded
* as `lastFailureKind = null` (existing behavior preserved).
*
* Pair with `classify429()` from `./classify429.ts` for HTTP responses,
* or supply a custom classifier for non-HTTP errors.
*/
classifyError?: (error: unknown) => FailureKind | undefined;
}
export class CircuitBreaker {
@@ -48,6 +68,9 @@ export class CircuitBreaker {
successCount: number;
lastFailureTime: number | null;
halfOpenAllowed: number;
cooldownByKind: Partial<Record<FailureKind, number>>;
classifyError: ((error: unknown) => FailureKind | undefined) | null;
lastFailureKind: FailureKind | null;
constructor(name: string, options: CircuitBreakerOptions = {}) {
this.name = name;
@@ -62,6 +85,9 @@ export class CircuitBreaker {
this.successCount = 0;
this.lastFailureTime = null;
this.halfOpenAllowed = 0;
this.cooldownByKind = options.cooldownByKind ?? {};
this.classifyError = options.classifyError ?? null;
this.lastFailureKind = null;
// Try to restore state from DB
this._restoreFromDb();
@@ -151,7 +177,17 @@ export class CircuitBreaker {
return result;
} catch (error) {
if (this.isFailure(error)) {
this._onFailure();
let kind: FailureKind | undefined;
if (this.classifyError) {
try {
kind = this.classifyError(error);
} catch {
// A user-supplied classifier must not mask the original error
// or change failure-counting semantics; fall back to no kind.
kind = undefined;
}
}
this._onFailure(kind);
}
throw error;
}
@@ -205,6 +241,7 @@ export class CircuitBreaker {
this.failureCount = 0;
this.successCount = 0;
this.lastFailureTime = null;
this.lastFailureKind = null;
this._persistToDb();
}
@@ -218,10 +255,12 @@ export class CircuitBreaker {
this.failureCount = 0;
this.successCount = 0;
this.lastFailureTime = null;
this.lastFailureKind = null;
} else if (this.state === STATE.HALF_OPEN) {
this.successCount++;
this._transition(STATE.CLOSED);
this.failureCount = 0;
this.lastFailureKind = null;
} else {
// In CLOSED state, just reset failure count
this.failureCount = 0;
@@ -229,9 +268,10 @@ export class CircuitBreaker {
this._persistToDb();
}
_onFailure() {
_onFailure(kind?: FailureKind | null) {
this.failureCount++;
this.lastFailureTime = Date.now();
this.lastFailureKind = kind ?? null;
if (this.state === STATE.OPEN) {
// Already OPEN — just update persistence (re-tripped by combo path)
@@ -245,12 +285,31 @@ export class CircuitBreaker {
_shouldAttemptReset() {
if (!this.lastFailureTime) return true;
return Date.now() - this.lastFailureTime >= this.resetTimeout;
const cooldown = this._effectiveCooldown();
return Date.now() - this.lastFailureTime >= cooldown;
}
/**
* Resolve the cooldown for the current `lastFailureKind`. Falls back to
* `resetTimeout` when no kind was recorded, no override exists for it,
* or the override is not a finite non-negative number (NaN / Infinity /
* negative all silently fall through to `resetTimeout`).
* @private
*/
_effectiveCooldown() {
if (this.lastFailureKind !== null) {
const override = this.cooldownByKind[this.lastFailureKind];
if (typeof override === "number" && Number.isFinite(override) && override >= 0) {
return override;
}
}
return this.resetTimeout;
}
_timeUntilReset() {
if (!this.lastFailureTime) return 0;
return Math.max(0, this.resetTimeout - (Date.now() - this.lastFailureTime));
const cooldown = this._effectiveCooldown();
return Math.max(0, cooldown - (Date.now() - this.lastFailureTime));
}
_refreshOpenState() {
@@ -315,6 +374,18 @@ export function getCircuitBreaker(name: string, options?: CircuitBreakerOptions)
if (typeof options.isFailure === "function") {
breaker.isFailure = options.isFailure;
}
if (options.cooldownByKind) {
// Merge keys, don't replace: callers that add different kinds
// (e.g. one sets `quota_exhausted`, another `rate_limit`) should
// not silently lose each other's overrides.
breaker.cooldownByKind = {
...breaker.cooldownByKind,
...options.cooldownByKind,
};
}
if (typeof options.classifyError === "function") {
breaker.classifyError = options.classifyError;
}
breaker._persistToDb();
}
return breaker;

View File

@@ -0,0 +1,243 @@
/**
* 429 response classifier — distinguish rate-limit from quota-exhausted.
*
* Most LLM providers return HTTP 429 for two semantically different reasons:
*
* 1. **Rate-limit**: short transient back-off ("too many requests in
* the last minute"). Fix: wait the Retry-After window and retry.
* 2. **Quota-exhausted**: long-period cap hit ("daily/monthly limit
* reached"). Fix: wait until the period rolls over (could be hours
* or days). Retrying every 60s wastes calls and burns alerts.
*
* The HTTP status alone cannot disambiguate. This helper inspects the
* response body and headers to return a `FailureKind` the circuit
* breaker can use to pick the right cooldown.
*
* Companion to OmniRoute issue #2100.
*
* @module shared/utils/classify429
*/
export type FailureKind = "rate_limit" | "quota_exhausted" | "transient";
/**
* Heuristic regexes for "explicit quota exhausted" vs "rate-limited"
* detection in 429 error bodies. A 429 alone never implies quota
* exhausted — only an explicit keyword does.
*
* Patterns observed across OpenAI, Anthropic, Groq, Cerebras, Mistral,
* Google Gemini, and OpenRouter free-tier responses.
*/
const QUOTA_PATTERNS: ReadonlyArray<RegExp> = [
/daily.*limit/i,
/daily.*quota/i,
/per.?day.*limit/i,
/monthly.*limit/i,
/monthly.*quota/i,
/per.?month.*limit/i,
/quota.*exceed/i,
/exceed.*quota/i,
/insufficient.*quota/i,
/billing.*cap/i,
/credit.*exhaust/i,
/out of credits/i,
/hard.?limit/i,
/plan.*limit/i,
];
/**
* Best-effort case-insensitive header lookup.
*/
function getHeader(headers: Record<string, string> | undefined, name: string): string | undefined {
if (!headers) return undefined;
const target = name.toLowerCase();
for (const [k, v] of Object.entries(headers)) {
if (k.toLowerCase() === target) return v;
}
return undefined;
}
/**
* Coerce a body of unknown shape to a string for keyword scanning.
* - string: returned as-is
* - object: JSON-stringified (so nested error.message gets scanned)
* - undefined/null: empty string
*/
function bodyToText(body: unknown): string {
if (typeof body === "string") return body;
if (body == null) return "";
try {
return JSON.stringify(body);
} catch {
return "";
}
}
/**
* Returns true if the body looks like an explicit quota-exhausted
* error — i.e. the upstream is telling us a long-period cap was hit.
*/
export function looksLikeQuotaExhausted(body: unknown): boolean {
const text = bodyToText(body);
if (!text) return false;
return QUOTA_PATTERNS.some((pat) => pat.test(text));
}
/**
* Classify a 429 (or any) response into a `FailureKind`.
*
* Decision order:
* 1. status !== 429 → `"transient"` (don't pretend to know more than
* the caller does about non-429 failures).
* 2. body matches a quota keyword → `"quota_exhausted"`.
* 3. otherwise → `"rate_limit"` (default for 429 — even without
* Retry-After, a 429 is per definition a rate-limit signal).
*
* @param response - the upstream response with status, optional headers,
* optional body. Headers are looked up
* case-insensitively.
*/
export function classify429(response: {
status: number;
headers?: Record<string, string>;
body?: unknown;
}): FailureKind {
if (response.status !== 429) return "transient";
if (looksLikeQuotaExhausted(response.body)) return "quota_exhausted";
return "rate_limit";
}
/**
* Parse a `Retry-After` header value into seconds.
*
* Accepts:
* - integer seconds: `"60"`
* - HTTP date: `"Wed, 08 May 2026 03:00:00 GMT"`
* - Groq-style relative: `"60s"`, `"5m"`, `"2h"`
*
* Returns `null` if unparseable.
*
* Note: integer seconds vs Groq relative units are easy to confuse —
* `parseInt("5m", 10)` returns `5` (parses leading digits and ignores
* trailing). This helper checks the relative-unit pattern FIRST.
*/
export function parseRetryAfter(headerValue: string | undefined): number | null {
if (!headerValue) return null;
const trimmed = headerValue.trim();
if (!trimmed) return null;
// Groq-style relative: must check BEFORE plain int parse.
const relMatch = trimmed.match(/^(\d+)([smh])$/i);
if (relMatch) {
const n = Number(relMatch[1]);
const unit = relMatch[2].toLowerCase();
if (Number.isFinite(n)) {
if (unit === "s") return n;
if (unit === "m") return n * 60;
if (unit === "h") return n * 3600;
}
}
// Pure integer seconds.
if (/^\d+$/.test(trimmed)) {
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
// HTTP date.
const ts = Date.parse(trimmed);
if (Number.isFinite(ts)) {
return Math.max(0, Math.floor((ts - Date.now()) / 1000));
}
return null;
}
/**
* Convenience wrapper: pull the Retry-After from a response's headers
* and parse it to seconds. Returns null if absent or unparseable.
*/
export function retryAfterFromResponse(response: {
headers?: Record<string, string>;
}): number | null {
return parseRetryAfter(getHeader(response.headers, "retry-after"));
}
/**
* Normalize an unknown headers-like value into a plain `Record<string, string>`.
* Native `Headers` (from `fetch`) does NOT respond to `Object.entries` — it
* exposes `.entries()` instead. Without this normalization, `getHeader` would
* silently miss every header on a Headers instance.
*/
function normalizeHeaders(raw: unknown): Record<string, string> | undefined {
if (raw === null || typeof raw !== "object") return undefined;
const maybeIter = (raw as { entries?: unknown }).entries;
if (typeof maybeIter === "function") {
try {
return Object.fromEntries((raw as { entries: () => Iterable<[string, string]> }).entries());
} catch {
// fall through to plain-object treatment
}
}
return raw as Record<string, string>;
}
/**
* Adapter that takes an error thrown by an HTTP client (fetch wrapper, axios,
* upstream SDK, etc.) and produces a {@link FailureKind} suitable for the
* `classifyError` option of the circuit breaker.
*
* Recognises the common error shapes:
* - `err.status` + `err.headers` + `err.body` (low-level fetch wrapper)
* - `err.response.status` + `err.response.headers` + `err.response.data` (axios-style)
* - `err.message` (last-resort body for keyword scan)
*
* Returns `undefined` when the error doesn't carry enough information to
* classify, so the breaker can decide what to do without a kind tag.
*
* Companion to issue #2100 follow-up.
*/
export function classify429FromError(err: unknown): FailureKind | undefined {
if (err === null || typeof err !== "object") return undefined;
const e = err as Record<string, unknown>;
let status: number | undefined;
let headers: Record<string, string> | undefined;
let body: unknown;
if (typeof e.status === "number") {
status = e.status;
}
if (typeof e.statusCode === "number" && status === undefined) {
status = e.statusCode;
}
if (e.response && typeof e.response === "object") {
const resp = e.response as Record<string, unknown>;
if (typeof resp.status === "number" && status === undefined) {
status = resp.status;
}
if (resp.headers && typeof resp.headers === "object") {
headers = normalizeHeaders(resp.headers);
}
if (resp.data !== undefined) {
body = resp.data;
} else if (typeof resp.body !== "undefined") {
body = resp.body;
}
}
if (headers === undefined && e.headers && typeof e.headers === "object") {
headers = normalizeHeaders(e.headers);
}
if (body === undefined) {
if (typeof e.body !== "undefined") {
body = e.body;
} else if (typeof e.message === "string") {
body = e.message;
}
}
if (typeof status !== "number") return undefined;
return classify429({ status, headers, body });
}

View File

@@ -0,0 +1,73 @@
/**
* Per-provider default policy for upstream 429 hint trust.
*
* @see Issue #2100 follow-up — surface a user-overridable per-profile toggle
* that decides whether the circuit breaker uses upstream 429 body / Retry-After
* hints (`classify429`, `cooldownByKind`) to differentiate rate-limit from
* quota-exhausted failure cooldowns.
*
* This helper returns the **default** answer for a given provider. The actual
* runtime decision is the user override (if any) OR this default. See
* `accountFallback.ts` / `chat.ts` / `chatHelpers.ts` for the resolution
* call sites:
*
* ```ts
* const userValue = providerProfile.useUpstream429BreakerHints; // boolean | undefined
* const useHints = userValue !== undefined
* ? userValue
* : defaultUseUpstream429BreakerHints(provider);
* ```
*
* Default policy: direct cloud providers default `true` because their 429
* bodies and `Retry-After` headers are authoritative. Reverse-proxy /
* self-hosted / CLI-backed providers default `false` because forwarded 429
* metadata is often unreliable or fabricated by the proxy.
*
* @module shared/utils/providerHints
*/
import {
UPSTREAM_PROXY_PROVIDERS,
SELF_HOSTED_CHAT_PROVIDER_IDS,
isLocalProvider,
isClaudeCodeCompatibleProvider,
} from "../constants/providers";
/**
* Conservative per-provider default for `useUpstream429BreakerHints`.
*
* Returns `false` for any provider whose 429 metadata may be forwarded by
* an intermediary (proxy, self-hosted runtime, CLI wrapper). Returns `true`
* for direct cloud providers where the upstream response is authoritative.
*/
export function defaultUseUpstream429BreakerHints(providerId: string): boolean {
if (Object.prototype.hasOwnProperty.call(UPSTREAM_PROXY_PROVIDERS, providerId)) {
return false;
}
if (isLocalProvider(providerId)) {
return false;
}
if (SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId)) {
return false;
}
if (isClaudeCodeCompatibleProvider(providerId)) {
return false;
}
return true;
}
/**
* Resolve the effective `useHints` decision: the user override wins if set,
* otherwise fall back to the per-provider default.
*
* `undefined` means "not user-set" and triggers the default lookup.
*/
export function resolveUseUpstream429BreakerHints(
providerId: string,
userValue: boolean | undefined
): boolean {
if (userValue !== undefined) {
return userValue;
}
return defaultUseUpstream429BreakerHints(providerId);
}

View File

@@ -11,7 +11,7 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 600_000;
export const DEFAULT_STREAM_READINESS_TIMEOUT_MS = 30_000;
export const DEFAULT_FETCH_CONNECT_TIMEOUT_MS = 30_000;
export const DEFAULT_FETCH_KEEPALIVE_TIMEOUT_MS = 4_000;
export const DEFAULT_API_BRIDGE_PROXY_TIMEOUT_MS = 30_000;
export const DEFAULT_API_BRIDGE_PROXY_TIMEOUT_MS = 600_000;
export const DEFAULT_API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS = 300_000;
export const DEFAULT_API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS = 60_000;
export const DEFAULT_API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS = 5_000;

View File

@@ -775,6 +775,11 @@ const connectionCooldownProfileSchema = z
.object({
baseCooldownMs: z.number().int().min(0).optional(),
useUpstreamRetryHints: z.boolean().optional(),
// Issue #2100 follow-up: per-profile toggle for upstream 429 hint trust.
// `null` is an explicit unset sentinel — PATCH handler deletes the key
// from stored settings so the per-provider default resolves at runtime.
// `undefined` (key omitted) means "leave existing value unchanged".
useUpstream429BreakerHints: z.boolean().nullable().optional(),
maxBackoffSteps: z.number().int().min(0).optional(),
})
.strict();

View File

@@ -104,6 +104,11 @@ export const updateSettingsSchema = z.object({
lkgpEnabled: z.boolean().optional(),
backgroundDegradation: z.unknown().optional(),
bruteForceProtection: z.boolean().optional(),
// Auto-routing settings
autoRoutingEnabled: z.boolean().optional(),
autoRoutingDefaultVariant: z
.enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"])
.optional(),
});
export const databaseSettingsSchema = z.object(

View File

@@ -3,6 +3,8 @@ import {
getProviderCredentialsWithQuotaPreflight,
markAccountUnavailable,
extractApiKey,
isValidApiKey,
extractSessionAffinityKey,
} from "../services/auth";
import {
getRuntimeProviderProfile,
@@ -23,6 +25,7 @@ import {
getModelTargetFormat,
PROVIDER_ID_TO_ALIAS,
} from "@omniroute/open-sse/config/providerModels.ts";
import type { AutoVariant } from "@omniroute/open-sse/services/autoCombo/autoPrefix.ts";
import * as log from "../utils/logger";
import { checkAndRefreshToken } from "../services/tokenRefresh";
import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs";
@@ -43,6 +46,8 @@ import {
} from "./chatHelpers";
// Pipeline integration — wired modules
import { classify429FromError, type FailureKind } from "@/shared/utils/classify429";
import { resolveUseUpstream429BreakerHints } from "@/shared/utils/providerHints";
import { getCircuitBreaker } from "../../shared/utils/circuitBreaker";
import { markAccountExhaustedFrom429 } from "../../domain/quotaCache";
import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry";
@@ -208,6 +213,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
// T04: client-provided external session header has priority over generated fingerprint.
const externalSessionId = extractExternalSessionId(request.headers);
const sessionId = externalSessionId || generateStableSessionId(body);
const sessionAffinityKey = extractSessionAffinityKey(body, request.headers) || sessionId;
const requestedConnectionId = request.headers.get("x-omniroute-connection")?.trim() || null;
if (sessionId) {
touchSession(sessionId);
@@ -229,9 +235,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
// Guardrail pre-call pipeline — prompt injection, PII masking, and future custom rules.
telemetry.startPhase("validate");
const preCallGuardrails = await guardrailRegistry.runPreCallHooks(body, {
apiKeyInfo,
apiKeyInfo: apiKeyInfo as any,
disabledGuardrails: resolveDisabledGuardrails({
apiKeyInfo: apiKeyInfo as Record<string, unknown> | null,
apiKeyInfo: (apiKeyInfo ?? null) as any,
body,
headers: request.headers,
}),
@@ -295,6 +301,44 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
telemetry.endPhase();
}
// ── Zero-Config Auto-Routing (auto and auto/ prefix) ────────────────────────
// If the model ID is "auto" or starts with "auto/", bypass DB combo lookup
// entirely and generate a virtual auto-combo on-the-fly from connected providers.
let autoVariant: AutoVariant | undefined;
let isAutoRouting = resolvedModelStr === "auto" || resolvedModelStr.startsWith("auto/");
if (isAutoRouting) {
// C2: Enforce autoRoutingEnabled setting
const settings = await getSettings();
if (settings?.autoRoutingEnabled === false) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
"Auto routing is disabled. Enable it in Settings > Routing."
);
}
try {
const { parseAutoPrefix } =
await import("@omniroute/open-sse/services/autoCombo/autoPrefix.ts");
const parsed = parseAutoPrefix(resolvedModelStr);
if (parsed.valid) {
autoVariant = parsed.variant;
// C3: Apply autoRoutingDefaultVariant from settings when bare "auto" is used
if (autoVariant === undefined && settings?.autoRoutingDefaultVariant) {
autoVariant = settings.autoRoutingDefaultVariant as AutoVariant;
}
log.info(
"AUTO",
`Zero-config routing variant: ${autoVariant || "default"} (model=${resolvedModelStr})`
);
} else {
log.warn("AUTO", `Invalid auto prefix format: ${resolvedModelStr}`);
}
} catch (err) {
log.error("AUTO", "Failed to load auto-prefix parser", { err });
}
}
// ────────────────────────────────────────────────────────────────────────────
// Check if model is a combo (has multiple models with fallback)
telemetry.startPhase("resolve");
let combo: any = await getComboForModel(resolvedModelStr);
@@ -312,6 +356,23 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
}
}
// Auto-prefix short-circuit: if auto/ prefix was detected, replace combo with virtual one
if (isAutoRouting && combo === null) {
try {
const { createVirtualAutoCombo } =
await import("@omniroute/open-sse/services/autoCombo/virtualFactory.ts");
const virtualCombo = await createVirtualAutoCombo(autoVariant);
virtualCombo.name = resolvedModelStr;
virtualCombo.id = resolvedModelStr;
combo = virtualCombo;
log.info(
"AUTO",
`Virtual auto-combo created: ${combo.name} (${virtualCombo.candidatePool?.length || 0} candidates)`
);
} catch (err) {
log.error("AUTO", "Failed to create virtual auto-combo", { err });
}
}
if (combo) {
log.info(
"CHAT",
@@ -358,6 +419,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
allowedConnections,
resolvedModel,
{
sessionKey: sessionAffinityKey,
...(target?.connectionId ? { forcedConnectionId: target.connectionId } : {}),
}
);
@@ -388,6 +450,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
connectionId?: string | null;
executionKey?: string | null;
stepId?: string | null;
allowedConnectionIds?: string[] | null;
}
) =>
handleSingleModelChat(
@@ -400,6 +463,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
telemetry,
{
sessionId,
sessionAffinityKey,
forceLiveComboTest: isComboLiveTest,
forcedConnectionId: target?.connectionId ?? null,
allowedConnectionIds: target?.allowedConnectionIds ?? null,
@@ -450,7 +514,12 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
combo.name,
apiKeyInfo,
telemetry,
{ sessionId, emergencyFallbackTried: true, forceLiveComboTest: isComboLiveTest },
{
sessionId,
sessionAffinityKey,
emergencyFallbackTried: true,
forceLiveComboTest: isComboLiveTest,
},
combo.strategy,
true
);
@@ -486,6 +555,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
telemetry,
{
sessionId,
sessionAffinityKey,
forceLiveComboTest: isComboLiveTest,
forcedConnectionId: requestedConnectionId,
},
@@ -524,6 +594,7 @@ async function handleSingleModelChat(
emergencyFallbackTried?: boolean;
forceLiveComboTest?: boolean;
sessionId?: string | null;
sessionAffinityKey?: string | null;
forcedConnectionId?: string | null;
allowedConnectionIds?: string[] | null;
comboStepId?: string | null;
@@ -615,11 +686,25 @@ async function handleSingleModelChat(
});
if (gate) return gate;
// Issue #2100 follow-up: opt-in upstream 429 hint trust per provider.
const useHints429 = resolveUseUpstream429BreakerHints(
provider,
(providerProfile as { useUpstream429BreakerHints?: boolean }).useUpstream429BreakerHints
);
const breaker = getCircuitBreaker(provider, {
failureThreshold: providerProfile.failureThreshold,
resetTimeout: providerProfile.resetTimeoutMs,
onStateChange: (name: string, from: string, to: string) =>
log.info("CIRCUIT", `${name}: ${from}${to}`),
...(useHints429
? {
cooldownByKind: {
rate_limit: 60_000,
quota_exhausted: 3_600_000,
} satisfies Partial<Record<FailureKind, number>>,
classifyError: classify429FromError,
}
: {}),
});
const userAgent = request?.headers?.get("user-agent") || "";
@@ -670,6 +755,7 @@ async function handleSingleModelChat(
effectiveAllowedConnections,
model,
{
sessionKey: runtimeOptions.sessionAffinityKey ?? runtimeOptions.sessionId ?? null,
excludeConnectionIds: Array.from(excludedConnectionIds),
...(forceLiveComboTest
? {
@@ -861,6 +947,25 @@ async function handleSingleModelChat(
return result.response;
}
if (result.errorType === "account_semaphore_capacity") {
// Local concurrency pressure is not an upstream quota failure. Prefer another
// account when possible; pinned combo steps fall through to combo orchestration.
if (hasForcedConnection) {
return result.response;
}
log.warn(
"AUTH",
`Account ${accountId}... at local concurrency cap, trying fallback account`
);
excludedConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;
requestRetryLastError = result.error;
requestRetryLastStatus = result.status;
continue;
}
// Emergency fallback for budget exhaustion (402 / billing / quota keywords):
// reroute to a free model (default provider/model: nvidia + openai/gpt-oss-120b) exactly once.
if (!runtimeOptions.emergencyFallbackTried) {

View File

@@ -25,6 +25,9 @@ import {
} from "@omniroute/open-sse/utils/proxyFetch.ts";
import { resolveProxyForConnection } from "@/lib/localDb";
import { CircuitBreakerOpenError, getCircuitBreaker } from "../../shared/utils/circuitBreaker";
import { classify429FromError, type FailureKind } from "../../shared/utils/classify429";
import { resolveUseUpstream429BreakerHints } from "../../shared/utils/providerHints";
import { logProxyEvent } from "../../lib/proxyLogger";
import { logTranslationEvent } from "../../lib/translatorEvents";
import { getRuntimeProviderProfile } from "@omniroute/open-sse/services/accountFallback.ts";
@@ -238,11 +241,25 @@ export async function checkPipelineGates(
) {
const bypassReason = options.bypassReason || "pipeline override";
const providerProfile = options.providerProfile ?? (await getRuntimeProviderProfile(provider));
// Issue #2100 follow-up: opt-in upstream 429 hint trust per provider.
const useHints429 = resolveUseUpstream429BreakerHints(
provider,
(providerProfile as { useUpstream429BreakerHints?: boolean }).useUpstream429BreakerHints
);
const breaker = getCircuitBreaker(provider, {
failureThreshold: providerProfile.failureThreshold ?? providerProfile.circuitBreakerThreshold,
resetTimeout: providerProfile.resetTimeoutMs ?? providerProfile.circuitBreakerReset,
onStateChange: (name: string, from: string, to: string) =>
log.info("CIRCUIT", `${name}: ${from}${to}`),
...(useHints429
? {
cooldownByKind: {
rate_limit: 60_000,
quota_exhausted: 3_600_000,
} satisfies Partial<Record<FailureKind, number>>,
classifyError: classify429FromError,
}
: {}),
});
if (options.ignoreCircuitBreaker && !breaker.canExecute()) {
log.info("CIRCUIT", `Bypassing OPEN circuit breaker for ${provider} (${bypassReason})`);

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