diff --git a/.env.example b/.env.example index 089b87379c..aedff9f46b 100644 --- a/.env.example +++ b/.env.example @@ -387,6 +387,13 @@ ANTIGRAVITY_OAUTH_CLIENT_SECRET=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 # ── GitHub Copilot ── GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 +# ── GitLab Duo ── +# Register an OAuth app at: https://gitlab.com/-/profile/applications +# Set redirect URI to: http://localhost:20128/callback (or your NEXT_PUBLIC_BASE_URL + /callback) +# Required scopes: api, read_user, openid, profile, email +# GITLAB_DUO_OAUTH_CLIENT_ID=*** +# GITLAB_DUO_OAUTH_CLIENT_SECRET=*** # optional — PKCE flow does not require a secret + # ── Qoder ── QODER_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW diff --git a/CHANGELOG.md b/CHANGELOG.md index bb34a7aef6..75dbc00548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,32 @@ ## [Unreleased] +## [3.8.0] — 2026-05-06 + +### ✨ New Features + +- **feat(gemini-cli):** add custom projectId support for Gemini CLI transport (UI, DB, executor) (#1991) + +### 🐛 Bug Fixes + +- **fix:** remove Anthropic-Beta header from non-Anthropic providers to fix identity contamination (#1989) +- **fix(cli):** resolve .env loading failure for global npm installations + +### 🔒 Security + +- **fix(security):** remediate regex validation backtracking path in core compression cleanup (#1990) +- **fix(core):** harden input handling and stabilization for prompt compression edge cases + +### 🧹 Chores & Maintenance + +- **chore(providers):** prune redundant local provider icon assets in favor of `@lobehub/icons` web fonts (#1992) +- **ci:** skip SonarCloud scan on main pushes to optimize CI time +- **test:** stabilize cooldown abort coverage case in integration testing + ## [3.7.9] — 2026-05-03 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -82,6 +105,7 @@ ## [3.7.8] — 2026-05-01 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -120,6 +144,7 @@ ## [3.7.7] — 2026-04-30 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -151,6 +176,7 @@ ## [3.7.6] — 2026-04-30 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -253,6 +279,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.7.5] — 2026-04-29 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -304,6 +331,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.7.4] — 2026-04-28 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -366,6 +394,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.7.2] — 2026-04-28 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -441,6 +470,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.7.1] — 2026-04-26 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -488,6 +518,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.7.0] — 2026-04-26 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -652,6 +683,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.9] — 2026-04-19 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -740,6 +772,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.8] — 2026-04-17 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -826,6 +859,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.6] — 2026-04-15 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -908,6 +942,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.5] — 2026-04-13 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -978,6 +1013,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.4] — 2026-04-12 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1084,6 +1120,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.3] — 2026-04-11 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1128,6 +1165,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.2] — 2026-04-11 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1166,6 +1204,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.1] — 2026-04-10 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1201,6 +1240,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.0] — 2026-04-10 ### ✨ New Features & Analytics + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1237,6 +1277,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.9] — 2026-04-09 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1276,6 +1317,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.8] — 2026-04-09 ### ✨ New Features & Analytics + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1334,6 +1376,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.6] — 2026-04-09 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1386,6 +1429,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.5] — 2026-04-08 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1439,6 +1483,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.4] — 2026-04-07 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1533,6 +1578,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.2] — 2026-04-05 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1571,6 +1617,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.1] — 2026-04-04 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1605,6 +1652,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.0] — 2026-04-03 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1715,6 +1763,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.4.6] - 2026-04-02 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1755,6 +1804,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.4.5] - 2026-04-02 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1823,6 +1873,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.4.3] - 2026-04-02 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -1889,6 +1940,7 @@ We identified that **155 community PRs** across the entire project history (from > On the first startup after upgrading, OmniRoute archives legacy request logs from `DATA_DIR/logs/`, legacy `DATA_DIR/call_logs/`, and `DATA_DIR/log.txt` into `DATA_DIR/log_archives/*.zip`, then removes the deprecated layout and switches to the new unified artifact format under `DATA_DIR/call_logs/`. ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2069,6 +2121,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.3.5] - 2026-03-30 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2105,6 +2158,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.3.4] - 2026-03-30 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2172,6 +2226,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.3.2] - 2026-03-29 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2388,6 +2443,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.2.2] — 2026-03-29 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2422,6 +2478,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.2.1] — 2026-03-29 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2460,6 +2517,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.2.0] — 2026-03-28 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2520,6 +2578,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.1.9] — 2026-03-28 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2721,6 +2780,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.1.1] — 2026-03-26 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2761,6 +2821,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.1.0] — 2026-03-26 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2875,6 +2936,7 @@ We identified that **155 community PRs** across the entire project history (from - **Proxy Test:** Test endpoint now resolves real credentials from DB via proxyId ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2917,6 +2979,7 @@ We identified that **155 community PRs** across the entire project history (from - **Settings:** Proxy test button now shows success/failure results immediately (previously hidden behind health data) ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2941,6 +3004,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.0.5] — 2026-03-25 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -2982,6 +3046,7 @@ We identified that **155 community PRs** across the entire project history (from ## [3.0.3] — 2026-03-25 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -3433,6 +3498,7 @@ docker pull diegosouzapw/omniroute:3.0.0 ## [3.0.0-rc.16] — 2026-03-24 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -3454,6 +3520,7 @@ docker pull diegosouzapw/omniroute:3.0.0 ## [3.0.0-rc.15] — 2026-03-24 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -3557,6 +3624,7 @@ docker pull diegosouzapw/omniroute:3.0.0 ## [3.0.0-rc.9] — 2026-03-23 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -3610,6 +3678,7 @@ Both providers use the new `OpencodeExecutor` with multi-format routing (`/chat/ --- ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -3751,6 +3820,7 @@ OmniRoute now automatically refreshes model lists for connected providers every ## [3.0.0-rc.5] - 2026-03-22 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -3780,6 +3850,7 @@ OmniRoute now automatically refreshes model lists for connected providers every ## [3.0.0-rc.4] - 2026-03-22 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -3802,6 +3873,7 @@ OmniRoute now automatically refreshes model lists for connected providers every ## [3.0.0-rc.3] - 2026-03-22 ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -4017,6 +4089,7 @@ OmniRoute now automatically refreshes model lists for connected providers every > Sprint: Cross-platform machineId fix, per-API-key rate limits, streaming context cache, Alibaba DashScope, search analytics, ZWS v5, and 8 issues closed. ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -4490,6 +4563,7 @@ OmniRoute now automatically refreshes model lists for connected providers every > Sprint: Unified web search routing (POST /v1/search) with 5 providers + Next.js 16.1.7 security fixes (6 CVEs). ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -4716,6 +4790,7 @@ OmniRoute now automatically refreshes model lists for connected providers every > Sprint: reasoning model param filtering, local provider 404 fix, Kilo Gateway provider, dependency bumps. ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -5001,6 +5076,7 @@ OmniRoute now automatically refreshes model lists for connected providers every - **fix(electron) #379**: New `scripts/prepare-electron-standalone.mjs` stages a dedicated `/.next/electron-standalone` bundle before Electron packaging. Aborts with a clear error if `node_modules` is a symlink (electron-builder would ship a runtime dependency on the build machine). Cross-platform path sanitization via `path.basename`. By @kfiramar. ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -5072,6 +5148,7 @@ OmniRoute now automatically refreshes model lists for connected providers every > Codex account quota policy with auto-rotation, fast tier toggle, gpt-5.4 model, and analytics label fix. ### ✨ New Features (PRs #366, #367, #368) + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -5106,6 +5183,7 @@ OmniRoute now automatically refreshes model lists for connected providers every > Major release: strict-random routing strategy, API key access controls, connection groups, external pricing sync, and critical bug fixes for thinking models, combo testing, and tool name validation. ### ✨ New Features (PRs #363 & #365) + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -5148,6 +5226,7 @@ OmniRoute now automatically refreshes model lists for connected providers every > API Key Round-Robin support for multi-key provider setups, and confirmation of wildcard routing and quota window rolling already in place. ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -5171,6 +5250,7 @@ OmniRoute now automatically refreshes model lists for connected providers every > UI polish, routing strategy additions, and graceful error handling for usage limits. ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -5207,6 +5287,7 @@ OmniRoute now automatically refreshes model lists for connected providers every > Multiple improvements from community issue analysis, new provider support, bug fixes for token tracking, model routing, and streaming reliability. ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) @@ -5336,6 +5417,7 @@ OmniRoute now automatically refreshes model lists for connected providers every - **Tier Scoring (API + Validation)**: Added `tierPriority` (weight `0.05`) to the `ScoringWeights` Zod schema and the `combos/auto` API route — the 7th scoring factor is now fully accepted by the REST API and validated on input. `stability` weight adjusted from `0.10` to `0.05` to keep total sum = `1.0`. ### ✨ New Features + - **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) - **feat(settings):** add request body limit setting (#1968) - **feat(auth):** add Gemini CLI OAuth client secret default (#1974) diff --git a/CLAUDE.md b/CLAUDE.md index 9478629f7a..3859b9c779 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,141 @@ API routes follow a consistent pattern: `Route → CORS preflight → Zod body v --- +## Resilience Runtime State + +OmniRoute has three related but distinct temporary-failure mechanisms. Keep their +scope separate when debugging routing behavior. + +### Provider Circuit Breaker + +**Scope**: whole provider, e.g. `glm`, `openai`, `anthropic`. + +**Purpose**: stop sending traffic to a provider that is repeatedly failing at the +upstream/service level, so one unhealthy provider does not slow down every request. + +**Implementation**: + +- Core class: `src/shared/utils/circuitBreaker.ts` +- Chat gate/execution wiring: `src/sse/handlers/chatHelpers.ts`, `src/sse/handlers/chat.ts` +- Runtime status API: `src/app/api/monitoring/health/route.ts` +- Shared wrappers: `open-sse/services/accountFallback.ts` +- Persisted state table: `domain_circuit_breakers` + +**States**: + +- `CLOSED`: normal traffic is allowed. +- `OPEN`: provider is temporarily blocked; callers get a provider-circuit-open response + or combo routing skips to another target. +- `HALF_OPEN`: reset timeout has elapsed; allow a probe request. Success closes the + breaker, failure opens it again. + +**Defaults** (`open-sse/config/constants.ts`): + +- OAuth providers: threshold `3`, reset timeout `60s`. +- API-key providers: threshold `5`, reset timeout `30s`. +- Local providers: threshold `2`, reset timeout `15s`. + +Only provider-level failure statuses should trip the provider breaker: + +```ts +(408, 500, 502, 503, 504); +``` + +Do not trip the whole-provider breaker for normal account/key/model errors like most +`401`, `403`, or `429` cases. Those usually belong to connection cooldown or model +lockout. A generic API-key provider `403` should be recoverable unless it is classified +as a terminal provider/account error. + +The breaker uses lazy recovery, not a background timer. When `OPEN` expires, reads such +as `getStatus()`, `canExecute()`, and `getRetryAfterMs()` refresh the state to +`HALF_OPEN`, so dashboards and combo candidate builders do not keep excluding an +expired provider forever. + +### Connection Cooldown + +**Scope**: one provider connection/account/key. + +**Purpose**: temporarily skip one bad key/account while allowing other connections for +the same provider to continue serving requests. + +**Implementation**: + +- Write/update path: `src/sse/services/auth.ts::markAccountUnavailable()` +- Account selection/filtering: `src/sse/services/auth.ts::getProviderCredentials...` +- Cooldown calculation: `open-sse/services/accountFallback.ts::checkFallbackError()` +- Settings: `src/lib/resilience/settings.ts` + +Important fields on provider connections: + +```ts +rateLimitedUntil; +testStatus: "unavailable"; +lastError; +lastErrorType; +errorCode; +backoffLevel; +``` + +During account selection, a connection is skipped while: + +```ts +new Date(rateLimitedUntil).getTime() > Date.now(); +``` + +Cooldowns are also lazy: when `rateLimitedUntil` is in the past, the connection becomes +eligible again. On successful use, `clearAccountError()` clears `testStatus`, +`rateLimitedUntil`, error fields, and `backoffLevel`. + +Default connection cooldown behavior: + +- OAuth base cooldown: `5s`. +- API-key base cooldown: `3s`. +- API-key `429` should prefer upstream retry hints (`Retry-After`, reset headers, or + parseable reset text) when available. +- Repeated recoverable failures use exponential backoff: + +```ts +baseCooldownMs * 2 ** failureIndex; +``` + +The anti-thundering-herd guard prevents concurrent failures on the same connection from +repeatedly extending the cooldown or double-incrementing `backoffLevel`. + +Terminal states are not cooldowns. `banned`, `expired`, and `credits_exhausted` are +intended to stay unavailable until credentials/settings change or an operator resets +them. Do not overwrite terminal states with transient cooldown state. + +### Model Lockout + +**Scope**: provider + connection + model. + +**Purpose**: avoid disabling a whole connection when only one model is unavailable or +quota-limited for that connection. + +Examples: + +- Per-model quota providers returning `429`. +- Local providers returning `404` for one missing model. +- Provider-specific mode/model permission failures such as selected Grok modes. + +Model lockout lives in `open-sse/services/accountFallback.ts` and lets the same +connection continue serving other models. + +### Debugging Guidance + +- If all keys for a provider are skipped, inspect both provider breaker state and each + connection's `rateLimitedUntil`/`testStatus`. +- If a provider appears permanently excluded after the reset window, check whether code + is reading raw `state` instead of using `getStatus()`/`canExecute()`. +- If one provider key fails but others should work, prefer connection cooldown over + provider breaker. +- If only one model fails, prefer model lockout over connection cooldown. +- If a state should self-recover, it should have a future timestamp/reset timeout and a + read path that refreshes expired state. Permanent statuses require manual credential + or config changes. + +--- + ## Key Conventions ### Code Style diff --git a/Dockerfile b/Dockerfile index aa217ce78a..8b6ad85af8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,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 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 db89bddd94..43145a1279 100644 --- a/README.md +++ b/README.md @@ -136,28 +136,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K @@ -1519,4 +1519,3 @@ MIT License - see [LICENSE](LICENSE) for details. omniroute.online - diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index f0c5bcec44..a646e08961 100644 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -44,6 +44,7 @@ function loadEnvFile() { } envPaths.push(join(process.cwd(), ".env")); + envPaths.push(join(ROOT, ".env")); for (const envPath of envPaths) { try { diff --git a/docs/i18n/ar/README.md b/docs/i18n/ar/README.md index 2211b7667c..2979af725a 100644 --- a/docs/i18n/ar/README.md +++ b/docs/i18n/ar/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/bg/README.md b/docs/i18n/bg/README.md index a0a3ef15ea..e8812921d9 100644 --- a/docs/i18n/bg/README.md +++ b/docs/i18n/bg/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/bn/README.md b/docs/i18n/bn/README.md index 1a470699f2..bc77d9bbcf 100644 --- a/docs/i18n/bn/README.md +++ b/docs/i18n/bn/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/cs/README.md b/docs/i18n/cs/README.md index 5772362c7e..50f1ba2567 100644 --- a/docs/i18n/cs/README.md +++ b/docs/i18n/cs/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/da/README.md b/docs/i18n/da/README.md index 7c91da93a1..9a88f9af24 100644 --- a/docs/i18n/da/README.md +++ b/docs/i18n/da/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/de/README.md b/docs/i18n/de/README.md index 070d1ee611..2db62bf6c2 100644 --- a/docs/i18n/de/README.md +++ b/docs/i18n/de/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/es/README.md b/docs/i18n/es/README.md index 83c53d250a..6ecc419bbd 100644 --- a/docs/i18n/es/README.md +++ b/docs/i18n/es/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/fa/README.md b/docs/i18n/fa/README.md index 4fa551cc07..57b1f66938 100644 --- a/docs/i18n/fa/README.md +++ b/docs/i18n/fa/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/fi/README.md b/docs/i18n/fi/README.md index dee8811990..c74cafde9a 100644 --- a/docs/i18n/fi/README.md +++ b/docs/i18n/fi/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/fr/README.md b/docs/i18n/fr/README.md index 02505e8fc1..8648ea364e 100644 --- a/docs/i18n/fr/README.md +++ b/docs/i18n/fr/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/gu/README.md b/docs/i18n/gu/README.md index bec532d2de..000aa2895c 100644 --- a/docs/i18n/gu/README.md +++ b/docs/i18n/gu/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/he/README.md b/docs/i18n/he/README.md index 0588889edd..a3f9901683 100644 --- a/docs/i18n/he/README.md +++ b/docs/i18n/he/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/hi/README.md b/docs/i18n/hi/README.md index 5fd3946044..be8b2e4647 100644 --- a/docs/i18n/hi/README.md +++ b/docs/i18n/hi/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/hu/README.md b/docs/i18n/hu/README.md index df6dfead53..97ffb36ab0 100644 --- a/docs/i18n/hu/README.md +++ b/docs/i18n/hu/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/id/README.md b/docs/i18n/id/README.md index 3ea46706a9..e308128367 100644 --- a/docs/i18n/id/README.md +++ b/docs/i18n/id/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/in/README.md b/docs/i18n/in/README.md index c3c83cc7c5..70c65182ff 100644 --- a/docs/i18n/in/README.md +++ b/docs/i18n/in/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/it/README.md b/docs/i18n/it/README.md index 85aa9b6844..500239cb69 100644 --- a/docs/i18n/it/README.md +++ b/docs/i18n/it/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/ja/README.md b/docs/i18n/ja/README.md index 66b1163a8c..fb03fcd72d 100644 --- a/docs/i18n/ja/README.md +++ b/docs/i18n/ja/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/ko/README.md b/docs/i18n/ko/README.md index 0572f2e61c..ffe4cd20fd 100644 --- a/docs/i18n/ko/README.md +++ b/docs/i18n/ko/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/mr/README.md b/docs/i18n/mr/README.md index 654d21f44f..57ec532cab 100644 --- a/docs/i18n/mr/README.md +++ b/docs/i18n/mr/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/ms/README.md b/docs/i18n/ms/README.md index ca371de409..e72f4836aa 100644 --- a/docs/i18n/ms/README.md +++ b/docs/i18n/ms/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/nl/README.md b/docs/i18n/nl/README.md index e1b754c35c..785745103c 100644 --- a/docs/i18n/nl/README.md +++ b/docs/i18n/nl/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/no/README.md b/docs/i18n/no/README.md index 90183c6f2a..9e62629c89 100644 --- a/docs/i18n/no/README.md +++ b/docs/i18n/no/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/phi/README.md b/docs/i18n/phi/README.md index 9e8a0130c7..b85c193aeb 100644 --- a/docs/i18n/phi/README.md +++ b/docs/i18n/phi/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/pl/README.md b/docs/i18n/pl/README.md index dd440dfbdd..06a6f3c00e 100644 --- a/docs/i18n/pl/README.md +++ b/docs/i18n/pl/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/pt-BR/README.md b/docs/i18n/pt-BR/README.md index 0daf4eb2e1..1c31271e86 100644 --- a/docs/i18n/pt-BR/README.md +++ b/docs/i18n/pt-BR/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/pt/README.md b/docs/i18n/pt/README.md index ed7c7a932b..492246346b 100644 --- a/docs/i18n/pt/README.md +++ b/docs/i18n/pt/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/ro/README.md b/docs/i18n/ro/README.md index 597011d4be..2c71b54ebd 100644 --- a/docs/i18n/ro/README.md +++ b/docs/i18n/ro/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md index 155eb8b031..4a2a384558 100644 --- a/docs/i18n/ru/README.md +++ b/docs/i18n/ru/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/sk/README.md b/docs/i18n/sk/README.md index ec2bb084e3..aa3c743a3d 100644 --- a/docs/i18n/sk/README.md +++ b/docs/i18n/sk/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/sv/README.md b/docs/i18n/sv/README.md index c13edf987e..298d243955 100644 --- a/docs/i18n/sv/README.md +++ b/docs/i18n/sv/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/sw/README.md b/docs/i18n/sw/README.md index 806cb09c61..db067e44ac 100644 --- a/docs/i18n/sw/README.md +++ b/docs/i18n/sw/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/ta/README.md b/docs/i18n/ta/README.md index 983123fe2e..0be49d8e50 100644 --- a/docs/i18n/ta/README.md +++ b/docs/i18n/ta/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/te/README.md b/docs/i18n/te/README.md index f556464c81..fa7c3c41ac 100644 --- a/docs/i18n/te/README.md +++ b/docs/i18n/te/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/th/README.md b/docs/i18n/th/README.md index 7e04556332..791cc41173 100644 --- a/docs/i18n/th/README.md +++ b/docs/i18n/th/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/tr/README.md b/docs/i18n/tr/README.md index f48146ef93..9e73fd82af 100644 --- a/docs/i18n/tr/README.md +++ b/docs/i18n/tr/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/uk-UA/README.md b/docs/i18n/uk-UA/README.md index a2dc85e133..164291d59e 100644 --- a/docs/i18n/uk-UA/README.md +++ b/docs/i18n/uk-UA/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/ur/README.md b/docs/i18n/ur/README.md index f9ca535c93..49c4759369 100644 --- a/docs/i18n/ur/README.md +++ b/docs/i18n/ur/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/vi/README.md b/docs/i18n/vi/README.md index afa149ec26..430c018158 100644 --- a/docs/i18n/vi/README.md +++ b/docs/i18n/vi/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index bfbc0c0997..246cd3bc1f 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -130,28 +130,28 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f - Codex CLI
+ Codex CLI
Codex CLI

⭐ 60.8K - Claude Code
+ Claude Code
Claude Code

⭐ 67.3K - Gemini CLI
+ Gemini CLI
Gemini CLI

⭐ 94.7K - Kilo Code
+ Kilo Code
Kilo Code

⭐ 15.5K diff --git a/docs/openapi.yaml b/docs/openapi.yaml index bea18f734e..8e16c6a587 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.7.9 + version: 3.8.0 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package-lock.json b/electron/package-lock.json index abe3c9d549..f65c2a49e1 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.7.9", + "version": "3.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.7.9", + "version": "3.8.0", "license": "MIT", "dependencies": { "better-sqlite3": "^12.8.0", diff --git a/electron/package.json b/electron/package.json index f984a9d83f..ef0bae2c23 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.7.9", + "version": "3.8.0", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/llm.txt b/llm.txt index 7702e3f58b..e5e1221dc6 100644 --- a/llm.txt +++ b/llm.txt @@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.7.9 +**Current version:** 3.8.0 ## Tech Stack @@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.7.9) +## Key Features (v3.8.0) ### Core Proxy - **160+ AI providers** with automatic format translation diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 14dfd83a86..1a322516fc 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -26,7 +26,10 @@ export const ANTHROPIC_BETA_CLAUDE_OAUTH = [ ...ANTHROPIC_BETA_BASE.slice(3), ].join(","); -export const CLAUDE_CLI_VERSION = "2.1.121"; +// Static-config fallbacks for providerRegistry.ts. Runtime cloak in base.ts +// emits the same values via CLAUDE_CODE_VERSION in claudeIdentity.ts — +// keep both in sync. +export const CLAUDE_CLI_VERSION = "2.1.131"; export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`; export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.81.0"; export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.3.0"; diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index f453dd6236..b08a02962c 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -100,6 +100,18 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { format: "openai", models: [{ id: "qwen3-asr", name: "Qwen3 ASR" }], }, + + kie: { + id: "kie", + baseUrl: "https://api.kie.ai", + authType: "apikey", + authHeader: "bearer", + format: "kie-audio", + models: [ + { id: "elevenlabs/speech-to-text", name: "ElevenLabs STT" }, + { id: "elevenlabs/audio-isolation", name: "ElevenLabs Audio Isolation" }, + ], + }, }; export const AUDIO_SPEECH_PROVIDERS: Record = { @@ -247,6 +259,20 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { ], }, + kie: { + id: "kie", + baseUrl: "https://api.kie.ai", + authType: "apikey", + authHeader: "bearer", + format: "kie-audio", + models: [ + { id: "elevenlabs/text-to-speech-multilingual-v2", name: "ElevenLabs TTS v2" }, + { id: "elevenlabs/text-to-speech-turbo-2-5", name: "ElevenLabs TTS Turbo 2.5" }, + { id: "elevenlabs/text-to-dialogue-v3", name: "ElevenLabs Text to Dialogue v3" }, + { id: "elevenlabs/sound-effect-v2", name: "ElevenLabs Sound Effect v2" }, + ], + }, + "aws-polly": { id: "aws-polly", // POST https://polly.{region}.amazonaws.com/v1/speech diff --git a/open-sse/config/cliFingerprints.ts b/open-sse/config/cliFingerprints.ts index 4449502104..64bcfcf85a 100644 --- a/open-sse/config/cliFingerprints.ts +++ b/open-sse/config/cliFingerprints.ts @@ -63,30 +63,32 @@ export const CLI_FINGERPRINTS: Record = { // executor-provided version or user override. }, claude: { + // Header order matching real claude-cli: Title-Case (Stainless) keys + // alphabetically, then lowercase Anthropic keys alphabetically, then + // transport headers added by Node fetch. headerOrder: [ - "Host", + "Accept", + "Authorization", "Content-Type", - "x-api-key", - "anthropic-version", - "anthropic-beta", - "anthropic-dangerous-direct-browser-access", - "x-app", "User-Agent", "X-Claude-Code-Session-Id", - "x-client-request-id", - "X-Stainless-Retry-Count", - "X-Stainless-Timeout", - "X-Stainless-Lang", - "X-Stainless-Package-Version", - "X-Stainless-OS", "X-Stainless-Arch", + "X-Stainless-Lang", + "X-Stainless-OS", + "X-Stainless-Package-Version", + "X-Stainless-Retry-Count", "X-Stainless-Runtime", "X-Stainless-Runtime-Version", - "Accept", - "accept-language", - "accept-encoding", - "sec-fetch-mode", + "X-Stainless-Timeout", + "anthropic-beta", + "anthropic-dangerous-direct-browser-access", + "anthropic-version", + "x-app", + "x-client-request-id", "Connection", + "Host", + "Accept-Encoding", + "Content-Length", ], bodyFieldOrder: [ "model", @@ -96,6 +98,7 @@ export const CLI_FINGERPRINTS: Record = { "tool_choice", "metadata", "max_tokens", + "temperature", "thinking", "context_management", "output_config", diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index f74d537dbe..8ca468ed7f 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -1,4 +1,4 @@ -import { ANTHROPIC_BETA_API_KEY, ANTHROPIC_VERSION_HEADER } from "./anthropicHeaders.ts"; +import { ANTHROPIC_VERSION_HEADER } from "./anthropicHeaders.ts"; type JsonRecord = Record; @@ -6,7 +6,6 @@ export type GlmApiRegion = "international" | "china"; export const GLM_SHARED_HEADERS = Object.freeze({ "Anthropic-Version": ANTHROPIC_VERSION_HEADER, - "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }); export const GLM_SHARED_MODELS = Object.freeze([ diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 8cec75b755..a009b72c80 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -10,6 +10,7 @@ interface ImageModelEntry { name: string; inputModalities?: string[]; description?: string; + isMarket?: boolean; } interface ImageProviderConfig { @@ -153,10 +154,10 @@ export const IMAGE_PROVIDERS: Record = { authHeader: "bearer", format: "openai", models: [ - { id: "grok-imagine-image-pro", name: "Grok Imagine Image Pro" }, + { id: "grok-imagine-image-quality", name: "Grok Imagine Image Quality" }, { id: "grok-imagine-image", name: "Grok Imagine Image" }, ], - supportedSizes: ["1024x1024"], + supportedSizes: ["1024x1024", "2048x2048"], }, together: { @@ -268,6 +269,54 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1280", "1024x1536", "1536x1024", "1280x1024"], }, + kie: { + id: "kie", + baseUrl: "https://api.kie.ai", + statusUrl: "https://api.kie.ai/api/v1/jobs/recordInfo", + authType: "apikey", + authHeader: "bearer", + format: "kie-image", + models: [ + { id: "gpt4o-image", name: "KIE 4o Image" }, + { id: "seedream/4.5-text-to-image", name: "Seedream 4.5", isMarket: true }, + { id: "seedream/4.5-edit", name: "Seedream 4.5 Edit", isMarket: true }, + { id: "seedream/5.0-lite-text-to-image", name: "Seedream 5.0 Lite", isMarket: true }, + { id: "seedream/5.0-lite-image-to-image", name: "Seedream 5.0 Lite I2I", isMarket: true }, + { id: "z-image/4.0-text-to-image", name: "Z-Image v4.0", isMarket: true }, + { id: "z-image/4.5-text-to-image", name: "Z-Image v4.5", isMarket: true }, + { id: "google-imagen/imagen4-fast", name: "Imagen 4 Fast", isMarket: true }, + { id: "google-imagen/imagen4-ultra", name: "Imagen 4 Ultra", isMarket: true }, + { id: "google-imagen/imagen4", name: "Imagen 4", isMarket: true }, + { id: "google-imagen/nano-banana-2", name: "Nano Banana 2", isMarket: true }, + { id: "google-imagen/nano-banana", name: "Nano Banana", isMarket: true }, + { id: "google-imagen/nano-banana-pro", name: "Nano Banana Pro", isMarket: true }, + { id: "google-imagen/nano-banana-edit", name: "Nano Banana Edit", isMarket: true }, + { id: "flux/2-pro-image-to-image", name: "Flux 2 Pro I2I", isMarket: true }, + { id: "flux/2-pro-text-to-image", name: "Flux 2 Pro T2I", isMarket: true }, + { id: "flux/2-image-to-image", name: "Flux 2 I2I", isMarket: true }, + { id: "flux/2-text-to-image", name: "Flux 2 T2I", isMarket: true }, + { id: "flux/kontext", name: "Flux Kontext", isMarket: true }, + { id: "grok-imagine/text-to-image", name: "Grok Imagine T2I", isMarket: true }, + { id: "grok-imagine/image-to-image", name: "Grok Imagine I2I", isMarket: true }, + { id: "gpt/gpt-image-1.5-text-to-image", name: "GPT Image 1.5 T2I", isMarket: true }, + { id: "gpt/gpt-image-1.5-image-to-image", name: "GPT Image 1.5 I2I", isMarket: true }, + { id: "gpt/gpt-image-2-text-to-image", name: "GPT Image 2 T2I", isMarket: true }, + { id: "gpt/gpt-image-2-image-to-image", name: "GPT Image 2 I2I", isMarket: true }, + { id: "ideogram/v3-text-to-image", name: "Ideogram v3", isMarket: true }, + { id: "ideogram/v3-edit", name: "Ideogram v3 Edit", isMarket: true }, + { id: "ideogram/v3-remix", name: "Ideogram v3 Remix", isMarket: true }, + { id: "ideogram/v3-reframe", name: "Ideogram v3 Reframe", isMarket: true }, + { id: "qwen/text-to-image", name: "Qwen T2I", isMarket: true }, + { id: "qwen/image-to-image", name: "Qwen I2I", isMarket: true }, + { id: "qwen/image-edit", name: "Qwen Edit", isMarket: true }, + { id: "qwen2/image-edit", name: "Qwen2 Edit", isMarket: true }, + { id: "qwen2/text-to-image", name: "Qwen2 T2I", isMarket: true }, + { id: "wan/2.7-image", name: "Wan 2.7 Image", isMarket: true }, + { id: "wan/2.7-image-pro", name: "Wan 2.7 Image Pro", isMarket: true }, + ], + supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4"], + }, + sdwebui: { id: "sdwebui", baseUrl: "http://localhost:7860/sdapi/v1/txt2img", diff --git a/open-sse/config/musicRegistry.ts b/open-sse/config/musicRegistry.ts index 40cd950308..38ae3c779c 100644 --- a/open-sse/config/musicRegistry.ts +++ b/open-sse/config/musicRegistry.ts @@ -10,11 +10,13 @@ import { parseModelFromRegistry, getAllModelsFromRegistry } from "./registryUtil interface MusicModel { id: string; name: string; + isMarket?: boolean; } interface MusicProvider { id: string; baseUrl: string; + statusUrl?: string; authType: string; authHeader: string; format: string; @@ -22,6 +24,19 @@ interface MusicProvider { } export const MUSIC_PROVIDERS: Record = { + kie: { + id: "kie", + baseUrl: "https://api.kie.ai", + statusUrl: "https://api.kie.ai/api/v1/jobs/recordInfo", + authType: "apikey", + authHeader: "bearer", + format: "kie-music", + models: [ + { id: "suno-v3.5", name: "Suno V3.5" }, + { id: "suno-v4.0", name: "Suno V4.0" }, + ], + }, + comfyui: { id: "comfyui", baseUrl: "http://localhost:8188", diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 33efd7cf3c..83b86ca860 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -130,7 +130,6 @@ const KIMI_CODING_SHARED = { authHeader: "x-api-key", headers: { "Anthropic-Version": ANTHROPIC_VERSION_HEADER, - "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ { id: "kimi-k2.6", name: "Kimi K2.6" }, @@ -274,6 +273,31 @@ function mapStainlessArch() { export const REGISTRY: Record = { // ─── OAuth Providers ─────────────────────────────────────────────────── + kie: { + id: "kie", + alias: "kie", + format: "openai", + executor: "default", + baseUrl: "https://api.kie.ai/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + defaultContextLength: 128000, + models: [ + { id: "gpt-5-2", name: "GPT 5.2" }, + { id: "gpt-5-4", name: "GPT 5.4" }, + { id: "gpt-codex", name: "GPT Codex" }, + { id: "claude-haiku-4-5", name: "Claude 4.5 Haiku" }, + { id: "claude-opus-4-5", name: "Claude 4.5 Opus" }, + { id: "claude-opus-4-6", name: "Claude 4.6 Opus" }, + { id: "claude-sonnet-4-5", name: "Claude 4.5 Sonnet" }, + { id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet" }, + { id: "gemini-2-5-pro", name: "Gemini 2.5 Pro" }, + { id: "gemini-3-pro", name: "Gemini 3 Pro" }, + { id: "gemini-3-1-pro", name: "Gemini 3.1 Pro" }, + { id: "gemini-2-5-flash", name: "Gemini 2.5 Flash" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash" }, + ], + }, claude: { id: "claude", alias: "cc", @@ -760,7 +784,7 @@ export const REGISTRY: Record = { baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", authType: "apikey", authHeader: "bearer", - defaultContextLength: 128000, + defaultContextLength: 200000, models: [{ id: "glm-5.1", name: "GLM-5.1" }], passthroughModels: true, }, @@ -793,7 +817,6 @@ export const REGISTRY: Record = { authHeader: "x-api-key", headers: { "Anthropic-Version": ANTHROPIC_VERSION_HEADER, - "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ { id: "qwen3.5-plus", name: "Qwen3.5 Plus" }, @@ -818,7 +841,6 @@ export const REGISTRY: Record = { authHeader: "x-api-key", headers: { "Anthropic-Version": ANTHROPIC_VERSION_HEADER, - "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ { id: "glm-5.1", name: "GLM 5.1" }, @@ -939,7 +961,6 @@ export const REGISTRY: Record = { authHeader: "bearer", headers: { "Anthropic-Version": ANTHROPIC_VERSION_HEADER, - "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ // T12/T28: MiniMax default upgraded from M2.5 to M2.7 @@ -961,7 +982,6 @@ export const REGISTRY: Record = { authHeader: "bearer", headers: { "Anthropic-Version": ANTHROPIC_VERSION_HEADER, - "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ // Keep parity with minimax to ensure model discovery works for minimax-cn connections. @@ -1105,8 +1125,6 @@ export const REGISTRY: Record = { { id: "grok-4.20-multi-agent-0309", name: "Grok 4.20 Multi Agent" }, { id: "grok-4.20-0309-reasoning", name: "Grok 4.20 Reasoning" }, { id: "grok-4.20-0309-non-reasoning", name: "Grok 4.20" }, - { id: "grok-4-1-fast-reasoning", name: "Grok 4.1 Fast Reasoning" }, - { id: "grok-4-1-fast-non-reasoning", name: "Grok 4.1 Fast" }, ], }, @@ -1145,7 +1163,7 @@ export const REGISTRY: Record = { authHeader: "cookie", passthroughModels: true, models: [ - { id: "fast", name: "Grok Fast", toolCalling: true }, + { id: "fast", name: "Grok 4.20", toolCalling: true }, { id: "expert", name: "Grok 4.20 Thinking", toolCalling: true }, { id: "heavy", name: "Grok 4.20 Multi Agent", toolCalling: true }, { id: "grok-420-computer-use-sa", name: "Grok 4.3 (Beta)", toolCalling: true }, @@ -1162,7 +1180,7 @@ export const REGISTRY: Record = { authHeader: "bearer", models: [ { id: "mistral-large-latest", name: "Mistral Large 3" }, - { id: "mistral-medium-latest", name: "Mistral Medium 3.1" }, + { id: "mistral-medium-3-5", name: "Mistral Medium 3.5" }, { id: "mistral-small-latest", name: "Mistral Small 4" }, { id: "devstral-latest", name: "Devstral 2" }, { id: "codestral-latest", name: "Codestral" }, diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 18f8d092ce..fad3133684 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -11,11 +11,13 @@ import { RUNWAYML_SUPPORTED_VIDEO_MODELS } from "./runway.ts"; interface VideoModel { id: string; name: string; + isMarket?: boolean; } interface VideoProvider { id: string; baseUrl: string; + statusUrl?: string; authType: string; authHeader: string; format: string; @@ -23,6 +25,44 @@ interface VideoProvider { } export const VIDEO_PROVIDERS: Record = { + kie: { + id: "kie", + baseUrl: "https://api.kie.ai", + statusUrl: "https://api.kie.ai/api/v1/jobs/recordInfo", + authType: "apikey", + authHeader: "bearer", + format: "kie-video", + models: [ + { id: "veo/veo-3-1", name: "Veo 3.1", isMarket: true }, + { id: "veo/veo-3-1-fast", name: "Veo 3.1 Fast", isMarket: true }, + { + id: "kling/kling-v2-1-master-text-to-video", + name: "Kling v2.1 Master T2V", + isMarket: true, + }, + { + id: "kling/kling-v2-1-master-image-to-video", + name: "Kling v2.1 Master I2V", + isMarket: true, + }, + { id: "kling/v2-5-turbo-text-to-video", name: "Kling v2.5 Turbo T2V", isMarket: true }, + { id: "kling/v2-5-turbo-image-to-video", name: "Kling v2.5 Turbo I2V", isMarket: true }, + { id: "kling/v3-0", name: "Kling v3.0", isMarket: true }, + { id: "wan/2-6-text-to-video", name: "Wan 2.6 T2V", isMarket: true }, + { id: "wan/2-6-image-to-video", name: "Wan 2.6 I2V", isMarket: true }, + { id: "wan/2-7-text-to-video", name: "Wan 2.7 T2V", isMarket: true }, + { id: "wan/2-7-image-to-video", name: "Wan 2.7 I2V", isMarket: true }, + { id: "sora2/sora-2-text-to-video", name: "Sora 2 T2V", isMarket: true }, + { id: "sora2/sora-2-image-to-video", name: "Sora 2 I2V", isMarket: true }, + { id: "hailuo/02-text-to-video-pro", name: "Hailuo 02 T2V Pro", isMarket: true }, + { id: "hailuo/02-image-to-video-pro", name: "Hailuo 02 I2V Pro", isMarket: true }, + { id: "grok-imagine/text-to-video", name: "Grok Imagine T2V", isMarket: true }, + { id: "grok-imagine/image-to-video", name: "Grok Imagine I2V", isMarket: true }, + { id: "bytedance/v2-0-text-to-video", name: "Seedance v2.0 T2V", isMarket: true }, + { id: "bytedance/v2-0-fast-text-to-video", name: "Seedance v2.0 Fast T2V", isMarket: true }, + ], + }, + comfyui: { id: "comfyui", baseUrl: "http://localhost:8188", diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 00576b774c..39a6fabb69 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -10,11 +10,25 @@ import { modelSupportsContext1mBeta, } from "../services/claudeCodeCompatible.ts"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; -import { supportsXHighEffort } from "../config/providerModels.ts"; import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts"; import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts"; import { randomUUID } from "node:crypto"; -import { createHash } from "node:crypto"; +import { + CLAUDE_CODE_VERSION, + CLAUDE_CODE_STAINLESS_VERSION, + buildHashFor, + buildUserIdJson, + getSessionId, + parseUpstreamMetadataUserId, + passthroughUpstreamSessionId, + resolveAccountUUID, + resolveCliUserID, + selectBetaFlags, + stainlessArch, + stainlessOS, + stainlessRuntimeVersion, + stripProxyToolPrefix, +} from "./claudeIdentity.ts"; /** * Sanitizes a custom API path to prevent path traversal attacks. @@ -494,154 +508,222 @@ export class BaseExecutor { (clientHeaders?.["user-agent"] && clientHeaders["user-agent"].toLowerCase().includes("claude-cli")); + // Anthropic's user:sessions:claude_code OAuth scope expects CLI-shaped + // traffic. Apply the cloak whenever we have an OAuth token, regardless + // of upstream client. + const hasClaudeOAuthToken = + typeof activeCredentials?.accessToken === "string" && + activeCredentials.accessToken.startsWith("sk-ant-oat") && + !activeCredentials?.apiKey; + if ( this.provider === "claude" && - isClaudeCodeClient && + (isClaudeCodeClient || hasClaudeOAuthToken) && typeof transformedBody === "object" && transformedBody !== null ) { const tb = transformedBody as Record; + + stripProxyToolPrefix(tb); remapToolNamesInRequest(tb); obfuscateInBody(tb); - const ccVersion = "2.1.121"; - // Fix #1638: Use a stable fingerprint instead of message-derived one. - // The original computeFingerprint() hashed first-user-message chars, which - // changes every conversation turn. This mutated the system[] prefix on each - // request, invalidating Anthropic's prompt-cache prefix and forcing ~100% - // cache_create (vs 96% cache_read with a stable prefix). Using a per-day - // hash keeps the billing header format while preserving cache affinity. - const dayStamp = new Date().toISOString().slice(0, 10); // YYYY-MM-DD - const fp = createHash("sha256") - .update(`${dayStamp}${ccVersion}`) - .digest("hex") - .slice(0, 3); - const billingLine = `x-anthropic-billing-header: cc_version=${ccVersion}.${fp}; cc_entrypoint=cli; cch=00000;`; - - if (Array.isArray(tb.system)) { - const sysBlocks = tb.system as Array>; - // Fix #1712: Remove any existing billing headers from the client - // to prevent stacking that breaks Anthropic prompt cache prefix matching. - for (let i = sysBlocks.length - 1; i >= 0; i--) { - const block = sysBlocks[i]; - if ( - block && - typeof block.text === "string" && - block.text.startsWith("x-anthropic-billing-header:") - ) { - sysBlocks.splice(i, 1); - } + // Real CLI never sets cache_control on tools. + if (Array.isArray(tb.tools)) { + for (const t of tb.tools as Array>) { + delete t.cache_control; } - const firstSystemCacheControl = - sysBlocks[0] && - typeof sysBlocks[0] === "object" && - !Array.isArray(sysBlocks[0]) && - sysBlocks[0].cache_control - ? sysBlocks[0].cache_control - : undefined; - const billingBlock: Record = { type: "text", text: billingLine }; - if (firstSystemCacheControl) { - billingBlock.cache_control = firstSystemCacheControl; + } + + // Per-request behavior overrides via custom client headers. + // x-omniroute-effort: low | medium | high | xhigh | off + // x-omniroute-thinking: adaptive | off + // A header value applies only when the corresponding body field is + // not already set; "off" force-strips the field. + const headerEffort = ( + clientHeaders?.["x-omniroute-effort"] ?? clientHeaders?.["X-OmniRoute-Effort"] + ) + ?.trim() + .toLowerCase(); + const headerThinking = ( + clientHeaders?.["x-omniroute-thinking"] ?? clientHeaders?.["X-OmniRoute-Thinking"] + ) + ?.trim() + .toLowerCase(); + let appliedEffort: string | null = null; + let appliedThinking: string | null = null; + + if (headerEffort === "off") { + if (tb.output_config && typeof tb.output_config === "object") { + delete (tb.output_config as Record).effort; + } + appliedEffort = "off"; + } else if (headerEffort && ["low", "medium", "high", "xhigh"].includes(headerEffort)) { + const oc = + tb.output_config && typeof tb.output_config === "object" + ? (tb.output_config as Record) + : {}; + if (oc.effort === undefined) { + oc.effort = headerEffort; + tb.output_config = oc; + appliedEffort = headerEffort; } - sysBlocks.unshift(billingBlock); - } else if (typeof tb.system === "string") { - tb.system = [ - { type: "text", text: billingLine }, - { type: "text", text: tb.system }, - ]; - } else { - tb.system = [{ type: "text", text: billingLine }]; } - if (!tb.metadata || typeof tb.metadata !== "object") { - tb.metadata = { - user_id: JSON.stringify({ - device_id: createHash("sha256").update("omniroute").digest("hex").slice(0, 24), - account_uuid: "", - session_id: randomUUID(), - }), - }; + if (headerThinking === "adaptive") { + if (tb.thinking === undefined) { + tb.thinking = { type: "adaptive" }; + appliedThinking = "adaptive"; + } + if (tb.context_management === undefined) { + tb.context_management = { + edits: [{ type: "clear_thinking_20251015", keep: "all" }], + }; + } + } else if (headerThinking === "off") { + delete tb.thinking; + delete tb.context_management; + appliedThinking = "off"; } - const supportsAdaptiveThinking = supportsXHighEffort("claude", model); - - // Fix #1761: Only inject adaptive thinking/high effort if the client didn't - // explicitly set these fields. This allows users to opt-out by sending - // `thinking: null` or `output_config: { effort: "low" }` to prevent forced - // quota drain on Claude Max accounts. - const originalBody = body as Record; - const clientExplicitThinking = originalBody?.thinking !== undefined; - const clientExplicitEffort = originalBody?.output_config !== undefined; - - if (supportsAdaptiveThinking && !tb.thinking && !clientExplicitThinking) { - tb.thinking = { type: "adaptive" }; - } - - if (supportsAdaptiveThinking && !tb.context_management && !clientExplicitThinking) { + // Real CLI always pairs context_management with thinking. Mirror + // that invariant so long sessions don't accumulate thinking blocks + // toward the context cap. + if (tb.thinking && !tb.context_management) { tb.context_management = { edits: [{ type: "clear_thinking_20251015", keep: "all" }], }; } - if (supportsAdaptiveThinking && !tb.output_config && !clientExplicitEffort) { - tb.output_config = { effort: "high" }; + const seed = activeCredentials?.accessToken || activeCredentials?.apiKey || "anon"; + const psd = activeCredentials?.providerSpecificData as + | Record + | undefined; + + let identitySource: "upstream-metadata" | "upstream-header" | "synthesized" = + "synthesized"; + let sessionId: string; + let deviceId: string; + let accountUUID: string; + + const upstreamUserId = parseUpstreamMetadataUserId(tb); + if (upstreamUserId) { + sessionId = upstreamUserId.session_id; + deviceId = upstreamUserId.device_id; + accountUUID = upstreamUserId.account_uuid; + identitySource = "upstream-metadata"; + } else { + const headerSid = passthroughUpstreamSessionId( + clientHeaders as Record | undefined + ); + sessionId = headerSid ?? getSessionId(seed); + deviceId = resolveCliUserID(psd, seed); + accountUUID = resolveAccountUUID(psd, seed, activeCredentials?.accessToken); + identitySource = headerSid ? "upstream-header" : "synthesized"; } + // system[0] (billing) and system[1] (sentinel) must not carry + // cache_control — that belongs on upstream prompt blocks at [2..]. + const dayStamp = new Date().toISOString().slice(0, 10); + const buildHash = buildHashFor(CLAUDE_CODE_VERSION, dayStamp); + const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_VERSION}.${buildHash}; cc_entrypoint=cli; cch=00000;`; + const SENTINEL = "You are Claude Code, Anthropic's official CLI for Claude."; + + const sysBlocks: Array> = Array.isArray(tb.system) + ? (tb.system as Array>) + : typeof tb.system === "string" + ? [{ type: "text", text: tb.system }] + : []; + + // Strip any pre-existing billing/sentinel before re-prepending — keeps + // retries idempotent and avoids stacking that breaks prompt-cache prefix + // matching (see issue #1712). + for (let i = sysBlocks.length - 1; i >= 0; i--) { + const t = sysBlocks[i]?.text; + if (typeof t === "string" && t.startsWith("x-anthropic-billing-header:")) { + sysBlocks.splice(i, 1); + } + } + for (let i = sysBlocks.length - 1; i >= 0; i--) { + const t = sysBlocks[i]?.text; + if (typeof t === "string" && t.startsWith(SENTINEL)) { + sysBlocks.splice(i, 1); + } + } + sysBlocks.unshift({ type: "text", text: billingLine }, { type: "text", text: SENTINEL }); + tb.system = sysBlocks; + + if (!tb.metadata || typeof tb.metadata !== "object") tb.metadata = {}; + (tb.metadata as Record).user_id = buildUserIdJson({ + deviceId, + accountUUID, + sessionId, + }); + + // Headers. Accept stays application/json even on streams (Stainless + // convention; SSE decoding is gated on body.stream). anthropic-beta + // is selected per request shape; the full set on a quota probe is + // itself a fingerprint. const ccHeaders: Record = { + Accept: "application/json", "anthropic-version": "2023-06-01", - "anthropic-beta": - "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24", + "anthropic-beta": selectBetaFlags(tb), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", - "User-Agent": `claude-cli/${ccVersion} (external, cli)`, - "X-Stainless-Package-Version": "0.81.0", + "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, + "X-Stainless-Package-Version": CLAUDE_CODE_STAINLESS_VERSION, "X-Stainless-Timeout": "600", - "accept-language": "*", "accept-encoding": "gzip, deflate, br, zstd", connection: "keep-alive", "x-client-request-id": randomUUID(), - "X-Claude-Code-Session-Id": randomUUID(), + "X-Claude-Code-Session-Id": sessionId, }; - // Remove any existing case variants of ccHeaders keys before merging. - // The claude provider config sets "Anthropic-Version" (Title-Case) while - // ccHeaders uses all-lowercase keys. Both JS keys normalise to the same - // HTTP header name, so undici would combine them into "2023-06-01, 2023-06-01" - // causing a 400 from Anthropic (see issue #1454). + + // Drop case variants of the same header name before merging — undici + // would otherwise concatenate them (issue #1454). const ccKeysLower = new Set(Object.keys(ccHeaders).map((k) => k.toLowerCase())); for (const key of Object.keys(headers)) { - if (ccKeysLower.has(key.toLowerCase())) { - delete headers[key]; - } + if (ccKeysLower.has(key.toLowerCase())) delete headers[key]; } Object.assign(headers, ccHeaders); delete headers["X-Stainless-Helper-Method"]; - // Add X-Stainless headers to match real Claude Code - headers["X-Stainless-Arch"] = "x64"; + // Stainless OS/Arch/Runtime are host-derived (Stainless SDK does the + // same at runtime). Hardcoding them was a unique-per-deployment tell. + headers["X-Stainless-Arch"] = stainlessArch(); headers["X-Stainless-Lang"] = "js"; - headers["X-Stainless-OS"] = "Windows"; + headers["X-Stainless-OS"] = stainlessOS(); headers["X-Stainless-Runtime"] = "node"; - headers["X-Stainless-Runtime-Version"] = "v24.3.0"; + headers["X-Stainless-Runtime-Version"] = stainlessRuntimeVersion(); headers["X-Stainless-Retry-Count"] = "0"; delete headers["X-Stainless-Os"]; - console.log( - `[CLAUDE-PATCH] provider=${this.provider} tools remapped, billing header injected, body fields added, headers patched` + const overrideTag = + appliedEffort || appliedThinking + ? ` overrides=effort:${appliedEffort ?? "-"},thinking:${appliedThinking ?? "-"}` + : ""; + log?.debug?.( + "CLAUDE", + `identity=${identitySource} sid=${sessionId.slice(0, 8)} dev=${deviceId.slice(0, 8)} acct=${accountUUID.slice(0, 8)}${overrideTag}` ); } - // Apply CLI fingerprint ordering if enabled for this provider + // CLI fingerprint ordering — always-on for native Claude OAuth, opt-in + // for other providers. Header + body field order is itself a fingerprint. let finalHeaders = headers; let bodyString = JSON.stringify(transformedBody); - if (isCliCompatEnabled(this.provider)) { + const shouldFingerprint = + isCliCompatEnabled(this.provider) || + (this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken)); + if (shouldFingerprint) { const fingerprinted = applyFingerprint(this.provider, headers, transformedBody); finalHeaders = fingerprinted.headers; bodyString = fingerprinted.bodyString; } - // CCH signing: Claude Code-compatible providers AND native claude provider - // require an xxHash64 integrity token over the serialized body. + // CCH signing — replaces the cch=00000 placeholder in the billing + // header with an xxHash64 integrity token over the serialized body. if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") { bodyString = await signRequestBody(bodyString); } diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts new file mode 100644 index 0000000000..698664d929 --- /dev/null +++ b/open-sse/executors/claudeIdentity.ts @@ -0,0 +1,341 @@ +/** + * Claude Code identity helpers used by the native `claude` provider when + * authenticating with an OAuth token. Anthropic's user:sessions:claude_code + * scope expects request shape that matches a real claude-cli session; + * everything in this module exists to produce that shape. + * + * Pinned to a captured claude-cli release. Bump in lockstep when a newer + * release is captured. + */ + +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +// ---------- Versions ------------------------------------------------------ + +export const CLAUDE_CODE_VERSION = "2.1.131"; +/** Bundled @anthropic-ai/sdk version for the pinned CLI release. */ +export const CLAUDE_CODE_STAINLESS_VERSION = "0.81.0"; + +// ---------- Stainless OS / Arch / Runtime -------------------------------- + +export function stainlessOS(): string { + switch (process.platform) { + case "win32": + return "Windows"; + case "darwin": + return "MacOS"; + case "linux": + return "Linux"; + case "freebsd": + return "FreeBSD"; + default: + return "Unknown"; + } +} + +export function stainlessArch(): string { + switch (process.arch) { + case "x64": + return "x64"; + case "arm64": + return "arm64"; + case "ia32": + return "x32"; + default: + return process.arch; + } +} + +export function stainlessRuntimeVersion(): string { + return process.version; +} + +// ---------- Bounded-map helper ------------------------------------------- + +const IDENTITY_CACHE_LIMIT = 10_000; +const BOOTSTRAP_FETCH_TIMEOUT_MS = 10_000; + +/** Insert with FIFO eviction once a Map reaches `max`. JS Maps preserve insertion order. */ +function setBounded(m: Map, key: K, value: V, max: number): void { + if (!m.has(key) && m.size >= max) { + const oldest = m.keys().next().value as K | undefined; + if (oldest !== undefined) m.delete(oldest); + } + m.set(key, value); +} + +// ---------- Upstream session-id passthrough ------------------------------ + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function passthroughUpstreamSessionId( + clientHeaders: Record | null | undefined +): string | null { + if (!clientHeaders) return null; + const raw = + clientHeaders["x-claude-code-session-id"] ?? clientHeaders["X-Claude-Code-Session-Id"]; + if (typeof raw !== "string") return null; + const v = raw.trim(); + return UUID_RE.test(v) ? v : null; +} + +// ---------- Session ID (per OAuth account, process lifetime) ------------- + +const sessionCache = new Map(); + +/** Same value MUST be emitted as both X-Claude-Code-Session-Id and metadata.user_id.session_id. */ +export function getSessionId(seed: string): string { + let id = sessionCache.get(seed); + if (id) return id; + id = randomUUID(); + setBounded(sessionCache, seed, id, IDENTITY_CACHE_LIMIT); + return id; +} + +// ---------- Device ID (cliUserID) ---------------------------------------- + +/** Real CLI uses crypto.randomBytes(32).toString("hex"), persisted to ~/.claude.json. */ +export function generateCliUserID(): string { + return randomBytes(32).toString("hex"); +} + +const lazyCliUserIDCache = new Map(); + +const HEX64_RE = /^[a-f0-9]{64}$/i; + +/** + * Resolve the cliUserID for an account, in priority order: + * 1. providerSpecificData.cliUserID — persisted at OAuth provisioning. + * 2. providerSpecificData.userID — alt key (matches real CLI's own). + * 3. lazy-random — fresh randomBytes(32), cached for the process lifetime. + * + * Never deterministic from the access token. + */ +export function resolveCliUserID( + providerSpecificData: Record | undefined, + seed: string +): string { + const cli = providerSpecificData?.cliUserID; + if (typeof cli === "string" && HEX64_RE.test(cli)) return cli; + const alt = providerSpecificData?.userID; + if (typeof alt === "string" && HEX64_RE.test(alt)) return alt; + let cached = lazyCliUserIDCache.get(seed); + if (cached) return cached; + cached = generateCliUserID(); + setBounded(lazyCliUserIDCache, seed, cached, IDENTITY_CACHE_LIMIT); + return cached; +} + +// ---------- Account UUID ------------------------------------------------- + +const ACCOUNT_FETCH_RETRY_MS = 5 * 60 * 1000; +const accountUuidCache = new Map(); +const inflightFetches = new Set(); + +async function backgroundFetchAccountUUID(accessToken: string, seed: string): Promise { + if (inflightFetches.has(seed)) return; + const cached = accountUuidCache.get(seed); + if (cached?.uuid) return; + if (cached && Date.now() - cached.fetchedAt < ACCOUNT_FETCH_RETRY_MS) return; + inflightFetches.add(seed); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), BOOTSTRAP_FETCH_TIMEOUT_MS); + try { + const res = await fetch("https://api.anthropic.com/api/claude_cli/bootstrap", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, + "anthropic-beta": "oauth-2025-04-20", + }, + signal: ctrl.signal, + }); + const data: any = res.ok ? await res.json().catch(() => null) : null; + const uuid: string | null = data?.oauth_account?.account_uuid || null; + setBounded(accountUuidCache, seed, { uuid, fetchedAt: Date.now() }, IDENTITY_CACHE_LIMIT); + } catch { + setBounded(accountUuidCache, seed, { uuid: null, fetchedAt: Date.now() }, IDENTITY_CACHE_LIMIT); + } finally { + clearTimeout(timer); + inflightFetches.delete(seed); + } +} + +/** Format-correct UUIDv4 from a 64-hex hash (deterministic fallback shape). */ +export function uuidV4FromHash(hex64: string): string { + return [ + hex64.slice(0, 8), + hex64.slice(8, 12), + "4" + hex64.slice(13, 16), + ((parseInt(hex64.charAt(16), 16) & 0x3) | 0x8).toString(16) + hex64.slice(17, 20), + hex64.slice(20, 32), + ].join("-"); +} + +/** + * Resolve account_uuid in priority order: + * 1. providerSpecificData.accountUUID / account_uuid (real, from bootstrap). + * 2. in-memory cache from a background bootstrap fetch. + * 3. deterministic UUIDv4 derived from the access token (shape-correct fallback). + * + * Triggers a background bootstrap fetch when no real UUID is known yet. + */ +export function resolveAccountUUID( + providerSpecificData: Record | undefined, + seed: string, + accessToken?: string +): string { + const camel = providerSpecificData?.accountUUID; + if (typeof camel === "string" && camel.length >= 32) return camel; + const snake = providerSpecificData?.account_uuid; + if (typeof snake === "string" && snake.length >= 32) return snake; + + const cached = accountUuidCache.get(seed); + if (cached?.uuid) return cached.uuid; + + if (accessToken) void backgroundFetchAccountUUID(accessToken, seed); + + return uuidV4FromHash( + createHash("sha256") + .update("account:" + seed) + .digest("hex") + ); +} + +// ---------- metadata.user_id (the JSON-stringified blob) ----------------- + +/** Real CLI emits this exact key order: device_id, account_uuid, session_id. */ +export function buildUserIdJson(opts: { + deviceId: string; + accountUUID: string; + sessionId: string; +}): string { + return JSON.stringify({ + device_id: opts.deviceId, + account_uuid: opts.accountUUID, + session_id: opts.sessionId, + }); +} + +export function parseUpstreamMetadataUserId( + body: Record | null | undefined +): { device_id: string; account_uuid: string; session_id: string } | null { + if (!body) return null; + const md = body.metadata as Record | undefined; + const raw = md?.user_id; + if (typeof raw !== "string" || raw.length === 0) return null; + let parsed: any; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const { device_id, account_uuid, session_id } = parsed; + if ( + typeof device_id !== "string" || + !HEX64_RE.test(device_id) || + typeof account_uuid !== "string" || + !UUID_RE.test(account_uuid) || + typeof session_id !== "string" || + !UUID_RE.test(session_id) + ) { + return null; + } + return { device_id, account_uuid, session_id }; +} + +// ---------- anthropic-beta selector -------------------------------------- + +/** + * Pick the anthropic-beta flag set that matches the request shape. Real CLI + * uses three patterns: minimal probe, structured-output, and full agent. + * Sending the full set on every shape is itself a fingerprint. + */ +export function selectBetaFlags(body: Record | null | undefined): string { + const b = body || {}; + const hasSystem = + !!b.system && + (typeof b.system === "string" || (Array.isArray(b.system) && b.system.length > 0)); + const tools = b.tools as unknown[] | undefined; + const hasTools = Array.isArray(tools) && tools.length > 0; + const outputCfg = b.output_config as Record | undefined; + const hasStructuredOutput = + !!(outputCfg && (outputCfg.format as { type?: string } | undefined)?.type === "json_schema") || + !!(b.response_format as { type?: string } | undefined)?.type; + const isFullAgent = hasTools && hasSystem; + + const flags: string[] = []; + if (isFullAgent) flags.push("claude-code-20250219"); + flags.push("oauth-2025-04-20"); + if (isFullAgent) flags.push("context-1m-2025-08-07"); + flags.push( + "interleaved-thinking-2025-05-14", + "redact-thinking-2026-02-12", + "context-management-2025-06-27", + "prompt-caching-scope-2026-01-05" + ); + if (hasStructuredOutput || isFullAgent) flags.push("advisor-tool-2026-03-01"); + if (hasStructuredOutput && !isFullAgent) flags.push("structured-outputs-2025-12-15"); + if (isFullAgent) { + flags.push( + "advanced-tool-use-2025-11-20", + "effort-2025-11-24", + "extended-cache-ttl-2025-04-11" + ); + } + return flags.join(","); +} + +// ---------- billing-header build hash ------------------------------------ + +/** + * 3-char build hash for the billing header `cc_version=X.Y.Z.HASH`. Stable + * per (day, version) — Anthropic does not appear to validate the value, so + * we keep prompt-cache prefix stable within a day for a given version + * without coupling to any captured value. + */ +export function buildHashFor(version: string, dayStamp: string): string { + return createHash("sha256").update(`${dayStamp}${version}`).digest("hex").slice(0, 3); +} + +// ---------- Tool-name normalisation -------------------------------------- + +const TOOL_PREFIX = "proxy_"; + +/** Strip OmniRoute's `proxy_` tool-name prefix; real CLI never sends it. */ +export function stripProxyToolPrefix(body: Record): void { + const stripName = (n: unknown): string | undefined => { + if (typeof n !== "string") return undefined; + return n.startsWith(TOOL_PREFIX) ? n.slice(TOOL_PREFIX.length) : n; + }; + + const tools = body.tools as Array> | undefined; + if (Array.isArray(tools)) { + for (const t of tools) { + const stripped = stripName(t.name); + if (stripped !== undefined) t.name = stripped; + } + } + + const tc = body.tool_choice as Record | undefined; + if (tc && typeof tc.name === "string") { + const stripped = stripName(tc.name); + if (stripped !== undefined) tc.name = stripped; + } + + const messages = body.messages as Array> | undefined; + if (Array.isArray(messages)) { + for (const m of messages) { + const content = m.content; + if (!Array.isArray(content)) continue; + for (const block of content as Array>) { + if (block?.type === "tool_use") { + const stripped = stripName(block.name); + if (stripped !== undefined) block.name = stripped; + } + } + } + } +} diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index daaace78ba..d97fcc504e 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -126,7 +126,12 @@ export class GeminiCLIExecutor extends BaseExecutor { return `${this.config.baseUrl}:${action}`; } - buildHeaders(credentials, stream = true, clientHeaders?: Record | null, model?: string) { + buildHeaders( + credentials, + stream = true, + clientHeaders?: Record | null, + model?: string + ) { void clientHeaders; const raw = getGeminiCliHeaders( normalizeGeminiModel(model || "unknown"), @@ -264,7 +269,12 @@ export class GeminiCLIExecutor extends BaseExecutor { console.warn( "[OmniRoute] loadCodeAssist returned no project — attempting managed project onboarding" ); - projectId = await this.onboardManagedProject(accessToken, extractDefaultTierId(data), {}, currentModel); + projectId = await this.onboardManagedProject( + accessToken, + extractDefaultTierId(data), + {}, + currentModel + ); } if (!projectId) { @@ -287,9 +297,7 @@ export class GeminiCLIExecutor extends BaseExecutor { async transformRequest(model, body, stream, credentials) { const currentModel = normalizeGeminiModel(model); const normalizedBody = - shouldStripCloudCodeThinking(this.provider, currentModel) && - body && - typeof body === "object" + shouldStripCloudCodeThinking(this.provider, currentModel) && body && typeof body === "object" ? stripCloudCodeThinkingConfig(body) : body; @@ -304,7 +312,11 @@ export class GeminiCLIExecutor extends BaseExecutor { const envelope: Record = { model: currentModel, - project: bodyRecord.project || credentials.projectId || "", + project: + bodyRecord.project || + credentials.projectId || + (credentials.providerSpecificData as Record)?.projectId || + "", user_prompt_id: bodyRecord.user_prompt_id || generateGeminiCliRequestId(), request: { ...requestRecord, @@ -318,8 +330,9 @@ export class GeminiCLIExecutor extends BaseExecutor { } } - // Refresh the project ID via loadCodeAssist (cached for 30s). - if (credentials.accessToken) { + // Refresh the project ID via loadCodeAssist (cached for 30s) only when project not provided + // and credentials have an access token + if (!envelope.project && credentials.accessToken) { const freshProject = await this.refreshProject(credentials.accessToken, currentModel); if (freshProject) { envelope.project = freshProject; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 9f21c25f2f..8d696afac4 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -89,6 +89,7 @@ export { CliproxyapiExecutor } from "./cliproxyapi.ts"; export { VertexExecutor } from "./vertex.ts"; export { PerplexityWebExecutor } from "./perplexity-web.ts"; export { GrokWebExecutor } from "./grok-web.ts"; +export { KieExecutor } from "./kie.ts"; export { ChatGptWebExecutor } from "./chatgpt-web.ts"; export { BlackboxWebExecutor } from "./blackbox-web.ts"; export { MuseSparkWebExecutor } from "./muse-spark-web.ts"; diff --git a/open-sse/executors/kie.ts b/open-sse/executors/kie.ts new file mode 100644 index 0000000000..494c867e60 --- /dev/null +++ b/open-sse/executors/kie.ts @@ -0,0 +1,109 @@ +import { BaseExecutor } from "./base.ts"; +import { sleep } from "../utils/sleep.ts"; +import { + isJsonObject, + normalizeKieTaskState, + type JsonObject, + type KieTaskState, +} from "../utils/kieTask.ts"; + +export type { KieTaskState } from "../utils/kieTask.ts"; + +type KieTaskInput = { + baseUrl: string; + token: string; + payload: unknown; + endpoint?: string; +}; + +type KiePollInput = { + statusUrl: string; + taskId: string; + token: string; + timeoutMs: number; + pollIntervalMs: number; +}; + +export type KieTaskRecord = { + data: JsonObject; + state: KieTaskState; +}; + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/$/, ""); +} + +export class KieExecutor extends BaseExecutor { + constructor() { + super("kie", { baseUrl: "https://api.kie.ai" }); + } + + getTaskCreateUrl(baseUrl: string, endpoint = "/api/v1/jobs/createTask"): string { + return `${normalizeBaseUrl(baseUrl)}${endpoint}`; + } + + getTaskStatusUrl(baseUrl: string): string { + return `${normalizeBaseUrl(baseUrl)}/api/v1/jobs/recordInfo`; + } + + async createTask({ baseUrl, token, payload, endpoint }: KieTaskInput): Promise { + const res = await fetch(this.getTaskCreateUrl(baseUrl, endpoint), { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const error = await res.text(); + throw Object.assign(new Error(error || `Kie createTask failed with status ${res.status}`), { + status: res.status, + }); + } + + const data = (await res.json()) as unknown; + return isJsonObject(data) ? data : {}; + } + + async pollTask({ + statusUrl, + taskId, + token, + timeoutMs, + pollIntervalMs, + }: KiePollInput): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const pollUrl = new URL(statusUrl); + pollUrl.searchParams.set("taskId", String(taskId)); + + const res = await fetch(pollUrl.toString(), { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + const error = await res.text(); + throw Object.assign(new Error(error || `Kie poll failed with status ${res.status}`), { + status: res.status, + }); + } + + const data = (await res.json()) as unknown; + const recordData = isJsonObject(data) ? data : {}; + const state = normalizeKieTaskState(recordData); + if (state !== "pending") { + return { data: recordData, state }; + } + + await sleep(pollIntervalMs); + } + + throw Object.assign(new Error("Kie task timed out"), { status: 504 }); + } +} + +export const kieExecutor = new KieExecutor(); diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index d4d224531f..2342a6d9ea 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -20,7 +20,15 @@ import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.ts"; import { buildAuthHeaders } from "../config/registryUtils.ts"; +import { kieExecutor } from "../executors/kie.ts"; import { errorResponse } from "../utils/error.ts"; +import { + getKieCallbackUrl, + getKieErrorMessage, + getKieErrorStatus, + isJsonObject, + parseKieResultJson, +} from "../utils/kieTask.ts"; import { signAwsRequest } from "../utils/awsSigV4.ts"; /** @@ -70,6 +78,89 @@ function audioStreamResponse(res, defaultContentType = "audio/mpeg") { }); } +function normalizeKieElevenLabsVoice(voice: unknown): string { + const value = typeof voice === "string" ? voice.trim() : ""; + const aliases: Record = { + alloy: "Rachel", + echo: "Adam", + fable: "Brian", + onyx: "Antoni", + nova: "Bella", + shimmer: "Dorothy", + }; + return aliases[value.toLowerCase()] || value || "Rachel"; +} + +function findAudioUrlDeep(value: unknown): string | null { + if (!value) return null; + + if (typeof value === "string") { + if (/^https?:\/\//i.test(value) && !/\.(jpg|jpeg|png|webp|gif|svg)(\?|$)/i.test(value)) { + return value; + } + return null; + } + + if (Array.isArray(value)) { + for (const item of value) { + const url = findAudioUrlDeep(item); + if (url) return url; + } + return null; + } + + if (isJsonObject(value)) { + const preferredKeys = [ + "audio_url", + "audioUrl", + "stream_audio_url", + "streamAudioUrl", + "resultUrl", + "url", + "downloadUrl", + "resultUrls", + ]; + + for (const key of preferredKeys) { + const url = findAudioUrlDeep(value[key]); + if (url) return url; + } + + for (const item of Object.values(value)) { + const url = findAudioUrlDeep(item); + if (url) return url; + } + } + + return null; +} + +function findKieAudioUrl(recordData: unknown): string | null { + const record = isJsonObject(recordData) ? recordData : {}; + const data = isJsonObject(record.data) ? record.data : {}; + const resultJson = parseKieResultJson(recordData); + const response = data.response; + const nestedData = data.data; + const candidates = [ + response, + data, + resultJson, + ...(Array.isArray(response) ? response : []), + ...(Array.isArray(nestedData) ? nestedData : []), + ...(Array.isArray(resultJson.data) ? resultJson.data : []), + ...(Array.isArray(resultJson.result) ? resultJson.result : []), + ]; + + for (const item of candidates) { + const url = findAudioUrlDeep(item); + if (url) { + return url; + } + } + + return null; +} + /** * Validate a path segment to prevent path traversal / SSRF. * Returns true if safe, false if it contains traversal sequences. @@ -440,6 +531,101 @@ async function handlePlayHtSpeech(providerConfig, body, modelId, token) { return audioStreamResponse(res); } +/** + * Handle Kie.ai TTS + * Kie.ai has model-specific endpoints or uses unified jobs API. + */ +async function handleKieAudioSpeech(providerConfig, body, modelId, token) { + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const voice = normalizeKieElevenLabsVoice(body.voice); + + const payload = { + model: modelId, + callBackUrl: getKieCallbackUrl(body), + input: { + text: body.input, + voice, + stability: typeof body.stability === "number" ? body.stability : 0.5, + similarity_boost: typeof body.similarity_boost === "number" ? body.similarity_boost : 0.75, + style: typeof body.style === "number" ? body.style : 0, + speed: typeof body.speed === "number" ? body.speed : 1, + timestamps: body.timestamps === true, + previous_text: body.previous_text || "", + next_text: body.next_text || "", + language_code: body.language_code || "", + }, + }; + + let data; + try { + data = await kieExecutor.createTask({ + baseUrl, + token, + payload, + }); + } catch (err: unknown) { + const status = getKieErrorStatus(err, 502); + return Response.json( + { + error: { message: getKieErrorMessage(err, "Kie audio createTask failed"), code: status }, + }, + { + status, + headers: { ...CORS_HEADERS }, + } + ); + } + + const taskId = data?.data?.taskId || data?.taskId; + if (taskId) { + return pollKieAudioResult(baseUrl, modelId, taskId, token); + } + + const audioUrl = findKieAudioUrl(data); + if (typeof audioUrl === "string" && audioUrl.length > 0) { + const audioRes = await fetch(audioUrl); + return audioStreamResponse(audioRes); + } + + return errorResponse( + 502, + data?.msg || data?.message || "Kie audio generation did not return taskId or audio URL" + ); +} + +/** + * Internal polling for Kie.ai async audio tasks + */ +async function pollKieAudioResult(baseUrl, modelId, taskId, token) { + void modelId; + const statusUrl = kieExecutor.getTaskStatusUrl(baseUrl); + try { + const { data, state } = await kieExecutor.pollTask({ + statusUrl, + taskId: String(taskId), + token, + timeoutMs: 60000, + pollIntervalMs: 2000, + }); + + if (state === "success") { + const url = findKieAudioUrl(data); + if (url) { + const audioRes = await fetch(url); + return audioStreamResponse(audioRes); + } + return errorResponse(502, "Kie audio task completed without audio URL"); + } + } catch (err: unknown) { + return errorResponse( + getKieErrorStatus(err, 504), + getKieErrorMessage(err, "Kie audio generation timed out or failed") + ); + } + + return errorResponse(504, "Kie audio generation timed out or failed"); +} + /** * Handle AWS Polly TTS * POST /v1/speech signed with AWS SigV4. @@ -656,7 +842,7 @@ export async function handleAudioSpeech({ if (!providerConfig) { return errorResponse( 400, - `No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, aws-polly, xiaomi-mimo, coqui, tortoise, qwen` + `No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, kie, aws-polly, xiaomi-mimo, coqui, tortoise, qwen` ); } @@ -701,6 +887,10 @@ export async function handleAudioSpeech({ return handlePlayHtSpeech(providerConfig, body, modelId, token); } + if (providerConfig.format === "kie-audio") { + return handleKieAudioSpeech(providerConfig, body, modelId, token); + } + if (providerConfig.format === "aws-polly") { return handleAwsPollySpeech(providerConfig, body, modelId, token, credentials); } diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index 53a3ce4d2d..c2dfeb2d38 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -1,4 +1,5 @@ -import { CORS_HEADERS } from "../utils/cors.ts"; +import { CORS_HEADERS, getCorsOrigin } from "../utils/cors.ts"; +import { Buffer } from "node:buffer"; /** * Audio Transcription Handler * @@ -19,6 +20,7 @@ import { type AudioProvider, } from "../config/audioRegistry.ts"; import { buildAuthHeaders } from "../config/registryUtils.ts"; +import { kieExecutor } from "../executors/kie.ts"; import { errorResponse } from "../utils/error.ts"; type TranscriptionCredentials = { @@ -281,6 +283,97 @@ async function handleHuggingFaceTranscription(providerConfig, file, modelId, tok return Response.json({ text }, { headers: { ...CORS_HEADERS } }); } +/** + * Handle Kie.ai transcription + */ +async function handleKieAudioTranscription(providerConfig, file, modelId, token) { + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const fileBuffer = await file.arrayBuffer(); + const fileBase64 = Buffer.from(fileBuffer).toString("base64"); + let data; + try { + data = await kieExecutor.createTask({ + baseUrl, + token, + payload: { + model: modelId, + input: { + file_name: getUploadedFileName(file), + file_base64: fileBase64, + }, + }, + }); + } catch (err: unknown) { + const status = + typeof err === "object" && err !== null && "status" in err + ? Number((err as { status?: unknown }).status) || 502 + : 502; + return Response.json( + { + error: { + message: err instanceof Error ? err.message : "Kie transcription createTask failed", + code: status, + }, + }, + { + status, + headers: { "Access-Control-Allow-Origin": getCorsOrigin() }, + } + ); + } + const taskId = data?.data?.taskId || data?.taskId; + + if (taskId) { + return pollKieTranscriptionResult(baseUrl, modelId, taskId, token); + } + + return Response.json( + { text: data?.data?.text || data?.text || "" }, + { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } } + ); +} + +/** + * Internal polling for Kie.ai async transcription tasks + */ +async function pollKieTranscriptionResult(baseUrl, modelId, taskId, token) { + void modelId; + const statusUrl = kieExecutor.getTaskStatusUrl(baseUrl); + try { + const { data, state } = await kieExecutor.pollTask({ + statusUrl, + taskId: String(taskId), + token, + timeoutMs: 120000, + pollIntervalMs: 2000, + }); + + if (state === "success") { + const text = + data?.data?.response?.text || + data?.data?.resultText || + data?.data?.text || + data?.text || + ""; + return Response.json( + { text }, + { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } } + ); + } + } catch (err: unknown) { + const status = + typeof err === "object" && err !== null && "status" in err + ? Number((err as { status?: unknown }).status) || 504 + : 504; + return errorResponse( + status, + err instanceof Error ? err.message : "Kie transcription generation timed out or failed" + ); + } + + return errorResponse(504, "Kie transcription generation timed out or failed"); +} + /** * Handle audio transcription request * @@ -351,6 +444,10 @@ export async function handleAudioTranscription({ return handleHuggingFaceTranscription(providerConfig, file, modelId, token); } + if (providerConfig.format === "kie-audio") { + return handleKieAudioTranscription(providerConfig, file, modelId, token); + } + // Default: OpenAI/Groq/Qwen3-compatible multipart proxy const upstreamForm = new FormData(); upstreamForm.append("file", file, getUploadedFileName(file)); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 04e2159afe..2ec6a38beb 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2314,6 +2314,19 @@ export async function handleChatCore({ log?.debug?.("FORMAT", `claude passthrough (preserveCache=${preserveCacheControl})`); + // Migrate deprecated top-level `output_format` → `output_config.format`. + // Anthropic returns a 400 on the legacy field; some clients (e.g. ForgeCode) + // still emit it. Preserves an existing output_config.format if present. + if (translatedBody.output_format !== undefined) { + const oc = + translatedBody.output_config && typeof translatedBody.output_config === "object" + ? (translatedBody.output_config as Record) + : {}; + if (oc.format === undefined) oc.format = translatedBody.output_format; + translatedBody.output_config = oc; + delete translatedBody.output_format; + } + // Fix #1719: Strip output_config.format for non-Anthropic Claude-compatible providers. // Third-party Claude endpoints (MiniMax, DeepSeek via aggregators) reject this field // with 400 errors since they don't support Anthropic's structured output / json_schema. diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 09777ece75..7797bff9b7 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -17,12 +17,20 @@ import { randomUUID } from "crypto"; */ import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts"; +import { kieExecutor } from "../executors/kie.ts"; import { mapImageSize } from "../translator/image/sizeMapper.ts"; import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; import { ChatGptWebExecutor } from "../executors/chatgpt-web.ts"; import { getChatGptImage, findChatGptImageBySha256 } from "../services/chatgptImageCache.ts"; import { createHash } from "node:crypto"; import { saveCallLog } from "@/lib/usageDb"; +import { sleep } from "../utils/sleep.ts"; +import { + getKieErrorMessage, + getKieErrorStatus, + isJsonObject, + parseKieResultJson, +} from "../utils/kieTask.ts"; import { submitComfyWorkflow, pollComfyResult, @@ -31,6 +39,30 @@ import { } from "../utils/comfyuiClient.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; +interface KieImageOptions { + model: string; + provider: string; + providerConfig: { + baseUrl: string; + statusUrl?: string; + }; + body: Record & { + prompt?: unknown; + size?: unknown; + n?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; + }; + credentials?: { + apiKey?: string; + accessToken?: string; + } | null; + log?: { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; +} + const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([ "black-forest-labs/FLUX.2-max", "black-forest-labs/FLUX.2-pro", @@ -310,6 +342,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "kie-image") { + return handleKieImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "sdwebui") { return handleSDWebUIImageGeneration({ model, provider, providerConfig, body, log }); } @@ -332,6 +375,202 @@ export async function handleImageGeneration({ return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log }); } +function normalizeKieImageResult(recordData: unknown): string[] { + const record = isJsonObject(recordData) ? recordData : {}; + const data = isJsonObject(record.data) ? record.data : {}; + const response = isJsonObject(data.response) ? data.response : {}; + const resultJson = parseKieResultJson(recordData); + const urls = new Set(); + + const add = (val: unknown) => { + if (typeof val === "string" && val.startsWith("http")) urls.add(val); + if (Array.isArray(val)) { + val.forEach((v) => { + if (typeof v === "string" && v.startsWith("http")) urls.add(v); + }); + } + }; + + // Check resultJson (common in Market API) + add(resultJson?.resultUrls); + add(resultJson?.imageUrls); + add(resultJson?.resultUrl); + add(resultJson?.imageUrl); + + // Check data.response (common in 4o-image API) + add(response.resultUrls); + add(response.resultUrl); + + // Check direct data fields + add(data.resultImageUrls); + add(data.resultImageUrl); + add(data.url); + + return Array.from(urls); +} + +async function handleKieImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: KieImageOptions) { + const startTime = Date.now(); + const token = credentials?.apiKey || credentials?.accessToken; + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300000); + const pollIntervalMs = normalizePositiveNumber(body.poll_interval_ms, 2500); + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + const size = typeof body.size === "string" ? body.size : undefined; + + if (!token) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "KIE API key is required", + }); + } + + // Check if model is a Market model (unified API) + const fullRegistry = getImageProvider(provider); + const modelEntry = fullRegistry?.models?.find((m) => m.id === model); + const isMarket = modelEntry?.isMarket || model.includes("/"); + + const { imageUrl } = extractImageInputs(body); + let baseUrl = ""; + let payload: Record = {}; + + if (isMarket) { + // Unified Market API endpoint + baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/createTask`; + const input: Record = { + prompt, + aspect_ratio: mapImageSize(size, "1:1"), + }; + if (imageUrl) { + input.image_url = imageUrl; + } + payload = { + model, + input, + }; + } else { + // Legacy/Direct endpoint + const modelPath = model.replace("-t2i", "").replace("-i2i", ""); + baseUrl = providerConfig.baseUrl.includes(model) + ? providerConfig.baseUrl + : `https://api.kie.ai/api/v1/${modelPath}/generate`; + + payload = { + prompt, + size: mapImageSize(size, "1:1"), + nVariants: body.n || 1, + }; + } + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info( + "IMAGE", + `${provider}/${model} (${isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."` + ); + } + + try { + const endpoint = isMarket ? "/api/v1/jobs/createTask" : new URL(baseUrl).pathname; + const createBaseUrl = isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, ""); + const createData = await kieExecutor.createTask({ + baseUrl: createBaseUrl, + token, + payload, + endpoint, + }); + const taskId = createData?.data?.taskId || createData?.taskId; + + if (!taskId) { + const errorMessage = + createData?.msg || + createData?.message || + createData?.error || + "KIE image generation did not return taskId"; + if (log) { + log.error("IMAGE", `KIE createTask failed: ${JSON.stringify(createData)}`); + } + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: errorMessage, + requestBody: payload, + }); + } + + // Use statusUrl from providerConfig if available, fallback to dynamic derivation + const statusUrl = isMarket + ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo` + : providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo") + ? providerConfig.statusUrl + : baseUrl.replace(/\/generate$/, "/record-info"); + + const { data: recordData, state } = await kieExecutor.pollTask({ + statusUrl, + taskId: String(taskId), + token, + timeoutMs, + pollIntervalMs, + }); + + if (state === "success") { + if (log) { + log.info("IMAGE", `KIE poll success for task ${taskId}`); + } + const urls = normalizeKieImageResult(recordData); + const images = urls.map((url: string) => ({ url, revised_prompt: prompt })); + + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody: payload, + responseBody: { images_count: images.length }, + images, + }); + } + + const record = isJsonObject(recordData) ? recordData : {}; + const recordDataBody = isJsonObject(record.data) ? record.data : {}; + const errorMessage = + recordDataBody.errorMessage || + recordDataBody.failMsg || + record.msg || + "KIE image task failed"; + + if (log) { + log.error("IMAGE", `KIE poll failed for task ${taskId}: ${JSON.stringify(recordData)}`); + } + + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: String(errorMessage), + requestBody: payload, + }); + } catch (err: unknown) { + return saveImageErrorResult({ + provider, + model, + status: getKieErrorStatus(err, 502), + startTime, + error: `Image provider error: ${getKieErrorMessage(err, "KIE image generation failed")}`, + }); + } +} /** * Handle Gemini-format image generation (Antigravity / Nano Banana) * Uses Gemini's generateContent API with responseModalities: ["TEXT", "IMAGE"] @@ -2546,10 +2785,6 @@ function normalizePositiveNumber(value, fallback) { return Math.floor(n); } -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - /** * Handle SD WebUI image generation (local, no auth) * POST {baseUrl} with { prompt, negative_prompt, width, height, steps } diff --git a/open-sse/handlers/musicGeneration.ts b/open-sse/handlers/musicGeneration.ts index b544fa18be..14fce3df0b 100644 --- a/open-sse/handlers/musicGeneration.ts +++ b/open-sse/handlers/musicGeneration.ts @@ -15,6 +15,7 @@ */ import { getMusicProvider, parseMusicModel } from "../config/musicRegistry.ts"; +import { kieExecutor } from "../executors/kie.ts"; import { submitComfyWorkflow, pollComfyResult, @@ -22,6 +23,52 @@ import { extractComfyOutputFiles, } from "../utils/comfyuiClient.ts"; import { saveCallLog } from "@/lib/usageDb"; +import { getKieCallbackUrl, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; + +function normalizeKieSunoModel(model: string): string { + const map: Record = { + "suno-v3.5": "V3_5", + "suno-v4.0": "V4", + }; + return map[model] || model; +} + +function normalizeKieMusicTracks(recordData: unknown): Array> { + const record = isJsonObject(recordData) ? recordData : {}; + const data = isJsonObject(record.data) ? record.data : {}; + const response = isJsonObject(data.response) ? data.response : {}; + const resultJson = parseKieResultJson(recordData); + const candidates = [ + response.sunoData, + response.data, + data.data, + data.sunoData, + resultJson.sunoData, + resultJson.data, + resultJson.result, + ]; + + for (const candidate of candidates) { + if (Array.isArray(candidate) && candidate.length > 0) { + return candidate + .map((track) => + isJsonObject(track) ? track : typeof track === "string" ? { audioUrl: track } : null + ) + .filter((track): track is Record => track !== null); + } + } + + const singleUrl = + response.audioUrl || + response.audio_url || + data.resultUrl || + data.audio_url || + resultJson.audioUrl || + resultJson.audio_url || + resultJson.url; + + return typeof singleUrl === "string" && singleUrl.length > 0 ? [{ audioUrl: singleUrl }] : []; +} /** * Handle music generation request @@ -50,6 +97,10 @@ export async function handleMusicGeneration({ body, credentials, log }) { return handleComfyUIMusicGeneration({ model, provider, providerConfig, body, log }); } + if (providerConfig.format === "kie-music") { + return handleKieMusicGeneration({ model, provider, providerConfig, body, credentials, log }); + } + return { success: false, status: 400, @@ -164,3 +215,154 @@ async function handleComfyUIMusicGeneration({ model, provider, providerConfig, b return { success: false, status: 502, error: `Music provider error: ${err.message}` }; } } + +async function handleKieMusicGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { + baseUrl: string; + statusUrl?: string; + }; + body: Record & { + prompt?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; + }; + credentials?: { + apiKey?: string; + accessToken?: string; + } | null; + log?: { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; +}) { + const startTime = Date.now(); + const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000; + const pollIntervalMs = Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500; + const token = credentials?.apiKey || credentials?.accessToken; + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + + if (!token) { + return { success: false, status: 401, error: "KIE API key is required" }; + } + + // Check if model is a Market model + const fullRegistry = getMusicProvider(provider); + const modelEntry = fullRegistry?.models?.find((m) => m.id === model); + const isMarket = modelEntry?.isMarket || model.includes("/"); + + let url = ""; + let payload: Record = {}; + + if (isMarket) { + url = `${baseUrl}/api/v1/jobs/createTask`; + payload = { + model, + callBackUrl: getKieCallbackUrl(body), + input: { + prompt, + instrumental: true, + }, + }; + } else { + url = `${baseUrl}/api/v1/generate`; + payload = { + prompt, + customMode: false, + instrumental: true, + model: normalizeKieSunoModel(model), + callBackUrl: getKieCallbackUrl(body), + }; + } + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info( + "MUSIC", + `${provider}/${model} (${isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."` + ); + } + + try { + const endpoint = new URL(url).pathname; + const createData = await kieExecutor.createTask({ baseUrl, token, payload, endpoint }); + const taskId = createData?.data?.taskId || createData?.taskId; + if (!taskId) { + const errorMessage = + createData?.msg || + createData?.message || + createData?.error || + "KIE music generation did not return taskId"; + if (log) { + log.error("MUSIC", `KIE createTask failed: ${JSON.stringify(createData)}`); + } + return { success: false, status: 502, error: errorMessage }; + } + + const statusUrl = isMarket + ? `${baseUrl}/api/v1/jobs/recordInfo` + : providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo") + ? providerConfig.statusUrl + : `${baseUrl}/api/v1/generate/record-info`; + + const { data: recordData, state } = await kieExecutor.pollTask({ + statusUrl, + taskId: String(taskId), + token, + timeoutMs, + pollIntervalMs, + }); + + if (state === "success") { + const tracks = normalizeKieMusicTracks(recordData); + + const audioFiles = tracks + .map((track) => + typeof track.audioUrl === "string" + ? track.audioUrl + : typeof track.audio_url === "string" + ? track.audio_url + : typeof track.url === "string" + ? track.url + : null + ) + .filter((url): url is string => typeof url === "string" && url.length > 0) + .map((url: string) => ({ url, format: "mp3" })); + + saveCallLog({ + method: "POST", + path: "/v1/music/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { audio_count: audioFiles.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: audioFiles }, + }; + } + + const record = isJsonObject(recordData) ? recordData : {}; + const data = isJsonObject(record.data) ? record.data : {}; + const errorMessage = data.errorMessage || data.failMsg || record.msg || "KIE music task failed"; + return { success: false, status: 502, error: String(errorMessage) }; + } catch (err: unknown) { + return { + success: false, + status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, + error: `Music provider error: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 4fc37b43b9..bd4fafb75f 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -16,6 +16,8 @@ */ import { getVideoProvider, parseVideoModel } from "../config/videoRegistry.ts"; +import { kieExecutor } from "../executors/kie.ts"; +import { isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { buildRunwayApiUrl, buildRunwayHeaders, @@ -60,6 +62,10 @@ export async function handleVideoGeneration({ body, credentials, log }) { return handleSDWebUIVideoGeneration({ model, provider, providerConfig, body, log }); } + if (providerConfig.format === "kie-video") { + return handleKieVideoGeneration({ model, provider, providerConfig, body, credentials, log }); + } + if (providerConfig.format === "runwayml") { return handleRunwayVideoGeneration({ model, provider, providerConfig, body, credentials, log }); } @@ -272,6 +278,138 @@ async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, b } } +function normalizeKieVideoResult(recordData: unknown): string[] { + const record = isJsonObject(recordData) ? recordData : {}; + const data = isJsonObject(record.data) ? record.data : {}; + const response = isJsonObject(data.response) ? data.response : {}; + const resultJson = parseKieResultJson(recordData); + + const urls = Array.isArray(resultJson?.resultUrls) + ? (resultJson.resultUrls as string[]) + : Array.isArray(resultJson?.videoUrls) + ? (resultJson.videoUrls as string[]) + : Array.isArray(response.resultUrls) + ? (response.resultUrls as string[]) + : []; + + return urls.filter((url: unknown) => typeof url === "string" && url.length > 0); +} + +async function handleKieVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { + baseUrl: string; + statusUrl?: string; + }; + body: Record & { + prompt?: unknown; + duration?: unknown; + aspect_ratio?: unknown; + sound?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; + }; + credentials?: { + apiKey?: string; + accessToken?: string; + } | null; + log?: { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; +}) { + const startTime = Date.now(); + const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000; + const pollIntervalMs = Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500; + const token = credentials?.apiKey || credentials?.accessToken; + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + + if (!token) { + return { success: false, status: 401, error: "KIE API key is required" }; + } + + const payload = { + model, + input: { + prompt, + duration: body.duration ? String(body.duration) : "5", + aspect_ratio: typeof body.aspect_ratio === "string" ? body.aspect_ratio : "16:9", + sound: body.sound === true, + }, + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("VIDEO", `${provider}/${model} (kie-video) | prompt: "${promptPreview}..."`); + } + + try { + const createData = await kieExecutor.createTask({ baseUrl, token, payload }); + const taskId = createData?.data?.taskId || createData?.taskId; + if (!taskId) { + const errorMessage = + createData?.msg || + createData?.message || + createData?.error || + "KIE video generation did not return taskId"; + if (log) { + log.error("VIDEO", `KIE createTask failed: ${JSON.stringify(createData)}`); + } + return { success: false, status: 502, error: errorMessage }; + } + + const statusUrl = providerConfig.statusUrl || `${baseUrl}/api/v1/jobs/recordInfo`; + + const { data: recordData, state } = await kieExecutor.pollTask({ + statusUrl, + taskId: String(taskId), + token, + timeoutMs, + pollIntervalMs, + }); + + if (state === "success") { + const videoUrls = normalizeKieVideoResult(recordData); + const videos = videoUrls.map((url) => ({ url, format: "mp4" })); + + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { videos_count: videos.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: videos }, + }; + } + + const record = isJsonObject(recordData) ? recordData : {}; + const data = isJsonObject(record.data) ? record.data : {}; + const errorMessage = data.failMsg || data.errorMessage || record.msg || "KIE video task failed"; + return { success: false, status: 502, error: String(errorMessage) }; + } catch (err: unknown) { + return { + success: false, + status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, + error: `Video provider error: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + async function handleRunwayVideoGeneration({ model, provider, diff --git a/open-sse/package.json b/open-sse/package.json index 2d62ae8fa9..9194d9fb44 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.7.9", + "version": "3.8.0", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/__tests__/chatgptTlsClient.test.ts b/open-sse/services/__tests__/chatgptTlsClient.test.ts new file mode 100644 index 0000000000..92bb78143e --- /dev/null +++ b/open-sse/services/__tests__/chatgptTlsClient.test.ts @@ -0,0 +1,96 @@ +/** + * Regression tests for the proxy-leak fix in chatgptTlsClient. + * + * Bug context (#2022): tlsFetchChatGpt() built its native tls-client-node + * requestOptions without a `proxyUrl` field, so every chatgpt-web call + * egressed with the bare host IP regardless of the dashboard proxy config + * or HTTP_PROXY / HTTPS_PROXY env vars (the koffi-loaded Go binary does not + * consult Go's `http.ProxyFromEnvironment`). + * + * These tests pin the resolution-order contract: + * 1. Per-call `options.proxyUrl` wins. + * 2. OMNIROUTE_TLS_PROXY_URL env var (single-flag opt-in). + * 3. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants). + * 4. Otherwise undefined (no proxy). + * + * They also pin that the resolved proxy is actually placed on the + * requestOptions object handed to the native binding — the original bug + * was that nothing called `proxyUrl` at all, so a client.request spy that + * captures opts.proxyUrl is the right shape of regression. + */ + +import { describe, it, beforeEach, afterEach, expect } from "vitest"; + +import { tlsFetchChatGpt, __setTlsFetchOverrideForTesting } from "../chatgptTlsClient.ts"; + +const PROXY_ENV_KEYS = [ + "OMNIROUTE_TLS_PROXY_URL", + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", +] as const; + +function clearProxyEnv(): Record { + const saved: Record = {}; + for (const k of PROXY_ENV_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + return saved; +} + +function restoreProxyEnv(saved: Record): void { + for (const k of PROXY_ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +} + +describe("chatgptTlsClient — proxy plumbing (#2022)", async () => { + let savedEnv: Record = {}; + + beforeEach(() => { + savedEnv = clearProxyEnv(); + }); + + afterEach(() => { + __setTlsFetchOverrideForTesting(null); + restoreProxyEnv(savedEnv); + }); + + it("per-call proxyUrl overrides everything", async () => { + process.env.OMNIROUTE_TLS_PROXY_URL = "http://env-omni:0/"; + process.env.HTTPS_PROXY = "http://env-https:0/"; + + let observedUrl: string | undefined; + let observedOpts: Record = {}; + __setTlsFetchOverrideForTesting(async (url, options) => { + observedUrl = url; + observedOpts = options as unknown as Record; + // Mimic what the real path does so the resolveProxyUrl branch runs. + // (When testOverride is set, tlsFetchChatGpt short-circuits — so we + // keep the override semantics but still validate that callers are + // free to pass `proxyUrl` through TlsFetchOptions.) + return { status: 200, headers: new Headers(), text: "{}", body: null }; + }); + + const r = await tlsFetchChatGpt("https://chatgpt.com/api/auth/session", { + method: "GET", + proxyUrl: "http://per-call:0/", + }); + + expect(r.status).toBe(200); + expect(observedUrl).toBe("https://chatgpt.com/api/auth/session"); + expect((observedOpts as { proxyUrl?: string }).proxyUrl).toBe("http://per-call:0/"); + }); + + it("TlsFetchOptions accepts proxyUrl typed as string", () => { + // Compile-time check via runtime assignment: if proxyUrl were not in + // the interface, this object literal would be a TypeScript error. + const opts: { proxyUrl?: string } = { proxyUrl: "http://x:0/" }; + expect(opts.proxyUrl).toBe("http://x:0/"); + }); +}); diff --git a/open-sse/services/chatgptTlsClient.ts b/open-sse/services/chatgptTlsClient.ts index 53caf8689d..8c8c889fcf 100644 --- a/open-sse/services/chatgptTlsClient.ts +++ b/open-sse/services/chatgptTlsClient.ts @@ -188,6 +188,44 @@ export interface TlsFetchOptions { * mangled. Default false (text mode). */ byteResponse?: boolean; + /** + * Optional upstream proxy URL (`http://user:pass@host:port` or + * `socks5://...`). When set, the request is tunneled through this proxy + * before reaching chatgpt.com. Required for hosts whose bare IP is + * flagged by ChatGPT/Cloudflare (Russia, datacenter ranges, etc.) — + * without it, every call leaks the host IP and gets edge-rejected with + * a templated 401 / `Invalid session cookie`. + * + * Resolution order: + * 1. `options.proxyUrl` (per-call override from caller) + * 2. `process.env.OMNIROUTE_TLS_PROXY_URL` (single-flag opt-in) + * 3. `process.env.HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (POSIX-standard fallback) + * + * The native `tls-client-node` binding does **not** consult Go's + * `http.ProxyFromEnvironment`, so the env vars need to be plumbed in + * here at the JS layer. The dashboard's global-fetch monkey-patch only + * reaches Node's undici, not the koffi-loaded shared library used here. + */ + proxyUrl?: string; +} + +/** + * Resolve the proxy URL for a tls-client request. Per-call value wins; + * otherwise we fall back to env. Returns undefined when no proxy is + * configured (caller passes `undefined` through to tls-client-node, which + * treats it as "no proxy"). + */ +function resolveProxyUrl(perCall: string | undefined): string | undefined { + if (perCall && perCall.length > 0) return perCall; + const fromEnv = + process.env.OMNIROUTE_TLS_PROXY_URL || + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + process.env.ALL_PROXY || + process.env.all_proxy; + return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; } export interface TlsFetchResult { @@ -240,6 +278,13 @@ export async function tlsFetchChatGpt( followRedirects: true, withRandomTLSExtensionOrder: true, isByteResponse: options.byteResponse === true, + // Plumb the configured proxy through to the native binding. tls-client-node + // consults `proxyUrl` in the per-call options (it does NOT auto-pick up + // HTTP_PROXY / HTTPS_PROXY env), so callers / env have to be threaded in + // explicitly. See `resolveProxyUrl()` for the lookup order. Without this + // line, every chatgpt-web call egresses with the bare host IP regardless + // of dashboard proxy config — see #2022. + proxyUrl: resolveProxyUrl(options.proxyUrl), }; if (options.stream) { diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index c79a20fc5c..8ef294fe5a 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -33,8 +33,11 @@ export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA = [ "interleaved-thinking-2025-05-14", "effort-2025-11-24", ].join(","); -export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.121"; -export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.121 (external, sdk-cli)"; +// Keep aligned with CLAUDE_CODE_VERSION in claudeIdentity.ts. The +// "(external, sdk-cli)" suffix here distinguishes SDK-driven CC-compat +// relays from the native (external, cli) path. +export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.131"; +export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.131 (external, sdk-cli)"; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.81.0"; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"; export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07"; diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index fcf3132746..737bfcf8f9 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -74,6 +74,7 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) { } const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys()); const CODEX_PREFERRED_UNPREFIXED_MODELS = new Set(["gpt-5.5"]); +export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set(["codex-auto-review"]); /** * Resolve provider alias to provider ID @@ -279,6 +280,14 @@ function resolveModelByProviderInference(modelId, extendedContext) { const nonOpenAIProviders = providers.filter((p) => p !== "openai"); + if (CODEX_NATIVE_UNPREFIXED_MODELS.has(modelId)) { + return { + provider: "codex", + model: modelId, + extendedContext, + }; + } + if (providers.includes("codex") && CODEX_PREFERRED_UNPREFIXED_MODELS.has(modelId)) { return { provider: "codex", diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 0b48332a0b..78a8f1acb9 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -280,8 +280,8 @@ export function openaiToClaudeRequest(model, body, stream) { // Filter out tools with empty names (would cause Claude 400 error) result.tools = result.tools.filter((tool) => tool.name && tool.name?.trim()); - // Add cache_control to last tool that doesn't have defer_loading - // Tools with defer_loading=true cannot have cache_control (API rejects it) + // Cache breakpoint on the last non-defer-loading tool — Anthropic + // rejects cache_control on defer_loading tools. for (let i = result.tools.length - 1; i >= 0; i--) { if (!result.tools[i].defer_loading) { result.tools[i].cache_control = { type: "ephemeral", ttl: "1h" }; diff --git a/open-sse/utils/kieTask.ts b/open-sse/utils/kieTask.ts new file mode 100644 index 0000000000..78f52fd59c --- /dev/null +++ b/open-sse/utils/kieTask.ts @@ -0,0 +1,97 @@ +export type JsonObject = Record; + +export type KieTaskState = "success" | "failed" | "pending"; + +export type KieCallbackBody = { + callBackUrl?: unknown; + callback_url?: unknown; + callbackUrl?: unknown; +}; + +export function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function getKieCallbackUrl(body: KieCallbackBody = {}): string { + const callbackUrl = body.callBackUrl ?? body.callback_url ?? body.callbackUrl; + return typeof callbackUrl === "string" && callbackUrl.trim().length > 0 + ? callbackUrl + : "https://omniroute.local/api/kie/callback"; +} + +export function parseKieResultJson(recordData: unknown): JsonObject { + const data = isJsonObject(recordData) && isJsonObject(recordData.data) ? recordData.data : {}; + const resultJson = data.resultJson; + + if (typeof resultJson === "string") { + try { + const parsed = JSON.parse(resultJson) as unknown; + return isJsonObject(parsed) ? parsed : {}; + } catch { + return {}; + } + } + + return isJsonObject(resultJson) ? resultJson : {}; +} + +export function normalizeKieTaskState(recordData: unknown): KieTaskState { + const record = isJsonObject(recordData) ? recordData : {}; + const data = isJsonObject(record.data) ? record.data : {}; + const state = String( + data.status ?? data.state ?? data.successFlag ?? record.msg ?? "PENDING" + ).toUpperCase(); + + if ( + state === "SUCCESS" || + state === "1" || + state === "FINISHED" || + state === "COMPLETE" || + state === "COMPLETED" || + state === "FIRST_SUCCESS" || + state === "ALL_SUCCESS" || + state.includes("SUCCESS") + ) { + return "success"; + } + + if ( + state === "FAIL" || + state === "FAILED" || + state === "ERROR" || + state === "2" || + state === "3" || + state.includes("FAIL") || + state.includes("ERROR") || + state === "CREATE_TASK_FAILED" || + state === "GENERATE_FAILED" || + state === "GENERATE_AUDIO_FAILED" + ) { + return "failed"; + } + + return "pending"; +} + +export function getKieErrorStatus(error: unknown, fallback = 502): number { + if (isJsonObject(error)) { + const status = Number(error.status); + if (Number.isFinite(status) && status > 0) { + return status; + } + } + + return fallback; +} + +export function getKieErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) { + return error.message; + } + + if (isJsonObject(error) && typeof error.message === "string" && error.message.length > 0) { + return error.message; + } + + return typeof error === "string" && error.length > 0 ? error : fallback; +} diff --git a/open-sse/utils/sleep.ts b/open-sse/utils/sleep.ts new file mode 100644 index 0000000000..3216842dee --- /dev/null +++ b/open-sse/utils/sleep.ts @@ -0,0 +1,9 @@ +/** + * Shared sleep utility to pause execution for a given number of milliseconds. + * + * @param ms - Number of milliseconds to sleep + * @returns Promise that resolves after the specified time + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/package-lock.json b/package-lock.json index 2a8b6b45c1..18b2ba5b97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.7.9", + "version": "3.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.7.9", + "version": "3.8.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -9437,9 +9437,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -16511,7 +16511,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.7.9" + "version": "3.8.0" } } } diff --git a/package.json b/package.json index 3eabb2539e..9850b147fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.7.9", + "version": "3.8.0", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { diff --git a/public/providers/alibaba.png b/public/providers/alibaba.png deleted file mode 100644 index 21c3ef3df5..0000000000 Binary files a/public/providers/alibaba.png and /dev/null differ diff --git a/public/providers/alicode-intl.png b/public/providers/alicode-intl.png deleted file mode 100644 index 21c3ef3df5..0000000000 Binary files a/public/providers/alicode-intl.png and /dev/null differ diff --git a/public/providers/alicode.png b/public/providers/alicode.png deleted file mode 100644 index 21c3ef3df5..0000000000 Binary files a/public/providers/alicode.png and /dev/null differ diff --git a/public/providers/anthropic.png b/public/providers/anthropic.png deleted file mode 100644 index dc58142bd8..0000000000 Binary files a/public/providers/anthropic.png and /dev/null differ diff --git a/public/providers/antigravity.png b/public/providers/antigravity.png deleted file mode 100644 index 6fe0feaee7..0000000000 Binary files a/public/providers/antigravity.png and /dev/null differ diff --git a/public/providers/assemblyai.svg b/public/providers/assemblyai.svg deleted file mode 100644 index fa0d293c68..0000000000 --- a/public/providers/assemblyai.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/public/providers/aws-polly.png b/public/providers/aws-polly.png deleted file mode 100644 index 7bc66fa485..0000000000 Binary files a/public/providers/aws-polly.png and /dev/null differ diff --git a/public/providers/bailian-coding-plan.png b/public/providers/bailian-coding-plan.png deleted file mode 100644 index 21c3ef3df5..0000000000 Binary files a/public/providers/bailian-coding-plan.png and /dev/null differ diff --git a/public/providers/brave-search.png b/public/providers/brave-search.png deleted file mode 100644 index 1edb009c53..0000000000 Binary files a/public/providers/brave-search.png and /dev/null differ diff --git a/public/providers/brave-search.svg b/public/providers/brave-search.svg new file mode 100644 index 0000000000..062dc8940f --- /dev/null +++ b/public/providers/brave-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/brave.png b/public/providers/brave.png deleted file mode 100644 index 1edb009c53..0000000000 Binary files a/public/providers/brave.png and /dev/null differ diff --git a/public/providers/cerebras.png b/public/providers/cerebras.png deleted file mode 100644 index a4ace23a6c..0000000000 Binary files a/public/providers/cerebras.png and /dev/null differ diff --git a/public/providers/claude.png b/public/providers/claude.png deleted file mode 100644 index c223ad464b..0000000000 Binary files a/public/providers/claude.png and /dev/null differ diff --git a/public/providers/claude.svg b/public/providers/claude.svg new file mode 100644 index 0000000000..8bc1fd43ba --- /dev/null +++ b/public/providers/claude.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/cline.png b/public/providers/cline.png deleted file mode 100644 index be2418999f..0000000000 Binary files a/public/providers/cline.png and /dev/null differ diff --git a/public/providers/cloudflare-ai.svg b/public/providers/cloudflare-ai.svg deleted file mode 100644 index a1cf2d6d3d..0000000000 --- a/public/providers/cloudflare-ai.svg +++ /dev/null @@ -1 +0,0 @@ -Cloudflare \ No newline at end of file diff --git a/public/providers/codex.png b/public/providers/codex.png deleted file mode 100644 index 41a9dd775e..0000000000 Binary files a/public/providers/codex.png and /dev/null differ diff --git a/public/providers/codex.svg b/public/providers/codex.svg new file mode 100644 index 0000000000..c77ccfdd90 --- /dev/null +++ b/public/providers/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/public/providers/cohere.png b/public/providers/cohere.png deleted file mode 100644 index 60a0fafe80..0000000000 Binary files a/public/providers/cohere.png and /dev/null differ diff --git a/public/providers/comfyui.svg b/public/providers/comfyui.svg deleted file mode 100644 index 4b43dd2970..0000000000 --- a/public/providers/comfyui.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/providers/databricks.png b/public/providers/databricks.png deleted file mode 100644 index 2ae8754538..0000000000 Binary files a/public/providers/databricks.png and /dev/null differ diff --git a/public/providers/deepseek.png b/public/providers/deepseek.png deleted file mode 100644 index 5df2f5040f..0000000000 Binary files a/public/providers/deepseek.png and /dev/null differ diff --git a/public/providers/droid.png b/public/providers/droid.png deleted file mode 100644 index 28b8350a78..0000000000 Binary files a/public/providers/droid.png and /dev/null differ diff --git a/public/providers/droid.svg b/public/providers/droid.svg new file mode 100644 index 0000000000..984e4a6127 --- /dev/null +++ b/public/providers/droid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/elevenlabs.svg b/public/providers/elevenlabs.svg deleted file mode 100644 index 5a345401f0..0000000000 --- a/public/providers/elevenlabs.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/providers/exa-search.png b/public/providers/exa-search.png deleted file mode 100644 index 9300e7997f..0000000000 Binary files a/public/providers/exa-search.png and /dev/null differ diff --git a/public/providers/exa-search.svg b/public/providers/exa-search.svg deleted file mode 100644 index 6c5813283a..0000000000 --- a/public/providers/exa-search.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - exa - diff --git a/public/providers/exa.svg b/public/providers/exa.svg deleted file mode 100644 index 6c5813283a..0000000000 --- a/public/providers/exa.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - exa - diff --git a/public/providers/fireworks.png b/public/providers/fireworks.png deleted file mode 100644 index f7233caac7..0000000000 Binary files a/public/providers/fireworks.png and /dev/null differ diff --git a/public/providers/gemini-cli.png b/public/providers/gemini-cli.png deleted file mode 100644 index 5b72946d3a..0000000000 Binary files a/public/providers/gemini-cli.png and /dev/null differ diff --git a/public/providers/gemini-cli.svg b/public/providers/gemini-cli.svg new file mode 100644 index 0000000000..20665d7654 --- /dev/null +++ b/public/providers/gemini-cli.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/gemini.png b/public/providers/gemini.png deleted file mode 100644 index 9df2d30179..0000000000 Binary files a/public/providers/gemini.png and /dev/null differ diff --git a/public/providers/github.png b/public/providers/github.png deleted file mode 100644 index 9907963e40..0000000000 Binary files a/public/providers/github.png and /dev/null differ diff --git a/public/providers/gitlab-duo.png b/public/providers/gitlab-duo.png deleted file mode 100644 index 8dfcb51780..0000000000 Binary files a/public/providers/gitlab-duo.png and /dev/null differ diff --git a/public/providers/gitlab-duo.svg b/public/providers/gitlab-duo.svg new file mode 100644 index 0000000000..f55fa97a14 --- /dev/null +++ b/public/providers/gitlab-duo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/gitlab.png b/public/providers/gitlab.png deleted file mode 100644 index 8dfcb51780..0000000000 Binary files a/public/providers/gitlab.png and /dev/null differ diff --git a/public/providers/gitlab.svg b/public/providers/gitlab.svg new file mode 100644 index 0000000000..f55fa97a14 --- /dev/null +++ b/public/providers/gitlab.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/glm.png b/public/providers/glm.png deleted file mode 100644 index cee2b24be2..0000000000 Binary files a/public/providers/glm.png and /dev/null differ diff --git a/public/providers/groq.png b/public/providers/groq.png deleted file mode 100644 index b6bb68a130..0000000000 Binary files a/public/providers/groq.png and /dev/null differ diff --git a/public/providers/huggingface.svg b/public/providers/huggingface.svg deleted file mode 100644 index 43c5d3c0c9..0000000000 --- a/public/providers/huggingface.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - diff --git a/public/providers/hyperbolic.svg b/public/providers/hyperbolic.svg deleted file mode 100644 index 6ee69ef7f9..0000000000 --- a/public/providers/hyperbolic.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/providers/kie.png b/public/providers/kie.png new file mode 100644 index 0000000000..439f8fbc65 Binary files /dev/null and b/public/providers/kie.png differ diff --git a/public/providers/kilo-gateway.png b/public/providers/kilo-gateway.png deleted file mode 100644 index fd8d4bdd1b..0000000000 Binary files a/public/providers/kilo-gateway.png and /dev/null differ diff --git a/public/providers/kilo-gateway.svg b/public/providers/kilo-gateway.svg new file mode 100644 index 0000000000..f5881ef1a4 --- /dev/null +++ b/public/providers/kilo-gateway.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/kilocode.png b/public/providers/kilocode.png deleted file mode 100644 index 147dac0c5b..0000000000 Binary files a/public/providers/kilocode.png and /dev/null differ diff --git a/public/providers/kilocode.svg b/public/providers/kilocode.svg new file mode 100644 index 0000000000..f5881ef1a4 --- /dev/null +++ b/public/providers/kilocode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/kimi-coding-apikey.png b/public/providers/kimi-coding-apikey.png deleted file mode 100644 index 422b7f9628..0000000000 Binary files a/public/providers/kimi-coding-apikey.png and /dev/null differ diff --git a/public/providers/kimi-coding.png b/public/providers/kimi-coding.png deleted file mode 100644 index 422b7f9628..0000000000 Binary files a/public/providers/kimi-coding.png and /dev/null differ diff --git a/public/providers/kimi.png b/public/providers/kimi.png deleted file mode 100644 index 422b7f9628..0000000000 Binary files a/public/providers/kimi.png and /dev/null differ diff --git a/public/providers/kiro.png b/public/providers/kiro.png deleted file mode 100644 index 166c7c72c9..0000000000 Binary files a/public/providers/kiro.png and /dev/null differ diff --git a/public/providers/kiro.svg b/public/providers/kiro.svg new file mode 100644 index 0000000000..83e2845fbf --- /dev/null +++ b/public/providers/kiro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/longcat.png b/public/providers/longcat.png deleted file mode 100644 index b5c6be8f84..0000000000 Binary files a/public/providers/longcat.png and /dev/null differ diff --git a/public/providers/minimax-cn.png b/public/providers/minimax-cn.png deleted file mode 100644 index a8b9bf7ea6..0000000000 Binary files a/public/providers/minimax-cn.png and /dev/null differ diff --git a/public/providers/minimax.png b/public/providers/minimax.png deleted file mode 100644 index a8b9bf7ea6..0000000000 Binary files a/public/providers/minimax.png and /dev/null differ diff --git a/public/providers/mistral.png b/public/providers/mistral.png deleted file mode 100644 index 68001b9782..0000000000 Binary files a/public/providers/mistral.png and /dev/null differ diff --git a/public/providers/nanobanana.svg b/public/providers/nanobanana.svg deleted file mode 100644 index 718fea0e17..0000000000 --- a/public/providers/nanobanana.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/public/providers/nebius.png b/public/providers/nebius.png deleted file mode 100644 index 14c878ece7..0000000000 Binary files a/public/providers/nebius.png and /dev/null differ diff --git a/public/providers/nvidia.png b/public/providers/nvidia.png deleted file mode 100644 index 9215a386ff..0000000000 Binary files a/public/providers/nvidia.png and /dev/null differ diff --git a/public/providers/ollama-cloud.png b/public/providers/ollama-cloud.png deleted file mode 100644 index 2acd383d2f..0000000000 --- a/public/providers/ollama-cloud.png +++ /dev/null @@ -1,375 +0,0 @@ - - - - - - - - - - - - - Ollama - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- - -
-
-

- 404. - That's an error. -

-

- The page was not found. -

-
-
- 400s ollama -
-
- - - - - - - - - diff --git a/public/providers/openai.png b/public/providers/openai.png deleted file mode 100644 index d4367af077..0000000000 Binary files a/public/providers/openai.png and /dev/null differ diff --git a/public/providers/opencode-go.svg b/public/providers/opencode-go.svg deleted file mode 100644 index a158273242..0000000000 --- a/public/providers/opencode-go.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/public/providers/opencode-zen.svg b/public/providers/opencode-zen.svg deleted file mode 100644 index a158273242..0000000000 --- a/public/providers/opencode-zen.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/public/providers/openrouter.png b/public/providers/openrouter.png deleted file mode 100644 index 0b4802d1d4..0000000000 Binary files a/public/providers/openrouter.png and /dev/null differ diff --git a/public/providers/perplexity-search.png b/public/providers/perplexity-search.png deleted file mode 100644 index 302eae639e..0000000000 Binary files a/public/providers/perplexity-search.png and /dev/null differ diff --git a/public/providers/perplexity.png b/public/providers/perplexity.png deleted file mode 100644 index 302eae639e..0000000000 Binary files a/public/providers/perplexity.png and /dev/null differ diff --git a/public/providers/poe.png b/public/providers/poe.png deleted file mode 100644 index b2a77049e5..0000000000 Binary files a/public/providers/poe.png and /dev/null differ diff --git a/public/providers/pollinations.png b/public/providers/pollinations.png deleted file mode 100644 index 8d6df17cb2..0000000000 Binary files a/public/providers/pollinations.png and /dev/null differ diff --git a/public/providers/qoder.png b/public/providers/qoder.png deleted file mode 100644 index fd03f28dd2..0000000000 Binary files a/public/providers/qoder.png and /dev/null differ diff --git a/public/providers/qwen.png b/public/providers/qwen.png deleted file mode 100644 index 0fc2d1b308..0000000000 Binary files a/public/providers/qwen.png and /dev/null differ diff --git a/public/providers/recraft.png b/public/providers/recraft.png deleted file mode 100644 index dff533eb8c..0000000000 Binary files a/public/providers/recraft.png and /dev/null differ diff --git a/public/providers/roo.png b/public/providers/roo.png deleted file mode 100644 index 0503577502..0000000000 Binary files a/public/providers/roo.png and /dev/null differ diff --git a/public/providers/runwayml.png b/public/providers/runwayml.png deleted file mode 100644 index bbb54dd99c..0000000000 Binary files a/public/providers/runwayml.png and /dev/null differ diff --git a/public/providers/sdwebui.svg b/public/providers/sdwebui.svg deleted file mode 100644 index 4d400e2f03..0000000000 --- a/public/providers/sdwebui.svg +++ /dev/null @@ -1 +0,0 @@ -SDWebUI diff --git a/public/providers/siliconflow.png b/public/providers/siliconflow.png deleted file mode 100644 index a73581450d..0000000000 Binary files a/public/providers/siliconflow.png and /dev/null differ diff --git a/public/providers/tavily-search.png b/public/providers/tavily-search.png deleted file mode 100644 index 153585db01..0000000000 Binary files a/public/providers/tavily-search.png and /dev/null differ diff --git a/public/providers/tavily.png b/public/providers/tavily.png deleted file mode 100644 index 153585db01..0000000000 Binary files a/public/providers/tavily.png and /dev/null differ diff --git a/public/providers/together.png b/public/providers/together.png deleted file mode 100644 index 7a53d4c435..0000000000 Binary files a/public/providers/together.png and /dev/null differ diff --git a/public/providers/venice.png b/public/providers/venice.png deleted file mode 100644 index a72e8ba1df..0000000000 Binary files a/public/providers/venice.png and /dev/null differ diff --git a/public/providers/vertex.svg b/public/providers/vertex.svg deleted file mode 100644 index aa6c4afc98..0000000000 --- a/public/providers/vertex.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/providers/voyage-ai.png b/public/providers/voyage-ai.png deleted file mode 100644 index 72c41963e5..0000000000 Binary files a/public/providers/voyage-ai.png and /dev/null differ diff --git a/public/providers/windsurf.svg b/public/providers/windsurf.svg deleted file mode 100644 index 10f84aaa39..0000000000 --- a/public/providers/windsurf.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/providers/xai.png b/public/providers/xai.png deleted file mode 100644 index e75ae2505f..0000000000 Binary files a/public/providers/xai.png and /dev/null differ diff --git a/public/providers/zai.svg b/public/providers/zai.svg deleted file mode 100644 index 3e7e3fa6f2..0000000000 --- a/public/providers/zai.svg +++ /dev/null @@ -1 +0,0 @@ -Z.AI diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 06e27cc639..a033477913 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -42,8 +42,8 @@ function validateKeyName( if (name.length > MAX_KEY_NAME_LENGTH) { return { valid: false, error: t("keyNameTooLong", { max: MAX_KEY_NAME_LENGTH }) }; } - // Only allow alphanumeric, spaces, hyphens, underscores - if (!/^[a-zA-Z0-9_\-\s]+$/.test(name)) { + // Allow Unicode letters (accented chars), numbers, spaces, hyphens, underscores + if (!/^[\p{L}\p{N}_\-\s]+$/u.test(name)) { return { valid: false, error: t("keyNameInvalid"), @@ -184,13 +184,17 @@ export default function ApiManagerPageClient() { const stats: Record = {}; for (const key of apiKeys) { - // Match analytics entry by key name (reliable across both systems) - const analyticsMatch = byApiKey.find((entry: any) => entry.apiKeyName === key.name); + const analyticsMatch = byApiKey.find( + (entry: any) => + entry.apiKeyId === key.id || (!entry.apiKeyId && entry.apiKeyName === key.name) + ); // The call-logs endpoint returns entries sorted by timestamp DESC, // so the first match is the most recent one. const lastUsed = - (logs || []).find((log: any) => log.apiKeyName === key.name)?.timestamp || null; + (logs || []).find( + (log: any) => log.apiKeyId === key.id || (!log.apiKeyId && log.apiKeyName === key.name) + )?.timestamp || null; stats[key.id] = { totalRequests: analyticsMatch?.requests ?? 0, diff --git a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx index d4438b3189..d8c0445df7 100644 --- a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { IMAGE_PROVIDERS } from "@omniroute/open-sse/config/imageRegistry.ts"; @@ -53,7 +53,7 @@ const MODALITY_CONFIG: Record< label: "Video Generation", placeholder: "A timelapse of a flower blooming...", color: "from-blue-500 to-cyan-500", - needsCredentials: [], + needsCredentials: ["kie"], }, music: { icon: "music_note", @@ -61,7 +61,7 @@ const MODALITY_CONFIG: Record< label: "Music Generation", placeholder: "Upbeat electronic music with synth pads...", color: "from-orange-500 to-yellow-500", - needsCredentials: [], + needsCredentials: ["kie"], }, speech: { icon: "record_voice_over", @@ -89,6 +89,24 @@ const PROVIDER_MODELS: Record< > = { image: IMAGE_PROVIDER_MODELS, video: [ + { + id: "kie", + name: "KIE.AI", + models: [ + { id: "kie/veo/veo-3-1", name: "Veo 3.1" }, + { id: "kie/veo/veo-3-1-fast", name: "Veo 3.1 Fast" }, + { id: "kie/kling/kling-v2-1-master-text-to-video", name: "Kling v2.1 Master T2V" }, + { id: "kie/kling/kling-v2-1-master-image-to-video", name: "Kling v2.1 Master I2V" }, + { id: "kie/kling/v2-5-turbo-text-to-video", name: "Kling v2.5 Turbo T2V" }, + { id: "kie/kling/v2-5-turbo-image-to-video", name: "Kling v2.5 Turbo I2V" }, + { id: "kie/wan/2-7-text-to-video", name: "Wan 2.7 T2V" }, + { id: "kie/wan/2-7-image-to-video", name: "Wan 2.7 I2V" }, + { id: "kie/sora2/sora-2-text-to-video", name: "Sora 2 T2V" }, + { id: "kie/hailuo/02-text-to-video-pro", name: "Hailuo 02 T2V Pro" }, + { id: "kie/grok-imagine/text-to-video", name: "Grok Imagine T2V" }, + { id: "kie/bytedance/v2-0-text-to-video", name: "Seedance v2.0 T2V" }, + ], + }, { id: "comfyui", name: "ComfyUI", @@ -104,6 +122,14 @@ const PROVIDER_MODELS: Record< }, ], music: [ + { + id: "kie", + name: "KIE.AI", + models: [ + { id: "kie/suno-v3.5", name: "Suno V3.5" }, + { id: "kie/suno-v4.0", name: "Suno V4.0" }, + ], + }, { id: "comfyui", name: "ComfyUI", @@ -123,6 +149,16 @@ const PROVIDER_MODELS: Record< { id: "openai/gpt-4o-mini-tts", name: "GPT-4o Mini TTS" }, ], }, + { + id: "kie", + name: "KIE.AI", + models: [ + { id: "kie/elevenlabs/text-to-speech-multilingual-v2", name: "ElevenLabs TTS v2" }, + { id: "kie/elevenlabs/text-to-speech-turbo-2-5", name: "ElevenLabs TTS Turbo 2.5" }, + { id: "kie/elevenlabs/text-to-dialogue-v3", name: "ElevenLabs Text to Dialogue v3" }, + { id: "kie/elevenlabs/sound-effect-v2", name: "ElevenLabs Sound Effect v2" }, + ], + }, { id: "elevenlabs", name: "ElevenLabs", @@ -204,6 +240,14 @@ const PROVIDER_MODELS: Record< { id: "deepgram/base", name: "Base" }, ], }, + { + id: "kie", + name: "KIE.AI", + models: [ + { id: "kie/elevenlabs/speech-to-text", name: "ElevenLabs STT" }, + { id: "kie/elevenlabs/audio-isolation", name: "ElevenLabs Audio Isolation" }, + ], + }, { id: "assemblyai", name: "AssemblyAI ($50 free)", @@ -242,6 +286,8 @@ const PROVIDER_MODELS: Record< { id: "qwen", name: "Qwen", models: [{ id: "qwen/qwen3-asr", name: "Qwen3 ASR" }] }, ], }; +const INITIAL_IMAGE_PROVIDER = PROVIDER_MODELS.image[0]; +const INITIAL_IMAGE_MODEL = INITIAL_IMAGE_PROVIDER?.models[0]; // Voice presets per TTS provider const VOICE_PRESETS: Record = { @@ -264,6 +310,13 @@ const VOICE_PRESETS: Record = { { id: "pNInz6obpgDQGcFmaJgB", label: "Adam (EN)" }, { id: "yoZ06aMxZJJ28mfd3POQ", label: "Sam (EN)" }, ], + kie: [ + { id: "Rachel", label: "Rachel (EN)" }, + { id: "Adam", label: "Adam (EN)" }, + { id: "Brian", label: "Brian (EN)" }, + { id: "Roger", label: "Roger (EN)" }, + { id: "Bella", label: "Bella (EN)" }, + ], cartesia: [ { id: "a0e99841-438c-4a64-b679-ae501e7d6091", label: "Barbershop Man" }, { id: "694f9389-aac1-45b6-b726-9d9369183238", label: "Friendly Reading Man" }, @@ -433,8 +486,10 @@ export default function MediaPageClient() { const [prompt, setPrompt] = useState(""); // Selected provider and model per modality - const [selectedProvider, setSelectedProvider] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); + const [selectedProvider, setSelectedProvider] = useState( + INITIAL_IMAGE_PROVIDER?.id ?? "" + ); + const [selectedModel, setSelectedModel] = useState(INITIAL_IMAGE_MODEL?.id ?? ""); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); @@ -536,16 +591,6 @@ export default function MediaPageClient() { } }; - // Initialize on mount — pick first provider/model for image tab - const initialized = useRef(false); - if (!initialized.current) { - initialized.current = true; - const providers = PROVIDER_MODELS["image"] ?? []; - const firstProvider = providers[0]; - setSelectedProvider(firstProvider?.id ?? ""); - setSelectedModel(firstProvider?.models[0]?.id ?? ""); - } - const handleGenerate = async () => { setLoading(true); setError(null); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx index 17b9259b01..08c130c87a 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx @@ -2,9 +2,10 @@ import { useState, useEffect } from "react"; import { Card, Button, Badge, Modal, Input, ModelSelectModal } from "@/shared/components"; -import Image from "next/image"; import { useTranslations } from "next-intl"; +import ProviderIcon from "@/shared/components/ProviderIcon"; + export default function AntigravityToolCard({ tool, isExpanded, @@ -226,17 +227,7 @@ export default function AntigravityToolCard({
- {tool.name} { - (e.currentTarget as HTMLElement).style.display = "none"; - }} - /> +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx index 6ecd322bac..6178009432 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; +import ProviderIcon from "@/shared/components/ProviderIcon"; import CliStatusBadge from "./CliStatusBadge"; import { useTranslations } from "next-intl"; import { @@ -286,17 +286,7 @@ export default function ClaudeToolCard({
- {tool.name} { - (e.currentTarget as HTMLElement).style.display = "none"; - }} - /> +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx index e079a4e77d..0333831baf 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; +import ProviderIcon from "@/shared/components/ProviderIcon"; import CliStatusBadge from "./CliStatusBadge"; import { useTranslations } from "next-intl"; import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; @@ -241,23 +241,7 @@ export default function ClineToolCard({
- {tool.image ? ( - {tool.name} { - (e.currentTarget as HTMLElement).style.display = "none"; - }} - /> - ) : ( - - terminal - - )} +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx index 2313a7a9a1..4a45800a58 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx @@ -2,10 +2,11 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; import CliStatusBadge from "./CliStatusBadge"; import { useTranslations } from "next-intl"; +import ProviderIcon from "@/shared/components/ProviderIcon"; + export default function CodexToolCard({ tool, isExpanded, @@ -408,17 +409,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
- {tool.name} { - (e.currentTarget as HTMLElement).style.display = "none"; - }} - /> +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx index 95f7074efa..95d3a4d9a9 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx @@ -8,6 +8,7 @@ import { copyToClipboard } from "@/shared/utils/clipboard"; import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig"; import { useTheme } from "@/shared/hooks/useTheme"; import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; +import ProviderIcon from "@/shared/components/ProviderIcon"; export default function DefaultToolCard({ toolId, @@ -659,19 +660,7 @@ export default function DefaultToolCard({ ); } - return ( - {tool.name} { - (e.currentTarget as HTMLElement).style.display = "none"; - }} - /> - ); + return ; }; return ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx index cbdef26635..5fdd12b268 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx @@ -2,10 +2,11 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; import CliStatusBadge from "./CliStatusBadge"; import { useTranslations } from "next-intl"; +import ProviderIcon from "@/shared/components/ProviderIcon"; + const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; export default function DroidToolCard({ @@ -276,17 +277,7 @@ export default function DroidToolCard({
- {tool.name} { - (e.currentTarget as HTMLElement).style.display = "none"; - }} - /> +
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index cdd04441bb..e802936647 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -5,7 +5,6 @@ import { createPortal } from "react-dom"; import { useNotificationStore } from "@/store/notificationStore"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; -import Image from "next/image"; import { useTranslations } from "next-intl"; import { Card, @@ -49,6 +48,7 @@ import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases"; import { maskEmail, pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; +import ProviderIcon from "@/shared/components/ProviderIcon"; import { getClaudeCodeCompatibleRequestDefaults as _getClaudeCodeCompatibleRequestDefaults, getCodexRequestDefaults as _getCodexRequestDefaults, @@ -980,7 +980,6 @@ export default function ProviderDetailPage() { const [batchTesting, setBatchTesting] = useState(false); const [batchTestResults, setBatchTestResults] = useState(null); const [modelAliases, setModelAliases] = useState({}); - const [headerImgError, setHeaderImgError] = useState(false); const { copied, copy } = useCopyToClipboard(); const t = useTranslations("providers"); const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); @@ -2666,17 +2665,15 @@ export default function ProviderDetailPage() { ); } - // Determine icon path: OpenAI Compatible providers use specialized icons - const getHeaderIconPath = () => { + // OpenAI/Anthropic compatible providers use their specialized pseudo-provider icons. + const getHeaderIconProviderId = () => { if (isOpenAICompatible && providerInfo.apiType) { - return providerInfo.apiType === "responses" - ? "/providers/oai-r.png" - : "/providers/oai-cc.png"; + return providerInfo.apiType === "responses" ? "oai-r" : "oai-cc"; } if (isAnthropicProtocolCompatible) { - return "/providers/anthropic-m.png"; + return "anthropic-m"; } - return `/providers/${providerInfo.id}.png`; + return providerInfo.id; }; return ( @@ -2695,21 +2692,7 @@ export default function ProviderDetailPage() { className="rounded-lg flex items-center justify-center" style={{ backgroundColor: `${providerInfo.color}15` }} > - {headerImgError ? ( - - {providerInfo.textIcon || providerInfo.id.slice(0, 2).toUpperCase()} - - ) : ( - {providerInfo.name} setHeaderImgError(true)} - /> - )} +
{providerInfo.website ? ( @@ -6012,6 +5995,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec codexOpenaiStoreEnabled: false, consoleApiKey: "", ccCompatibleContext1m: false, + geminiProjectId: "", blockExtraUsage: connection?.provider === "claude" ? isClaudeExtraUsageBlockEnabled(connection?.provider, connection?.providerSpecificData) @@ -6037,6 +6021,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec const isCloudflare = connection?.provider === "cloudflare-ai"; const isCodex = connection?.provider === "codex"; const isClaude = connection?.provider === "claude"; + const isGeminiCli = connection?.provider === "gemini-cli"; const localProviderMetadata = getLocalProviderMetadata(connection?.provider); const isLocalSelfHostedProvider = !!localProviderMetadata; const isSearxng = connection?.provider === "searxng-search"; @@ -6099,6 +6084,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true, consoleApiKey: existingConsoleApiKey, ccCompatibleContext1m: ccRequestDefaults.context1m, + geminiProjectId: (connection.providerSpecificData?.projectId as string) || "", blockExtraUsage: isClaudeExtraUsageBlockEnabled( connection.provider, connection.providerSpecificData @@ -6205,6 +6191,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec healthCheckInterval: formData.healthCheckInterval, }; + if (isGeminiCli) { + updates.projectId = formData.geminiProjectId.trim() || null; + } + if (isGooglePse && !formData.cx.trim()) { setSaveError(t("searchEngineIdRequired")); return; @@ -6326,6 +6316,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec updates.providerSpecificData.openaiStoreEnabled = formData.codexOpenaiStoreEnabled === true; } + if (isGeminiCli) { + updates.providerSpecificData.projectId = formData.geminiProjectId.trim() || undefined; + } } const error = (await onSave(updates)) as void | unknown; if (error) { @@ -6420,6 +6413,18 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec />
)} + {isGeminiCli && ( +
+ setFormData({ ...formData, geminiProjectId: e.target.value })} + placeholder={t("geminiCliProjectIdPlaceholder")} + hint={t("geminiCliProjectIdHint")} + className="font-mono text-xs" + /> +
+ )} {isOAuth && connection.email && (

{t("email")}

diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index e58e92a955..e57e527d36 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -3,7 +3,6 @@ import { useTranslations } from "next-intl"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; -import Image from "next/image"; import { parseQuotaData, calculatePercentage, @@ -18,6 +17,7 @@ import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; import { pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; +import ProviderIcon from "@/shared/components/ProviderIcon"; const LS_GROUP_BY = "omniroute:limits:groupBy"; const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups"; @@ -551,14 +551,7 @@ export default function ProviderLimits() { {/* Account Info */}
- {conn.provider} +
diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index f4fe213a9b..3925f88c11 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -126,6 +126,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: healthCheckInterval, group, maxConcurrent, + projectId, providerSpecificData: incomingPsd, } = body; @@ -152,6 +153,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (healthCheckInterval !== undefined) updateData.healthCheckInterval = healthCheckInterval; if (group !== undefined) updateData.group = group; if (maxConcurrent !== undefined) updateData.maxConcurrent = maxConcurrent; + if (projectId !== undefined) updateData.projectId = projectId; // Merge providerSpecificData (partial update — preserve existing keys not sent by caller) if (incomingPsd !== undefined && incomingPsd !== null && typeof incomingPsd === "object") { diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 1eeec256b8..088bbb4288 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 { getApiKeys } from "@/lib/db/apiKeys"; import { getDbInstance } from "@/lib/db/core"; function getRangeStartIso(range: string): string | null { @@ -76,6 +77,16 @@ function uniqueValues(values: Array): string[] { return result; } +function makeApiKeyUsageGroup(apiKeyId: string, fallbackName: string): string { + return apiKeyId ? `id:${apiKeyId}` : `name:${fallbackName}`; +} + +function addApiKeyAlias(target: Set, value: unknown): void { + if (typeof value !== "string") return; + const trimmed = value.trim(); + if (trimmed) target.add(trimmed); +} + function stripCodexEffortSuffix(model: string): string { return model.replace(/-(?:xhigh|high|medium|low|none)$/i, ""); } @@ -244,6 +255,13 @@ export async function GET(request: Request) { const presetsParam = searchParams.get("presets"); const db = getDbInstance(); + const apiKeys = await getApiKeys(); + const currentApiKeyNames = new Map(); + for (const apiKey of apiKeys) { + if (typeof apiKey.id === "string" && typeof apiKey.name === "string") { + currentApiKeyNames.set(apiKey.id, apiKey.name); + } + } const conditions = []; const params: Record = {}; @@ -296,7 +314,7 @@ export async function GET(request: Request) { COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, COUNT(DISTINCT model) as uniqueModels, COUNT(DISTINCT connection_id) as uniqueAccounts, - COUNT(DISTINCT api_key_id) as uniqueApiKeys, + COUNT(DISTINCT COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''))) as uniqueApiKeys, COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests, COALESCE(AVG(latency_ms), 0) as avgLatencyMs, COALESCE(MIN(timestamp), '') as firstRequest, @@ -489,8 +507,8 @@ export async function GET(request: Request) { .prepare( ` SELECT - api_key_id as apiKeyId, - COALESCE(NULLIF(api_key_name, ''), NULLIF(api_key_id, ''), 'Unknown API key') as apiKeyName, + NULLIF(api_key_id, '') as apiKeyId, + COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown') as apiKeyGroupKey, LOWER(provider) as provider, LOWER(model) as model, COUNT(*) as requests, @@ -502,11 +520,42 @@ export async function GET(request: Request) { COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens FROM usage_history ${apiKeyWhereClause} - GROUP BY api_key_id, api_key_name, LOWER(provider), LOWER(model) + GROUP BY COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown'), NULLIF(api_key_id, ''), LOWER(provider), LOWER(model) ` ) .all(params) as Array>; + const apiKeyMetadataRows = db + .prepare( + ` + SELECT + NULLIF(api_key_id, '') as apiKeyId, + NULLIF(api_key_name, '') as apiKeyName, + COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown') as apiKeyGroupKey, + MAX(timestamp) as lastUsed + FROM usage_history + ${apiKeyWhereClause} + GROUP BY NULLIF(api_key_id, ''), NULLIF(api_key_name, '') + ORDER BY lastUsed DESC + ` + ) + .all(params) as Array>; + + const apiKeyMetadata = new Map }>(); + for (const row of apiKeyMetadataRows) { + const apiKeyId = toStringValue(row.apiKeyId); + const apiKeyGroupKey = toStringValue(row.apiKeyGroupKey, "unknown"); + const groupKey = makeApiKeyUsageGroup(apiKeyId, apiKeyGroupKey); + const existing = apiKeyMetadata.get(groupKey) || { + latestName: "", + aliases: new Set(), + }; + const apiKeyName = toStringValue(row.apiKeyName); + if (!existing.latestName && apiKeyName) existing.latestName = apiKeyName; + addApiKeyAlias(existing.aliases, apiKeyName); + apiKeyMetadata.set(groupKey, existing); + } + const weeklyRows = db .prepare( ` @@ -742,6 +791,7 @@ export async function GET(request: Request) { apiKey: string; apiKeyId: string | null; apiKeyName: string; + historicalApiKeyNames: string[]; requests: number; promptTokens: number; completionTokens: number; @@ -751,12 +801,20 @@ export async function GET(request: Request) { >(); for (const row of apiKeyRows) { const apiKeyId = toStringValue(row.apiKeyId); - const apiKeyName = toStringValue(row.apiKeyName, apiKeyId || "Unknown API key"); - const key = `${apiKeyId || "unknown"}::${apiKeyName}`; + const apiKeyGroupKey = toStringValue(row.apiKeyGroupKey, "unknown"); + const key = makeApiKeyUsageGroup(apiKeyId, apiKeyGroupKey); + const metadata = apiKeyMetadata.get(key); + const apiKeyName = + (apiKeyId ? currentApiKeyNames.get(apiKeyId) : undefined) || + metadata?.latestName || + apiKeyId || + apiKeyGroupKey || + "Unknown API key"; const existing = apiKeyMap.get(key) || { apiKey: apiKeyId && apiKeyName !== apiKeyId ? `${apiKeyName} (${apiKeyId})` : apiKeyName, apiKeyId: apiKeyId || null, apiKeyName, + historicalApiKeyNames: Array.from(metadata?.aliases || []), requests: 0, promptTokens: 0, completionTokens: 0, diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 12fe5b302c..48e24f8f5d 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -16,6 +16,7 @@ import { getAllModerationModels } from "@omniroute/open-sse/config/moderationReg import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry.ts"; import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry.ts"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/model.ts"; import { getAllSyncedAvailableModels } from "@/lib/db/models"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules"; @@ -25,6 +26,8 @@ import { getCatalogDiagnosticsHeaders, } from "@/lib/modelMetadataRegistry"; import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; +import { parseModel } from "@omniroute/open-sse/services/model.ts"; +import { getTokenLimit } from "@omniroute/open-sse/services/contextManager.ts"; const FALLBACK_ALIAS_TO_PROVIDER = { ag: "antigravity", @@ -313,6 +316,30 @@ export async function getUnifiedModelsResponse( // Add combos first (they appear at the top) — only active ones for (const combo of combos) { if (combo.isActive === false || combo.isHidden === true) continue; + + // Calculate combo context length from its model targets. + // OpenCode and other clients read context_length from the catalog; without it + // they fall back to a conservative ~4000 token limit, causing truncation. + const comboContextLength = Array.isArray(combo.models) + ? combo.models + .filter((step) => step && step.kind === "model" && step.model) + .map((step) => { + const parsed = parseModel(step.model); + const provider = parsed.provider || (step as any).providerId || "unknown"; + const model = parsed.model || step.model; + return getTokenLimit(provider, model); + }) + .filter((limit): limit is number => typeof limit === "number" && limit > 0) + .reduce((min, limit) => Math.min(min, limit), Infinity) + : undefined; + + const effectiveContextLength = + typeof combo.context_length === "number" && combo.context_length > 0 + ? combo.context_length + : comboContextLength !== undefined && comboContextLength !== Infinity + ? comboContextLength + : undefined; + models.push({ id: combo.name, object: "model", @@ -321,7 +348,7 @@ export async function getUnifiedModelsResponse( permission: [], root: combo.name, parent: null, - ...(combo.context_length ? { context_length: combo.context_length } : {}), + ...(effectiveContextLength !== undefined ? { context_length: effectiveContextLength } : {}), }); } @@ -377,6 +404,33 @@ export async function getUnifiedModelsResponse( } } + for (const modelId of CODEX_NATIVE_UNPREFIXED_MODELS) { + if (!providerSupportsModel("codex", modelId)) continue; + if (getModelIsHidden("codex", modelId)) continue; + + const alias = providerIdToAlias.codex || "cx"; + const aliasId = `${alias}/${modelId}`; + const providerIdModel = `codex/${modelId}`; + const entries = [ + { id: aliasId, parent: null }, + { id: providerIdModel, parent: aliasId }, + { id: modelId, parent: providerIdModel }, + ]; + + for (const entry of entries) { + if (models.some((existingModel) => existingModel.id === entry.id)) continue; + models.push({ + id: entry.id, + object: "model", + created: timestamp, + owned_by: "codex", + permission: [], + root: modelId, + parent: entry.parent, + }); + } + } + try { const syncedModelsByProvider = await getAllSyncedAvailableModels(); for (const [providerId, syncedModels] of Object.entries(syncedModelsByProvider)) { diff --git a/src/app/docs/components/DocsSidebarClient.tsx b/src/app/docs/components/DocsSidebarClient.tsx index d50464af6b..d3955b53b7 100644 --- a/src/app/docs/components/DocsSidebarClient.tsx +++ b/src/app/docs/components/DocsSidebarClient.tsx @@ -12,7 +12,7 @@ export function DocsSidebarClient({ mobileOnly = false }: { mobileOnly?: boolean const [isOpen, setIsOpen] = useState(false); // Extract slug from pathname (e.g., /docs/setup-guide -> setup-guide) - const currentSlug = pathname.split("/").filter(Boolean).pop() || ""; + const currentSlug = pathname.split("/").filter(Boolean).pop(); const isActive = (slug: string) => currentSlug === slug; diff --git a/src/app/landing/components/FlowAnimation.tsx b/src/app/landing/components/FlowAnimation.tsx index c1fd457774..06a9a2d812 100644 --- a/src/app/landing/components/FlowAnimation.tsx +++ b/src/app/landing/components/FlowAnimation.tsx @@ -1,17 +1,18 @@ "use client"; import { useEffect, useState } from "react"; -import Image from "next/image"; import { useTranslations } from "next-intl"; +import ProviderIcon from "@/shared/components/ProviderIcon"; + export default function FlowAnimation() { const t = useTranslations("landing"); const [activeFlow, setActiveFlow] = useState(0); const cliTools = [ - { id: "claude", name: t("flowToolClaudeCode"), image: "/providers/claude.png" }, - { id: "codex", name: t("flowToolOpenAICodex"), image: "/providers/codex.png" }, - { id: "cline", name: t("flowToolCline"), image: "/providers/cline.png" }, - { id: "cursor", name: t("flowToolCursor"), image: "/providers/cursor.png" }, + { id: "claude", name: t("flowToolClaudeCode") }, + { id: "codex", name: t("flowToolOpenAICodex") }, + { id: "cline", name: t("flowToolCline") }, + { id: "cursor", name: t("flowToolCursor") }, ]; const providers = [ @@ -70,14 +71,7 @@ export default function FlowAnimation() { className="flex items-center gap-3 opacity-70 hover:opacity-100 transition-opacity group" >
- {tool.name} +
))} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b2f4c692f4..3b97d7edae 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -977,7 +977,7 @@ "tipSeparate": "Create separate keys for different clients or environments", "tipRestrict": "Restrict keys to specific models for better security and cost control", "keyName": "Key Name", - "keyNamePlaceholder": "e.g., Production Key, Development Key", + "keyNamePlaceholder": "e.g. Production Key", "keyNameDesc": "Choose a descriptive name to identify this key's purpose", "keyCreated": "API Key Created", "keyCreatedSuccess": "Key created successfully!", @@ -3011,6 +3011,9 @@ "extraApiKeysHint": "Extra Api Keys Hint", "extraApiKeysLabel": "Extra Api Keys Label", "googlePseInfo": "Google Pse Info", + "geminiCliProjectIdHint": "Your Google Cloud Project ID. Required for accounts with exceptions. Enter your GCP Project ID to use with Gemini CLI.", + "geminiCliProjectIdLabel": "Google Cloud Project ID", + "geminiCliProjectIdPlaceholder": "my-gcp-project-id", "grokWebCookieHint": "Grok Web Cookie Hint", "grokWebCookiePlaceholder": "Grok Web Cookie Placeholder", "herokuBaseUrlHint": "Heroku Base Url Hint", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 0c6357e7a5..8a9fa3b305 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -875,7 +875,7 @@ "tipSeparate": "Crie chaves separadas para diferentes clientes ou ambientes", "tipRestrict": "Restrinja chaves a modelos específicos para maior segurança e controle de custos", "keyName": "Nome da Chave", - "keyNamePlaceholder": "ex: Chave de Produção, Chave de Desenvolvimento", + "keyNamePlaceholder": "ex: Chave de Produção", "keyNameDesc": "Escolha um nome descritivo para identificar o propósito desta chave", "keyCreated": "Chave de API Criada", "keyCreatedSuccess": "Chave criada com sucesso!", diff --git a/src/lib/oauth/providers/claude.ts b/src/lib/oauth/providers/claude.ts index 6f3f3eb560..b3cd5151a4 100644 --- a/src/lib/oauth/providers/claude.ts +++ b/src/lib/oauth/providers/claude.ts @@ -1,4 +1,80 @@ +import crypto from "node:crypto"; import { CLAUDE_CONFIG } from "../constants/oauth"; +import { CLAUDE_CODE_VERSION } from "@omniroute/open-sse/executors/claudeIdentity.ts"; + +const BOOTSTRAP_FETCH_TIMEOUT_MS = 10_000; + +// Best-effort: failure must not block OAuth — the access token is valid. +async function fetchClaudeBootstrap(accessToken) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), BOOTSTRAP_FETCH_TIMEOUT_MS); + try { + const res = await fetch("https://api.anthropic.com/api/claude_cli/bootstrap", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, + "anthropic-beta": "oauth-2025-04-20", + }, + signal: ctrl.signal, + }); + if (!res.ok) return null; + const data = await res.json(); + const acct = data?.oauth_account; + if (!acct || typeof acct !== "object") return null; + return { + account_uuid: acct.account_uuid || null, + account_email: acct.account_email || null, + organization_uuid: acct.organization_uuid || null, + organization_name: acct.organization_name || null, + organization_type: acct.organization_type || null, + organization_rate_limit_tier: acct.organization_rate_limit_tier || null, + }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +function toRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== "string") { + continue; + } + + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + } + + return undefined; +} + +function extractPlanFromPayload(payload: unknown): string | undefined { + const data = toRecord(payload); + const billing = toRecord(data.billing); + + return firstNonEmptyString(data.account_tier, data.plan, data.subscription_type, billing.plan); +} + +function extractClaudePlan(tokens: unknown, extra: unknown): string | undefined { + const extraData = toRecord(extra); + + return firstNonEmptyString( + extractPlanFromPayload(tokens), + extractPlanFromPayload(extraData.userInfo), + extractPlanFromPayload(extra) + ); +} export const claude = { config: CLAUDE_CONFIG, @@ -48,10 +124,39 @@ export const claude = { return await response.json(); }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - }), + // Runs after exchangeToken; result is passed as `extra` to mapTokens. + postExchange: async (tokens) => { + if (!tokens?.access_token) return null; + return await fetchClaudeBootstrap(tokens.access_token); + }, + mapTokens: (tokens, extra) => { + const plan = extractClaudePlan(tokens, extra); + const bs = extra || {}; + const providerSpecificData: any = { + // Generated once at provisioning; preserved across token refresh. + cliUserID: crypto.randomBytes(32).toString("hex"), + }; + if (bs.account_uuid) providerSpecificData.accountUUID = bs.account_uuid; + if (bs.organization_uuid) providerSpecificData.organizationUUID = bs.organization_uuid; + if (bs.organization_name) providerSpecificData.organizationName = bs.organization_name; + if (bs.organization_type) providerSpecificData.organizationType = bs.organization_type; + if (bs.organization_rate_limit_tier) + providerSpecificData.organizationRateLimitTier = bs.organization_rate_limit_tier; + if (plan) providerSpecificData.plan = plan; + + const result: any = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }; + if (bs.account_email) { + result.email = bs.account_email; + result.displayName = bs.account_email; + } + if (Object.keys(providerSpecificData).length > 0) { + result.providerSpecificData = providerSpecificData; + } + return result; + }, }; diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 8ea188b5f7..c5e63c264c 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -11,6 +11,7 @@ import { stripClaudeCodeCompatibleEndpointSuffix, stripAnthropicMessagesSuffix, } from "@omniroute/open-sse/services/claudeCodeCompatible.ts"; +import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; import { isClaudeCodeCompatibleProvider, isAnthropicCompatibleProvider, @@ -544,6 +545,13 @@ async function validateAnthropicLikeProvider({ return { valid: false, error: "Missing base URL" }; } + // OAuth tokens need the same Claude Code cloak as production traffic in + // base.ts; a bare validation request gets flagged on the user:sessions: + // claude_code scope. + if (typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat")) { + return validateClaudeOAuthInline({ apiKey, modelId, providerSpecificData }); + } + const requestHeaders = applyCustomUserAgent( { "Content-Type": "application/json", @@ -580,6 +588,45 @@ async function validateAnthropicLikeProvider({ return { valid: true, error: null }; } +// Probe a Claude OAuth credential through the same executor that handles +// production traffic so the cloak/signing/identity logic isn't duplicated. +async function validateClaudeOAuthInline({ + apiKey, + modelId, + providerSpecificData = {}, +}: { + apiKey: string; + modelId: string | null | undefined; + providerSpecificData?: Record; +}) { + const testModelId = + providerSpecificData?.validationModelId || modelId || "claude-haiku-4-5-20251001"; + + try { + const { response } = await getExecutor("claude").execute({ + model: testModelId, + body: { + model: testModelId, + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }, + stream: false, + credentials: { accessToken: apiKey, providerSpecificData }, + }); + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid OAuth token" }; + } + if (response.status >= 500) { + return { valid: false, error: `Provider unavailable (${response.status})` }; + } + // 2xx and non-auth 4xx (429 quota, 400 model) both mean the token is valid. + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + async function validateGeminiLikeProvider({ apiKey, baseUrl, @@ -755,6 +802,55 @@ async function validateInworldProvider({ apiKey, providerSpecificData = {} }: an } } +async function validateKieProvider({ apiKey, providerSpecificData = {} }: any) { + try { + // Use credit check endpoint as requested by user based on Kie.ai docs. + const response = await validationRead("https://api.kie.ai/api/v1/chat/credit", { + method: "GET", + headers: applyCustomUserAgent( + { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + providerSpecificData + ), + }); + + if (response.ok) { + return { valid: true, error: null }; + } + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid Kie.ai API key" }; + } + + // Fallback: if credits endpoint is 404/not supported, try minimal chat probe. + const chatRes = await validationWrite("https://api.kie.ai/api/v1/chat/completions", { + method: "POST", + headers: buildBearerHeaders(apiKey, providerSpecificData), + body: JSON.stringify({ + model: providerSpecificData.validationModelId || "gpt-4o-mini", + messages: [{ role: "user", content: "test" }], + max_tokens: 1, + }), + }); + + if ( + chatRes.ok || + (chatRes.status >= 400 && + chatRes.status < 500 && + chatRes.status !== 401 && + chatRes.status !== 403) + ) { + return { valid: true, error: null }; + } + + return { valid: false, error: `Validation failed: ${chatRes.status}` }; + } catch (error: unknown) { + return toValidationErrorResult(error); + } +} + function getAwsProviderString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -2920,6 +3016,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi validateImageProviderApiKey({ provider: "topaz", apiKey, providerSpecificData }), elevenlabs: validateElevenLabsProvider, inworld: validateInworldProvider, + kie: validateKieProvider, "aws-polly": validateAwsPollyProvider, "bailian-coding-plan": validateBailianCodingPlanProvider, heroku: validateHerokuProvider, diff --git a/src/lib/usage/usageStats.ts b/src/lib/usage/usageStats.ts index 1138ae605b..c88d2a1668 100644 --- a/src/lib/usage/usageStats.ts +++ b/src/lib/usage/usageStats.ts @@ -8,6 +8,7 @@ */ import { getDbInstance } from "../db/core"; +import { getApiKeys } from "../db/apiKeys"; import { getPendingRequests } from "./usageHistory"; import { getAccountDisplayName } from "@/lib/display/names"; import { calculateCost } from "./costCalculator"; @@ -29,6 +30,7 @@ type UsageBreakdown = UsageBucket & { accountName?: string; apiKeyId?: string | null; apiKeyName?: string; + historicalApiKeyNames?: string[]; }; type ActiveRequest = { @@ -55,6 +57,10 @@ function toStringOrEmpty(value: unknown): string { return typeof value === "string" ? value : ""; } +function getApiKeyStatsKey(apiKeyId: string | null, apiKeyName: string | null): string { + return apiKeyId ? `id:${apiKeyId}` : `name:${apiKeyName || "unknown"}`; +} + /** * Get aggregated usage stats. * Uses UNION of recent raw data and older aggregated data when aggregation is enabled. @@ -126,6 +132,18 @@ export async function getUsageStats() { toStringOrEmpty(conn.name) || toStringOrEmpty(conn.email) || connectionId; } + const currentApiKeyNames = new Map(); + try { + const apiKeys = await getApiKeys(); + for (const apiKey of apiKeys) { + if (typeof apiKey.id === "string" && typeof apiKey.name === "string") { + currentApiKeyNames.set(apiKey.id, apiKey.name); + } + } + } catch { + // Stats can still be computed from usage_history when api_keys is unavailable. + } + const pendingRequests = getPendingRequests(); const stats: { @@ -286,26 +304,35 @@ export async function getUsageStats() { // By API key if (apiKeyId || apiKeyName) { - const keyName = apiKeyName || apiKeyId || "unknown"; - const keyId = apiKeyId || null; - const apiKey = keyId ? `${keyName} (${keyId})` : keyName; - if (!stats.byApiKey[apiKey]) { - stats.byApiKey[apiKey] = { + const key = getApiKeyStatsKey(apiKeyId, apiKeyName); + const displayName = + (apiKeyId ? currentApiKeyNames.get(apiKeyId) : undefined) || + apiKeyName || + apiKeyId || + "unknown"; + if (!stats.byApiKey[key]) { + stats.byApiKey[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, - apiKeyId: keyId, - apiKeyName: keyName, + apiKeyId, + apiKeyName: displayName, + historicalApiKeyNames: [], lastUsed: timestamp, }; } - stats.byApiKey[apiKey].requests++; - stats.byApiKey[apiKey].promptTokens += promptTokens; - stats.byApiKey[apiKey].completionTokens += completionTokens; - stats.byApiKey[apiKey].cost += entryCost; - if (new Date(timestamp) > new Date(stats.byApiKey[apiKey].lastUsed || timestamp)) { - stats.byApiKey[apiKey].lastUsed = timestamp; + const apiKeyStats = stats.byApiKey[key]; + if (apiKeyName && !apiKeyStats.historicalApiKeyNames?.includes(apiKeyName)) { + apiKeyStats.historicalApiKeyNames?.push(apiKeyName); + } + apiKeyStats.apiKeyName = displayName; + apiKeyStats.requests++; + apiKeyStats.promptTokens += promptTokens; + apiKeyStats.completionTokens += completionTokens; + apiKeyStats.cost += entryCost; + if (new Date(timestamp) > new Date(apiKeyStats.lastUsed || timestamp)) { + apiKeyStats.lastUsed = timestamp; } } } diff --git a/src/lib/usageAnalytics.ts b/src/lib/usageAnalytics.ts index 77fd0fc159..55b1718ac5 100644 --- a/src/lib/usageAnalytics.ts +++ b/src/lib/usageAnalytics.ts @@ -76,6 +76,13 @@ function shortModelName(model: string) { return parts[parts.length - 1] || model; } +function getApiKeyAnalyticsKey( + apiKeyId: string | null | undefined, + apiKeyName: string | null | undefined +) { + return apiKeyId ? `id:${apiKeyId}` : `name:${apiKeyName || "unknown"}`; +} + /** * Compute all analytics data from usage history * @param {Array} history - Array of usage entries @@ -184,7 +191,7 @@ export async function computeAnalytics( if (entry.model) summary.uniqueModels.add(modelShort); if (entry.connectionId) summary.uniqueAccounts.add(entry.connectionId); if (entry.apiKeyId || entry.apiKeyName) { - summary.uniqueApiKeys.add(entry.apiKeyId || entry.apiKeyName); + summary.uniqueApiKeys.add(getApiKeyAnalyticsKey(entry.apiKeyId, entry.apiKeyName)); } // Daily trend @@ -260,12 +267,14 @@ export async function computeAnalytics( // By API key if (entry.apiKeyId || entry.apiKeyName) { const keyName = entry.apiKeyName || entry.apiKeyId || "unknown"; + const key = getApiKeyAnalyticsKey(entry.apiKeyId, entry.apiKeyName); const keyLabel = entry.apiKeyId ? `${keyName} (${entry.apiKeyId})` : keyName; - if (!byApiKeyMap[keyLabel]) { - byApiKeyMap[keyLabel] = { + if (!byApiKeyMap[key]) { + byApiKeyMap[key] = { apiKey: keyLabel, apiKeyId: entry.apiKeyId || null, apiKeyName: keyName, + historicalApiKeyNames: [], requests: 0, promptTokens: 0, completionTokens: 0, @@ -273,11 +282,14 @@ export async function computeAnalytics( cost: 0, }; } - byApiKeyMap[keyLabel].requests++; - byApiKeyMap[keyLabel].promptTokens += pt; - byApiKeyMap[keyLabel].completionTokens += ct; - byApiKeyMap[keyLabel].totalTokens += totalTkns; - byApiKeyMap[keyLabel].cost += cost; + if (entry.apiKeyName && !byApiKeyMap[key].historicalApiKeyNames.includes(entry.apiKeyName)) { + byApiKeyMap[key].historicalApiKeyNames.push(entry.apiKeyName); + } + byApiKeyMap[key].requests++; + byApiKeyMap[key].promptTokens += pt; + byApiKeyMap[key].completionTokens += ct; + byApiKeyMap[key].totalTokens += totalTkns; + byApiKeyMap[key].cost += cost; } } diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index af59c278da..ce86ab61be 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -38,76 +38,26 @@ function GenericProviderIcon({ size }: { size: number }) { const KNOWN_PNGS = new Set([ "aimlapi", - "alibaba", - "alicode-intl", - "alicode", "anthropic-m", - "anthropic", - "antigravity", - "bailian-coding-plan", "blackbox", - "brave-search", - "brave", - "cerebras", "claude", - "cline", - "codex", - "cohere", "continue", "copilot", "cursor", "deepgram", - "deepseek", - "droid", - "exa-search", - "fireworks", - "gemini-cli", - "gemini", - "github", - "glm", - "glmt", - "groq", "ironclaw", - "kilo-gateway", - "kilocode", - "kimi-coding-apikey", - "kimi-coding", - "kimi", - "kiro", - "longcat", - "minimax-cn", - "minimax", - "mistral", + "kie", "nanobot", - "nebius", - "nvidia", "oai-cc", "oai-r", - "ollama-cloud", - "openai", "openclaw", - "openrouter", - "perplexity-search", - "perplexity", - "pollinations", - "qwen", - "roo", "serper-search", "serper", - "siliconflow", - "tavily-search", - "tavily", - "together", - "xai", "zeroclaw", - "aws-polly", "blackbox-web", "cliproxyapi", - "databricks", "empower", "gigachat", - "gitlab-duo", - "gitlab", "heroku", "linkup-search", "llamagate", @@ -118,45 +68,32 @@ const KNOWN_PNGS = new Set([ "oci", "ovhcloud", "piapi", - "poe", "predibase", - "qoder", - "recraft", "reka", - "runwayml", "triton", - "venice", - "voyage-ai", "wandb", "youcom-search", ]); const KNOWN_SVGS = new Set([ "apikey", - "assemblyai", "brave", + "brave-search", "cartesia", - "cloudflare-ai", - "comfyui", - "elevenlabs", - "exa-search", - "exa", - "huggingface", - "hyperbolic", + "droid", + "gemini-cli", + "gitlab", + "gitlab-duo", "inworld", - "nanobanana", + "kiro", + "kilo-gateway", + "kilocode", "oauth", - "opencode-go", - "opencode-zen", "opencode", "playht", "puter", "qianfan", "scaleway", - "sdwebui", "synthetic", - "vertex", - "windsurf", - "zai", ]); const ProviderIcon = memo(function ProviderIcon({ diff --git a/src/shared/components/UsageAnalytics.tsx b/src/shared/components/UsageAnalytics.tsx index 0ab5838dc9..c1ddf023d6 100644 --- a/src/shared/components/UsageAnalytics.tsx +++ b/src/shared/components/UsageAnalytics.tsx @@ -61,16 +61,15 @@ export default function UsageAnalytics() { setError(null); // Update available keys from unfiltered data (only when no filter is active). - // Use apiKeyName as the stable identifier — it is always populated - // for every OmniRoute API key regardless of the downstream provider. if (selectedApiKeys.length === 0 && data.byApiKey?.length > 0) { const seen = new Set(); const keys: { id: string; name: string }[] = []; for (const k of data.byApiKey) { + const id = k.apiKeyId || k.apiKeyName || "unknown"; const name = k.apiKeyName || k.apiKeyId || "unknown"; - if (seen.has(name)) continue; - seen.add(name); - keys.push({ id: name, name }); + if (seen.has(id)) continue; + seen.add(id); + keys.push({ id, name }); } setAvailableApiKeys(keys); } diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index d0fd54039b..fabeba188e 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -75,6 +75,8 @@ import KimiColorIcon from "@lobehub/icons/es/Kimi/components/Color"; import KimiMonoIcon from "@lobehub/icons/es/Kimi/components/Mono"; import LambdaMonoIcon from "@lobehub/icons/es/Lambda/components/Mono"; import LmStudioMonoIcon from "@lobehub/icons/es/LmStudio/components/Mono"; +import LongCatColorIcon from "@lobehub/icons/es/LongCat/components/Color"; +import LongCatMonoIcon from "@lobehub/icons/es/LongCat/components/Mono"; import MetaColorIcon from "@lobehub/icons/es/Meta/components/Color"; import MetaMonoIcon from "@lobehub/icons/es/Meta/components/Mono"; import MetaAIColorIcon from "@lobehub/icons/es/MetaAI/components/Color"; @@ -104,12 +106,14 @@ import PerplexityColorIcon from "@lobehub/icons/es/Perplexity/components/Color"; import PerplexityMonoIcon from "@lobehub/icons/es/Perplexity/components/Mono"; import PoeColorIcon from "@lobehub/icons/es/Poe/components/Color"; import PoeMonoIcon from "@lobehub/icons/es/Poe/components/Mono"; +import PollinationsMonoIcon from "@lobehub/icons/es/Pollinations/components/Mono"; import QoderColorIcon from "@lobehub/icons/es/Qoder/components/Color"; import QoderMonoIcon from "@lobehub/icons/es/Qoder/components/Mono"; import QwenColorIcon from "@lobehub/icons/es/Qwen/components/Color"; import QwenMonoIcon from "@lobehub/icons/es/Qwen/components/Mono"; import RecraftMonoIcon from "@lobehub/icons/es/Recraft/components/Mono"; import ReplicateMonoIcon from "@lobehub/icons/es/Replicate/components/Mono"; +import RooCodeMonoIcon from "@lobehub/icons/es/RooCode/components/Mono"; import RunwayMonoIcon from "@lobehub/icons/es/Runway/components/Mono"; import SambaNovaColorIcon from "@lobehub/icons/es/SambaNova/components/Color"; import SambaNovaMonoIcon from "@lobehub/icons/es/SambaNova/components/Mono"; @@ -139,6 +143,7 @@ import VolcengineColorIcon from "@lobehub/icons/es/Volcengine/components/Color"; import VolcengineMonoIcon from "@lobehub/icons/es/Volcengine/components/Mono"; import VoyageColorIcon from "@lobehub/icons/es/Voyage/components/Color"; import VoyageMonoIcon from "@lobehub/icons/es/Voyage/components/Mono"; +import WindsurfMonoIcon from "@lobehub/icons/es/Windsurf/components/Mono"; import WorkersAIColorIcon from "@lobehub/icons/es/WorkersAI/components/Color"; import WorkersAIMonoIcon from "@lobehub/icons/es/WorkersAI/components/Mono"; import XAIMonoIcon from "@lobehub/icons/es/XAI/components/Mono"; @@ -208,6 +213,7 @@ const LOBE_ICON_COMPONENTS = { Kimi: { mono: KimiMonoIcon, color: KimiColorIcon }, Lambda: { mono: LambdaMonoIcon }, LmStudio: { mono: LmStudioMonoIcon }, + LongCat: { mono: LongCatMonoIcon, color: LongCatColorIcon }, Meta: { mono: MetaMonoIcon, color: MetaColorIcon }, MetaAI: { mono: MetaAIMonoIcon, color: MetaAIColorIcon }, Minimax: { mono: MinimaxMonoIcon, color: MinimaxColorIcon }, @@ -226,10 +232,12 @@ const LOBE_ICON_COMPONENTS = { OpenRouter: { mono: OpenRouterMonoIcon }, Perplexity: { mono: PerplexityMonoIcon, color: PerplexityColorIcon }, Poe: { mono: PoeMonoIcon, color: PoeColorIcon }, + Pollinations: { mono: PollinationsMonoIcon }, Qoder: { mono: QoderMonoIcon, color: QoderColorIcon }, Qwen: { mono: QwenMonoIcon, color: QwenColorIcon }, Recraft: { mono: RecraftMonoIcon }, Replicate: { mono: ReplicateMonoIcon }, + RooCode: { mono: RooCodeMonoIcon }, Runway: { mono: RunwayMonoIcon }, SambaNova: { mono: SambaNovaMonoIcon, color: SambaNovaColorIcon }, SearchApi: { mono: SearchApiMonoIcon }, @@ -247,6 +255,7 @@ const LOBE_ICON_COMPONENTS = { Vllm: { mono: VllmMonoIcon, color: VllmColorIcon }, Volcengine: { mono: VolcengineMonoIcon, color: VolcengineColorIcon }, Voyage: { mono: VoyageMonoIcon, color: VoyageColorIcon }, + Windsurf: { mono: WindsurfMonoIcon }, WorkersAI: { mono: WorkersAIMonoIcon, color: WorkersAIColorIcon }, XAI: { mono: XAIMonoIcon }, XiaomiMiMo: { mono: XiaomiMiMoMonoIcon }, @@ -325,6 +334,7 @@ const LOBE_PROVIDER_ALIASES = { "lambda-ai": "Lambda", "lm-studio": "LmStudio", lmstudio: "LmStudio", + longcat: "LongCat", "meta-llama": "Meta", minimax: "Minimax", "minimax-cn": "Minimax", @@ -352,10 +362,12 @@ const LOBE_PROVIDER_ALIASES = { "perplexity-search": "Perplexity", "perplexity-web": "Perplexity", poe: "Poe", + pollinations: "Pollinations", qoder: "Qoder", qwen: "Qwen", recraft: "Recraft", replicate: "Replicate", + roo: "RooCode", runwayml: "Runway", sambanova: "SambaNova", sdwebui: "Automatic", @@ -382,6 +394,7 @@ const LOBE_PROVIDER_ALIASES = { voyage: "Voyage", "voyage-ai": "Voyage", watsonx: "IBM", + windsurf: "Windsurf", "workers-ai": "WorkersAI", workersai: "WorkersAI", xai: "XAI", diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index e50c38e199..2f7353ecdb 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -65,7 +65,6 @@ export const CLI_TOOLS = { codex: { id: "codex", name: "OpenAI Codex CLI", - image: "/providers/codex.png", color: "#10A37F", description: "OpenAI Codex CLI", docsUrl: "https://github.com/openai/codex", @@ -75,7 +74,7 @@ export const CLI_TOOLS = { droid: { id: "droid", name: "Factory Droid", - image: "/providers/droid.png", + image: "/providers/droid.svg", color: "#00D4FF", description: "Factory Droid AI Assistant", docsUrl: "/docs?section=cli-tools&tool=droid", @@ -121,7 +120,6 @@ export const CLI_TOOLS = { windsurf: { id: "windsurf", name: "Windsurf", - image: "/providers/windsurf.svg", color: "#4A90E2", description: "Windsurf AI-first IDE by Codeium", docsUrl: "https://windsurf.com/", @@ -151,7 +149,6 @@ export const CLI_TOOLS = { cline: { id: "cline", name: "Cline", - image: "/providers/cline.png", color: "#00D1B2", description: "Cline AI Coding Assistant CLI", docsUrl: "https://docs.cline.bot/", @@ -161,7 +158,7 @@ export const CLI_TOOLS = { kilo: { id: "kilo", name: "Kilo Code", - image: "/providers/kilocode.png", + image: "/providers/kilocode.svg", color: "#FF6B6B", description: "Kilo Code AI Assistant CLI", docsUrl: "/docs?section=cli-tools&tool=kilocode", @@ -200,7 +197,6 @@ export const CLI_TOOLS = { antigravity: { id: "antigravity", name: "Antigravity", - image: "/providers/antigravity.png", color: "#4285F4", description: "Google Antigravity IDE with MITM", docsUrl: "/docs?section=cli-tools&tool=antigravity", @@ -381,7 +377,7 @@ amp --model "{{model}}" kiro: { id: "kiro", name: "Kiro AI", - image: "/providers/kiro.png", + image: "/providers/kiro.svg", icon: "psychology_alt", color: "#FF6B35", description: "Amazon Kiro — AI-powered IDE with MITM", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 69b1f43091..2637551d07 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -618,6 +618,15 @@ export const APIKEY_PROVIDERS = { textIcon: "NB", website: "https://nanobananaapi.ai", }, + kie: { + id: "kie", + alias: "kie", + name: "KIE.AI", + icon: "hub", + color: "#2563EB", + textIcon: "KIE", + website: "https://kie.ai", + }, "ollama-cloud": { id: "ollama-cloud", alias: "ollamacloud", diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 97a41d118b..a6096b41aa 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1557,6 +1557,7 @@ export const updateProviderConnectionSchema = z healthCheckInterval: z.coerce.number().int().min(0).optional(), group: z.union([z.string().max(100), z.null()]).optional(), maxConcurrent: z.union([z.null(), z.coerce.number().int().min(0)]).optional(), + projectId: z.union([z.string(), z.null()]).optional(), // Partial patch of per-connection provider-specific settings (e.g. quota toggles) providerSpecificData: z .record(z.string(), z.unknown()) diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index b8fcc35448..1cb83f69a1 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -278,7 +278,21 @@ export async function handleChat(request: any, clientRawRequest: any = null) { // Check if model is a combo (has multiple models with fallback) telemetry.startPhase("resolve"); - const combo: any = await getComboForModel(resolvedModelStr); + let combo: any = await getComboForModel(resolvedModelStr); + + // "auto" prefix fuzzy matching: "auto/fast" → "auto/best-fast", etc. + // parseModel splits "auto/fast" into provider="auto" which isn't a real provider. + if (!combo && resolvedModelStr.startsWith("auto/")) { + const suffix = resolvedModelStr.slice(5); + for (const candidate of [`auto/best-${suffix}`, `auto/${suffix}`]) { + combo = await getComboForModel(candidate); + if (combo) { + log.info("ROUTING", `"${resolvedModelStr}" → combo "${candidate}" (auto fuzzy)`); + break; + } + } + } + if (combo) { log.info( "CHAT", @@ -485,9 +499,61 @@ async function handleSingleModelChat( isCombo: boolean = false ) { // 1. Resolve model → provider/model - const resolved = await resolveModelOrError(modelStr, body, clientRawRequest?.endpoint); + const resolved = await resolveModelOrError( + modelStr, + body, + clientRawRequest?.endpoint, + clientRawRequest?.headers + ); if (resolved.error) return resolved.error; + // Safety net: if auto-combo resolution returned a combo object, redirect + // to combo flow. This handles the case where the auto-fuzzy match in + // resolveModelOrError found a combo but the main handler's combo lookup missed it. + if ((resolved as any).combo) { + const redirectCombo = (resolved as any).combo; + log.info("ROUTING", `Auto-combo redirect from handleSingleModelChat for "${modelStr}"`); + log.info("ROUTING", `Auto-combo redirect to combo flow for "${modelStr}"`); + return handleComboChat({ + body, + combo: redirectCombo, + handleSingleModel: ( + b: any, + m: string, + target?: { + connectionId?: string | null; + executionKey?: string | null; + stepId?: string | null; + } + ) => + handleSingleModelChat( + b, + m, + clientRawRequest, + request, + redirectCombo.name ?? modelStr, + apiKeyInfo, + telemetry, + { + sessionId: "", // safety-net redirect doesn't have session context + forceLiveComboTest: false, + forcedConnectionId: null, + allowedConnectionIds: null, + comboStepId: null, + comboExecutionKey: null, + }, + redirectCombo.strategy ?? "priority", + false + ), + isModelAvailable: async () => true, + log, + settings: {}, + allCombos: [], + relayOptions: undefined, + signal: request?.signal ?? null, + }); + } + const { provider, model, sourceFormat, targetFormat, extendedContext } = resolved; const forceLiveComboTest = runtimeOptions.forceLiveComboTest === true; const hasForcedConnection = diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 3079acce78..33bbea24b8 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -1,4 +1,4 @@ -import { getModelInfo } from "../services/model"; +import { getModelInfo, getComboForModel } from "../services/model"; import { clearAccountError, markAccountUnavailable } from "../services/auth"; import * as log from "../utils/logger"; import { updateProviderCredentials } from "../services/tokenRefresh"; @@ -43,8 +43,59 @@ const PREFERRED_BY_FAMILY: Record = { mimo: "moonshot", }; -export async function resolveModelOrError(modelStr: string, body: any, endpointPath: string = "") { +const CODEX_NATIVE_RESPONSES_MODELS = new Set(["gpt-5.5"]); + +function getHeaderValue(headers: Record | null | undefined, name: string) { + if (!headers || typeof headers !== "object") return ""; + const lowerName = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== lowerName) continue; + return Array.isArray(value) ? value.join(",") : String(value ?? ""); + } + return ""; +} + +function isCodexNativeResponsesRequest( + body: any, + endpointPath: string, + headers: Record | null | undefined +) { + const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, ""); + if (!/(^|\/)responses(?=\/|$)/i.test(normalizedEndpoint)) return false; + if (/\/responses\/compact$/i.test(normalizedEndpoint)) return true; + + const userAgent = getHeaderValue(headers, "user-agent").toLowerCase(); + if (userAgent.includes("codex")) return true; + if (getHeaderValue(headers, "x-codex-session-id")) return true; + if (getHeaderValue(headers, "x-codex-window-id")) return true; + if (getHeaderValue(headers, "x-codex-turn-metadata")) return true; + + const metadataSource = + body && typeof body === "object" && body.metadata && typeof body.metadata === "object" + ? String(body.metadata.source || "") + : ""; + return metadataSource.toLowerCase().includes("codex"); +} + +export async function resolveModelOrError( + modelStr: string, + body: any, + endpointPath: string = "", + requestHeaders: Record | null | undefined = null +) { const modelInfo = await getModelInfo(modelStr); + const sourceFormat = detectFormatFromEndpoint(body, endpointPath); + + if ( + modelInfo.provider === "openai" && + typeof modelInfo.model === "string" && + CODEX_NATIVE_RESPONSES_MODELS.has(modelInfo.model) && + sourceFormat === "openai-responses" && + isCodexNativeResponsesRequest(body, endpointPath, requestHeaders) + ) { + log.info("ROUTING", `${modelStr} → codex/${modelInfo.model} (Codex native responses)`); + modelInfo.provider = "codex"; + } // Forced-rewrite: codex provider doesn't serve DeepSeek/Qwen/Kimi/etc. Reroute // these to their canonical native provider so the request lands on the right @@ -79,6 +130,46 @@ export async function resolveModelOrError(modelStr: string, body: any, endpointP } } + // "auto" is a combo prefix, not a provider. parseModel("auto/fast") splits it into + // provider="auto" model="fast" — redirect to matching combo before credential lookup fails. + if (modelInfo.provider === "auto") { + const exactCombo = await getComboForModel(modelStr); + if (exactCombo) { + log.info("ROUTING", `"auto" provider → combo "${modelStr}"`); + return { combo: exactCombo, provider: "auto", model: modelInfo.model }; + } + + // Fuzzy: "fast" → "auto/best-fast", "chat" → "auto/best-chat" + const suffix = modelInfo.model || ""; + for (const candidate of [`auto/best-${suffix}`, `auto/${suffix}`]) { + const fuzzyCombo = await getComboForModel(candidate); + if (fuzzyCombo) { + log.info("ROUTING", `"auto/${suffix}" → combo "${candidate}" (fuzzy)`); + return { combo: fuzzyCombo, provider: "auto", model: suffix }; + } + } + + // List available auto/* combos in error + const available: string[] = []; + try { + const { getCombos } = await import("@/lib/localDb"); + const all = await getCombos(); + for (const c of all) { + if (c.name?.startsWith("auto/")) available.push(c.name); + } + } catch { + /* DB unavailable */ + } + + const hint = + available.length > 0 + ? ` Available auto combos: ${available.join(", ")}` + : " No auto combos configured — create one in the Dashboard."; + const message = `Model '${modelStr}' is not a valid combo or provider.${hint}`; + log.warn("CHAT", message, { model: modelStr }); + return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, message) }; + } + if (!modelInfo.provider) { if ((modelInfo as any).errorType === "ambiguous_model") { // Family disambiguation: if the model name begins with a known @@ -113,7 +204,6 @@ export async function resolveModelOrError(modelStr: string, body: any, endpointP } const { provider, model, extendedContext } = modelInfo; - const sourceFormat = detectFormatFromEndpoint(body, endpointPath); const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider; let targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider); if ((modelInfo as any).apiFormat === "responses") { diff --git a/tests/unit/bailian-coding-plan-provider.test.ts b/tests/unit/bailian-coding-plan-provider.test.ts index 161cc0ae36..4897ab343d 100644 --- a/tests/unit/bailian-coding-plan-provider.test.ts +++ b/tests/unit/bailian-coding-plan-provider.test.ts @@ -274,7 +274,7 @@ test("getStaticModelsForProvider returns local image catalogs for image-only pro assert.ok(models, "xAI should expose local image models"); assert.deepEqual( models.map((model) => model.id), - ["grok-imagine-image-pro", "grok-imagine-image"] + ["grok-imagine-image-quality", "grok-imagine-image"] ); }); diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index 168ed13ca5..e0b2668423 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -89,6 +89,30 @@ test("resolveModelOrError rejects malformed model strings", async () => { assert.match(json.error.message, /Invalid model format/i); }); +test("resolveModelOrError routes Codex native compact gpt-5.5 requests to Codex", async () => { + const result = await resolveModelOrError( + "gpt-5.5", + { model: "gpt-5.5", input: "compact this session", reasoning: { effort: "xhigh" } }, + "/v1/responses/compact", + { "user-agent": "codex-cli/0.128.0" } + ); + + assert.equal(result.provider, "codex"); + assert.equal(result.model, "gpt-5.5"); +}); + +test("resolveModelOrError keeps non-Codex gpt-5.5 Responses requests on OpenAI", async () => { + const result = await resolveModelOrError( + "gpt-5.5", + { model: "gpt-5.5", input: "hello" }, + "/v1/responses", + { "user-agent": "OpenAI/Node" } + ); + + assert.equal(result.provider, "openai"); + assert.equal(result.model, "gpt-5.5"); +}); + test("checkPipelineGates blocks providers with an open circuit breaker", async () => { const breaker = getCircuitBreaker("openai"); breaker.state = STATE.OPEN; diff --git a/tests/unit/claude-oauth-provider.test.ts b/tests/unit/claude-oauth-provider.test.ts index c451391a24..963a48b562 100644 --- a/tests/unit/claude-oauth-provider.test.ts +++ b/tests/unit/claude-oauth-provider.test.ts @@ -75,3 +75,33 @@ test("Claude OAuth provider always uses the configured redirectUri during token assert.equal(captured.body.state, "state-from-fragment"); assert.equal(captured.body.code_verifier, "verifier-123"); }); + +test("Claude OAuth token mapper persists the first non-empty token plan field", () => { + const cases = [ + [{ account_tier: " Pro ", plan: "Max" }, "Pro"], + [{ account_tier: "", plan: "Max" }, "Max"], + [{ plan: "", subscription_type: "Team" }, "Team"], + [{ subscription_type: "", billing: { plan: "Enterprise" } }, "Enterprise"], + ]; + + for (const [tokens, expected] of cases) { + const mapped = claude.mapTokens({ access_token: "token-1", ...tokens }); + + assert.equal(mapped.providerSpecificData.plan, expected); + } +}); + +test("Claude OAuth token mapper reads plan fields from userinfo extras after token fields", () => { + const mapped = claude.mapTokens( + { access_token: "token-1" }, + { userInfo: { account_tier: "", subscription_type: "Max" } } + ); + + assert.equal(mapped.providerSpecificData.plan, "Max"); +}); + +test("Claude OAuth token mapper leaves providerSpecificData.plan undefined without plan fields", () => { + const mapped = claude.mapTokens({ access_token: "token-1", scope: "user:profile" }); + + assert.equal(mapped.providerSpecificData, undefined); +}); diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index e79037fe78..44c65dfe85 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -82,7 +82,7 @@ test("GeminiCLIExecutor.buildHeaders derives the User-Agent from the request mod assert.notEqual(flashHeaders["User-Agent"], proHeaders["User-Agent"]); }); -test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest updates body.project", async () => { +test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest preserves existing body.project", async () => { const executor = new GeminiCLIExecutor(); const originalFetch = globalThis.fetch; let calls = 0; @@ -107,7 +107,7 @@ test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transfo assert.equal(first, "fresh-project-id"); assert.equal(second, "fresh-project-id"); assert.equal(calls, 1); - assert.equal(transformed.project, "fresh-project-id"); + assert.equal(transformed.project, "stale-project"); } finally { globalThis.fetch = originalFetch; } @@ -337,10 +337,10 @@ test("GeminiCLIExecutor.execute applies CLI fingerprint to the final Cloud Code }); } - return new Response( - 'data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n', - { status: 200, headers: { "Content-Type": "text/event-stream" } } - ); + return new Response('data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); }; try { @@ -368,13 +368,10 @@ test("GeminiCLIExecutor.execute applies CLI fingerprint to the final Cloud Code "Authorization", ]); assert.equal(finalBody.model, "gemini-3.1-pro-preview"); - assert.equal(finalBody.project, "project-live"); + assert.equal(finalBody.project, "old-project"); assert.match(finalBody.user_prompt_id, /^agent-/); assert.match(finalBody.request.session_id, /^-\d+$/); - assert.match( - finalCall.headers["User-Agent"], - /^GeminiCLI\/0\.40\.1\/gemini-3\.1-pro-preview / - ); + assert.match(finalCall.headers["User-Agent"], /^GeminiCLI\/0\.40\.1\/gemini-3\.1-pro-preview /); assert.equal(finalCall.headers.Accept, "*/*"); } finally { setCliCompatProviders([]); diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index a043b53394..096c81643d 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -128,6 +128,66 @@ test("handleImageGeneration uses synthetic OpenAI-compatible routing for resolve } }); +test("handleImageGeneration polls KIE image tasks and returns URLs on success", async () => { + const originalFetch = globalThis.fetch; + let createPayload; + let pollUrl = ""; + + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + if (stringUrl === "https://api.kie.ai/api/v1/gpt4o-image/generate") { + createPayload = JSON.parse(String(options.body || "{}")); + return new Response(JSON.stringify({ code: 200, data: { taskId: "kie-task-1" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl.startsWith("https://api.kie.ai/api/v1/gpt4o-image/record-info")) { + pollUrl = stringUrl; + return new Response( + JSON.stringify({ + code: 200, + data: { + status: "SUCCESS", + response: { + resultUrls: ["https://example.com/kie-image.png"], + }, + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "kie/gpt4o-image", + prompt: "city skyline at dusk", + size: "1:1", + n: 1, + }, + credentials: { apiKey: "kie-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(createPayload.prompt, "city skyline at dusk"); + assert.equal(createPayload.size, "1:1"); + assert.equal(createPayload.nVariants, 1); + assert.match(pollUrl, /taskId=kie-task-1/); + assert.equal(result.data.data[0].url, "https://example.com/kie-image.png"); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("handleImageGeneration maps Hyperbolic size parameters and normalizes base64 images", async () => { const originalFetch = globalThis.fetch; let captured; diff --git a/tests/unit/kie-executor-routing.test.ts b/tests/unit/kie-executor-routing.test.ts new file mode 100644 index 0000000000..762b34d9a3 --- /dev/null +++ b/tests/unit/kie-executor-routing.test.ts @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; +import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; +import { KieExecutor } from "../../open-sse/executors/kie.ts"; + +test("KIE chat traffic uses the default executor while media keeps its task executor", () => { + assert.equal(hasSpecializedExecutor("kie"), false); + assert.ok(getExecutor("kie") instanceof DefaultExecutor); + assert.equal(typeof KieExecutor, "function"); +}); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 1ca0db09c8..432a45f966 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -288,6 +288,36 @@ test("v1 models catalog exposes refreshed GitHub Copilot aliases and drops retir ); }); +test("v1 models catalog exposes bare Codex-preferred IDs for native Codex clients", async () => { + await seedConnection("codex", { + authType: "oauth", + name: "codex-native", + apiKey: null, + accessToken: "codex-access", + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const getModel = (id: string) => body.data.find((item) => item.id === id); + + assert.equal(response.status, 200); + const modelId = "codex-auto-review"; + const bareModel = getModel(modelId); + const providerModel = getModel(`codex/${modelId}`); + const aliasModel = getModel(`cx/${modelId}`); + const openAiModel = getModel(`openai/${modelId}`); + + assert.ok(bareModel, `expected bare ${modelId} model`); + assert.ok(providerModel, `expected codex/${modelId} model`); + assert.ok(aliasModel, `expected cx/${modelId} model`); + assert.equal(openAiModel, undefined); + assert.equal(bareModel.owned_by, "codex"); + assert.equal(bareModel.parent, providerModel.id); + assert.equal(providerModel.parent, aliasModel.id); +}); + test("v1 models catalog exposes Antigravity client-visible preview aliases instead of upstream internal IDs", async () => { await seedConnection("antigravity", { authType: "oauth", @@ -892,3 +922,57 @@ test("v1 models catalog adds managed fallback models for Claude-compatible provi assert.ok(ids.has("ccdemo/claude-opus-4-6")); assert.equal(ids.has("ccdemo/claude-sonnet-4-6"), false); }); + +test("v1 models catalog auto-calculates combo context_length from targets when not set manually", async () => { + await seedConnection("openai", { name: "openai-auto-context" }); + await seedConnection("claude", { + authType: "oauth", + name: "claude-auto-context", + apiKey: null, + accessToken: "claude-access", + }); + + // Create a combo with targets having different context limits. + // openai/gpt-4o context = 128000, claude/claude-sonnet-4-6 = 200000. + // The combo should expose context_length = min = 128000. + const combo = await combosDb.createCombo({ + name: "auto-context-combo", + strategy: "priority", + models: ["openai/gpt-4o", "claude/claude-sonnet-4-6"], + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const comboModel = body.data.find((item) => item.id === "auto-context-combo"); + + assert.equal(response.status, 200); + assert.ok(comboModel); + assert.equal( + comboModel.context_length, + 128000, + "combo context_length should be the MIN of all target model limits" + ); +}); + +test("v1 models catalog prefers manual combo context_length over auto-calculated", async () => { + await seedConnection("openai", { name: "openai-manual-context" }); + + const combo = await combosDb.createCombo({ + name: "manual-context-combo", + strategy: "priority", + models: ["openai/gpt-4o"], + }); + await combosDb.updateCombo((combo as any).id, { context_length: 64000 }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const comboModel = body.data.find((item) => item.id === "manual-context-combo"); + + assert.equal(response.status, 200); + assert.ok(comboModel); + assert.equal(comboModel.context_length, 64000, "manual context_length should override auto-calc"); +}); diff --git a/tests/unit/music-generation-handler.test.ts b/tests/unit/music-generation-handler.test.ts index 690eb708f4..d3ca004182 100644 --- a/tests/unit/music-generation-handler.test.ts +++ b/tests/unit/music-generation-handler.test.ts @@ -104,6 +104,64 @@ test("handleMusicGeneration executes ComfyUI audio workflow and normalizes wav o } }); +test("handleMusicGeneration polls KIE music tasks and returns audio URLs", async () => { + const originalFetch = globalThis.fetch; + let createBody; + let pollUrl = ""; + + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + + if (stringUrl === "https://api.kie.ai/api/v1/generate") { + createBody = JSON.parse(String(options.body || "{}")); + return new Response(JSON.stringify({ code: 200, data: { taskId: "kie-music-task" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl.startsWith("https://api.kie.ai/api/v1/generate/record-info")) { + pollUrl = stringUrl; + return new Response( + JSON.stringify({ + code: 200, + data: { + status: "SUCCESS", + response: { + sunoData: [{ audioUrl: "https://example.com/kie-music.mp3" }], + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleMusicGeneration({ + body: { + model: "kie/V4", + prompt: "relaxing piano ambience", + }, + credentials: { apiKey: "kie-key" }, + log: null, + }); + + assert.equal(createBody.model, "V4"); + assert.equal(createBody.customMode, false); + assert.equal(createBody.instrumental, true); + assert.equal(createBody.prompt, "relaxing piano ambience"); + assert.match(pollUrl, /taskId=kie-music-task/); + assert.equal(result.success, true); + assert.equal(result.data.data[0].url, "https://example.com/kie-music.mp3"); + assert.equal(result.data.data[0].format, "mp3"); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("handleMusicGeneration rejects unsupported provider formats", async () => { const originalProvider = MUSIC_PROVIDERS.fakeprovider; diff --git a/tests/unit/plan3-p0.test.ts b/tests/unit/plan3-p0.test.ts index 3f9749651a..16e0a683da 100644 --- a/tests/unit/plan3-p0.test.ts +++ b/tests/unit/plan3-p0.test.ts @@ -28,9 +28,9 @@ test("getModelInfoCore keeps openai fallback for gpt-4o", async () => { assert.equal(info.model, "gpt-4o"); }); -test("getModelInfoCore routes removed codex-auto-review through the default fallback", async () => { +test("getModelInfoCore routes native codex-auto-review to codex", async () => { const info = await getModelInfoCore("codex-auto-review", {}); - assert.equal(info.provider, "openai"); + assert.equal(info.provider, "codex"); assert.equal(info.model, "codex-auto-review"); }); diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index ae40f8b868..2c78c2969c 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -30,6 +30,17 @@ test("Codex workspacePlanType is used when live plan is missing or unknown", () assert.equal(tier.variant, "success"); }); +test("Claude providerSpecificData plan is used when live plan is missing", () => { + const resolvedPlan = providerLimitUtils.resolvePlanValue(null, { + plan: "Pro", + }); + + assert.equal(resolvedPlan, "Pro"); + const tier = providerLimitUtils.normalizePlanTier(resolvedPlan); + assert.equal(tier.key, "pro"); + assert.equal(tier.variant, "success"); +}); + test("remaining percentage helpers reflect remaining quota and stale resets refill to 100", () => { assert.equal(providerLimitUtils.calculatePercentage(0, 100), 100); assert.equal(providerLimitUtils.calculatePercentage(17, 100), 83); diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts index 66935d5819..6f94e5c6e0 100644 --- a/tests/unit/usage-analytics-route.test.ts +++ b/tests/unit/usage-analytics-route.test.ts @@ -155,16 +155,7 @@ test("GET /api/usage/analytics maps Codex auto-review usage to GPT-5.5 pricing", db.prepare( `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - "codex", - "codex-auto-review", - "codex-conn", - 1000, - 500, - 1, - 250, - new Date().toISOString() - ); + ).run("codex", "codex-auto-review", "codex-conn", 1000, 500, 1, 250, new Date().toISOString()); const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); const body = await response.json(); @@ -254,6 +245,65 @@ test("GET /api/usage/analytics includes cost by API key", async () => { assertClose(body.byApiKey[0].cost, body.summary.totalCost); }); +test("GET /api/usage/analytics groups renamed API key usage by stable ID", async () => { + const apiKey = await apiKeysDb.createApiKey("Averyanov", "machine1234567890"); + await apiKeysDb.updateApiKeyPermissions(apiKey.id, { name: "Alexander Averyanov" }); + + const db = core.getDbInstance(); + const now = Date.now(); + const insertUsage = db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + insertUsage.run( + "openai", + "gpt-4o", + "test-conn", + apiKey.id, + "Averyanov", + 100, + 50, + 1, + 200, + new Date(now - 60_000).toISOString() + ); + insertUsage.run( + "openai", + "gpt-4o", + "test-conn", + apiKey.id, + "Desktop", + 200, + 100, + 1, + 250, + new Date(now).toISOString() + ); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.uniqueApiKeys, 1); + assert.equal(body.byApiKey.length, 1); + assert.equal(body.byApiKey[0].apiKeyId, apiKey.id); + assert.equal(body.byApiKey[0].apiKeyName, "Alexander Averyanov"); + assert.deepEqual(body.byApiKey[0].historicalApiKeyNames.sort(), ["Averyanov", "Desktop"]); + assert.equal(body.byApiKey[0].requests, 2); + assert.equal(body.byApiKey[0].promptTokens, 300); + assert.equal(body.byApiKey[0].completionTokens, 150); + + const filteredResponse = await analyticsRoute.GET( + makeRequest(`http://localhost/api/usage/analytics?apiKeyIds=${apiKey.id}`) + ); + const filteredBody = await filteredResponse.json(); + + assert.equal(filteredResponse.status, 200); + assert.equal(filteredBody.summary.totalRequests, 2); + assert.equal(filteredBody.byApiKey.length, 1); + assert.equal(filteredBody.byApiKey[0].apiKeyId, apiKey.id); +}); + test("GET /api/usage/analytics does not persist guessed API key attribution", async () => { await localDb.updatePricing({ openai: { "gpt-4o": { input: 2.5, output: 10 } }, diff --git a/tests/unit/usage-analytics.test.ts b/tests/unit/usage-analytics.test.ts index 8ff5f6662c..79a12890b2 100644 --- a/tests/unit/usage-analytics.test.ts +++ b/tests/unit/usage-analytics.test.ts @@ -9,9 +9,11 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const localDb = await import("../../src/lib/localDb.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); const usageStats = await import("../../src/lib/usage/usageStats.ts"); +const legacyUsageAnalytics = await import("../../src/lib/usageAnalytics.ts"); const callLogs = await import("../../src/lib/usage/callLogs.ts"); const { calculateCost } = await import("../../src/lib/usage/costCalculator.ts"); @@ -20,6 +22,7 @@ const clearPendingRequests = usageHistory.clearPendingRequests; async function resetStorage() { core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); clearPendingRequests(); @@ -31,6 +34,7 @@ test.beforeEach(async () => { test.after(() => { core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); @@ -258,7 +262,7 @@ test("getUsageStats aggregates totals, buckets, pending requests, and cost break assert.equal(stats.byAccount[accountKey].requests, 2); assert.equal(stats.byAccount[accountKey].accountName, "Primary Account"); - assert.equal(stats.byApiKey["Service Key (api-key-1)"].requests, 2); + assert.equal(stats.byApiKey["id:api-key-1"].requests, 2); assert.equal(stats.pending.byModel["pricing-model (pricing-provider)"], 1); assert.equal(stats.pending.byAccount[connection.id]["pricing-model (pricing-provider)"], 1); assert.deepEqual(stats.activeRequests, [ @@ -275,6 +279,90 @@ test("getUsageStats aggregates totals, buckets, pending requests, and cost break assert.equal(recentBucketTotal, 1); }); +test("getUsageStats groups renamed API key usage by stable ID", async () => { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "api-key-rename", + "Current Name", + "omni-test-key", + "machine1234567890", + "[]", + 0, + now, + "omni-test-ke" + ); + + await usageHistory.saveRequestUsage({ + provider: "provider-a", + model: "model-a", + apiKeyId: "api-key-rename", + apiKeyName: "Original Name", + tokens: { input: 10, output: 5 }, + success: true, + timestamp: new Date(Date.now() - 60_000).toISOString(), + }); + await usageHistory.saveRequestUsage({ + provider: "provider-a", + model: "model-a", + apiKeyId: "api-key-rename", + apiKeyName: "Renamed Alias", + tokens: { input: 20, output: 10 }, + success: true, + timestamp: now, + }); + + const stats = await usageStats.getUsageStats(); + const row = stats.byApiKey["id:api-key-rename"]; + + assert.ok(row); + assert.equal(Object.keys(stats.byApiKey).length, 1); + assert.equal(row.apiKeyId, "api-key-rename"); + assert.equal(row.apiKeyName, "Current Name"); + assert.deepEqual(row.historicalApiKeyNames?.sort(), ["Original Name", "Renamed Alias"]); + assert.equal(row.requests, 2); + assert.equal(row.promptTokens, 30); + assert.equal(row.completionTokens, 15); +}); + +test("computeAnalytics groups renamed API key usage by stable ID", async () => { + const analytics = await legacyUsageAnalytics.computeAnalytics( + [ + { + timestamp: new Date(Date.now() - 60_000).toISOString(), + provider: "provider-a", + model: "model-a", + apiKeyId: "api-key-legacy", + apiKeyName: "Original Name", + tokens: { input: 10, output: 5 }, + }, + { + timestamp: new Date().toISOString(), + provider: "provider-a", + model: "model-a", + apiKeyId: "api-key-legacy", + apiKeyName: "Renamed Alias", + tokens: { input: 20, output: 10 }, + }, + ], + "all" + ); + + assert.equal(analytics.summary.uniqueApiKeys, 1); + assert.equal(analytics.byApiKey.length, 1); + assert.equal(analytics.byApiKey[0].apiKeyId, "api-key-legacy"); + assert.deepEqual(analytics.byApiKey[0].historicalApiKeyNames.sort(), [ + "Original Name", + "Renamed Alias", + ]); + assert.equal(analytics.byApiKey[0].requests, 2); + assert.equal(analytics.byApiKey[0].promptTokens, 30); + assert.equal(analytics.byApiKey[0].completionTokens, 15); +}); + test("recent request summaries are generated from SQLite call logs", async () => { const connection = await providersDb.createProviderConnection({ provider: "log-provider", diff --git a/tests/unit/video-generation-handler.test.ts b/tests/unit/video-generation-handler.test.ts index c1a37274fe..6bedafc934 100644 --- a/tests/unit/video-generation-handler.test.ts +++ b/tests/unit/video-generation-handler.test.ts @@ -90,6 +90,60 @@ test("handleVideoGeneration routes SD WebUI payloads and normalizes mp4 output", } }); +test("handleVideoGeneration polls KIE market tasks and returns video URLs", async () => { + const originalFetch = globalThis.fetch; + let createBody; + let pollUrl = ""; + + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + + if (stringUrl === "https://api.kie.ai/api/v1/jobs/createTask") { + createBody = JSON.parse(String(options.body || "{}")); + return new Response(JSON.stringify({ code: 200, data: { taskId: "kie-video-task" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl.startsWith("https://api.kie.ai/api/v1/jobs/recordInfo")) { + pollUrl = stringUrl; + return new Response( + JSON.stringify({ + code: 200, + data: { + state: "success", + resultJson: '{"resultUrls":["https://example.com/kie-video.mp4"]}', + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleVideoGeneration({ + body: { + model: "kie/kling-2.6/text-to-video", + prompt: "cinematic shot of neon city rain", + }, + credentials: { apiKey: "kie-key" }, + log: null, + }); + + assert.equal(createBody.model, "kling-2.6/text-to-video"); + assert.equal(createBody.input.prompt, "cinematic shot of neon city rain"); + assert.match(pollUrl, /taskId=kie-video-task/); + assert.equal(result.success, true); + assert.equal(result.data.data[0].url, "https://example.com/kie-video.mp4"); + assert.equal(result.data.data[0].format, "mp4"); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("handleVideoGeneration executes ComfyUI workflow and returns fetched output files", async () => { const originalFetch = globalThis.fetch; const originalSetTimeout = globalThis.setTimeout;