Compare commits

...

8 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
6e7e04839f Merge pull request #610 from diegosouzapw/release/v3.0.2
chore(release): v3.0.2 — Proxy UI fixes & Connection Tag Grouping
2026-03-25 09:08:24 -03:00
Diego Rodrigues de Sa e Souza
f62dcc12a0 Merge pull request #608 from diegosouzapw/release/v3.0.1
chore(release): v3.0.1 — hotfix for proxy_ prefix, LongCat validation, and MCP tool schemas
2026-03-25 09:07:27 -03:00
diegosouzapw
bef591c2e6 chore(release): v3.0.2 — proxy ui fixes and connection tag grouping 2026-03-25 09:02:38 -03:00
diegosouzapw
5907296d36 fix: proxy UI bugs, connection tag grouping, and function_call prefix stripping
## Proxy UI Bug Fixes
- fix: proxy badge on connection cards now uses resolveProxyForConnection()
  per-connection (covers registry + config-file assignments)
- fix: Test Connection button now works in 'saved' proxy mode by resolving
  proxy config from savedProxies list
- fix: ProxyConfigModal now calls onClose() after save/clear (fixes UI freeze)
- fix: ProxyRegistryManager loads usage eagerly on mount with deduplication
  by scope+scopeId to prevent double-counting; adds per-row Test button

## Connection Tag Grouping (new feature)
- feat: add Tag/Group field to EditConnectionModal (stored in
  providerSpecificData.tag, no DB schema change)
- feat: connections list groups by tag with visual dividers when any account
  has a tag; untagged accounts appear first without header

## Post-merge fix from PR #607 review
- fix: function_call blocks in translateNonStreamingResponse now also strip
  Claude OAuth proxy_ prefix via toolNameMap (kilo-code-bot #607 warning)
  Affects OpenAI Responses API format path — tool_use was fixed in PR #607
  but function_call was missed
2026-03-25 08:54:46 -03:00
diegosouzapw
aa2a7d12be chore(release): v3.0.1 — hotfix for proxy_ prefix, LongCat validation, and MCP tool schemas 2026-03-25 08:20:04 -03:00
Diego Rodrigues de Sa e Souza
33fee5dcc5 fix: strip proxy_ prefix in non-streaming Claude responses & fix LongCat validation (#605, #592) (#607)
- fix(translator): pass toolNameMap to translateNonStreamingResponse so Claude
  OAuth proxy_ prefix is correctly stripped from tool_use block names in
  non-streaming responses (was only stripped in streaming path)
- fix(validation): add LongCat specialty validator that probes /chat/completions
  directly, bypassing the /v1/models endpoint that LongCat does not expose (#592)

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-03-25 08:16:46 -03:00
Randi
e9ae50be0c fix: improve Provider Limits light mode contrast and Claude plan tier display (#591)
- Replace hardcoded rgba(255,255,255,...) borders/backgrounds with theme-aware
  CSS variables (--color-border, --color-bg-subtle) for proper light mode contrast
- Add dark: variants for hover states and progress bar backgrounds
- Fix Claude plan tier: try to extract actual plan from OAuth response instead
  of hardcoding "Claude Code"
- Recognize provider names (Claude Code, Kimi Coding, Kiro) as non-plan-tier
  values in normalizePlanTier() to avoid showing them as tier badges

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 08:16:28 -03:00
Flo
5886c0fd5e docs(i18n): fix russian translation for playground and testbed (#589)
Co-authored-by: Vladimir Alabov <vladimir.alabov@bsc-ideas.com>
2026-03-25 08:15:59 -03:00
15 changed files with 620 additions and 210 deletions

View File

@@ -4,6 +4,79 @@
---
## [3.0.2] — 2026-03-25
### 🚀 Enhancements & Features
#### feat(ui): Connection Tag Grouping
- Added a Tag/Group field to `EditConnectionModal` (stored in `providerSpecificData.tag`) without requiring DB schema migrations.
- Connections in the provider view now dynamically group by tag with visual dividers.
- Untagged connections appear first without a header, followed by tagged groups in alphabetical order.
- The tag grouping automatically applies to the Codex/Copilot/Antigravity Limits section since toggles exist inside connection rows.
### 🐛 Bug Fixes
#### fix(ui): Proxy Management UI Stabilization
- **Missing badges on connection cards:** Fixed by using `resolveProxyForConnection()` rather than static mapping.
- **Test Connection disabled in saved mode:** Enabled the Test button by resolving proxy config from the saved list.
- **Config Modal freezing:** Added `onClose()` calls after save/clear to prevent the UI from freezing.
- **Double usage counting:** `ProxyRegistryManager` now loads usage eagerly on mount with deduplication by `scope` + `scopeId`. Usage counts were replaced with a Test button displaying IP/latency inline.
#### fix(translator): `function_call` prefix stripping
- Repaired an incomplete fix from PR #607 where only `tool_use` blocks stripped Claude's `proxy_` tool prefix. Now, clients using the OpenAI Responses API format will also correctly receive tool tools without the `proxy_` prefix.
---
## [3.0.1] — 2026-03-25
### 🔧 Hotfix Patch — Critical Bug Fixes
Three critical regressions reported by users after the v3.0.0 launch have been resolved.
#### fix(translator): strip `proxy_` prefix in non-streaming Claude responses (#605)
The `proxy_` prefix added by Claude OAuth was only stripped from **streaming** responses. In **non-streaming** mode, `translateNonStreamingResponse` had no access to the `toolNameMap`, causing clients to receive mangled tool names like `proxy_read_file` instead of `read_file`.
**Fix:** Added optional `toolNameMap` parameter to `translateNonStreamingResponse` and applied prefix stripping in the Claude `tool_use` block handler. `chatCore.ts` now passes the map through.
#### fix(validation): add LongCat specialty validator to skip /models probe (#592)
LongCat AI does not expose `GET /v1/models`. The generic `validateOpenAICompatibleProvider` validator fell through to a chat-completions fallback only if `validationModelId` was set, which LongCat doesn't configure. This caused provider validation to fail with a misleading error on add/save.
**Fix:** Added `longcat` to the specialty validators map, probing `/chat/completions` directly and treating any non-auth response as a pass.
#### fix(translator): normalize object tool schemas for Anthropic (#595)
MCP tools (e.g. `pencil`, `computer_use`) forward tool definitions with `{type:"object"}` but without a `properties` field. Anthropic's API rejects these with: `object schema missing properties`.
**Fix:** In `openai-to-claude.ts`, inject `properties: {}` as a safe default when `type` is `"object"` and `properties` is absent.
---
### 🔀 Community PRs Merged (2)
| PR | Author | Summary |
| -------- | ------- | -------------------------------------------------------------------------- |
| **#589** | @flobo3 | docs(i18n): fix Russian translation for Playground and Testbed |
| **#591** | @rdself | fix(ui): improve Provider Limits light mode contrast and plan tier display |
---
### ✅ Issues Resolved
`#592` `#595` `#605`
---
### 🧪 Tests
- **926 tests, 0 failures** (unchanged from v3.0.0)
---
## [3.0.0] — 2026-03-24
### 🎉 OmniRoute v3.0.0 — The Free AI Gateway, Now with 67+ Providers
@@ -16,39 +89,39 @@
### 🆕 New Providers (+31 since v2.9.5)
| Provider | Alias | Tier | Notes |
|----------|-------|------|-------|
| **OpenCode Zen** | `opencode-zen` | Free | 3 models via `opencode.ai/zen/v1` (PR #530 by @kang-heewon) |
| **OpenCode Go** | `opencode-go` | Paid | 4 models via `opencode.ai/zen/go/v1` (PR #530 by @kang-heewon) |
| **LongCat AI** | `lc` | Free | 50M tokens/day (Flash-Lite) + 500K/day (Chat/Thinking) during public beta |
| **Pollinations AI** | `pol` | Free | No API key needed — GPT-5, Claude, Gemini, DeepSeek V3, Llama 4 (1 req/15s) |
| **Cloudflare Workers AI** | `cf` | Free | 10K Neurons/day — ~150 LLM responses or 500s Whisper audio, edge inference |
| **Scaleway AI** | `scw` | Free | 1M free tokens for new accounts — EU/GDPR compliant (Paris) |
| **AI/ML API** | `aiml` | Free | $0.025/day free credits — 200+ models via single endpoint |
| **Puter AI** | `pu` | Free | 500+ models (GPT-5, Claude Opus 4, Gemini 3 Pro, Grok 4, DeepSeek V3) |
| **Alibaba Cloud (DashScope)** | `ali` | Paid | International + China endpoints via `alicode`/`alicode-intl` |
| **Alibaba Coding Plan** | `bcp` | Paid | Alibaba Model Studio with Anthropic-compatible API |
| **Kimi Coding (API Key)** | `kmca` | Paid | Dedicated API-key-based Kimi access (separate from OAuth) |
| **MiniMax Coding** | `minimax` | Paid | International endpoint |
| **MiniMax (China)** | `minimax-cn` | Paid | China-specific endpoint |
| **Z.AI (GLM-5)** | `zai` | Paid | Zhipu AI next-gen GLM models |
| **Vertex AI** | `vertex` | Paid | Google Cloud — Service Account JSON or OAuth access_token |
| **Ollama Cloud** | `ollamacloud` | Paid | Ollama's hosted API service |
| **Synthetic** | `synthetic` | Paid | Passthrough models gateway |
| **Kilo Gateway** | `kg` | Paid | Passthrough models gateway |
| **Perplexity Search** | `pplx-search` | Paid | Dedicated search-grounded endpoint |
| **Serper Search** | `serper-search` | Paid | Web search API integration |
| **Brave Search** | `brave-search` | Paid | Brave Search API integration |
| **Exa Search** | `exa-search` | Paid | Neural search API integration |
| **Tavily Search** | `tavily-search` | Paid | AI search API integration |
| **NanoBanana** | `nb` | Paid | Image generation API |
| **ElevenLabs** | `el` | Paid | Text-to-speech voice synthesis |
| **Cartesia** | `cartesia` | Paid | Ultra-fast TTS voice synthesis |
| **PlayHT** | `playht` | Paid | Voice cloning and TTS |
| **Inworld** | `inworld` | Paid | AI character voice chat |
| **SD WebUI** | `sdwebui` | Self-hosted | Stable Diffusion local image generation |
| **ComfyUI** | `comfyui` | Self-hosted | ComfyUI local workflow node-based generation |
| **GLM Coding** | `glm` | Paid | BigModel/Zhipu coding-specific endpoint |
| Provider | Alias | Tier | Notes |
| ----------------------------- | --------------- | ----------- | --------------------------------------------------------------------------- |
| **OpenCode Zen** | `opencode-zen` | Free | 3 models via `opencode.ai/zen/v1` (PR #530 by @kang-heewon) |
| **OpenCode Go** | `opencode-go` | Paid | 4 models via `opencode.ai/zen/go/v1` (PR #530 by @kang-heewon) |
| **LongCat AI** | `lc` | Free | 50M tokens/day (Flash-Lite) + 500K/day (Chat/Thinking) during public beta |
| **Pollinations AI** | `pol` | Free | No API key needed — GPT-5, Claude, Gemini, DeepSeek V3, Llama 4 (1 req/15s) |
| **Cloudflare Workers AI** | `cf` | Free | 10K Neurons/day — ~150 LLM responses or 500s Whisper audio, edge inference |
| **Scaleway AI** | `scw` | Free | 1M free tokens for new accounts — EU/GDPR compliant (Paris) |
| **AI/ML API** | `aiml` | Free | $0.025/day free credits — 200+ models via single endpoint |
| **Puter AI** | `pu` | Free | 500+ models (GPT-5, Claude Opus 4, Gemini 3 Pro, Grok 4, DeepSeek V3) |
| **Alibaba Cloud (DashScope)** | `ali` | Paid | International + China endpoints via `alicode`/`alicode-intl` |
| **Alibaba Coding Plan** | `bcp` | Paid | Alibaba Model Studio with Anthropic-compatible API |
| **Kimi Coding (API Key)** | `kmca` | Paid | Dedicated API-key-based Kimi access (separate from OAuth) |
| **MiniMax Coding** | `minimax` | Paid | International endpoint |
| **MiniMax (China)** | `minimax-cn` | Paid | China-specific endpoint |
| **Z.AI (GLM-5)** | `zai` | Paid | Zhipu AI next-gen GLM models |
| **Vertex AI** | `vertex` | Paid | Google Cloud — Service Account JSON or OAuth access_token |
| **Ollama Cloud** | `ollamacloud` | Paid | Ollama's hosted API service |
| **Synthetic** | `synthetic` | Paid | Passthrough models gateway |
| **Kilo Gateway** | `kg` | Paid | Passthrough models gateway |
| **Perplexity Search** | `pplx-search` | Paid | Dedicated search-grounded endpoint |
| **Serper Search** | `serper-search` | Paid | Web search API integration |
| **Brave Search** | `brave-search` | Paid | Brave Search API integration |
| **Exa Search** | `exa-search` | Paid | Neural search API integration |
| **Tavily Search** | `tavily-search` | Paid | AI search API integration |
| **NanoBanana** | `nb` | Paid | Image generation API |
| **ElevenLabs** | `el` | Paid | Text-to-speech voice synthesis |
| **Cartesia** | `cartesia` | Paid | Ultra-fast TTS voice synthesis |
| **PlayHT** | `playht` | Paid | Voice cloning and TTS |
| **Inworld** | `inworld` | Paid | AI character voice chat |
| **SD WebUI** | `sdwebui` | Self-hosted | Stable Diffusion local image generation |
| **ComfyUI** | `comfyui` | Self-hosted | ComfyUI local workflow node-based generation |
| **GLM Coding** | `glm` | Paid | BigModel/Zhipu coding-specific endpoint |
**Total: 67+ providers** (4 Free, 8 OAuth, 55 API Key) + unlimited OpenAI/Anthropic-Compatible custom providers.
@@ -60,15 +133,15 @@
Auto-generate and issue OmniRoute API keys programmatically with per-provider and per-account quota enforcement.
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/registered-keys` | `POST` | Issue a new key — raw key returned **once only** |
| `/api/v1/registered-keys` | `GET` | List registered keys (masked) |
| `/api/v1/registered-keys/{id}` | `GET/DELETE` | Get metadata / Revoke |
| `/api/v1/quotas/check` | `GET` | Pre-validate quota before issuing |
| `/api/v1/providers/{id}/limits` | `GET/PUT` | Configure per-provider issuance limits |
| `/api/v1/accounts/{id}/limits` | `GET/PUT` | Configure per-account issuance limits |
| `/api/v1/issues/report` | `POST` | Report quota events to GitHub Issues |
| Endpoint | Method | Description |
| ------------------------------- | ------------ | ------------------------------------------------ |
| `/api/v1/registered-keys` | `POST` | Issue a new key — raw key returned **once only** |
| `/api/v1/registered-keys` | `GET` | List registered keys (masked) |
| `/api/v1/registered-keys/{id}` | `GET/DELETE` | Get metadata / Revoke |
| `/api/v1/quotas/check` | `GET` | Pre-validate quota before issuing |
| `/api/v1/providers/{id}/limits` | `GET/PUT` | Configure per-provider issuance limits |
| `/api/v1/accounts/{id}/limits` | `GET/PUT` | Configure per-account issuance limits |
| `/api/v1/issues/report` | `POST` | Report quota events to GitHub Issues |
**Security:** Keys stored as SHA-256 hashes. Raw key shown once on creation, never retrievable again.
@@ -83,6 +156,7 @@ Auto-refreshes model lists for connected providers every **24 hours**. Runs on s
#### 🔀 Per-Model Combo Routing (#563)
Map model name patterns (glob) to specific combos for automatic routing:
- `claude-sonnet*` → code-combo, `gpt-4o*` → openai-combo, `gemini-*` → google-combo
- New `model_combo_mappings` table with glob-to-regex matching
- Dashboard UI section: "Model Routing Rules" with inline add/edit/toggle/delete
@@ -122,12 +196,14 @@ Full media generation playground at `/dashboard/media`: Image Generation, Video,
### 🐛 Bug Fixes (40+)
#### OAuth & Auth
- **#537** — Gemini CLI OAuth: clear actionable error when `GEMINI_OAUTH_CLIENT_SECRET` missing in Docker
- **#549** — CLI settings routes now resolve real API key from `keyId` (not masked strings)
- **#574** — Login no longer freezes after skipping wizard password setup
- **#506** — Cross-platform `machineId` rewritten (Windows REG.exe → macOS ioreg → Linux → hostname fallback)
#### Providers & Routing
- **#536** — LongCat AI: fixed `baseUrl` and `authHeader`
- **#535** — Pinned model override: `body.model` correctly set to `pinnedModel`
- **#570** — Unprefixed Claude models now resolve to Anthropic provider
@@ -137,6 +213,7 @@ Full media generation playground at `/dashboard/media`: Image Generation, Video,
- **#511** — `<omniModel>` tag injected into first content chunk (not after `[DONE]`)
#### CLI & Tools
- **#527** — Claude Code + Codex loop: `tool_result` blocks now converted to text
- **#524** — OpenCode config saved correctly (XDG_CONFIG_HOME, TOML format)
- **#522** — API Manager: removed misleading "Copy masked key" button
@@ -146,12 +223,14 @@ Full media generation playground at `/dashboard/media`: Image Generation, Video,
- **#492** — CLI detects `mise`/`nvm`-managed Node when `app/server.js` missing
#### Streaming & SSE
- **PR #587** — Revert `resolveDataDir` import in responsesTransformer for Cloudflare Workers compat (@k0valik)
- **PR #495** — Bottleneck 429 infinite wait: drop waiting jobs on rate limit (@xandr0s)
- **#483** — Stop trailing `data: null` after `[DONE]` signal
- **#473** — Zombie SSE streams: timeout reduced 300s → 120s for faster fallback
#### Media & Transcription
- **Transcription** — Deepgram `video/mp4``audio/mp4` MIME mapping, auto language detection, punctuation
- **TTS** — `[object Object]` error display fixed for ElevenLabs-style nested errors
- **Upload limits** — Media transcription increased to 2GB (nginx `client_max_body_size 2g` + `maxDuration=300`)
@@ -214,27 +293,27 @@ Full media generation playground at `/dashboard/media`: Image Generation, Video,
### 🔀 Community PRs Merged (10)
| PR | Author | Summary |
|----|--------|---------|
| **#587** | @k0valik | fix(sse): revert resolveDataDir import for Cloudflare Workers compat |
| **#582** | @jay77721 | feat(proxy): model name prefix stripping option |
| **#581** | @jay77721 | fix(npm): link electron-release to npm-publish workflow |
| **#578** | @hijak | feat: configurable context length in model metadata |
| **#575** | @zhangqiang8vip | feat: per-model upstream headers, compat PATCH, chat alignment |
| **#562** | @coobabm | fix: MCP session management, Claude passthrough, detectFormat |
| **#561** | @zen0bit | fix(i18n): Czech translation corrections |
| **#555** | @k0valik | fix(sse): centralized `resolveDataDir()` for path resolution |
| **#546** | @k0valik | fix(cli): `--version` returning `unknown` on Windows |
| **#544** | @k0valik | fix(cli): secure CLI tool detection via installation paths |
| **#542** | @rdself | fix(ui): light mode contrast CSS theme variables |
| **#530** | @kang-heewon | feat: OpenCode Zen + Go providers with `OpencodeExecutor` |
| **#512** | @zhangqiang8vip | feat: per-protocol model compatibility (`compatByProtocol`) |
| **#497** | @zhangqiang8vip | fix: dev-mode HMR resource leaks (ZWS v5) |
| **#495** | @xandr0s | fix: Bottleneck 429 infinite wait (drop waiting jobs) |
| **#494** | @zhangqiang8vip | feat: MiniMax developer→system role fix |
| **#480** | @prakersh | fix: stream flush usage extraction |
| **#479** | @prakersh | feat: Codex 5.3/5.4 and Anthropic pricing entries |
| **#475** | @only4copilot | feat(i18n): improved Chinese translation |
| PR | Author | Summary |
| -------- | --------------- | -------------------------------------------------------------------- |
| **#587** | @k0valik | fix(sse): revert resolveDataDir import for Cloudflare Workers compat |
| **#582** | @jay77721 | feat(proxy): model name prefix stripping option |
| **#581** | @jay77721 | fix(npm): link electron-release to npm-publish workflow |
| **#578** | @hijak | feat: configurable context length in model metadata |
| **#575** | @zhangqiang8vip | feat: per-model upstream headers, compat PATCH, chat alignment |
| **#562** | @coobabm | fix: MCP session management, Claude passthrough, detectFormat |
| **#561** | @zen0bit | fix(i18n): Czech translation corrections |
| **#555** | @k0valik | fix(sse): centralized `resolveDataDir()` for path resolution |
| **#546** | @k0valik | fix(cli): `--version` returning `unknown` on Windows |
| **#544** | @k0valik | fix(cli): secure CLI tool detection via installation paths |
| **#542** | @rdself | fix(ui): light mode contrast CSS theme variables |
| **#530** | @kang-heewon | feat: OpenCode Zen + Go providers with `OpencodeExecutor` |
| **#512** | @zhangqiang8vip | feat: per-protocol model compatibility (`compatByProtocol`) |
| **#497** | @zhangqiang8vip | fix: dev-mode HMR resource leaks (ZWS v5) |
| **#495** | @xandr0s | fix: Bottleneck 429 infinite wait (drop waiting jobs) |
| **#494** | @zhangqiang8vip | feat: MiniMax developer→system role fix |
| **#480** | @prakersh | fix: stream flush usage extraction |
| **#479** | @prakersh | feat: Codex 5.3/5.4 and Anthropic pricing entries |
| **#475** | @only4copilot | feat(i18n): improved Chinese translation |
**Thank you to all contributors!** 🙏
@@ -255,11 +334,11 @@ Full media generation playground at `/dashboard/media`: Image Generation, Video,
### 📦 Database Migrations
| Migration | Description |
|-----------|-------------|
| **008** | `registered_keys`, `provider_key_limits`, `account_key_limits` tables |
| **009** | `requested_model` column in `call_logs` |
| **010** | `model_combo_mappings` table for per-model combo routing |
| Migration | Description |
| --------- | --------------------------------------------------------------------- |
| **008** | `registered_keys`, `provider_key_limits`, `account_key_limits` tables |
| **009** | `requested_model` column in `call_logs` |
| **010** | `model_combo_mappings` table for per-model combo routing |
---
@@ -310,6 +389,7 @@ docker pull diegosouzapw/omniroute:3.0.0
## [3.0.0-rc.16] — 2026-03-24
### ✨ New Features
- Increased media transcription limits
- Added Model Context Length to registry metadata
- Added per-model upstream custom headers via configuration UI

View File

@@ -386,7 +386,7 @@ Claude Code, Codex, Gemini CLI, Copilot — все используют OAuth 2.
- **Панель управления унифицированными журналами** — 4 вкладки: журналы запросов, журналы прокси, журналы аудита, консоль.
- **Консольный просмотр журнала** — просмотрщик в режиме терминала в режиме реального времени с уровнями с цветовой кодировкой, автоматической прокруткой, поиском и фильтрацией.
- **Журналы прокси-сервера SQLite** — постоянные журналы, сохраняющиеся после перезапуска сервера.
- **Площадка переводчика** — 4 режима отладки: Площадка (перевод формата), Тестер чата (туда и обратно), Тестовый стенд (пакетный), Мониторинг в реальном времени (в режиме реального времени).
- **Площадка транслятора (Translator Playground)** — 4 режима отладки: Площадка (перевод формата), Тестер чата (туда и обратно), Тестовый стенд (пакетный), Мониторинг в реальном времени (в режиме реального времени).
- **Запрос телеметрии** — задержка p50/p95/p99 + отслеживание X-Request-Id
- **Журналирование на основе файлов с ротацией** — перехватчик консоли записывает все в журнал JSON с ротацией на основе размера.
@@ -451,7 +451,7 @@ Claude Code, Codex, Gemini CLI, Copilot — все используют OAuth 2.
- **Оценки LLM** — тестирование золотого набора с 10 предварительно загруженными вариантами, охватывающими приветствия, математику, географию, генерацию кода, соответствие JSON, перевод, уценку, отказ от безопасности.
- **4 стратегии сопоставления** — `exact`, `contains`, `regex`, `custom` (функция JS)
- **Тестовый стенд Translator Playground** — пакетное тестирование с несколькими входными данными и ожидаемыми результатами, сравнение между поставщиками.
- **Тестовый стенд (Testbed)** — пакетное тестирование с несколькими входными данными и ожидаемыми результатами, сравнение между поставщиками.
- **Тестер чата** — полный цикл с визуальным отображением ответов.
- **Живой монитор** — поток всех запросов, проходящих через прокси, в реальном времени.
@@ -1016,7 +1016,7 @@ OmniRoute включает встроенный фреймворк оценки
- Приветствия, математика, география, генерация кода
- Соответствие формату JSON, перевод, markdown
- Отказ от небезопасного контента, подсчёт, булева логика
- Отказ от небезопасного контента (Safety refusal), подсчёт, булева логика
### Стратегии оценки

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.0.0
version: 3.0.2
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,

View File

@@ -223,7 +223,8 @@ export async function handleChatCore({
const endpointPath = String(clientRawRequest?.endpoint || "");
const sourceFormat = detectFormatFromEndpoint(body, endpointPath);
const isResponsesEndpoint = /\/responses(?=\/|$)/i.test(endpointPath) || /^responses(?=\/|$)/i.test(endpointPath);
const isResponsesEndpoint =
/\/responses(?=\/|$)/i.test(endpointPath) || /^responses(?=\/|$)/i.test(endpointPath);
const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({
provider,
sourceFormat,
@@ -1067,8 +1068,14 @@ export async function handleChatCore({
}
// Translate response to client's expected format (usually OpenAI)
// Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605)
let translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
? translateNonStreamingResponse(
responseBody,
targetFormat,
sourceFormat,
toolNameMap as Map<string, string> | null
)
: responseBody;
// T26: Strip markdown code blocks if provider format is Claude

View File

@@ -68,11 +68,14 @@ function findBestMessageText(output: unknown[]): {
/**
* Translate non-streaming response to OpenAI format
* Handles different provider response formats (Gemini, Claude, etc.)
*
* @param toolNameMap - Optional Map<prefixedName, originalName> for Claude OAuth tool name stripping
*/
export function translateNonStreamingResponse(
responseBody: unknown,
targetFormat: string,
sourceFormat: string
sourceFormat: string,
toolNameMap?: Map<string, string> | null
): unknown {
// If already in source format (usually OpenAI), return as-is
if (targetFormat === sourceFormat || targetFormat === FORMATS.OPENAI) {
@@ -122,11 +125,14 @@ export function translateNonStreamingResponse(
typeof itemObj.arguments === "string"
? itemObj.arguments
: JSON.stringify(itemObj.arguments || {});
const rawName = toString(itemObj.name);
// Strip Claude OAuth proxy_ prefix using toolNameMap (mirrors tool_use fix for #605)
const resolvedName = toolNameMap?.get(rawName) ?? rawName;
toolCalls.push({
id: callId,
type: "function",
function: {
name: toString(itemObj.name),
name: resolvedName,
arguments: fnArgs,
},
});
@@ -334,11 +340,15 @@ export function translateNonStreamingResponse(
} else if (blockObj.type === "thinking") {
thinkingContent += toString(blockObj.thinking);
} else if (blockObj.type === "tool_use") {
// Strip Claude OAuth tool name prefix (proxy_) using the map from request translation.
// Fallback to raw name if block wasn't prefixed (disableToolPrefix path).
const rawName = toString(blockObj.name);
const strippedName = toolNameMap?.get(rawName) ?? rawName;
toolCalls.push({
id: toString(blockObj.id, `call_${Date.now()}_${toolCalls.length}`),
type: "function",
function: {
name: toString(blockObj.name),
name: strippedName,
arguments: JSON.stringify(blockObj.input || {}),
},
});

View File

@@ -86,7 +86,8 @@ function toDisplayLabel(value: string): string {
.filter(Boolean)
.map((part) => {
if (/^pro\+$/i.test(part)) return "Pro+";
if (/^[a-z]{2,}$/.test(part)) return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
if (/^[a-z]{2,}$/.test(part))
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
return part;
})
.join(" ")
@@ -200,7 +201,9 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
if (dataRecord.quota_snapshots) {
// Paid plan format
const snapshots = toRecord(dataRecord.quota_snapshots);
const resetAt = parseResetTime(getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"));
const resetAt = parseResetTime(
getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate")
);
const premiumQuota = formatGitHubQuotaSnapshot(snapshots.premium_interactions, resetAt);
const chatQuota = formatGitHubQuotaSnapshot(snapshots.chat, resetAt);
const completionsQuota = formatGitHubQuotaSnapshot(snapshots.completions, resetAt);
@@ -225,7 +228,11 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
// Free/limited plan format
const monthlyQuotas = toRecord(dataRecord.monthly_quotas);
const usedQuotas = toRecord(dataRecord.limited_user_quotas);
const resetDate = getFieldValue(dataRecord, "limited_user_reset_date", "limitedUserResetDate");
const resetDate = getFieldValue(
dataRecord,
"limited_user_reset_date",
"limitedUserResetDate"
);
const resetAt = parseResetTime(resetDate);
const quotas: Record<string, UsageQuota> = {};
@@ -327,11 +334,7 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null):
toNumber(getFieldValue(monthlyQuotas, "premium_interactions", "premiumInteractions"), 0);
const chatTotal = toNumber(getFieldValue(monthlyQuotas, "chat", "chat"), 0);
if (
combined.includes("PRO+") ||
combined.includes("PRO_PLUS") ||
combined.includes("PROPLUS")
) {
if (combined.includes("PRO+") || combined.includes("PRO_PLUS") || combined.includes("PROPLUS")) {
return "Copilot Pro+";
}
if (combined.includes("ENTERPRISE")) return "Copilot Enterprise";
@@ -655,8 +658,18 @@ async function getClaudeUsage(accessToken) {
}
}
// Try to extract plan tier from the OAuth response
const planRaw =
typeof data.tier === "string"
? data.tier
: typeof data.plan === "string"
? data.plan
: typeof data.subscription_type === "string"
? data.subscription_type
: null;
return {
plan: "Claude Code",
plan: planRaw || "Claude Code",
quotas,
extraUsage: data.extra_usage ?? null,
};

View File

@@ -259,11 +259,19 @@ export function openaiToClaudeRequest(model, body, stream) {
toolNameMap.set(toolName, originalName);
}
// Normalize input_schema: Anthropic requires `properties` when type is "object" (#595).
// MCP tools (e.g. pencil, computer_use) may omit properties on object-type schemas.
const rawSchema: Record<string, unknown> =
toolData.parameters || toolData.input_schema || { type: "object", properties: {}, required: [] };
const normalizedSchema =
rawSchema.type === "object" && !rawSchema.properties
? { ...rawSchema, properties: {} }
: rawSchema;
return {
name: toolName,
description: toolData.description || "",
input_schema: toolData.parameters ||
toolData.input_schema || { type: "object", properties: {}, required: [] },
input_schema: normalizedSchema,
};
});

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.0.0",
"version": "3.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.0.0",
"version": "3.0.2",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.0.0",
"version": "3.0.2",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {

View File

@@ -802,6 +802,9 @@ export default function ProviderDetailPage() {
const userDismissed = useRef(false);
const [proxyTarget, setProxyTarget] = useState(null);
const [proxyConfig, setProxyConfig] = useState(null);
const [connProxyMap, setConnProxyMap] = useState<
Record<string, { proxy: any; level: string } | null>
>({});
const [importingModels, setImportingModels] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [importProgress, setImportProgress] = useState({
@@ -938,18 +941,48 @@ export default function ProviderDetailPage() {
useEffect(() => {
fetchConnections();
fetchAliases();
// Load proxy config for visual indicators
// Load proxy config for visual indicators (provider-level button)
fetch("/api/settings/proxy")
.then((r) => (r.ok ? r.json() : null))
.then((c) => setProxyConfig(c))
.catch(() => {});
}, [fetchConnections, fetchAliases]);
const loadConnProxies = useCallback(async (conns: { id?: string }[]) => {
if (!conns.length) return;
try {
const results = await Promise.all(
conns
.filter((c) => c.id)
.map((c) =>
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => [c.id!, data] as [string, any])
.catch(() => [c.id!, null] as [string, any])
)
);
const map: Record<string, { proxy: any; level: string } | null> = {};
for (const [id, data] of results) {
map[id] = data?.proxy ? data : null;
}
setConnProxyMap(map);
} catch {
// ignore
}
}, []);
useEffect(() => {
if (loading || isSearchProvider) return;
fetchProviderModelMeta();
}, [loading, isSearchProvider, fetchProviderModelMeta]);
// Load per-connection effective proxy (handles registry assignments)
useEffect(() => {
if (!loading && connections.length > 0) {
void loadConnProxies(connections);
}
}, [loading, connections, loadConnProxies]);
// Auto-open Add Connection modal when no connections exist (better UX)
// Only fires once on initial load, not on HMR remounts or after user dismissal
useEffect(() => {
@@ -1930,68 +1963,153 @@ export default function ProviderDetailPage() {
)}
</div>
) : (
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{connections
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === connections.length - 1}
onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
isCodex={providerId === "codex"}
onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)}
onToggleCodexWeekly={(enabled) =>
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
}
onRetest={() => handleRetestConnection(conn.id)}
isRetesting={retestingId === conn.id}
onEdit={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
isRefreshing={refreshingId === conn.id}
onProxy={() =>
setProxyTarget({
level: "key",
id: conn.id,
label: conn.name || conn.email || conn.id,
})
}
hasProxy={
!!(
proxyConfig?.keys?.[conn.id] ||
proxyConfig?.providers?.[providerId] ||
proxyConfig?.global
)
}
proxySource={
proxyConfig?.keys?.[conn.id]
? "key"
: proxyConfig?.providers?.[providerId]
? "provider"
: proxyConfig?.global
? "global"
: null
}
proxyHost={
(
proxyConfig?.keys?.[conn.id] ||
proxyConfig?.providers?.[providerId] ||
proxyConfig?.global
)?.host || null
}
/>
))}
</div>
(() => {
// Group connections by tag (providerSpecificData.tag)
const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0));
const hasAnyTag = sorted.some((c) => c.providerSpecificData?.tag as string | undefined);
if (!hasAnyTag) {
// No tags — render flat list as before
return (
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{sorted.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === sorted.length - 1}
onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
isCodex={providerId === "codex"}
onToggleCodex5h={(enabled) =>
handleToggleCodexLimit(conn.id, "use5h", enabled)
}
onToggleCodexWeekly={(enabled) =>
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
}
onRetest={() => handleRetestConnection(conn.id)}
isRetesting={retestingId === conn.id}
onEdit={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
isRefreshing={refreshingId === conn.id}
onProxy={() =>
setProxyTarget({
level: "key",
id: conn.id,
label: conn.name || conn.email || conn.id,
})
}
hasProxy={!!connProxyMap[conn.id]?.proxy}
proxySource={connProxyMap[conn.id]?.level || null}
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
/>
))}
</div>
);
}
// Build ordered tag groups: untagged first, then alphabetically
const groupMap = new Map<string, typeof sorted>();
for (const conn of sorted) {
const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || "";
if (!groupMap.has(tag)) groupMap.set(tag, []);
groupMap.get(tag)!.push(conn);
}
const groupKeys = Array.from(groupMap.keys()).sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
return a.localeCompare(b);
});
return (
<div className="flex flex-col gap-0">
{groupKeys.map((tag, gi) => {
const groupConns = groupMap.get(tag)!;
return (
<div
key={tag || "__untagged__"}
className={
gi > 0
? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1"
: ""
}
>
{tag && (
<div className="flex items-center gap-2 px-3 pt-2 pb-1">
<span className="material-symbols-outlined text-[13px] text-text-muted/50">
label
</span>
<span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted/60 select-none">
{tag}
</span>
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
<span className="text-[10px] text-text-muted/40">
{groupConns.length}
</span>
</div>
)}
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{groupConns.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={gi === 0 && index === 0}
isLast={gi === groupKeys.length - 1 && index === groupConns.length - 1}
onMoveUp={() =>
handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1])
}
onMoveDown={() =>
handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1])
}
onToggleActive={(isActive) =>
handleUpdateConnectionStatus(conn.id, isActive)
}
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
isCodex={providerId === "codex"}
onToggleCodex5h={(enabled) =>
handleToggleCodexLimit(conn.id, "use5h", enabled)
}
onToggleCodexWeekly={(enabled) =>
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
}
onRetest={() => handleRetestConnection(conn.id)}
isRetesting={retestingId === conn.id}
onEdit={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
isRefreshing={refreshingId === conn.id}
onProxy={() =>
setProxyTarget({
level: "key",
id: conn.id,
label: conn.name || conn.email || conn.id,
})
}
hasProxy={!!connProxyMap[conn.id]?.proxy}
proxySource={connProxyMap[conn.id]?.level || null}
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
/>
))}
</div>
</div>
);
})}
</div>
);
})()
)}
</Card>
@@ -2188,6 +2306,7 @@ export default function ProviderDetailPage() {
level={proxyTarget.level}
levelId={proxyTarget.id}
levelLabel={proxyTarget.label}
onSaved={() => void loadConnProxies(connections)}
/>
)}
{/* Import Progress Modal */}
@@ -4130,6 +4249,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
baseUrl: "",
region: "",
validationModelId: "",
tag: "",
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
@@ -4159,6 +4279,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
baseUrl: existingBaseUrl || (isBailian ? defaultBailianUrl : ""),
region: existingRegion || (isVertex ? defaultRegion : ""),
validationModelId: (connection.providerSpecificData?.validationModelId as string) || "",
tag: (connection.providerSpecificData?.tag as string) || "",
});
// Load existing extra keys from providerSpecificData
const existing = connection.providerSpecificData?.extraApiKeys;
@@ -4282,6 +4403,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
updates.providerSpecificData = {
...(connection.providerSpecificData || {}),
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
tag: formData.tag.trim() || undefined,
};
if (formData.validationModelId) {
updates.providerSpecificData.validationModelId = formData.validationModelId;
@@ -4292,6 +4414,12 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
} else if (isVertex) {
updates.providerSpecificData.region = formData.region;
}
} else {
// Also persist tag for OAuth accounts
updates.providerSpecificData = {
...(connection.providerSpecificData || {}),
tag: formData.tag.trim() || undefined,
};
}
const error = (await onSave(updates)) as void | unknown;
if (error) {
@@ -4322,6 +4450,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={isOAuth ? t("accountName") : t("productionKey")}
/>
<Input
label="Tag / Group"
value={formData.tag}
onChange={(e) => setFormData({ ...formData, tag: e.target.value })}
placeholder="e.g. personal, work, team-a"
hint="Used to group accounts in the provider view"
/>
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">{t("email")}</p>

View File

@@ -27,6 +27,14 @@ type HealthInfo = {
lastSeenAt: string | null;
};
type TestResult = {
success: boolean;
publicIp?: string;
latencyMs?: number;
country?: string;
error?: string;
};
const EMPTY_FORM = {
id: "",
name: "",
@@ -51,6 +59,8 @@ export default function ProxyRegistryManager() {
const [usageById, setUsageById] = useState<Record<string, UsageInfo>>({});
const [healthById, setHealthById] = useState<Record<string, HealthInfo>>({});
const [testById, setTestById] = useState<Record<string, TestResult | null>>({});
const [testingId, setTestingId] = useState<string | null>(null);
const [migrating, setMigrating] = useState(false);
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkSaving, setBulkSaving] = useState(false);
@@ -75,6 +85,36 @@ export default function ProxyRegistryManager() {
}
}, []);
const loadAllUsage = useCallback(async (proxyIds: string[]) => {
if (!proxyIds.length) return;
try {
const results = await Promise.all(
proxyIds.map((id) =>
fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
const rawAssignments: Array<{ scope: string; scopeId: string | null }> =
Array.isArray(data?.items) ? data.items : [];
// Deduplicate by scope+scopeId — prevents double-counting when both
// a provider-scope and account-scope row exist for the same proxy
const seen = new Set<string>();
const assignments = rawAssignments.filter((a) => {
const key = `${a.scope}:${a.scopeId ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return [id, { count: assignments.length, assignments }] as [string, UsageInfo];
})
.catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo])
)
);
setUsageById(Object.fromEntries(results));
} catch {
// ignore
}
}, []);
const load = useCallback(async () => {
setLoading(true);
setError(null);
@@ -86,15 +126,18 @@ export default function ProxyRegistryManager() {
setItems([]);
return;
}
setItems(Array.isArray(data?.items) ? data.items : []);
const loaded: ProxyItem[] = Array.isArray(data?.items) ? data.items : [];
setItems(loaded);
const ids = loaded.map((p) => p.id).filter(Boolean);
void loadHealth();
void loadAllUsage(ids);
} catch (e: any) {
setError(e?.message || "Failed to load proxy registry");
setItems([]);
} finally {
setLoading(false);
}
}, [loadHealth]);
}, [loadHealth, loadAllUsage]);
useEffect(() => {
void load();
@@ -130,22 +173,63 @@ export default function ProxyRegistryManager() {
const loadUsage = async (proxyId: string) => {
try {
const res = await fetch(
`/api/settings/proxies?id=${encodeURIComponent(proxyId)}&whereUsed=1`
`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}`
);
const data = await res.json().catch(() => ({}));
if (!res.ok) return;
const rawAssignments: Array<{ scope: string; scopeId: string | null }> = Array.isArray(
data?.items
)
? data.items
: [];
const seen = new Set<string>();
const assignments = rawAssignments.filter((a) => {
const key = `${a.scope}:${a.scopeId ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
setUsageById((prev) => ({
...prev,
[proxyId]: {
count: Number(data?.count || 0),
assignments: Array.isArray(data?.assignments) ? data.assignments : [],
},
[proxyId]: { count: assignments.length, assignments },
}));
} catch {
// ignore usage loading errors in UI
}
};
const handleTestProxy = async (item: ProxyItem) => {
if (testingId) return;
setTestingId(item.id);
setTestById((prev) => ({ ...prev, [item.id]: null }));
try {
const res = await fetch("/api/settings/proxy/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proxy: {
type: item.type || "http",
host: item.host,
port: String(item.port || 8080),
},
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setTestById((prev) => ({
...prev,
[item.id]: { success: false, error: data?.error?.message || "Test failed" },
}));
return;
}
setTestById((prev) => ({ ...prev, [item.id]: { success: true, ...data } }));
} catch (e: any) {
setTestById((prev) => ({ ...prev, [item.id]: { success: false, error: e?.message } }));
} finally {
setTestingId(null);
}
};
const handleSave = async () => {
if (!form.name.trim() || !form.host.trim()) {
setError("Name and host are required");
@@ -378,27 +462,47 @@ export default function ProxyRegistryManager() {
</span>
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
{health ? (
<div className="flex flex-col gap-0.5">
<span>{health.successRate ?? 0}% success</span>
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
</div>
) : (
"-"
)}
<div className="flex flex-col gap-0.5">
{health ? (
<>
<span>{health.successRate ?? 0}% success</span>
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
</>
) : testById[item.id] ? (
testById[item.id]!.success ? (
<>
<span className="text-emerald-400">
{testById[item.id]!.publicIp}
</span>
{testById[item.id]!.latencyMs && (
<span>{testById[item.id]!.latencyMs}ms</span>
)}
</>
) : (
<span className="text-red-400">
{testById[item.id]!.error || "failed"}
</span>
)
) : (
<span></span>
)}
</div>
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
{usage ? `${usage.count} assignment(s)` : "-"}
{usageById[item.id] != null
? `${usageById[item.id].count} assignment(s)`
: "—"}
</td>
<td className="py-2">
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
icon="visibility"
onClick={() => void loadUsage(item.id)}
icon="speed"
onClick={() => void handleTestProxy(item)}
loading={testingId === item.id}
>
Usage
Test
</Button>
<Button
size="sm"

View File

@@ -412,24 +412,25 @@ export default function ProviderLimits() {
<div className="flex items-center gap-2">
{/* Group by toggle */}
<div className="flex rounded-lg border border-white/[0.08] overflow-hidden">
<div className="flex rounded-lg border border-border overflow-hidden">
<button
onClick={() => handleSetGroupBy("none")}
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none"
style={{
background: groupBy === "none" ? "rgba(255,255,255,0.1)" : "transparent",
color: groupBy === "none" ? "var(--text-main)" : "var(--text-muted)",
background: groupBy === "none" ? "var(--color-bg-subtle)" : "transparent",
color: groupBy === "none" ? "var(--color-text-main)" : "var(--color-text-muted)",
}}
>
{t("viewFlat")}
</button>
<button
onClick={() => handleSetGroupBy("environment")}
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none border-l border-white/[0.08]"
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none"
style={{
background: groupBy === "environment" ? "rgba(255,255,255,0.1)" : "transparent",
color: groupBy === "environment" ? "var(--text-main)" : "var(--text-muted)",
borderLeft: "1px solid rgba(255,255,255,0.08)",
background: groupBy === "environment" ? "var(--color-bg-subtle)" : "transparent",
color:
groupBy === "environment" ? "var(--color-text-main)" : "var(--color-text-muted)",
borderLeft: "1px solid var(--color-border)",
}}
>
{t("viewByEnvironment")}
@@ -442,7 +443,7 @@ export default function ProviderLimits() {
setAutoRefresh(next);
localStorage.setItem(LS_AUTO_REFRESH, String(next));
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-transparent cursor-pointer text-text-main text-[13px]"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-transparent cursor-pointer text-text-main text-[13px]"
>
<span
className="material-symbols-outlined text-[18px]"
@@ -459,7 +460,7 @@ export default function ProviderLimits() {
<button
onClick={refreshAll}
disabled={refreshingAll}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-white/[0.06] border border-white/10 text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<span
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
@@ -483,10 +484,10 @@ export default function ProviderLimits() {
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer"
style={{
border: active
? "1px solid var(--primary, #E54D5E)"
: "1px solid rgba(255,255,255,0.12)",
background: active ? "rgba(249,120,21,0.14)" : "transparent",
color: active ? "var(--primary, #E54D5E)" : "var(--text-muted)",
? "1px solid var(--color-primary, #E54D5E)"
: "1px solid var(--color-border)",
background: active ? "rgba(229,77,94,0.1)" : "transparent",
color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)",
}}
>
<span>{t(tier.labelKey)}</span>
@@ -497,10 +498,10 @@ export default function ProviderLimits() {
</div>
{/* Account rows */}
<div className="rounded-xl border border-white/[0.06] overflow-hidden bg-black/15">
<div className="rounded-xl border border-border overflow-hidden bg-bg-subtle">
{/* Table header */}
<div
className="items-center px-4 py-2.5 border-b border-white/[0.06] text-[11px] font-semibold uppercase tracking-wider text-text-muted"
className="items-center px-4 py-2.5 border-b border-border text-[11px] font-semibold uppercase tracking-wider text-text-muted"
style={{ display: "grid", gridTemplateColumns: "280px 1fr 100px 48px" }}
>
<div>{t("account")}</div>
@@ -523,11 +524,11 @@ export default function ProviderLimits() {
return (
<div
key={conn.id}
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-black/[0.03] dark:hover:bg-white/[0.02]"
style={{
display: "grid",
gridTemplateColumns: "280px 1fr 100px 48px",
borderBottom: !isLast ? "1px solid rgba(255,255,255,0.04)" : "none",
borderBottom: !isLast ? "1px solid var(--color-border)" : "none",
}}
>
{/* Account Info */}
@@ -614,7 +615,7 @@ export default function ProviderLimits() {
) : null}
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
<div className="flex-1 h-1.5 rounded-sm bg-black/[0.06] dark:bg-white/[0.06] min-w-[60px] overflow-hidden">
<div
className="h-full rounded-sm transition-[width] duration-300 ease-out"
style={{
@@ -672,13 +673,10 @@ export default function ProviderLimits() {
if (groupedConnections) {
const entries = [...groupedConnections.entries()];
return entries.map(([groupName, conns]) => (
<div
key={groupName}
className="border border-white/[0.08] rounded-lg overflow-hidden mb-2"
>
<div key={groupName} className="border border-border rounded-lg overflow-hidden mb-2">
<button
onClick={() => toggleGroup(groupName)}
className="w-full flex items-center gap-2 px-4 py-2.5 bg-white/[0.03] hover:bg-white/[0.05] transition-colors text-left border-none cursor-pointer"
className="w-full flex items-center gap-2 px-4 py-2.5 bg-bg-subtle hover:bg-black/[0.04] dark:hover:bg-white/[0.05] transition-colors text-left border-none cursor-pointer"
>
<span className="material-symbols-outlined text-[16px] text-text-muted">
{expandedGroups.has(groupName) ? "expand_less" : "expand_more"}
@@ -689,7 +687,7 @@ export default function ProviderLimits() {
<span className="text-[12px] font-semibold text-text-main uppercase tracking-wider flex-1">
{groupName}
</span>
<span className="text-[11px] text-text-muted bg-white/[0.06] px-2 py-0.5 rounded-full">
<span className="text-[11px] text-text-muted bg-black/[0.04] dark:bg-white/[0.06] px-2 py-0.5 rounded-full">
{conns.length}
</span>
</button>

View File

@@ -222,6 +222,11 @@ export function normalizePlanTier(plan) {
const upper = raw.toUpperCase();
// Provider names that are not real plan tiers — treat as unknown
if (upper === "CLAUDE CODE" || upper === "KIMI CODING" || upper === "KIRO") {
return { key: "unknown", label: raw, variant: "default", rank: 0, raw };
}
if (upper.includes("PRO+") || upper.includes("PRO PLUS") || upper.includes("PROPLUS")) {
return { key: "plus", label: "Pro+", variant: "secondary", rank: 4, raw };
}

View File

@@ -652,6 +652,27 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
elevenlabs: validateElevenLabsProvider,
inworld: validateInworldProvider,
"bailian-coding-plan": validateBailianCodingPlanProvider,
// LongCat AI — does not expose /v1/models; validate via chat completions directly (#592)
longcat: async ({ apiKey }: any) => {
try {
const res = await fetch("https://longcat.chat/api/v1/chat/completions", {
method: "POST",
headers: buildBearerHeaders(apiKey),
body: JSON.stringify({
model: "longcat",
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
});
if (res.status === 401 || res.status === 403) {
return { valid: false, error: "Invalid API key" };
}
// Any non-auth response (200, 400, 422) means auth passed
return { valid: true, error: null };
} catch (error: any) {
return { valid: false, error: error.message || "Connection failed" };
}
},
// Search providers — use factored validator
...Object.fromEntries(
Object.entries(SEARCH_VALIDATOR_CONFIGS).map(([id, configFn]) => [

View File

@@ -245,6 +245,7 @@ export default function ProxyConfigModal({
setSelectedProxyId("");
}
onSaved?.();
onClose();
} catch (error) {
console.error("Error saving proxy:", error);
setFormError(error.message || "Failed to save proxy configuration");
@@ -281,6 +282,7 @@ export default function ProxyConfigModal({
setSelectedProxyId("");
setTestResult(null);
onSaved?.();
onClose();
} catch (error) {
console.error("Error clearing proxy:", error);
setFormError(error.message || "Failed to clear proxy configuration");
@@ -290,22 +292,49 @@ export default function ProxyConfigModal({
};
const handleTest = async () => {
if (mode === "saved") {
setFormError("Use custom mode to run manual connection test.");
return;
}
if (!host.trim()) return;
setFormError(null);
setTesting(true);
setTestResult(null);
try {
const proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
let proxy: {
type: string;
host: string;
port: string;
username?: string;
password?: string;
} | null = null;
if (mode === "saved") {
if (!selectedProxyId) {
setFormError("Select a saved proxy first.");
setTesting(false);
return;
}
const found = (savedProxies as any[]).find((p: any) => p.id === selectedProxyId);
if (!found) {
setFormError("Selected proxy not found.");
setTesting(false);
return;
}
proxy = {
type: found.type || "http",
host: found.host || "",
port: String(found.port || 8080),
};
} else {
if (!host.trim()) {
setTesting(false);
return;
}
proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
}
const res = await fetch("/api/settings/proxy/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -550,7 +579,7 @@ export default function ProxyConfigModal({
icon="speed"
onClick={handleTest}
loading={testing}
disabled={mode !== "custom" || !host.trim()}
disabled={mode === "saved" ? !selectedProxyId : !host.trim()}
>
Test Connection
</Button>