mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
119
CHANGELOG.md
119
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
356
README.md
@@ -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
2822
bin/cli-commands.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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")) {
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
BIN
docs/screenshots/10-providers-aitradepulse.png
Normal file
BIN
docs/screenshots/10-providers-aitradepulse.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/screenshots/11-combos-aitradepulse.png
Normal file
BIN
docs/screenshots/11-combos-aitradepulse.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/screenshots/ai-aitradepulse-dashboard.png
Normal file
BIN
docs/screenshots/ai-aitradepulse-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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 || "")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
54
open-sse/services/autoCombo/autoPrefix.ts
Normal file
54
open-sse/services/autoCombo/autoPrefix.ts
Normal 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" };
|
||||
}
|
||||
9
open-sse/services/autoCombo/providerRegistryAccessor.ts
Normal file
9
open-sse/services/autoCombo/providerRegistryAccessor.ts
Normal 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;
|
||||
}
|
||||
141
open-sse/services/autoCombo/virtualFactory.ts
Normal file
141
open-sse/services/autoCombo/virtualFactory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})`
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
478
src/app/(dashboard)/dashboard/cloud-agents/page.tsx
Normal file
478
src/app/(dashboard)/dashboard/cloud-agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>) || {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 "Use upstream retry hints".
|
||||
</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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
79
src/app/api/analytics/auto-routing/route.ts
Normal file
79
src/app/api/analytics/auto-routing/route.ts
Normal 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: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
186
src/app/api/v1/agents/tasks/[id]/route.ts
Normal file
186
src/app/api/v1/agents/tasks/[id]/route.ts
Normal 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() }
|
||||
);
|
||||
}
|
||||
}
|
||||
173
src/app/api/v1/agents/tasks/route.ts
Normal file
173
src/app/api/v1/agents/tasks/route.ts
Normal 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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
148
src/lib/cloudAgent/agents/codex.ts
Normal file
148
src/lib/cloudAgent/agents/codex.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
137
src/lib/cloudAgent/agents/devin.ts
Normal file
137
src/lib/cloudAgent/agents/devin.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
170
src/lib/cloudAgent/agents/jules.ts
Normal file
170
src/lib/cloudAgent/agents/jules.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
95
src/lib/cloudAgent/baseAgent.ts
Normal file
95
src/lib/cloudAgent/baseAgent.ts
Normal 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
145
src/lib/cloudAgent/db.ts
Normal 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);
|
||||
}
|
||||
8
src/lib/cloudAgent/index.ts
Normal file
8
src/lib/cloudAgent/index.ts
Normal 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();
|
||||
25
src/lib/cloudAgent/registry.ts
Normal file
25
src/lib/cloudAgent/registry.ts
Normal 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
111
src/lib/cloudAgent/types.ts
Normal 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>;
|
||||
@@ -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);
|
||||
|
||||
11
src/lib/db/migrations/041_session_account_affinity.sql
Normal file
11
src/lib/db/migrations/041_session_account_affinity.sql
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
79
src/shared/components/AutoRoutingBanner.tsx
Normal file
79
src/shared/components/AutoRoutingBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
243
src/shared/utils/classify429.ts
Normal file
243
src/shared/utils/classify429.ts
Normal 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 });
|
||||
}
|
||||
73
src/shared/utils/providerHints.ts
Normal file
73
src/shared/utils/providerHints.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user