mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-27 18:32:16 +03:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
948513ef5f | ||
|
|
c497a35d21 | ||
|
|
e0a539bc64 | ||
|
|
44b8395ead | ||
|
|
1bc8878490 | ||
|
|
ded2ac493d | ||
|
|
57b3319ac0 | ||
|
|
eba7ba25b8 | ||
|
|
df774892c8 | ||
|
|
f3b4ce6b67 | ||
|
|
bb8545b3e1 | ||
|
|
600149fc2b | ||
|
|
f4de3c8748 | ||
|
|
6e7e04839f | ||
|
|
f62dcc12a0 | ||
|
|
bef591c2e6 | ||
|
|
5907296d36 | ||
|
|
aa2a7d12be | ||
|
|
33fee5dcc5 | ||
|
|
e9ae50be0c | ||
|
|
5886c0fd5e | ||
|
|
35538e6f77 | ||
|
|
ea924f3bbf | ||
|
|
7bc15a2fc9 | ||
|
|
2bf7db92ee | ||
|
|
95260f56ba | ||
|
|
c5ace0376a | ||
|
|
7ee09388fa | ||
|
|
a15b0ef060 | ||
|
|
57cfd9a315 | ||
|
|
5fb4149c32 | ||
|
|
03d97ba617 | ||
|
|
5205f5f4b4 | ||
|
|
6eda0f4d00 |
39
.agents/workflows/deploy-vps-akamai.md
Normal file
39
.agents/workflows/deploy-vps-akamai.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35)
|
||||
---
|
||||
|
||||
# Deploy to Akamai VPS Workflow
|
||||
|
||||
Deploy OmniRoute to the Akamai VPS using `npm pack + scp` + PM2.
|
||||
|
||||
**Akamai VPS:** `69.164.221.35`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/9router && npm run build:cli && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to Akamai VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
|
||||
```
|
||||
49
.agents/workflows/deploy-vps-both.md
Normal file
49
.agents/workflows/deploy-vps-both.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: Deploy the latest OmniRoute code to BOTH the Akamai VPS and the Local VPS
|
||||
---
|
||||
|
||||
# Deploy to VPS (Both) Workflow
|
||||
|
||||
Deploy OmniRoute to the production VPSs using `npm pack + scp` + PM2.
|
||||
|
||||
**Akamai VPS:** `69.164.221.35`
|
||||
**Local VPS:** `192.168.0.15`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
**PM2 entry:** `/usr/lib/node_modules/omniroute/app/server.js`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The npm registry rejects packages > 100MB, so deployment uses **npm pack + scp**.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/9router && npm run build:cli && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to both VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/ && scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
|
||||
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
|
||||
```
|
||||
39
.agents/workflows/deploy-vps-local.md
Normal file
39
.agents/workflows/deploy-vps-local.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: Deploy the latest OmniRoute code to the Local VPS (192.168.0.15)
|
||||
---
|
||||
|
||||
# Deploy to Local VPS Workflow
|
||||
|
||||
Deploy OmniRoute to the Local VPS using `npm pack + scp` + PM2.
|
||||
|
||||
**Local VPS:** `192.168.0.15`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/9router && npm run build:cli && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to Local VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
|
||||
```
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35) via npm
|
||||
---
|
||||
|
||||
# Deploy to VPS Workflow
|
||||
|
||||
Deploy OmniRoute to the production VPS using `npm pack + scp` + PM2.
|
||||
|
||||
**VPS:** `69.164.221.35` (Akamai, Ubuntu 24.04, 1GB RAM + 2.5GB swap)
|
||||
**Local VPS:** `192.168.0.15` (same setup)
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
**PM2 entry:** `/usr/lib/node_modules/omniroute/app/server.js`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> PM2 runs from the global npm package at `/usr/lib/node_modules/omniroute`.
|
||||
> The Next.js standalone build is at `app/server.js` inside that directory.
|
||||
> The npm registry rejects packages > 100MB, so deployment uses **npm pack + scp**.
|
||||
|
||||
> [!CAUTION]
|
||||
> **NEVER** use `pm2 restart omniroute` after `npm install -g`. This drops env vars.
|
||||
> Always use `pm2 delete omniroute && pm2 start <ecosystem.config.cjs> --update-env`.
|
||||
> After `npm install -g`, always rebuild better-sqlite3: `cd .../app && npm rebuild better-sqlite3`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
Run the full build (includes hash-strip patch) and create the .tgz:
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/9router && npm run build:cli && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to both VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/ && scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "pm2 list && cat \$(npm root -g)/omniroute/app/package.json | grep version | head -1 && curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:20128/"
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "pm2 list && cat \$(npm root -g)/omniroute/app/package.json | grep version | head -1 && curl -s -X POST http://localhost:20128/api/auth/login -H 'Content-Type: application/json' -d '{\"password\":\"123456\"}'"
|
||||
```
|
||||
|
||||
Expected: PM2 shows `online`, version matches, login returns `{"success":true}`.
|
||||
|
||||
## How it works
|
||||
|
||||
1. `npm run build:cli` builds Next.js standalone → `app/` and strips Turbopack hashed require() calls from chunks
|
||||
2. `npm pack --ignore-scripts` packages without re-running the build
|
||||
3. `scp` transfers the .tgz to each VPS (~286MB)
|
||||
4. `npm install -g /tmp/omniroute-*.tgz --ignore-scripts` installs pre-built package
|
||||
5. `npm rebuild better-sqlite3` recompiles native bindings for the VPS Node.js version
|
||||
6. `pm2 delete` + `pm2 start ecosystem.config.cjs --update-env` restarts with env vars
|
||||
7. `pm2 save` persists the process list for reboot survival
|
||||
|
||||
## Ecosystem Config
|
||||
|
||||
Both VPSs have `ecosystem.config.cjs` at `/root/.omniroute/ecosystem.config.cjs`.
|
||||
This file defines env vars (PORT, DATA_DIR, INITIAL_PASSWORD, OAuth secrets, etc.)
|
||||
that `pm2 restart` does NOT inject — only `pm2 start --update-env` does.
|
||||
|
||||
## PM2 Setup (one-time — if reconfiguring from scratch)
|
||||
|
||||
```bash
|
||||
ssh root@<VPS> "
|
||||
pm2 delete omniroute 2>/dev/null;
|
||||
cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 &&
|
||||
pm2 start /root/.omniroute/ecosystem.config.cjs --update-env &&
|
||||
pm2 save && pm2 startup
|
||||
"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Ensure `/root/.omniroute/ecosystem.config.cjs` exists with all required env vars.
|
||||
> For fresh installs, copy from the existing VPS or create from the template in `.env`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Env vars are in `/root/.omniroute/ecosystem.config.cjs` (NOT `.env` in app dir)
|
||||
- PM2 is configured with `pm2 startup` to auto-restart on reboot
|
||||
- Nginx proxies `omniroute.online` → `localhost:20128`
|
||||
- The VPS has only 1GB RAM — builds happen locally, never on the VPS
|
||||
- After `npm install -g`, `better-sqlite3` MUST be rebuilt in the `app/` subdir
|
||||
244
CHANGELOG.md
244
CHANGELOG.md
@@ -4,6 +4,107 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.0.4] — 2026-03-25
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Streaming:** Fixed `TextDecoder` state corruption inside combo `sanitize` TransformStream which caused SSE garbled output matching multibyte characters (PR #614)
|
||||
- **Providers UI:** Safely render HTML tags inside provider connection error tooltips using `dangerouslySetInnerHTML`
|
||||
- **Proxy Settings:** Added missing `username` and `password` payload body properties allowing authenticated proxies to be successfully verified from the Dashboard.
|
||||
- **Provider API:** Bound soft exception returns to `getCodexUsage` preventing API HTTP 500 failures when token fetch fails
|
||||
|
||||
---
|
||||
|
||||
## [3.0.3] — 2026-03-25
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Auto-Sync Models:** Added a UI toggle and `sync-models` endpoint to automatically synchronise model lists per provider using a scheduled interval scheduler (PR #597)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Timeouts:** Elevated default proxies `FETCH_TIMEOUT_MS` and `STREAM_IDLE_TIMEOUT_MS` to 10 minutes to properly support deep reasoning models (like o1) without aborting requests (Fixes #609)
|
||||
- **CLI Tool Detection:** Improved cross-platform detection handling NVM paths, Windows `PATHEXT` (preventing `.cmd` wrappers issue), and custom NPM prefixes (PR #598)
|
||||
- **Streaming Logs:** Implemented `tool_calls` delta accumulation in streaming response logs so function calls are tracked and persisted accurately in DB (PR #603)
|
||||
- **Model Catalog:** Removed auth exemption, properly hiding `comfyui` and `sdwebui` models when no provider is explicitly configured (PR #599)
|
||||
|
||||
### 🌐 Translations
|
||||
|
||||
- **cs:** Improved Czech translation strings across the app (PR #601)
|
||||
|
||||
## [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 +117,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 +161,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 +184,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 +224,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 +241,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 +251,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 +321,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 +362,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 +417,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
|
||||
|
||||
@@ -32,12 +32,12 @@ _Your universal API proxy — one endpoint, 67+ providers, zero downtime. Now wi
|
||||
|
||||
| Area | Change |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🔒 **CodeQL Security** | Fixed 10+ CodeQL alerts: polynomial-redos, insecure-randomness, shell-injection remediation |
|
||||
| ✅ **Route Validation** | All 176 API routes now validated with Zod schemas + `validateBody()` — CI `check:route-validation:t06` passes |
|
||||
| 🐛 **omniModel Tag Leak** | Internal `<omniModel>` tags no longer leak to clients in SSE streaming responses (#585) |
|
||||
| 🔒 **CodeQL Security** | Fixed 10+ CodeQL alerts: polynomial-redos, insecure-randomness, shell-injection remediation |
|
||||
| ✅ **Route Validation** | All 176 API routes now validated with Zod schemas + `validateBody()` — CI `check:route-validation:t06` passes |
|
||||
| 🐛 **omniModel Tag Leak** | Internal `<omniModel>` tags no longer leak to clients in SSE streaming responses (#585) |
|
||||
| 🔑 **Registered Keys API** | Auto-provision API keys via `POST /api/v1/registered-keys` with per-provider/account quota enforcement, idempotency, SHA-256 storage, and optional GitHub issue reporting |
|
||||
| 🎨 **Provider Icons** | 130+ provider logos via `@lobehub/icons` (SVG) with PNG → generic fallback chain |
|
||||
| 🔄 **Model Auto-Sync** | 24h scheduler refreshes model lists for 16 providers on startup — configurable via `MODEL_SYNC_INTERVAL_HOURS` |
|
||||
| 🔄 **Model Auto-Sync** | 24h scheduler and manual UI toggle to sync model lists for built-in and custom OpenAI-compatible providers |
|
||||
| 🌐 **OpenCode Zen/Go** | Two new providers from @kang-heewon via PR #530: free tier + subscription tier via `OpencodeExecutor` |
|
||||
| 🐛 **Gemini CLI OAuth** | Actionable error when `GEMINI_OAUTH_CLIENT_SECRET` is missing in Docker (was cryptic Google error) |
|
||||
| 🐛 **OpenCode config** | `saveOpenCodeConfig()` now correctly writes TOML to `XDG_CONFIG_HOME` |
|
||||
|
||||
@@ -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.4
|
||||
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,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { loadProviderCredentials } from "./credentialLoader.ts";
|
||||
|
||||
// Timeout for non-streaming fetch requests (ms). Prevents stalled connections.
|
||||
export const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "120000", 10);
|
||||
export const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "600000", 10);
|
||||
|
||||
// Idle timeout for SSE streams (ms). Closes stream if no data for this duration.
|
||||
// Default: 120s balances deep-reasoning pauses with fast zombie stream detection (#473).
|
||||
// Extended-thinking models rarely pause >90s between chunks. Override with STREAM_IDLE_TIMEOUT_MS env var.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "120000", 10);
|
||||
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "600000", 10);
|
||||
|
||||
// Provider configurations
|
||||
// OAuth credentials read from env vars with hardcoded fallbacks for backward compatibility.
|
||||
|
||||
@@ -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 || {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -526,18 +526,32 @@ export async function handleComboChat({
|
||||
// visible content so they don't leak to the user. The tag is still
|
||||
// present in the full response for round-trip context pinning, but
|
||||
// we clean it from each SSE chunk's content field before delivery.
|
||||
//
|
||||
// IMPORTANT: Use a SEPARATE TextDecoder from the transform stream above.
|
||||
// The transform stream's decoder accumulates UTF-8 state; reusing it here
|
||||
// would corrupt multi-byte characters split across chunk boundaries.
|
||||
const sanitizeDecoder = new TextDecoder();
|
||||
const sanitize = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
const text = decoder.decode(chunk, { stream: true });
|
||||
// Only run replacement if the chunk actually contains the tag
|
||||
if (text.includes("<omniModel>")) {
|
||||
const cleaned = text.replace(
|
||||
/(?:\\\\n|\\n)?<omniModel>[^<]+<\/omniModel>(?:\\\\n|\\n)?/g,
|
||||
""
|
||||
);
|
||||
controller.enqueue(encoder.encode(cleaned));
|
||||
} else {
|
||||
controller.enqueue(chunk);
|
||||
const text = sanitizeDecoder.decode(chunk, { stream: true });
|
||||
if (text) {
|
||||
if (text.includes("<omniModel>")) {
|
||||
const cleaned = text.replace(/\n?<omniModel>[^<]+<\/omniModel>\n?/g, "");
|
||||
if (cleaned) controller.enqueue(encoder.encode(cleaned));
|
||||
} else {
|
||||
controller.enqueue(encoder.encode(text));
|
||||
}
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
const tail = sanitizeDecoder.decode();
|
||||
if (tail) {
|
||||
if (tail.includes("<omniModel>")) {
|
||||
const cleaned = tail.replace(/\n?<omniModel>[^<]+<\/omniModel>\n?/g, "");
|
||||
if (cleaned) controller.enqueue(encoder.encode(cleaned));
|
||||
} else {
|
||||
controller.enqueue(encoder.encode(tail));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ const DEFAULT_COMBO_CONFIG = {
|
||||
strategy: "priority",
|
||||
maxRetries: 1,
|
||||
retryDelayMs: 2000,
|
||||
timeoutMs: 120000,
|
||||
timeoutMs: 600000,
|
||||
concurrencyPerModel: 3, // max simultaneous requests per model (round-robin)
|
||||
queueTimeoutMs: 30000, // max wait time in semaphore queue (round-robin)
|
||||
healthCheckEnabled: true,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -843,7 +856,7 @@ async function getCodexUsage(accessToken, providerSpecificData: Record<string, u
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch Codex usage: ${error.message}`);
|
||||
return { message: `Failed to fetch Codex usage: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ type TranslateState = ReturnType<typeof initState> & {
|
||||
accumulatedContent?: string;
|
||||
};
|
||||
|
||||
type ToolCall = {
|
||||
id: string | null;
|
||||
index: number;
|
||||
type: string;
|
||||
function: { name: string; arguments: string };
|
||||
};
|
||||
|
||||
type UsageTokenRecord = Record<string, number>;
|
||||
|
||||
function getOpenAIIntermediateChunks(value: unknown): unknown[] {
|
||||
@@ -113,6 +120,9 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
let usage: UsageTokenRecord | null = null;
|
||||
/** Passthrough (OpenAI CC shape): saw tool_calls in stream before finish_reason */
|
||||
let passthroughHasToolCalls = false;
|
||||
/** Passthrough: accumulate tool_calls deltas for call log responseBody */
|
||||
const passthroughToolCalls = new Map<string, ToolCall>();
|
||||
let passthroughToolCallSeq = 0;
|
||||
|
||||
// State for translate mode (accumulatedContent for call log response body)
|
||||
const state: TranslateState | null =
|
||||
@@ -268,9 +278,39 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// T18: Track if we saw tool calls
|
||||
// T18: Track if we saw tool calls & accumulate for call log
|
||||
if (delta?.tool_calls && delta.tool_calls.length > 0) {
|
||||
passthroughHasToolCalls = true;
|
||||
for (const tc of delta.tool_calls) {
|
||||
// Key by index first — id only appears on the first delta in OpenAI streaming
|
||||
let key: string;
|
||||
if (Number.isInteger(tc?.index)) {
|
||||
key = `idx:${tc.index}`;
|
||||
} else if (tc?.id) {
|
||||
key = `id:${tc.id}`;
|
||||
} else {
|
||||
key = `seq:${++passthroughToolCallSeq}`;
|
||||
}
|
||||
const existing = passthroughToolCalls.get(key);
|
||||
const deltaArgs =
|
||||
typeof tc?.function?.arguments === "string" ? tc.function.arguments : "";
|
||||
if (!existing) {
|
||||
passthroughToolCalls.set(key, {
|
||||
id: tc?.id ?? null,
|
||||
index: Number.isInteger(tc?.index) ? tc.index : passthroughToolCalls.size,
|
||||
type: tc?.type || "function",
|
||||
function: {
|
||||
name: tc?.function?.name || "",
|
||||
arguments: deltaArgs,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (tc?.id) existing.id = existing.id || tc.id;
|
||||
if (tc?.function?.name && !existing.function.name)
|
||||
existing.function.name = tc.function.name;
|
||||
existing.function.arguments += deltaArgs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = delta?.content || delta?.reasoning_content;
|
||||
@@ -516,13 +556,20 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0);
|
||||
const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0);
|
||||
const content = passthroughAccumulatedContent.trim() || "";
|
||||
const message: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: content || null,
|
||||
};
|
||||
if (passthroughToolCalls.size > 0) {
|
||||
message.tool_calls = [...passthroughToolCalls.values()].sort(
|
||||
(a, b) => a.index - b.index
|
||||
);
|
||||
}
|
||||
const responseBody = {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content,
|
||||
},
|
||||
message,
|
||||
finish_reason: passthroughHasToolCalls ? "tool_calls" : "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
@@ -643,13 +690,32 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0);
|
||||
const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0);
|
||||
const content = (state?.accumulatedContent ?? "").trim() || "";
|
||||
const message: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: content || null,
|
||||
};
|
||||
const hasToolCalls = state?.toolCalls?.size > 0;
|
||||
if (hasToolCalls) {
|
||||
// Normalize shape — translators may store different structures
|
||||
message.tool_calls = [...state.toolCalls.values()]
|
||||
.map(
|
||||
(tc: Record<string, unknown>): ToolCall => ({
|
||||
id: (tc.id as string) ?? null,
|
||||
index: (tc.index as number) ?? (tc.blockIndex as number) ?? 0,
|
||||
type: (tc.type as string) ?? "function",
|
||||
function: (tc.function as ToolCall["function"]) ?? {
|
||||
name: (tc.name as string) ?? "",
|
||||
arguments: "",
|
||||
},
|
||||
})
|
||||
)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
const responseBody = {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content,
|
||||
},
|
||||
message,
|
||||
finish_reason: hasToolCalls ? "tool_calls" : "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.4",
|
||||
"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(() => {
|
||||
@@ -1480,6 +1513,35 @@ export default function ProviderDetailPage() {
|
||||
|
||||
const canImportModels = connections.some((conn) => conn.isActive !== false);
|
||||
|
||||
// Auto-sync toggle state: read from first active connection's providerSpecificData
|
||||
const autoSyncConnection = connections.find((conn: any) => conn.isActive !== false);
|
||||
const isAutoSyncEnabled = !!(autoSyncConnection as any)?.providerSpecificData?.autoSync;
|
||||
const [togglingAutoSync, setTogglingAutoSync] = useState(false);
|
||||
|
||||
const handleToggleAutoSync = async () => {
|
||||
if (!autoSyncConnection || togglingAutoSync) return;
|
||||
setTogglingAutoSync(true);
|
||||
try {
|
||||
const newValue = !isAutoSyncEnabled;
|
||||
await fetch(`/api/providers/${(autoSyncConnection as any).id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerSpecificData: { autoSync: newValue },
|
||||
}),
|
||||
});
|
||||
await fetchConnections();
|
||||
notify[newValue ? "success" : "info"](
|
||||
newValue ? t("autoSyncEnabled") : t("autoSyncDisabled")
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("Error toggling auto-sync:", error);
|
||||
notify.error(t("autoSyncToggleFailed"));
|
||||
} finally {
|
||||
setTogglingAutoSync(false);
|
||||
}
|
||||
};
|
||||
|
||||
const customMap = useMemo(() => buildCompatMap(modelMeta.customModels), [modelMeta.customModels]);
|
||||
const overrideMap = useMemo(
|
||||
() => buildCompatMap(modelMeta.modelCompatOverrides),
|
||||
@@ -1571,29 +1633,50 @@ export default function ProviderDetailPage() {
|
||||
};
|
||||
|
||||
const renderModelsSection = () => {
|
||||
const autoSyncToggle = canImportModels && (
|
||||
<button
|
||||
onClick={handleToggleAutoSync}
|
||||
disabled={togglingAutoSync}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-border bg-transparent cursor-pointer text-[12px] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t("autoSyncTooltip")}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px]"
|
||||
style={{ color: isAutoSyncEnabled ? "#22c55e" : "var(--color-text-muted)" }}
|
||||
>
|
||||
{isAutoSyncEnabled ? "toggle_on" : "toggle_off"}
|
||||
</span>
|
||||
<span className="text-text-main">{t("autoSync")}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isCompatible) {
|
||||
return (
|
||||
<CompatibleModelsSection
|
||||
providerStorageAlias={providerStorageAlias}
|
||||
providerDisplayAlias={providerDisplayAlias}
|
||||
modelAliases={modelAliases}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={handleSetAlias}
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
connections={connections}
|
||||
isAnthropic={isAnthropicCompatible}
|
||||
onImportWithProgress={handleCompatibleImportWithProgress}
|
||||
t={t}
|
||||
effectiveModelNormalize={effectiveModelNormalize}
|
||||
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
|
||||
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
|
||||
saveModelCompatFlags={saveModelCompatFlags}
|
||||
compatSavingModelId={compatSavingModelId}
|
||||
onModelsChanged={fetchProviderModelMeta}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">{autoSyncToggle}</div>
|
||||
<CompatibleModelsSection
|
||||
providerStorageAlias={providerStorageAlias}
|
||||
providerDisplayAlias={providerDisplayAlias}
|
||||
modelAliases={modelAliases}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={handleSetAlias}
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
connections={connections}
|
||||
isAnthropic={isAnthropicCompatible}
|
||||
onImportWithProgress={handleCompatibleImportWithProgress}
|
||||
t={t}
|
||||
effectiveModelNormalize={effectiveModelNormalize}
|
||||
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
|
||||
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
|
||||
saveModelCompatFlags={saveModelCompatFlags}
|
||||
compatSavingModelId={compatSavingModelId}
|
||||
onModelsChanged={fetchProviderModelMeta}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (providerInfo.passthroughModels) {
|
||||
return (
|
||||
<div>
|
||||
@@ -1607,6 +1690,7 @@ export default function ProviderDetailPage() {
|
||||
>
|
||||
{importingModels ? t("importingModels") : t("importFromModels")}
|
||||
</Button>
|
||||
{autoSyncToggle}
|
||||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
|
||||
)}
|
||||
@@ -1640,6 +1724,7 @@ export default function ProviderDetailPage() {
|
||||
>
|
||||
{importingModels ? t("importingModels") : t("importFromModels")}
|
||||
</Button>
|
||||
{autoSyncToggle}
|
||||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
|
||||
)}
|
||||
@@ -1930,68 +2015,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 +2358,7 @@ export default function ProviderDetailPage() {
|
||||
level={proxyTarget.level}
|
||||
levelId={proxyTarget.id}
|
||||
levelLabel={proxyTarget.label}
|
||||
onSaved={() => void loadConnProxies(connections)}
|
||||
/>
|
||||
)}
|
||||
{/* Import Progress Modal */}
|
||||
@@ -3676,10 +3847,9 @@ function ConnectionRow({
|
||||
{connection.lastError && connection.isActive !== false && (
|
||||
<span
|
||||
className={`text-xs truncate max-w-[300px] ${statusPresentation.errorTextClass}`}
|
||||
title={connection.lastError}
|
||||
>
|
||||
{connection.lastError}
|
||||
</span>
|
||||
title={connection.lastError.replace(/<[^>]*>?/gm, "")}
|
||||
dangerouslySetInnerHTML={{ __html: connection.lastError }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-text-muted">#{connection.priority}</span>
|
||||
{connection.globalPriority && (
|
||||
@@ -4130,6 +4300,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
baseUrl: "",
|
||||
region: "",
|
||||
validationModelId: "",
|
||||
tag: "",
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
@@ -4159,6 +4330,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 +4454,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 +4465,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 +4501,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>
|
||||
|
||||
@@ -9,6 +9,8 @@ type ProxyItem = {
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
region?: string | null;
|
||||
notes?: string | null;
|
||||
status?: string;
|
||||
@@ -27,6 +29,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 +61,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 +87,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 +128,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 +175,65 @@ 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),
|
||||
username: item.username,
|
||||
password: item.password,
|
||||
},
|
||||
}),
|
||||
});
|
||||
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 +466,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 };
|
||||
}
|
||||
|
||||
125
src/app/api/providers/[id]/sync-models/route.ts
Normal file
125
src/app/api/providers/[id]/sync-models/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById } from "@/models";
|
||||
import { replaceCustomModels } from "@/lib/db/models";
|
||||
import { saveCallLog } from "@/lib/usage/callLogs";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
/**
|
||||
* POST /api/providers/[id]/sync-models
|
||||
*
|
||||
* Fetches the model list from a provider's /models endpoint and replaces the
|
||||
* full custom models list for that provider. Logs the operation to call_logs.
|
||||
*
|
||||
* Used by:
|
||||
* - modelSyncScheduler (auto-sync on interval)
|
||||
* - Manual trigger from UI
|
||||
*/
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const start = Date.now();
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "Authentication required", type: "invalid_api_key" } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const connection = await getProviderConnectionById(id);
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Use a human-readable provider name for logs
|
||||
const providerLabel = connection.name || connection.provider || "unknown";
|
||||
|
||||
// Fetch models from the existing /api/providers/[id]/models endpoint
|
||||
const origin = new URL(request.url).origin;
|
||||
const modelsUrl = `${origin}/api/providers/${id}/models`;
|
||||
const modelsRes = await fetch(modelsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: request.headers.get("cookie") || "",
|
||||
"x-internal": "model-sync",
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - start;
|
||||
const modelsData = await modelsRes.json();
|
||||
|
||||
if (!modelsRes.ok) {
|
||||
// Log the failed attempt
|
||||
await saveCallLog({
|
||||
method: "GET",
|
||||
path: `/api/providers/${id}/models`,
|
||||
status: modelsRes.status,
|
||||
model: "model-sync",
|
||||
provider: providerLabel,
|
||||
sourceFormat: "-",
|
||||
connectionId: id,
|
||||
duration,
|
||||
error: modelsData.error || `HTTP ${modelsRes.status}`,
|
||||
requestType: "model-sync",
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: modelsData.error || "Failed to fetch models" },
|
||||
{ status: modelsRes.status }
|
||||
);
|
||||
}
|
||||
|
||||
const fetchedModels = modelsData.models || [];
|
||||
|
||||
// Replace the full model list
|
||||
const models = fetchedModels
|
||||
.map((m: any) => ({
|
||||
id: m.id || m.name || m.model,
|
||||
name: m.name || m.displayName || m.id || m.model,
|
||||
source: "auto-sync",
|
||||
}))
|
||||
.filter((m: any) => m.id);
|
||||
|
||||
const replaced = await replaceCustomModels(connection.provider, models);
|
||||
|
||||
// Log the successful sync
|
||||
await saveCallLog({
|
||||
method: "GET",
|
||||
path: `/api/providers/${id}/models`,
|
||||
status: 200,
|
||||
model: "model-sync",
|
||||
provider: providerLabel,
|
||||
sourceFormat: "-",
|
||||
connectionId: id,
|
||||
duration: Date.now() - start,
|
||||
requestType: "model-sync",
|
||||
responseBody: {
|
||||
syncedModels: models.length,
|
||||
provider: connection.provider,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
provider: connection.provider,
|
||||
syncedModels: replaced.length,
|
||||
models: replaced,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Log error
|
||||
await saveCallLog({
|
||||
method: "POST",
|
||||
path: `/api/providers/${id}/sync-models`,
|
||||
status: 500,
|
||||
model: "model-sync",
|
||||
provider: "unknown",
|
||||
sourceFormat: "-",
|
||||
connectionId: id,
|
||||
duration: Date.now() - start,
|
||||
error: error.message || "Sync failed",
|
||||
requestType: "model-sync",
|
||||
}).catch(() => {});
|
||||
|
||||
return NextResponse.json({ error: error.message || "Failed to sync models" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry.ts";
|
||||
import { getAllVideoModels, getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
|
||||
import { getAllMusicModels, getMusicProvider } from "@omniroute/open-sse/config/musicRegistry.ts";
|
||||
import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry.ts";
|
||||
import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry.ts";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
|
||||
const FALLBACK_ALIAS_TO_PROVIDER = {
|
||||
@@ -315,10 +315,9 @@ export async function getUnifiedModelsResponse(
|
||||
});
|
||||
}
|
||||
|
||||
// Add video models (local providers always listed, cloud filtered by active)
|
||||
// Add video models (filtered by active providers)
|
||||
for (const videoModel of getAllVideoModels()) {
|
||||
const vConfig = getVideoProvider(videoModel.provider);
|
||||
if (vConfig?.authType !== "none" && !isProviderActive(videoModel.provider)) continue;
|
||||
if (!isProviderActive(videoModel.provider)) continue;
|
||||
models.push({
|
||||
id: videoModel.id,
|
||||
object: "model",
|
||||
@@ -328,10 +327,9 @@ export async function getUnifiedModelsResponse(
|
||||
});
|
||||
}
|
||||
|
||||
// Add music models (local providers always listed, cloud filtered by active)
|
||||
// Add music models (filtered by active providers)
|
||||
for (const musicModel of getAllMusicModels()) {
|
||||
const mConfig = getMusicProvider(musicModel.provider);
|
||||
if (mConfig?.authType !== "none" && !isProviderActive(musicModel.provider)) continue;
|
||||
if (!isProviderActive(musicModel.provider)) continue;
|
||||
models.push({
|
||||
id: musicModel.id,
|
||||
object: "model",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1378,6 +1378,11 @@
|
||||
"chatCompletions": "Chat Completions",
|
||||
"importingModels": "Importing...",
|
||||
"importFromModels": "Import from /models",
|
||||
"autoSync": "Auto-Sync",
|
||||
"autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)",
|
||||
"autoSyncEnabled": "Auto-sync enabled — models will refresh periodically",
|
||||
"autoSyncDisabled": "Auto-sync disabled",
|
||||
"autoSyncToggleFailed": "Failed to toggle auto-sync",
|
||||
"addConnectionToImport": "Add a connection to enable importing.",
|
||||
"noModelsConfigured": "No models configured",
|
||||
"connectionCount": "{count} connection(s)",
|
||||
|
||||
@@ -1378,6 +1378,11 @@
|
||||
"chatCompletions": "聊天完成",
|
||||
"importingModels": "正在导入...",
|
||||
"importFromModels": "从 /models 导入",
|
||||
"autoSync": "自动同步",
|
||||
"autoSyncTooltip": "每24小时自动刷新模型列表(可通过 MODEL_SYNC_INTERVAL_HOURS 配置)",
|
||||
"autoSyncEnabled": "已启用自动同步 — 模型列表将定期刷新",
|
||||
"autoSyncDisabled": "已禁用自动同步",
|
||||
"autoSyncToggleFailed": "切换自动同步失败",
|
||||
"addConnectionToImport": "添加连接以启用导入。",
|
||||
"noModelsConfigured": "尚未配置模型",
|
||||
"connectionCount": "{count} 连接",
|
||||
|
||||
@@ -360,6 +360,76 @@ export async function addCustomModel(
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire custom models list for a provider (used by auto-sync).
|
||||
* Preserves per-model compatibility overrides for models that still exist.
|
||||
*/
|
||||
export async function replaceCustomModels(
|
||||
providerId: string,
|
||||
models: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
apiFormat?: string;
|
||||
supportedEndpoints?: string[];
|
||||
}>
|
||||
) {
|
||||
const db = getDbInstance();
|
||||
const existing = await getCustomModels(providerId);
|
||||
const existingMap = new Map<string, JsonRecord>();
|
||||
if (Array.isArray(existing)) {
|
||||
for (const m of existing) {
|
||||
if (m && typeof m === "object" && m.id) existingMap.set(m.id, m);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: keep existing per-model compat flags if model still exists
|
||||
const merged = models.map((m) => {
|
||||
const prev = existingMap.get(m.id);
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
source: m.source || "auto-sync",
|
||||
apiFormat: m.apiFormat || (prev as any)?.apiFormat || "chat-completions",
|
||||
supportedEndpoints: m.supportedEndpoints || (prev as any)?.supportedEndpoints || ["chat"],
|
||||
// Preserve existing compat flags
|
||||
...(prev && (prev as any).normalizeToolCallId !== undefined
|
||||
? { normalizeToolCallId: (prev as any).normalizeToolCallId }
|
||||
: {}),
|
||||
...(prev && (prev as any).preserveOpenAIDeveloperRole !== undefined
|
||||
? { preserveOpenAIDeveloperRole: (prev as any).preserveOpenAIDeveloperRole }
|
||||
: {}),
|
||||
...(prev && (prev as any).compatByProtocol
|
||||
? { compatByProtocol: (prev as any).compatByProtocol }
|
||||
: {}),
|
||||
...(prev && (prev as any).upstreamHeaders
|
||||
? { upstreamHeaders: (prev as any).upstreamHeaders }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
if (merged.length === 0) {
|
||||
db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run(
|
||||
providerId
|
||||
);
|
||||
} else {
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)"
|
||||
).run(providerId, JSON.stringify(merged));
|
||||
}
|
||||
|
||||
// Remove compat overrides for models that no longer exist
|
||||
const newIds = new Set(models.map((m) => m.id));
|
||||
const compatList = readCompatList(providerId);
|
||||
const filteredCompat = compatList.filter((e) => newIds.has(e.id));
|
||||
if (filteredCompat.length !== compatList.length) {
|
||||
writeCompatList(providerId, filteredCompat);
|
||||
}
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function removeCustomModel(providerId, modelId) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
|
||||
@@ -48,6 +48,7 @@ export {
|
||||
getCustomModels,
|
||||
getAllCustomModels,
|
||||
addCustomModel,
|
||||
replaceCustomModels,
|
||||
removeCustomModel,
|
||||
updateCustomModel,
|
||||
getModelCompatOverrides,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { spawn } from "child_process";
|
||||
import { spawn, execFileSync } from "child_process";
|
||||
|
||||
const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]);
|
||||
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
|
||||
@@ -258,6 +259,42 @@ const validateEnvPath = (value: string | undefined, allowedParents: string[]): s
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect the npm global bin directory.
|
||||
* Cached on first call — `execFileSync` is expensive, only run once.
|
||||
*/
|
||||
let _npmGlobalPrefix: string | undefined;
|
||||
const getNpmGlobalPrefix = (): string => {
|
||||
if (_npmGlobalPrefix !== undefined) return _npmGlobalPrefix;
|
||||
|
||||
const envPrefix = String(process.env.npm_config_prefix || "").trim();
|
||||
if (envPrefix && path.isAbsolute(envPrefix)) {
|
||||
_npmGlobalPrefix = envPrefix;
|
||||
return _npmGlobalPrefix;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = execFileSync("npm", ["config", "get", "prefix"], {
|
||||
timeout: 5000,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
...(isWindows() ? { shell: true } : {}),
|
||||
});
|
||||
const prefix = result.trim();
|
||||
if (
|
||||
prefix &&
|
||||
path.isAbsolute(prefix) &&
|
||||
!DANGEROUS_PATH_CHARS.some((c) => prefix.includes(c))
|
||||
) {
|
||||
_npmGlobalPrefix = prefix;
|
||||
return _npmGlobalPrefix;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
_npmGlobalPrefix = "";
|
||||
return _npmGlobalPrefix;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-compute expected parent directories at module startup for performance.
|
||||
* These are the allowed directories for CLI binary installation locations.
|
||||
@@ -281,6 +318,8 @@ const getExpectedParentPaths = (): string[] => {
|
||||
"C:\\Program Files (x86)",
|
||||
]);
|
||||
|
||||
const npmPrefix = getNpmGlobalPrefix();
|
||||
|
||||
return [
|
||||
home,
|
||||
userProfile,
|
||||
@@ -288,6 +327,7 @@ const getExpectedParentPaths = (): string[] => {
|
||||
validatedLocalAppData,
|
||||
validatedProgramFiles,
|
||||
validatedProgramFilesX86,
|
||||
npmPrefix,
|
||||
].filter(Boolean);
|
||||
};
|
||||
|
||||
@@ -310,86 +350,94 @@ const getExtraPaths = () =>
|
||||
});
|
||||
|
||||
/**
|
||||
* Get known installation paths for a specific CLI tool on Windows.
|
||||
* Returns ONLY verified, tool-specific paths - NOT generic user bin directories.
|
||||
* This is more secure than searching PATH as it checks known locations only.
|
||||
* Get known installation paths for a specific CLI tool.
|
||||
* Checks npm global prefix, NVM locations, standalone installer paths.
|
||||
* Works on all platforms — Windows checks .cmd wrappers, Linux/macOS checks bare names.
|
||||
*/
|
||||
const getKnownToolPaths = (toolId: string): string[] => {
|
||||
if (!isWindows()) return [];
|
||||
|
||||
const home = os.homedir();
|
||||
const userProfile = process.env.USERPROFILE || home;
|
||||
const paths: string[] = [];
|
||||
|
||||
// Validate environment paths against allowed parent directories
|
||||
const appData = validateEnvPath(process.env.APPDATA, [home, userProfile]);
|
||||
const localAppData = validateEnvPath(process.env.LOCALAPPDATA, [
|
||||
path.join(home, "AppData", "Local"),
|
||||
path.join(userProfile, "AppData", "Local"),
|
||||
userProfile,
|
||||
]);
|
||||
|
||||
// Cache nvm node path to avoid duplicate detection calls
|
||||
const npmPrefix = getNpmGlobalPrefix();
|
||||
const nvmNodePath = getNvmNodePath();
|
||||
|
||||
// Tool-specific known installation paths (verified locations only)
|
||||
const knownPaths: Record<string, string[]> = {
|
||||
const toolBins: Record<string, [string, string][]> = {
|
||||
claude: [
|
||||
// Official Claude Code standalone installer locations
|
||||
path.join(home, ".local", "bin", "claude.exe"),
|
||||
...(localAppData ? [path.join(localAppData, "Programs", "Claude", "claude.exe")] : []),
|
||||
...(localAppData ? [path.join(localAppData, "claude-code", "claude.exe")] : []),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "claude-code.cmd")] : []),
|
||||
],
|
||||
codex: [
|
||||
path.join(home, ".local", "bin", "codex"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "codex.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "codex.cmd")] : []),
|
||||
],
|
||||
droid: [
|
||||
path.join(home, ".local", "bin", "droid"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "droid.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "droid.cmd")] : []),
|
||||
],
|
||||
openclaw: [
|
||||
path.join(home, ".local", "bin", "openclaw"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "openclaw.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "openclaw.cmd")] : []),
|
||||
["claude.cmd", "claude"],
|
||||
["claude.exe", "claude"],
|
||||
],
|
||||
codex: [["codex.cmd", "codex"]],
|
||||
droid: [["droid.cmd", "droid"]],
|
||||
openclaw: [["openclaw.cmd", "openclaw"]],
|
||||
cursor: [
|
||||
path.join(home, ".local", "bin", "agent"),
|
||||
path.join(home, ".local", "bin", "cursor"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "agent.cmd")] : []),
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "cursor.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "agent.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "cursor.cmd")] : []),
|
||||
["agent.cmd", "agent"],
|
||||
["cursor.cmd", "cursor"],
|
||||
],
|
||||
cline: [
|
||||
path.join(home, ".local", "bin", "cline"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "cline.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "cline.cmd")] : []),
|
||||
],
|
||||
kilo: [
|
||||
path.join(home, ".local", "bin", "kilocode"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "kilocode.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "kilocode.cmd")] : []),
|
||||
],
|
||||
opencode: [
|
||||
path.join(home, ".local", "bin", "opencode"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "opencode.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "opencode.cmd")] : []),
|
||||
],
|
||||
// Add other tools as needed with their specific known paths
|
||||
cline: [["cline.cmd", "cline"]],
|
||||
kilo: [["kilocode.cmd", "kilocode"]],
|
||||
opencode: [["opencode.cmd", "opencode"]],
|
||||
};
|
||||
|
||||
return knownPaths[toolId] || [];
|
||||
const bins = toolBins[toolId] || [];
|
||||
|
||||
if (isWindows()) {
|
||||
const userProfile = process.env.USERPROFILE || home;
|
||||
const appData = validateEnvPath(process.env.APPDATA, [home, userProfile]);
|
||||
const localAppData = validateEnvPath(process.env.LOCALAPPDATA, [
|
||||
path.join(home, "AppData", "Local"),
|
||||
path.join(userProfile, "AppData", "Local"),
|
||||
userProfile,
|
||||
]);
|
||||
|
||||
if (toolId === "claude") {
|
||||
paths.push(path.join(home, ".local", "bin", "claude.exe"));
|
||||
if (localAppData) {
|
||||
paths.push(path.join(localAppData, "Programs", "Claude", "claude.exe"));
|
||||
paths.push(path.join(localAppData, "claude-code", "claude.exe"));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [winName] of bins) {
|
||||
if (npmPrefix) paths.push(path.join(npmPrefix, winName));
|
||||
if (appData) {
|
||||
const appDataPath = path.join(appData, "npm", winName);
|
||||
if (
|
||||
!npmPrefix ||
|
||||
path.normalize(appDataPath) !== path.normalize(path.join(npmPrefix, winName))
|
||||
) {
|
||||
paths.push(appDataPath);
|
||||
}
|
||||
}
|
||||
if (nvmNodePath) paths.push(path.join(nvmNodePath, winName));
|
||||
}
|
||||
} else {
|
||||
for (const [, posixName] of bins) {
|
||||
const nodeBinDir = path.dirname(process.execPath);
|
||||
paths.push(path.join(nodeBinDir, posixName));
|
||||
|
||||
if (npmPrefix) {
|
||||
paths.push(path.join(npmPrefix, "bin", posixName));
|
||||
}
|
||||
|
||||
paths.push(path.join(home, ".local", "bin", posixName));
|
||||
// Only add system paths if they exist (avoids unnecessary stat calls)
|
||||
if (fsSync.existsSync("/usr/local/bin")) {
|
||||
paths.push(path.join("/usr", "local", "bin", posixName));
|
||||
}
|
||||
if (fsSync.existsSync("/usr/bin")) {
|
||||
paths.push(path.join("/usr", "bin", posixName));
|
||||
}
|
||||
|
||||
if (toolId === "opencode") {
|
||||
paths.push(path.join(home, ".opencode", "bin", posixName));
|
||||
}
|
||||
if (toolId === "claude") {
|
||||
paths.push(path.join(home, ".claude", "bin", posixName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -492,7 +540,7 @@ const locateCommand = async (command: string, env: Record<string, string | undef
|
||||
* Security hardening:
|
||||
* - Resolves symlinks and verifies target stays within expected directories
|
||||
* - Verifies file is a regular file (not directory, pipe, or device)
|
||||
* - Checks file size bounds (1KB - 100MB) to detect suspicious binaries
|
||||
* - Checks file size bounds (30B - 100MB) to detect suspicious binaries
|
||||
*/
|
||||
const checkKnownPath = async (commandPath: string) => {
|
||||
if (!path.isAbsolute(commandPath)) {
|
||||
@@ -521,9 +569,10 @@ const checkKnownPath = async (commandPath: string) => {
|
||||
return { installed: false, commandPath: null, reason: "not_file" };
|
||||
}
|
||||
|
||||
// CLI binaries should be > 1KB and < 100MB
|
||||
// This catches suspicious files while allowing for wrapper scripts
|
||||
if (stat.size < 1024 || stat.size > 100 * 1024 * 1024) {
|
||||
// CLI binaries should be > 30 bytes and < 100MB
|
||||
// npm .cmd wrappers on Windows are ~300-500 bytes, JS wrappers on Linux can be ~44 bytes
|
||||
// Minimum catches empty/suspicious files while allowing legitimate thin wrappers
|
||||
if (stat.size < 30 || stat.size > 100 * 1024 * 1024) {
|
||||
return { installed: false, commandPath: null, reason: "suspicious_size" };
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -556,7 +605,7 @@ const locateCommandCandidate = async (
|
||||
|
||||
// SECURITY: First check known installation paths for this specific tool
|
||||
// This avoids searching PATH and reduces attack surface
|
||||
if (toolId && isWindows()) {
|
||||
if (toolId) {
|
||||
const knownPaths = getKnownToolPaths(toolId);
|
||||
for (const knownPath of knownPaths) {
|
||||
const result = await checkKnownPath(knownPath);
|
||||
@@ -592,6 +641,7 @@ const checkRunnable = async (
|
||||
PATH: env.PATH,
|
||||
HOME: env.HOME || env.USERPROFILE,
|
||||
SystemRoot: env.SystemRoot, // Windows needs this
|
||||
PATHEXT: env.PATHEXT, // Windows cmd.exe needs this to resolve .cmd/.bat/.exe extensions
|
||||
};
|
||||
|
||||
for (const args of [["--version"], ["-v"]]) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Model Auto-Sync Scheduler (#488)
|
||||
*
|
||||
* Automatically refreshes model lists for all providers with autoSync enabled
|
||||
* at a configurable interval (default: 24h).
|
||||
* Automatically refreshes model lists for provider connections that have
|
||||
* autoSync enabled in their providerSpecificData, at a configurable
|
||||
* interval (default: 24h).
|
||||
*
|
||||
* Pattern mirrors cloudSyncScheduler.ts for consistency.
|
||||
*/
|
||||
@@ -12,53 +13,67 @@ import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const MODEL_SYNC_SETTING_KEY = "model_sync_last_run";
|
||||
|
||||
/** Providers that support live model list fetching via /v1/models */
|
||||
const AUTO_SYNC_PROVIDERS = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"gemini",
|
||||
"deepseek",
|
||||
"groq",
|
||||
"mistral",
|
||||
"cohere",
|
||||
"openrouter",
|
||||
"together",
|
||||
"fireworks",
|
||||
"perplexity",
|
||||
"xai",
|
||||
"cerebras",
|
||||
"ollama",
|
||||
"nvidia",
|
||||
];
|
||||
|
||||
let schedulerTimer: NodeJS.Timeout | null = null;
|
||||
let isRunning = false;
|
||||
|
||||
/**
|
||||
* Fetch and cache models for a single provider.
|
||||
* Calls the internal /api/providers/{id}/sync-models endpoint (if it exists)
|
||||
* or falls back to /v1/models from the provider registry.
|
||||
* Fetch all provider connections that have autoSync enabled.
|
||||
*/
|
||||
async function syncProviderModels(providerId: string, baseUrl: string): Promise<void> {
|
||||
async function getAutoSyncConnections(): Promise<
|
||||
Array<{ id: string; provider: string; name?: string }>
|
||||
> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/provider-nodes/sync-models`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-internal": "model-sync-scheduler" },
|
||||
body: JSON.stringify({ provider: providerId }),
|
||||
const { getProviderConnections } = await import("@/lib/localDb");
|
||||
const connections = await getProviderConnections();
|
||||
return connections.filter((conn: any) => {
|
||||
if (!conn.isActive && conn.isActive !== undefined) return false;
|
||||
const psd =
|
||||
conn.providerSpecificData && typeof conn.providerSpecificData === "object"
|
||||
? conn.providerSpecificData
|
||||
: {};
|
||||
return psd.autoSync === true;
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[ModelSync] Provider ${providerId}: sync returned ${res.status}`);
|
||||
} else {
|
||||
console.log(`[ModelSync] Provider ${providerId}: ✓ updated`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[ModelSync] Provider ${providerId}: fetch failed —`, (err as Error).message);
|
||||
console.warn("[ModelSync] Failed to load connections:", (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one full model-sync cycle across all auto-sync providers.
|
||||
* Sync models for a single connection via the internal sync-models endpoint.
|
||||
*/
|
||||
async function syncConnectionModels(
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
baseUrl: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/providers/${connectionId}/sync-models`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-internal": "model-sync-scheduler" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[ModelSync] ${provider} (${connectionId.slice(0, 8)}): sync returned ${res.status}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const data = await res.json();
|
||||
console.log(
|
||||
`[ModelSync] ${provider} (${connectionId.slice(0, 8)}): ✓ ${data.syncedModels || 0} models`
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[ModelSync] ${provider} (${connectionId.slice(0, 8)}): fetch failed —`,
|
||||
(err as Error).message
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one full model-sync cycle across all auto-sync connections.
|
||||
*/
|
||||
async function runSyncCycle(apiBaseUrl: string): Promise<void> {
|
||||
if (isRunning) {
|
||||
@@ -67,26 +82,37 @@ async function runSyncCycle(apiBaseUrl: string): Promise<void> {
|
||||
}
|
||||
isRunning = true;
|
||||
const start = Date.now();
|
||||
console.log(
|
||||
`[ModelSync] Starting 24h model sync cycle — ${AUTO_SYNC_PROVIDERS.length} providers`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
AUTO_SYNC_PROVIDERS.map((id) => syncProviderModels(id, apiBaseUrl))
|
||||
);
|
||||
|
||||
const succeeded = results.filter((r) => r.status === "fulfilled").length;
|
||||
console.log(
|
||||
`[ModelSync] Cycle complete: ${succeeded}/${AUTO_SYNC_PROVIDERS.length} providers synced in ${Date.now() - start}ms`
|
||||
);
|
||||
|
||||
// Record last sync time
|
||||
try {
|
||||
await updateSettings({ [MODEL_SYNC_SETTING_KEY]: new Date().toISOString() });
|
||||
} catch {
|
||||
// Non-critical
|
||||
const connections = await getAutoSyncConnections();
|
||||
|
||||
if (connections.length === 0) {
|
||||
console.log("[ModelSync] No connections with autoSync enabled — skipping cycle");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[ModelSync] Starting model sync cycle — ${connections.length} connection(s)`);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
connections.map((conn) =>
|
||||
syncConnectionModels(conn.id, conn.name || conn.provider, apiBaseUrl)
|
||||
)
|
||||
);
|
||||
|
||||
const succeeded = results.filter((r) => r.status === "fulfilled" && r.value === true).length;
|
||||
console.log(
|
||||
`[ModelSync] Cycle complete: ${succeeded}/${connections.length} synced in ${Date.now() - start}ms`
|
||||
);
|
||||
|
||||
// Record last sync time
|
||||
try {
|
||||
await updateSettings({ [MODEL_SYNC_SETTING_KEY]: new Date().toISOString() });
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,9 +134,7 @@ export function startModelSyncScheduler(
|
||||
const effectiveIntervalMs =
|
||||
!isNaN(envHours) && envHours > 0 ? envHours * 60 * 60 * 1000 : intervalMs;
|
||||
|
||||
console.log(
|
||||
`[ModelSync] Scheduler started — interval: ${effectiveIntervalMs / 3_600_000}h, providers: ${AUTO_SYNC_PROVIDERS.length}`
|
||||
);
|
||||
console.log(`[ModelSync] Scheduler started — interval: ${effectiveIntervalMs / 3_600_000}h`);
|
||||
|
||||
// Run immediately on startup (staggered by 5s to avoid startup congestion)
|
||||
const startupDelay = setTimeout(() => runSyncCycle(apiBaseUrl), 5_000);
|
||||
|
||||
202
tests/unit/cli-runtime-detection.test.mjs
Normal file
202
tests/unit/cli-runtime-detection.test.mjs
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Tests for CLI tool detection: cross-platform known paths, size threshold,
|
||||
* npm prefix deduplication, and env var overrides.
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const { getCliRuntimeStatus, CLI_TOOL_IDS } =
|
||||
await import("../../src/shared/services/cliRuntime.ts");
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "cli-test-"));
|
||||
}
|
||||
|
||||
function createFile(dir, name, content) {
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, content);
|
||||
if (process.platform !== "win32") {
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// ─── CLI_TOOL_IDS ─────────────────────────────────────────────
|
||||
|
||||
describe("CLI_TOOL_IDS", () => {
|
||||
it("should include all expected tools", () => {
|
||||
const expected = [
|
||||
"claude",
|
||||
"codex",
|
||||
"droid",
|
||||
"openclaw",
|
||||
"cursor",
|
||||
"cline",
|
||||
"kilo",
|
||||
"continue",
|
||||
"opencode",
|
||||
];
|
||||
for (const id of expected) {
|
||||
assert.ok(CLI_TOOL_IDS.includes(id), `Missing tool: ${id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Size Threshold (30 bytes) ────────────────────────────────
|
||||
|
||||
describe("Size threshold — checkKnownPath", () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = createTempDir();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should detect files >= 30 bytes via env var", async () => {
|
||||
const prev = process.env.CLI_DROID_BIN;
|
||||
// Create a valid 30-byte+ script
|
||||
const content =
|
||||
process.platform === "win32"
|
||||
? "@echo off\r\necho 1.0.0\r\nexit 0\r\n"
|
||||
: "#!/bin/sh\r\necho 1.0.0\r\nexit 0\r\n";
|
||||
const script = createFile(tmpDir, "droid-valid", content);
|
||||
// Verify it's at least 30 bytes
|
||||
const stat = fs.statSync(script);
|
||||
assert.ok(stat.size >= 30, `File should be >= 30 bytes, got ${stat.size}`);
|
||||
|
||||
process.env.CLI_DROID_BIN = script;
|
||||
try {
|
||||
const result = await getCliRuntimeStatus("droid");
|
||||
assert.ok(result.installed, `Expected installed=true, got reason=${result.reason}`);
|
||||
assert.ok(result.commandPath === script, `Expected commandPath=${script}`);
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.CLI_DROID_BIN = prev;
|
||||
else delete process.env.CLI_DROID_BIN;
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect a valid CLI script (>= 30 bytes) via env var", async () => {
|
||||
const prev = process.env.CLI_DROID_BIN;
|
||||
const script =
|
||||
process.platform === "win32"
|
||||
? createFile(tmpDir, "droid.cmd", "@echo off\necho 1.0.0\n")
|
||||
: createFile(tmpDir, "droid", "#!/bin/sh\necho 1.0.0\n");
|
||||
|
||||
process.env.CLI_DROID_BIN = script;
|
||||
try {
|
||||
const result = await getCliRuntimeStatus("droid");
|
||||
assert.ok(result.installed, `Expected installed=true, got reason=${result.reason}`);
|
||||
assert.ok(
|
||||
result.commandPath === script,
|
||||
`Expected commandPath=${script}, got ${result.commandPath}`
|
||||
);
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.CLI_DROID_BIN = prev;
|
||||
else delete process.env.CLI_DROID_BIN;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Healthcheck with --version ───────────────────────────────
|
||||
|
||||
describe("Healthcheck — checkRunnable", () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = createTempDir();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should report runnable=true for a script that outputs version", async () => {
|
||||
const prev = process.env.CLI_CLINE_BIN;
|
||||
const script =
|
||||
process.platform === "win32"
|
||||
? createFile(tmpDir, "good.cmd", "@echo off\necho 1.0.0\n")
|
||||
: createFile(tmpDir, "good", "#!/bin/sh\necho 1.0.0\n");
|
||||
|
||||
process.env.CLI_CLINE_BIN = script;
|
||||
try {
|
||||
const result = await getCliRuntimeStatus("cline");
|
||||
assert.ok(result.installed, `Expected installed=true, got reason=${result.reason}`);
|
||||
if (result.runnable) {
|
||||
assert.ok(result.reason === null, `Expected no reason, got ${result.reason}`);
|
||||
}
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.CLI_CLINE_BIN = prev;
|
||||
else delete process.env.CLI_CLINE_BIN;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unknown tool ─────────────────────────────────────────────
|
||||
|
||||
describe("Unknown tool", () => {
|
||||
it("should return unknown_tool for non-existent tool", async () => {
|
||||
const result = await getCliRuntimeStatus("nonexistent-tool-xyz");
|
||||
assert.equal(result.installed, false);
|
||||
assert.equal(result.reason, "unknown_tool");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── continue tool (requiresBinary: false) ────────────────────
|
||||
|
||||
describe("continue tool — no binary required", () => {
|
||||
it("should report installed=true without checking binary", async () => {
|
||||
const result = await getCliRuntimeStatus("continue");
|
||||
assert.equal(result.installed, true);
|
||||
assert.equal(result.reason, "not_required");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveOpencodeConfigPath — cross-platform ─────────────────
|
||||
|
||||
const { resolveOpencodeConfigPath: resolveOpencodeConfigPathFn } =
|
||||
await import("../../src/shared/services/cliRuntime.ts");
|
||||
|
||||
describe("resolveOpencodeConfigPath — cross-platform", () => {
|
||||
it("should resolve on Linux with XDG_CONFIG_HOME", () => {
|
||||
const result = resolveOpencodeConfigPathFn(
|
||||
"linux",
|
||||
{ XDG_CONFIG_HOME: "/tmp/xdg" },
|
||||
"/home/dev"
|
||||
);
|
||||
assert.equal(result, path.join("/tmp/xdg", "opencode", "opencode.json"));
|
||||
});
|
||||
|
||||
it("should resolve on Linux with default .config", () => {
|
||||
const result = resolveOpencodeConfigPathFn("linux", {}, "/home/dev");
|
||||
assert.equal(result, path.join("/home/dev", ".config", "opencode", "opencode.json"));
|
||||
});
|
||||
|
||||
it("should resolve on Windows with APPDATA", () => {
|
||||
const result = resolveOpencodeConfigPathFn(
|
||||
"win32",
|
||||
{ APPDATA: "C:\\Users\\dev\\AppData\\Roaming" },
|
||||
"C:\\Users\\dev"
|
||||
);
|
||||
assert.equal(
|
||||
result,
|
||||
path.join("C:\\Users\\dev\\AppData\\Roaming", "opencode", "opencode.json")
|
||||
);
|
||||
});
|
||||
|
||||
it("should fallback to home/AppData/Roaming on Windows without APPDATA", () => {
|
||||
const result = resolveOpencodeConfigPathFn("win32", {}, "C:\\Users\\dev");
|
||||
assert.equal(
|
||||
result,
|
||||
path.join("C:\\Users\\dev", "AppData", "Roaming", "opencode", "opencode.json")
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user