diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f713a54e6..875588b7ce 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Dockerfile b/Dockerfile index ee090131d8..7500434464 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index d1dca716b4..9eb7aa3814 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + +
# 🚀 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
diff --git a/bin/cli-commands.mjs b/bin/cli-commands.mjs new file mode 100644 index 0000000000..bf9560caec --- /dev/null +++ b/bin/cli-commands.mjs @@ -0,0 +1,2822 @@ +#!/usr/bin/env node + +/** + * OmniRoute CLI - Production-grade CLI Integration Suite + * + * Commands: + * setup - Configure CLI tools to use OmniRoute + * doctor - Run health diagnostics + * status - Show comprehensive status + * logs - View application logs + * provider - Add OmniRoute as provider for tools + * config - Show current OmniRoute configuration + * test - Test provider/model connectivity + * update - Check for updates + */ + +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, + copyFileSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir, platform, release } from "node:os"; +import { execSync, spawn } from "node:child_process"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = join(__dirname, ".."); + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const DEFAULT_BASE_URL = "http://localhost:20128"; +const API_PORT = 20128; +const DASHBOARD_PORT = 20129; + +const CLI_TOOLS = { + claude: { + id: "claude", + name: "Claude Code", + command: "claude", + configPath: ".claude/settings.json", + type: "json", + }, + codex: { + id: "codex", + name: "Codex CLI", + command: "codex", + configPath: ".codex/config.toml", + type: "toml", + }, + opencode: { + id: "opencode", + name: "OpenCode", + command: "opencode", + configPath: ".config/opencode/opencode.json", + type: "json", + }, + cline: { + id: "cline", + name: "Cline", + command: "cline", + configPath: ".cline/data/globalState.json", + type: "json", + }, + kilo: { + id: "kilo", + name: "Kilo Code", + command: "kilocode", + configPath: ".config/kilocode/settings.json", + type: "json", + }, + continue: { + id: "continue", + name: "Continue", + command: "continue", + configPath: ".continue/config.json", + type: "json", + }, + openclaw: { + id: "openclaw", + name: "OpenClaw", + command: "openclaw", + configPath: ".openclaw/openclaw.json", + type: "json", + }, +}; + +const PROVIDER_HELP = { + opencode: `OpenCode configuration: +1. Add to ~/.config/opencode/opencode.json: +{ + "provider": { + "omniroute": { + "name": "OmniRoute", + "baseURL": "http://localhost:20128/v1" + } + } +} +2. Set environment: export OPENAI_API_KEY=your-key`, + + cursor: `Cursor configuration: +1. Open Cursor Settings +2. Go to Models → Add Model +3. Set Base URL to: http://localhost:20128/v1 +4. Set API Key to your OmniRoute key`, + + cline: `Cline configuration: +1. Open Cline Settings +2. Find "OpenAI Compatible" provider settings +3. Set Base URL: http://localhost:20128/v1 +4. Set API Key: your OmniRoute key`, + + vscode: `VS Code + MCP configuration: +1. Install Cline extension +2. Or use: omniroute --mcp for MCP server`, +}; + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +function getHomeDir() { + return homedir(); +} + +function resolveConfigPath(relativePath) { + return join(getHomeDir(), relativePath); +} + +function execCommand(command, timeout = 3000) { + try { + const output = execSync(command, { + encoding: "utf8", + timeout, + stdio: ["pipe", "pipe", "pipe"], + }); + return { success: true, output: output.trim() }; + } catch (error) { + return { + success: false, + error: error.message, + code: error.status || error.signal, + }; + } +} + +function readJsonFile(filePath) { + try { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath, "utf8"); + return JSON.parse(content); + } catch (error) { + return null; + } +} + +function writeJsonFile(filePath, data) { + try { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8"); + return true; + } catch (error) { + return false; + } +} + +function createBackup(filePath) { + if (!existsSync(filePath)) return null; + const backupPath = filePath + ".backup." + Date.now(); + try { + writeFileSync(backupPath, readFileSync(filePath), "utf8"); + return backupPath; + } catch { + return null; + } +} + +function colorize(text, color) { + const colors = { + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", + reset: "\x1b[0m", + dim: "\x1b[2m", + }; + return colors[color] ? `${colors[color]}${text}${colors.reset}` : text; +} + +function log(message, color = "reset") { + console.log(colorize(message, color)); +} + +function logSection(title) { + console.log("\n" + colorize("┌─ " + title + " ".repeat(50), "cyan")); +} + +function logEndSection() { + console.log(colorize("└" + "─".repeat(51), "cyan")); +} + +// ============================================================================ +// TOOL DETECTION +// ============================================================================ + +function detectInstalledTools() { + const results = []; + + for (const [id, tool] of Object.entries(CLI_TOOLS)) { + const result = execCommand(`which ${tool.command}`, 2000); + const installed = result.success; + let version = null; + + if (installed) { + const versionResult = execCommand(`${tool.command} --version`, 2000); + if (versionResult.success) { + version = versionResult.output.slice(0, 20); + } + } + + results.push({ + id, + name: tool.name, + installed, + version, + configPath: resolveConfigPath(tool.configPath), + configured: checkToolConfigured(id), + }); + } + + return results; +} + +function checkToolConfigured(toolId) { + const tool = CLI_TOOLS[toolId]; + if (!tool) return false; + + const configPath = resolveConfigPath(tool.configPath); + + try { + if (!existsSync(configPath)) return false; + + const content = readFileSync(configPath, "utf8").toLowerCase(); + const hasOmniRoute = + content.includes("omniroute") || + content.includes(`localhost:${API_PORT}`) || + content.includes(`127.0.0.1:${API_PORT}`); + return hasOmniRoute; + } catch { + return false; + } +} + +// ============================================================================ +// API FUNCTIONS +// ============================================================================ + +async function checkServerHealth() { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/health`, { + method: "GET", + signal: AbortSignal.timeout(3000), + }); + return res.ok; + } catch { + return false; + } +} + +async function getCliToolsStatusFromApi() { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/cli-tools/status`, { + method: "GET", + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + return await res.json(); + } + } catch {} + return null; +} + +async function getConsoleLogs(limit = 100, level = null) { + try { + const url = new URL(`${DEFAULT_BASE_URL}/api/logs/console`); + url.searchParams.set("limit", String(limit)); + if (level) url.searchParams.set("level", level); + + const res = await fetch(url.toString(), { + method: "GET", + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + return await res.json(); + } + } catch {} + return []; +} + +async function testProviderConnection(provider = "claude", model = "claude-sonnet-4-20250514") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer sk-omniroute-cli-test", + }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content: "Hi" }], + max_tokens: 10, + }), + signal: AbortSignal.timeout(10000), + }); + + if (res.ok) { + const data = await res.json(); + return { success: true, response: data.choices?.[0]?.message?.content || "OK" }; + } else { + const error = await res.text(); + return { success: false, error: `HTTP ${res.status}: ${error.slice(0, 100)}` }; + } + } catch (error) { + return { success: false, error: error.message }; + } +} + +// ============================================================================ +// CONFIG MANAGEMENT +// ============================================================================ + +function configureTool(toolId, baseUrl, apiKey) { + const tool = CLI_TOOLS[toolId]; + if (!tool) { + return { success: false, error: "Unknown tool: " + toolId }; + } + + const configPath = tool.configPath; + const fullPath = resolveConfigPath(configPath); + + // Create backup first + const backupPath = createBackup(fullPath); + + try { + // Ensure directory exists + const dir = dirname(fullPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Read existing config or create new + let config = {}; + if (existsSync(fullPath)) { + const content = readFileSync(fullPath, "utf8"); + if (tool.type === "json") { + config = JSON.parse(content); + } + } + + // Apply tool-specific configuration + switch (toolId) { + case "claude": + config.api = config.api || {}; + config.api.omniroute = { + baseUrl: `${baseUrl}/v1`, + apiKey: apiKey, + model: "claude-sonnet-4-20250514", + }; + break; + + case "codex": + // For TOML, we need to append/modify + const tomlContent = `[openai] +base_url = "${baseUrl}/v1" +api_key = "${apiKey}" +model = "gpt-4o" +`; + writeFileSync(fullPath, tomlContent, "utf8"); + return { success: true, configPath: fullPath, backupPath }; + + case "opencode": + config.provider = config.provider || {}; + config.provider.omniroute = { + name: "OmniRoute", + baseURL: `${baseUrl}/v1`, + apiKey: apiKey, + }; + break; + + case "cline": + config.openAiBaseUrl = `${baseUrl}/v1`; + config.openAiApiKey = apiKey; + config.actModeApiProvider = "openai"; + config.planModeApiProvider = "openai"; + break; + + case "kilo": + config.apiUrl = `${baseUrl}/v1`; + config.apiKey = apiKey; + break; + + case "continue": + config.models = config.models || []; + config.models.push({ + name: "OmniRoute", + provider: "openai-compatible", + apiKey: apiKey, + baseUrl: `${baseUrl}/v1`, + }); + break; + + case "openclaw": + config.OPENAI_BASE_URL = `${baseUrl}/v1`; + config.OPENAI_API_KEY = apiKey; + break; + } + + // Write JSON configs + if (tool.type === "json") { + writeFileSync(fullPath, JSON.stringify(config, null, 2), "utf8"); + } + + return { success: true, configPath: fullPath, backupPath }; + } catch (error) { + // Restore backup on failure + if (backupPath && existsSync(backupPath)) { + try { + writeFileSync(fullPath, readFileSync(backupPath), "utf8"); + } catch {} + } + return { success: false, error: error.message }; + } +} + +// ============================================================================ +// CONFIG SHOW +// ============================================================================ + +function getOmniRouteConfig() { + const config = { + port: API_PORT, + dashboardPort: DASHBOARD_PORT, + baseUrl: `http://localhost:${API_PORT}`, + dataDir: resolveConfigPath(".omniroute"), + requireApiKey: process.env.REQUIRE_API_KEY === "true", + logLevel: process.env.LOG_LEVEL || "info", + }; + + // Check for existing providers + try { + const dbPath = join(config.dataDir, "storage.sqlite"); + config.hasDatabase = existsSync(dbPath); + } catch { + config.hasDatabase = false; + } + + // Node version + config.nodeVersion = process.version; + config.platform = platform(); + config.osRelease = release(); + + return config; +} + +// ============================================================================ +// COMMAND IMPLEMENTATIONS +// ============================================================================ + +export async function runSubcommand(cmd, args) { + switch (cmd) { + case "setup": + await runSetup(args); + break; + case "doctor": + await runDoctor(args); + break; + case "status": + await runStatus(args); + break; + case "logs": + await runLogs(args); + break; + case "provider": + await runProvider(args); + break; + case "config": + await runConfig(args); + break; + case "test": + await runTest(args); + break; + case "update": + await runUpdate(args); + break; + case "serve": + await runServe(args); + break; + case "stop": + await runStop(args); + break; + case "restart": + await runRestart(args); + break; + case "keys": + await runKeys(args); + break; + case "models": + await runModels(args); + break; + case "combo": + await runCombo(args); + break; + case "completion": + await runCompletion(args); + break; + case "dashboard": + await runDashboard(args); + break; + case "backup": + await runBackup(args); + break; + case "restore": + await runRestore(args); + break; + case "quota": + await runQuota(args); + break; + case "health": + await runHealth(args); + break; + case "cache": + await runCache(args); + break; + case "mcp": + await runMcp(args); + break; + case "a2a": + await runA2a(args); + break; + case "tunnel": + await runTunnel(args); + break; + case "env": + await runEnv(args); + break; + case "open": + await runDashboard(args); + break; + default: + log(`Unknown subcommand: ${cmd}`, "red"); + log("Run 'omniroute --help' for available commands", "dim"); + process.exit(1); + } +} + +async function runSetup(args) { + const toolsArg = args.find((a) => a.startsWith("--tools="))?.split("=")[1]; + const urlArg = args.find((a) => a.startsWith("--url="))?.split("=")[1] || DEFAULT_BASE_URL; + const keyArg = + args.find((a) => a.startsWith("--key="))?.split("=")[1] || "sk-omniroute-cli-configured"; + const listArg = args.includes("--list"); + + const baseUrl = urlArg.endsWith("/") ? urlArg.slice(0, -1) : urlArg; + + if (listArg) { + logSection("Available CLI Tools"); + const tools = detectInstalledTools(); + for (const tool of tools) { + const status = tool.installed + ? tool.configured + ? colorize("✓ configured", "green") + : colorize("✗ not configured", "yellow") + : colorize("✗ not installed", "red"); + log(` ${tool.name.padEnd(14)} ${status}`); + } + logEndSection(); + return; + } + + logSection("OmniRoute CLI Setup"); + + // Detect installed tools + const installed = detectInstalledTools().filter((t) => t.installed); + + if (installed.length === 0) { + log("No CLI tools detected. Install Claude Code, Codex, OpenCode, etc.", "yellow"); + return; + } + + log(`Found ${installed.length} installed tools:`, "dim"); + for (const tool of installed) { + log(` - ${tool.name}`); + } + console.log(); + + // Determine which tools to configure + const toolsToConfigure = toolsArg + ? toolsArg.split(",").filter((t) => CLI_TOOLS[t]) + : installed.map((t) => t.id); + + // Configure each tool + log(`Configuring ${toolsToConfigure.length} tool(s)...\n`, "cyan"); + + let successCount = 0; + let failCount = 0; + + for (const toolId of toolsToConfigure) { + const tool = CLI_TOOLS[toolId]; + log(`Configuring ${tool.name}...`, "dim"); + + const result = configureTool(toolId, baseUrl, keyArg); + + if (result.success) { + log(` ✓ Configured: ${result.configPath}`, "green"); + if (result.backupPath) { + log(` Backup: ${result.backupPath}`, "dim"); + } + successCount++; + } else { + log(` ✗ Failed: ${result.error}`, "red"); + failCount++; + } + } + + logEndSection(); + + console.log(); + if (successCount > 0) { + log(`✓ Successfully configured ${successCount} tool(s)`, "green"); + } + if (failCount > 0) { + log(`✗ Failed to configure ${failCount} tool(s)`, "red"); + } + + console.log(); + log("Next steps:", "cyan"); + log(" 1. Test: omniroute test", "dim"); + log(" 2. Status: omniroute status", "dim"); + log(" 3. Start server: omniroute", "dim"); +} + +async function runDoctor(args) { + const verbose = args.includes("--verbose"); + const serverRunning = await checkServerHealth(); + + logSection("OmniRoute Doctor"); + + // Server status + if (serverRunning) { + log("Server: " + colorize("✓ Running", "green")); + log(`API: http://localhost:${API_PORT}/v1`); + log(`Dashboard: http://localhost:${DASHBOARD_PORT}`); + } else { + log("Server: " + colorize("✗ Not running", "red")); + log("Run 'omniroute' to start the server", "dim"); + } + logEndSection(); + + // CLI Tools status + logSection("CLI Tools Status"); + + let tools; + let dataSource = "local"; + + if (serverRunning) { + const apiStatus = await getCliToolsStatusFromApi(); + if (apiStatus) { + dataSource = "api"; + tools = Object.entries(apiStatus).map(([id, data]) => ({ + id, + name: CLI_TOOLS[id]?.name || id, + installed: data.installed, + configured: data.configStatus === "configured", + runnable: data.runnable, + })); + } + } + + if (!tools) { + tools = detectInstalledTools(); + } + + // Sort: configured first, then installed, then not installed + tools.sort((a, b) => { + if (a.configured && !b.configured) return -1; + if (!a.configured && b.configured) return 1; + if (a.installed && !b.installed) return -1; + if (!a.installed && b.installed) return 1; + return 0; + }); + + for (const tool of tools) { + let status; + if (tool.configured) { + status = colorize("✓ configured", "green"); + } else if (tool.installed) { + status = colorize("○ not configured", "yellow"); + } else { + status = colorize("✗ not installed", "red"); + } + log(` ${tool.name.padEnd(12)} ${status}`); + } + + console.log( + `\n${colorize("Data source:", "dim")} ${dataSource === "api" ? "API (accurate)" : "Local detection"}` + ); + logEndSection(); + + // Recommendations + console.log(); + const notConfigured = tools.filter((t) => t.installed && !t.configured); + if (notConfigured.length > 0) { + log("Recommendations:", "cyan"); + log(` Run 'omniroute setup --tools=${notConfigured.map((t) => t.id).join(",")}' to configure`); + } + + if (!serverRunning) { + log(" Run 'omniroute' to start the server for full diagnostics", "dim"); + } + + if (verbose && serverRunning) { + console.log(); + logSection("System Info"); + log(`Node: ${process.version}`); + log(`Platform: ${platform()} ${release()}`); + log(`Home: ${getHomeDir()}`); + log(`Data Dir: ${resolveConfigPath(".omniroute")}`); + logEndSection(); + } +} + +async function runStatus(args) { + const json = args.includes("--json"); + const serverRunning = await checkServerHealth(); + + const config = getOmniRouteConfig(); + const tools = detectInstalledTools(); + const configuredCount = tools.filter((t) => t.configured).length; + const installedCount = tools.filter((t) => t.installed).length; + + if (json) { + console.log( + JSON.stringify( + { + server: { + running: serverRunning, + port: config.port, + url: config.baseUrl, + }, + dashboard: `http://localhost:${config.dashboardPort}`, + config: { + dataDir: config.dataDir, + requireApiKey: config.requireApiKey, + logLevel: config.logLevel, + }, + tools: { + total: Object.keys(CLI_TOOLS).length, + installed: installedCount, + configured: configuredCount, + }, + }, + null, + 2 + ) + ); + return; + } + + logSection("OmniRoute Status"); + log( + `Server: ${serverRunning ? colorize("✓ Running", "green") : colorize("✗ Stopped", "red")}` + ); + log(`API URL: ${config.baseUrl}/v1`); + log(`Dashboard: http://localhost:${config.dashboardPort}`); + log(`Data Dir: ${config.dataDir}`); + logEndSection(); + + logSection("CLI Tools"); + log(`Installed: ${installedCount}`); + log(`Configured: ${configuredCount}`); + console.log(); + + for (const tool of tools) { + const icon = tool.configured + ? colorize("●", "green") + : tool.installed + ? colorize("○", "yellow") + : colorize("×", "red"); + log(` ${icon} ${tool.name}`); + } + logEndSection(); +} + +async function runLogs(args) { + const linesArg = args.find((a) => a.startsWith("--lines="))?.split("=")[1] || "100"; + const levelArg = args.find((a) => a.startsWith("--level="))?.split("=")[1]; + const followArg = args.includes("--follow"); + const jsonArg = args.includes("--json"); + const searchArg = args.find((a) => a.startsWith("--search="))?.split("=")[1]; + + const limit = Math.min(Math.max(parseInt(linesArg) || 100, 10), 1000); + const level = levelArg || null; + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute'", "red"); + return; + } + + if (jsonArg) { + const logs = await getConsoleLogs(limit, level); + console.log(JSON.stringify(logs, null, 2)); + return; + } + + logSection("Console Logs"); + log(`Fetching last ${limit} lines...`, "dim"); + if (level) log(`Filter: ${level}`, "dim"); + if (searchArg) log(`Search: ${searchArg}`, "dim"); + logEndSection(); + + let logs = await getConsoleLogs(limit, level); + + // Search filter + if (searchArg) { + const search = searchArg.toLowerCase(); + logs = logs.filter( + (l) => + (l.msg || l.message || "").toLowerCase().includes(search) || + (l.level || "").toLowerCase().includes(search) + ); + } + + if (logs.length === 0) { + log("No logs found", "yellow"); + return; + } + + for (const entry of logs) { + const timestamp = entry.time || entry.timestamp || ""; + const lvl = entry.level || entry.severity || "info"; + const msg = entry.msg || entry.message || ""; + + let color = "dim"; + if (lvl === "error" || lvl === "fatal") color = "red"; + else if (lvl === "warn") color = "yellow"; + else if (lvl === "debug") color = "dim"; + else color = "reset"; + + console.log(`${colorize(timestamp.slice(0, 24), "dim")} [${lvl.slice(0, 5).padEnd(5)}] ${msg}`); + } + + if (followArg) { + log("\nFollowing logs (Ctrl+C to exit)...", "cyan"); + // Simple polling implementation + let lastTime = logs[logs.length - 1]?.time || ""; + + const interval = setInterval(async () => { + const newLogs = await getConsoleLogs(50, level); + const filtered = newLogs.filter((l) => l.time > lastTime); + for (const entry of filtered) { + const timestamp = entry.time || ""; + const lvl = entry.level || "info"; + const msg = entry.msg || ""; + let color = lvl === "error" ? "red" : lvl === "warn" ? "yellow" : "dim"; + console.log( + `${colorize(timestamp.slice(0, 24), "dim")} [${lvl.slice(0, 5).padEnd(5)}] ${msg}` + ); + lastTime = entry.time; + } + }, 2000); + + // Handle interrupt + process.on("SIGINT", () => { + clearInterval(interval); + log("\nStopped following", "yellow"); + process.exit(0); + }); + } +} + +async function runProvider(args) { + const action = args[0] || "list"; + + if (action === "list") { + logSection("Available Provider Integrations"); + for (const [id, name] of Object.entries({ + opencode: "OpenCode", + cursor: "Cursor", + cline: "Cline", + vscode: "VS Code", + })) { + log(` ${name}`); + } + logEndSection(); + log("\nUsage: omniroute provider add ", "dim"); + return; + } + + if (action === "add") { + const provider = args[1]; + if (!provider || !PROVIDER_HELP[provider]) { + log(`Unknown provider: ${provider}`, "red"); + log("Available: " + Object.keys(PROVIDER_HELP).join(", "), "dim"); + return; + } + + logSection(`Configure ${provider}`); + console.log(PROVIDER_HELP[provider]); + logEndSection(); + return; + } + + log(`Unknown action: ${action}`, "red"); + log("Usage: omniroute provider [list|add ]", "dim"); +} + +async function runConfig(args) { + const action = args[0] || "show"; + + if (action === "show") { + const config = getOmniRouteConfig(); + + logSection("OmniRoute Configuration"); + log(`API Port: ${config.port}`); + log(`Dashboard Port: ${config.dashboardPort}`); + log(`Base URL: ${config.baseUrl}`); + log(`Data Directory: ${config.dataDir}`); + log(`Require API Key: ${config.requireApiKey ? "Yes" : "No"}`); + log(`Log Level: ${config.logLevel}`); + log(`Node Version: ${config.nodeVersion}`); + log(`Platform: ${config.platform} ${config.osRelease}`); + logEndSection(); + return; + } + + log(`Unknown action: ${action}`, "red"); + log("Usage: omniroute config show", "dim"); +} + +async function runTest(args) { + const providerArg = args.find((a) => a.startsWith("--provider="))?.split("=")[1]; + const modelArg = args.find((a) => a.startsWith("--model="))?.split("=")[1]; + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute'", "red"); + return; + } + + const provider = providerArg || "claude"; + const model = modelArg || "claude-sonnet-4-20250514"; + + logSection("Testing Provider Connection"); + log(`Provider: ${provider}`); + log(`Model: ${model}`); + log("Connecting...", "dim"); + console.log(); + + const result = await testProviderConnection(provider, model); + + if (result.success) { + log("✓ Connection successful!", "green"); + log(`Response: ${result.response}`, "dim"); + } else { + log("✗ Connection failed!", "red"); + log(`Error: ${result.error}`, "yellow"); + } + + logEndSection(); +} + +async function runUpdate(args) { + logSection("Checking for Updates"); + + // Get current version + try { + const pkgPath = join(ROOT, "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + log(`Current version: ${colorize(pkg.version, "cyan")}`); + } catch { + log("Current version: unknown", "yellow"); + } + + // Get latest version from npm + log("Checking npm...", "dim"); + const npmResult = execCommand("npm view omniroute version", 10000); + + if (npmResult.success) { + const latest = npmResult.output.trim(); + // Try to get current version again for comparison + const pkgPath = join(ROOT, "package.json"); + const current = JSON.parse(readFileSync(pkgPath, "utf8")).version; + + console.log(); + if (latest !== current) { + log(`Latest version: ${colorize(latest, "green")}`); + log(`Update available! Run:`, "yellow"); + log(` npm install -g omniroute@latest`, "dim"); + } else { + log(`Latest version: ${colorize(latest, "green")}`); + log("Already on the latest version!", "green"); + } + } else { + log("Could not check for updates (npm not available)", "yellow"); + } + + logEndSection(); +} + +// ============================================================================ +// PID FILE MANAGEMENT +// ============================================================================ + +function getPidFilePath() { + return join(resolveConfigPath(".omniroute"), "server.pid"); +} + +function writePidFile(pid) { + try { + const dir = dirname(getPidFilePath()); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(getPidFilePath(), String(pid), "utf8"); + return true; + } catch { + return false; + } +} + +function readPidFile() { + try { + const path = getPidFilePath(); + if (!existsSync(path)) return null; + const content = readFileSync(path, "utf8").trim(); + return content ? parseInt(content, 10) : null; + } catch { + return null; + } +} + +function isPidRunning(pid) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function cleanupPidFile() { + try { + const path = getPidFilePath(); + if (existsSync(path)) { + const fs = require("node:fs"); + fs.unlinkSync(path); + } + } catch {} +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForServer(port, timeout = 15000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const res = await fetch(`http://localhost:${port}/api/health`, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) return true; + } catch {} + await sleep(500); + } + return false; +} + +// ============================================================================ +// SERVER MANAGEMENT COMMANDS +// ============================================================================ + +async function runServe(args) { + const portArg = args.find((a) => a.startsWith("--port="))?.split("=")[1]; + const port = portArg ? parseInt(portArg) : API_PORT; + const daemonArg = args.includes("--daemon"); + + logSection("Starting OmniRoute Server"); + + // Check if already running via PID file + const existingPid = readPidFile(); + if (existingPid && isPidRunning(existingPid)) { + log(`Server already running (PID: ${existingPid})`, "red"); + log(`API: http://localhost:${port}/v1`); + log(`Dashboard: http://localhost:${port + 1}`); + logEndSection(); + return; + } + + // Check if port is in use + const portCheck = execCommand(`lsof -ti:${port} 2>/dev/null || true`, 2000); + if (portCheck.success && portCheck.output.trim()) { + log(`Port ${port} is already in use`, "red"); + log("Stop the existing server or use a different port", "dim"); + logEndSection(); + return; + } + + const appDir = join(ROOT, "app"); + const serverWsJs = join(appDir, "server-ws.mjs"); + const serverJs = existsSync(serverWsJs) ? serverWsJs : join(appDir, "server.js"); + + if (!existsSync(serverJs)) { + log("Server not found. Run 'npm run build' first.", "red"); + logEndSection(); + return; + } + + log(`Starting server on port ${port}...`, "dim"); + + const env = { + ...process.env, + OMNIROUTE_PORT: String(port), + PORT: String(port + 1), + DASHBOARD_PORT: String(port + 1), + API_PORT: String(port), + HOSTNAME: "0.0.0.0", + NODE_ENV: "production", + }; + + const server = spawn("node", [serverJs], { + cwd: appDir, + env, + stdio: daemonArg ? "ignore" : "pipe", + }); + + // Write PID file + writePidFile(server.pid); + + if (daemonArg) { + log(`Server started in background (PID: ${server.pid})`, "green"); + log(`API: http://localhost:${port}/v1`); + log(`Dashboard: http://localhost:${port + 1}`); + } else { + // Wait for server to be ready + const ready = await waitForServer(port); + if (ready) { + log(`Server running!`, "green"); + log(`API: http://localhost:${port}/v1`); + log(`Dashboard: http://localhost:${port + 1}`); + log(""); + log("Press Ctrl+C to stop", "dim"); + } else { + log("Server may not have started properly", "yellow"); + } + } + + logEndSection(); + + if (!daemonArg) { + // Keep process alive, handle shutdown + process.on("SIGINT", () => { + log("\nShutting down...", "yellow"); + server.kill("SIGTERM"); + cleanupPidFile(); + process.exit(0); + }); + + server.on("exit", (code) => { + cleanupPidFile(); + if (code !== 0) { + log(`Server exited with code ${code}`, "red"); + } + process.exit(code || 0); + }); + } +} + +async function runStop(args) { + logSection("Stopping OmniRoute Server"); + + const pid = readPidFile(); + + if (pid && isPidRunning(pid)) { + log(`Sending SIGTERM to PID ${pid}...`, "dim"); + + try { + process.kill(pid, "SIGTERM"); + + // Wait for graceful shutdown + let waited = 0; + while (waited < 5000 && isPidRunning(pid)) { + await sleep(100); + waited += 100; + } + + if (isPidRunning(pid)) { + log("Force killing...", "yellow"); + process.kill(pid, "SIGKILL"); + await sleep(500); + } + + cleanupPidFile(); + log("Server stopped", "green"); + } catch (err) { + log(`Error stopping server: ${err.message}`, "red"); + } + } else { + // Fallback: try to kill by port + log("No PID file, trying port-based cleanup...", "dim"); + + try { + // Try multiple methods + execCommand("lsof -ti:20128 | xargs kill -9 2>/dev/null || true", 2000); + execCommand("lsof -ti:20129 | xargs kill -9 2>/dev/null || true", 2000); + cleanupPidFile(); + log("Server stopped (port-based)", "green"); + } catch { + log("No server running", "yellow"); + } + } + + logEndSection(); +} + +async function runRestart(args) { + logSection("Restarting OmniRoute Server"); + + const portArg = args.find((a) => a.startsWith("--port="))?.split("=")[1] || String(API_PORT); + + // Stop first + await runStop([]); + + // Small delay + await sleep(1000); + + // Start with same port + log("Starting server...", "dim"); + await runServe(["--port=" + portArg]); + + logEndSection(); +} + +// ============================================================================ +// API KEY MANAGEMENT +// ============================================================================ + +const VALID_PROVIDERS = [ + "openai", + "anthropic", + "google", + "deepseek", + "groq", + "mistral", + "xai", + "cohere", + "google-generativeai", + "azure", + "aws", + "bedrock", + "perplexity", + "together", + "fireworks", + "huggingface", + "nvidia", + "cerebras", + "siliconflow", + "nebius", + "openrouter", + "ollama", +]; + +async function runKeys(args) { + const action = args[0]; + + if (!action) { + logSection("API Key Management"); + log("Usage:"); + log(" omniroute keys add "); + log(" omniroute keys list"); + log(" omniroute keys remove "); + log(""); + log(`Valid providers: ${VALID_PROVIDERS.join(", ")}`, "dim"); + logEndSection(); + return; + } + + switch (action) { + case "add": + await runKeysAdd(args.slice(1)); + break; + case "list": + await runKeysList(args.slice(1)); + break; + case "remove": + await runKeysRemove(args.slice(1)); + break; + default: + log(`Unknown action: ${action}`, "red"); + log("Valid actions: add, list, remove", "dim"); + } +} + +async function runKeysAdd(args) { + const provider = args[0]; + const apiKey = args[1]; + + if (!provider || !apiKey) { + log("Usage: omniroute keys add ", "red"); + return; + } + + const providerLower = provider.toLowerCase(); + if (!VALID_PROVIDERS.includes(providerLower)) { + log(`Invalid provider. Valid: ${VALID_PROVIDERS.join(", ")}`, "red"); + return; + } + + logSection(`Adding API Key for ${provider}`); + + // Try API first + const serverRunning = await checkServerHealth(); + + if (serverRunning) { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/v1/providers/keys`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerLower, apiKey }), + }); + + if (res.ok) { + log(`API key for ${provider} added successfully via API`, "green"); + logEndSection(); + return; + } + } catch {} + } + + // Direct DB fallback + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "red"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + // Check if connection exists + const existing = db + .prepare("SELECT id FROM provider_connections WHERE provider_id = ?") + .get(providerLower); + + if (existing) { + db.prepare("UPDATE provider_connections SET api_key = ? WHERE provider_id = ?").run( + apiKey, + providerLower + ); + log(`API key for ${provider} updated`, "green"); + } else { + db.prepare( + "INSERT INTO provider_connections (provider_id, api_key, name, enabled) VALUES (?, ?, ?, 1)" + ).run(providerLower, apiKey, provider); + log(`API key for ${provider} added`, "green"); + } + + db.close(); + } catch (err) { + log(`Failed to save key: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runKeysList(args) { + logSection("Configured API Keys"); + + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "yellow"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + const keys = db + .prepare( + ` + SELECT provider_id, api_key, enabled + FROM provider_connections + WHERE api_key IS NOT NULL AND api_key != '' + ` + ) + .all(); + + db.close(); + + if (keys.length === 0) { + log("No API keys configured", "yellow"); + } else { + for (const key of keys) { + const masked = + key.api_key && key.api_key.length > 8 + ? key.api_key.slice(0, 6) + "***" + key.api_key.slice(-4) + : "***"; + const status = key.enabled + ? colorize("● enabled", "green") + : colorize("○ disabled", "yellow"); + log(` ${key.provider_id.padEnd(20)} ${masked.padEnd(20)} ${status}`); + } + } + } catch (err) { + log(`Error reading keys: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runKeysRemove(args) { + const provider = args[0]; + + if (!provider) { + log("Usage: omniroute keys remove ", "red"); + return; + } + + logSection(`Removing API Key for ${provider}`); + + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "red"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + const result = db + .prepare("DELETE FROM provider_connections WHERE provider_id = ?") + .run(provider.toLowerCase()); + + if (result.changes > 0) { + log(`API key for ${provider} removed`, "green"); + } else { + log(`No API key found for ${provider}`, "yellow"); + } + + db.close(); + } catch (err) { + log(`Failed to remove key: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// MODEL BROWSER +// ============================================================================ + +async function runModels(args) { + const providerFilter = args[0] && !args[0].startsWith("--") ? args[0] : null; + const jsonOutput = args.includes("--json"); + const searchQuery = args.find((a) => a.startsWith("--search="))?.split("=")[1]; + + logSection("Available Models"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve' or 'omniroute'", "red"); + logEndSection(); + return; + } + + try { + // Try various endpoints + let models = []; + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/models`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + const data = await res.json(); + models = data.models || data || []; + } + } catch {} + + // Fallback: try provider registry + if (models.length === 0) { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/v1/models`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + models = await res.json(); + } + } catch {} + } + + // Filter by provider if specified + if (providerFilter) { + const filter = providerFilter.toLowerCase(); + models = models.filter( + (m) => + (m.provider && m.provider.toLowerCase().includes(filter)) || + (m.id && m.id.toLowerCase().startsWith(filter)) || + (m.name && m.name.toLowerCase().includes(filter)) + ); + } + + // Search filter + if (searchQuery) { + const search = searchQuery.toLowerCase(); + models = models.filter( + (m) => + (m.id && m.id.toLowerCase().includes(search)) || + (m.name && m.name.toLowerCase().includes(search)) || + (m.provider && m.provider.toLowerCase().includes(search)) || + (m.description && m.description.toLowerCase().includes(search)) + ); + } + + if (jsonOutput) { + console.log(JSON.stringify(models, null, 2)); + logEndSection(); + return; + } + + if (models.length === 0) { + log("No models found", "yellow"); + logEndSection(); + return; + } + + // Display as formatted table + console.log(); + console.log(colorize(" Model".padEnd(45) + "Provider".padEnd(20) + "Context", "cyan")); + console.log( + colorize(" " + "─".repeat(44) + " " + "─".repeat(19) + " " + "─".repeat(10), "dim") + ); + + const displayModels = models.slice(0, 50); + for (const model of displayModels) { + const name = (model.id || model.name || "unknown").slice(0, 43); + const provider = (model.provider || "unknown").slice(0, 18); + const context = model.context_length || model.max_tokens || model.contextWindow || "-"; + console.log(` ${name.padEnd(45)}${provider.padEnd(20)}${String(context).padEnd(10)}`); + } + + console.log(); + if (models.length > 50) { + log(`... and ${models.length - 50} more models. Use --json for full list.`, "dim"); + } + + log(`Total: ${models.length} models`, "green"); + } catch (err) { + log(`Failed to fetch models: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// COMBO MANAGEMENT +// ============================================================================ + +async function runCombo(args) { + const action = args[0]; + + if (!action) { + logSection("Combo Management"); + log("Usage:"); + log(" omniroute combo list"); + log(" omniroute combo switch "); + log(" omniroute combo create "); + log(" omniroute combo delete "); + log(""); + log("Strategies: priority, weighted, round-robin, p2c, random, auto, lkgp", "dim"); + logEndSection(); + return; + } + + switch (action) { + case "list": + await runComboList(args.slice(1)); + break; + case "switch": + await runComboSwitch(args.slice(1)); + break; + case "create": + await runComboCreate(args.slice(1)); + break; + case "delete": + await runComboDelete(args.slice(1)); + break; + default: + log(`Unknown action: ${action}`, "red"); + log("Valid actions: list, switch, create, delete", "dim"); + } +} + +async function runComboList(args) { + const jsonOutput = args.includes("--json"); + + logSection("Routing Combos"); + + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "yellow"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + const combos = db + .prepare( + ` + SELECT id, name, strategy, enabled, target_count + FROM combos + ORDER BY name + ` + ) + .all(); + + // Get active combo + let activeCombo = null; + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/combos/active`, { + signal: AbortSignal.timeout(3000), + }); + if (res.ok) { + const data = await res.json(); + activeCombo = data.active || data.name || data.combo; + } + } catch {} + + db.close(); + + if (jsonOutput) { + console.log(JSON.stringify({ combos, active: activeCombo }, null, 2)); + logEndSection(); + return; + } + + if (combos.length === 0) { + log("No combos configured", "yellow"); + } else { + for (const combo of combos) { + const isActive = activeCombo && (combo.name === activeCombo || combo.id === activeCombo); + const icon = isActive ? colorize("●", "green") : colorize("○", "dim"); + const status = combo.enabled ? colorize("enabled", "green") : colorize("disabled", "red"); + const strategy = combo.strategy || "priority"; + console.log(` ${icon} ${combo.name.padEnd(25)} [${strategy.padEnd(12)}] ${status}`); + } + } + } catch (err) { + log(`Error reading combos: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runComboSwitch(args) { + const name = args[0]; + + if (!name) { + log("Usage: omniroute combo switch ", "red"); + return; + } + + logSection(`Switching to Combo: ${name}`); + + // Try API first + const serverRunning = await checkServerHealth(); + + if (serverRunning) { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/combos/switch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + + if (res.ok) { + log(`Switched to combo '${name}'`, "green"); + logEndSection(); + return; + } + } catch {} + } + + // Direct DB fallback + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "red"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + // Check combo exists + const combo = db.prepare("SELECT id FROM combos WHERE name = ?").get(name); + + if (!combo) { + log(`Combo '${name}' not found`, "red"); + db.close(); + logEndSection(); + return; + } + + // Update settings to set active combo + const settingsPath = join(dataDir, "settings.json"); + let settings = {}; + if (existsSync(settingsPath)) { + try { + settings = JSON.parse(readFileSync(settingsPath, "utf8")); + } catch {} + } + + settings.activeCombo = name; + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8"); + + log(`Switched to combo '${name}'`, "green"); + db.close(); + } catch (err) { + log(`Failed to switch combo: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runComboCreate(args) { + const name = args[0]; + const strategy = args[1] || "priority"; + + if (!name) { + log("Usage: omniroute combo create [strategy]", "red"); + return; + } + + const validStrategies = [ + "priority", + "weighted", + "round-robin", + "p2c", + "random", + "auto", + "lkgp", + "context-optimized", + "context-relay", + ]; + if (!validStrategies.includes(strategy)) { + log(`Invalid strategy. Valid: ${validStrategies.join(", ")}`, "red"); + return; + } + + logSection(`Creating Combo: ${name}`); + + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "red"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + // Check if combo already exists + const existing = db.prepare("SELECT id FROM combos WHERE name = ?").get(name); + + if (existing) { + log(`Combo '${name}' already exists. Use 'combo delete' first.`, "red"); + db.close(); + logEndSection(); + return; + } + + // Insert new combo + db.prepare( + ` + INSERT INTO combos (name, strategy, enabled, target_count) + VALUES (?, ?, 1, 0) + ` + ).run(name, strategy); + + log(`Combo '${name}' created with strategy '${strategy}'`, "green"); + log("Use 'omniroute combo switch " + name + "' to activate", "dim"); + db.close(); + } catch (err) { + log(`Failed to create combo: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runComboDelete(args) { + const name = args[0]; + + if (!name) { + log("Usage: omniroute combo delete ", "red"); + return; + } + + logSection(`Deleting Combo: ${name}`); + + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "red"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + const result = db.prepare("DELETE FROM combos WHERE name = ?").run(name); + + if (result.changes > 0) { + log(`Combo '${name}' deleted`, "green"); + } else { + log(`Combo '${name}' not found`, "yellow"); + } + + db.close(); + } catch (err) { + log(`Failed to delete combo: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// SHELL COMPLETION +// ============================================================================ + +const VALID_SHELLS = ["bash", "zsh", "fish"]; + +async function runCompletion(args) { + const shell = args[0]; + + if (!shell) { + logSection("Shell Completion"); + log("Usage:"); + log(" omniroute completion bash"); + log(" omniroute completion zsh"); + log(" omniroute completion fish"); + log(""); + log("To install:"); + log(" bash: omniroute completion bash > ~/.bash_completion"); + log(" zsh: omniroute completion zsh > ~/.zsh/completions/_omniroute", "dim"); + logEndSection(); + return; + } + + if (!VALID_SHELLS.includes(shell)) { + log(`Invalid shell. Valid: ${VALID_SHELLS.join(", ")}`, "red"); + return; + } + + switch (shell) { + case "bash": + console.log(generateBashCompletion()); + break; + case "zsh": + console.log(generateZshCompletion()); + break; + case "fish": + console.log(generateFishCompletion()); + break; + } +} + +function generateBashCompletion() { + const script = `#!/bin/bash +# OmniRoute CLI Bash Completion + +_omniroute() { + local cur prev opts cmds + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + + opts="--help --version" + cmds="setup doctor status logs provider config test update serve stop restart keys models combo completion dashboard" + + # Command-specific options + case "\${prev}" in + setup) + COMPREPLY=($(compgen -W "--tools --url --key --list" -- \${cur})) + return 0 + ;; + logs) + COMPREPLY=($(compgen -W "--lines --level --follow" -- \${cur})) + return 0 + ;; + doctor) + COMPREPLY=($(compgen -W "--verbose" -- \${cur})) + return 0 + ;; + status) + COMPREPLY=($(compgen -W "--json" -- \${cur})) + return 0 + ;; + keys) + COMPREPLY=($(compgen -W "add list remove" -- \${cur})) + return 0 + ;; + keys add) + COMPREPLY=($(compgen -W "openai anthropic google deepseek groq mistral xai cohere" -- \${cur})) + return 0 + ;; + models) + COMPREPLY=($(compgen -W "--json openai anthropic google deepseek groq" -- \${cur})) + return 0 + ;; + combo) + COMPREPLY=($(compgen -W "list switch create delete" -- \${cur})) + return 0 + ;; + provider) + COMPREPLY=($(compgen -W "list add" -- \${cur})) + return 0 + ;; + completion) + COMPREPLY=($(compgen -W "bash zsh fish" -- \${cur})) + return 0 + ;; + serve) + COMPREPLY=($(compgen -W "--port --daemon" -- \${cur})) + return 0 + ;; + test) + COMPREPLY=($(compgen -W "--provider --model" -- \${cur})) + return 0 + ;; + dashboard) + COMPREPLY=($(compgen -W "--url" -- \${cur})) + return 0 + ;; + config) + COMPREPLY=($(compgen -W "show" -- \${cur})) + return 0 + ;; + *) + COMPREPLY=($(compgen -W "\${cmds} \${opts}" -- \${cur})) + return 0 + ;; + esac +} + +complete -F _omniroute omniroute +`; + return script; +} + +function generateZshCompletion() { + return `#compdef omniroute + +local -a commands +commands=( + 'setup:Configure CLI tools to use OmniRoute' + 'doctor:Run health diagnostics' + 'status:Show server and tools status' + 'logs:View application logs' + 'provider:Add OmniRoute as provider' + 'config:Show configuration' + 'test:Test provider connectivity' + 'update:Check for updates' + 'serve:Start the server' + 'stop:Stop the server' + 'restart:Restart the server' + 'keys:Manage API keys' + 'models:Browse available models' + 'combo:Manage routing combos' + 'completion:Generate shell completion' + 'dashboard:Open dashboard' +) + +_arguments -C \\ + '1: :->command' \\ + '*:: :->arg' \\ + && return 0 + +case $state in + command) + _describe 'command' commands + ;; + arg) + case $words[1] in + setup) + _arguments '--tools[Tools to configure]:tools:(claude codex opencode cline kilo continue openclaw)' '--url[Base URL]:url:' '--key[API Key]:key:' '--list[List available tools' + ;; + keys) + case $words[2] in + add) + _arguments '2:provider:(openai anthropic google deepseek groq mistral xai cohere)' '3:api-key:' + ;; + remove) + _arguments '2:provider:(openai anthropic google deepseek groq mistral xai cohere)' + ;; + *) + _describe 'subcommand' 'add:Add API key' 'list:List keys' 'remove:Remove key' + ;; + esac + ;; + combo) + _describe 'subcommand' 'list:List combos' 'switch:Switch combo' 'create:Create combo' 'delete:Delete combo' + ;; + completion) + _arguments '2:shell:(bash zsh fish)' + ;; + serve) + _arguments '--port[Port number]:port:' '--daemon[Run in background' + ;; + models) + _arguments '--json[JSON output]' '2:provider:(openai anthropic google deepseek groq)' + ;; + logs) + _arguments '--lines[Number of lines]:lines:' '--level[Log level]:level:(debug info warn error)' '--follow[Follow logs]' + ;; + esac + ;; +esac +`; +} + +function generateFishCompletion() { + return `# OmniRoute CLI Fish Completion + +complete -c omniroute -f + +# Main commands +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'setup' -d 'Configure CLI tools' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'doctor' -d 'Run health diagnostics' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'status' -d 'Show status' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'logs' -d 'View logs' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'provider' -d 'Provider management' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'config' -d 'Show config' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'test' -d 'Test connectivity' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'update' -d 'Check updates' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'serve' -d 'Start server' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'stop' -d 'Stop server' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'restart' -d 'Restart server' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'keys' -d 'API key management' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'models' -d 'Browse models' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'combo' -d 'Combo management' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'completion' -d 'Shell completion' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'dashboard' -d 'Open dashboard' + +# Subcommands +complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add' -d 'Add key' +complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'list' -d 'List keys' +complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'remove' -d 'Remove key' + +complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list' -d 'List combos' +complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'switch' -d 'Switch combo' +complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'create' -d 'Create combo' +complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'delete' -d 'Delete combo' + +complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'bash' -d 'Bash completion' +complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh' -d 'Zsh completion' +complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'fish' -d 'Fish completion' +`; +} + +// ============================================================================ +// DASHBOARD COMMAND +// ============================================================================ + +async function runDashboard(args) { + const urlOnly = args.includes("--url"); + + const dashboardUrl = `http://localhost:${DASHBOARD_PORT}`; + + if (urlOnly) { + console.log(dashboardUrl); + return; + } + + logSection("Opening Dashboard"); + + try { + const { execSync } = require("node:child_process"); + const platform = process.platform; + + let command; + if (platform === "darwin") { + command = `open "${dashboardUrl}"`; + } else if (platform === "win32") { + command = `start "" "${dashboardUrl}"`; + } else { + command = `xdg-open "${dashboardUrl}" 2>/dev/null || sensible-browser "${dashboardUrl}" 2>/dev/null || echo "Cannot open browser. Go to: ${dashboardUrl}"`; + } + + execSync(command, { stdio: "ignore" }); + log(`Opening: ${dashboardUrl}`, "green"); + } catch { + log(`Open in browser: ${dashboardUrl}`, "yellow"); + } + + logEndSection(); +} + +// ============================================================================ +// BACKUP & RESTORE +// ============================================================================ + +async function runBackup(args) { + const dataDir = resolveConfigPath(".omniroute"); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const backupDir = join(dataDir, "backups"); + const backupName = `omniroute-backup-${timestamp}`; + const backupPath = join(backupDir, backupName); + + logSection("Creating Backup"); + + try { + // Ensure backup directory exists + if (!existsSync(backupDir)) { + mkdirSync(backupDir, { recursive: true }); + } + + // Files to backup + const filesToBackup = [ + { name: "storage.sqlite", dest: "storage.sqlite" }, + { name: "settings.json", dest: "settings.json" }, + { name: "combos.json", dest: "combos.json" }, + { name: "providers.json", dest: "providers.json" }, + ]; + + let backedUp = 0; + let skipped = 0; + + for (const file of filesToBackup) { + const sourcePath = join(dataDir, file.name); + if (existsSync(sourcePath)) { + const destPath = join(backupPath, file.dest); + mkdirSync(dirname(destPath), { recursive: true }); + copyFileSync(sourcePath, destPath); + backedUp++; + } else { + skipped++; + } + } + + if (backedUp > 0) { + // Create backup info + const info = { + timestamp: new Date().toISOString(), + version: "omniroute-cli-v1", + files: filesToBackup.filter((f) => existsSync(join(dataDir, f.name))).map((f) => f.name), + }; + writeFileSync(join(backupPath, "backup-info.json"), JSON.stringify(info, null, 2), "utf8"); + + log(`Backup created: ${backupName}`, "green"); + log(`Files: ${backedUp} backed up, ${skipped} skipped`, "dim"); + log(`Location: ${backupPath}`, "dim"); + } else { + log("No files to backup (database not initialized)", "yellow"); + } + } catch (err) { + log(`Backup failed: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runRestore(args) { + const backupName = args[0]; + const dataDir = resolveConfigPath(".omniroute"); + const backupDir = join(dataDir, "backups"); + + if (!backupName) { + logSection("Available Backups"); + if (!existsSync(backupDir)) { + log("No backups found", "yellow"); + logEndSection(); + return; + } + + try { + const dirs = readdirSync(backupDir).filter((f) => f.startsWith("omniroute-backup-")); + if (dirs.length === 0) { + log("No backups found", "yellow"); + } else { + for (const dir of dirs.sort().reverse()) { + const infoPath = join(backupDir, dir, "backup-info.json"); + if (existsSync(infoPath)) { + const info = JSON.parse(readFileSync(infoPath, "utf8")); + log(` ${dir.replace("omniroute-backup-", "")}`); + log( + ` ${new Date(info.timestamp).toLocaleString()} - ${info.files?.length || 0} files`, + "dim" + ); + } else { + log(` ${dir.replace("omniroute-backup-", "")}`, "dim"); + } + } + } + } catch (err) { + log(`Error listing backups: ${err.message}`, "red"); + } + logEndSection(); + console.log("Usage: omniroute restore "); + return; + } + + logSection(`Restoring from: ${backupName}`); + + const backupPath = join(backupDir, `omniroute-backup-${backupName}`); + + if (!existsSync(backupPath)) { + log(`Backup not found: ${backupName}`, "red"); + logEndSection(); + return; + } + + try { + // Restore files + const filesToRestore = [ + { name: "storage.sqlite", dest: "storage.sqlite" }, + { name: "settings.json", dest: "settings.json" }, + { name: "combos.json", dest: "combos.json" }, + { name: "providers.json", dest: "providers.json" }, + ]; + + for (const file of filesToRestore) { + const sourcePath = join(backupPath, file.dest); + if (existsSync(sourcePath)) { + const destPath = join(dataDir, file.name); + copyFileSync(sourcePath, destPath); + log(`Restored: ${file.name}`, "dim"); + } + } + + log("Backup restored successfully!", "green"); + log("Restart OmniRoute to load restored data", "dim"); + } catch (err) { + log(`Restore failed: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// QUOTA MANAGEMENT +// ============================================================================ + +async function runQuota(args) { + const jsonOutput = args.includes("--json"); + + logSection("Provider Quota Usage"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "red"); + logEndSection(); + return; + } + + try { + // Try quota endpoint + let quotaData = null; + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/quota`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + quotaData = await res.json(); + } + } catch {} + + // Fallback: get from providers + if (!quotaData) { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/v1/providers`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + const providers = await res.json(); + quotaData = { + providers: providers.map((p) => ({ + provider: p.name || p.id, + quota: p.quota || p.remaining || "N/A", + used: p.used || 0, + reset: p.resetAt || "N/A", + })), + }; + } + } catch {} + } + + if (jsonOutput) { + console.log(JSON.stringify(quotaData || { error: "No quota data" }, null, 2)); + logEndSection(); + return; + } + + if (!quotaData?.providers) { + log("No quota information available", "yellow"); + logEndSection(); + return; + } + + console.log(); + console.log( + colorize( + " Provider".padEnd(25) + "Used".padEnd(15) + "Remaining".padEnd(20) + "Reset", + "cyan" + ) + ); + console.log( + colorize( + " " + "─".repeat(24) + " " + "─".repeat(14) + " " + "─".repeat(19) + " " + "─".repeat(15), + "dim" + ) + ); + + for (const p of quotaData.providers) { + const provider = (p.provider || "unknown").slice(0, 23); + const used = String(p.used || 0).padEnd(14); + const remaining = (p.quota || p.remaining || "N/A").toString().slice(0, 18); + const reset = p.reset || "N/A"; + console.log(` ${provider.padEnd(25)}${used.padEnd(15)}${remaining.padEnd(20)}${reset}`); + } + + log(`Total: ${quotaData.providers.length} providers`, "green"); + } catch (err) { + log(`Failed to fetch quota: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// HEALTH STATUS +// ============================================================================ + +async function runHealth(args) { + const verbose = args.includes("--verbose"); + const jsonOutput = args.includes("--json"); + + logSection("OmniRoute Health"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "red"); + logEndSection(); + return; + } + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/health`, { + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const health = await res.json(); + + if (jsonOutput) { + console.log(JSON.stringify(health, null, 2)); + logEndSection(); + return; + } + + // Display health info + log(`Status: ${colorize("healthy", "green")}`); + log(`Uptime: ${health.uptime || "N/A"}`); + log(`Version: ${health.version || "N/A"}`); + + if (health.breakers) { + console.log(); + logSection("Circuit Breakers"); + for (const [name, status] of Object.entries(health.breakers)) { + const state = + status.state === "closed" + ? colorize("● closed", "green") + : colorize("○ open", "yellow"); + log(` ${name.padEnd(20)} ${state}`); + } + } + + if (health.cache) { + console.log(); + logSection("Cache Status"); + log(` Semantic: ${health.cache.semanticHits || 0} hits`); + log(` Signature: ${health.cache.signatureHits || 0} hits`); + } + + if (verbose && health.memory) { + console.log(); + logSection("Memory"); + log(` RSS: ${health.memory.rss || "N/A"}`); + log(` Heap Used: ${health.memory.heapUsed || "N/A"}`); + } + } + } catch (err) { + log(`Failed to get health: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// CACHE MANAGEMENT +// ============================================================================ + +async function runCache(args) { + const action = args[0]; + + if (!action || action === "status") { + logSection("Cache Status"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "yellow"); + logEndSection(); + return; + } + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/cache/stats`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + const stats = await res.json(); + log(`Semantic Cache: ${stats.semanticHits || 0} hits`); + log(`Signature Cache: ${stats.signatureHits || 0} hits`); + } else { + log("Cache stats not available", "yellow"); + } + } catch { + log("Cache stats not available", "yellow"); + } + logEndSection(); + return; + } + + if (action === "clear") { + logSection("Clearing Cache"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Cannot clear cache.", "red"); + logEndSection(); + return; + } + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/cache/clear`, { + method: "POST", + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + log("Cache cleared successfully!", "green"); + } else { + log("Failed to clear cache", "red"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + logEndSection(); + return; + } + + log(`Unknown cache action: ${action}`, "red"); + log("Valid actions: status, clear", "dim"); +} + +// ============================================================================ +// MCP SERVER STATUS +// ============================================================================ + +async function runMcp(args) { + const action = args[0] || "status"; + + logSection("MCP Server"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "red"); + logEndSection(); + return; + } + + if (action === "status" || action === "list") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/mcp/status`, { + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const status = await res.json(); + log( + `Status: ${status.running ? colorize("running", "green") : colorize("stopped", "red")}` + ); + log(`Tools: ${status.toolsCount || 0}`); + log(`Transport: ${status.transport || "stdio"}`); + + if (status.scopes) { + console.log(); + log("Scopes:"); + for (const scope of status.scopes) { + log(` - ${scope}`); + } + } + } else { + log("MCP status not available", "yellow"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else if (action === "restart") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/mcp/restart`, { + method: "POST", + signal: AbortSignal.timeout(10000), + }); + + if (res.ok) { + log("MCP server restarted", "green"); + } else { + log("Failed to restart MCP", "red"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else { + log(`Unknown action: ${action}`, "red"); + log("Valid actions: status, list, restart", "dim"); + } + + logEndSection(); +} + +// ============================================================================ +// A2A SERVER STATUS +// ============================================================================ + +async function runA2a(args) { + const action = args[0] || "status"; + + logSection("A2A Server"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "red"); + logEndSection(); + return; + } + + if (action === "status" || action === "list") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/a2a/status`, { + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const status = await res.json(); + log( + `Status: ${status.running ? colorize("running", "green") : colorize("stopped", "red")}` + ); + log(`Protocol: ${status.protocol || "JSON-RPC 2.0"}`); + log(`Tasks: ${status.activeTasks || 0} active`); + + if (status.skills) { + console.log(); + log("Skills:"); + for (const skill of status.skills) { + log(` - ${skill.name}: ${skill.description || "N/A"}`, "dim"); + } + } + } else { + log("A2A status not available", "yellow"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else if (action === "card") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/.well-known/agent.json`, { + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const card = await res.json(); + console.log(JSON.stringify(card, null, 2)); + } else { + log("Agent card not available", "yellow"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else { + log(`Unknown action: ${action}`, "red"); + log("Valid actions: status, list, card", "dim"); + } + + logEndSection(); +} + +// ============================================================================ +// TUNNEL MANAGEMENT +// ============================================================================ + +async function runTunnel(args) { + const action = args[0]; + + logSection("Tunnel Management"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "red"); + logEndSection(); + return; + } + + if (!action || action === "list") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/tunnels`, { + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const tunnels = await res.json(); + + if (tunnels.length === 0) { + log("No active tunnels", "yellow"); + } else { + for (const t of tunnels) { + const status = t.active ? colorize("● active", "green") : colorize("○ inactive", "dim"); + log(` ${t.type || "unknown"}: ${t.url || "N/A"} ${status}`); + } + } + } else { + log("Tunnel info not available", "yellow"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else if (action === "create" || action === "add") { + const tunnelType = args[1] || "cloudflare"; + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/tunnels`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: tunnelType }), + signal: AbortSignal.timeout(15000), + }); + + if (res.ok) { + const result = await res.json(); + log(`Tunnel created: ${result.url}`, "green"); + } else { + log("Failed to create tunnel", "red"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else if (action === "stop" || action === "delete") { + const tunnelType = args[1]; + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/tunnels/${tunnelType}`, { + method: "DELETE", + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + log(`Tunnel ${tunnelType} stopped`, "green"); + } else { + log("Failed to stop tunnel", "red"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else { + log("Valid actions: list, create , stop "); + log("Types: cloudflare, tailscale, ngrok", "dim"); + } + + logEndSection(); +} + +// ============================================================================ +// ENVIRONMENT VARIABLES +// ============================================================================ + +async function runEnv(args) { + const action = args[0]; + + if (!action || action === "show" || action === "list") { + logSection("Environment Variables"); + + const importantVars = [ + "PORT", + "API_PORT", + "DASHBOARD_PORT", + "DATA_DIR", + "REQUIRE_API_KEY", + "LOG_LEVEL", + "NODE_ENV", + "REQUEST_TIMEOUT_MS", + "ENABLE_SOCKS5_PROXY", + ]; + + log("Current configuration:"); + console.log(); + + for (const key of importantVars) { + const value = process.env[key]; + if (value !== undefined) { + log(` ${key.padEnd(25)} ${value}`, "dim"); + } + } + + console.log(); + log("Defaults:", "dim"); + log(" PORT 20128"); + log(" DASHBOARD_PORT 20129"); + log(" DATA_DIR ~/.omniroute"); + logEndSection(); + return; + } + + if (action === "get") { + const key = args[1]; + if (!key) { + log("Usage: omniroute env get ", "red"); + return; + } + console.log(process.env[key] || ""); + return; + } + + if (action === "set") { + const key = args[1]; + const value = args[2]; + + if (!key || value === undefined) { + log("Usage: omniroute env set ", "red"); + return; + } + + log(`Setting ${key}=${value} (temporary - only affects current session)`, "yellow"); + process.env[key] = value; + log("Set successfully (note: this is temporary)", "green"); + return; + } + + log("Valid actions: show, get , set ", "dim"); +} diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index 8202818956..1ce5ca86f7 100644 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -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 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 Get specific env var + omniroute env set 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")) { diff --git a/docs/AUTO-COMBO.md b/docs/AUTO-COMBO.md index afa5463279..c042204b81 100644 --- a/docs/AUTO-COMBO.md +++ b/docs/AUTO-COMBO.md @@ -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: + +# 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**: diff --git a/docs/screenshots/10-providers-aitradepulse.png b/docs/screenshots/10-providers-aitradepulse.png new file mode 100644 index 0000000000..f0f6324d2b Binary files /dev/null and b/docs/screenshots/10-providers-aitradepulse.png differ diff --git a/docs/screenshots/11-combos-aitradepulse.png b/docs/screenshots/11-combos-aitradepulse.png new file mode 100644 index 0000000000..ded3f6a9e6 Binary files /dev/null and b/docs/screenshots/11-combos-aitradepulse.png differ diff --git a/docs/screenshots/ai-aitradepulse-dashboard.png b/docs/screenshots/ai-aitradepulse-dashboard.png new file mode 100644 index 0000000000..5dac9818a5 Binary files /dev/null and b/docs/screenshots/ai-aitradepulse-dashboard.png differ diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index b0c18cafd0..359214aabb 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -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 = { 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 = { 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 = { 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 = { 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" }, diff --git a/open-sse/config/errorConfig.ts b/open-sse/config/errorConfig.ts index 84c6df8bd4..3bcad8edcc 100644 --- a/open-sse/config/errorConfig.ts +++ b/open-sse/config/errorConfig.ts @@ -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", diff --git a/open-sse/config/providerHeaderProfiles.ts b/open-sse/config/providerHeaderProfiles.ts index 99cf3386bb..2e58750055 100644 --- a/open-sse/config/providerHeaderProfiles.ts +++ b/open-sse/config/providerHeaderProfiles.ts @@ -186,17 +186,3 @@ export function getCursorRegistryHeaders( "User-Agent": getCursorUserAgent(version), }; } - -export function getCursorUsageHeaders( - accessToken: string, - version = CURSOR_REGISTRY_VERSION -): Record { - const userAgent = getCursorUserAgent(version); - return { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - "User-Agent": userAgent, - "x-cursor-client-version": version, - "x-cursor-user-agent": userAgent, - }; -} diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 52e769aa19..df4ae34c3d 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -553,20 +553,24 @@ export const REGISTRY: Record = { 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 = { 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" }, ], }, diff --git a/open-sse/config/runway.ts b/open-sse/config/runway.ts index a76719d757..165a9af4c2 100644 --- a/open-sse/config/runway.ts +++ b/open-sse/config/runway.ts @@ -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 || "") diff --git a/open-sse/executors/pollinations.ts b/open-sse/executors/pollinations.ts index 653026fdf3..cd6219d8f1 100644 --- a/open-sse/executors/pollinations.ts +++ b/open-sse/executors/pollinations.ts @@ -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; } } diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 2342a6d9ea..ffa2542aa8 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -428,7 +428,21 @@ async function handleHuggingFaceTtsSpeech(providerConfig, body, modelId, token) * POST { text, voiceId, modelId, audioConfig } → JSON { audioContent: "" } * 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, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index fbcde7c28c..5e60a86260 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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, + timeoutMs: number +): Promise> { + 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 { + if ( + !upstreamStream || + !response.body || + (!contentType.includes("text/event-stream") && !contentType.includes("application/x-ndjson")) + ) { + return withBodyTimeout(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 | 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( readable: ReadableStream, 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(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(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 = { ...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", diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index 44beadf0da..7432590a4f 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -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); } } diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 446b9510c1..7ec998d196 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -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>, + 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); } diff --git a/open-sse/services/autoCombo/autoPrefix.ts b/open-sse/services/autoCombo/autoPrefix.ts new file mode 100644 index 0000000000..18be4d9db7 --- /dev/null +++ b/open-sse/services/autoCombo/autoPrefix.ts @@ -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" }; +} diff --git a/open-sse/services/autoCombo/providerRegistryAccessor.ts b/open-sse/services/autoCombo/providerRegistryAccessor.ts new file mode 100644 index 0000000000..69e8e13467 --- /dev/null +++ b/open-sse/services/autoCombo/providerRegistryAccessor.ts @@ -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; +} diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts new file mode 100644 index 0000000000..a38c45421a --- /dev/null +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -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 { + 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, + }; +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 8424b89af2..86187a01a0 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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 ({ + 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 = { + 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 { - 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 = {}; - 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, }; diff --git a/open-sse/translator/helpers/geminiToolsSanitizer.ts b/open-sse/translator/helpers/geminiToolsSanitizer.ts index d76f23cf7c..3d36dc3468 100644 --- a/open-sse/translator/helpers/geminiToolsSanitizer.ts +++ b/open-sse/translator/helpers/geminiToolsSanitizer.ts @@ -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; } diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 14bccca754..50f2973bb7 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -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 diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 1eb87198dc..ff3957aa38 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -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>; tools?: Array>; }; }; + _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, + 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; }; }; @@ -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, }), diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 410b876ef6..8a84469d4a 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -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) diff --git a/open-sse/utils/streamReadiness.ts b/open-sse/utils/streamReadiness.ts index 5899d27106..ef6cb71918 100644 --- a/open-sse/utils/streamReadiness.ts +++ b/open-sse/utils/streamReadiness.ts @@ -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"})` diff --git a/scripts/check-docs-sync.mjs b/scripts/check-docs-sync.mjs index 7b7aab5860..c8434fa185 100644 --- a/scripts/check-docs-sync.mjs +++ b/scripts/check-docs-sync.mjs @@ -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)); } diff --git a/scripts/pack-artifact-policy.ts b/scripts/pack-artifact-policy.ts index 06793c10c6..5417abecce 100644 --- a/scripts/pack-artifact-policy.ts +++ b/scripts/pack-artifact-policy.ts @@ -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", diff --git a/src/app/(dashboard)/dashboard/analytics/AutoRoutingAnalyticsTab.tsx b/src/app/(dashboard)/dashboard/analytics/AutoRoutingAnalyticsTab.tsx new file mode 100644 index 0000000000..abbb4db632 --- /dev/null +++ b/src/app/(dashboard)/dashboard/analytics/AutoRoutingAnalyticsTab.tsx @@ -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; + avgSelectionScore: number; + topProviders: Array<{ provider: string; count: number }>; + explorationRate: number; + lkgpHitRate: number; +} + +export default function AutoRoutingAnalyticsTab() { + const [stats, setStats] = useState(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 ( + +
+
+
+
+
+ ); + } + + if (!stats) { + return ( + +
+ No auto-routing analytics available. Make requests using the auto/ prefix to see metrics. +
+
+ ); + } + + return ( +
+ {/* Summary Cards */} +
+ +
+
+ auto_awesome +
+
+

Total Auto Requests

+

{stats.totalRequests.toLocaleString()}

+
+
+
+ + +
+
+ target +
+
+

Avg Selection Score

+

{(stats.avgSelectionScore * 100).toFixed(1)}%

+
+
+
+ + +
+
+ explore +
+
+

Exploration Rate

+

{(stats.explorationRate * 100).toFixed(1)}%

+
+
+
+ + +
+
+ history +
+
+

LKGP Hit Rate

+

{(stats.lkgpHitRate * 100).toFixed(1)}%

+
+
+
+
+ + {/* Variant Breakdown */} + +

Requests by Variant

+
+ {Object.entries(stats.variantBreakdown).map(([variant, count]) => { + const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0; + return ( +
+
{variant || "default"}
+
+
+
+
+ {count.toLocaleString()} ({percentage.toFixed(1)}%) +
+
+ ); + })} +
+ + + {/* Top Providers */} + +

Top Routed Providers

+
+ + + + + + + + + + {stats.topProviders.map((provider, index) => { + const percentage = + stats.totalRequests > 0 ? (provider.count / stats.totalRequests) * 100 : 0; + return ( + + + + + + ); + })} + +
ProviderRequestsShare
+
+ #{index + 1} + {provider.provider} +
+
{provider.count.toLocaleString()} + {percentage.toFixed(1)}% +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/analytics/page.tsx b/src/app/(dashboard)/dashboard/analytics/page.tsx index b900d4351c..94f22dfab1 100644 --- a/src/app/(dashboard)/dashboard/analytics/page.tsx +++ b/src/app/(dashboard)/dashboard/analytics/page.tsx @@ -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} diff --git a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx index 5b2ba40bea..ae5b69f32e 100644 --- a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx @@ -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 = { { 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 = { }; const SPEECH_FORMATS = ["mp3", "wav", "opus", "flac", "pcm"]; -const SPEECH_FORMATS_BY_PROVIDER: Record = { - "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) { diff --git a/src/app/(dashboard)/dashboard/cloud-agents/page.tsx b/src/app/(dashboard)/dashboard/cloud-agents/page.tsx new file mode 100644 index 0000000000..ff803e4a0a --- /dev/null +++ b/src/app/(dashboard)/dashboard/cloud-agents/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [selectedTask, setSelectedTask] = useState(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 = { + 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 ( + + {status === "running" && } + {s.label} + + ); + }; + + const getAgentInfo = (providerId: string) => { + return CLOUD_AGENTS.find((a) => a.id === providerId) || CLOUD_AGENTS[0]; + }; + + if (loading) { + return ( +
+
+

{t("loading")}

+
+ ); + } + + return ( +
+
+
+

{t("title")}

+

{t("description")}

+
+
+ + +
+
+
+

{t("aboutTitle")}

+

{t("aboutDescription")}

+
+
+
+ {CLOUD_AGENTS.map((agent) => ( +
+
+ {agent.icon} +

{agent.name}

+
+

{agent.description}

+

{agent.provider}

+
+ ))} +
+
+ {t("howItWorksTitle")} + {t("howItWorksDesc")} +
+
+
+ + +
+
+ add_task +
+
+

{t("newTaskTitle")}

+

{t("newTaskDescription")}

+
+
+
+
+
+ + +
+
+
+ setNewTask({ ...newTask, description: e.target.value })} + required + /> +
+
+ +
+
+
+ +
+
+

{t("tasks")}

+ {tasks.length === 0 ? ( +
+ assignment +

{t("noTasks")}

+
+ ) : ( + tasks.map((task) => { + const agent = getAgentInfo(task.provider); + return ( + setSelectedTask(task)} + > +
+
+ {agent.icon} +
+

+ {task.description || t("untitledTask")} +

+

+ {agent.name} • {new Date(task.createdAt).toLocaleString()} +

+
+
+ {getStatusBadge(task.status)} +
+
+ ); + }) + )} +
+ +
+

{t("taskDetail")}

+ {selectedTask ? ( + +
+
+ {getAgentInfo(selectedTask.provider).icon} +
+

{getAgentInfo(selectedTask.provider).name}

+

+ {t("created")}: {new Date(selectedTask.createdAt).toLocaleString()} +

+
+
+ {getStatusBadge(selectedTask.status)} +
+ + {selectedTask.status === "waiting_approval" && selectedTask.plan && ( +
+
+ + description + + + {t("planReady")} + +
+
+                    {selectedTask.plan}
+                  
+
+ + +
+
+ )} + + {selectedTask.messages.length > 0 && ( +
+

{t("conversation")}

+
+ {selectedTask.messages.map((msg, i) => ( +
+ {msg.role}: + {msg.content} +
+ ))} +
+
+ )} + + {selectedTask.result && ( +
+
+ + check_circle + + + {t("result")} + +
+
+                    {selectedTask.result}
+                  
+
+ )} + + {selectedTask.error && ( +
+
+ + error + + {t("error")} +
+

{selectedTask.error}

+
+ )} + + {selectedTask.status === "running" && ( +
+ setMessageInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSendMessage()} + className="flex-1" + /> + +
+ )} + +
+ + +
+
+ ) : ( +
+ touch_app +

{t("selectTaskPrompt")}

+
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx b/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx index 3dabb89695..087aba2fbd 100644 --- a/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx +++ b/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx @@ -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(); 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) => { onChange({ @@ -64,7 +77,7 @@ export default function BuilderIntelligentStep({ ...patch, weights: { ...normalizedConfig.weights, - ...(patch.weights || {}), + ...((patch.weights as Record) || {}), }, }); }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index e1e4300c90..5a6a96491c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -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 && ( +
+
+ {cloudAgentProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + {/* Local / Self-Hosted Providers */} {localProviderEntries.length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index f3ca6f7105..744aedb3f0 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -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({ })) } /> +
+ +

+ 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". +

+
+
+ Use upstream 429 hints (breaker) + + {current.useUpstream429BreakerHints === true + ? "Yes" + : current.useUpstream429BreakerHints === false + ? "No" + : "Default"} + +
Max backoff steps {current.maxBackoffSteps} @@ -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); }} /> diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 7568bec217..82f5110939 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -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() { ))}
+ + +
+
+
+ +
+
+

Zero-Config Auto-Routing

+

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

+
+
+
+ +
+
+
+ +
+ {[ + { 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) => ( + + ))} +
+
+
); } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 615dd51c61..6851c621e3 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -26,6 +26,11 @@ const QUOTA_LABEL_MAP: Record = { "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 = { diff --git a/src/app/api/analytics/auto-routing/route.ts b/src/app/api/analytics/auto-routing/route.ts new file mode 100644 index 0000000000..65fe319303 --- /dev/null +++ b/src/app/api/analytics/auto-routing/route.ts @@ -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 = {}; + 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: [], + }); + } +} diff --git a/src/app/api/memory/[id]/route.ts b/src/app/api/memory/[id]/route.ts index a7be2e7748..b4a27e16c1 100644 --- a/src/app/api/memory/[id]/route.ts +++ b/src/app/api/memory/[id]/route.ts @@ -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); diff --git a/src/app/api/memory/route.ts b/src/app/api/memory/route.ts index 785b75002f..4d3cf913e1 100644 --- a/src/app/api/memory/route.ts +++ b/src/app/api/memory/route.ts @@ -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); diff --git a/src/app/api/model-combo-mappings/[id]/route.ts b/src/app/api/model-combo-mappings/[id]/route.ts index 88c0733c53..57d0cd6d74 100644 --- a/src/app/api/model-combo-mappings/[id]/route.ts +++ b/src/app/api/model-combo-mappings/[id]/route.ts @@ -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); diff --git a/src/app/api/model-combo-mappings/route.ts b/src/app/api/model-combo-mappings/route.ts index 359a7fb569..5b5e54dc62 100644 --- a/src/app/api/model-combo-mappings/route.ts +++ b/src/app/api/model-combo-mappings/route.ts @@ -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); diff --git a/src/app/api/oauth/cursor/import/route.ts b/src/app/api/oauth/cursor/import/route.ts index f2ba171d62..8e747c8041 100755 --- a/src/app/api/oauth/cursor/import/route.ts +++ b/src/app/api/oauth/cursor/import/route.ts @@ -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", }); diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index 25c0f27487..29a0cf6a64 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -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>, + 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 = { + 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 } + ); } } diff --git a/src/app/api/pricing/route.ts b/src/app/api/pricing/route.ts index 3ea891cb78..7db9659574 100644 --- a/src/app/api/pricing/route.ts +++ b/src/app/api/pricing/route.ts @@ -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"); diff --git a/src/app/api/pricing/sync/route.ts b/src/app/api/pricing/sync/route.ts index b60d78fccf..1ff9ece74f 100644 --- a/src/app/api/pricing/sync/route.ts +++ b/src/app/api/pricing/sync/route.ts @@ -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(); diff --git a/src/app/api/provider-nodes/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts index bc2d13c962..22d9136f73 100644 --- a/src/app/api/provider-nodes/validate/route.ts +++ b/src/app/api/provider-nodes/validate/route.ts @@ -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 { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index de67c6487b..4ee7e835ae 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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", diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 3925f88c11..4e954bc917 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -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 = { ...connection }; - delete result.apiKey; + if (!revealKeys) { + result.apiKey = result.apiKey ? maskStoredApiKey(result.apiKey) : undefined; + } delete result.accessToken; delete result.refreshToken; delete result.idToken; diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 802947bec7..6f964ed423 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -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, diff --git a/src/app/api/providers/validate/route.ts b/src/app/api/providers/validate/route.ts index 41f198d992..687d935bf9 100644 --- a/src/app/api/providers/validate/route.ts +++ b/src/app/api/providers/validate/route.ts @@ -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 { diff --git a/src/app/api/resilience/route.ts b/src/app/api/resilience/route.ts index 08fe0aeb05..c7c031ed7e 100644 --- a/src/app/api/resilience/route.ts +++ b/src/app/api/resilience/route.ts @@ -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; @@ -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, diff --git a/src/app/api/settings/export-json/route.ts b/src/app/api/settings/export-json/route.ts index 6eaa783728..0de7dbbe7a 100644 --- a/src/app/api/settings/export-json/route.ts +++ b/src/app/api/settings/export-json/route.ts @@ -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 = { 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: { diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index e3fada7b03..17fc459e74 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -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): 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) ` diff --git a/src/app/api/usage/history/route.ts b/src/app/api/usage/history/route.ts index 16a5d40725..6094e7baec 100644 --- a/src/app/api/usage/history/route.ts +++ b/src/app/api/usage/history/route.ts @@ -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); diff --git a/src/app/api/usage/logs/route.ts b/src/app/api/usage/logs/route.ts index b5b875ec94..ebb409d2f2 100644 --- a/src/app/api/usage/logs/route.ts +++ b/src/app/api/usage/logs/route.ts @@ -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); diff --git a/src/app/api/usage/request-logs/route.ts b/src/app/api/usage/request-logs/route.ts index 0ae5e961cc..0e335c26d9 100644 --- a/src/app/api/usage/request-logs/route.ts +++ b/src/app/api/usage/request-logs/route.ts @@ -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); diff --git a/src/app/api/v1/agents/tasks/[id]/route.ts b/src/app/api/v1/agents/tasks/[id]/route.ts new file mode 100644 index 0000000000..031bd51a29 --- /dev/null +++ b/src/app/api/v1/agents/tasks/[id]/route.ts @@ -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() } + ); + } +} diff --git a/src/app/api/v1/agents/tasks/route.ts b/src/app/api/v1/agents/tasks/route.ts new file mode 100644 index 0000000000..600817d107 --- /dev/null +++ b/src/app/api/v1/agents/tasks/route.ts @@ -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() } + ); + } +} diff --git a/src/app/docs/lib/docs-auto-generated.ts b/src/app/docs/lib/docs-auto-generated.ts index 867dda3e31..47b3790d63 100644 --- a/src/app/docs/lib/docs-auto-generated.ts +++ b/src/app/docs/lib/docs-auto-generated.ts @@ -23,200 +23,201 @@ export interface AutoGenSearchItem { export const autoNavSections: AutoGenNavSection[] = [ { - "title": "Getting Started", - "items": [ + title: "Getting Started", + items: [ { - "slug": "architecture", - "title": "OmniRoute Architecture", - "fileName": "ARCHITECTURE.md" + slug: "architecture", + title: "OmniRoute Architecture", + fileName: "ARCHITECTURE.md", }, { - "slug": "cli-tools", - "title": "CLI Tools Setup Guide", - "fileName": "CLI-TOOLS.md" + slug: "cli-tools", + title: "CLI Tools Setup Guide", + fileName: "CLI-TOOLS.md", }, { - "slug": "setup-guide", - "title": "Setup Guide", - "fileName": "SETUP_GUIDE.md" + slug: "setup-guide", + title: "Setup Guide", + fileName: "SETUP_GUIDE.md", }, { - "slug": "user-guide", - "title": "User Guide", - "fileName": "USER_GUIDE.md" - } - ] + slug: "user-guide", + title: "User Guide", + fileName: "USER_GUIDE.md", + }, + ], }, { - "title": "Features", - "items": [ + title: "Features", + items: [ { - "slug": "auto-combo", - "title": "OmniRoute Auto-Combo Engine", - "fileName": "AUTO-COMBO.md" + slug: "auto-combo", + title: "OmniRoute Auto-Combo Engine", + fileName: "AUTO-COMBO.md", }, { - "slug": "compression-engines", - "title": "Compression Engines", - "fileName": "COMPRESSION_ENGINES.md" + slug: "compression-engines", + title: "Compression Engines", + fileName: "COMPRESSION_ENGINES.md", }, { - "slug": "compression-guide", - "title": "🗜️ Prompt Compression Guide", - "fileName": "COMPRESSION_GUIDE.md" + slug: "compression-guide", + title: "🗜️ Prompt Compression Guide", + fileName: "COMPRESSION_GUIDE.md", }, { - "slug": "compression-language-packs", - "title": "Compression Language Packs", - "fileName": "COMPRESSION_LANGUAGE_PACKS.md" + slug: "compression-language-packs", + title: "Compression Language Packs", + fileName: "COMPRESSION_LANGUAGE_PACKS.md", }, { - "slug": "compression-rules-format", - "title": "Compression Rules Format", - "fileName": "COMPRESSION_RULES_FORMAT.md" + slug: "compression-rules-format", + title: "Compression Rules Format", + fileName: "COMPRESSION_RULES_FORMAT.md", }, { - "slug": "features", - "title": "OmniRoute — Dashboard Features Gallery", - "fileName": "FEATURES.md" + slug: "features", + title: "OmniRoute — Dashboard Features Gallery", + fileName: "FEATURES.md", }, { - "slug": "free-tiers", - "title": "🆓 Free LLM API Providers — Consolidated Directory", - "fileName": "FREE_TIERS.md" + slug: "free-tiers", + title: "🆓 Free LLM API Providers — Consolidated Directory", + fileName: "FREE_TIERS.md", }, { - "slug": "rtk-compression", - "title": "RTK Compression", - "fileName": "RTK_COMPRESSION.md" - } - ] + slug: "rtk-compression", + title: "RTK Compression", + fileName: "RTK_COMPRESSION.md", + }, + ], }, { - "title": "API & Protocols", - "items": [ + title: "API & Protocols", + items: [ { - "slug": "a2a-server", - "title": "OmniRoute A2A Server Documentation", - "fileName": "A2A-SERVER.md" + slug: "a2a-server", + title: "OmniRoute A2A Server Documentation", + fileName: "A2A-SERVER.md", }, { - "slug": "api-reference", - "title": "API Reference", - "fileName": "API_REFERENCE.md" + slug: "api-reference", + title: "API Reference", + fileName: "API_REFERENCE.md", }, { - "slug": "mcp-server", - "title": "OmniRoute MCP Server Documentation", - "fileName": "MCP-SERVER.md" - } - ] + slug: "mcp-server", + title: "OmniRoute MCP Server Documentation", + fileName: "MCP-SERVER.md", + }, + ], }, { - "title": "Deployment", - "items": [ + title: "Deployment", + items: [ { - "slug": "docker-guide", - "title": "🐳 Docker Guide", - "fileName": "DOCKER_GUIDE.md" + slug: "docker-guide", + title: "🐳 Docker Guide", + fileName: "DOCKER_GUIDE.md", }, { - "slug": "fly-io-deployment-guide", - "title": "OmniRoute Fly.io 部署指南", - "fileName": "FLY_IO_DEPLOYMENT_GUIDE.md" + slug: "fly-io-deployment-guide", + title: "OmniRoute Fly.io 部署指南", + fileName: "FLY_IO_DEPLOYMENT_GUIDE.md", }, { - "slug": "pwa-guide", - "title": "Progressive Web App (PWA) Guide", - "fileName": "PWA_GUIDE.md" + slug: "pwa-guide", + title: "Progressive Web App (PWA) Guide", + fileName: "PWA_GUIDE.md", }, { - "slug": "termux-guide", - "title": "Termux Headless Setup", - "fileName": "TERMUX_GUIDE.md" + slug: "termux-guide", + title: "Termux Headless Setup", + fileName: "TERMUX_GUIDE.md", }, { - "slug": "vm-deployment-guide", - "title": "OmniRoute — Deployment Guide on VM with Cloudflare", - "fileName": "VM_DEPLOYMENT_GUIDE.md" - } - ] + slug: "vm-deployment-guide", + title: "OmniRoute — Deployment Guide on VM with Cloudflare", + fileName: "VM_DEPLOYMENT_GUIDE.md", + }, + ], }, { - "title": "Operations", - "items": [ + title: "Operations", + items: [ { - "slug": "environment", - "title": "Environment Variables Reference", - "fileName": "ENVIRONMENT.md" + slug: "environment", + title: "Environment Variables Reference", + fileName: "ENVIRONMENT.md", }, { - "slug": "proxy-guide", - "title": "OmniRoute Proxy Guide", - "fileName": "PROXY_GUIDE.md" + slug: "proxy-guide", + title: "OmniRoute Proxy Guide", + fileName: "PROXY_GUIDE.md", }, { - "slug": "resilience-guide", - "title": "🛡️ Resilience Guide", - "fileName": "RESILIENCE_GUIDE.md" + slug: "resilience-guide", + title: "🛡️ Resilience Guide", + fileName: "RESILIENCE_GUIDE.md", }, { - "slug": "troubleshooting", - "title": "Troubleshooting", - "fileName": "TROUBLESHOOTING.md" - } - ] + slug: "troubleshooting", + title: "Troubleshooting", + fileName: "TROUBLESHOOTING.md", + }, + ], }, { - "title": "Development", - "items": [ + title: "Development", + items: [ { - "slug": "codebase-documentation", - "title": "omniroute — Codebase Documentation", - "fileName": "CODEBASE_DOCUMENTATION.md" + slug: "codebase-documentation", + title: "omniroute — Codebase Documentation", + fileName: "CODEBASE_DOCUMENTATION.md", }, { - "slug": "coverage-plan", - "title": "Test Coverage Plan", - "fileName": "COVERAGE_PLAN.md" + slug: "coverage-plan", + title: "Test Coverage Plan", + fileName: "COVERAGE_PLAN.md", }, { - "slug": "i18n", - "title": "i18n — Internationalization Guide", - "fileName": "I18N.md" + slug: "i18n", + title: "i18n — Internationalization Guide", + fileName: "I18N.md", }, { - "slug": "release-checklist", - "title": "Release Checklist", - "fileName": "RELEASE_CHECKLIST.md" + slug: "release-checklist", + title: "Release Checklist", + fileName: "RELEASE_CHECKLIST.md", }, { - "slug": "uninstall", - "title": "OmniRoute — Uninstall Guide", - "fileName": "UNINSTALL.md" - } - ] + slug: "uninstall", + title: "OmniRoute — Uninstall Guide", + fileName: "UNINSTALL.md", + }, + ], }, { - "title": "Other", - "items": [ + title: "Other", + items: [ { - "slug": "rfc-auto-assessment", - "title": "RFC: Auto-Assessment & Self-Healing Combo Engine", - "fileName": "RFC-AUTO-ASSESSMENT.md" - } - ] - } + slug: "rfc-auto-assessment", + title: "RFC: Auto-Assessment & Self-Healing Combo Engine", + fileName: "RFC-AUTO-ASSESSMENT.md", + }, + ], + }, ]; export const autoSearchIndex: AutoGenSearchItem[] = [ { - "slug": "architecture", - "title": "OmniRoute Architecture", - "fileName": "ARCHITECTURE.md", - "section": "Getting Started", - "content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", - "headings": [ + slug: "architecture", + title: "OmniRoute Architecture", + fileName: "ARCHITECTURE.md", + section: "Getting Started", + content: + "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", + headings: [ "Executive Summary", "Scope and Boundaries", "In Scope", @@ -226,16 +227,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Core Runtime Components", "1) API and Routing Layer (Next.js App Routes)", "2) SSE + Translation Core", - "3) Persistence Layer" - ] + "3) Persistence Layer", + ], }, { - "slug": "cli-tools", - "title": "CLI Tools Setup Guide", - "fileName": "CLI-TOOLS.md", - "section": "Getting Started", - "content": "This guide explains how to install and configure all supported AI coding CLI tools to use OmniRoute as the unified backend, giving you centralized key management, cost tracking, model switching, and request logging across every tool. The dashboard cards in /dashboard/cli-tools are generated from src", - "headings": [ + slug: "cli-tools", + title: "CLI Tools Setup Guide", + fileName: "CLI-TOOLS.md", + section: "Getting Started", + content: + "This guide explains how to install and configure all supported AI coding CLI tools to use OmniRoute as the unified backend, giving you centralized key management, cost tracking, model switching, and request logging across every tool. The dashboard cards in /dashboard/cli-tools are generated from src", + headings: [ "How It Works", "Supported Tools (Dashboard Source of Truth)", "CLI fingerprint sync (Agents + Settings)", @@ -245,16 +247,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Step 4 — Configure Each Tool", "Claude Code", "OpenAI Codex", - "OpenCode" - ] + "OpenCode", + ], }, { - "slug": "setup-guide", - "title": "Setup Guide", - "fileName": "SETUP_GUIDE.md", - "section": "Getting Started", - "content": "Complete setup reference for OmniRoute. For the quick version, see the Quick Start in README. - Install Methods - CLI Tool Configuration - Protocol Setup (MCP + A2A) - Timeout Configuration - Split-Port Mode - Void Linux (xbps-src) - Uninstalling -------------------- --------------------------------", - "headings": [ + slug: "setup-guide", + title: "Setup Guide", + fileName: "SETUP_GUIDE.md", + section: "Getting Started", + content: + "Complete setup reference for OmniRoute. For the quick version, see the Quick Start in README. - Install Methods - CLI Tool Configuration - Protocol Setup (MCP + A2A) - Timeout Configuration - Split-Port Mode - Void Linux (xbps-src) - Uninstalling -------------------- --------------------------------", + headings: [ "Table of Contents", "Install Methods", "npm (recommended)", @@ -264,51 +267,56 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Docker", "CLI Options", "CLI Tool Configuration", - "1) Connect Providers and Create API Key" - ] + "1) Connect Providers and Create API Key", + ], }, { - "slug": "user-guide", - "title": "User Guide", - "fileName": "USER_GUIDE.md", - "section": "Getting Started", - "content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", - "headings": [ + slug: "user-guide", + title: "User Guide", + fileName: "USER_GUIDE.md", + section: "Getting Started", + content: + "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", + headings: [ "Table of Contents", "💰 Pricing at a Glance", "🎯 Use Cases", - "Case 1: \"I have Claude Pro subscription\"", - "Case 2: \"I want zero cost\"", - "Case 3: \"I need 24/7 coding, no interruptions\"", - "Case 4: \"I want FREE AI in OpenClaw\"", + 'Case 1: "I have Claude Pro subscription"', + 'Case 2: "I want zero cost"', + 'Case 3: "I need 24/7 coding, no interruptions"', + 'Case 4: "I want FREE AI in OpenClaw"', "📖 Provider Setup", "🔐 Subscription Providers", - "Claude Code (Pro/Max)" - ] + "Claude Code (Pro/Max)", + ], }, { - "slug": "auto-combo", - "title": "OmniRoute Auto-Combo Engine", - "fileName": "AUTO-COMBO.md", - "section": "Features", - "content": "Self-managing model chains with adaptive scoring The Auto-Combo Engine dynamically selects the best provider/model for each request using a 6-factor scoring function: Factor Weight Description :--------- :----- :---------------------------------------------- Quota 0.20 Remaining capacity [0..1] Heal", - "headings": [ - "How It Works", + slug: "auto-combo", + title: "OmniRoute Auto-Combo Engine", + fileName: "AUTO-COMBO.md", + section: "Features", + content: + "Self-managing model chains with adaptive scoring + zero-config auto-routing NEW: No combo creation required. Use auto/ prefix directly in any client. Model ID Variant Behavior ------------------ --------- ------------------------------------------------------------------------ auto default All conne", + headings: [ + "Zero-Config Auto-Routing (auto/ prefix)", + "Quick Examples", + "How It Works (Persisted Auto-Combos)", "Mode Packs", "Self-Healing", "Bandit Exploration", "API", "Task Fitness", - "Files" - ] + "Files", + ], }, { - "slug": "compression-engines", - "title": "Compression Engines", - "fileName": "COMPRESSION_ENGINES.md", - "section": "Features", - "content": "OmniRoute compression is built around engine contracts. A mode can run one engine directly (caveman or rtk) or a deterministic stacked pipeline that executes multiple engines in order. Mode Engine path Intended input ------------ ---------------------------------- -----------------------------------", - "headings": [ + slug: "compression-engines", + title: "Compression Engines", + fileName: "COMPRESSION_ENGINES.md", + section: "Features", + content: + "OmniRoute compression is built around engine contracts. A mode can run one engine directly (caveman or rtk) or a deterministic stacked pipeline that executes multiple engines in order. Mode Engine path Intended input ------------ ---------------------------------- -----------------------------------", + headings: [ "Modes", "Engine Registry", "Caveman", @@ -317,16 +325,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Compression Combos", "API Surface", "MCP Tools", - "Validation" - ] + "Validation", + ], }, { - "slug": "compression-guide", - "title": "🗜️ Prompt Compression Guide", - "fileName": "COMPRESSION_GUIDE.md", - "section": "Features", - "content": "Save 15-95% on eligible context automatically. For a quick overview, see the README Compression section. OmniRoute implements a modular prompt compression pipeline that runs proactively before requests hit upstream providers. This means your token savings happen transparently — no changes needed to ", - "headings": [ + slug: "compression-guide", + title: "🗜️ Prompt Compression Guide", + fileName: "COMPRESSION_GUIDE.md", + section: "Features", + content: + "Save 15-95% on eligible context automatically. For a quick overview, see the README Compression section. OmniRoute implements a modular prompt compression pipeline that runs proactively before requests hit upstream providers. This means your token savings happen transparently — no changes needed to ", + headings: [ "Overview", "Compression Modes", "Off", @@ -336,46 +345,49 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Ultra Mode (~75% savings)", "RTK Mode (60-90% upstream range)", "Stacked Mode (78-95% eligible range)", - "Upstream Savings Math" - ] + "Upstream Savings Math", + ], }, { - "slug": "compression-language-packs", - "title": "Compression Language Packs", - "fileName": "COMPRESSION_LANGUAGE_PACKS.md", - "section": "Features", - "content": "Caveman compression can load language-specific rule packs in addition to the built-in English rules. This keeps the core engine stable while allowing Portuguese, Spanish, German, French, Japanese, and future language packs to evolve independently. Language packs live under: Current shipped packs inc", - "headings": [ + slug: "compression-language-packs", + title: "Compression Language Packs", + fileName: "COMPRESSION_LANGUAGE_PACKS.md", + section: "Features", + content: + "Caveman compression can load language-specific rule packs in addition to the built-in English rules. This keeps the core engine stable while allowing Portuguese, Spanish, German, French, Japanese, and future language packs to evolve independently. Language packs live under: Current shipped packs inc", + headings: [ "Location", "Language Detection", "Config Shape", "Adding a Language Pack", "API", - "Operational Notes" - ] + "Operational Notes", + ], }, { - "slug": "compression-rules-format", - "title": "Compression Rules Format", - "fileName": "COMPRESSION_RULES_FORMAT.md", - "section": "Features", - "content": "Compression rules are JSON files loaded at runtime. They are intentionally data-only so new language packs and RTK command filters can be reviewed without changing engine code. Caveman rule packs live under: Each pack contains replacements that apply to normal prose after protected regions are isola", - "headings": [ + slug: "compression-rules-format", + title: "Compression Rules Format", + fileName: "COMPRESSION_RULES_FORMAT.md", + section: "Features", + content: + "Compression rules are JSON files loaded at runtime. They are intentionally data-only so new language packs and RTK command filters can be reviewed without changing engine code. Caveman rule packs live under: Each pack contains replacements that apply to normal prose after protected regions are isola", + headings: [ "Caveman Rule Packs", "Caveman Fields", "RTK Filter Packs", "RTK Fields", "Safety Rules", - "Validation" - ] + "Validation", + ], }, { - "slug": "features", - "title": "OmniRoute — Dashboard Features Gallery", - "fileName": "FEATURES.md", - "section": "Features", - "content": "🌐 Main README translations: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indones", - "headings": [ + slug: "features", + title: "OmniRoute — Dashboard Features Gallery", + fileName: "FEATURES.md", + section: "Features", + content: + "🌐 Main README translations: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indones", + headings: [ "🔌 Providers", "🎨 Combos", "📊 Analytics", @@ -385,16 +397,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "🎨 Themes _(v2.0.5+)_", "⚙️ Settings", "🔧 CLI Tools", - "🤖 CLI Agents _(v2.0.11+)_" - ] + "🤖 CLI Agents _(v2.0.11+)_", + ], }, { - "slug": "free-tiers", - "title": "🆓 Free LLM API Providers — Consolidated Directory", - "fileName": "FREE_TIERS.md", - "section": "Features", - "content": "The ultimate aggregated reference for all permanently free LLM API providers. Consolidated from 6 community repositories. Use with OmniRoute to route through 25+ free providers simultaneously. Last consolidated: May 2026 · Sources: awesome-free-llm-apis, awesome-free-llm-apis2, free-llm-api-resource", - "headings": [ + slug: "free-tiers", + title: "🆓 Free LLM API Providers — Consolidated Directory", + fileName: "FREE_TIERS.md", + section: "Features", + content: + "The ultimate aggregated reference for all permanently free LLM API providers. Consolidated from 6 community repositories. Use with OmniRoute to route through 25+ free providers simultaneously. Last consolidated: May 2026 · Sources: awesome-free-llm-apis, awesome-free-llm-apis2, free-llm-api-resource", + headings: [ "Table of Contents", "Quick Comparison", "Provider APIs (First-Party)", @@ -404,16 +417,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Z.AI (Zhipu AI) 🇨🇳", "IBM watsonx 🇺🇸", "Inference Providers (Third-Party)", - "Groq 🇺🇸" - ] + "Groq 🇺🇸", + ], }, { - "slug": "rtk-compression", - "title": "RTK Compression", - "fileName": "RTK_COMPRESSION.md", - "section": "Features", - "content": "RTK compression is OmniRoute's command-aware compression engine for terminal and tool output. It is designed for coding-agent sessions where most context growth comes from test logs, build output, package manager noise, shell transcripts, Docker output, git output, and stack traces. RTK can run dire", - "headings": [ + slug: "rtk-compression", + title: "RTK Compression", + fileName: "RTK_COMPRESSION.md", + section: "Features", + content: + "RTK compression is OmniRoute's command-aware compression engine for terminal and tool output. It is designed for coding-agent sessions where most context growth comes from test logs, build output, package manager noise, shell transcripts, Docker output, git output, and stack traces. RTK can run dire", + headings: [ "What It Compresses", "Filter Resolution", "Filter DSL", @@ -421,16 +435,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "API", "Raw Output Recovery", "Verify Gate", - "Extending RTK" - ] + "Extending RTK", + ], }, { - "slug": "a2a-server", - "title": "OmniRoute A2A Server Documentation", - "fileName": "A2A-SERVER.md", - "section": "API & Protocols", - "content": "Agent-to-Agent Protocol v0.3 — OmniRoute as an intelligent routing agent Returns the Agent Card describing OmniRoute's capabilities, skills, and authentication requirements. Sends a message to a skill and waits for the complete response. Response: Same as message/send but returns Server-Sent Events ", - "headings": [ + slug: "a2a-server", + title: "OmniRoute A2A Server Documentation", + fileName: "A2A-SERVER.md", + section: "API & Protocols", + content: + "Agent-to-Agent Protocol v0.3 — OmniRoute as an intelligent routing agent Returns the Agent Card describing OmniRoute's capabilities, skills, and authentication requirements. Sends a message to a skill and waits for the complete response. Response: Same as message/send but returns Server-Sent Events ", + headings: [ "Agent Discovery", "Authentication", "Enablement", @@ -440,16 +455,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "tasks/get — Query Task Status", "tasks/cancel — Cancel a Task", "Available Skills", - "Task Lifecycle" - ] + "Task Lifecycle", + ], }, { - "slug": "api-reference", - "title": "API Reference", - "fileName": "API_REFERENCE.md", - "section": "API & Protocols", - "content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", - "headings": [ + slug: "api-reference", + title: "API Reference", + fileName: "API_REFERENCE.md", + section: "API & Protocols", + content: + "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", + headings: [ "Table of Contents", "Chat Completions", "Custom Headers", @@ -459,16 +475,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Compatibility Endpoints", "Dedicated Provider Routes", "Semantic Cache", - "Dashboard & Management" - ] + "Dashboard & Management", + ], }, { - "slug": "mcp-server", - "title": "OmniRoute MCP Server Documentation", - "fileName": "MCP-SERVER.md", - "section": "API & Protocols", - "content": "Model Context Protocol server with 37 tools across routing, cache, compression, memory, skills, and proxy operations OmniRoute MCP is built-in. Start it with: Or via the open-sse transport: See MCP Client Configuration for Claude Desktop, Cursor, Cline, and compatible MCP client setup. -------------", - "headings": [ + slug: "mcp-server", + title: "OmniRoute MCP Server Documentation", + fileName: "MCP-SERVER.md", + section: "API & Protocols", + content: + "Model Context Protocol server with 37 tools across routing, cache, compression, memory, skills, and proxy operations OmniRoute MCP is built-in. Start it with: Or via the open-sse transport: See MCP Client Configuration for Claude Desktop, Cursor, Cline, and compatible MCP client setup. -------------", + headings: [ "Installation", "IDE Configuration", "Essential Tools (8)", @@ -478,16 +495,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Other Tool Groups", "Authentication", "Audit Logging", - "Files" - ] + "Files", + ], }, { - "slug": "docker-guide", - "title": "🐳 Docker Guide", - "fileName": "DOCKER_GUIDE.md", - "section": "Deployment", - "content": "Complete Docker deployment reference. For a quick start, see the README Docker section. - Quick Run - With Environment File - Docker Compose - Docker Compose with Caddy (HTTPS) - Cloudflare Quick Tunnel - Image Tags - Important Notes --------------------- -------- ------ --------------------- diegos", - "headings": [ + slug: "docker-guide", + title: "🐳 Docker Guide", + fileName: "DOCKER_GUIDE.md", + section: "Deployment", + content: + "Complete Docker deployment reference. For a quick start, see the README Docker section. - Quick Run - With Environment File - Docker Compose - Docker Compose with Caddy (HTTPS) - Cloudflare Quick Tunnel - Image Tags - Important Notes --------------------- -------- ------ --------------------- diegos", + headings: [ "Table of Contents", "Quick Run", "With Environment File", @@ -497,16 +515,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Tunnel Notes", "Image Tags", "Important Notes", - "See Also" - ] + "See Also", + ], }, { - "slug": "fly-io-deployment-guide", - "title": "OmniRoute Fly.io 部署指南", - "fileName": "FLY_IO_DEPLOYMENT_GUIDE.md", - "section": "Deployment", - "content": "本文档记录 OmniRoute 在 Fly.io 上的实际部署方法,适用于两类场景: - 首次把当前项目部署到 Fly.io - 后续代码更新后继续发布 - 新项目参考同样流程部署 本文基于当前项目已经验证通过的配置整理,应用名为 omniroute。 当前仓库中的 fly.toml 已确认包含以下关键项: 说明: - app = 'omniroute' 决定实际部署到哪个 Fly 应用 - destination = '/data' 决定持久卷挂载目录 - 本项目必须让 DATADIR=/data,否则数据库和密钥会写到容器临时目录 --- Windows PowerShell: 如果安装脚", - "headings": [ + slug: "fly-io-deployment-guide", + title: "OmniRoute Fly.io 部署指南", + fileName: "FLY_IO_DEPLOYMENT_GUIDE.md", + section: "Deployment", + content: + "本文档记录 OmniRoute 在 Fly.io 上的实际部署方法,适用于两类场景: - 首次把当前项目部署到 Fly.io - 后续代码更新后继续发布 - 新项目参考同样流程部署 本文基于当前项目已经验证通过的配置整理,应用名为 omniroute。 当前仓库中的 fly.toml 已确认包含以下关键项: 说明: - app = 'omniroute' 决定实际部署到哪个 Fly 应用 - destination = '/data' 决定持久卷挂载目录 - 本项目必须让 DATADIR=/data,否则数据库和密钥会写到容器临时目录 --- Windows PowerShell: 如果安装脚", + headings: [ "1. 部署目标", "2. 当前项目关键配置", "3. 必备工具", @@ -516,16 +535,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "4. 首次部署当前项目", "4.1 获取代码并进入目录", "4.2 确认应用名", - "4.3 创建应用" - ] + "4.3 创建应用", + ], }, { - "slug": "pwa-guide", - "title": "Progressive Web App (PWA) Guide", - "fileName": "PWA_GUIDE.md", - "section": "Deployment", - "content": "OmniRoute ships as a fully installable Progressive Web App. When you access the dashboard from any mobile browser — Android (Chrome) or iOS (Safari) — you can \"Add to Home Screen\" and get a native app-like experience with no app store required. A Progressive Web App turns the OmniRoute web dashboard", - "headings": [ + slug: "pwa-guide", + title: "Progressive Web App (PWA) Guide", + fileName: "PWA_GUIDE.md", + section: "Deployment", + content: + 'OmniRoute ships as a fully installable Progressive Web App. When you access the dashboard from any mobile browser — Android (Chrome) or iOS (Safari) — you can "Add to Home Screen" and get a native app-like experience with no app store required. A Progressive Web App turns the OmniRoute web dashboard', + headings: [ "What Is a PWA?", "Installation", "Android (Chrome)", @@ -535,16 +555,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Fullscreen Experience", "Offline Support", "Offline Page", - "App Icons" - ] + "App Icons", + ], }, { - "slug": "termux-guide", - "title": "Termux Headless Setup", - "fileName": "TERMUX_GUIDE.md", - "section": "Deployment", - "content": "OmniRoute can run as a headless server on Android through Termux. The Electron desktop app is not supported in Termux, but the web dashboard and OpenAI-compatible API work from the local browser or from other devices on the same network. Install Termux from F-Droid or GitHub releases, then update pa", - "headings": [ + slug: "termux-guide", + title: "Termux Headless Setup", + fileName: "TERMUX_GUIDE.md", + section: "Deployment", + content: + "OmniRoute can run as a headless server on Android through Termux. The Electron desktop app is not supported in Termux, but the web dashboard and OpenAI-compatible API work from the local browser or from other devices on the same network. Install Termux from F-Droid or GitHub releases, then update pa", + headings: [ "Prerequisites", "Install", "Run", @@ -554,16 +575,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Limitations", "Troubleshooting", "better-sqlite3 Build Errors", - "Port Already In Use" - ] + "Port Already In Use", + ], }, { - "slug": "vm-deployment-guide", - "title": "OmniRoute — Deployment Guide on VM with Cloudflare", - "fileName": "VM_DEPLOYMENT_GUIDE.md", - "section": "Deployment", - "content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", - "headings": [ + slug: "vm-deployment-guide", + title: "OmniRoute — Deployment Guide on VM with Cloudflare", + fileName: "VM_DEPLOYMENT_GUIDE.md", + section: "Deployment", + content: + "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", + headings: [ "Prerequisites", "1. Configure the VM", "1.1 Create the instance", @@ -573,16 +595,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "1.5 Install nginx", "1.6 Configure Firewall (UFW)", "2. Install OmniRoute", - "2.1 Create configuration directory" - ] + "2.1 Create configuration directory", + ], }, { - "slug": "environment", - "title": "Environment Variables Reference", - "fileName": "ENVIRONMENT.md", - "section": "Operations", - "content": "Complete reference for every environment variable recognized by OmniRoute. For a quick-start template, see .env.example. These must be set before the first run. Without them, the application will either refuse to start or operate with insecure defaults. Variable Required Default Source File Descript", - "headings": [ + slug: "environment", + title: "Environment Variables Reference", + fileName: "ENVIRONMENT.md", + section: "Operations", + content: + "Complete reference for every environment variable recognized by OmniRoute. For a quick-start template, see .env.example. These must be set before the first run. Without them, the application will either refuse to start or operate with insecure defaults. Variable Required Default Source File Descript", + headings: [ "Table of Contents", "1. Required Secrets", "Generation Commands", @@ -592,16 +615,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Port Modes", "4. Security & Authentication", "Hardening Checklist", - "5. Input Sanitization & PII Protection" - ] + "5. Input Sanitization & PII Protection", + ], }, { - "slug": "proxy-guide", - "title": "OmniRoute Proxy Guide", - "fileName": "PROXY_GUIDE.md", - "section": "Operations", - "content": "Bypass geographic blocks, protect your identity, and route AI traffic through any proxy — with zero configuration complexity. OmniRoute includes a full-featured proxy management system that lets you route upstream AI provider traffic through HTTP, HTTPS, or SOCKS5 proxies. Whether you're in a blocke", - "headings": [ + slug: "proxy-guide", + title: "OmniRoute Proxy Guide", + fileName: "PROXY_GUIDE.md", + section: "Operations", + content: + "Bypass geographic blocks, protect your identity, and route AI traffic through any proxy — with zero configuration complexity. OmniRoute includes a full-featured proxy management system that lets you route upstream AI provider traffic through HTTP, HTTPS, or SOCKS5 proxies. Whether you're in a blocke", + headings: [ "Table of Contents", "Why Use Proxies?", "Architecture Overview", @@ -611,16 +635,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "What Gets Proxied", "Proxy Registry (CRUD)", "Creating a Proxy", - "Updating a Proxy" - ] + "Updating a Proxy", + ], }, { - "slug": "resilience-guide", - "title": "🛡️ Resilience Guide", - "fileName": "RESILIENCE_GUIDE.md", - "section": "Operations", - "content": "How OmniRoute keeps your AI coding workflow running when providers fail. OmniRoute implements a multi-layered resilience system that ensures zero downtime: ------------ ------- ------------------------------------ Queue Size 10 Max queued requests per connection Pacing Interval 0ms Minimum gap betwe", - "headings": [ + slug: "resilience-guide", + title: "🛡️ Resilience Guide", + fileName: "RESILIENCE_GUIDE.md", + section: "Operations", + content: + "How OmniRoute keeps your AI coding workflow running when providers fail. OmniRoute implements a multi-layered resilience system that ensures zero downtime: ------------ ------- ------------------------------------ Queue Size 10 Max queued requests per connection Pacing Interval 0ms Minimum gap betwe", + headings: [ "Overview", "Request Queue & Pacing", "Connection Cooldown", @@ -630,35 +655,37 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Combo Fallback Chains", "13 Routing Strategies", "TLS Fingerprint Spoofing", - "Health Dashboard" - ] + "Health Dashboard", + ], }, { - "slug": "troubleshooting", - "title": "Troubleshooting", - "fileName": "TROUBLESHOOTING.md", - "section": "Operations", - "content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", - "headings": [ + slug: "troubleshooting", + title: "Troubleshooting", + fileName: "TROUBLESHOOTING.md", + section: "Operations", + content: + "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", + headings: [ "Quick Fixes", "Node.js Compatibility", - "Login page crashes or shows \"Module self-registration\" error", - "macOS: dlopen / \"slice is not valid mach-o file\"", + 'Login page crashes or shows "Module self-registration" error', + 'macOS: dlopen / "slice is not valid mach-o file"', "Proxy Issues", - "Provider validation shows \"fetch failed\"", - "Token health check fails with \"fetch failed\"", - "SOCKS5 proxy returns \"invalid onRequestStart method\"", + 'Provider validation shows "fetch failed"', + 'Token health check fails with "fetch failed"', + 'SOCKS5 proxy returns "invalid onRequestStart method"', "Provider Issues", - "\"Language model did not provide messages\"" - ] + '"Language model did not provide messages"', + ], }, { - "slug": "codebase-documentation", - "title": "omniroute — Codebase Documentation", - "fileName": "CODEBASE_DOCUMENTATION.md", - "section": "Development", - "content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", - "headings": [ + slug: "codebase-documentation", + title: "omniroute — Codebase Documentation", + fileName: "CODEBASE_DOCUMENTATION.md", + section: "Development", + content: + "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", + headings: [ "1. What Is omniroute?", "2. Architecture Overview", "Core Principle: Hub-and-Spoke Translation", @@ -668,16 +695,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Credential Loading Flow", "4.2 Executors (open-sse/executors/)", "4.3 Handlers (open-sse/handlers/)", - "Request Lifecycle (chatCore.ts)" - ] + "Request Lifecycle (chatCore.ts)", + ], }, { - "slug": "coverage-plan", - "title": "Test Coverage Plan", - "fileName": "COVERAGE_PLAN.md", - "section": "Development", - "content": "Last updated: 2026-03-28 There are multiple coverage numbers depending on how the report is computed. For planning, only one of them is useful. Metric Scope Statements / Lines Branches Functions Notes -------------------- ----------------------------------------------------- -----------------: -----", - "headings": [ + slug: "coverage-plan", + title: "Test Coverage Plan", + fileName: "COVERAGE_PLAN.md", + section: "Development", + content: + "Last updated: 2026-03-28 There are multiple coverage numbers depending on how the report is computed. For planning, only one of them is useful. Metric Scope Statements / Lines Branches Functions Notes -------------------- ----------------------------------------------------- -----------------: -----", + headings: [ "Baseline", "Rules", "Current command set", @@ -687,16 +715,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Phase 1: 56.95% -> 60%", "Phase 2: 60% -> 65%", "Phase 3: 65% -> 70%", - "Phase 4: 70% -> 75%" - ] + "Phase 4: 70% -> 75%", + ], }, { - "slug": "i18n", - "title": "i18n — Internationalization Guide", - "fileName": "I18N.md", - "section": "Development", - "content": "OmniRoute supports 30 languages with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew. 🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇩🇪 Deutsch 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇯🇵 日本語 🇰🇷 한국어 🇸🇦 العربية 🇮🇳 ", - "headings": [ + slug: "i18n", + title: "i18n — Internationalization Guide", + fileName: "I18N.md", + section: "Development", + content: + "OmniRoute supports 30 languages with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew. 🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇩🇪 Deutsch 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇯🇵 日本語 🇰🇷 한국어 🇸🇦 العربية 🇮🇳 ", + headings: [ "Quick Reference", "Architecture", "Source of Truth", @@ -706,29 +735,26 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "1. Register the Locale", "2. Add to Generator", "3. Generate Initial Translation", - "4. Review & Fix Auto-Translations" - ] + "4. Review & Fix Auto-Translations", + ], }, { - "slug": "release-checklist", - "title": "Release Checklist", - "fileName": "RELEASE_CHECKLIST.md", - "section": "Development", - "content": "Use this checklist before tagging or publishing a new OmniRoute release. 1. Bump package.json version (x.y.z) in the release branch. 2. Move release notes from [Unreleased] in CHANGELOG.md to a dated section: - [x.y.z] — YYYY-MM-DD 3. Keep [Unreleased] as the first changelog section for upcoming wor", - "headings": [ - "Version and Changelog", - "API Docs", - "Runtime Docs", - "Automated Check" - ] + slug: "release-checklist", + title: "Release Checklist", + fileName: "RELEASE_CHECKLIST.md", + section: "Development", + content: + "Use this checklist before tagging or publishing a new OmniRoute release. 1. Bump package.json version (x.y.z) in the release branch. 2. Move release notes from [Unreleased] in CHANGELOG.md to a dated section: - [x.y.z] — YYYY-MM-DD 3. Keep [Unreleased] as the first changelog section for upcoming wor", + headings: ["Version and Changelog", "API Docs", "Runtime Docs", "Automated Check"], }, { - "slug": "uninstall", - "title": "OmniRoute — Uninstall Guide", - "fileName": "UNINSTALL.md", - "section": "Development", - "content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", - "headings": [ + slug: "uninstall", + title: "OmniRoute — Uninstall Guide", + fileName: "UNINSTALL.md", + section: "Development", + content: + "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c", + headings: [ "Quick Uninstall (v3.6.2+)", "Keep Your Data", "Full Removal", @@ -738,16 +764,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Docker", "Docker Compose", "Electron Desktop App", - "Source Install (git clone)" - ] + "Source Install (git clone)", + ], }, { - "slug": "rfc-auto-assessment", - "title": "RFC: Auto-Assessment & Self-Healing Combo Engine", - "fileName": "RFC-AUTO-ASSESSMENT.md", - "section": "Other", - "content": "Omniroute's combo system currently requires manual configuration: users must know which providers and models are actually working, then manually wire them into combo chains. When providers fail (rate limits, auth errors, model deprecation), combos silently degrade — routing to dead endpoints that ti", - "headings": [ + slug: "rfc-auto-assessment", + title: "RFC: Auto-Assessment & Self-Healing Combo Engine", + fileName: "RFC-AUTO-ASSESSMENT.md", + section: "Other", + content: + "Omniroute's combo system currently requires manual configuration: users must know which providers and models are actually working, then manually wire them into combo chains. When providers fail (rate limits, auth errors, model deprecation), combos silently degrade — routing to dead endpoints that ti", + headings: [ "Summary", "Problem Statement", "What we encountered (real production incident)", @@ -757,20 +784,17 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "New Components", "Detailed Design", "1. Assessor — src/domain/assessor.ts", - "2. Categorizer — src/domain/categorizer.ts" - ] + "2. Categorizer — src/domain/categorizer.ts", + ], }, { - "slug": "api-explorer", - "title": "API Explorer", - "fileName": "API_REFERENCE.md", - "section": "API & Protocols", - "content": "interactive try it live api explorer endpoint test request response curl example", - "headings": [ - "Try It", - "Endpoints" - ] - } + slug: "api-explorer", + title: "API Explorer", + fileName: "API_REFERENCE.md", + section: "API & Protocols", + content: "interactive try it live api explorer endpoint test request response curl example", + headings: ["Try It", "Endpoints"], + }, ]; export const autoAllSlugs: string[] = [ @@ -803,5 +827,5 @@ export const autoAllSlugs: string[] = [ "i18n", "release-checklist", "uninstall", - "rfc-auto-assessment" + "rfc-auto-assessment", ]; diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 07b6ef1647..6d47235d8c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index e6cd0d6ca5..c46df5d17e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -140,515 +140,515 @@ "http": "HTTP", "goToDashboard": "前往仪表板", "checkSystemStatus": "查看系统状态", - "selectModel": "Select Model", + "selectModel": "选择模型", "combos": "Combos", - "noModelsFound": "No models found", - "clear": "Clear", - "done": "Done", - "errorOccurred": "Error Occurred", - "comboDeleted": "Combo Deleted", - "hide": "Hide", - "creating": "Creating", - "comboCreated": "Combo Created", - "swapFormats": "Swap Formats", - "daysAgo": "Days Ago", - "retries": "Retries", - "errorDuringRestore": "Error During Restore", - "eventsAppearHint": "Events Appear Hint", - "noLockouts": "No Lockouts", - "webSearchDesc": "Web Search Desc", - "audioProvidersHeading": "Audio Providers Heading", - "minutesAgo": "Minutes Ago", + "noModelsFound": "未找到 Model", + "clear": "清除", + "done": "完成", + "errorOccurred": "错误发生", + "comboDeleted": "Combo 已删除", + "hide": "隐藏", + "creating": "正在创建", + "comboCreated": "Combo 已创建", + "swapFormats": "交换格式", + "daysAgo": "天前", + "retries": "重试次数", + "errorDuringRestore": "错误期间恢复", + "eventsAppearHint": "事件显示提示", + "noLockouts": "无锁定", + "webSearchDesc": "Web Search 功能说明", + "audioProvidersHeading": "音频 Provider", + "minutesAgo": "分钟前", "a": "A", - "liveAutoRefreshing": "Live Auto Refreshing", + "liveAutoRefreshing": "实时自动刷新中", "webSearch": "Web Search", - "anthropicPrefixPlaceholder": "Anthropic Prefix Placeholder", - "addModelToCombo": "Add Model To Combo", - "failedToLoad": "Failed To Load", - "categoryMedia": "Category Media", - "enableCloud": "Enable Cloud", - "expirationBannerExpiringSoon": "Expiration Banner Expiring Soon", - "retry": "Retry", + "anthropicPrefixPlaceholder": "Anthropic 前缀", + "addModelToCombo": "添加模型到组合", + "failedToLoad": "加载失败", + "categoryMedia": "类别媒体", + "enableCloud": "启用云端", + "expirationBannerExpiringSoon": "过期横幅即将过期即将", + "retry": "重试", "embeddings": "Embeddings", - "errorCount": "Error Count", - "backupReasonManual": "Backup Reason Manual", - "safeSearchModerate": "Safe Search Moderate", - "activeLimiters": "Active Limiters", - "a2aCardTitle": "A2A Card Title", - "cloudWorkerUnreachable": "Cloud Worker Unreachable", - "testFailed": "Test Failed", - "compatibleBaseUrlHint": "Compatible Base Url Hint", - "usageTracking": "Usage Tracking", - "disableCloudTitle": "Disable Cloud Title", - "noConnections": "No Connections", - "providerHealth": "Provider Health", - "confirmDbImport": "Confirm Db Import", - "notAvailableSymbol": "Not Available Symbol", - "backupsAvailable": "Backups Available", - "providerAuto": "Provider Auto", - "newProviderNamePlaceholder": "New Provider Name Placeholder", - "a2aQuickStartStep2": "A2A Quick Start Step2", - "ok": "Ok", - "available": "Available", - "noBackupYet": "No Backup Yet", - "more": "More", - "noCompatibleYet": "No Compatible Yet", - "multiProvider": "Multi Provider", - "repairEnvHint": "Repair Env Hint", - "disablingCloud": "Disabling Cloud", - "testBench": "Test Bench", - "valid": "Valid", - "cloudBenefitShare": "Cloud Benefit Share", - "mcpCardTitle": "Mcp Card Title", - "disableConfirm": "Disable Confirm", - "filters": "Filters", - "expirationBannerExpiredDesc": "Expiration Banner Expired Desc", - "saveComboDefaults": "Save Combo Defaults", - "cloudConnectedVerified": "Cloud Connected Verified", - "uptime": "Uptime", - "compatibleProdPlaceholder": "Compatible Prod Placeholder", - "fallbackChainsTitle": "Fallback Chains Title", - "oauthLabel": "Oauth Label", - "a2aCardDescription": "A2A Card Description", - "okShort": "Ok Short", - "maintenance": "Maintenance", - "formatConverter": "Format Converter", - "zedImportNetworkError": "Zed Import Network Error", - "apiKeyLabel": "Api Key Label", - "noModelsForProvider": "No Models For Provider", - "mcpCardDescription": "Mcp Card Description", - "apiTypeLabel": "Api Type Label", - "configuredProvidersLabel": "Configured Providers Label", - "maxRetriesLabel": "Max Retries Label", - "title": "Title", - "output": "Output", - "prefixHint": "Prefix Hint", - "skipWizard": "Skip Wizard", - "failedCount": "Failed Count", - "confirmDbImportDesc": "Confirm Db Import Desc", - "entries": "Entries", - "until": "Until", - "disableCombo": "Disable Combo", - "liveMonitorDescriptionPrefix": "Live Monitor Description Prefix", - "runningCount": "Running Count", - "noActiveConnectionsInGroup": "No Active Connections In Group", - "savedSuccessfully": "Saved Successfully", - "systemStorage": "System Storage", - "videoDesc": "Video Desc", - "maxResults": "Max Results", - "timeRangeMonth": "Time Range Month", - "testSummary": "Test Summary", - "failedSetPassword": "Failed Set Password", - "audio": "Audio", - "restore": "Restore", - "disableWarning": "Disable Warning", - "moderationsDesc": "Moderations Desc", - "providerHealthStatusAria": "Provider Health Status Aria", - "modelNamePlaceholder": "Model Name Placeholder", - "mcpQuickStartStep2": "Mcp Quick Start Step2", - "quickStart": "Quick Start", - "fullExportFailedWithError": "Full Export Failed With Error", - "loadingBackups": "Loading Backups", - "anthropicBaseUrlPlaceholder": "Anthropic Base Url Placeholder", - "description": "Description", - "noProviderFound": "No Provider Found", - "auto": "Auto", - "protocolsDescription": "Protocols Description", - "cloudSessionNote": "Cloud Session Note", - "advancedSettings": "Advanced Settings", - "defaultStrategy": "Default Strategy", - "purgeExpiredLogs": "Purge Expired Logs", - "justNow": "Just Now", - "providerTestFailed": "Provider Test Failed", - "openaiBaseUrlPlaceholder": "Openai Base Url Placeholder", - "image": "Image", - "failedCreateChain": "Failed Create Chain", - "importDatabase": "Import Database", - "allDataLocal": "All Data Local", - "sectionTitle": "Section Title", - "failedToggle": "Failed Toggle", - "editCombo": "Edit Combo", - "testResults": "Test Results", - "searchQuery": "Search Query", + "errorCount": "错误数量", + "backupReasonManual": "备份原因手动", + "safeSearchModerate": "安全搜索中等", + "activeLimiters": "活跃限制器", + "a2aCardTitle": "A2A", + "cloudWorkerUnreachable": "Cloud Worker 不可达", + "testFailed": "测试失败", + "compatibleBaseUrlHint": "填写兼容 API 的 Base URL。", + "usageTracking": "使用量跟踪", + "disableCloudTitle": "禁用云端", + "noConnections": "无连接", + "providerHealth": "提供商健康状态", + "confirmDbImport": "确认DB导入", + "notAvailableSymbol": "—", + "backupsAvailable": "可用备份", + "providerAuto": "自动提供商", + "newProviderNamePlaceholder": "新的提供商名称", + "a2aQuickStartStep2": "A2A快速开始步骤2", + "ok": "确定", + "available": "可用", + "noBackupYet": "无备份暂无", + "more": "更多", + "noCompatibleYet": "无兼容暂无", + "multiProvider": "多提供商", + "repairEnvHint": "修复环境提示", + "disablingCloud": "正在禁用 Cloud", + "testBench": "测试台", + "valid": "有效", + "cloudBenefitShare": "共享 Cloud 访问能力", + "mcpCardTitle": "MCP", + "disableConfirm": "确认禁用?", + "filters": "筛选器", + "expirationBannerExpiredDesc": "过期横幅已过期说明", + "saveComboDefaults": "保存组合默认值", + "cloudConnectedVerified": "Cloud 连接已验证", + "uptime": "运行时间", + "compatibleProdPlaceholder": "兼容生产占位符", + "fallbackChainsTitle": "后备链", + "oauthLabel": "OAuth 标签", + "a2aCardDescription": "通过 A2A 协议连接 Agent 工作流。", + "okShort": "确定短标签", + "maintenance": "维护", + "formatConverter": "格式转换器", + "zedImportNetworkError": "Zed 导入网络错误", + "apiKeyLabel": "API key 标签", + "noModelsForProvider": "此提供商没有可用模型", + "mcpCardDescription": "通过 MCP 工具连接 Agent 和自动化流程。", + "apiTypeLabel": "API 类型标签", + "configuredProvidersLabel": "已配置 Provider 标签", + "maxRetriesLabel": "最大重试次数标签", + "title": "标题", + "output": "输出", + "prefixHint": "前缀提示", + "skipWizard": "跳过向导", + "failedCount": "失败数量", + "confirmDbImportDesc": "确认DB导入说明", + "entries": "条目", + "until": "直到", + "disableCombo": "禁用组合", + "liveMonitorDescriptionPrefix": "实时显示请求流经 OmniRoute 时产生的事件。使用", + "runningCount": "运行中数量", + "noActiveConnectionsInGroup": "此分组中没有活跃连接", + "savedSuccessfully": "保存成功", + "systemStorage": "系统存储", + "videoDesc": "视频说明", + "maxResults": "最大结果数", + "timeRangeMonth": "时间范围月", + "testSummary": "测试摘要", + "failedSetPassword": "设置密码失败", + "audio": "音频", + "restore": "恢复", + "disableWarning": "禁用警告", + "moderationsDesc": "审核说明", + "providerHealthStatusAria": "提供商健康状态", + "modelNamePlaceholder": "模型名称", + "mcpQuickStartStep2": "MCP 快速开始步骤 2", + "quickStart": "快速开始", + "fullExportFailedWithError": "完整导出失败:{error}", + "loadingBackups": "正在加载备份", + "anthropicBaseUrlPlaceholder": "Anthropic Base URL", + "description": "描述", + "noProviderFound": "未找到提供商", + "auto": "自动", + "protocolsDescription": "配置并测试支持的协议端点。", + "cloudSessionNote": "Cloud Session 提示", + "advancedSettings": "高级设置", + "defaultStrategy": "默认策略", + "purgeExpiredLogs": "清理已过期日志", + "justNow": "刚刚现在", + "providerTestFailed": "提供商测试失败", + "openaiBaseUrlPlaceholder": "OpenAI Base URL", + "image": "图像", + "failedCreateChain": "创建链失败", + "importDatabase": "导入数据库", + "allDataLocal": "全部数据本地", + "sectionTitle": "分区标题", + "failedToggle": "切换失败", + "editCombo": "编辑 Combo", + "testResults": "测试结果", + "searchQuery": "搜索查询", "addCcCompatible": "添加 CC 兼容", - "duplicate": "Duplicate", - "createCombo": "Create Combo", - "searchTypeWeb": "Search Type Web", - "addChain": "Add Chain", - "prefixLabel": "Prefix Label", - "listModelsDesc": "List Models Desc", - "databaseSize": "Database Size", - "chainCreated": "Chain Created", - "providerTestTimeout": "Provider Test Timeout", - "routingStrategy": "Routing Strategy", - "translateAction": "Translate Action", - "exportFailed": "Export Failed", - "connectedVerificationPending": "Connected Verification Pending", - "a2aQuickStartTitle": "A2A Quick Start Title", - "couldNotTest": "Could Not Test", - "testAllCompatible": "Test All Compatible", - "anthropicCompatibleName": "Anthropic Compatible Name", - "disabling": "Disabling", - "failedEnable": "Failed Enable", - "categoryUtility": "Category Utility", - "customUrlOptional": "Custom Url Optional", - "localProviders": "Local Providers", - "comboNamePlaceholder": "Combo Name Placeholder", - "memoryRss": "Memory Rss", - "disableProvider": "Disable Provider", - "welcomeDesc": "Welcome Desc", - "nameLabel": "Name Label", - "allOperational": "All Operational", - "backupNow": "Backup Now", - "providerMaxRetriesAria": "Provider Max Retries Aria", - "textToSpeechDesc": "Text To Speech Desc", - "machineId": "Machine Id", - "globalProxy": "Global Proxy", - "hitsMisses": "Hits Misses", - "testDesc": "Test Desc", - "chatDesc": "Chat Desc", - "importSuccess": "Import Success", - "chat": "Chat", - "a2aQuickStartStep1": "A2A Quick Start Step1", - "importFailed": "Import Failed", - "inputPlaceholder": "Input Placeholder", - "providerModelsTitle": "Provider Models Title", - "lastBackup": "Last Backup", - "yourEndpoint": "Your Endpoint", - "autoDisableThresholdDesc": "Auto Disable Threshold Desc", - "rerankDesc": "Rerank Desc", - "modelsPathPlaceholder": "Models Path Placeholder", - "noCombosYet": "No Combos Yet", - "connectedVerificationPendingWithError": "Connected Verification Pending With Error", - "comboDefaultsGuideHint1": "Combo Defaults Guide Hint1", - "enableCloudTitle": "Enable Cloud Title", - "configuredProvidersHint": "Configured Providers Hint", - "paused": "Paused", - "llmProviders": "Llm Providers", - "enableCombo": "Enable Combo", - "removeProviderOverrideAria": "Remove Provider Override Aria", - "nodeVersion": "Node Version", + "duplicate": "重复", + "createCombo": "创建组合", + "searchTypeWeb": "搜索类型:Web", + "addChain": "添加链", + "prefixLabel": "前缀标签", + "listModelsDesc": "列出可用 Model。", + "databaseSize": "数据库大小", + "chainCreated": "链已创建", + "providerTestTimeout": "提供商测试超时", + "routingStrategy": "路由策略", + "translateAction": "翻译操作", + "exportFailed": "导出失败", + "connectedVerificationPending": "连接验证待处理", + "a2aQuickStartTitle": "A2A 快速开始", + "couldNotTest": "无法测试", + "testAllCompatible": "测试所有兼容项", + "anthropicCompatibleName": "Anthropic 兼容名称", + "disabling": "正在禁用", + "failedEnable": "启用失败", + "categoryUtility": "工具类别", + "customUrlOptional": "自定义 URL(可选)", + "localProviders": "本地 Provider", + "comboNamePlaceholder": "Combo 名称", + "memoryRss": "内存 RSS", + "disableProvider": "禁用提供商", + "welcomeDesc": "欢迎使用 OmniRoute。", + "nameLabel": "名称标签", + "allOperational": "全部运行正常", + "backupNow": "备份现在", + "providerMaxRetriesAria": "提供商最大重试次数", + "textToSpeechDesc": "将文本转换为语音音频。", + "machineId": "机器 ID", + "globalProxy": "全局代理", + "hitsMisses": "命中未命中", + "testDesc": "运行连接测试以验证配置。", + "chatDesc": "聊天说明", + "importSuccess": "导入成功", + "chat": "聊天", + "a2aQuickStartStep1": "A2A快速开始步骤1", + "importFailed": "导入失败", + "inputPlaceholder": "输入内容...", + "providerModelsTitle": "提供商模型", + "lastBackup": "最近备份", + "yourEndpoint": "你的 Endpoint", + "autoDisableThresholdDesc": "触发自动禁用前允许的连续失败次数。", + "rerankDesc": "重排说明", + "modelsPathPlaceholder": "Model 路径", + "noCombosYet": "暂无 Combo", + "connectedVerificationPendingWithError": "已连接,验证待完成:{error}", + "comboDefaultsGuideHint1": "配置 Combo 默认策略和目标。", + "enableCloudTitle": "启用云端", + "configuredProvidersHint": "仅显示已配置的 Provider。", + "paused": "已暂停", + "llmProviders": "LLM Provider", + "enableCombo": "启用组合", + "removeProviderOverrideAria": "移除提供商覆盖", + "nodeVersion": "Node 版本", "openai": "Openai", - "exportFailedWithError": "Export Failed With Error", - "proxyConfigured": "Proxy Configured", - "concurrencyPerModel": "Concurrency Per Model", - "protocolTasksLabel": "Protocol Tasks Label", + "exportFailedWithError": "导出失败:{error}", + "proxyConfigured": "代理已配置", + "concurrencyPerModel": "每个模型并发数", + "protocolTasksLabel": "协议任务标签", "oauthProviders": "Oauth Providers", - "lockedCount": "Locked Count", - "deleteChainConfirm": "Delete Chain Confirm", - "recentTranslations": "Recent Translations", - "zedImportButton": "Zed Import Button", - "responsesDesc": "Responses Desc", - "testedCount": "Tested Count", - "providersCommaSeparatedPlaceholder": "Providers Comma Separated Placeholder", - "signatureDefaults": "Signature Defaults", - "errorCreating": "Error Creating", - "timeRangeYear": "Time Range Year", + "lockedCount": "已锁定数量", + "deleteChainConfirm": "删除此后备链?", + "recentTranslations": "最近翻译", + "zedImportButton": "从 Zed 导入", + "responsesDesc": "Responses API 兼容端点", + "testedCount": "已测试数量", + "providersCommaSeparatedPlaceholder": "以逗号分隔的 Provider", + "signatureDefaults": "签名默认值", + "errorCreating": "错误创建", + "timeRangeYear": "时间范围年", "compatibleLabel": "兼容", - "cloudDisabledSuccess": "Cloud Disabled Success", - "deleteConfirm": "Delete Confirm", - "check": "Check", - "safeSearch": "Safe Search", - "protocolLastActivity": "Protocol Last Activity", - "openMcpDashboard": "Open Mcp Dashboard", - "testBenchTab": "Test Bench Tab", - "chatPathLabel": "Chat Path Label", - "retryDelay": "Retry Delay", - "errorUpdating": "Error Updating", - "ideCliIntegrations": "Ide Cli Integrations", - "imageGeneration": "Image Generation", - "apiKeyRequired": "Api Key Required", - "resetAllTitle": "Reset All Title", - "connectionError": "Connection Error", - "modelName": "Model Name", - "apiKeyForCheck": "Api Key For Check", - "cloudRequestTimeout": "Cloud Request Timeout", - "showConfiguredOnly": "Show Configured Only", - "includeDomains": "Include Domains", - "promptCache": "Prompt Cache", - "cloudConnected": "Cloud Connected", - "deleteChain": "Delete Chain", - "chatPathPlaceholder": "Chat Path Placeholder", - "databasePath": "Database Path", - "usingLocalServer": "Using Local Server", - "globalComboConfig": "Global Combo Config", - "backupFailed": "Backup Failed", - "tabProtocols": "Tab Protocols", - "continue": "Continue", - "categorySearch": "Category Search", - "rateLimitStatus": "Rate Limit Status", - "repairEnvSuccess": "Repair Env Success", - "nameRequired": "Name Required", - "country": "Country", - "trackMetricsDesc": "Track Metrics Desc", - "durationSecondsShort": "Duration Seconds Short", - "formatConverterDescription": "Format Converter Description", - "cloudBenefitAccess": "Cloud Benefit Access", - "maxRetries": "Max Retries", - "queueTimeout": "Queue Timeout", - "addProvider": "Add Provider", - "realtime": "Realtime", - "totalTranslations": "Total Translations", - "protocolActiveStreamsLabel": "Protocol Active Streams Label", - "repairEnv": "Repair Env", - "modelsCount": "Models Count", - "viewBackups": "View Backups", - "responsesApi": "Responses Api", - "copyComboName": "Copy Combo Name", - "failures": "Failures", - "failedUpdate": "Failed Update", - "imageDesc": "Image Desc", - "failedCreate": "Failed Create", - "remainingOfLimit": "Remaining Of Limit", - "protocolsTitle": "Protocols Title", - "expirationBannerExpiringSoonDesc": "Expiration Banner Expiring Soon Desc", - "source": "Source", - "failed": "Failed", - "apiKeyMgmt": "Api Key Mgmt", - "mcpQuickStartStep1": "Mcp Quick Start Step1", - "runTest": "Run Test", - "loadingFallbackChains": "Loading Fallback Chains", - "healthy": "Healthy", - "version": "Version", - "saveBlockWeighted": "Save Block Weighted", - "zedImportNone": "Zed Import None", - "noBackupsYet": "No Backups Yet", - "noGlobalProxy": "No Global Proxy", - "noModels": "No Models", - "stickyLimit": "Sticky Limit", - "passedCount": "Passed Count", - "zedImportFailed": "Zed Import Failed", - "saving": "Saving", - "testAll": "Test All", - "globalProxyDesc": "Global Proxy Desc", - "latencyP99": "Latency P99", - "connectingToCloud": "Connecting To Cloud", + "cloudDisabledSuccess": "云端已禁用", + "deleteConfirm": "确认删除?", + "check": "检查", + "safeSearch": "安全搜索", + "protocolLastActivity": "协议最近活动", + "openMcpDashboard": "打开 MCP Dashboard", + "testBenchTab": "测试台", + "chatPathLabel": "聊天路径标签", + "retryDelay": "重试延迟", + "errorUpdating": "错误更新", + "ideCliIntegrations": "IDE 与 CLI 集成", + "imageGeneration": "图像生成", + "apiKeyRequired": "API key 为必填项", + "resetAllTitle": "全部重置", + "connectionError": "连接错误", + "modelName": "模型名称", + "apiKeyForCheck": "用于检查的 API 密钥", + "cloudRequestTimeout": "云端请求超时", + "showConfiguredOnly": "显示已配置仅", + "includeDomains": "包含域名", + "promptCache": "提示缓存", + "cloudConnected": "Cloud 已连接", + "deleteChain": "删除链", + "chatPathPlaceholder": "聊天路径占位符", + "databasePath": "数据库路径", + "usingLocalServer": "正在使用本地 Server", + "globalComboConfig": "全局 Combo 配置", + "backupFailed": "备份失败", + "tabProtocols": "协议标签页", + "continue": "继续", + "categorySearch": "类别搜索", + "rateLimitStatus": "速率限制状态", + "repairEnvSuccess": "环境修复成功", + "nameRequired": "名称必填", + "country": "国家/地区", + "trackMetricsDesc": "跟踪请求、延迟和成本指标。", + "durationSecondsShort": "时长秒短标签", + "formatConverterDescription": "在不同 API 格式之间转换请求。", + "cloudBenefitAccess": "访问 Cloud 能力", + "maxRetries": "最大重试次数", + "queueTimeout": "队列超时", + "addProvider": "添加提供商", + "realtime": "实时", + "totalTranslations": "总计翻译", + "protocolActiveStreamsLabel": "协议活跃 Stream", + "repairEnv": "修复环境", + "modelsCount": "Model 数量", + "viewBackups": "查看备份", + "responsesApi": "Responses API", + "copyComboName": "复制 Combo 名称", + "failures": "失败", + "failedUpdate": "更新失败", + "imageDesc": "图像说明", + "failedCreate": "创建失败", + "remainingOfLimit": "剩余Of限制", + "protocolsTitle": "协议", + "expirationBannerExpiringSoonDesc": "过期横幅即将过期即将说明", + "source": "来源", + "failed": "失败", + "apiKeyMgmt": "API key 管理", + "mcpQuickStartStep1": "MCP 快速开始步骤 1", + "runTest": "运行测试", + "loadingFallbackChains": "正在加载后备链", + "healthy": "健康", + "version": "版本", + "saveBlockWeighted": "保存 Block Weighted", + "zedImportNone": "没有可从 Zed 导入的内容", + "noBackupsYet": "无备份暂无", + "noGlobalProxy": "无全局代理", + "noModels": "无 Model", + "stickyLimit": "粘性限制", + "passedCount": "通过数量", + "zedImportFailed": "Zed 导入失败", + "saving": "正在保存", + "testAll": "全部测试", + "globalProxyDesc": "全局代理说明", + "latencyP99": "延迟P99", + "connectingToCloud": "正在连接云端", "responses": "Responses", - "errorDeleting": "Error Deleting", - "openaiPrefixPlaceholder": "Openai Prefix Placeholder", - "enterPassword": "Enter Password", - "openA2aDashboard": "Open A2A Dashboard", - "enableProvider": "Enable Provider", - "modeTest": "Mode Test", - "failedDeleteChain": "Failed Delete Chain", - "providerDesc": "Provider Desc", - "templateLoadHint": "Template Load Hint", - "lastFailure": "Last Failure", - "moveDown": "Move Down", - "providerLabel": "Provider Label", - "clearCacheFailed": "Clear Cache Failed", - "imagesGenerations": "Images Generations", - "repairEnvWorking": "Repair Env Working", - "millisecondsShort": "Milliseconds Short", - "globalLabel": "Global Label", - "failuresPlural": "Failures Plural", - "completionsLegacyDesc": "Completions Legacy Desc", - "searchProviders": "Search Providers", - "chatPathHint": "Chat Path Hint", - "defaultStrategyDesc": "Default Strategy Desc", - "latencyP95": "Latency P95", - "textToSpeech": "Text To Speech", - "searchType": "Search Type", + "errorDeleting": "错误删除", + "openaiPrefixPlaceholder": "OpenAI 前缀", + "enterPassword": "输入密码", + "openA2aDashboard": "打开 A2A Dashboard", + "enableProvider": "启用提供商", + "modeTest": "测试模式", + "failedDeleteChain": "删除链失败", + "providerDesc": "提供商描述", + "templateLoadHint": "选择模板以快速填充配置。", + "lastFailure": "最近失败", + "moveDown": "移动下移", + "providerLabel": "提供商标签", + "clearCacheFailed": "清除缓存失败", + "imagesGenerations": "图像生成", + "repairEnvWorking": "修复环境处理中", + "millisecondsShort": "毫秒短标签", + "globalLabel": "全局标签", + "failuresPlural": "失败复数", + "completionsLegacyDesc": "旧版 Completions API 兼容端点。", + "searchProviders": "搜索 Provider", + "chatPathHint": "聊天路径提示", + "defaultStrategyDesc": "默认策略说明", + "latencyP95": "延迟P95", + "textToSpeech": "文本转语音", + "searchType": "搜索类型", "messages": "Messages", - "aggregatorsGateways": "Aggregators Gateways", - "comboStrategyAria": "Combo Strategy Aria", - "input": "Input", - "testingConnection": "Testing Connection", - "excludeDomains": "Exclude Domains", + "aggregatorsGateways": "聚合器与网关", + "comboStrategyAria": "Combo 策略", + "input": "输入", + "testingConnection": "正在测试连接", + "excludeDomains": "排除域名", "webCookieProviders": "Web Cookie Providers", - "allTestsPassed": "All Tests Passed", - "openaiCompatibleName": "Openai Compatible Name", - "warningCostOptimizedPartialPricing": "Warning Cost Optimized Partial Pricing", - "comboDefaultsGuideTitle": "Combo Defaults Guide Title", - "hoursAgo": "Hours Ago", - "notAvailable": "Not Available", - "skipAndContinue": "Skip And Continue", - "moderations": "Moderations", - "proxyConfig": "Proxy Config", - "upstreamProxyProviders": "Upstream Proxy Providers", - "exportAll": "Export All", - "queuedCount": "Queued Count", - "resetAll": "Reset All", - "noTranslations": "No Translations", - "avgLatency": "Avg Latency", - "throttleStatus": "Throttle Status", - "backupRetentionDesc": "Backup Retention Desc", - "noCBData": "No Cb Data", - "chainDeleted": "Chain Deleted", - "timeLeft": "Time Left", - "expirationBannerExpired": "Expiration Banner Expired", - "language": "Language", - "invalidFileType": "Invalid File Type", - "monitoredProviders": "Monitored Providers", - "errors": "Errors", - "heap": "Heap", + "allTestsPassed": "全部测试通过", + "openaiCompatibleName": "OpenAI 兼容名称", + "warningCostOptimizedPartialPricing": "部分模型缺少定价,成本优化结果可能不完整。", + "comboDefaultsGuideTitle": "组合默认值指南", + "hoursAgo": "小时前", + "notAvailable": "不可用", + "skipAndContinue": "跳过并继续", + "moderations": "审核", + "proxyConfig": "代理配置", + "upstreamProxyProviders": "上游代理 Provider", + "exportAll": "导出全部", + "queuedCount": "排队中数量", + "resetAll": "重置全部", + "noTranslations": "无翻译", + "avgLatency": "平均延迟", + "throttleStatus": "限流状态", + "backupRetentionDesc": "备份保留说明", + "noCBData": "无CB数据", + "chainDeleted": "链已删除", + "timeLeft": "时间剩余", + "expirationBannerExpired": "过期横幅已过期", + "language": "语言", + "invalidFileType": "无效文件类型", + "monitoredProviders": "监控中的 Provider", + "errors": "错误", + "heap": "堆内存", "chatCompletions": "Chat Completions", - "exampleTemplatesHint": "Example Templates Hint", - "retryDelayLabel": "Retry Delay Label", - "audioTranscription": "Audio Transcription", - "fallbackChainsDesc": "Fallback Chains Desc", - "exampleTemplates": "Example Templates", - "connectionsCount": "Connections Count", - "modelsPathLabel": "Models Path Label", - "activeProvidersHint": "Active Providers Hint", - "activeLockouts": "Active Lockouts", - "invalid": "Invalid", - "skipPassword": "Skip Password", - "moveUp": "Move Up", - "timeRange": "Time Range", - "stickyLimitDesc": "Sticky Limit Desc", - "cloudBenefitEdge": "Cloud Benefit Edge", - "custom": "Custom", - "verifying": "Verifying", - "baseUrlLabel": "Base Url Label", - "setPassword": "Set Password", - "safeSearchStrict": "Safe Search Strict", - "noChangesSinceBackup": "No Changes Since Backup", - "modelsPathHint": "Models Path Hint", - "audioTranscriptions": "Audio Transcriptions", - "testCombo": "Test Combo", - "mcpQuickStartTitle": "Mcp Quick Start Title", - "comboUpdated": "Combo Updated", - "weighted": "Weighted", - "providers": "Providers", + "exampleTemplatesHint": "示例模板提示", + "retryDelayLabel": "重试延迟标签", + "audioTranscription": "音频转写", + "fallbackChainsDesc": "定义每个模型的提供商后备顺序。", + "exampleTemplates": "示例模板", + "connectionsCount": "连接数量", + "modelsPathLabel": "Model 路径", + "activeProvidersHint": "当前正在处理请求的 Provider。", + "activeLockouts": "活跃锁定", + "invalid": "无效", + "skipPassword": "跳过密码", + "moveUp": "移动上移", + "timeRange": "时间范围", + "stickyLimitDesc": "粘性限制说明", + "cloudBenefitEdge": "边缘 Cloud 能力", + "custom": "自定义", + "verifying": "正在验证", + "baseUrlLabel": "Base URL 标签", + "setPassword": "设置密码", + "safeSearchStrict": "安全搜索严格", + "noChangesSinceBackup": "无变更自从备份", + "modelsPathHint": "用于拉取 Model 列表的路径。", + "audioTranscriptions": "音频转写", + "testCombo": "测试组合", + "mcpQuickStartTitle": "MCP 快速开始", + "comboUpdated": "Combo 已更新", + "weighted": "加权", + "providers": "Provider", "ccCompatibleLabel": "CC 兼容", - "noFallbackChainsDesc": "No Fallback Chains Desc", - "yesImport": "Yes Import", - "lockoutsAutoRefreshHint": "Lockouts Auto Refresh Hint", - "tabApis": "Tab Apis", - "sectionDescription": "Section Description", - "filter": "Filter", - "purgeLogsFailed": "Purge Logs Failed", - "latency": "Latency", - "testAllOAuth": "Test All O Auth", - "mcpQuickStartStep3": "Mcp Quick Start Step3", - "repairEnvFailed": "Repair Env Failed", - "zedImportHint": "Zed Import Hint", - "modelsAcrossEndpoints": "Models Across Endpoints", - "autoDisableBannedAccounts": "Auto Disable Banned Accounts", - "securityDesc": "Security Desc", - "listModels": "List Models", - "backupRestore": "Backup Restore", - "target": "Target", - "zedImportSuccess": "Zed Import Success", - "lastHeaderUpdate": "Last Header Update", - "categoryCore": "Category Core", - "noFallbackChains": "No Fallback Chains", - "noSearchProviders": "No Search Providers", - "autoBalance": "Auto Balance", - "noModelsYet": "No Models Yet", - "signatureFamily": "Signature Family", - "externalApiCalls": "External Api Calls", - "providerOverridesDesc": "Provider Overrides Desc", - "signatureTool": "Signature Tool", - "connectionFailed": "Connection Failed", - "activeLimitersPlural": "Active Limiters Plural", - "doneDesc": "Done Desc", - "down": "Down", - "noDataYet": "No Data Yet", - "activeProviders": "Active Providers", - "nameInvalid": "Name Invalid", - "skip": "Skip", - "createChain": "Create Chain", - "audioSpeech": "Audio Speech", - "cacheCleared": "Cache Cleared", - "searchTypeNews": "Search Type News", - "durationMillisecondsShort": "Duration Milliseconds Short", - "addOpenAICompatible": "Add Open Ai Compatible", - "chatTesterTab": "Chat Tester Tab", - "queued": "Queued", - "domainPlaceholder": "Domain Placeholder", - "durationMinutesShort": "Duration Minutes Short", - "verifyingConnection": "Verifying Connection", - "imageProviders": "Image Providers", - "protocolToolsLabel": "Protocol Tools Label", - "queryPlaceholder": "Query Placeholder", - "videoGeneration": "Video Generation", - "timeRangeWeek": "Time Range Week", - "limitExhausted": "Limit Exhausted", - "failedDisable": "Failed Disable", - "inMemoryNote": "In Memory Note", - "compatibleHint": "Compatible Hint", - "errorShort": "Error Short", - "advancedHint": "Advanced Hint", - "restoreFailed": "Restore Failed", - "searchProvidersHeading": "Search Providers Heading", - "comboName": "Combo Name", - "autoDisableDescription": "Auto Disable Description", - "embeddingsDesc": "Embeddings Desc", - "operational": "Operational", - "testAllApiKey": "Test All Api Key", - "backupCreated": "Backup Created", - "errorDuringImport": "Error During Import", - "comboDefaultsGuideHint2": "Combo Defaults Guide Hint2", - "connecting": "Connecting", - "fillModelAndProviders": "Fill Model And Providers", - "syncing": "Syncing", - "resetConfirm": "Reset Confirm", - "trackMetrics": "Track Metrics", - "successful": "Successful", - "recovering": "Recovering", - "autoDisableThreshold": "Auto Disable Threshold", + "noFallbackChainsDesc": "创建一条链路,用于定义某个模型的提供商回退顺序。", + "yesImport": "确认导入", + "lockoutsAutoRefreshHint": "锁定自动刷新提示", + "tabApis": "API 标签页", + "sectionDescription": "分区描述", + "filter": "筛选", + "purgeLogsFailed": "清理日志失败", + "latency": "延迟", + "testAllOAuth": "测试所有 OAuth", + "mcpQuickStartStep3": "MCP 快速开始步骤 3", + "repairEnvFailed": "环境修复失败", + "zedImportHint": "从 Zed 配置中导入 Provider。", + "modelsAcrossEndpoints": "跨 Endpoint 的 Model", + "autoDisableBannedAccounts": "自动禁用被封禁账户", + "securityDesc": "安全说明", + "listModels": "列出 Model", + "backupRestore": "备份恢复", + "target": "目标", + "zedImportSuccess": "Zed 导入成功", + "lastHeaderUpdate": "上次请求头更新", + "categoryCore": "类别核心", + "noFallbackChains": "无后备链", + "noSearchProviders": "无搜索 Provider", + "autoBalance": "自动均衡", + "noModelsYet": "暂无 Model", + "signatureFamily": "签名族", + "externalApiCalls": "外部 API 调用", + "providerOverridesDesc": "覆盖每个提供商的超时和重试设置。", + "signatureTool": "签名工具", + "connectionFailed": "连接失败", + "activeLimitersPlural": "活跃限制器复数", + "doneDesc": "完成说明", + "down": "下移", + "noDataYet": "无数据暂无", + "activeProviders": "活跃 Provider", + "nameInvalid": "名称无效", + "skip": "跳过", + "createChain": "创建链", + "audioSpeech": "语音合成", + "cacheCleared": "缓存已清除", + "searchTypeNews": "搜索类型:News", + "durationMillisecondsShort": "时长毫秒短标签", + "addOpenAICompatible": "添加打开Ai兼容", + "chatTesterTab": "聊天测试标签页", + "queued": "排队中", + "domainPlaceholder": "域名占位符", + "durationMinutesShort": "时长分钟短标签", + "verifyingConnection": "正在验证连接", + "imageProviders": "图像 Provider", + "protocolToolsLabel": "协议工具标签", + "queryPlaceholder": "查询占位符", + "videoGeneration": "视频生成", + "timeRangeWeek": "时间范围:周", + "limitExhausted": "限额已耗尽", + "failedDisable": "禁用失败", + "inMemoryNote": "数据仅保存在内存中。", + "compatibleHint": "兼容提示", + "errorShort": "错误短标签", + "advancedHint": "显示高级选项。", + "restoreFailed": "恢复失败", + "searchProvidersHeading": "搜索 Provider", + "comboName": "Combo 名称", + "autoDisableDescription": "当检测到永久封禁信号时自动禁用账户。", + "embeddingsDesc": "Embeddings API 兼容端点。", + "operational": "运行正常", + "testAllApiKey": "测试所有 API 密钥", + "backupCreated": "备份已创建", + "errorDuringImport": "错误期间导入", + "comboDefaultsGuideHint2": "这些默认值会在新建 Combo 时使用。", + "connecting": "正在连接", + "fillModelAndProviders": "请填写模型和提供商", + "syncing": "正在同步", + "resetConfirm": "重置确认", + "trackMetrics": "跟踪指标", + "successful": "成功", + "recovering": "正在恢复", + "autoDisableThreshold": "自动禁用阈值", "anthropic": "Anthropic", - "syncingData": "Syncing Data", - "cloudBenefitPorts": "Cloud Benefit Ports", - "compatibleProviders": "Compatible Providers", - "clearCache": "Clear Cache", - "reqs": "Reqs", - "addAnthropicCompatible": "Add Anthropic Compatible", - "apiKeyProviders": "Api Key Providers", - "tabsAria": "Tabs Aria", - "resetting": "Resetting", - "millisecondsAbbr": "Milliseconds Abbr", - "backupReasonPreRestore": "Backup Reason Pre Restore", - "disableCloud": "Disable Cloud", - "newProviderNameAria": "New Provider Name Aria", - "passwordsMismatch": "Passwords Mismatch", - "a2aQuickStartStep3": "A2A Quick Start Step3", - "liveMonitorDescriptionSuffix": "Live Monitor Description Suffix", - "failedAddProvider": "Failed Add Provider", - "addAtLeastOneProvider": "Add At Least One Provider", - "reasonSeparator": "Reason Separator", - "zedImporting": "Zed Importing", - "confirmPasswordPlaceholder": "Confirm Password Placeholder", - "whatYouGet": "What You Get", - "signatureSession": "Signature Session", - "errorCountNoCode": "Error Count No Code", - "testing": "Testing", - "providersCommaSeparated": "Providers Comma Separated", - "exportDatabase": "Export Database", - "hitRate": "Hit Rate", - "completionsLegacy": "Completions Legacy", - "removeModel": "Remove Model", - "timeRangeDay": "Time Range Day", - "cloudRequestFailed": "Cloud Request Failed", - "updatedAt": "Updated At", - "monitoredProvidersHint": "Monitored Providers Hint", - "connectionSuccessful": "Connection Successful", - "latencyP50": "Latency P50", - "logsDeleted": "Logs Deleted", - "chatTester": "Chat Tester", - "safeSearchOff": "Safe Search Off", - "nameHint": "Name Hint", - "debugToggle": "Debug Toggle", + "syncingData": "正在同步数据", + "cloudBenefitPorts": "通过 Cloud 暴露端口", + "compatibleProviders": "兼容 Provider", + "clearCache": "清除缓存", + "reqs": "请求数", + "addAnthropicCompatible": "添加 Anthropic 兼容 Provider", + "apiKeyProviders": "API key Provider", + "tabsAria": "标签页", + "resetting": "正在重置", + "millisecondsAbbr": "毫秒缩写", + "backupReasonPreRestore": "恢复前备份", + "disableCloud": "禁用云端", + "newProviderNameAria": "新的提供商名称", + "passwordsMismatch": "密码不匹配", + "a2aQuickStartStep3": "A2A快速开始步骤3", + "liveMonitorDescriptionSuffix": "或外部 API 调用来生成事件。", + "failedAddProvider": "添加提供商失败", + "addAtLeastOneProvider": "至少添加一个提供商", + "reasonSeparator": "原因分隔符", + "zedImporting": "正在从 Zed 导入", + "confirmPasswordPlaceholder": "确认密码占位符", + "whatYouGet": "你将获得", + "signatureSession": "签名 Session", + "errorCountNoCode": "无代码错误数", + "testing": "正在测试", + "providersCommaSeparated": "以逗号分隔的 Provider", + "exportDatabase": "导出数据库", + "hitRate": "命中率", + "completionsLegacy": "Completions 旧版", + "removeModel": "移除模型", + "timeRangeDay": "时间范围:天", + "cloudRequestFailed": "云端请求失败", + "updatedAt": "更新时间", + "monitoredProvidersHint": "选择需要监控的 Provider。", + "connectionSuccessful": "连接成功", + "latencyP50": "延迟P50", + "logsDeleted": "日志已删除", + "chatTester": "聊天测试器", + "safeSearchOff": "Safe Search 关闭", + "nameHint": "名称提示", + "debugToggle": "调试开关", "embedding": "Embedding", - "issuesLabel": "Issues Label", - "optionAny": "Option Any", - "maxNestingDepth": "Max Nesting Depth", + "issuesLabel": "问题标签", + "optionAny": "任意选项", + "maxNestingDepth": "最大嵌套深度", "rerank": "Rerank", - "checking": "Checking", - "getStarted": "Get Started", - "restoreSuccess": "Restore Success", - "issuesDetected": "Issues Detected", - "signatureCache": "Signature Cache", - "modelLockouts": "Model Lockouts", - "usingCloudProxy": "Using Cloud Proxy", - "loadingHealth": "Loading Health", - "providerOverrides": "Provider Overrides", - "audioTranscriptionDesc": "Audio Transcription Desc", - "learnedFromHeaders": "Learned From Headers", - "totalRequests": "Total Requests", - "cloudUnstableNote": "Cloud Unstable Note" + "checking": "正在检查", + "getStarted": "开始使用", + "restoreSuccess": "恢复成功", + "issuesDetected": "检测到问题", + "signatureCache": "签名缓存", + "modelLockouts": "模型锁定", + "usingCloudProxy": "正在使用 Cloud 代理", + "loadingHealth": "正在加载健康状态", + "providerOverrides": "提供商覆盖", + "audioTranscriptionDesc": "音频转写说明", + "learnedFromHeaders": "从响应头学习", + "totalRequests": "总计请求", + "cloudUnstableNote": "Cloud 连接不稳定,部分功能可能受影响。" }, "sidebar": { "home": "首页", @@ -707,40 +707,41 @@ "cliToolsShort": "工具", "cache": "缓存", "cacheShort": "缓存", - "batch": "Batch Jobs", - "themeSystem": "Theme System", - "whitelabelingDesc": "Whitelabeling Desc", - "switchThemes": "Switch Themes", - "themeAccentDesc": "Theme Accent Desc", - "uploadFavicon": "Upload Favicon", - "themeDark": "Theme Dark", - "customLogoDesc": "Custom Logo Desc", - "sidebarVisibilityToggle": "Sidebar Visibility Toggle", - "themeAccent": "Theme Accent", - "resetFavicon": "Reset Favicon", - "whitelabeling": "Whitelabeling", - "darkMode": "Dark Mode", - "uploadLogo": "Upload Logo", - "themeLight": "Theme Light", - "appName": "App Name", - "appNameDesc": "App Name Desc", - "resetLogo": "Reset Logo", - "customFavicon": "Custom Favicon", - "hideHealthLogs": "Hide Health Logs", - "customLogo": "Custom Logo", - "appearance": "Appearance", - "themeSelectionAria": "Theme Selection Aria", - "themeCreate": "Theme Create", - "customFaviconDesc": "Custom Favicon Desc", - "logoPreview": "Logo Preview", - "themeCustom": "Theme Custom", - "hideHealthLogsDesc": "Hide Health Logs Desc", - "faviconPreview": "Favicon Preview", - "changelog": "Changelog", - "contextSection": "Context & Cache", + "batch": "批量任务", + "themeSystem": "主题系统", + "whitelabelingDesc": "自定义品牌展示与主题外观。", + "switchThemes": "切换主题", + "themeAccentDesc": "选择用于按钮、链接和高亮状态的强调色。", + "uploadFavicon": "上传 Favicon", + "themeDark": "深色主题", + "customLogoDesc": "上传用于侧边栏和登录页的自定义 Logo。", + "sidebarVisibilityToggle": "侧边栏可见性开关", + "themeAccent": "主题强调色", + "resetFavicon": "重置 Favicon", + "whitelabeling": "白标", + "darkMode": "深色模式", + "uploadLogo": "上传 Logo", + "themeLight": "浅色主题", + "appName": "应用名称", + "appNameDesc": "设置在界面和浏览器标题中显示的名称。", + "resetLogo": "重置 Logo", + "customFavicon": "自定义 Favicon", + "hideHealthLogs": "隐藏健康检查日志", + "customLogo": "自定义 Logo", + "appearance": "外观", + "themeSelectionAria": "主题选择", + "themeCreate": "创建主题", + "customFaviconDesc": "上传用于浏览器标签页的自定义 Favicon。", + "logoPreview": "Logo 预览", + "themeCustom": "自定义主题", + "hideHealthLogsDesc": "隐藏健康检查日志说明", + "faviconPreview": "Favicon 预览", + "changelog": "更新日志", + "contextSection": "上下文与缓存", "contextCaveman": "Caveman", "contextRtk": "RTK", - "contextCombos": "Compression Combos" + "contextCombos": "压缩组合", + "webhooks": "Webhook" }, "themesPage": { "title": "主题", @@ -832,8 +833,8 @@ "modelStatusError": "错误", "comboHealth": "组合健康状况", "comboHealthDescription": "组合级别配额、使用分布和性能指标", - "compressionAnalyticsTitle": "Compression Analytics", - "compressionAnalyticsDescription": "Compression analytics — token savings, mode breakdown, and provider stats." + "compressionAnalyticsTitle": "压缩分析", + "compressionAnalyticsDescription": "压缩分析 — token 节省、模式分布和提供商统计。" }, "apiManager": { "title": "API 密钥", @@ -1287,8 +1288,8 @@ "title": "选择模型" }, "5": { - "title": "Use Thinking Variant", - "desc": "For thinking models, run with --variant high/low/max (example command below)." + "title": "使用 Thinking 变体", + "desc": "对于思考模型,请使用 --variant high/low/max 运行(示例命令见下方)。" } }, "notes": { @@ -1320,12 +1321,12 @@ "windsurf": { "steps": { "1": { - "title": "Open AI Settings", - "desc": "Click the AI Settings icon in Windsurf or go to Settings" + "title": "打开 AI 设置", + "desc": "点击 Windsurf 中的 AI Settings 图标,或前往 Settings" }, "2": { - "title": "Add Custom Provider", - "desc": "Select \"Add custom provider\" (OpenAI-compatible)" + "title": "添加自定义提供商", + "desc": "选择 \"Add custom provider\" (OpenAI 兼容)" }, "3": { "title": "Base URL", @@ -1333,11 +1334,11 @@ }, "4": { "title": "API Key", - "desc": "Select your OmniRoute API key" + "desc": "选择你的 OmniRoute API 密钥" }, "5": { - "title": "Select Model", - "desc": "Choose a model from the dropdown" + "title": "选择模型", + "desc": "从下拉菜单中选择模型" } } } @@ -1800,23 +1801,61 @@ "noData": "无成本数据", "byModel": "按模型", "byProvider": "按提供商", - "spend30d": "Spend30D", - "activeModels": "Active Models", - "selectedWindow": "Selected Window", - "activeProviders": "Active Providers", - "overviewTitle": "Overview Title", - "spend7d": "Spend7D", - "avgCostPerRequest": "Avg Cost Per Request", - "noCostDataDescription": "No Cost Data Description", - "spendToday": "Spend Today", - "overviewLoadFailed": "Overview Load Failed", - "overviewDescription": "Overview Description", - "providerShare": "Provider Share", - "topProviders": "Top Providers", - "costTrend": "Cost Trend", - "noCostDataTitle": "No Cost Data Title", - "topModels": "Top Models", - "requestsInWindow": "Requests In Window" + "spend30d": "30 天支出", + "activeModels": "活跃 Model", + "selectedWindow": "已选窗口", + "activeProviders": "活跃 Provider", + "overviewTitle": "概览", + "spend7d": "7 天支出", + "avgCostPerRequest": "平均每次请求成本", + "noCostDataDescription": "发起请求后,成本数据会显示在这里。", + "spendToday": "今日支出", + "overviewLoadFailed": "加载成本概览失败", + "overviewDescription": "按提供商、模型和时间段汇总成本。", + "providerShare": "提供商占比", + "topProviders": "热门 Provider", + "costTrend": "成本趋势", + "noCostDataTitle": "暂无成本数据", + "topModels": "热门 Model", + "requestsInWindow": "窗口内请求数", + "pageDescription": "跟踪支出、分析趋势,并管理所有提供商的 AI 预算", + "overview": "概览", + "range7d": "7 天", + "range30d": "30 天", + "range90d": "90 天", + "rangeAll": "全部时间", + "tokenUsage": "Token 用量", + "totalTokens": "Token 总数", + "inputTokens": "输入 Token", + "outputTokens": "输出 Token", + "inputOutputRatio": "输入/输出比例", + "tokens": "token", + "routingEfficiency": "路由效率", + "fallbackCount": "回退请求数", + "fallbackRate": "回退率", + "modelCoverage": "模型覆盖率", + "modelCoverageDesc": "带有明确模型的请求占比", + "outOfRequests": "共 {total} 个请求", + "costByApiKey": "按 API 密钥统计成本", + "costByAccount": "按账户统计成本", + "apiKeyName": "API 密钥", + "account": "账户", + "requests": "请求数", + "cost": "成本", + "dayStreak": "连续天数", + "weeklyUsagePattern": "每周使用模式", + "activityHeatmap": "活动热力图(365 天)", + "less": "较少", + "more": "较多", + "monthlyForecast": "月度预测", + "forecastBasis": "基于最近 {days} 天", + "avgDailyCost": "平均每日成本", + "daysRemaining": "剩余 {days} 天", + "periodComparison": "周期对比", + "previousPeriod": "上半期", + "currentPeriod": "当前半期", + "exportCSV": "导出为 CSV", + "exportJSON": "导出为 JSON" }, "endpoint": { "title": "API 端点", @@ -1949,40 +1988,58 @@ "a2aQuickStartStep3": "使用 `tasks/get` 与 `tasks/cancel` 跟踪和控制任务。", "completionsLegacy": "Completions(旧版)", "completionsLegacyDesc": "旧版 OpenAI 文本补全接口,同时接受 `prompt` 字符串和 `messages` 数组格式", - "videoGeneration": "Video Generation", - "videoDesc": "Generate videos using AI models like ComfyUI and Stable Video Diffusion.", - "tailscaleRequestFailed": "Failed to load Tailscale status", - "tailscaleEnableFailed": "Failed to enable Tailscale Funnel", - "tailscaleWaitingForLogin": "Complete the Tailscale login in the opened browser tab. OmniRoute will retry automatically.", - "tailscaleLoginTimedOut": "Timed out waiting for Tailscale login", - "tailscaleWaitingForFunnel": "Enable Funnel for this device in the opened browser tab. OmniRoute will keep polling.", - "tailscaleFunnelTimedOut": "Timed out waiting for Tailscale Funnel to be enabled", - "tailscaleStarted": "Tailscale Funnel enabled", - "tailscaleDisableFailed": "Failed to disable Tailscale Funnel", - "tailscaleStopped": "Tailscale Funnel disabled", - "tailscaleInstallFailed": "Failed to install Tailscale", - "tailscaleInstallProgress": "Working...", - "tailscaleInstalled": "Tailscale installed successfully", - "tailscaleRunning": "Running", - "tailscaleNeedsLogin": "Needs Login", - "tailscaleStoppedState": "Stopped", - "tailscaleNotInstalled": "Not installed", - "tailscaleUnsupported": "Unsupported", - "tailscaleError": "Error", - "tailscaleDisable": "Stop Funnel", - "tailscaleInstallAndEnable": "Install & Enable", - "tailscaleLoginAndEnable": "Login & Enable", - "tailscaleEnable": "Enable Funnel", - "tailscaleUrlNotice": "Uses your Tailscale .ts.net address. Login and Funnel approval may be required on first use.", + "videoGeneration": "视频生成", + "videoDesc": "使用 ComfyUI 和 Stable Video Diffusion 等 AI 模型生成视频。", + "tailscaleRequestFailed": "加载 Tailscale 状态失败", + "tailscaleEnableFailed": "启用 Tailscale Funnel 失败", + "tailscaleWaitingForLogin": "请在打开的浏览器标签页中完成 Tailscale 登录。OmniRoute 会自动重试。", + "tailscaleLoginTimedOut": "等待 Tailscale 登录超时", + "tailscaleWaitingForFunnel": "请在打开的浏览器标签页中为此设备启用 Funnel。OmniRoute 会继续轮询。", + "tailscaleFunnelTimedOut": "等待启用 Tailscale Funnel 超时", + "tailscaleStarted": "Tailscale Funnel 已启用", + "tailscaleDisableFailed": "禁用 Tailscale Funnel 失败", + "tailscaleStopped": "Tailscale Funnel 已禁用", + "tailscaleInstallFailed": "安装 Tailscale 失败", + "tailscaleInstallProgress": "处理中...", + "tailscaleInstalled": "Tailscale 安装成功", + "tailscaleRunning": "运行中", + "tailscaleNeedsLogin": "需要登录", + "tailscaleStoppedState": "已停止", + "tailscaleNotInstalled": "未安装", + "tailscaleUnsupported": "不支持", + "tailscaleError": "错误", + "tailscaleDisable": "停止 Funnel", + "tailscaleInstallAndEnable": "安装并启用", + "tailscaleLoginAndEnable": "登录并启用", + "tailscaleEnable": "启用 Funnel", + "tailscaleUrlNotice": "使用你的 Tailscale .ts.net 地址。首次使用时可能需要登录并批准 Funnel。", "tailscaleTitle": "Tailscale Funnel", - "tailscaleNeedsLoginHint": "Authenticate this machine with Tailscale, then enable Funnel.", + "tailscaleNeedsLoginHint": "先使用 Tailscale 认证此机器,然后启用 Funnel。", "tailscaleBinaryPath": "Binary: {path}", - "tailscaleLastError": "Last error: {error}", - "tailscaleInstallTitle": "Install Tailscale", - "tailscaleInstallIntro": "Installs Tailscale on this machine and prepares OmniRoute to enable Funnel.", - "tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.", - "tailscaleSudoPlaceholder": "Optional sudo password", - "tailscaleInstalling": "Installing" + "tailscaleLastError": "最近错误:{error}", + "tailscaleInstallTitle": "安装 Tailscale", + "tailscaleInstallIntro": "在此机器上安装 Tailscale,并准备让 OmniRoute 启用 Funnel。", + "tailscaleInstallPasswordHint": "在 macOS 和 Linux 上,安装软件包和启动守护进程可能需要 sudo。", + "tailscaleSudoPlaceholder": "可选 sudo 密码", + "tailscaleInstalling": "正在安装", + "tailscaleSudoLabel": "Sudo 密码(macOS/Linux 上必需)", + "ngrokTitle": "ngrok 隧道", + "ngrokRunning": "运行中", + "ngrokStarting": "正在启动", + "ngrokStoppedState": "已停止", + "ngrokNeedsAuth": "需要认证", + "ngrokNotInstalled": "未安装", + "ngrokUnsupported": "不支持", + "ngrokError": "错误", + "ngrokEnable": "启用隧道", + "ngrokDisable": "停止隧道", + "ngrokUrlNotice": "创建一个公开的 ngrok 隧道。", + "ngrokAuthTokenLabel": "Authtoken(未设置 NGROK_AUTHTOKEN 时必需)", + "ngrokAuthTokenPlaceholder": "输入你的 ngrok authtoken", + "ngrokLastError": "上次错误:{error}", + "ngrokStarted": "ngrok 隧道已启动", + "ngrokStopped": "ngrok 隧道已停止", + "ngrokRequestFailed": "更新 ngrok 隧道失败" }, "endpoints": { "tabProxy": "端点代理", @@ -2063,10 +2120,10 @@ "failed": "失败", "previous": "上一页", "next": "下一页", - "apiKeyId": "Api Key Id", - "offset": "Offset", - "limit": "Limit", - "tool": "Tool" + "apiKeyId": "API Key ID", + "offset": "偏移量", + "limit": "限制", + "tool": "工具" }, "a2aDashboard": { "loading": "正在加载 A2A 仪表板...", @@ -2121,9 +2178,9 @@ "metadata": "元数据", "events": "事件", "artifacts": "产物", - "tablePhase": "Table Phase", - "offset": "Offset", - "limit": "Limit", + "tablePhase": "阶段", + "offset": "偏移量", + "limit": "限制", "skill": "Skill" }, "health": { @@ -2199,11 +2256,11 @@ "resetting": "正在重置...", "resetAll": "全部重置", "until": "直到 {time}", - "limitExhausted": "Exhausted", - "learnedFromHeaders": "Learned from headers", - "remainingOfLimit": "{remaining}/{limit} remaining", - "throttleStatus": "Throttle: {value}", - "lastHeaderUpdate": "Header update: {age}" + "limitExhausted": "已耗尽", + "learnedFromHeaders": "从响应头学习", + "remainingOfLimit": "剩余 {remaining}/{limit}", + "throttleStatus": "限流:{value}", + "lastHeaderUpdate": "响应头更新:{age}" }, "limits": { "title": "限制和配额", @@ -2242,46 +2299,46 @@ "noEntries": "未找到审核日志条目", "previous": "上一页", "next": "下一页", - "providerWarningTitle": "Provider Warning Title", - "viewDetails": "View Details", - "eventMetadata": "Event Metadata", - "eventPayload": "Event Payload", - "requestId": "Request Id", - "providerWarningDesc": "Provider Warning Desc", + "providerWarningTitle": "提供商警告", + "viewDetails": "查看详情", + "eventMetadata": "事件元数据", + "eventPayload": "事件负载", + "requestId": "请求 ID", + "providerWarningDesc": "上游提供商返回了警告。请查看详情以了解更多信息。", "a": "A", - "offset": "Offset", - "limit": "Limit", - "status": "Status", - "resourceType": "Resource Type", - "totalEntries": "Total Entries", - "tab": "Tab", - "auditModalSubtitle": "Audit Modal Subtitle", - "close": "Close", - "runningRequests": "Active Requests", - "runningRequestsDesc": "Real-time view of currently running upstream requests", - "model": "Model", - "provider": "Provider", + "offset": "偏移量", + "limit": "限制", + "status": "状态", + "resourceType": "资源类型", + "totalEntries": "总条目数", + "tab": "标签页", + "auditModalSubtitle": "审计详情", + "close": "关闭", + "runningRequests": "活跃请求", + "runningRequestsDesc": "当前正在运行的上游请求实时视图", + "model": "模型", + "provider": "提供商", "account": "Account", - "elapsed": "Elapsed", - "count": "Count", - "payloads": "Payloads", - "viewPayloads": "View", - "activeCount": "{count} active", - "clientPayload": "Client Request Payload", - "upstreamPayload": "Upstream Provider Payload", - "upstreamNotSentYet": "Not sent to upstream yet", - "runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}", + "elapsed": "已耗时", + "count": "数量", + "payloads": "Payload", + "viewPayloads": "查看", + "activeCount": "{count} 个活跃", + "clientPayload": "客户端请求载荷", + "upstreamPayload": "上游提供商载荷", + "upstreamNotSentYet": "尚未发送到上游", + "runningRequestDetailMeta": "Account:{account} — 已耗时:{elapsed}", "consoleViewer": { - "fetchFailed": "Failed to fetch logs", - "copyFailed": "Failed to copy log entry", - "copyLogEntry": "Copy log entry" + "fetchFailed": "获取日志失败", + "copyFailed": "复制日志条目失败", + "copyLogEntry": "复制日志条目" }, - "export": "Export", - "exporting": "Exporting...", - "exportFailed": "Export failed", - "timeRange": "Time Range", - "lastNHours": "Last {hours}", - "defaultRange": "default" + "export": "导出", + "exporting": "正在导出...", + "exportFailed": "导出失败", + "timeRange": "时间范围", + "lastNHours": "最近 {hours} 小时", + "defaultRange": "默认" }, "onboarding": { "welcome": "欢迎", @@ -2377,7 +2434,7 @@ "selectProvider": "选择提供商", "selectedProvider": "选定的提供商", "authMethod": "认证方式", - "apiKeyLabel": "API密钥", + "apiKeyLabel": "API key", "apiKeyRequired": "需要 API 密钥", "selectProviderRequired": "请选择提供商", "enterApiKey": "输入您的 API 密钥", @@ -2420,7 +2477,7 @@ "nameLabel": "名称", "prefixLabel": "前缀", "baseUrlLabel": "基础 URL", - "apiTypeLabel": "API类型", + "apiTypeLabel": "API 类型", "prefixHint": "必填。模型名称使用的唯一前缀。", "nameHint": "必填。该节点的友好标签。", "baseUrlHint": "必填。  提供商 API 基本 URL。", @@ -2586,7 +2643,7 @@ "compatUpstreamRemoveRow": "删除此行", "compatBadgeUpstreamHeaders": "请求头", "perModelQuotaLabel": "按模型配额", - "perModelQuotaDescription": "启用后,429/404错误只会锁定特定模型,而不是整个连接。适用于具有按模型速率限制的提供商(例如ModelScope)。", + "perModelQuotaDescription": "启用后,429/404 错误只会锁定特定 Model,而不是整个连接。适用于具有按 Model 速率限制的 Provider(例如 ModelScope)。", "perModelQuotaToggle": "按模型配额开关", "modelId": "模型 ID", "customModelPlaceholder": "例如:gpt-4.5-turbo", @@ -2655,39 +2712,39 @@ "statusCreditsExhausted": "余额不足 / 配额已耗尽", "showEmails": "显示所有邮箱", "hideEmails": "隐藏所有邮箱", - "imagesGenerations": "Images Generations", - "audioSpeech": "Audio Speech", - "audioTranscriptions": "Audio Transcriptions", + "imagesGenerations": "图像生成", + "audioSpeech": "语音合成", + "audioTranscriptions": "音频转写", "embeddings": "Embeddings", "a": "A", - "accountConcurrencyCapHint": "Account Concurrency Cap Hint", - "accountConcurrencyCapLabel": "Account Concurrency Cap Label", - "accountIdHint": "Account Id Hint", - "accountIdLabel": "Account Id Label", - "accountIdPlaceholder": "Account Id Placeholder", - "addAnotherApiKey": "Add another API key or paste multiple keys", + "accountConcurrencyCapHint": "限制此 Account 可同时处理的请求数。", + "accountConcurrencyCapLabel": "Account 并发上限", + "accountIdHint": "用于区分同一 Provider 下的多个 Account。", + "accountIdLabel": "Account ID", + "accountIdPlaceholder": "Account ID 占位符", + "addAnotherApiKey": "添加另一个 API 密钥或粘贴多个密钥", "addCcCompatible": "添加 CC 兼容", - "aggregatorsGateways": "Aggregators Gateways", - "apiFormatLabel": "Api Format Label", - "apiKeyOptionalHint": "Api Key Optional Hint", - "apiKeyOptionalLabel": "Api Key Optional Label", - "apiRegionChina": "Api Region China", - "apiRegionHint": "Api Region Hint", - "apiRegionInternational": "Api Region International", - "apiRegionLabel": "Api Region Label", - "apikey": "Apikey", - "audio": "Audio", - "audioProvidersHeading": "Audio Providers Heading", - "audioShortLabel": "Audio Short Label", - "azureOpenAiBaseUrlHint": "Azure Open Ai Base Url Hint", - "bailianBaseUrlHint": "Bailian Base Url Hint", - "blackboxWebCookieHint": "Blackbox Web Cookie Hint", - "blackboxWebCookiePlaceholder": "Blackbox Web Cookie Placeholder", + "aggregatorsGateways": "聚合器与网关", + "apiFormatLabel": "API 格式", + "apiKeyOptionalHint": "如果上游不需要认证,可以留空 API key。", + "apiKeyOptionalLabel": "API key(可选)", + "apiRegionChina": "中国区", + "apiRegionHint": "选择此 Provider 的 API 区域。", + "apiRegionInternational": "国际区", + "apiRegionLabel": "API 区域", + "apikey": "API key", + "audio": "音频", + "audioProvidersHeading": "音频 Provider", + "audioShortLabel": "音频", + "azureOpenAiBaseUrlHint": "Azure OpenAI 资源的 Base URL。", + "bailianBaseUrlHint": "阿里云百炼服务的 Base URL。", + "blackboxWebCookieHint": "从 Blackbox Web 会话复制 Cookie。", + "blackboxWebCookiePlaceholder": "Blackbox Web Cookie", "blockClaudeExtraUsageDescription": "隐藏部分 Provider 返回的重复 Claude 额外用量记录,避免和主 token 统计重复。", "blockClaudeExtraUsageLabel": "屏蔽重复 Claude 用量", - "bulkPasteAdded": "{count, plural, one {1 key added} other {# keys added}}", - "bulkPasteDuplicatesIgnored": "{count, plural, one {1 duplicate skipped} other {# duplicates skipped}}", - "bulkPasteHint": "Paste one API key per line. Empty lines are ignored and duplicate keys are skipped.", + "bulkPasteAdded": "{count, plural, one {已添加 1 个 key} other {已添加 # 个 key}}", + "bulkPasteDuplicatesIgnored": "{count, plural, one {已跳过 1 个重复项} other {已跳过 # 个重复项}}", + "bulkPasteHint": "每行粘贴一个 API 密钥。空行会被忽略,重复密钥会被跳过。", "ccCompatibleBaseUrlHint": "Claude Code 专用中转站的 Base URL,不要包含 /messages。", "ccCompatibleBaseUrlPlaceholder": "https://relay.example.com/v1", "ccCompatibleChatPathHint": "默认使用 Claude Code 严格的 Messages API 路径。仅在中转站文档要求时修改。", @@ -2707,111 +2764,118 @@ "codexFastServiceTierDescription": "可用时为 Codex 请求使用 priority 服务层。", "codexFastServiceTierLabel": "Codex 快速服务层", "codexWeeklyToggleTitle": "为此连接跟踪 Codex 周配额", - "compatUpstreamHeaderNamePlaceholder": "Compat Upstream Header Name Placeholder", - "compatUpstreamHeaderValuePlaceholder": "Compat Upstream Header Value Placeholder", - "compatible": "Compatible", - "configuredCount": "Configured Count", - "consoleApiKeyOracleHint": "Console Api Key Oracle Hint", - "consoleApiKeyOracleLabel": "Console Api Key Oracle Label", - "consoleApiKeyOraclePlaceholder": "Console Api Key Oracle Placeholder", + "compatUpstreamHeaderNamePlaceholder": "上游 Header 名称", + "compatUpstreamHeaderValuePlaceholder": "上游 Header 值", + "compatible": "兼容", + "configuredCount": "已配置数量", + "consoleApiKeyOracleHint": "用于从 Console 获取或验证 API key 的辅助配置。", + "consoleApiKeyOracleLabel": "Console API key Oracle", + "consoleApiKeyOraclePlaceholder": "Console API key Oracle 占位符", "cpaModeDisabledTitle": "CLIProxyAPI 兼容模式已关闭", "cpaModeEnabledTitle": "CLIProxyAPI 兼容模式已开启", - "customUserAgentHint": "Custom User Agent Hint", - "customUserAgentLabel": "Custom User Agent Label", - "databricksBaseUrlHint": "Databricks Base Url Hint", + "customUserAgentHint": "发送给上游 Provider 的自定义 User-Agent。", + "customUserAgentLabel": "自定义 User-Agent", + "databricksBaseUrlHint": "Databricks Serving Endpoint 的 Base URL。", "defaultThinkingStrengthHint": "请求未指定 reasoning effort 时使用。", "defaultThinkingStrengthLabel": "默认思考强度", - "deleteAllExtraApiKeys": "Delete all", - "excludedModelsHint": "Excluded Models Hint", - "excludedModelsLabel": "Excluded Models Label", - "excludedModelsPlaceholder": "Excluded Models Placeholder", - "expirationBannerExpired": "Expiration Banner Expired", - "expirationBannerExpiredDesc": "Expiration Banner Expired Desc", - "expirationBannerExpiringSoon": "Expiration Banner Expiring Soon", - "expirationBannerExpiringSoonDesc": "Expiration Banner Expiring Soon Desc", + "deleteAllExtraApiKeys": "全部删除", + "excludedModelsHint": "这些 Model 不会出现在路由和选择器中。", + "excludedModelsLabel": "排除的 Model", + "excludedModelsPlaceholder": "以逗号分隔的 Model ID", + "expirationBannerExpired": "凭据已过期", + "expirationBannerExpiredDesc": "此 Provider 的凭据已过期,请更新后继续使用。", + "expirationBannerExpiringSoon": "凭据即将过期", + "expirationBannerExpiringSoonDesc": "此 Provider 的凭据即将过期,请提前更新。", "extraApiKeyMasked": "Key {index}: {prefix}••••{suffix}", - "extraApiKeysHint": "Extra Api Keys Hint", - "extraApiKeysLabel": "Extra Api Keys Label", - "googlePseInfo": "Google Pse Info", - "grokWebCookieHint": "Grok Web Cookie Hint", - "grokWebCookiePlaceholder": "Grok Web Cookie Placeholder", - "herokuBaseUrlHint": "Heroku Base Url Hint", - "hideEmail": "Hide Email", - "imageProviders": "Image Providers", - "imagesShortLabel": "Images Short Label", - "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", - "localProviderBaseUrlHint": "Local Provider Base Url Hint", - "localProviders": "Local Providers", - "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", - "museSparkWebCookieHint": "Muse Spark Web Cookie Hint", - "museSparkWebCookiePlaceholder": "Muse Spark Web Cookie Placeholder", - "oauth": "Oauth", - "openCliTools": "Open Cli Tools", - "openSettings": "Open Settings", + "extraApiKeysHint": "为同一提供商添加额外 API 密钥,以便轮换和回退使用。", + "extraApiKeysLabel": "额外 API 密钥", + "googlePseInfo": "配置 Google Programmable Search Engine 以启用 Web Search。", + "grokWebCookieHint": "从 Grok Web 会话复制 Cookie。", + "grokWebCookiePlaceholder": "Grok Web Cookie", + "herokuBaseUrlHint": "Heroku 部署的 Base URL。", + "hideEmail": "隐藏邮箱", + "imageProviders": "图像 Provider", + "imagesShortLabel": "图像", + "llmProviders": "LLM Provider", + "localProviderApiKeyOptionalHint": "本地提供商的 API 密钥通常是可选的。", + "localProviderBaseUrlHint": "输入本地提供商的 Base URL。", + "localProviders": "本地 Provider", + "maxConcurrentWholeNumberError": "最大并发必须是整数", + "museSparkWebCookieHint": "从 Muse Spark Web 会话复制 Cookie。", + "museSparkWebCookiePlaceholder": "Muse Spark Web Cookie", + "oauth": "OAuth", + "openCliTools": "打开 CLI Tools", + "openSettings": "打开设置", "openaiResponsesStoreDescription": "允许兼容的 Responses API 请求保留已存储的响应状态。", "openaiResponsesStoreLabel": "OpenAI Responses 存储", - "perplexitySearchSharedKeyInfo": "Perplexity Search Shared Key Info", - "perplexityWebCookieHint": "Perplexity Web Cookie Hint", - "perplexityWebCookiePlaceholder": "Perplexity Web Cookie Placeholder", - "personalAccessTokenLabel": "Personal Access Token Label", - "qoderPatHint": "Qoder Pat Hint", - "qoderPatPlaceholder": "Qoder Pat Placeholder", - "refreshOauthTokenTitle": "Refresh Oauth Token Title", - "regionHint": "Region Hint", - "regionLabel": "Region Label", - "removeThisKey": "Remove This Key", - "routingTagsHint": "Routing Tags Hint", - "routingTagsLabel": "Routing Tags Label", - "routingTagsPlaceholder": "Routing Tags Placeholder", - "search": "Search", - "searchEngineIdHint": "Search Engine Id Hint", - "searchEngineIdLabel": "Search Engine Id Label", - "searchEngineIdRequired": "Search Engine Id Required", - "searchProvider": "Search Provider", - "searchProviderDesc": "Search Provider Desc", - "searchProviders": "Search Providers", - "searchProvidersHeading": "Search Providers Heading", - "searxngBaseUrlHint": "Searxng Base Url Hint", - "searxngInfo": "Searxng Info", - "sessionCookieLabel": "Session Cookie Label", - "showEmail": "Show Email", - "snowflakeBaseUrlHint": "Snowflake Base Url Hint", + "perplexitySearchSharedKeyInfo": "Perplexity Search 可使用共享 key 配置。", + "perplexityWebCookieHint": "从 Perplexity Web 会话复制 Cookie。", + "perplexityWebCookiePlaceholder": "Perplexity Web Cookie", + "personalAccessTokenLabel": "个人访问令牌", + "qoderPatHint": "输入 Qoder Personal Access Token。", + "qoderPatPlaceholder": "Qoder PAT", + "refreshOauthTokenTitle": "刷新 OAuth Token", + "regionHint": "选择此 Provider 使用的区域。", + "regionLabel": "区域", + "removeThisKey": "移除此 key", + "routingTagsHint": "添加标签以便在路由规则中匹配此连接。", + "routingTagsLabel": "路由标签", + "routingTagsPlaceholder": "例如 coding, fast, cheap", + "search": "搜索", + "searchEngineIdHint": "Google Programmable Search Engine 的 ID。", + "searchEngineIdLabel": "Search Engine ID", + "searchEngineIdRequired": "Search Engine ID 为必填项", + "searchProvider": "搜索提供商", + "searchProviderDesc": "按名称、能力或类别查找提供商。", + "searchProviders": "搜索 Provider", + "searchProvidersHeading": "搜索 Provider", + "searxngBaseUrlHint": "SearXNG 实例的 Base URL。", + "searxngInfo": "配置 SearXNG 以启用自托管 Web Search。", + "sessionCookieLabel": "Session Cookie", + "showEmail": "显示邮箱", + "snowflakeBaseUrlHint": "Snowflake Cortex 服务的 Base URL。", "supportedEndpointAudio": "音频", "supportedEndpointChat": "聊天", "supportedEndpointEmbeddings": "Embeddings", "supportedEndpointImages": "图像", "supportedEndpointsLabel": "支持的端点", - "tagGroupHint": "Tag Group Hint", - "tagGroupLabel": "Tag Group Label", - "tagGroupPlaceholder": "Tag Group Placeholder", - "testModel": "Test Model", - "testingModel": "Testing Model", + "tagGroupHint": "用于筛选和组织 Provider 的标签分组。", + "tagGroupLabel": "标签分组", + "tagGroupPlaceholder": "标签分组占位符", + "testModel": "测试模型", + "testingModel": "正在测试模型", "toggleOffShort": "关", "toggleOnShort": "开", - "tokenExpiredBadge": "Token Expired Badge", - "tokenExpiredTitle": "Token Expired Title", - "tokenExpiresSoonTitle": "Token Expires Soon Title", - "tokenShort": "Token Short", - "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", - "unhideModel": "Unhide Model", - "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", - "vertexServiceAccountPlaceholder": "Vertex Service Account Placeholder", - "webCookieProviders": "Web Cookie Providers", - "weeklyShort": "Weekly Short", - "xiaomiMimoBaseUrlHint": "Xiaomi Mimo Base Url Hint", - "zedImportButton": "Zed Import Button", - "zedImportFailed": "Zed Import Failed", - "zedImportHint": "Zed Import Hint", - "zedImportNetworkError": "Zed Import Network Error", - "zedImportNone": "Zed Import None", - "zedImportSuccess": "Zed Import Success", - "zedImporting": "Zed Importing", - "expiredBadge": "Expired", - "expiringSoonBadge": "Expiring Soon" + "tokenExpiredBadge": "Token 已过期", + "tokenExpiredTitle": "Token 已过期", + "tokenExpiresSoonTitle": "Token 即将过期", + "tokenShort": "Token", + "totalKeysRotating": "{count, plural, one {1 个 key 正在轮换} other {# 个 key 正在轮换}}", + "unhideModel": "取消隐藏模型", + "upstreamProxyProviders": "上游代理 Provider", + "validationModelIdHint": "用于验证此提供商连接的模型 ID。", + "validationModelIdLabel": "验证模型 ID", + "validationModelIdPlaceholder": "输入用于测试的模型 ID", + "vertexServiceAccountPlaceholder": "Vertex Service Account", + "webCookieProviders": "Web Cookie Provider", + "weeklyShort": "每周", + "xiaomiMimoBaseUrlHint": "小米 Mimo 服务的 Base URL。", + "zedImportButton": "从 Zed 导入", + "zedImportFailed": "Zed 导入失败", + "zedImportHint": "从 Zed 配置中导入 Provider。", + "zedImportNetworkError": "Zed 导入网络错误", + "zedImportNone": "没有可从 Zed 导入的内容", + "zedImportSuccess": "Zed 导入成功", + "zedImporting": "正在从 Zed 导入", + "expiredBadge": "已过期", + "expiringSoonBadge": "即将过期", + "freeTier": "免费额度", + "freeTierAvailable": "有免费额度", + "deprecated": "已弃用", + "deprecatedProvider": "此提供商已弃用", + "enterpriseCloud": "企业与云", + "videoProviders": "视频生成", + "embeddingRerankProviders": "嵌入与重排序" }, "settings": { "title": "设置", @@ -2824,22 +2888,22 @@ "systemPrompt": "系统提示", "thinkingBudget": "思考预算", "proxy": "代理", - "httpProxy": "HTTP Proxy", + "httpProxy": "HTTP 代理", "1proxy": "1proxy", - "proxySubTabsAria": "Proxy sections", - "requestBodyLimitTitle": "Request Body Limit", - "requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.", - "requestBodyLimitInputLabel": "Request body limit in MB", - "requestBodyLimitEmptyError": "Enter a limit in MB", - "requestBodyLimitWholeNumberError": "Use a whole number", - "requestBodyLimitMinimumError": "Minimum is {min} MB", - "requestBodyLimitMaximumError": "Maximum is {max} MB", - "requestBodyLimitLoadFailed": "Failed to load request limit settings", - "requestBodyLimitSaveSuccess": "Request body limit saved", - "requestBodyLimitSaveFailed": "Failed to save request body limit", - "requestBodyLimitSaving": "Saving...", - "requestBodyLimitSave": "Save", - "requestBodyLimitCurrent": "Current: {value}", + "proxySubTabsAria": "代理设置分区", + "requestBodyLimitTitle": "请求体大小限制", + "requestBodyLimitDescription": "解析请求体前允许的最大 API 载荷大小。专用上传路由仍至少保留其内置的 100 MB 限制。", + "requestBodyLimitInputLabel": "请求体限制(MB)", + "requestBodyLimitEmptyError": "请输入 MB 限制值", + "requestBodyLimitWholeNumberError": "请使用整数", + "requestBodyLimitMinimumError": "最小值为 {min} MB", + "requestBodyLimitMaximumError": "最大值为 {max} MB", + "requestBodyLimitLoadFailed": "加载请求限制设置失败", + "requestBodyLimitSaveSuccess": "请求体限制已保存", + "requestBodyLimitSaveFailed": "保存请求体限制失败", + "requestBodyLimitSaving": "正在保存...", + "requestBodyLimitSave": "保存", + "requestBodyLimitCurrent": "当前:{value}", "pricing": "定价", "storage": "存储", "policies": "策略", @@ -3030,7 +3094,7 @@ "effortMedium": "中(10K Tokens)", "effortHigh": "高(128K Tokens)", "tokenBudget": "Token 预算", - "tokens": "Tokens", + "tokens": "Token", "baseEffortLevel": "基本努力水平", "adaptiveHint": "自适应模式根据消息计数、工具使用情况和提示长度从此基本级别进行扩展。", "requireLogin": "需要登录", @@ -3084,7 +3148,7 @@ "stickyLimitDesc": "切换前每个账户连续处理的请求次数", "modelAliases": "模型别名", "modelAliasesTitle": "模型别名", - "modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.", + "modelAliasesDesc": "使用精确匹配或通配符模式重映射模型名称。", "addCustomAlias": "添加自定义别名", "deprecatedModelId": "已弃用的模型 ID", "newModelId": "新模型 ID", @@ -3338,200 +3402,197 @@ "maintenance": "维护", "purgeExpiredLogs": "清理过期日志", "purgeLogsFailed": "清理日志失败", - "contextOpt": "Context Optimized", - "contextOptDesc": "Routes based on context window requirements and conversation length", - "priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on", - "weightedDesc": "Distributes traffic by percentage weights across providers", - "modelRoutingTitle": "Model Routing Rules", - "modelRoutingDesc": "Automatically route models to specific combos using glob patterns", - "addRule": "Add Rule", - "routeToCombo": "Route to Combo", - "selectCombo": "Select combo...", - "priorityHint": "Higher = checked first. Use 10+ for specific patterns.", - "patternHint": "Use * for any chars, ? for single char. Case-insensitive.", - "noRoutingRules": "No routing rules configured. Requests use the global combo by default.", - "routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.", - "deleteRoutingRule": "Delete this model routing rule?", - "exactMatchMode": "Exact Match", - "wildcardPatternMode": "Wildcard Pattern", - "exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.", - "wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.", - "noExactAliasesConfigured": "No exact-match aliases configured.", - "wildcardRulesTitle": "Wildcard Rules", - "noWildcardAliasesConfigured": "No wildcard aliases configured.", - "overview": "Overview", - "unknownError": "Unknown Error", - "pricingSourceLiteLLM": "Pricing Source Lite Llm", - "clearSyncedPricingConfirm": "Clear Synced Pricing Confirm", - "clearSyncedPricingFailed": "Clear Synced Pricing Failed", - "pricingSourceUser": "Pricing Source User", - "pageDescription": "Page Description", - "pricingSyncStatus": "Pricing Sync Status", - "whitelist": "Whitelist", - "syncDisabled": "Sync Disabled", - "pricingLoadFailed": "Pricing Load Failed", - "pricingSyncDescription": "Pricing Sync Description", - "clearSyncedPricingSuccess": "Clear Synced Pricing Success", - "clearSyncedPricingFailedWithReason": "Clear Synced Pricing Failed With Reason", - "pricingSourceDefault": "Pricing Source Default", - "pricingSyncSuccess": "Pricing Sync Success", - "enableSyncError": "Enable Sync Error", - "syncEnabled": "Sync Enabled", - "blacklist": "Blacklist", - "pricingSourceModelsDev": "Pricing Source Models Dev", - "syncedModels": "Synced Models", - "budget": "Budget", - "pricingSyncTitle": "Pricing Sync Title", - "pricingResetFailedWithReason": "Pricing Reset Failed With Reason", - "tab": "Tab", - "pricingSavedProvider": "Pricing Saved Provider", - "pricingSyncFailed": "Pricing Sync Failed", - "pricingSaveFailedWithReason": "Pricing Save Failed With Reason", - "pricingResetProvider": "Pricing Reset Provider", - "nextSync": "Next Sync", - "pricingSyncFailedWithReason": "Pricing Sync Failed With Reason", - "clearSyncedPricing": "Clear Synced Pricing", - "compressionTitle": "Prompt Compression", - "compressionDesc": "Reduce token usage by compressing prompts before sending to providers", - "compressionMode": "Compression Mode", - "compressionModeOff": "Off", - "compressionModeOffDesc": "No compression applied", + "contextOpt": "上下文优化", + "contextOptDesc": "根据上下文窗口需求和对话长度进行路由", + "priorityDesc": "顺序回退——先尝试提供商 1,再尝试提供商 2,依此类推", + "weightedDesc": "按百分比权重在各提供商之间分配流量", + "modelRoutingTitle": "模型路由规则", + "modelRoutingDesc": "使用 glob 模式自动将模型路由到指定组合", + "addRule": "添加规则", + "routeToCombo": "路由到组合", + "selectCombo": "选择组合...", + "priorityHint": "数值越高越优先检查。具体模式建议使用 10+。", + "patternHint": "使用 * 匹配任意字符,? 匹配单个字符。不区分大小写。", + "noRoutingRules": "未配置路由规则。请求默认使用全局组合。", + "routingRuleHint": "添加类似 claude-opus* -> frontier-combo 的规则,以自动路由请求。", + "deleteRoutingRule": "删除此模型路由规则?", + "exactMatchMode": "精确匹配", + "wildcardPatternMode": "通配符模式", + "exactMatchModeDesc": "对已弃用或已重命名的模型 ID 使用精确别名。", + "wildcardPatternModeDesc": "当一组模型应映射到同一目标时,使用带 * 和 ? 的通配符别名。", + "noExactAliasesConfigured": "未配置精确匹配别名。", + "wildcardRulesTitle": "通配符规则", + "noWildcardAliasesConfigured": "未配置通配符别名。", + "overview": "概览", + "unknownError": "未知错误", + "pricingSourceLiteLLM": "LiteLLM 定价来源", + "clearSyncedPricingConfirm": "确定要清除已同步的定价吗?", + "clearSyncedPricingFailed": "清除已同步定价失败", + "pricingSourceUser": "用户定价来源", + "pageDescription": "页面描述", + "pricingSyncStatus": "定价同步状态", + "whitelist": "白名单", + "syncDisabled": "同步已禁用", + "pricingLoadFailed": "加载定价失败", + "pricingSyncDescription": "从 models.dev 同步模型定价和能力数据。", + "clearSyncedPricingSuccess": "已清除同步定价", + "clearSyncedPricingFailedWithReason": "清除价格失败:{reason}", + "pricingSourceDefault": "默认定价来源", + "pricingSyncSuccess": "定价同步成功", + "enableSyncError": "启用同步失败", + "syncEnabled": "同步已启用", + "blacklist": "黑名单", + "pricingSourceModelsDev": "models.dev 定价来源", + "syncedModels": "已同步模型", + "budget": "预算", + "pricingSyncTitle": "定价同步", + "pricingResetFailedWithReason": "重置价格失败:{reason}", + "tab": "选项卡", + "pricingSavedProvider": "已保存 {provider} 的价格", + "pricingSyncFailed": "定价同步失败", + "pricingSaveFailedWithReason": "保存价格失败:{reason}", + "pricingResetProvider": "已重置 {provider} 的价格", + "nextSync": "下次同步", + "pricingSyncFailedWithReason": "价格同步失败:{reason}", + "clearSyncedPricing": "清除同步定价", + "compressionTitle": "提示词压缩", + "compressionDesc": "在发送给提供商之前压缩提示词,以减少 token 使用量", + "compressionMode": "压缩模式", + "compressionModeOff": "关闭", + "compressionModeOffDesc": "不应用压缩", "compressionModeLite": "Lite", - "compressionModeLiteDesc": "Whitespace and blank line reduction", - "compressionModeStandard": "Standard (Caveman)", - "compressionModeStandardDesc": "Rule-based compression with 30+ patterns, preserves code blocks and URLs", + "compressionModeLiteDesc": "减少空白字符和空行", + "compressionModeStandard": "标准(Caveman)", + "compressionModeStandardDesc": "基于规则的压缩,包含 30+ 个模式,并保留代码块和 URL", "compressionModeAggressive": "Aggressive", - "compressionModeAggressiveDesc": "Summarization + tool result compression + progressive aging for maximum savings", + "compressionModeAggressiveDesc": "摘要 + 工具结果压缩 + 渐进老化,以实现最大节省", "compressionModeUltra": "Ultra", - "compressionModeUltraDesc": "Maximum compression with all techniques including semantic deduplication", - "compressionGeneral": "General Settings", - "compressionAutoTrigger": "Auto-Trigger Threshold", - "compressionCacheTTL": "Cache TTL", - "compressionPreserveSystem": "Preserve System Prompt", - "compressionCavemanConfig": "Caveman Engine Configuration", - "compressionCavemanConfigDesc": "Fine-tune the rule-based compression engine", - "compressionRoles": "Compress Message Roles", - "compressionRoleUser": "User", - "compressionRoleAssistant": "Assistant", - "compressionRoleSystem": "System", - "compressionMinLength": "Minimum Message Length", - "compressionSkipRules": "Skip Compression Rules", - "compressionSkipRulesDesc": "Click rules to skip them during compression", - "compressionPreservePatterns": "Preserve Patterns", - "compressionPreservePatternsDesc": "Regex patterns that are never compressed (one per line)", - "compressionLogTitle": "Compression Log", - "compressionLogEmpty": "No compressed requests yet. Compression stats will appear here when requests are processed with compression enabled.", + "compressionModeUltraDesc": "使用包括语义去重在内的全部技术进行最大压缩", + "compressionGeneral": "通用设置", + "compressionAutoTrigger": "自动触发阈值", + "compressionCacheTTL": "缓存 TTL", + "compressionPreserveSystem": "保留系统提示", + "compressionCavemanConfig": "Caveman 引擎配置", + "compressionCavemanConfigDesc": "微调基于规则的压缩引擎", + "compressionRoles": "压缩消息角色", + "compressionRoleUser": "用户", + "compressionRoleAssistant": "助手", + "compressionRoleSystem": "系统", + "compressionMinLength": "最小消息长度", + "compressionSkipRules": "跳过压缩规则", + "compressionSkipRulesDesc": "点击规则以在压缩时跳过它们", + "compressionPreservePatterns": "保留模式", + "compressionPreservePatternsDesc": "永不压缩的正则表达式模式(每行一个)", + "compressionLogTitle": "压缩日志", + "compressionLogEmpty": "尚无压缩请求。启用压缩后处理请求时,压缩统计会显示在这里。", "compressionModeRtk": "RTK", - "compressionModeRtkDesc": "Command-aware tool output filtering", - "compressionModeStacked": "Stacked", - "compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression", - "qdrantTitle": "Qdrant (Vector memory)", - "qdrantDesc": "Optional. Indexes semantic memories in an external vector database for faster retrieval.", - "qdrantStatusActive": "Active", - "qdrantStatusError": "Error", - "qdrantStatusDisabled": "Disabled", - "qdrantEnable": "Enable Qdrant", - "qdrantEnableDesc": "When enabled, semantic/hybrid strategy can use Qdrant to retrieve memories.", - "qdrantTesting": "Testing...", - "qdrantTestConnection": "Test connection", - "qdrantSaved": "Configuration saved", - "qdrantSaveError": "Failed to save configuration", - "qdrantHostHint": "Without port. Example: 127.0.0.1 or http://qdrant", - "qdrantPort": "Port", - "qdrantPortHint": "Qdrant default: 6333", - "qdrantCollectionHint": "Where memory points will be stored.", - "qdrantEmbeddingModel": "Embedding model", - "qdrantHelpTitle": "Quick setup help", - "qdrantHelpQuickTitle": "Quick setup (Qdrant + OpenRouter)", - "qdrantHelpStep1": "1. Host: Qdrant IP/URL, Port: 6333, Collection: omniroute_memory.", - "qdrantHelpStep2": "2. If using nvidia/llama-nemotron-embed-vl-1b-v2:free, use collection dimension 2048.", - "qdrantHelpStep3": "3. Model field: openrouter/nvidia/llama-nemotron-embed-vl-1b-v2:free.", - "qdrantHelpStep4": "4. Save, test connection, then test search.", - "qdrantEmbeddingQuickSelect": "Quick select from discovered models...", - "qdrantEmbeddingInputPlaceholder": "openai/text-embedding-3-small", - "qdrantEmbeddingHint": "Format: provider/model. The provider credential must be configured.", - "qdrantApiKeyPlaceholderKeep": "(leave empty to keep current key)", - "qdrantApiKeyPlaceholderOptional": "(leave empty if not used)", - "qdrantSaveHint": "Tip: edit host/port/collection/model and click Save. API key is optional.", - "qdrantSearchTestTitle": "Search test", - "qdrantSearchTestDesc": "Generates embedding and searches in Qdrant.", - "qdrantSearchPlaceholder": "Example: user preferences, history, etc", - "qdrantNoResults": "No results (or Qdrant is not configured).", - "qdrantCleanupTitle": "Retention and cleanup", - "qdrantCleanupDesc": "Removes expired and old points based on", - "searching": "Searching...", - "cleaning": "Cleaning...", - "cleanNow": "Clean now" + "compressionModeRtkDesc": "感知命令的工具输出过滤", + "compressionModeStacked": "堆叠", + "compressionModeStackedDesc": "先进行 RTK 工具输出过滤,再进行 Caveman 消息压缩", + "mitmProxy": "MITM 代理", + "authModelHeading": "当前授权模型", + "authModelClient": "客户端 API 端点(/v1/*、/chat/*、/responses/*、/codex/*、/messages/*)需要 Bearer API 密钥。", + "authModelManagement": "管理端点(/dashboard、/api/*)需要仪表盘会话或管理凭据。", + "authModelPublic": "只有登录、健康检查和引导路由是公开的。", + "bruteForceProtection": "登录暴力破解保护", + "bruteForceProtectionDesc": "在同一 IP 多次失败后,对 /api/auth/login 进行限流和锁定。", + "corsAllowedOrigins": "CORS 允许的源", + "corsAllowedOriginsDesc": "允许调用此服务器的浏览器来源列表,以逗号分隔。空列表 = 不允许浏览器 CORS 访问(服务器到服务器仍可用)。仅在开发环境使用 CORS_ALLOW_ALL=true 环境变量。", + "compressionAggressiveConfig": "Aggressive 引擎配置", + "compressionAggressiveConfigDesc": "微调摘要、工具压缩和老化阈值", + "compressionUltraConfig": "Ultra 引擎配置", + "compressionUltraConfigDesc": "微调启发式剪枝、SLM 后备和每条消息阈值", + "compressionUltraRate": "保留比例", + "compressionUltraMinScore": "最低分数阈值", + "compressionUltraSlmFallback": "回退到 Aggressive", + "compressionUltraModelPath": "SLM 模型路径", + "compressionSummarizerEnabled": "启用摘要器", + "compressionMaxTokensPerMessage": "每条消息最大 Token 数", + "compressionMinSavings": "最低节省阈值", + "compressionAgingThresholds": "老化阈值", + "compressionAgingThresholdsDesc": "每个老化层级保留的最近消息数量(越高表示保留越多)", + "compressionToolStrategies": "工具结果策略", + "compressionToolStrategiesDesc": "为不同工具结果类型切换压缩策略", + "minutes": "分钟" }, "contextRtk": { - "title": "RTK Engine", - "description": "Command-aware compression for tool output, terminal logs and build results.", - "enabled": "Enabled", - "intensity": "Intensity", - "intensityMinimal": "Minimal", - "intensityStandard": "Standard", - "intensityAggressive": "Aggressive", - "toolResults": "Tool results", - "assistantMessages": "Assistant messages", - "codeBlocks": "Code blocks", - "maxLines": "Max lines", - "maxChars": "Max chars", - "filterTesting": "Filter Testing", - "pasteOutput": "Paste tool output to test filtering...", - "run": "Run", - "result": "Result", - "previewEmpty": "Run a sample to preview RTK output.", - "detected": "Detected", - "tokensFiltered": "Tokens filtered", - "filtersActive": "Filters active", - "requests": "Requests", - "avgSavings": "Avg savings" + "title": "RTK 引擎", + "description": "面向工具输出、终端日志和构建结果的命令感知压缩。", + "enabled": "已启用", + "intensity": "强度", + "intensityMinimal": "最小", + "intensityStandard": "标准", + "intensityAggressive": "激进", + "toolResults": "工具结果", + "assistantMessages": "助手消息", + "codeBlocks": "代码块", + "maxLines": "最大行数", + "maxChars": "最大字符数", + "filterTesting": "过滤测试", + "pasteOutput": "粘贴工具输出以测试过滤...", + "run": "运行", + "result": "结果", + "previewEmpty": "运行示例以预览 RTK 输出。", + "detected": "已检测", + "tokensFiltered": "已过滤 token", + "filtersActive": "活动过滤器", + "requests": "请求数", + "avgSavings": "平均节省", + "deduplicateThreshold": "去重阈值", + "customFilters": "自定义过滤器", + "trustProjectFilters": "信任项目过滤器", + "rawOutputRetention": "原始输出保留", + "rawOutputNever": "从不", + "rawOutputFailures": "失败时", + "rawOutputAlways": "始终", + "rawOutputMaxBytes": "原始输出最大字节数" }, "contextCombos": { - "title": "Compression Combos", - "description": "Define how engines are combined for different routing scenarios.", - "createCombo": "Create Compression Combo", - "editCombo": "Edit", - "deleteCombo": "Delete", - "deleteConfirm": "Delete this compression combo?", - "pipeline": "Pipeline", - "addStep": "Add Step", - "removeStep": "Remove", - "name": "Name", - "descriptionField": "Description", - "languagePacks": "Language Packs", - "outputMode": "Output Mode", - "assignToRouting": "Assign to Routing Combos", - "noAssignments": "No routing combos available", - "default": "Default", - "setAsDefault": "Set as Default", - "save": "Save", - "cancel": "Cancel", - "enabled": "Enabled" + "title": "压缩组合", + "description": "定义不同路由场景中引擎的组合方式。", + "createCombo": "创建压缩组合", + "editCombo": "编辑", + "deleteCombo": "删除", + "deleteConfirm": "删除此压缩组合?", + "pipeline": "流水线", + "addStep": "添加步骤", + "removeStep": "移除", + "name": "名称", + "descriptionField": "描述", + "languagePacks": "语言包", + "outputMode": "输出模式", + "assignToRouting": "分配到路由组合", + "noAssignments": "没有可用的路由组合", + "default": "默认", + "setAsDefault": "设为默认", + "save": "保存", + "cancel": "取消", + "enabled": "已启用" }, "contextCaveman": { - "title": "Caveman Engine", - "description": "Rule-based message compression, language packs, analytics and output mode controls.", - "requests": "Requests", - "tokensSaved": "Tokens saved", - "savingsPercent": "Savings %", - "avgLatency": "Avg latency", - "languagePacks": "Language Packs", - "languagePacksDesc": "Enable compression rules for specific languages.", - "enabled": "Enabled", - "autoDetect": "Auto-detect language", - "rulesCount": "{count} rules", - "analyticsTitle": "Compression Analytics", - "noAnalytics": "No compression analytics yet.", - "outputModeTitle": "Output Mode", - "outputModeDesc": "Instructs the LLM to respond in a terse, compact format.", - "autoClarity": "Auto-Clarity Bypass", - "bypassConditions": "Bypass conditions", + "title": "Caveman 引擎", + "description": "基于规则的消息压缩,包含语言包、分析和输出模式控制。", + "requests": "请求数", + "tokensSaved": "已节省 token", + "savingsPercent": "节省比例", + "avgLatency": "平均延迟", + "languagePacks": "语言包", + "languagePacksDesc": "为特定语言启用压缩规则。", + "enabled": "已启用", + "autoDetect": "自动检测语言", + "rulesCount": "{count} 条规则", + "analyticsTitle": "压缩分析", + "noAnalytics": "尚无压缩分析。", + "outputModeTitle": "输出模式", + "outputModeDesc": "指示 LLM 以简洁、紧凑的格式回复。", + "autoClarity": "自动清晰度绕过", + "bypassConditions": "绕过条件", "preview": { - "lite": "Respond concise. Preserve technical terms, code, errors, URLs, and identifiers.", - "full": "Respond terse and compact. Preserve all technical substance.", - "ultra": "Respond ultra terse with common technical abbreviations. Preserve exact symbols." - } + "lite": "回复要简洁。保留技术术语、代码、错误、URL 和标识符。", + "full": "回复要简短紧凑。保留所有技术实质。", + "ultra": "用常见技术缩写进行极简回复。保留精确符号。" + }, + "bypassConditionsList": "安全、不可逆、需澄清、顺序敏感" }, "translator": { "title": "翻译器", @@ -3683,7 +3744,34 @@ "requestFailed": "请求失败", "noTextExtracted": "(未提取文字)", "liveMonitorDescriptionPrefix": "这里会显示 API 调用流经 OmniRoute 时产生的翻译事件。事件来自内存缓冲区(重启后会重置)。使用", - "liveMonitorDescriptionSuffix": ",或外部 API 调用来生成事件。" + "liveMonitorDescriptionSuffix": ",或外部 API 调用来生成事件。", + "translationPathHubSpoke": "{source} → OpenAI(中间格式)→ {target}", + "translationPathDirect": "{source} → {target}(直接转换器)", + "translationPathPassthrough": "格式相同 — 无需转换", + "openaiIntermediatePanel": "OpenAI 中间格式", + "autoFeaturesTitle": "OmniRoute 自动处理的内容", + "autoFeaturesCount": "8 项功能", + "featureReasoningCache": "推理缓存", + "featureReasoningCacheDesc": "当客户端在对话历史中省略 thinking-mode 模型(DeepSeek V4、Kimi K2、Qwen)的缓存 reasoning_content 时,会重新注入。", + "featureSchemaCoercion": "Schema 校正", + "featureSchemaCoercionDesc": "修复损坏的工具 schema:添加缺失的 additionalProperties、清理过长描述、规范化嵌套对象。", + "featureRoleNormalization": "角色规范化", + "featureRoleNormalizationDesc": "针对非 OpenAI 目标将 developer→system。针对不支持 system 角色的模型将 system→user。", + "featureToolCallIds": "工具调用 ID 规范化", + "featureToolCallIdsDesc": "在缺失时生成唯一的 tool_call ID。针对 Mistral 等提供商规范化为 9 字符格式。", + "featureMissingToolResponse": "工具响应注入", + "featureMissingToolResponseDesc": "当客户端发送 tool_calls 但没有对应响应时,注入空的 tool_result 消息。", + "featureThinkingBudget": "思考预算", + "featureThinkingBudgetDesc": "自动管理 thinking 配置。当最后一条消息不是用户消息时移除 thinking 参数。", + "featureDirectPaths": "直接转换路径", + "featureDirectPathsDesc": "某些格式组合(Claude→Gemini)有绕过 OpenAI 中枢的直接转换器,可产生更准确的输出。", + "featureImageMapping": "图像尺寸映射", + "featureImageMappingDesc": "在 API 格式之间转换图像尺寸约定(例如 OpenAI detail 等级 → Gemini 尺寸)。", + "liveMonitorMemoryNote": "事件存储在内存中,重启后会丢失。", + "liveMonitorMemoryCapNote": "最多保留 200 个事件。", + "eventSourcesLabel": "事件来源:", + "eventSourceTranslatorPage": "• Translator 页面(Chat Tester、Test Bench)", + "eventSourceMainPipeline": "• 主请求流水线(CLI/IDE/API 流量)" }, "usage": { "title": "用量", @@ -3695,7 +3783,7 @@ "loadingBudgetData": "正在加载预算数据...", "noApiKeysTitle": "没有 API 密钥", "noApiKeysDescription": "首先添加 API 密钥以设置预算限制。", - "apiKey": "API密钥", + "apiKey": "API key", "todaysSpend": "今天的花费", "thisMonth": "本月", "setLimits": "设定限制", @@ -3831,99 +3919,124 @@ "tierPlus": "加号", "tierFree": "免费", "tierUnknown": "未知", - "suiteBuilderSaveFailed": "Failed to save suite", + "suiteBuilderSaveFailed": "保存套件失败", "scorecardTitle": "Scorecard", - "evalApiKey": "API Key", - "scorecardPassRate": "Pass Rate", - "targetTypeModel": "Model", - "actualOutputLabel": "Actual Output", - "evalTargetHint": "Select a model or combo to evaluate.", - "suiteBuilderDeleted": "Suite deleted successfully", - "suiteBuilderCaseCardHint": "Define the input prompt and expected output criteria.", - "nextResetUtc": "Next reset (UTC)", - "scorecardHint": "Aggregated pass rates across all evaluation suites.", - "suiteLatestRunsHint": "Most recent evaluation runs for this suite.", - "saving": "Saving", - "suiteBuilderCaseModelLabel": "Model (optional)", - "evalControlsTitle": "Evaluation Controls", - "suiteBuilderEditTitle": "Edit Eval Suite", - "suiteBuilderCaseStrategyLabel": "Validation Strategy", - "notifySelectDifferentCompareTarget": "Please select a different target for comparison", - "recentRunsHint": "Showing the most recent evaluation runs. Click a run to see detailed results.", - "evalCompareHint": "Optionally compare results against a second target.", - "suiteBuilderCaseInvalid": "This case has validation errors. Please fix before saving.", - "historyEmpty": "No evaluation history yet", - "suiteBuilderUpdated": "Suite updated successfully", - "resetInterval": "Reset Interval", - "suiteBuilderCreateTitle": "Create Eval Suite", - "activePeriodSpend": "Active Period Spend", - "evalApiKeyHint": "Select which API key to use for evaluation requests.", - "evalCompareOptional": "Compare (optional)", - "suiteBuilderDeleteFailed": "Failed to delete suite", - "suiteBuilderCaseSystemPromptPlaceholder": "Optional system instructions...", - "scorecardCases": "Cases", - "runCompletedWithScore": "Evaluation completed — {score}% pass rate", - "suiteBuilderCustomBadge": "Custom", - "targetSuiteDefaults": "Suite defaults", - "suiteBuilderDeleteConfirm": "Are you sure you want to delete this suite? This action cannot be undone.", - "suiteBuilderNameLabel": "Suite Name", + "evalApiKey": "API key", + "scorecardPassRate": "通过率", + "targetTypeModel": "模型", + "actualOutputLabel": "实际输出", + "evalTargetHint": "选择要评估的模型或组合。", + "suiteBuilderDeleted": "Suite 删除成功", + "suiteBuilderCaseCardHint": "定义输入提示和预期输出标准。", + "nextResetUtc": "下次重置(UTC)", + "scorecardHint": "汇总所有 Evaluation Suite 的通过率。", + "suiteLatestRunsHint": "此套件最近的评估运行。", + "saving": "正在保存", + "suiteBuilderCaseModelLabel": "模型(可选)", + "evalControlsTitle": "Evaluation 控制项", + "suiteBuilderEditTitle": "编辑 Evaluation Suite", + "suiteBuilderCaseStrategyLabel": "验证策略", + "notifySelectDifferentCompareTarget": "请选择不同的比较目标", + "recentRunsHint": "正在显示最近的评估运行。点击某次运行可查看详细结果。", + "evalCompareHint": "可选择与第二个目标比较结果。", + "suiteBuilderCaseInvalid": "此 Case 存在验证错误,请先修复再保存。", + "historyEmpty": "暂无 Evaluation 历史", + "suiteBuilderUpdated": "Suite 更新成功", + "resetInterval": "重置间隔", + "suiteBuilderCreateTitle": "创建评估套件", + "activePeriodSpend": "当前周期支出", + "evalApiKeyHint": "选择用于评估请求的 API 密钥。", + "evalCompareOptional": "对比(可选)", + "suiteBuilderDeleteFailed": "删除套件失败", + "suiteBuilderCaseSystemPromptPlaceholder": "可选系统指令...", + "scorecardCases": "Case", + "runCompletedWithScore": "Evaluation 已完成 — 通过率 {score}%", + "suiteBuilderCustomBadge": "自定义", + "targetSuiteDefaults": "Suite 默认值", + "suiteBuilderDeleteConfirm": "确定要删除此套件吗?此操作无法撤销。", + "suiteBuilderNameLabel": "Suite 名称", "scorecardSuites": "Suites", - "evalCompareTarget": "Compare Target", - "suiteBuilderCaseModelPlaceholder": "e.g. gpt-4o-mini", - "evalControlsHint": "Configure your evaluation target and API key, then run suites to validate model quality.", - "recentRunsTitle": "Recent Runs", - "suiteBuilderCaseSystemPromptLabel": "System Prompt", - "suiteBuilderCaseExpectedPlaceholder": "e.g. def fibonacci", - "suiteBuilderNamePlaceholder": "e.g. Coding Quality Suite", - "suiteBuilderAddCase": "Add Case", - "cancel": "Cancel", - "evalTarget": "Target", - "suiteBuilderCaseNameLabel": "Case Name", - "weeklyLimitUsd": "Weekly Limit (USD)", - "suiteBuilderCaseUserPromptPlaceholder": "e.g. Write a fibonacci function in Python", - "suiteBuilderNameRequired": "Suite name is required", - "suiteBuilderCaseUserPromptLabel": "User Prompt", - "daily": "Daily", - "resultErrorLabel": "Error", - "suiteBuilderBuiltInBadge": "Built-in", - "suiteBuilderCaseCardTitle": "Test Case", - "weeklyLimitPlaceholder": "e.g. 50.00", - "suiteBuilderCaseTagsHint": "Comma-separated tags for organizing test cases.", - "notifyEvalRunFailedWithReason": "Evaluation failed: {reason}", - "weekly": "Weekly", - "runEvalRunning": "Running evaluation...", - "delete": "Delete", - "resetTimeUtc": "Reset Time (UTC)", - "evalApiKeyAuto": "Auto (use default)", - "suiteBuilderDescriptionPlaceholder": "Optional description of what this suite tests...", - "targetComparisonTitle": "Target Comparison", - "save": "Save", - "suiteBuilderCreated": "Suite created successfully", - "suiteLatestRuns": "Latest Runs", - "suiteBuilderCaseExpectedLabel": "Expected Value", - "historyLatency": "Latency", - "suiteBuilderCaseTagsPlaceholder": "e.g. coding, python", + "evalCompareTarget": "对比目标", + "suiteBuilderCaseModelPlaceholder": "例如 gpt-4o-mini", + "evalControlsHint": "配置评估目标和 API 密钥,然后运行套件以验证模型质量。", + "recentRunsTitle": "最近运行", + "suiteBuilderCaseSystemPromptLabel": "系统提示", + "suiteBuilderCaseExpectedPlaceholder": "例如 def fibonacci", + "suiteBuilderNamePlaceholder": "例如 Coding Quality Suite", + "suiteBuilderAddCase": "添加 Case", + "cancel": "取消", + "evalTarget": "目标", + "suiteBuilderCaseNameLabel": "Case 名称", + "weeklyLimitUsd": "每周限额(USD)", + "suiteBuilderCaseUserPromptPlaceholder": "例如用 Python 写一个 fibonacci 函数", + "suiteBuilderNameRequired": "Suite 名称为必填项", + "suiteBuilderCaseUserPromptLabel": "用户 Prompt", + "daily": "每日", + "resultErrorLabel": "错误", + "suiteBuilderBuiltInBadge": "内置", + "suiteBuilderCaseCardTitle": "测试用例", + "weeklyLimitPlaceholder": "例如 50.00", + "suiteBuilderCaseTagsHint": "用于组织测试用例的逗号分隔标签。", + "notifyEvalRunFailedWithReason": "评估失败:{reason}", + "weekly": "每周", + "runEvalRunning": "正在运行 Evaluation...", + "delete": "删除", + "resetTimeUtc": "重置时间(UTC)", + "evalApiKeyAuto": "自动(使用默认值)", + "suiteBuilderDescriptionPlaceholder": "可选:描述此套件测试的内容...", + "targetComparisonTitle": "目标对比", + "save": "保存", + "suiteBuilderCreated": "Suite 创建成功", + "suiteLatestRuns": "最新运行", + "suiteBuilderCaseExpectedLabel": "期望值", + "historyLatency": "延迟", + "suiteBuilderCaseTagsPlaceholder": "例如 coding, python", "targetTypeCombo": "Combo", - "scorecardPassed": "Passed", - "suiteBuilderCaseTagsLabel": "Tags", - "suiteBuilderNewSuite": "New Suite", - "notifyEvalLoadFailed": "Failed to load evaluation data", - "notConfigured": "Not Configured", - "suiteBuilderCaseNamePlaceholder": "e.g. Python fibonacci test", - "intervalLabel": "Interval", - "suiteBuilderCreateAction": "Create Suite", - "suiteBuilderCasesHint": "Each case sends a prompt and validates the response.", - "suiteBuilderUpdatedAt": "Updated", - "errorBadge": "Error", - "suiteBuilderCasesTitle": "Test Cases", - "edit": "Edit", - "monthly": "Monthly", - "suiteBuilderCasesRequired": "At least one test case is required", - "weeklyLimitSummary": "Weekly budget limit summary", - "suiteBuilderDescriptionLabel": "Description", - "targetComparisonHint": "Side-by-side comparison of evaluation results between targets.", - "compareCompletedWithScore": "Comparison completed — {score}% pass rate", - "staleQuotaTooltip": "Last refresh failed — showing cached data" + "scorecardPassed": "已通过", + "suiteBuilderCaseTagsLabel": "标签", + "suiteBuilderNewSuite": "新建 Suite", + "notifyEvalLoadFailed": "加载评估数据失败", + "notConfigured": "未配置", + "suiteBuilderCaseNamePlaceholder": "例如 Python 斐波那契测试", + "intervalLabel": "间隔", + "suiteBuilderCreateAction": "创建套件", + "suiteBuilderCasesHint": "每个用例都会发送一个提示并验证响应。", + "suiteBuilderUpdatedAt": "更新时间", + "errorBadge": "错误", + "suiteBuilderCasesTitle": "测试用例", + "edit": "编辑", + "monthly": "每月", + "suiteBuilderCasesRequired": "至少需要一个测试用例", + "weeklyLimitSummary": "每周预算限额摘要", + "suiteBuilderDescriptionLabel": "描述", + "targetComparisonHint": "在目标之间并排比较评估结果。", + "compareCompletedWithScore": "对比已完成 — 通过率 {score}%", + "runAllSuites": "全部运行", + "runAllRunning": "正在全部运行...", + "runAllProgress": "正在运行 {current}/{total}:{name}", + "runAllFailedSuites": "{count, plural, one {# 个套件失败} other {# 个套件失败}}", + "runAllCompleted": "已运行 {suites} 个套件 — {passed} 个通过,{failed} 个失败", + "runAllCompletedWithFailures": "已运行 {completed} 个套件;{failedSuites} 个运行失败", + "resultPassed": "已通过", + "resultFailed": "失败", + "expandResult": "展开结果详情", + "collapseResult": "折叠结果详情", + "expectedOutputLabel": "预期输出", + "clone": "克隆", + "exportSuite": "导出", + "importSuite": "导入", + "suiteExported": "套件已导出", + "suiteExportFailed": "导出套件失败", + "suiteImportReady": "套件导入已加载,等待检查", + "suiteImportFailed": "导入套件失败", + "suiteImportInvalid": "无效的 eval 套件 JSON", + "suiteBuilderCloneSuffix": "副本", + "suiteBuilderImportedSuite": "已导入套件", + "suiteBuilderCaseExpectedPlaceholderContains": "例如 def fibonacci", + "suiteBuilderCaseExpectedPlaceholderExact": "粘贴精确的预期响应", + "suiteBuilderCaseExpectedPlaceholderRegex": "例如 ^\\s*\\{.*\\}\\s*$", + "suiteBuilderCaseExpectedHintRegex": "使用不带包裹斜杠的 JavaScript 正则表达式。", + "suiteBuilderDuplicateCase": "复制" }, "modals": { "waitingAuth": "等待授权", @@ -4265,7 +4378,71 @@ "troubleshootingCircuitBreaker": "如果提供商显示断路器已打开,请等待冷却或查看运行状况页面以了解详细信息。", "troubleshootingOAuth": "对于 OAuth 提供商,如果 Token 过期,请重新认证,并检查提供商卡片上的状态指示器。", "deployTermuxTitle": "Termux (Android)", - "deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser." + "deployTermuxText": "通过 Termux 在 Android 上以无界面模式运行 OmniRoute,并从移动浏览器访问仪表盘。", + "deploymentGuides": "部署指南", + "clientWindsurfTitle": "Windsurf", + "clientWindsurfBullet1": "将 OmniRoute 用作 OpenAI 兼容的 base URL,并保留显式提供商前缀,以实现确定性路由。", + "clientWindsurfBullet2": "常规流量将模型指向 `/v1/chat/completions`,并为 Codex 风格流程保留 `/v1/responses`。", + "clientWindsurfBullet3": "使用仪表盘 -> CLI 工具获取现成的 Windsurf 配置指南。", + "clientClineTitle": "Cline", + "clientClineBullet1": "Cline 最适合使用显式的提供商/模型前缀,这样路由器无需猜测后端。", + "clientClineBullet2": "常规模型使用 `/v1/chat/completions`,并在不同账户间复用同一个 OmniRoute base URL。", + "clientClineBullet3": "调试 Cline 运行时问题前,请使用提供商仪表盘验证 OAuth/API 密钥。", + "clientKimiTitle": "Kimi Coding", + "clientKimiBullet1": "轮换底层账户或提供商组合时,将 OmniRoute 用作稳定的 base URL。", + "clientKimiBullet2": "在编码流程中优先使用带前缀的模型,让回退和审计轨迹保持明确。", + "clientKimiBullet3": "当你希望为使用工具的客户端启用原生 Responses 风格路由时,请使用 `/v1/responses`。", + "endpointCompletionsNote": "用于文本生成的旧版 completions 端点。", + "endpointModerationsNote": "内容审核和安全分类。", + "endpointRerankNote": "用于检索增强生成的文档重排序(Cohere、Jina)。", + "endpointSearchNote": "通过 5 个提供商进行网页搜索(Serper、Brave、Exa、Tavily、Perplexity)。", + "endpointSearchAnalyticsNote": "搜索请求的分析和指标。", + "endpointVideoNote": "视频生成(ComfyUI、SD WebUI 工作流)。", + "endpointMusicNote": "通过 ComfyUI 工作流生成音乐。", + "endpointMessagesNote": "Anthropic 原生 messages 端点。", + "endpointCountTokensNote": "统计给定消息载荷的 token 数。", + "endpointFilesNote": "用于多模态输入的文件上传。", + "endpointBatchesNote": "用于批量 API 请求的 Batch 处理。", + "endpointWsNote": "用于实时流式传输的 WebSocket 端点。", + "mgmtProvidersListNote": "列出所有已注册的提供商连接。", + "mgmtProvidersCreateNote": "创建新的提供商连接。", + "mgmtProvidersUpdateNote": "更新现有提供商连接。", + "mgmtProvidersDeleteNote": "删除提供商连接。", + "mgmtProvidersTestNote": "测试提供商的连接和认证。", + "mgmtProvidersModelsNote": "列出特定提供商的可用模型。", + "mgmtSettingsGetNote": "获取当前应用设置。", + "mgmtSettingsUpdateNote": "更新应用设置。", + "mgmtPayloadRulesGetNote": "获取载荷转换规则。", + "mgmtPayloadRulesUpdateNote": "更新载荷转换规则。", + "mcpToolsTitle": "MCP 工具", + "mcpToolsDescription": "OmniRoute 通过 Model Context Protocol 暴露 {count} 个工具,用于 Agent 编排。", + "mcpToolsCount": "{count} 个工具", + "mcpToolsToc": "MCP 工具", + "mcpToolsRoutingTitle": "路由与发现", + "mcpToolsRoutingDesc": "健康检查、组合管理、配额监控、成本报告和模型目录访问。", + "mcpToolsOperationsTitle": "运维与策略", + "mcpToolsOperationsDesc": "路由模拟、预算保护、策略切换、韧性配置和提供商指标。", + "mcpToolsCacheTitle": "缓存管理", + "mcpToolsCacheDesc": "查看缓存统计,并清空语义缓存或签名缓存。", + "mcpToolsMemoryTitle": "记忆", + "mcpToolsMemoryDesc": "搜索、添加和清除持久化对话记忆条目。", + "mcpToolsSkillsTitle": "技能", + "mcpToolsSkillsDesc": "列出、启用、执行和监控自定义技能执行。", + "featureAutoComboTitle": "Auto-Combo", + "featureAutoComboText": "根据你连接的提供商、使用模式和模型能力,自动创建优化组合。", + "featureSearchTitle": "网页搜索", + "featureSearchText": "集成 5 个提供商的网页搜索(Serper、Brave、Exa、Tavily、Perplexity),并包含分析和成本跟踪。", + "featureMemoryTitle": "记忆系统", + "featureMemoryText": "跨会话提供提取、注入、检索和摘要能力的持久化对话记忆。", + "featureSkillsTitle": "技能框架", + "featureSkillsText": "可扩展技能系统,支持内置和自定义技能、沙箱执行、请求拦截和上下文注入。", + "featureAcpTitle": "Agent 通信", + "featureAcpText": "Agent Communication Protocol (ACP) 注册表,用于管理 Agent 间工作流和工具编排。", + "protocolAcpTitle": "ACP(Agent 通信)", + "protocolAcpDesc": "通过 ACP 注册表注册和管理 Agent,用于 Agent 间通信和工具共享。", + "protocolAcpStep1": "前往仪表盘 → Agents 查看已注册的 ACP Agent。", + "protocolAcpStep2": "使用能力和端点配置注册新的 Agent。", + "protocolAcpStep3": "使用 CLI 工具配置 Agent 通信通道。" }, "legal": { "privacyPolicy": "隐私政策", @@ -4357,19 +4534,39 @@ "setupGuideCustomAgentDesc": "如果你的 CLI 不在内置列表中,请使用“添加自定义智能体”,并填写可执行文件名与版本命令。", "setupGuideCommandMissingTitle": "修复“command not found”", "setupGuideCommandMissingDesc": "请确认 CLI 命令已存在于 PATH 中,重新打开一个终端会话后再次点击“刷新”。", - "cliToolsRedirectTitle": "Cli Tools Redirect Title", - "cliToolsRedirectDesc": "Cli Tools Redirect Desc", - "spawnArgsPlaceholder": "Spawn Args Placeholder", - "binaryNamePlaceholder": "Binary Name Placeholder", - "versionCommandPlaceholder": "Version Command Placeholder", - "architectureTitle": "Architecture Title", - "flowLocalBinary": "Flow Local Binary", - "flowOmniRoute": "Flow Omni Route", - "agentNamePlaceholder": "Agent Name Placeholder", - "architectureDescription": "Architecture Description", - "flowExecute": "Flow Execute", - "flowSpawn": "Flow Spawn", - "cliToolsRedirectCta": "Cli Tools Redirect Cta" + "cliToolsRedirectTitle": "CLI 工具已移至专用页面", + "cliToolsRedirectDesc": "在 CLI Tools 页面管理 Agent CLI 集成与重定向。", + "spawnArgsPlaceholder": "启动参数占位符", + "binaryNamePlaceholder": "二进制名称占位符", + "versionCommandPlaceholder": "版本命令占位符", + "architectureTitle": "架构", + "flowLocalBinary": "本地二进制", + "flowOmniRoute": "OmniRoute", + "agentNamePlaceholder": "Agent 名称占位符", + "architectureDescription": "了解 OmniRoute 如何在客户端、路由器和 Agent 目标之间转发请求。", + "flowExecute": "执行", + "flowSpawn": "启动", + "cliToolsRedirectCta": "打开 CLI Tools", + "comparisonTitle": "CLI 工具与 Agent 目标有什么区别?", + "comparisonCliToolsLabel": "CLI 工具页面", + "comparisonCliToolsTitle": "你的 IDE 通过 OmniRoute 发送请求", + "comparisonCliToolsDesc": "配置 Claude Code、Codex、Cursor 和其他 IDE,将 OmniRoute 用作它们的 API base URL。OmniRoute 作为代理,将请求路由到你配置的提供商。", + "comparisonAgentsLabel": "当前页面(Agent 目标)", + "comparisonAgentsTitle": "OmniRoute 将请求发送到本地 CLI 工具", + "comparisonAgentsDesc": "OmniRoute 可以启动本地 CLI 二进制文件(claude、codex、goose)作为执行后端。CLI 工具使用自己的认证处理请求并返回结果。", + "comparisonSummary": "简而言之:CLI 工具 = 你配置工具指向 OmniRoute。Agent 目标 = OmniRoute 将工具用作端点。", + "agentUseCaseHint": "可通过 ACP 协议用作执行目标", + "flowDiagramClient": "客户端应用", + "flowDiagramClientDesc": "SDK、API 或上游服务", + "flowDiagramOmniRoute": "OmniRoute", + "flowDiagramOmniRouteDesc": "接收请求并选择目标", + "flowDiagramSpawn": "启动进程", + "flowDiagramSpawnDesc": "通过 stdio 启动 CLI 二进制文件", + "flowDiagramCli": "CLI Agent", + "flowDiagramCliDesc": "使用自身认证/模型处理", + "fingerprintSettingsHint": "CLI 指纹匹配(伪装成特定 CLI 工具的请求)可在以下位置配置:", + "settingsRoutingLink": "设置/路由", + "openSettings": "设置" }, "templateNames": { "simple-chat": "简单对话", @@ -4454,14 +4651,14 @@ "cacheHitRate": "缓存命中率", "cacheRate": "缓存率", "cacheRateDesc": "占总请求数", - "cachedTokens": "Cache Read Tokens", - "cacheCreationTokens": "Cache Write Tokens", + "cachedTokens": "缓存读取 Token", + "cacheCreationTokens": "缓存写入 Token", "cacheMetrics": "Prompt 缓存指标", "withCacheControl": "含缓存控制", - "cachedTokensRead": "Read from cache", - "cacheCreationWrite": "Written to cache", + "cachedTokensRead": "从缓存读取", + "cacheCreationWrite": "写入缓存", "cacheReuseRatio": "缓存复用率", - "cacheReuseRatioDesc": "Cache read tokens / 输入 Tokens 总量", + "cacheReuseRatioDesc": "缓存读取 token / 输入 token 总量", "estCostSaved": "预估节省费用", "lastUpdated": "上次更新", "hoursTracked": "个小时", @@ -4476,12 +4673,12 @@ "resetting": "正在重置...", "resetMetrics": "重置指标", "byProvider": "按提供商分类", - "providerCacheRateDesc": "每个提供商都会直接展示总输入 Tokens、cache read tokens 和 cache write tokens,方便你对照原始数据判断 ratio 是否可靠。", + "providerCacheRateDesc": "每个提供商都会直接展示总输入 token、cache read token 和 cache write token,方便你对照原始数据判断比率是否可靠。", "provider": "提供商", "requests": "请求数", "inputTokens": "输入 Tokens 总计", - "cachedTokensCol": "Cache Read", - "cacheCreation": "Cache Write", + "cachedTokensCol": "缓存读取", + "cacheCreation": "缓存写入", "trend24h": "缓存趋势(24 小时)", "peakCached": "峰值缓存量", "cached": "已缓存", @@ -4495,7 +4692,7 @@ "loading": "加载中...", "entriesLoadError": "加载语义缓存条目失败。", "noEntries": "未找到缓存条目", - "noPromptCacheData": "暂时还没有记录到 provider-side prompt cache 活动。", + "noPromptCacheData": "暂时还没有记录到提供商侧 prompt cache 活动。", "noTrendData": "最近 24 小时还没有记录到 prompt cache 活动。", "signature": "签名", "model": "模型", @@ -4505,8 +4702,33 @@ "deduplicatedRequests": "去重请求数", "savedCalls": "节省的 API 调用次数", "totalProcessed": "已处理请求总数", - "disabled": "Disabled", - "totalRequests": "Total Requests" + "disabled": "已禁用", + "totalRequests": "总请求数", + "reasoningCache": "推理回放", + "reasoningCacheDesc": "为多轮工具调用流程保留模型思考内容", + "reasoningEntries": "活动条目", + "reasoningReplayRate": "回放率", + "reasoningReplays": "总回放次数", + "reasoningCharsCached": "已缓存字符数", + "reasoningMisses": "缓存未命中", + "reasoningByProvider": "按提供商", + "reasoningByModel": "按模型", + "reasoningRecentEntries": "最近条目", + "reasoningToolCallId": "工具调用 ID", + "reasoningChars": "字符数", + "reasoningAge": "时间", + "reasoningView": "查看", + "reasoningDetail": "推理内容", + "reasoningBehavior": "行为", + "reasoningBehaviorCapture": "从流式响应中捕获 reasoning_content", + "reasoningBehaviorReplay": "当客户端省略时,在下一轮重新注入", + "reasoningBehaviorFallback": "内存优先,并使用 SQLite 作为崩溃恢复后备", + "reasoningBehaviorTtl": "TTL:2 小时 | 最大条目:2,000(内存)", + "reasoningBehaviorModels": "支持:DeepSeek、Kimi、Qwen-Thinking、GLM", + "reasoningClearAll": "清空推理缓存", + "reasoningClearSuccess": "已清除 {count} 个推理缓存条目", + "reasoningClearError": "清除推理缓存失败", + "reasoningNoData": "尚未缓存推理条目。思考模型使用工具调用时会显示条目。" }, "memory": { "title": "记忆管理", @@ -4543,7 +4765,8 @@ "pipelineError": "Pipeline 错误", "healthUnknown": "健康状态未知", "cancel": "取消", - "save": "保存" + "save": "保存", + "a": "A" }, "skills": { "title": "技能", @@ -4571,7 +4794,7 @@ "timeoutDesc": "等待响应的最长时间", "networkAccess": "网络访问", "networkAccessDesc": "允许发起出站网络请求", - "mode": "Mode", + "mode": "模式", "q": "Q" }, "proxyConfigModal": { @@ -4733,7 +4956,26 @@ "noData": "—", "testSuccess": "✓ {ip}", "testLatency": "{latency}毫秒", - "testFailure": "✗ {error}" + "testFailure": "✗ {error}", + "bulkImport": "批量导入", + "bulkImportTitle": "批量导入代理", + "bulkImportDescription": "使用管道符分隔格式粘贴代理配置。每行一个代理。已有代理(相同 host + port)将被更新。", + "bulkImportParse": "解析", + "bulkImportImport": "导入 {count} 个代理", + "bulkImportImporting": "正在导入...", + "bulkImportParsed": "已解析 {count} 个代理", + "bulkImportSkipped": "已跳过 {count} 行", + "bulkImportParseErrors": "{count} 个错误", + "bulkImportNoValidEntries": "未找到有效条目。请检查格式后重试。", + "bulkImportSuccess": "导入完成:已创建 {created} 个,已更新 {updated} 个,失败 {failed} 个", + "bulkImportErrorLine": "第 {line} 行:{reason}", + "bulkImportMaxExceeded": "每次最多导入 100 个代理", + "bulkImportPreview": "预览", + "bulkImportErrorMissingName": "缺少 NAME", + "bulkImportErrorMissingHost": "缺少 HOST", + "bulkImportErrorInvalidPort": "PORT 无效(必须为 1-65535)", + "bulkImportErrorInvalidType": "TYPE 无效(使用 http、https 或 socks5)", + "bulkImportErrorInvalidStatus": "STATUS 无效(使用 active 或 inactive)" }, "playground": { "title": "模型演练场", @@ -4822,37 +5064,38 @@ "tokens": "Tokens", "tps": "TPS", "duration": "时长", - "time": "时间" + "time": "时间", + "compressed": "压缩" }, "loadingLogs": "正在加载日志...", "noLogs": "暂无日志记录。进行一些API调用以在此处查看它们。", "noMatchingLogs": "没有匹配当前筛选器的日志。", "callLogsInfo": "调用日志也保存为JSON文件到{dataDir},并根据{retentionDays}和{maxEntries}进行轮换。", - "updatePipelineFailed": "Failed to update pipeline logging", - "capturePipeline": "Capture pipeline payloads for new requests", - "sortLogs": "Sort logs", - "refresh": "Refresh", - "columnsLabel": "Columns", + "updatePipelineFailed": "更新流水线日志失败", + "capturePipeline": "为新请求捕获流水线载荷", + "sortLogs": "日志排序", + "refresh": "刷新", + "columnsLabel": "列", "cacheSem": "SEM", "cacheUp": "UP", - "semantic": "Semantic", - "upstream": "Upstream", - "semanticCacheHit": "Semantic cache hit (served by OmniRoute)", - "upstreamResponse": "Upstream provider response", - "noApiKey": "No API key", - "requestedRoutedTitle": "Requested {requested}, routed as {routed}" + "semantic": "Semantic Cache", + "upstream": "上游", + "semanticCacheHit": "语义缓存命中(由 OmniRoute 提供)", + "upstreamResponse": "上游提供商响应", + "noApiKey": "无 API key", + "requestedRoutedTitle": "请求 {requested},路由为 {routed}" }, "endpointOptions": { - "speech": "Speech", - "search": "Search", - "images": "Images", - "chat": "Chat", - "music": "Music", + "speech": "语音", + "search": "搜索", + "images": "图像", + "chat": "聊天", + "music": "音乐", "responses": "Responses", "rerank": "Rerank", - "video": "Video", + "video": "视频", "embeddings": "Embeddings", - "transcription": "Transcription" + "transcription": "转写" }, "toolDescriptions": { "cline": "Cline", @@ -4863,47 +5106,203 @@ "kilo": "Kilo" }, "proxyLogger": { - "filterAll": "All", - "filterErrors": "Errors", - "filterSuccess": "Success", - "filterTimeout": "Timeout", - "colStatus": "Status", - "colProxy": "Proxy", + "filterAll": "全部", + "filterErrors": "错误", + "filterSuccess": "成功", + "filterTimeout": "超时", + "colStatus": "状态", + "colProxy": "代理", "colTls": "TLS", - "colType": "Type", - "colLevel": "Level", - "colProvider": "Provider", - "colTarget": "Target", - "colLatency": "Latency", - "colPublicIp": "Public IP", - "colTime": "Time", - "recording": "Recording", - "paused": "Paused", - "searchPlaceholder": "Search host, provider, target, IP...", - "allTypes": "All Types", - "allLevels": "All Levels", - "allProviders": "All Providers", - "total": "total", + "colType": "类型", + "colLevel": "级别", + "colProvider": "提供商", + "colTarget": "目标", + "colLatency": "延迟", + "colPublicIp": "公网 IP", + "colTime": "时间", + "recording": "记录中", + "paused": "已暂停", + "searchPlaceholder": "搜索主机、提供商、目标或 IP...", + "allTypes": "全部类型", + "allLevels": "全部级别", + "allProviders": "全部 Provider", + "total": "总计", "ok": "OK", "err": "ERR", "timeoutShort": "TMO", "direct": "direct", - "newest": "Newest", - "oldest": "Oldest", - "latencyDesc": "Latency ↓", - "latencyAsc": "Latency ↑", - "refresh": "Refresh", - "columns": "Columns", - "loadingProxyLogs": "Loading proxy logs...", - "noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.", - "noMatchingLogs": "No logs match the current filters.", - "tlsFingerprint": "Chrome 124 TLS Fingerprint" + "newest": "最新", + "oldest": "最旧", + "latencyDesc": "延迟 ↓", + "latencyAsc": "延迟 ↑", + "refresh": "刷新", + "columns": "列", + "loadingProxyLogs": "正在加载代理日志...", + "noProxyLogs": "尚无代理日志。配置代理并发起 API 调用后会显示在这里。", + "noMatchingLogs": "没有日志匹配当前筛选条件。", + "tlsFingerprint": "Chrome 124 TLS 指纹" }, - "agentFeatures": { - "agentFeaturesContextLength": "Context length", - "agentFeaturesContextLengthPlaceholder": "e.g. 128000", - "agentFeaturesContextLengthHint": "Defines the context window for this combo in /v1/models.", - "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", - "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" + "webhooks": { + "title": "Webhook", + "description": "配置系统事件的 HTTP 回调。", + "configuredWebhooks": "已配置的 Webhook", + "configuredWebhooksDesc": "管理投递端点、订阅事件、状态和测试发送。", + "addWebhook": "添加 Webhook", + "editWebhook": "编辑 Webhook", + "name": "名称", + "namePlaceholder": "生产监控", + "unnamedWebhook": "未命名 Webhook", + "url": "端点 URL", + "events": "事件", + "allEvents": "所有事件", + "secret": "密钥", + "secretPlaceholder": "留空将自动生成密钥", + "secretEditPlaceholder": "留空以保留当前密钥", + "status": "状态", + "active": "活动", + "inactive": "未活动", + "errored": "出错", + "total": "总计", + "lastTriggered": "上次触发", + "actions": "操作", + "enabled": "已启用", + "enabledDesc": "禁用的 Webhook 会继续保存,但不会接收投递。", + "refresh": "刷新", + "loading": "正在加载 Webhook...", + "never": "从未", + "failureCount": "{count, plural, =0 {no failures} one {# 次失败} other {# 次失败}}", + "testWebhook": "发送测试", + "testSuccess": "测试 Webhook 发送成功。", + "testFailed": "测试 Webhook 失败。", + "saveSuccess": "Webhook 保存成功。", + "saveFailed": "保存 Webhook 失败。", + "loadFailed": "加载 Webhook 失败。", + "delete": "删除", + "deleteConfirm": "确定要删除此 Webhook 吗?", + "deleteSuccess": "Webhook 删除成功。", + "deleteFailed": "删除 Webhook 失败。", + "edit": "编辑", + "enable": "启用", + "disable": "禁用", + "noWebhooks": "尚未配置 Webhook。", + "signatureTitle": "Webhook 签名", + "signatureDescription": "每次投递都会包含一个 X-Webhook-Signature 请求头,该签名使用 Webhook 密钥通过 HMAC-SHA256 生成。信任载荷前请先验证签名。" + }, + "compliance": { + "auditTitle": "审计", + "auditDescription": "在一个运维视图中查看合规事件和 MCP 工具调用。", + "complianceTab": "合规", + "mcpTab": "MCP 审计", + "title": "合规审计", + "description": "合规审计日志记录的策略、访问、提供商和安全事件。", + "eventType": "事件类型", + "eventTypePlaceholder": "按操作或事件类型筛选", + "severity": "严重级别", + "allSeverities": "所有严重级别", + "info": "信息", + "warning": "警告", + "critical": "严重", + "sourceIp": "来源 IP", + "userOrKey": "用户 / 密钥", + "action": "操作", + "result": "结果", + "details": "详情", + "timestamp": "时间戳", + "from": "起始", + "to": "结束", + "refresh": "刷新", + "export": "导出", + "clearFilters": "清除筛选", + "loading": "正在加载审计事件...", + "showing": "正在显示 {total} 个事件中的 {count} 个", + "policyViolation": "策略违规", + "accessDenied": "访问被拒绝", + "injectionBlocked": "注入已阻止", + "noEvents": "尚未记录合规事件。", + "failedFetch": "获取合规审计日志失败。", + "viewDetails": "查看详情", + "closeDetails": "关闭详情", + "notAvailable": "—", + "system": "system", + "previous": "上一页", + "next": "下一页", + "mcpAudit": "MCP 审计", + "mcpAuditDesc": "MCP 服务器记录的工具调用审计条目。", + "failedFetchMcpAudit": "获取 MCP 审计日志失败。", + "tool": "工具", + "toolPlaceholder": "按工具名称筛选", + "duration": "持续时间", + "apiKey": "API 密钥", + "output": "输出", + "allResults": "所有结果", + "success": "成功", + "failure": "失败", + "noMcpEvents": "尚未记录 MCP 审计事件。" + }, + "telemetry": { + "title": "系统遥测", + "description": "来自此 OmniRoute 进程的滚动请求、运行时、会话和内存信号。", + "uptime": "运行时间", + "totalRequests": "请求总数", + "avgLatency": "平均延迟", + "errorRate": "错误率", + "activeConnections": "活动连接", + "memoryUsage": "内存使用", + "latencyTrend": "延迟趋势", + "throughputTrend": "吞吐趋势", + "memoryTrend": "内存趋势", + "refresh": "刷新", + "updatedAt": "更新于 {time}", + "loadFailed": "加载遥测失败。", + "partialData": "遥测数据部分可用:{error}" + }, + "mitm": { + "title": "MITM 代理", + "description": "用于拦截和路由客户端请求的透明代理。", + "enable": "启用 MITM 代理", + "enableDesc": "启动或停止本地拦截进程和 DNS 覆盖。", + "status": "状态", + "running": "运行中", + "stopped": "已停止", + "start": "启动", + "stop": "停止", + "refresh": "刷新", + "port": "代理端口", + "apiKey": "路由器 API 密钥", + "apiKeyPlaceholder": "可选;留空则回退到本地密钥", + "sudoPassword": "Sudo 密码", + "cachedPassword": "已为此进程缓存", + "saveSettings": "保存设置", + "settingsSaved": "MITM 设置已保存。", + "startedSuccess": "MITM 代理已启动。", + "stoppedSuccess": "MITM 代理已停止。", + "saveFailed": "更新 MITM 设置失败。", + "loadFailed": "加载 MITM 设置失败。", + "invalidPort": "透明 MITM 拦截当前要求使用端口 443。", + "certificate": "CA 证书", + "certificateReady": "证书已可用于客户端信任安装。", + "certificateMissing": "尚未生成证书。", + "available": "可用", + "missing": "缺失", + "downloadCert": "下载 CA 证书", + "regenerateCert": "重新生成证书", + "regenerateConfirm": "这会使现有客户端信任失效。要继续吗?", + "regenerateSuccess": "MITM 证书已重新生成。", + "regenerateFailed": "重新生成 MITM 证书失败。", + "targetRoutes": "目标路由", + "interceptedRequests": "已拦截请求", + "activeConnections": "活动连接", + "dnsConfigured": "DNS 已配置", + "pid": "PID", + "lastIntercept": "上次拦截", + "target": "目标", + "host": "主机", + "localPort": "本地端口", + "endpoints": "端点", + "enabled": "已启用", + "configured": "已配置", + "yes": "是", + "no": "否", + "noTargets": "未配置目标路由。" } -} \ No newline at end of file +} diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 50cbd0e208..b55d1e352e 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -136,11 +136,15 @@ export async function registerNodejs(): Promise { } 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 { 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) { diff --git a/src/lib/apiBridgeServer.ts b/src/lib/apiBridgeServer.ts index d289d6f752..4109dfd6ac 100644 --- a/src/lib/apiBridgeServer.ts +++ b/src/lib/apiBridgeServer.ts @@ -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`, }) ); }); diff --git a/src/lib/cloudAgent/agents/codex.ts b/src/lib/cloudAgent/agents/codex.ts new file mode 100644 index 0000000000..f069ac9109 --- /dev/null +++ b/src/lib/cloudAgent/agents/codex.ts @@ -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 { + const taskId = this.generateTaskId(); + + const body: Record = { + 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 { + 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 { + throw new Error("Codex Cloud does not support plan approval - it auto-plans"); + } + + async sendMessage( + externalId: string, + message: string, + credentials: AgentCredentials + ): Promise { + 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 []; + } +} diff --git a/src/lib/cloudAgent/agents/devin.ts b/src/lib/cloudAgent/agents/devin.ts new file mode 100644 index 0000000000..c409c92349 --- /dev/null +++ b/src/lib/cloudAgent/agents/devin.ts @@ -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 { + const taskId = this.generateTaskId(); + + const body: Record = { + 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 { + 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) => ({ + 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 { + throw new Error("Devin does not support plan approval - it auto-plans"); + } + + async sendMessage( + externalId: string, + message: string, + credentials: AgentCredentials + ): Promise { + 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 []; + } +} diff --git a/src/lib/cloudAgent/agents/jules.ts b/src/lib/cloudAgent/agents/jules.ts new file mode 100644 index 0000000000..932eb4dfaa --- /dev/null +++ b/src/lib/cloudAgent/agents/jules.ts @@ -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 { + const taskId = this.generateTaskId(); + + const body: Record = { + 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 { + 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) => ({ + 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 { + 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 { + 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) => ({ + name: source.name as string, + url: `https://github.com/${source.repoOwner}/${source.repoName}`, + branch: source.defaultBranch as string | undefined, + })); + } +} diff --git a/src/lib/cloudAgent/baseAgent.ts b/src/lib/cloudAgent/baseAgent.ts new file mode 100644 index 0000000000..9f5689b90b --- /dev/null +++ b/src/lib/cloudAgent/baseAgent.ts @@ -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; + }; +} + +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; + + abstract getStatus(externalId: string, credentials: AgentCredentials): Promise; + + abstract approvePlan(externalId: string, credentials: AgentCredentials): Promise; + + abstract sendMessage( + externalId: string, + message: string, + credentials: AgentCredentials + ): Promise; + + 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)}`; + } +} diff --git a/src/lib/cloudAgent/db.ts b/src/lib/cloudAgent/db.ts new file mode 100644 index 0000000000..91667f5422 --- /dev/null +++ b/src/lib/cloudAgent/db.ts @@ -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> +): void { + const db = getDbInstance(); + + // Validate keys against whitelist to prevent SQL injection + const validUpdates: Partial> = {}; + for (const [key, value] of Object.entries(updates)) { + if (ALLOWED_UPDATE_COLUMNS.has(key)) { + (validUpdates as Record)[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); +} diff --git a/src/lib/cloudAgent/index.ts b/src/lib/cloudAgent/index.ts new file mode 100644 index 0000000000..c8a23bf314 --- /dev/null +++ b/src/lib/cloudAgent/index.ts @@ -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(); diff --git a/src/lib/cloudAgent/registry.ts b/src/lib/cloudAgent/registry.ts new file mode 100644 index 0000000000..091359a400 --- /dev/null +++ b/src/lib/cloudAgent/registry.ts @@ -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 = { + 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"; diff --git a/src/lib/cloudAgent/types.ts b/src/lib/cloudAgent/types.ts new file mode 100644 index 0000000000..13d7d398b0 --- /dev/null +++ b/src/lib/cloudAgent/types.ts @@ -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; +} + +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; + }; + 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; +export type UpdateCloudAgentTaskInput = z.infer; diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index bfc32b5e63..d53c66e9a9 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -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); diff --git a/src/lib/db/migrations/041_session_account_affinity.sql b/src/lib/db/migrations/041_session_account_affinity.sql new file mode 100644 index 0000000000..5a93707fb3 --- /dev/null +++ b/src/lib/db/migrations/041_session_account_affinity.sql @@ -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); diff --git a/src/lib/db/sessionAccountAffinity.ts b/src/lib/db/sessionAccountAffinity.ts index 80ba0a729d..dc63011a11 100644 --- a/src/lib/db/sessionAccountAffinity.ts +++ b/src/lib/db/sessionAccountAffinity.ts @@ -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; +} diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 3c17d7d9bc..c76fd50dd9 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -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 | 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) { diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts index a3c5443687..41d8a0f1c6 100644 --- a/src/lib/embeddings/service.ts +++ b/src/lib/embeddings/service.ts @@ -15,7 +15,7 @@ import { getProviderNodes } from "@/lib/localDb"; type ValidatedEmbeddingBody = Record & { model: string }; -interface EmbeddingHandlerOptions { +export interface EmbeddingHandlerOptions { clientRawRequest?: { endpoint: string; body: Record; diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index efe96f3199..f3af94bc83 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -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"; diff --git a/src/lib/oauth/services/cursor.ts b/src/lib/oauth/services/cursor.ts index d460520879..2d1e5af620 100644 --- a/src/lib/oauth/services/cursor.ts +++ b/src/lib/oauth/services/cursor.ts @@ -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; + 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 */ diff --git a/src/lib/providers/catalog.ts b/src/lib/providers/catalog.ts index d50f21475a..16bd18723c 100644 --- a/src/lib/providers/catalog.ts +++ b/src/lib/providers/catalog.ts @@ -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", ]; diff --git a/src/lib/resilience/settings.ts b/src/lib/resilience/settings.ts index f6f65e8faf..23842e5f47 100644 --- a/src/lib/resilience/settings.ts +++ b/src/lib/resilience/settings.ts @@ -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( diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index df189ceb5a..26d83fff16 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -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 = {}; diff --git a/src/server/authz/classify.ts b/src/server/authz/classify.ts index 6093714baa..60128281c2 100644 --- a/src/server/authz/classify.ts +++ b/src/server/authz/classify.ts @@ -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", diff --git a/src/server/authz/types.ts b/src/server/authz/types.ts index a8fd786408..443001dbca 100644 --- a/src/server/authz/types.ts +++ b/src/server/authz/types.ts @@ -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" diff --git a/src/shared/components/AutoRoutingBanner.tsx b/src/shared/components/AutoRoutingBanner.tsx new file mode 100644 index 0000000000..062de5c001 --- /dev/null +++ b/src/shared/components/AutoRoutingBanner.tsx @@ -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 ( +
+
+
+ + + Auto-Routing Active + +
+
+ OmniRoute is automatically routing requests using combo-based strategies. + + View or change your routing configuration on the{" "} + + Combos page + + . + +
+ +
+
+ ); +} diff --git a/src/shared/components/Card.tsx b/src/shared/components/Card.tsx index be782cde92..f78f8abdde 100644 --- a/src/shared/components/Card.tsx +++ b/src/shared/components/Card.tsx @@ -65,8 +65,12 @@ export default function Card({ ); } +interface CardSectionProps extends React.HTMLAttributes { + 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 (
{ + 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 (
{ + 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 (
setSidebarOpen(true)} /> {!isE2EMode && } +
diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 05dbcb5d46..3c01070b24 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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"); diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 300a75d86c..35805f7e60 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -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" }, ]; diff --git a/src/shared/utils/circuitBreaker.ts b/src/shared/utils/circuitBreaker.ts index f94d73949a..e6ba3a17f2 100644 --- a/src/shared/utils/circuitBreaker.ts +++ b/src/shared/utils/circuitBreaker.ts @@ -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>; + /** + * 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>; + 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; diff --git a/src/shared/utils/classify429.ts b/src/shared/utils/classify429.ts new file mode 100644 index 0000000000..df0c8ea027 --- /dev/null +++ b/src/shared/utils/classify429.ts @@ -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 = [ + /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 | 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; + 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; +}): number | null { + return parseRetryAfter(getHeader(response.headers, "retry-after")); +} + +/** + * Normalize an unknown headers-like value into a plain `Record`. + * 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 | 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; +} + +/** + * 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; + + let status: number | undefined; + let headers: Record | 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; + 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 }); +} diff --git a/src/shared/utils/providerHints.ts b/src/shared/utils/providerHints.ts new file mode 100644 index 0000000000..e5a375575b --- /dev/null +++ b/src/shared/utils/providerHints.ts @@ -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); +} diff --git a/src/shared/utils/runtimeTimeouts.ts b/src/shared/utils/runtimeTimeouts.ts index 672466a1c2..14e0e67afb 100644 --- a/src/shared/utils/runtimeTimeouts.ts +++ b/src/shared/utils/runtimeTimeouts.ts @@ -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; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index ae5aeb25cb..6dba0a55dc 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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(); diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 2e4973b22b..7a5b1b3411 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -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( diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 56cbee63d9..e816960249 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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 | 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>, + 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) { diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 3792a2b26a..fdac283a01 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -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>, + classifyError: classify429FromError, + } + : {}), }); if (options.ignoreCircuitBreaker && !breaker.canExecute()) { log.info("CIRCUIT", `Bypassing OPEN circuit breaker for ${provider} (${bypassReason})`); diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 4d0d9e5a77..d3dac846d1 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -759,3 +759,58 @@ test("recordModelLockoutFailure uses regular backoff for non-quota reasons", () clearModelLock("modelscope", "test-conn-modelscope-2", "qwen/Qwen2.5-Coder-32B-Instruct"); } }); + +// Test for hour quota related error messages +test("checkFallbackError classifies hour quota errors correctly", () => { + // For OAuth providers (e.g., codex), hour quota errors should be QUOTA_EXHAUSTED + const result1 = checkFallbackError( + 429, + "Coding Plan hour quota has been exceeded", + 0, + null, + "codex" + ); + assert.equal(result1.shouldFallback, true); + assert.equal(result1.reason, RateLimitReason.QUOTA_EXHAUSTED); + + const result2 = checkFallbackError(429, "hour quota exceeded", 0, null, "codex"); + assert.equal(result2.shouldFallback, true); + assert.equal(result2.reason, RateLimitReason.QUOTA_EXHAUSTED); + + const result3 = checkFallbackError(429, "Your hour quota is exceeded", 0, null, "codex"); + assert.equal(result3.shouldFallback, true); + assert.equal(result3.reason, RateLimitReason.QUOTA_EXHAUSTED); + + const result4 = checkFallbackError(429, "hour quota depleted", 0, null, "codex"); + assert.equal(result4.shouldFallback, true); + assert.equal(result4.reason, RateLimitReason.QUOTA_EXHAUSTED); + + // For API-key providers with 402 status, hour quota errors should be QUOTA_EXHAUSTED + const result5 = checkFallbackError(402, "hour quota has been exceeded", 0, null, "openai"); + assert.equal(result5.shouldFallback, true); + assert.equal(result5.reason, RateLimitReason.QUOTA_EXHAUSTED); + + const result6 = checkFallbackError( + 403, + "Coding Plan hour quota has been exceeded", + 0, + null, + "openai" + ); + assert.equal(result6.shouldFallback, true); + assert.equal(result6.reason, RateLimitReason.QUOTA_EXHAUSTED); +}); + +// Test for classifyErrorText function with hour quota +test("classifyErrorText handles hour quota messages", () => { + const { classifyErrorText } = accountFallback; + + assert.equal( + classifyErrorText("Coding Plan hour quota has been exceeded"), + RateLimitReason.QUOTA_EXHAUSTED + ); + assert.equal(classifyErrorText("hour quota exceeded"), RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(classifyErrorText("Your hour quota is exceeded"), RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(classifyErrorText("hour quota has been exceeded"), RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(classifyErrorText("quota has been exceeded"), RateLimitReason.QUOTA_EXHAUSTED); +}); diff --git a/tests/unit/audio-speech-handler.test.ts b/tests/unit/audio-speech-handler.test.ts index 0cd3f39ead..49d24998c9 100644 --- a/tests/unit/audio-speech-handler.test.ts +++ b/tests/unit/audio-speech-handler.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; const { handleAudioSpeech } = await import("../../open-sse/handlers/audioSpeech.ts"); +const { AUDIO_SPEECH_PROVIDERS } = await import("../../open-sse/config/audioRegistry.ts"); test("handleAudioSpeech requires model", async () => { const response = await handleAudioSpeech({ @@ -486,7 +487,7 @@ test("handleAudioSpeech maps Inworld requests to basic auth and wav output", asy try { const response = await handleAudioSpeech({ body: { - model: "inworld/inworld-tts-1.5-max", + model: "inworld/inworld-tts-2", input: "inworld text", voice: "voice-9", response_format: "wav", @@ -498,8 +499,8 @@ test("handleAudioSpeech maps Inworld requests to basic auth and wav output", asy assert.deepEqual(captured.body, { text: "inworld text", voiceId: "voice-9", - modelId: "inworld-tts-1.5-max", - audioConfig: { audioEncoding: "LINEAR16" }, + modelId: "inworld-tts-2", + audioConfig: { audioEncoding: "WAV" }, }); assert.equal(response.headers.get("content-type"), "audio/wav"); assert.deepEqual(Array.from(new Uint8Array(await response.arrayBuffer())), [1, 2, 3, 4]); @@ -508,6 +509,60 @@ test("handleAudioSpeech maps Inworld requests to basic auth and wav output", asy } }); +test("Inworld speech registry exposes supported formats without flac", () => { + assert.deepEqual(AUDIO_SPEECH_PROVIDERS.inworld.supportedFormats, ["mp3", "wav", "opus", "pcm"]); +}); + +test("handleAudioSpeech maps Inworld opus output and rejects flac", async () => { + const originalFetch = globalThis.fetch; + let captured; + let callCount = 0; + + globalThis.fetch = async (_url, options = {}) => { + callCount += 1; + captured = JSON.parse(String(options.body || "{}")); + + return new Response(JSON.stringify({ audioContent: "BQY=", contentType: "audio/opus" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const opusResponse = await handleAudioSpeech({ + body: { + model: "inworld/inworld-tts-2", + input: "inworld text", + response_format: "opus", + }, + credentials: { apiKey: "encoded-basic-token" }, + }); + + assert.deepEqual(captured.audioConfig, { audioEncoding: "OPUS" }); + assert.equal(opusResponse.headers.get("content-type"), "audio/opus"); + assert.deepEqual(Array.from(new Uint8Array(await opusResponse.arrayBuffer())), [5, 6]); + + const flacResponse = await handleAudioSpeech({ + body: { + model: "inworld/inworld-tts-2", + input: "inworld text", + response_format: "flac", + }, + credentials: { apiKey: "encoded-basic-token" }, + }); + const payload = (await flacResponse.json()) as any; + + assert.equal(flacResponse.status, 400); + assert.equal( + payload.error.message, + "Inworld TTS supports response_format mp3, wav, opus, or pcm only" + ); + assert.equal(callCount, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("handleAudioSpeech supports local Coqui providers without credentials", async () => { const originalFetch = globalThis.fetch; let captured; diff --git a/tests/unit/authz/classify.test.ts b/tests/unit/authz/classify.test.ts index 7f6021992e..5870d8ed30 100644 --- a/tests/unit/authz/classify.test.ts +++ b/tests/unit/authz/classify.test.ts @@ -16,7 +16,7 @@ const cases: Case[] = [ { name: "root /", path: "/", expectedClass: "MANAGEMENT", expectedNormalized: "/" }, { name: "dashboard root", path: "/dashboard", expectedClass: "MANAGEMENT" }, { name: "dashboard nested", path: "/dashboard/settings", expectedClass: "MANAGEMENT" }, - { name: "dashboard onboarding", path: "/dashboard/onboarding", expectedClass: "MANAGEMENT" }, + { name: "dashboard onboarding", path: "/dashboard/onboarding", expectedClass: "PUBLIC" }, { name: "/api/v1 base", diff --git a/tests/unit/autoPrefix.test.ts b/tests/unit/autoPrefix.test.ts new file mode 100644 index 0000000000..cb0588dc61 --- /dev/null +++ b/tests/unit/autoPrefix.test.ts @@ -0,0 +1,89 @@ +import { parseAutoPrefix } from "../../open-sse/services/autoCombo/autoPrefix.ts"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +describe("parseAutoPrefix", () => { + it('should return valid for "auto" with no variant', () => { + const result = parseAutoPrefix("auto"); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.variant, undefined); + }); + + it('should return valid for "auto/" with no variant', () => { + const result = parseAutoPrefix("auto/"); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.variant, undefined); + }); + + it('should return valid for "auto/coding" with coding variant', () => { + const result = parseAutoPrefix("auto/coding"); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.variant, "coding"); + }); + + it('should return valid for "auto/fast" with fast variant', () => { + const result = parseAutoPrefix("auto/fast"); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.variant, "fast"); + }); + + it('should return valid for "auto/cheap" with cheap variant', () => { + const result = parseAutoPrefix("auto/cheap"); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.variant, "cheap"); + }); + + it('should return valid for "auto/offline" with offline variant', () => { + const result = parseAutoPrefix("auto/offline"); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.variant, "offline"); + }); + + it('should return valid for "auto/smart" with smart variant', () => { + const result = parseAutoPrefix("auto/smart"); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.variant, "smart"); + }); + + it('should return valid for "auto/lkgp" with lkgp variant', () => { + const result = parseAutoPrefix("auto/lkgp"); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.variant, "lkgp"); + }); + + it('should return invalid for "autocoding" (invalid format)', () => { + const result = parseAutoPrefix("autocoding"); + assert.strictEqual(result.valid, false); + assert.match(result.error || "", /Invalid auto prefix format/); + }); + + it('should return invalid for "auto/unknown" (invalid variant)', () => { + const result = parseAutoPrefix("auto/unknown"); + assert.strictEqual(result.valid, false); + assert.match(result.error || "", /Invalid auto variant: unknown/); + }); + + it("should return invalid for a non-auto prefixed model", () => { + const result = parseAutoPrefix("otherModel"); + assert.strictEqual(result.valid, false); + assert.match(result.error || "", /Not an auto-prefixed model/); + }); + + it("should return invalid for empty string", () => { + const result = parseAutoPrefix(""); + assert.strictEqual(result.valid, false); + assert.match(result.error || "", /Not an auto-prefixed model/); + }); + + it("should return invalid for null/undefined input (handled by TS but for robustness)", () => { + // @ts-ignore testing invalid input that TS normally prevents + const result = parseAutoPrefix(null); + assert.strictEqual(result.valid, false); + assert.match(result.error || "", /Not an auto-prefixed model/); + + // @ts-ignore + const result2 = parseAutoPrefix(undefined); + assert.strictEqual(result2.valid, false); + assert.match(result2.error || "", /Not an auto-prefixed model/); + }); +}); diff --git a/tests/unit/cc-compatible-provider.test.ts b/tests/unit/cc-compatible-provider.test.ts index 0f03139ddf..5eac9493c8 100644 --- a/tests/unit/cc-compatible-provider.test.ts +++ b/tests/unit/cc-compatible-provider.test.ts @@ -571,6 +571,91 @@ test("handleChatCore forces SSE upstream for CC compatible providers while retur assert.equal(payload.usage.completion_tokens, 5); }); +test("handleChatCore stops buffering CC-compatible SSE once a non-stream response completes", async () => { + const encoder = new TextEncoder(); + let upstreamCancelled = false; + const upstreamChunks = [ + "data:\n\n", + [ + "event: message_start", + 'data: {"type":"message_start","message":{"id":"msg_3","type":"message","role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":4,"output_tokens":0}}}', + "", + "event: content_block_delta", + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Finished but connection stayed open"}}', + "", + "event: message_delta", + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":6}}', + "", + "event: message_stop", + 'data: {"type":"message_stop"}', + "", + ].join("\n"), + ]; + let chunkIndex = 0; + + globalThis.fetch = async () => + new Response( + new ReadableStream({ + pull(controller) { + if (chunkIndex < upstreamChunks.length) { + controller.enqueue(encoder.encode(upstreamChunks[chunkIndex++])); + } + }, + cancel() { + upstreamCancelled = true; + }, + }), + { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + } + ); + + const result = await handleChatCore({ + body: { + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "Ping" }], + stream: false, + }, + modelInfo: { + provider: "anthropic-compatible-cc-test", + model: "claude-sonnet-4-6", + extendedContext: false, + }, + credentials: { + apiKey: "sk-test", + providerSpecificData: { + baseUrl: "https://proxy.example.com", + chatPath: CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH, + }, + }, + clientRawRequest: { + endpoint: "/v1/chat/completions", + body: { + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "Ping" }], + stream: false, + }, + headers: new Headers({ accept: "application/json" }), + }, + userAgent: "unit-test", + log: { + debug() {}, + info() {}, + warn() {}, + error() {}, + }, + }); + + assert.equal(result.success, true); + assert.equal(upstreamCancelled, true); + const payload = (await result.response.json()) as any; + assert.equal(payload.choices[0].message.content, "Finished but connection stayed open"); + assert.equal(payload.usage.completion_tokens, 6); +}); + test("handleChatCore preserves client cache markers for Claude Code requests to CC-compatible providers", async () => { const calls = []; globalThis.fetch = async (url, init = {}) => { diff --git a/tests/unit/circuit-breaker-failure-kind.test.ts b/tests/unit/circuit-breaker-failure-kind.test.ts new file mode 100644 index 0000000000..d9498f1a0a --- /dev/null +++ b/tests/unit/circuit-breaker-failure-kind.test.ts @@ -0,0 +1,231 @@ +/** + * Tests for circuit breaker per-failure-kind cooldowns (Issue #2100). + * + * Each test creates a uniquely-named breaker so SQLite-backed state from + * `loadCircuitBreakerState()` cannot leak between tests when run in any + * order or with --test-concurrency > 1. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + CircuitBreaker, + resetAllCircuitBreakers, + getCircuitBreaker, +} from "../../src/shared/utils/circuitBreaker.ts"; +import type { FailureKind } from "../../src/shared/utils/classify429.ts"; + +const uniqueName = (suffix: string) => + `cb-test-#2100-${suffix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +test("default behavior unchanged when neither cooldownByKind nor classifyError is set", () => { + const cb = new CircuitBreaker(uniqueName("legacy"), { + failureThreshold: 2, + resetTimeout: 30_000, + }); + cb._onFailure(); + cb._onFailure(); + assert.equal(cb.state, "OPEN"); + assert.equal(cb.lastFailureKind, null); + // Cooldown is the legacy resetTimeout — not biased by kind. + const t = cb._timeUntilReset(); + assert.ok(t > 29_000 && t <= 30_000, `expected ~30s, got ${t}`); + cb.reset(); +}); + +test("cooldownByKind: quota_exhausted gets longer cooldown than rate_limit", () => { + const cb = new CircuitBreaker(uniqueName("kind-cooldown"), { + failureThreshold: 1, + resetTimeout: 30_000, + cooldownByKind: { + rate_limit: 60_000, + quota_exhausted: 3_600_000, + }, + }); + + cb._onFailure("rate_limit"); + assert.equal(cb.state, "OPEN"); + assert.equal(cb.lastFailureKind, "rate_limit"); + const tRate = cb._timeUntilReset(); + assert.ok(tRate > 59_000 && tRate <= 60_000, `rate_limit cooldown: expected ~60s, got ${tRate}`); + + cb.reset(); + cb._onFailure("quota_exhausted"); + const tQuota = cb._timeUntilReset(); + assert.ok( + tQuota > 3_599_000 && tQuota <= 3_600_000, + `quota_exhausted cooldown: expected ~3600s, got ${tQuota}` + ); + + cb.reset(); +}); + +test("cooldownByKind falls back to resetTimeout when no override for the kind", () => { + const cb = new CircuitBreaker(uniqueName("partial-cooldown"), { + failureThreshold: 1, + resetTimeout: 45_000, + cooldownByKind: { + quota_exhausted: 3_600_000, + // rate_limit intentionally omitted — should use resetTimeout + }, + }); + cb._onFailure("rate_limit"); + const t = cb._timeUntilReset(); + assert.ok(t > 44_000 && t <= 45_000, `expected ~45s fallback, got ${t}`); + cb.reset(); +}); + +test("cooldownByKind falls back to resetTimeout when lastFailureKind is null", () => { + const cb = new CircuitBreaker(uniqueName("null-kind"), { + failureThreshold: 1, + resetTimeout: 30_000, + cooldownByKind: { + rate_limit: 60_000, + quota_exhausted: 3_600_000, + }, + }); + // Old-style call site that doesn't classify — kind stays null. + cb._onFailure(); + assert.equal(cb.lastFailureKind, null); + const t = cb._timeUntilReset(); + assert.ok(t > 29_000 && t <= 30_000, `expected resetTimeout fallback, got ${t}`); + cb.reset(); +}); + +test("classifyError on execute() routes errors to the correct kind", async () => { + const cb = new CircuitBreaker(uniqueName("classifier"), { + failureThreshold: 1, + resetTimeout: 1_000, + cooldownByKind: { quota_exhausted: 600_000 }, + classifyError: (err: unknown): FailureKind | undefined => { + if (err instanceof Error && err.message.includes("daily limit")) { + return "quota_exhausted"; + } + return "transient"; + }, + }); + + await assert.rejects(async () => { + await cb.execute(async () => { + throw new Error("You exceeded your daily limit"); + }); + }); + + assert.equal(cb.state, "OPEN"); + assert.equal(cb.lastFailureKind, "quota_exhausted"); + const t = cb._timeUntilReset(); + assert.ok(t > 599_000 && t <= 600_000, `expected ~600s quota cooldown, got ${t}`); + cb.reset(); +}); + +test("_onSuccess clears lastFailureKind on close transition", () => { + const cb = new CircuitBreaker(uniqueName("success-clear"), { + failureThreshold: 1, + resetTimeout: 100, + cooldownByKind: { rate_limit: 100 }, + }); + cb._onFailure("rate_limit"); + assert.equal(cb.lastFailureKind, "rate_limit"); + // simulate elapsed time & success path through _onSuccess (which the + // combo path uses); after success, kind should clear and breaker close. + cb._onSuccess(); + assert.equal(cb.lastFailureKind, null); + assert.equal(cb.state, "CLOSED"); +}); + +test("reset() clears lastFailureKind", () => { + const cb = new CircuitBreaker(uniqueName("reset-clear"), { + failureThreshold: 1, + cooldownByKind: { rate_limit: 60_000 }, + }); + cb._onFailure("rate_limit"); + assert.equal(cb.lastFailureKind, "rate_limit"); + cb.reset(); + assert.equal(cb.lastFailureKind, null); +}); + +test("getCircuitBreaker registry merges new options on subsequent calls", () => { + const name = uniqueName("registry-merge"); + const cb1 = getCircuitBreaker(name, { failureThreshold: 5 }); + // First call: no cooldownByKind, classifyError null + assert.deepEqual(cb1.cooldownByKind, {}); + assert.equal(cb1.classifyError, null); + // Second call: add overrides + const cb2 = getCircuitBreaker(name, { + cooldownByKind: { rate_limit: 60_000 }, + classifyError: () => "rate_limit" as FailureKind, + }); + assert.equal(cb1, cb2, "registry returns same instance"); + assert.deepEqual(cb1.cooldownByKind, { rate_limit: 60_000 }); + assert.ok(typeof cb1.classifyError === "function"); + cb1.reset(); +}); + +test("registry MERGES cooldownByKind across calls (does not replace)", () => { + // Regression test for codex audit MEDIUM #1: if two registrations supply + // disjoint keys, both must survive. Without spread-merge the second call + // wiped the first. + const name = uniqueName("registry-merge-keys"); + const cb1 = getCircuitBreaker(name, { + cooldownByKind: { quota_exhausted: 3_600_000 }, + }); + assert.deepEqual(cb1.cooldownByKind, { quota_exhausted: 3_600_000 }); + const cb2 = getCircuitBreaker(name, { + cooldownByKind: { rate_limit: 60_000 }, + }); + assert.equal(cb1, cb2); + assert.deepEqual(cb1.cooldownByKind, { + quota_exhausted: 3_600_000, + rate_limit: 60_000, + }); + cb1.reset(); +}); + +test("classifyError throwing does not mask original error or skip _onFailure", async () => { + // Regression test for codex audit MEDIUM #2. + const cb = new CircuitBreaker(uniqueName("classifier-throws"), { + failureThreshold: 1, + resetTimeout: 1_000, + classifyError: () => { + throw new Error("classifier itself blew up"); + }, + }); + let caught: unknown; + try { + await cb.execute(async () => { + throw new Error("original error"); + }); + } catch (e) { + caught = e; + } + assert.ok(caught instanceof Error); + assert.equal((caught as Error).message, "original error"); + assert.equal(cb.state, "OPEN", "failure was still recorded"); + assert.equal(cb.lastFailureKind, null, "kind defaults to null when classifier throws"); + cb.reset(); +}); + +test("invalid cooldownByKind values (NaN, Infinity, negative) fall back to resetTimeout", () => { + // Regression test for codex audit MEDIUM #3. + const cb = new CircuitBreaker(uniqueName("invalid-cooldown"), { + failureThreshold: 1, + resetTimeout: 30_000, + cooldownByKind: { + rate_limit: NaN, + quota_exhausted: -5_000, + transient: Number.POSITIVE_INFINITY, + }, + }); + for (const kind of ["rate_limit", "quota_exhausted", "transient"] as const) { + cb.reset(); + cb._onFailure(kind); + const t = cb._timeUntilReset(); + assert.ok(t > 29_000 && t <= 30_000, `expected resetTimeout fallback for ${kind}, got ${t}`); + } + cb.reset(); +}); + +// Cleanup: clear the registry (and SQLite-persisted breaker state) so this +// test file does not leak into others when --test-concurrency=10. +test("teardown — reset all circuit breakers", () => { + resetAllCircuitBreakers(); +}); diff --git a/tests/unit/classify429.test.ts b/tests/unit/classify429.test.ts new file mode 100644 index 0000000000..75d1a4fbd3 --- /dev/null +++ b/tests/unit/classify429.test.ts @@ -0,0 +1,156 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + classify429, + looksLikeQuotaExhausted, + parseRetryAfter, + retryAfterFromResponse, + type FailureKind, +} from "../../src/shared/utils/classify429.ts"; + +test("classify429: non-429 status returns 'transient'", () => { + const out: FailureKind = classify429({ status: 500 }); + assert.equal(out, "transient"); +}); + +test("classify429: 429 with no body or hints returns 'rate_limit'", () => { + assert.equal(classify429({ status: 429 }), "rate_limit"); + assert.equal(classify429({ status: 429, body: "" }), "rate_limit"); + assert.equal(classify429({ status: 429, body: undefined }), "rate_limit"); +}); + +test("classify429: 429 with quota keyword in string body returns 'quota_exhausted'", () => { + assert.equal( + classify429({ status: 429, body: "You exceeded your daily limit." }), + "quota_exhausted" + ); + assert.equal( + classify429({ status: 429, body: "Monthly quota reached. Resets on the 1st." }), + "quota_exhausted" + ); + assert.equal( + classify429({ status: 429, body: "Out of credits — top up your account." }), + "quota_exhausted" + ); + assert.equal(classify429({ status: 429, body: "plan limit reached" }), "quota_exhausted"); +}); + +test("classify429: 429 with quota keyword in nested object body returns 'quota_exhausted'", () => { + assert.equal( + classify429({ + status: 429, + body: { error: { message: "You have exceeded your monthly quota." } }, + }), + "quota_exhausted" + ); + assert.equal( + classify429({ + status: 429, + body: { error: { type: "insufficient_quota", message: "..." } }, + }), + "quota_exhausted" + ); +}); + +test("classify429: 429 without quota keyword returns 'rate_limit'", () => { + // Plain rate-limit message — keyword 'rate' alone is NOT in QUOTA_PATTERNS + // so classifier should default to "rate_limit" for any 429. + assert.equal( + classify429({ status: 429, body: "Too many requests. Try again in 60s." }), + "rate_limit" + ); + assert.equal( + classify429({ + status: 429, + body: "Rate limit reached for requests. Please retry.", + }), + "rate_limit" + ); +}); + +test("looksLikeQuotaExhausted: detects all known keyword variants", () => { + for (const body of [ + "daily limit exceeded", + "daily quota reached", + "per-day limit reached", + "monthly limit", + "monthly quota", + "per-month limit", + "quota exceeded", + "exceeded quota", + "insufficient_quota", + "billing cap reached", + "credit exhausted", + "out of credits", + "hard limit", + "hard-limit", + "plan limit", + ]) { + assert.equal(looksLikeQuotaExhausted(body), true, `failed for: ${body}`); + } +}); + +test("looksLikeQuotaExhausted: rejects empty / null / non-quota text", () => { + assert.equal(looksLikeQuotaExhausted(undefined), false); + assert.equal(looksLikeQuotaExhausted(null), false); + assert.equal(looksLikeQuotaExhausted(""), false); + assert.equal(looksLikeQuotaExhausted("rate limit, please retry in 60s"), false); + assert.equal(looksLikeQuotaExhausted("server error 500"), false); +}); + +test("ambiguous 'daily rate limit' messages classify as quota_exhausted (intentional)", () => { + // Codex audit LOW: messages combining 'daily' or 'monthly' with 'limit' + // match the quota regex even when paired with 'rate'. This is intentional + // because daily/monthly caps semantically warrant a long cooldown — even + // when the upstream calls them "rate limits". Locking it down here so a + // future regex tweak doesn't silently change the behavior. + assert.equal(classify429({ status: 429, body: "daily rate limit exceeded" }), "quota_exhausted"); + assert.equal( + classify429({ status: 429, body: "monthly rate limit exceeded" }), + "quota_exhausted" + ); +}); + +test("parseRetryAfter: integer seconds", () => { + assert.equal(parseRetryAfter("60"), 60); + assert.equal(parseRetryAfter("3600"), 3600); + assert.equal(parseRetryAfter("0"), 0); +}); + +test("parseRetryAfter: Groq-style relative units", () => { + // Regression for the parseInt-trap: parseInt("5m", 10) returns 5, + // which would be wrong (5s instead of 300s). The relative-unit + // pattern must be checked BEFORE plain integer parse. + assert.equal(parseRetryAfter("60s"), 60); + assert.equal(parseRetryAfter("5m"), 300); + assert.equal(parseRetryAfter("2h"), 7200); + assert.equal(parseRetryAfter("1H"), 3600); +}); + +test("parseRetryAfter: HTTP-date in the future", () => { + const future = new Date(Date.now() + 60_000).toUTCString(); + const secs = parseRetryAfter(future); + assert.ok(secs !== null); + assert.ok(secs! >= 50 && secs! <= 65, `expected ~60, got ${secs}`); +}); + +test("parseRetryAfter: HTTP-date in the past clamps to 0", () => { + const past = new Date(Date.now() - 5 * 60_000).toUTCString(); + assert.equal(parseRetryAfter(past), 0); +}); + +test("parseRetryAfter: unparseable returns null", () => { + assert.equal(parseRetryAfter(undefined), null); + assert.equal(parseRetryAfter(""), null); + assert.equal(parseRetryAfter(" "), null); + assert.equal(parseRetryAfter("not-a-date"), null); + assert.equal(parseRetryAfter("60xyz"), null); +}); + +test("retryAfterFromResponse: case-insensitive header lookup", () => { + assert.equal(retryAfterFromResponse({ headers: { "Retry-After": "30" } }), 30); + assert.equal(retryAfterFromResponse({ headers: { "retry-after": "45" } }), 45); + assert.equal(retryAfterFromResponse({ headers: { "RETRY-AFTER": "60s" } }), 60); + assert.equal(retryAfterFromResponse({ headers: {} }), null); + assert.equal(retryAfterFromResponse({}), null); +}); diff --git a/tests/unit/cloudAgent-types.test.ts b/tests/unit/cloudAgent-types.test.ts new file mode 100644 index 0000000000..f1d8dcb620 --- /dev/null +++ b/tests/unit/cloudAgent-types.test.ts @@ -0,0 +1,166 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { CLOUD_AGENT_STATUS, CloudAgentStatusSchema, CloudAgentTask } = + await import("../../src/lib/cloudAgent/types.ts"); +const { CreateCloudAgentTaskSchema, UpdateCloudAgentTaskSchema } = + await import("../../src/lib/cloudAgent/types.ts"); +const { isCloudAgentProvider, getAgent, getAvailableAgents } = + await import("../../src/lib/cloudAgent/registry.ts"); + +test("CLOUD_AGENT_STATUS has correct values", () => { + assert.strictEqual(CLOUD_AGENT_STATUS.QUEUED, "queued"); + assert.strictEqual(CLOUD_AGENT_STATUS.RUNNING, "running"); + assert.strictEqual(CLOUD_AGENT_STATUS.AWAITING_APPROVAL, "awaiting_approval"); + assert.strictEqual(CLOUD_AGENT_STATUS.COMPLETED, "completed"); + assert.strictEqual(CLOUD_AGENT_STATUS.FAILED, "failed"); + assert.strictEqual(CLOUD_AGENT_STATUS.CANCELLED, "cancelled"); +}); + +test("CloudAgentStatusSchema validates valid statuses", () => { + const validStatuses = [ + "queued", + "running", + "awaiting_approval", + "completed", + "failed", + "cancelled", + ]; + for (const status of validStatuses) { + const result = CloudAgentStatusSchema.safeParse(status); + assert.strictEqual(result.success, true, `Status ${status} should be valid`); + } +}); + +test("CloudAgentStatusSchema rejects invalid status", () => { + const result = CloudAgentStatusSchema.safeParse("invalid"); + assert.strictEqual(result.success, false); +}); + +test("CreateCloudAgentTaskSchema validates valid input", () => { + const validInput = { + providerId: "jules", + prompt: "Fix the bug in auth.ts", + source: { + repoName: "my-repo", + repoUrl: "https://github.com/user/my-repo", + branch: "main", + }, + options: { + autoCreatePr: true, + planApprovalRequired: true, + }, + }; + const result = CreateCloudAgentTaskSchema.safeParse(validInput); + assert.strictEqual(result.success, true); +}); + +test("CreateCloudAgentTaskSchema rejects missing required fields", () => { + const invalidInput = { + providerId: "jules", + }; + const result = CreateCloudAgentTaskSchema.safeParse(invalidInput); + assert.strictEqual(result.success, false); +}); + +test("CreateCloudAgentTaskSchema rejects invalid providerId", () => { + const invalidInput = { + providerId: "invalid-provider", + prompt: "Test", + source: { + repoName: "test", + repoUrl: "https://github.com/test/test", + }, + }; + const result = CreateCloudAgentTaskSchema.safeParse(invalidInput); + assert.strictEqual(result.success, false); +}); + +test("CreateCloudAgentTaskSchema rejects invalid repoUrl", () => { + const invalidInput = { + providerId: "jules", + prompt: "Test", + source: { + repoName: "test", + repoUrl: "not-a-url", + }, + }; + const result = CreateCloudAgentTaskSchema.safeParse(invalidInput); + assert.strictEqual(result.success, false); +}); + +test("UpdateCloudAgentTaskSchema validates approve action", () => { + const validInput = { + id: "123e4567-e89b-12d3-a456-426614174000", + action: "approve", + }; + const result = UpdateCloudAgentTaskSchema.safeParse(validInput); + assert.strictEqual(result.success, true); +}); + +test("UpdateCloudAgentTaskSchema validates cancel action", () => { + const validInput = { + id: "123e4567-e89b-12d3-a456-426614174000", + action: "cancel", + }; + const result = UpdateCloudAgentTaskSchema.safeParse(validInput); + assert.strictEqual(result.success, true); +}); + +test("UpdateCloudAgentTaskSchema validates message action with content", () => { + const validInput = { + id: "123e4567-e89b-12d3-a456-426614174000", + action: "message", + message: "Please continue with the next step", + }; + const result = UpdateCloudAgentTaskSchema.safeParse(validInput); + assert.strictEqual(result.success, true); +}); + +test("UpdateCloudAgentTaskSchema allows message without content (optional)", () => { + const validInput = { + id: "123e4567-e89b-12d3-a456-426614174000", + action: "message", + }; + const result = UpdateCloudAgentTaskSchema.safeParse(validInput); + assert.strictEqual(result.success, true); +}); + +test("getAvailableAgents returns all registered agents", () => { + const agents = getAvailableAgents(); + assert.strictEqual(agents.includes("jules"), true); + assert.strictEqual(agents.includes("devin"), true); + assert.strictEqual(agents.includes("codex-cloud"), true); + assert.strictEqual(agents.length, 3); +}); + +test("getAgent returns correct agent for valid providerId", () => { + const jules = getAgent("jules"); + assert.notStrictEqual(jules, null); + assert.strictEqual(jules?.providerId, "jules"); + + const devin = getAgent("devin"); + assert.notStrictEqual(devin, null); + assert.strictEqual(devin?.providerId, "devin"); + + const codex = getAgent("codex-cloud"); + assert.notStrictEqual(codex, null); + assert.strictEqual(codex?.providerId, "codex-cloud"); +}); + +test("getAgent returns null for invalid providerId", () => { + const result = getAgent("invalid-provider"); + assert.strictEqual(result, null); +}); + +test("isCloudAgentProvider returns true for valid providers", () => { + assert.strictEqual(isCloudAgentProvider("jules"), true); + assert.strictEqual(isCloudAgentProvider("devin"), true); + assert.strictEqual(isCloudAgentProvider("codex-cloud"), true); +}); + +test("isCloudAgentProvider returns false for invalid providers", () => { + assert.strictEqual(isCloudAgentProvider("openai"), false); + assert.strictEqual(isCloudAgentProvider("anthropic"), false); + assert.strictEqual(isCloudAgentProvider("invalid"), false); +}); diff --git a/tests/unit/compression/rtk-code-stripper.test.ts b/tests/unit/compression/rtk-code-stripper.test.ts index a191d31160..bba15c0264 100644 --- a/tests/unit/compression/rtk-code-stripper.test.ts +++ b/tests/unit/compression/rtk-code-stripper.test.ts @@ -23,7 +23,7 @@ describe("RTK code stripper", () => { ); assert.ok(js.text.includes("// comment")); - assert.match(js.text, /https:\/\/example\.com\/a\/\/b/); + assert.ok(js.text.includes("https://example.com/a//b")); assert.ok(js.text.includes("/* block */")); }); diff --git a/tests/unit/cursor-usage-fetcher.test.ts b/tests/unit/cursor-usage-fetcher.test.ts new file mode 100644 index 0000000000..60cc78d63c --- /dev/null +++ b/tests/unit/cursor-usage-fetcher.test.ts @@ -0,0 +1,238 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const usageService = await import("../../open-sse/services/usage.ts"); + +const CURSOR_USAGE_URL = "https://cursor.com/api/dashboard/get-current-period-usage"; + +function makeJwt(payload: Record): string { + const b64url = (input: string) => + Buffer.from(input) + .toString("base64") + .replace(/=+$/, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const body = b64url(JSON.stringify(payload)); + return `${header}.${body}.signature`; +} + +const SAMPLE_RESPONSE = { + billingCycleStart: "1776672371000", + billingCycleEnd: "1779264371000", + planUsage: { + totalSpend: 1529, + includedSpend: 1529, + remaining: 471, + limit: 2000, + autoPercentUsed: 13.20952380952381, + apiPercentUsed: 3.155555555555556, + totalPercentUsed: 10.193333333333333, + }, + spendLimitUsage: { limitType: "user" }, + enabled: true, +}; + +interface CapturedRequest { + url: string; + init: RequestInit; +} + +function installFetchMock( + responder: (url: string, init: RequestInit) => Response | Promise +): { restore: () => void; calls: CapturedRequest[] } { + const calls: CapturedRequest[] = []; + const original = globalThis.fetch; + globalThis.fetch = (async (input: any, init: RequestInit = {}) => { + const url = typeof input === "string" ? input : (input as Request).url; + calls.push({ url, init }); + return await responder(url, init); + }) as typeof fetch; + return { + restore: () => { + globalThis.fetch = original; + }, + calls, + }; +} + +test("cursor usage: happy path returns three windows with correct percentages and reset", async () => { + const userId = "user_01TESTSTOREDID"; + const accessToken = makeJwt({ sub: `google-oauth2|${userId}` }); + + const mock = installFetchMock( + async () => + new Response(JSON.stringify(SAMPLE_RESPONSE), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + + try { + const usage = await usageService.getUsageForProvider({ + provider: "cursor", + accessToken, + providerSpecificData: { userId }, + }); + + assert.equal(usage.plan, "Cursor Pro"); + assert.deepEqual(Object.keys(usage.quotas), ["Total", "Auto + Composer", "API"]); + + const total = usage.quotas.Total; + assert.equal(total.total, 20); // limit 2000 cents = $20 + assert.equal(total.used, 15.29); // totalSpend 1529 cents = $15.29 + assert.equal(total.remaining, 4.71); + assert.ok(Math.abs(total.remainingPercentage - (100 - 10.193333333333333)) < 1e-6); + assert.equal(total.unlimited, false); + assert.equal(total.resetAt, new Date(Number("1779264371000")).toISOString()); + + const auto = usage.quotas["Auto + Composer"]; + assert.equal(auto.total, 20); + // 2000 cents * 13.2095% ≈ 264 cents = $2.64 + assert.equal(auto.used, 2.64); + assert.ok(Math.abs(auto.remainingPercentage - (100 - 13.20952380952381)) < 1e-6); + + const api = usage.quotas.API; + assert.equal(api.total, 20); + // 2000 cents * 3.1556% ≈ 63 cents = $0.63 + assert.equal(api.used, 0.63); + assert.ok(Math.abs(api.remainingPercentage - (100 - 3.155555555555556)) < 1e-6); + } finally { + mock.restore(); + } +}); + +test("cursor usage: sends correct cookie, origin, body, and method", async () => { + const userId = "user_01HEADERCHECK"; + const accessToken = makeJwt({ sub: userId }); + + const mock = installFetchMock( + async () => + new Response(JSON.stringify(SAMPLE_RESPONSE), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + + try { + await usageService.getUsageForProvider({ + provider: "cursor", + accessToken, + providerSpecificData: { userId }, + }); + + assert.equal(mock.calls.length, 1); + const { url, init } = mock.calls[0]; + assert.equal(url, CURSOR_USAGE_URL); + assert.equal(init.method, "POST"); + assert.equal(init.body, "{}"); + + const headers = init.headers as Record; + assert.equal(headers.Cookie, `WorkosCursorSessionToken=${userId}::${accessToken}`); + assert.equal(headers.Origin, "https://cursor.com"); + assert.match(headers.Referer, /^https:\/\/cursor\.com\/dashboard/); + assert.equal(headers["Content-Type"], "application/json"); + assert.ok(headers["User-Agent"]); + } finally { + mock.restore(); + } +}); + +test("cursor usage: falls back to JWT sub when providerSpecificData.userId is missing", async () => { + const userId = "user_01JWTFALLBACK"; + const accessToken = makeJwt({ sub: `google-oauth2|${userId}` }); + + const mock = installFetchMock( + async () => + new Response(JSON.stringify(SAMPLE_RESPONSE), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + + try { + const usage = await usageService.getUsageForProvider({ + provider: "cursor", + accessToken, + providerSpecificData: {}, + }); + + assert.equal(usage.plan, "Cursor Pro"); + assert.equal(mock.calls.length, 1); + const headers = mock.calls[0].init.headers as Record; + // Cookie should use the full sub claim (google-oauth2|user_...) since Cursor accepts both forms. + assert.equal( + headers.Cookie, + `WorkosCursorSessionToken=google-oauth2|${userId}::${accessToken}` + ); + } finally { + mock.restore(); + } +}); + +test("cursor usage: returns message and skips fetch when userId is unrecoverable", async () => { + const mock = installFetchMock(async () => new Response("should not be called", { status: 500 })); + + try { + const usage = await usageService.getUsageForProvider({ + provider: "cursor", + accessToken: "not-a-jwt-and-too-short", + providerSpecificData: {}, + }); + + assert.equal(mock.calls.length, 0); + assert.match(usage.message, /missing user id/i); + assert.equal(usage.quotas, undefined); + } finally { + mock.restore(); + } +}); + +test("cursor usage: 307 redirect surfaces as expired session message", async () => { + const accessToken = makeJwt({ sub: "user_01EXPIRED" }); + const mock = installFetchMock( + async () => + new Response("", { + status: 307, + headers: { Location: "https://api.workos.com/user_management/authorize?..." }, + }) + ); + + try { + const usage = await usageService.getUsageForProvider({ + provider: "cursor", + accessToken, + providerSpecificData: { userId: "user_01EXPIRED" }, + }); + + assert.equal(usage.plan, "Cursor"); + assert.match(usage.message, /session expired|Re-import/i); + assert.equal(usage.quotas, undefined); + } finally { + mock.restore(); + } +}); + +test("cursor usage: empty planUsage returns informational message", async () => { + const accessToken = makeJwt({ sub: "user_01EMPTY" }); + const mock = installFetchMock( + async () => + new Response( + JSON.stringify({ billingCycleStart: "0", billingCycleEnd: "0", planUsage: {} }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + + try { + const usage = await usageService.getUsageForProvider({ + provider: "cursor", + accessToken, + providerSpecificData: { userId: "user_01EMPTY" }, + }); + + assert.equal(usage.plan, "Cursor"); + assert.match(usage.message, /No active plan usage/i); + } finally { + mock.restore(); + } +}); diff --git a/tests/unit/db-migration-runner.test.ts b/tests/unit/db-migration-runner.test.ts index a0595ad532..519fcebf74 100644 --- a/tests/unit/db-migration-runner.test.ts +++ b/tests/unit/db-migration-runner.test.ts @@ -857,6 +857,141 @@ test( } ); +test( + "runMigrations ignores superseded 041 session affinity duplicate when 050 exists", + serial, + async () => { + const runner = await importFresh("src/lib/db/migrationRunner.ts"); + const db = createDb(); + + try { + const count = withMockedMigrationFs( + { + "038_compression_analytics.sql": ` + CREATE TABLE compression_analytics ( + id TEXT PRIMARY KEY, + request_id TEXT + ); + `, + "041_compression_receipts.sql": "-- handled by migrationRunner", + "041_session_account_affinity.sql": ` + CREATE TABLE duplicate_041_session_account_affinity (id TEXT PRIMARY KEY); + `, + "050_session_account_affinity.sql": ` + 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) + ); + `, + }, + () => runner.runMigrations(db) + ); + + assert.equal(count, 3); + assert.equal( + db.prepare("SELECT name FROM _omniroute_migrations WHERE version = ?").get("041")?.name, + "compression_receipts" + ); + assert.equal( + db.prepare("SELECT name FROM _omniroute_migrations WHERE version = ?").get("050")?.name, + "session_account_affinity" + ); + assert.deepEqual( + db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (?, ?) ORDER BY name" + ) + .all("duplicate_041_session_account_affinity", "session_account_affinity"), + [{ name: "session_account_affinity" }] + ); + } finally { + db.close(); + } + } +); + +test( + "reconcileRenumberedMigrations moves legacy 041 session affinity marker to 050", + serial, + async () => { + const runner = await importFresh("src/lib/db/migrationRunner.ts"); + const db = createDb(); + + try { + db.exec(` + CREATE TABLE _omniroute_migrations ( + version TEXT PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE compression_analytics ( + id TEXT PRIMARY KEY, + request_id TEXT + ); + `); + db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( + "041", + "session_account_affinity" + ); + + const consoleErrors: string[] = []; + const originalError = console.error; + console.error = (...args: any[]) => { + consoleErrors.push(args.map(String).join(" ")); + }; + + try { + const count = withMockedMigrationFs( + { + "041_compression_receipts.sql": "-- handled by migrationRunner", + "041_session_account_affinity.sql": ` + CREATE TABLE duplicate_041_session_account_affinity (id TEXT PRIMARY KEY); + `, + "050_session_account_affinity.sql": ` + 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) + ); + `, + }, + () => runner.runMigrations(db) + ); + + assert.equal(count, 1); + assert.equal( + db.prepare("SELECT name FROM _omniroute_migrations WHERE version = ?").get("041")?.name, + "compression_receipts" + ); + assert.equal( + db.prepare("SELECT name FROM _omniroute_migrations WHERE version = ?").get("050")?.name, + "session_account_affinity" + ); + + const renumberingWarnings = consoleErrors.filter( + (e) => e.includes("CRITICAL") && e.includes("renumbered") + ); + assert.equal( + renumberingWarnings.length, + 0, + `Expected no renumbering warnings, got: ${renumberingWarnings.join("; ")}` + ); + } finally { + console.error = originalError; + } + } finally { + db.close(); + } + } +); + test( "full upgrade simulation: all 3 renumbered migrations reconciled without CRITICAL warnings", serial, diff --git a/tests/unit/fix-2130-system-passthrough.test.ts b/tests/unit/fix-2130-system-passthrough.test.ts new file mode 100644 index 0000000000..f45fe072f8 --- /dev/null +++ b/tests/unit/fix-2130-system-passthrough.test.ts @@ -0,0 +1,103 @@ +/** + * Regression test for #2130: system prompt missing for Claude Code OAuth on Linux. + * + * Claude Code sends requests to /v1/chat/completions with `body.system` as a + * native Anthropic array (not as role="system" messages). The openai→claude + * translator must preserve body.system when no role="system" messages exist. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.ts"; + +describe("#2130: body.system passthrough in openai→claude translator", () => { + it("preserves body.system array when no role=system messages exist", () => { + const body = { + model: "claude-opus-4-7", + max_tokens: 2048, + stream: false, + messages: [ + { + role: "user", + content: [{ type: "text", text: "hi" }], + }, + ], + system: [ + { + type: "text", + text: "You are Claude Code, Anthropic's official CLI for Claude.", + }, + ], + }; + + const result = openaiToClaudeRequest("claude-opus-4-7", body, false); + + assert.ok(result.system, "result.system must be defined"); + assert.ok(Array.isArray(result.system), "result.system must be an array"); + assert.ok( + result.system.some((b) => b.text && b.text.includes("You are Claude Code")), + "result.system must contain the Claude Code system prompt" + ); + }); + + it("preserves body.system string when no role=system messages exist", () => { + const body = { + model: "claude-sonnet-4-6", + max_tokens: 4096, + messages: [{ role: "user", content: "hello" }], + system: "You are a helpful assistant.", + }; + + const result = openaiToClaudeRequest("claude-sonnet-4-6", body, true); + + assert.ok(result.system, "result.system must be defined"); + assert.ok(Array.isArray(result.system), "result.system must be an array"); + assert.equal(result.system[0].text, "You are a helpful assistant."); + }); + + it("merges body.system with role=system messages", () => { + const body = { + model: "claude-opus-4-7", + max_tokens: 2048, + messages: [ + { role: "system", content: "Be concise." }, + { role: "user", content: "hi" }, + ], + system: [ + { + type: "text", + text: "You are Claude Code, Anthropic's official CLI for Claude.", + }, + ], + }; + + const result = openaiToClaudeRequest("claude-opus-4-7", body, false); + + assert.ok(result.system, "result.system must be defined"); + assert.ok(Array.isArray(result.system), "result.system must be an array"); + // Should have the original body.system + the extracted role=system text + assert.ok( + result.system.some((b) => b.text && b.text.includes("You are Claude Code")), + "must contain the body.system content" + ); + assert.ok( + result.system.some((b) => b.text && b.text.includes("Be concise")), + "must contain the role=system message content" + ); + }); + + it("works correctly when neither body.system nor role=system messages exist", () => { + const body = { + model: "claude-sonnet-4-6", + max_tokens: 2048, + messages: [{ role: "user", content: "hello" }], + }; + + const result = openaiToClaudeRequest("claude-sonnet-4-6", body, true); + + assert.equal( + result.system, + undefined, + "result.system should be undefined when no system input exists" + ); + }); +}); diff --git a/tests/unit/management-auth-hardening.test.ts b/tests/unit/management-auth-hardening.test.ts index 2b14e81a3e..dd9540b3a5 100644 --- a/tests/unit/management-auth-hardening.test.ts +++ b/tests/unit/management-auth-hardening.test.ts @@ -43,3 +43,76 @@ test("compression analytics route requires management authentication before retu content.indexOf("getCompressionAnalyticsSummary(") ); }); + +test("administrative pricing and routing routes require management authentication", () => { + const routePaths = [ + "src/app/api/pricing/route.ts", + "src/app/api/pricing/sync/route.ts", + "src/app/api/model-combo-mappings/route.ts", + "src/app/api/model-combo-mappings/[id]/route.ts", + ]; + + for (const routePath of routePaths) { + const content = fs.readFileSync(routePath, "utf8"); + assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath); + assert.ok( + content.includes("const authError = await requireManagementAuth(request);"), + routePath + ); + assert.ok(content.includes("if (authError) return authError;"), routePath); + } +}); + +test("memory management routes require management authentication", () => { + const routePaths = ["src/app/api/memory/route.ts", "src/app/api/memory/[id]/route.ts"]; + + for (const routePath of routePaths) { + const content = fs.readFileSync(routePath, "utf8"); + assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath); + assert.ok( + content.includes("const authError = await requireManagementAuth(request);"), + routePath + ); + assert.ok(content.includes("if (authError) return authError;"), routePath); + } +}); + +test("provider validation routes require management authentication before reading credentials", () => { + const routePaths = [ + "src/app/api/provider-nodes/validate/route.ts", + "src/app/api/providers/validate/route.ts", + ]; + + for (const routePath of routePaths) { + const content = fs.readFileSync(routePath, "utf8"); + assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath); + assert.ok( + content.includes("const authError = await requireManagementAuth(request);"), + routePath + ); + assert.ok(content.includes("if (authError) return authError;"), routePath); + assert.ok( + content.indexOf("requireManagementAuth(request)") < content.indexOf("request.json()"), + `${routePath} should authenticate before parsing submitted provider credentials` + ); + } +}); + +test("usage analytics and request log routes require management authentication", () => { + const routePaths = [ + "src/app/api/usage/analytics/route.ts", + "src/app/api/usage/history/route.ts", + "src/app/api/usage/request-logs/route.ts", + "src/app/api/usage/logs/route.ts", + ]; + + for (const routePath of routePaths) { + const content = fs.readFileSync(routePath, "utf8"); + assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath); + assert.ok( + content.includes("const authError = await requireManagementAuth(request);"), + routePath + ); + assert.ok(content.includes("if (authError) return authError;"), routePath); + } +}); diff --git a/tests/unit/pack-artifact-policy.test.ts b/tests/unit/pack-artifact-policy.test.ts index e47925474f..21f09440f1 100644 --- a/tests/unit/pack-artifact-policy.test.ts +++ b/tests/unit/pack-artifact-policy.test.ts @@ -73,6 +73,8 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball", "app/open-sse/services/compression/rules/en/filler.json", "app/responses-ws-proxy.mjs", "app/server-ws.mjs", + "bin/cli-commands.mjs", + "bin/cli/index.mjs", "bin/mcp-server.mjs", "bin/nodeRuntimeSupport.mjs", "scripts/native-binary-compat.mjs", diff --git a/tests/unit/provider-header-profiles.test.ts b/tests/unit/provider-header-profiles.test.ts index df7e79b340..46e2f0a0da 100644 --- a/tests/unit/provider-header-profiles.test.ts +++ b/tests/unit/provider-header-profiles.test.ts @@ -2,7 +2,6 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - CURSOR_REGISTRY_VERSION, GITHUB_COPILOT_API_VERSION, GITHUB_COPILOT_CHAT_PLUGIN_VERSION, GITHUB_COPILOT_CHAT_USER_AGENT, @@ -12,7 +11,6 @@ import { KIRO_AMZ_USER_AGENT, KIRO_SDK_USER_AGENT, QWEN_CLI_VERSION, - getCursorUsageHeaders, getQwenCliUserAgent, getGitHubCopilotChatHeaders, getGitHubCopilotInternalUserHeaders, @@ -39,7 +37,7 @@ test("provider header profiles expose current GitHub chat and internal headers", assert.equal(internalHeaders["X-GitHub-Api-Version"], GITHUB_COPILOT_API_VERSION); }); -test("provider header profiles expose dedicated refresh, qwen, qoder, kiro and cursor variants", () => { +test("provider header profiles expose dedicated refresh, qwen, qoder and kiro variants", () => { const refreshHeaders = getGitHubCopilotRefreshHeaders("token gh-access"); assert.equal(refreshHeaders.Authorization, "token gh-access"); assert.equal(refreshHeaders["User-Agent"], GITHUB_COPILOT_REFRESH_USER_AGENT); @@ -65,12 +63,6 @@ test("provider header profiles expose dedicated refresh, qwen, qoder, kiro and c assert.equal(kiroHeaders.Accept, "application/json"); assert.equal(kiroHeaders["User-Agent"], KIRO_SDK_USER_AGENT); assert.equal(kiroHeaders["X-Amz-User-Agent"], KIRO_AMZ_USER_AGENT); - - const cursorHeaders = getCursorUsageHeaders("cursor-token"); - assert.equal(cursorHeaders.Authorization, "Bearer cursor-token"); - assert.equal(cursorHeaders["User-Agent"], `Cursor/${CURSOR_REGISTRY_VERSION}`); - assert.equal(cursorHeaders["x-cursor-user-agent"], `Cursor/${CURSOR_REGISTRY_VERSION}`); - assert.equal(cursorHeaders["x-cursor-client-version"], CURSOR_REGISTRY_VERSION); }); test("provider header profiles tolerate browser-like process shims", async () => { diff --git a/tests/unit/provider-hints.test.ts b/tests/unit/provider-hints.test.ts new file mode 100644 index 0000000000..ddbf966753 --- /dev/null +++ b/tests/unit/provider-hints.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + defaultUseUpstream429BreakerHints, + resolveUseUpstream429BreakerHints, +} from "../../src/shared/utils/providerHints.ts"; + +test("defaultUseUpstream429BreakerHints: direct cloud providers default true", () => { + for (const id of ["openai", "anthropic", "groq", "cerebras", "mistral", "google"]) { + assert.equal(defaultUseUpstream429BreakerHints(id), true, `expected true for ${id}`); + } +}); + +test("defaultUseUpstream429BreakerHints: cliproxyapi defaults false", () => { + assert.equal(defaultUseUpstream429BreakerHints("cliproxyapi"), false); +}); + +test("defaultUseUpstream429BreakerHints: self-hosted chat providers default false", () => { + for (const id of [ + "lm-studio", + "vllm", + "lemonade", + "llamafile", + "triton", + "xinference", + "oobabooga", + ]) { + assert.equal(defaultUseUpstream429BreakerHints(id), false, `expected false for ${id}`); + } +}); + +test("defaultUseUpstream429BreakerHints: claude-code-* prefix defaults false", () => { + for (const id of [ + "anthropic-compatible-cc-direct", + "anthropic-compatible-cc-bedrock", + "anthropic-compatible-cc-vertex", + ]) { + assert.equal(defaultUseUpstream429BreakerHints(id), false, `expected false for ${id}`); + } +}); + +test("resolveUseUpstream429BreakerHints: user override wins (both directions)", () => { + // Cloud provider with user override OFF → false + assert.equal(resolveUseUpstream429BreakerHints("openai", false), false); + // Cloud provider with user override ON → true (no-op vs default) + assert.equal(resolveUseUpstream429BreakerHints("openai", true), true); + // Proxy provider with user override ON → true (user explicitly trusted it) + assert.equal(resolveUseUpstream429BreakerHints("cliproxyapi", true), true); + // Proxy provider with user override OFF → false (no-op vs default) + assert.equal(resolveUseUpstream429BreakerHints("cliproxyapi", false), false); +}); + +test("resolveUseUpstream429BreakerHints: undefined falls back to per-provider default", () => { + // Critical: this is the v3 regression-test for the v1 default-vs-gate bug. + assert.equal(resolveUseUpstream429BreakerHints("openai", undefined), true); + assert.equal(resolveUseUpstream429BreakerHints("cliproxyapi", undefined), false); + assert.equal(resolveUseUpstream429BreakerHints("lm-studio", undefined), false); +}); diff --git a/tests/unit/resilience-settings-upstream429-breaker.test.ts b/tests/unit/resilience-settings-upstream429-breaker.test.ts new file mode 100644 index 0000000000..b22a2d95c9 --- /dev/null +++ b/tests/unit/resilience-settings-upstream429-breaker.test.ts @@ -0,0 +1,96 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + DEFAULT_RESILIENCE_SETTINGS, + mergeResilienceSettings, + resolveResilienceSettings, + type ResilienceSettings, +} from "../../src/lib/resilience/settings.ts"; + +function cloneDefaults(): ResilienceSettings { + // structuredClone is enough for the plain-object settings shape. + return structuredClone(DEFAULT_RESILIENCE_SETTINGS); +} + +test("defaults: useUpstream429BreakerHints omitted (undefined) on both profiles", () => { + const settings = cloneDefaults(); + assert.equal(settings.connectionCooldown.oauth.useUpstream429BreakerHints, undefined); + assert.equal(settings.connectionCooldown.apikey.useUpstream429BreakerHints, undefined); +}); + +test("mergeResilienceSettings: explicit true is stored", () => { + const current = cloneDefaults(); + const next = mergeResilienceSettings(current, { + connectionCooldown: { oauth: { useUpstream429BreakerHints: true } }, + }); + assert.equal(next.connectionCooldown.oauth.useUpstream429BreakerHints, true); + // apikey unchanged + assert.equal(next.connectionCooldown.apikey.useUpstream429BreakerHints, undefined); +}); + +test("mergeResilienceSettings: explicit false is stored", () => { + const current = cloneDefaults(); + const next = mergeResilienceSettings(current, { + connectionCooldown: { apikey: { useUpstream429BreakerHints: false } }, + }); + assert.equal(next.connectionCooldown.apikey.useUpstream429BreakerHints, false); +}); + +test("mergeResilienceSettings: null sentinel deletes the field (back to undefined)", () => { + // Start with explicit false on oauth + const start = mergeResilienceSettings(cloneDefaults(), { + connectionCooldown: { oauth: { useUpstream429BreakerHints: false } }, + }); + assert.equal(start.connectionCooldown.oauth.useUpstream429BreakerHints, false); + // PATCH with null should reset to undefined + const next = mergeResilienceSettings(start, { + connectionCooldown: { + oauth: { useUpstream429BreakerHints: null as unknown as boolean }, + }, + }); + assert.equal(next.connectionCooldown.oauth.useUpstream429BreakerHints, undefined); + // Key should not appear in JSON + const serialized = JSON.parse(JSON.stringify(next.connectionCooldown.oauth)); + assert.equal( + "useUpstream429BreakerHints" in serialized, + false, + "key should be absent in serialized JSON" + ); +}); + +test("mergeResilienceSettings: omitted key (partial-merge) leaves existing value", () => { + // Start with explicit true on apikey + const start = mergeResilienceSettings(cloneDefaults(), { + connectionCooldown: { apikey: { useUpstream429BreakerHints: true } }, + }); + // PATCH oauth only — apikey must keep its value + const next = mergeResilienceSettings(start, { + connectionCooldown: { oauth: { baseCooldownMs: 5000 } }, + }); + assert.equal(next.connectionCooldown.apikey.useUpstream429BreakerHints, true); +}); + +test("resolveResilienceSettings: omitted field in record stays undefined (no toBoolean coercion)", () => { + const record = { + connectionCooldown: { + oauth: { baseCooldownMs: 1000, useUpstreamRetryHints: true, maxBackoffSteps: 5 }, + apikey: { baseCooldownMs: 2000, useUpstreamRetryHints: false, maxBackoffSteps: 3 }, + }, + }; + const resolved = resolveResilienceSettings( + record as Parameters[0] + ); + assert.equal(resolved.connectionCooldown.oauth.useUpstream429BreakerHints, undefined); + assert.equal(resolved.connectionCooldown.apikey.useUpstream429BreakerHints, undefined); +}); + +test("mixed-provider round-trip: oauth=false + apikey=true survives merge", () => { + const next = mergeResilienceSettings(cloneDefaults(), { + connectionCooldown: { + oauth: { useUpstream429BreakerHints: false }, + apikey: { useUpstream429BreakerHints: true }, + }, + }); + assert.equal(next.connectionCooldown.oauth.useUpstream429BreakerHints, false); + assert.equal(next.connectionCooldown.apikey.useUpstream429BreakerHints, true); +}); diff --git a/tests/unit/runtime-timeouts.test.ts b/tests/unit/runtime-timeouts.test.ts index 3ac71b5de6..2a588a63b6 100644 --- a/tests/unit/runtime-timeouts.test.ts +++ b/tests/unit/runtime-timeouts.test.ts @@ -94,3 +94,10 @@ test("API bridge timeouts align request timeout with long proxy timeout by defau serverSocketTimeoutMs: 0, }); }); + +test("API bridge proxy timeout defaults to the long upstream request window", () => { + const config = runtimeTimeouts.getApiBridgeTimeoutConfig({}); + + assert.equal(config.proxyTimeoutMs, 600000); + assert.equal(config.serverRequestTimeoutMs, 600000); +}); diff --git a/tests/unit/shared/components/AutoRoutingBanner.test.tsx b/tests/unit/shared/components/AutoRoutingBanner.test.tsx new file mode 100644 index 0000000000..d7a8153812 --- /dev/null +++ b/tests/unit/shared/components/AutoRoutingBanner.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => { + container.remove(); + }); + return container; +} + +describe("AutoRoutingBanner", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + localStorage.clear(); + }); + + it("renders banner on first mount", async () => { + const { default: AutoRoutingBanner } = await import("@/shared/components/AutoRoutingBanner"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.querySelector('[role="banner"]')).toBeTruthy(); + expect(container.textContent).toContain("Auto-Routing Active"); + }); + + it("includes link to Combos page", async () => { + const { default: AutoRoutingBanner } = await import("@/shared/components/AutoRoutingBanner"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const link = container.querySelector('a[href="/dashboard/combos"]'); + expect(link).toBeTruthy(); + }); + + it("can be dismissed by clicking close button", async () => { + const { default: AutoRoutingBanner } = await import("@/shared/components/AutoRoutingBanner"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.querySelector('[role="banner"]')).toBeTruthy(); + const closeButton = container.querySelector('button[aria-label="Dismiss auto-routing banner"]'); + expect(closeButton).toBeTruthy(); + await act(async () => { + closeButton?.click(); + }); + expect(container.querySelector('[role="banner"]')).toBeFalsy(); + }); + + it("persists dismissal to localStorage", async () => { + const { default: AutoRoutingBanner } = await import("@/shared/components/AutoRoutingBanner"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const closeButton = container.querySelector('button[aria-label="Dismiss auto-routing banner"]'); + await act(async () => { + closeButton?.click(); + }); + expect(localStorage.getItem("auto-routing-banner-dismissed")).toBe("true"); + }); + + it("remains hidden after dismissal on remount", async () => { + localStorage.setItem("auto-routing-banner-dismissed", "true"); + const { default: AutoRoutingBanner } = await import("@/shared/components/AutoRoutingBanner"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.querySelector('[role="banner"]')).toBeFalsy(); + }); +}); diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index 7ac60df468..23f3f5419d 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -14,7 +14,6 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const auth = await import("../../src/sse/services/auth.ts"); const quotaCache = await import("../../src/domain/quotaCache.ts"); -const { COOLDOWN_MS } = await import("../../open-sse/config/constants.ts"); async function resetStorage() { core.resetDbInstance(); @@ -45,7 +44,6 @@ async function seedConnection(provider: string, overrides: any = {}) { lastErrorSource: overrides.lastErrorSource, errorCode: overrides.errorCode, backoffLevel: overrides.backoffLevel, - maxConcurrent: overrides.maxConcurrent, providerSpecificData: overrides.providerSpecificData || {}, lastUsedAt: overrides.lastUsedAt, consecutiveUseCount: overrides.consecutiveUseCount, @@ -135,7 +133,7 @@ test("getProviderCredentials enforces generic quota policy unless explicitly byp }, }); const resetAt = futureIso(); - quotaCache.setQuotaCache((connection as any).id, "openai", { + quotaCache.setQuotaCache(connection.id, "openai", { daily: { remainingPercentage: 10, resetAt }, }); @@ -179,38 +177,6 @@ test("getProviderCredentialsWithQuotaPreflight skips exhausted preflight account assert.equal((selected as any).connectionId, healthy.id); }); -test("getProviderCredentials includes per-account maxConcurrent caps", async () => { - const connection = await seedConnection("openai", { - name: "openai-concurrency-cap", - maxConcurrent: 2, - }); - - const selected = await auth.getProviderCredentials("openai"); - - assert.equal(selected.connectionId, connection.id); - assert.equal(selected.maxConcurrent, 2); -}); - -test("getProviderCredentials skips connections that exclude the requested model and selects the next eligible account", async () => { - const excluded = await seedConnection("openai", { - name: "excluded-first", - priority: 1, - providerSpecificData: { - excludedModels: ["gpt-4o*"], - }, - }); - const allowed = await seedConnection("openai", { - name: "allowed-second", - priority: 2, - apiKey: "sk-allowed", - }); - - const selected = await auth.getProviderCredentials("openai", null, null, "gpt-4o-mini"); - - assert.equal(selected.connectionId, allowed.id); - assert.notEqual(selected.connectionId, excluded.id); -}); - test("getProviderCredentialsWithQuotaPreflight returns allRateLimited when a forced connection is blocked by preflight", async () => { const blocked = await seedConnection("openai", { name: "quota-preflight-forced", @@ -237,6 +203,62 @@ test("getProviderCredentialsWithQuotaPreflight returns allRateLimited when a for assert.match(selected.lastError, /quota preflight/i); }); +test("getProviderCredentials keeps separate codex affinity per session", async () => { + await settingsDb.updateSettings({ fallbackStrategy: "round-robin", stickyRoundRobinLimit: 10 }); + const first = await seedConnection("codex", { + name: "codex-affinity-a", + lastUsedAt: new Date(Date.now() - 20_000).toISOString(), + }); + const second = await seedConnection("codex", { + name: "codex-affinity-b", + lastUsedAt: new Date(Date.now() - 10_000).toISOString(), + }); + + const sessionA1 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-a", + }); + const sessionB1 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-b", + }); + const sessionA2 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-a", + }); + const sessionB2 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-b", + }); + + assert.equal(sessionA1.connectionId, first.id); + assert.equal(sessionB1.connectionId, second.id); + assert.equal(sessionA2.connectionId, first.id); + assert.equal(sessionB2.connectionId, second.id); +}); + +test("getProviderCredentials rebinds codex session when affinity connection is excluded", async () => { + await settingsDb.updateSettings({ fallbackStrategy: "round-robin", stickyRoundRobinLimit: 10 }); + const first = await seedConnection("codex", { + name: "codex-affinity-excluded-a", + lastUsedAt: new Date(Date.now() - 20_000).toISOString(), + }); + const second = await seedConnection("codex", { + name: "codex-affinity-excluded-b", + lastUsedAt: new Date(Date.now() - 10_000).toISOString(), + }); + + const initial = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-excluded", + }); + const rebound = await auth.getProviderCredentials("codex", first.id, null, "gpt-5.5", { + sessionKey: "session-excluded", + }); + const sticky = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-excluded", + }); + + assert.equal(initial.connectionId, first.id); + assert.equal(rebound.connectionId, second.id); + assert.equal(sticky.connectionId, second.id); +}); + test("resolveQuotaLimitPolicy normalizes Codex windows, thresholds, and defaults", () => { const normalized = auth.resolveQuotaLimitPolicy("codex", { limitPolicy: { @@ -264,7 +286,7 @@ test("resolveQuotaLimitPolicy normalizes Codex windows, thresholds, and defaults }); assert.deepEqual(defaults, { enabled: true, - thresholdPercent: 99, + thresholdPercent: 90, windows: ["session", "weekly"], }); assert.deepEqual(generic, { @@ -314,17 +336,17 @@ test("getProviderCredentials round-robin stays on the current account while belo priority: 2, }); - await providersDb.updateProviderConnection((current as any).id, { + await providersDb.updateProviderConnection(current.id, { lastUsedAt: new Date().toISOString(), consecutiveUseCount: 1, }); - await providersDb.updateProviderConnection((other as any).id, { + await providersDb.updateProviderConnection(other.id, { lastUsedAt: new Date(Date.now() - 60_000).toISOString(), consecutiveUseCount: 0, }); const selected = await auth.getProviderCredentials("openai"); - const updated = await providersDb.getProviderConnectionById((current as any).id); + const updated = await providersDb.getProviderConnectionById(current.id); assert.equal(selected.connectionId, current.id); assert.equal(updated.consecutiveUseCount, 2); @@ -428,7 +450,7 @@ test("getProviderCredentials retains terminal accounts for combo live tests", as const bypassed = await auth.getProviderCredentials("openai", null, null, null, { allowSuppressedConnections: true, }); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(blocked, null); assert.equal(bypassed.connectionId, connection.id); @@ -469,15 +491,9 @@ test("getProviderCredentials reports allRateLimited when every account is model- name: "gemini-model-lock-second", }); + await auth.markAccountUnavailable(first.id, 429, "too many requests", "gemini", "gemini-2.5-pro"); await auth.markAccountUnavailable( - (first as any).id, - 429, - "too many requests", - "gemini", - "gemini-2.5-pro" - ); - await auth.markAccountUnavailable( - (second as any).id, + second.id, 429, "too many requests", "gemini", @@ -504,7 +520,7 @@ test("getProviderCredentials auto-decays stale backoff metadata for recovered ac const selected = await auth.getProviderCredentials("openai"); await flushWrites(); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(selected.connectionId, connection.id); assert.equal(updated.backoffLevel, 0); @@ -522,7 +538,7 @@ test("getProviderCredentials falls back to a five-minute retry window when quota }, }); - quotaCache.setQuotaCache((connection as any).id, "openai", { + quotaCache.setQuotaCache(connection.id, "openai", { daily: { remainingPercentage: 0, resetAt: null }, }); @@ -547,10 +563,10 @@ test("getProviderCredentials prioritizes accounts that still have quota availabl apiKey: "sk-available", }); - quotaCache.setQuotaCache((exhausted as any).id, "openai", { + quotaCache.setQuotaCache(exhausted.id, "openai", { daily: { remainingPercentage: 0, resetAt: futureIso() }, }); - quotaCache.setQuotaCache((available as any).id, "openai", { + quotaCache.setQuotaCache(available.id, "openai", { daily: { remainingPercentage: 65, resetAt: futureIso() }, }); @@ -574,17 +590,17 @@ test("getProviderCredentials round-robin switches to the least recently used acc priority: 2, }); - await providersDb.updateProviderConnection((current as any).id, { + await providersDb.updateProviderConnection(current.id, { lastUsedAt: new Date().toISOString(), consecutiveUseCount: 2, }); - await providersDb.updateProviderConnection((fallback as any).id, { + await providersDb.updateProviderConnection(fallback.id, { lastUsedAt: new Date(Date.now() - 120_000).toISOString(), consecutiveUseCount: 0, }); const selected = await auth.getProviderCredentials("openai"); - const updated = await providersDb.getProviderConnectionById((fallback as any).id); + const updated = await providersDb.getProviderConnectionById(fallback.id); assert.equal(selected.connectionId, fallback.id); assert.equal(updated.consecutiveUseCount, 1); @@ -604,17 +620,17 @@ test("getProviderCredentials round-robin fallback mode excludes the failed accou priority: 2, }); - await providersDb.updateProviderConnection((failed as any).id, { + await providersDb.updateProviderConnection(failed.id, { lastUsedAt: new Date().toISOString(), consecutiveUseCount: 3, }); - await providersDb.updateProviderConnection((fallback as any).id, { + await providersDb.updateProviderConnection(fallback.id, { lastUsedAt: new Date(Date.now() - 120_000).toISOString(), consecutiveUseCount: 0, }); - const selected = await auth.getProviderCredentials("openai" as any, (failed as any).id); - const updated = await providersDb.getProviderConnectionById((fallback as any).id); + const selected = await auth.getProviderCredentials("openai", failed.id); + const updated = await providersDb.getProviderConnectionById(fallback.id); assert.equal(selected.connectionId, fallback.id); assert.equal(updated.consecutiveUseCount, 1); @@ -644,10 +660,10 @@ test("getProviderCredentials least-used prefers accounts that were never used", name: "least-used-never", priority: 9, }); - await providersDb.updateProviderConnection((recentlyUsed as any).id, { + await providersDb.updateProviderConnection(recentlyUsed.id, { lastUsedAt: new Date().toISOString(), }); - await providersDb.updateProviderConnection((neverUsed as any).id, { + await providersDb.updateProviderConnection(neverUsed.id, { lastUsedAt: null, }); @@ -668,10 +684,10 @@ test("getProviderCredentials least-used prefers the oldest timestamp when all ac priority: 1, }); - await providersDb.updateProviderConnection((oldest as any).id, { + await providersDb.updateProviderConnection(oldest.id, { lastUsedAt: new Date(Date.now() - 120_000).toISOString(), }); - await providersDb.updateProviderConnection((newest as any).id, { + await providersDb.updateProviderConnection(newest.id, { lastUsedAt: new Date().toISOString(), }); @@ -723,10 +739,10 @@ test("getProviderCredentials p2c prefers the account with more quota headroom ov }, }); - (quotaCache as any).setQuotaCache(nearLimit.id, "openai", { + quotaCache.setQuotaCache(nearLimit.id, "openai", { daily: { remainingPercentage: 12, resetAt: futureIso(180_000) }, }); - (quotaCache as any).setQuotaCache(healthy.id, "openai", { + quotaCache.setQuotaCache(healthy.id, "openai", { daily: { remainingPercentage: 78, resetAt: futureIso(180_000) }, }); @@ -786,7 +802,7 @@ test("getProviderCredentials exposes copilotToken when present in providerSpecif assert.equal(selected.copilotToken, "copilot-token-value"); }); -test("markAccountUnavailable keeps local 404 failures model-scoped with the local not-found cooldown", async () => { +test("markAccountUnavailable uses configured cooldowns for local 404 model lockouts", async () => { await settingsDb.updateSettings({ providerProfiles: { apikey: { @@ -807,13 +823,13 @@ test("markAccountUnavailable keeps local 404 failures model-scoped with the loca }); const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 404, "model not found", "openai", "local-model" ); - const updated = await (providersDb as any).getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.equal(result.cooldownMs, 250); @@ -829,14 +845,14 @@ test("markAccountUnavailable applies a model-only lockout for Gemini 429 respons }); const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 429, "too many requests", "gemini", "gemini-2.5-pro" ); await flushWrites(); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); @@ -852,14 +868,14 @@ test("markAccountUnavailable applies a model-only lockout for compatible provide }); const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 429, "The upstream compatible service exhausted its capacity", "openai-compatible-custom-node", "custom-model-a" ); await flushWrites(); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); @@ -869,7 +885,7 @@ test("markAccountUnavailable applies a model-only lockout for compatible provide assert.equal(Number(updated.errorCode), 429); }); -test("markAccountUnavailable uses the unified configured api-key connection cooldown", async () => { +test("markAccountUnavailable honors configured api-key rate-limit cooldowns", async () => { await settingsDb.updateSettings({ providerProfiles: { apikey: { @@ -887,7 +903,7 @@ test("markAccountUnavailable uses the unified configured api-key connection cool }); const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 429, "too many requests", "openai", @@ -909,20 +925,20 @@ test("markAccountUnavailable stores Codex scope-specific cooldowns without a glo }); const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 429, "quota reached", "codex", "codex-spark-mini" ); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); const selected = await auth.getProviderCredentials("codex", null, null, "codex-spark-mini"); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); assert.equal(updated.testStatus, "unavailable"); assert.equal(updated.rateLimitedUntil, undefined); - assert.ok((updated.providerSpecificData as any).codexScopeRateLimitedUntil.spark); + assert.ok(updated.providerSpecificData.codexScopeRateLimitedUntil.spark); assert.equal(selected.allRateLimited, true); }); @@ -932,13 +948,13 @@ test("markAccountUnavailable returns without fallback on bad requests", async () }); const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 400, "schema mismatch", "openai", "gpt-4o" ); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.deepEqual(result, { shouldFallback: false, cooldownMs: 0 }); assert.equal(updated.testStatus, "active"); @@ -952,13 +968,8 @@ test("markAccountUnavailable preserves terminal statuses without overwriting the rateLimitedUntil: null, }); - const result = await auth.markAccountUnavailable( - (connection as any).id, - 503, - "upstream error", - "openai" - ); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const result = await auth.markAccountUnavailable(connection.id, 503, "upstream error", "openai"); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.equal(result.cooldownMs, 0); @@ -973,13 +984,8 @@ test("markAccountUnavailable reuses an existing connection-wide cooldown", async rateLimitedUntil: retryAfter, }); - const result = await auth.markAccountUnavailable( - (connection as any).id, - 503, - "upstream error", - "openai" - ); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const result = await auth.markAccountUnavailable(connection.id, 503, "upstream error", "openai"); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); @@ -1003,21 +1009,18 @@ test("markAccountUnavailable reuses an existing Codex scope cooldown", async () }); const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 429, "quota reached", "codex", "codex-spark-mini" ); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); assert.equal(updated.rateLimitedUntil, undefined); - (assert as any).equal( - (updated.providerSpecificData as any).codexScopeRateLimitedUntil.spark, - retryAfter - ); + assert.equal(updated.providerSpecificData.codexScopeRateLimitedUntil.spark, retryAfter); }); test("markAccountUnavailable uses a connection-wide cooldown for non-local 404 errors", async () => { @@ -1029,13 +1032,13 @@ test("markAccountUnavailable uses a connection-wide cooldown for non-local 404 e }); const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 404, "model not found", "openai", "gpt-missing" ); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); @@ -1050,17 +1053,17 @@ test("markAccountUnavailable auto-disables permanently banned accounts when the }); const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 401, "Verify your account to continue", "openai", "gpt-4o" ); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, false); - assert.equal(updated.testStatus, "banned"); + assert.equal(updated.testStatus, "unavailable"); }); test("markAccountUnavailable leaves permanently banned accounts active when auto-disable is disabled", async () => { @@ -1070,17 +1073,17 @@ test("markAccountUnavailable leaves permanently banned accounts active when auto }); const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 401, "Verify your account to continue", "openai", "gpt-4o" ); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, true); - assert.equal(updated.testStatus, "banned"); + assert.equal(updated.testStatus, "unavailable"); }); test("markAccountUnavailable swallows auto-disable persistence errors", async () => { @@ -1114,17 +1117,17 @@ test("markAccountUnavailable swallows auto-disable persistence errors", async () try { const result = await auth.markAccountUnavailable( - (connection as any).id, + connection.id, 401, "Verify your account to continue", "openai", "gpt-4o" ); - const updated = await providersDb.getProviderConnectionById((connection as any).id); + const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, true); - assert.equal(updated.testStatus, "banned"); + assert.equal(updated.testStatus, "unavailable"); } finally { db.prepare = originalPrepare; } diff --git a/tests/unit/sse-parser.test.ts b/tests/unit/sse-parser.test.ts index 82b4fd655f..3d8bbfdf7f 100644 --- a/tests/unit/sse-parser.test.ts +++ b/tests/unit/sse-parser.test.ts @@ -114,6 +114,26 @@ test("parseSSEToClaudeResponse parses text, thinking, tool_use, and usage events assert.deepEqual(parsed.usage, { input_tokens: 10, output_tokens: 4 }); }); +test("parseSSEToClaudeResponse tolerates event-only types and missing blank separators", () => { + const rawSSE = [ + "event: message_start", + 'data: {"message":{"id":"msg_event_fallback","model":"claude-sonnet-4-6","role":"assistant","usage":{"input_tokens":3}}}', + "event: content_block_delta", + 'data: {"index":0,"delta":{"text":"event fallback ok"}}', + "event: message_delta", + 'data: {"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}', + "event: message_stop", + "data: {}", + ].join("\n"); + + const parsed = parseSSEToClaudeResponse(rawSSE, "fallback-model"); + + assert.equal(parsed.id, "msg_event_fallback"); + assert.equal(parsed.model, "claude-sonnet-4-6"); + assert.equal((parsed.content[0] as any).text, "event fallback ok"); + assert.deepEqual(parsed.usage, { input_tokens: 3, output_tokens: 2 }); +}); + test("parseSSEToClaudeResponse ignores malformed payloads and returns null when nothing valid remains", () => { const parsed = parseSSEToClaudeResponse( ["event: content_block_delta", "data: not-json", "", "data: [DONE]"].join("\n"), diff --git a/tests/unit/stream-readiness.test.ts b/tests/unit/stream-readiness.test.ts index a86c37dd31..15eb22654d 100644 --- a/tests/unit/stream-readiness.test.ts +++ b/tests/unit/stream-readiness.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { ensureStreamReadiness, + hasStreamReadinessSignal, hasUsefulStreamContent, } from "../../open-sse/utils/streamReadiness.ts"; @@ -20,6 +21,46 @@ function streamFromChunks(chunks: string[], delayMs = 0): ReadableStream { + return new ReadableStream({ + async start(controller) { + controller.enqueue( + encoder.encode( + [ + "event: message_start", + `data: ${JSON.stringify({ + message: { + id: "msg_1", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + usage: { input_tokens: 10, output_tokens: 0 }, + }, + })}`, + "", + ].join("\n") + ) + ); + + await new Promise((resolve) => setTimeout(resolve, 30)); + controller.enqueue( + encoder.encode( + [ + "event: content_block_delta", + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "slow hello" }, + })}`, + "", + ].join("\n") + ) + ); + controller.close(); + }, + }); +} + test("hasUsefulStreamContent ignores keepalives and lifecycle-only chunks", () => { assert.equal(hasUsefulStreamContent(": keepalive\n\n"), false); assert.equal(hasUsefulStreamContent("event: ping\ndata: {}\n\n"), false); @@ -80,6 +121,51 @@ test("hasUsefulStreamContent detects text, reasoning, and tool deltas", () => { ); }); +test("hasStreamReadinessSignal accepts Claude stream start events", () => { + assert.equal(hasStreamReadinessSignal(": keepalive\n\n"), false); + assert.equal(hasStreamReadinessSignal("event: ping\ndata: {}\n\n"), false); + assert.equal( + hasStreamReadinessSignal(`data: ${JSON.stringify({ type: "response.created" })}\n\n`), + false + ); + assert.equal( + hasStreamReadinessSignal( + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_1", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + }, + })}\n\n` + ), + true + ); + assert.equal( + hasStreamReadinessSignal( + `event: message_start\ndata: ${JSON.stringify({ + message: { + id: "msg_2", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + }, + })}\n\n` + ), + true + ); + assert.equal( + hasStreamReadinessSignal( + `event: content_block_start\ndata: ${JSON.stringify({ + index: 0, + content_block: { type: "text", text: "" }, + })}\n\n` + ), + true + ); +}); + test("ensureStreamReadiness preserves buffered chunks when stream starts", async () => { const response = new Response( streamFromChunks([ @@ -98,6 +184,23 @@ test("ensureStreamReadiness preserves buffered chunks when stream starts", async assert.match(text, / world/); }); +test("ensureStreamReadiness hands off long Claude streams after message_start", async () => { + const response = new Response(delayedClaudeStartStream(), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + + const result = await ensureStreamReadiness(response, { + timeoutMs: 10, + provider: "anthropic-compatible-cc-test", + model: "claude-sonnet-4-6", + }); + assert.equal(result.ok, true); + const text = await result.response.text(); + assert.match(text, /message_start/); + assert.match(text, /slow hello/); +}); + test("ensureStreamReadiness returns 504 when no useful content arrives before timeout", async () => { const response = new Response( streamFromChunks( diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index 3b77cff9dd..3e2d19e404 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -1119,68 +1119,6 @@ test("usage service treats MiniMax token-plan counts as used usage", async () => assert.ok(Date.parse(usage.quotas["session (5h)"].resetAt) >= beforeCall + 240_000); }); -test("usage service parses Cursor team quotas and clamps on-demand ratio", async () => { - const calls: any[] = []; - globalThis.fetch = async (url, init = {}) => { - calls.push({ url: String(url), init }); - - if (String(url).endsWith("/api/usage")) { - return new Response( - JSON.stringify({ - numRequestsTotal: 450, - hard_limit: 100, - teamMaxRequestUsage: 500, - onDemand: { - numRequests: 600, - }, - }), - { status: 200 } - ); - } - - if (String(url).endsWith("/api/auth/me")) { - return new Response( - JSON.stringify({ - plan: "team", - teamInfo: { id: "team-1", name: "Core Team" }, - }), - { status: 200 } - ); - } - - if (String(url).endsWith("/api/subscription")) { - return new Response( - JSON.stringify({ - teamMaxMonthlyRequests: 500, - }), - { status: 200 } - ); - } - - throw new Error(`unexpected fetch: ${url}`); - }; - - const usage: any = await usageService.getUsageForProvider({ - provider: "cursor", - accessToken: "cursor-token", - }); - - assert.equal(calls.length, 3); - for (const call of calls) { - assert.equal(call.init.headers.Authorization, "Bearer cursor-token"); - assert.equal(call.init.headers["User-Agent"], "Cursor/3.3"); - assert.equal(call.init.headers["x-cursor-client-version"], "3.3"); - } - - assert.equal(usage.plan, "Cursor Team"); - assert.equal(usage.quotas.requests.total, 500); - assert.equal(usage.quotas.requests.used, 450); - assert.equal(usage.quotas.requests.remainingPercentage, 10); - assert.equal(usage.quotas.on_demand.total, 500); - assert.equal(usage.quotas.on_demand.used, 500); - assert.equal(usage.quotas.on_demand.remainingPercentage, 0); -}); - test("usage helper branches cover reset parsing, GitHub quota math, and plan inference fallbacks", () => { const fixedDate = new Date("2026-01-02T03:04:05.000Z"); @@ -1279,32 +1217,6 @@ test("usage helper branches cover reset parsing, GitHub quota math, and plan inf "Copilot Student" ); assert.equal(__testing.inferGitHubPlanName({}, null), "GitHub Copilot"); - - assert.deepEqual(__testing.buildCursorUsageHeaders("cursor-token"), { - Authorization: "Bearer cursor-token", - Accept: "application/json", - "User-Agent": "Cursor/3.3", - "x-cursor-client-version": "3.3", - "x-cursor-user-agent": "Cursor/3.3", - }); - assert.equal( - __testing.getCursorMonthlyRequestLimit( - { hard_limit: 100, teamMaxRequestUsage: 400 }, - { teamMaxMonthlyRequests: 500 } - ), - 500 - ); - assert.equal(__testing.getCursorOnDemandLimit({ onDemand: { maxRequests: 120 } }, {}), 120); - assert.deepEqual(__testing.formatCursorQuota(150, 100, null), { - used: 100, - total: 100, - remaining: 0, - remainingPercentage: 0, - resetAt: null, - unlimited: false, - }); - assert.equal(__testing.inferCursorPlanName({ teamInfo: { id: "team-1" } }, {}), "Cursor Team"); - assert.equal(__testing.inferCursorPlanName({ plan: "pro" }, {}), "Cursor Pro"); }); test("usage helper branches cover Gemini CLI and Antigravity plan label fallbacks", () => { diff --git a/vitest.config.ts b/vitest.config.ts index 8fb8eb8c6e..95d3dc286f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "src/lib/memory/__tests__/**/*.test.ts", "src/lib/skills/__tests__/**/*.test.ts", "tests/unit/encryption.test.ts", + "tests/unit/**/*.test.tsx", "open-sse/**/__tests__/**/*.test.ts", "open-sse/services/**/__tests__/**/*.test.ts", "tests/e2e/ecosystem.test.ts", diff --git a/vitest.mcp.config.ts b/vitest.mcp.config.ts index da4324af0b..acd2360f04 100644 --- a/vitest.mcp.config.ts +++ b/vitest.mcp.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ "open-sse/mcp-server/__tests__/**/*.test.ts", "open-sse/services/autoCombo/__tests__/**/*.test.ts", "tests/unit/encryption.spec.ts", + "src/shared/components/**/*.test.tsx", + "src/shared/hooks/__tests__/**/*.test.tsx", + "src/app/(dashboard)/**/__tests__/**/*.test.tsx", ], exclude: ["**/node_modules/**", "**/.git/**"], coverage: {