mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-28 19:02:49 +03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e7e04839f | ||
|
|
f62dcc12a0 | ||
|
|
bef591c2e6 | ||
|
|
5907296d36 | ||
|
|
aa2a7d12be | ||
|
|
33fee5dcc5 | ||
|
|
e9ae50be0c | ||
|
|
5886c0fd5e |
216
CHANGELOG.md
216
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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), подсчёт, булева логика
|
||||
|
||||
### Стратегии оценки
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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": [
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]) => [
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user