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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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
⭐ 60.8K
-
+
Claude Code
⭐ 67.3K
-
+
Gemini CLI
⭐ 94.7K
-
+
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.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
-SD WebUI
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({
-
{
- (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({
-
{
- (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 ? (
-
{
- (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()}"
-
{
- (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 (
-
{
- (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({
-
{
- (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()}
-
- ) : (
- 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 */}
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"
>
))}
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;