Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbd60c853e | |||
| 2d8091340f | |||
| 2025c16c82 | |||
| 811fb7f9b2 | |||
| a2bd32e76c | |||
| 89c07f4e8a | |||
| ac89069671 | |||
| 7cb420d8e6 | |||
| d3919d441f | |||
| 4b5824babc | |||
| fb87df14fd | |||
| da9e4e929b | |||
| 10b23b15ae | |||
| 30fba39b35 | |||
| 5a75ff67c9 | |||
| 358828b617 | |||
| e080c4a16a | |||
| 04b7e38baf | |||
| 7ee23fbe19 | |||
| c49bdb4ebb | |||
| 0f7efed8d5 | |||
| d07bc6dcf3 | |||
| d607d46fa3 | |||
| 2225dd14aa | |||
| f6c0e7bbbe | |||
| c4675c5219 | |||
| 2d977a3c4d | |||
| 9405918258 | |||
| a69d7dd4b5 | |||
| 428e6cb53f | |||
| c9a2955d28 | |||
| 7aefcd3437 | |||
| 79f4f79c46 | |||
| c11c275678 | |||
| bbcd1d3a08 | |||
| 3342d5b931 | |||
| f96ee44213 | |||
| bc53fe0cd9 | |||
| 97a67b5d3e | |||
| 1ffa58be76 | |||
| a5cf51c0b9 | |||
| 3d38cbf70f | |||
| 196a4e037c | |||
| 6a0760a2c5 | |||
| b0819404c7 | |||
| bfe495931f | |||
| 11bcdd810a | |||
| 228ebf436e |
@@ -4,52 +4,73 @@ description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35)
|
||||
|
||||
# Deploy to VPS Workflow
|
||||
|
||||
Deploy OmniRoute to the production VPS using Node.js + PM2 (no Docker).
|
||||
Deploy OmniRoute to the production VPS using `npm install -g` + PM2.
|
||||
|
||||
**VPS:** `69.164.221.35` (Akamai, Ubuntu 24.04, 1GB RAM + 2.5GB swap)
|
||||
**App path:** `/opt/omniroute-app`
|
||||
**Local VPS:** `192.168.0.15` (same setup)
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> PM2 runs from the global npm package at `/usr/lib/node_modules/omniroute`.
|
||||
> **DO NOT** use git clone or local copies. The `npm install -g` command handles
|
||||
> building, publishing, and installing the standalone app in one step.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Push to GitHub
|
||||
### 1. Publish to npm
|
||||
|
||||
Ensure all changes are committed and pushed:
|
||||
Ensure the version in `package.json` is bumped and the package is published:
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
npm publish
|
||||
```
|
||||
|
||||
### 2. SSH into VPS, pull latest code, rebuild, and restart
|
||||
### 2. Install on VPS and restart PM2
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "
|
||||
cd /opt/omniroute-app &&
|
||||
git fetch origin &&
|
||||
git reset --hard origin/main &&
|
||||
export NODE_OPTIONS='--max-old-space-size=1536' &&
|
||||
npm install --no-audit --no-fund &&
|
||||
npm run build &&
|
||||
pm2 restart omniroute &&
|
||||
pm2 save &&
|
||||
echo '✅ Deploy complete!'
|
||||
"
|
||||
ssh root@69.164.221.35 "npm install -g omniroute@latest && pm2 restart omniroute && pm2 save && echo '✅ Deploy complete!'"
|
||||
```
|
||||
|
||||
For the local VPS:
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g omniroute@latest && pm2 restart omniroute && pm2 save && echo '✅ Deploy complete!'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "pm2 list && curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:20128/"
|
||||
ssh root@69.164.221.35 "pm2 list && cat \$(npm root -g)/omniroute/package.json | grep version | head -1 && curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:20128/"
|
||||
```
|
||||
|
||||
Expected: PM2 shows `online`, HTTP returns `307` (redirect to login).
|
||||
Expected: PM2 shows `online`, version matches published, HTTP returns `307` (redirect to login).
|
||||
|
||||
## How it works
|
||||
|
||||
1. `npm publish` builds Next.js standalone + bundles everything into the npm package
|
||||
2. `npm install -g omniroute@latest` downloads and installs to `/usr/lib/node_modules/omniroute/`
|
||||
3. PM2 is registered to run `npm start` from that directory (cwd: `/usr/lib/node_modules/omniroute`)
|
||||
4. `pm2 restart omniroute` picks up the new code immediately
|
||||
|
||||
## PM2 Setup (one-time)
|
||||
|
||||
If PM2 needs to be reconfigured from scratch:
|
||||
|
||||
```bash
|
||||
ssh root@<VPS> "
|
||||
cd /usr/lib/node_modules/omniroute &&
|
||||
PORT=20128 pm2 start app/server.js --name omniroute --env PORT=20128 &&
|
||||
pm2 save &&
|
||||
pm2 startup
|
||||
"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The VPS has only 1GB RAM. `NODE_OPTIONS='--max-old-space-size=1536'` uses swap for the build.
|
||||
- The `.env` file is at `/usr/lib/node_modules/omniroute/.env`. Back it up before major npm updates.
|
||||
- PM2 is configured with `pm2 startup` to auto-restart on reboot.
|
||||
- The `.env` file is at `/opt/omniroute-app/.env` (copied from the old Docker setup at `/opt/omniroute/.env`).
|
||||
- Nginx proxies `omniroute.online` → `localhost:20128`.
|
||||
- The VPS has only 1GB RAM — builds happen locally via `npm publish`, not on the VPS.
|
||||
|
||||
@@ -31,14 +31,14 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: runner-base
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: electron-${{ matrix.platform }}
|
||||
path: release-assets/
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
@@ -172,6 +172,5 @@ jobs:
|
||||
release-assets/*.blockmap
|
||||
release-assets/*.source.tar.gz
|
||||
release-assets/*.source.zip
|
||||
release-assets/OmniRoute.exe
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -117,3 +117,8 @@ icon.iconset/
|
||||
|
||||
# VS Code Extension (independent Git repo)
|
||||
vscode-extension/
|
||||
|
||||
# SQLite residual files
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.sqlite-journal
|
||||
|
||||
+218
-4
@@ -7,6 +7,220 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [2.0.9] — 2026-03-07
|
||||
|
||||
> ### 🚀 Feature Drop — Playground, CLI Fingerprints, ACP
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **#234 — Model Playground** — Dashboard page to test any model directly (provider/model/endpoint selectors, Monaco Editor, streaming, abort, timing metrics). Available in the Debug sidebar section.
|
||||
- **#223 — CLI Fingerprint Matching** — Per-provider header/body field ordering to match native CLI binary fingerprints, reducing account flagging risk. Enable via `CLI_COMPAT_<PROVIDER>=1` or `CLI_COMPAT_ALL=1` env vars.
|
||||
- **#235 — ACP Support** — Agent Client Protocol module with CLI agent discovery (Codex, Claude, Goose, Gemini CLI, OpenClaw), process spawner/manager, and `/api/acp/agents` endpoint.
|
||||
|
||||
### 🧹 Housekeeping
|
||||
|
||||
- **#192 & #200** — Closed as stale (needs-info, v1.8.1, no reproduction info provided)
|
||||
|
||||
---
|
||||
|
||||
## [2.0.8] — 2026-03-07
|
||||
|
||||
> ### 🐛 Bug Fix — Custom Image Model Handler Resolution
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **#238 — Custom image models still fail in handler layer** — v2.0.7 fixed the route-layer validation, but the handler (`handleImageGeneration()`) called `parseImageModel()` again internally, rejecting custom models a second time. Fix: handler now accepts an optional `resolvedProvider` parameter; when provided, it skips re-validation and routes custom models to the OpenAI-compatible handler with a synthetic config. PR #239
|
||||
|
||||
### 📁 Files Changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `open-sse/handlers/imageGeneration.ts` | Added `resolvedProvider` param + custom model fallback |
|
||||
| `src/app/api/v1/images/generations/route.ts` | Tracks `isCustomModel`, passes `resolvedProvider`, credentials for custom models |
|
||||
|
||||
---
|
||||
|
||||
## [2.0.7] — 2026-03-07
|
||||
|
||||
> ### 🐛 Bug Fixes — Custom Image Models + Codex OAuth Workspace Isolation
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **#232 — Custom Gemini image models fail on `/v1/images/generations`** — Custom models tagged with `supportedEndpoints: ["images"]` appeared in the model listing (GET) but were rejected by the POST handler. `parseImageModel()` only checked the built-in `IMAGE_PROVIDERS` registry. Fix: added a custom model DB fallback for models with the `images` endpoint tag. PR #237
|
||||
- **#236 — Codex OAuth overwrites existing connection when same email added to another workspace** — The OAuth callback route had 3 upsert blocks matching connections by email-only, bypassing the workspace-aware logic in `createProviderConnection()`. When the same email authenticated to a new workspace, the existing connection's `workspaceId` was silently overwritten. Fix: for Codex, the match now also checks `providerSpecificData.workspaceId`, allowing separate connections per workspace. PR #237
|
||||
|
||||
### 📁 Files Changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------ | ---------------------------------------------------- |
|
||||
| `src/app/api/v1/images/generations/route.ts` | Custom model DB fallback in POST handler |
|
||||
| `src/app/api/oauth/[provider]/[action]/route.ts` | Workspace-aware Codex matching in 3 upsert locations |
|
||||
|
||||
### ⏭️ Issues Triaged
|
||||
|
||||
- **#234** — Playground feature request — Acknowledged, added to roadmap
|
||||
- **#235** — ACP support feature request — Acknowledged, added to roadmap
|
||||
|
||||
---
|
||||
|
||||
## [2.0.6] — 2026-03-07
|
||||
|
||||
> ### 🐛 Bug Fix — Custom Model API Format Routing
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **#204 — Custom model `apiFormat` not used in routing** — Custom models configured with `apiFormat: "responses"` in the dashboard were still being routed through the Chat Completions translator. The `apiFormat` field was stored in the DB and displayed in the UI, but never consumed by the routing layer. Fix: `getModelInfo()` now returns `apiFormat` from the custom model DB, and both `resolveModelOrError()` functions override `targetFormat` to `openai-responses` when set. PR #233
|
||||
|
||||
### ✅ Issues Closed
|
||||
|
||||
- **#205** — Combo endpoint support — Already implemented in v2.0.2
|
||||
- **#206** — Manual model→endpoint mapping — Already implemented in v2.0.2
|
||||
- **#223** — CLI fingerprint parity — Responded with 4-phase roadmap
|
||||
|
||||
### 📁 Files Changed
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `src/sse/services/model.ts` | Added `lookupCustomModelApiFormat()`, enriched `getModelInfo()` return |
|
||||
| `src/sse/handlers/chat.ts` | Override `targetFormat` when `apiFormat === "responses"` |
|
||||
| `src/sse/handlers/chatHelpers.ts` | Same override in duplicate `resolveModelOrError()` |
|
||||
|
||||
---
|
||||
|
||||
## [2.0.5] — 2026-03-06
|
||||
|
||||
> ### 🐛 Bug Fix, Electron Auto-Update & Dependency Bumps
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **#224 — Chat→Responses translation creates invalid reasoning IDs** — Removed synthetic reasoning item generation in `openaiToOpenAIResponsesRequest()`. The translator was creating reasoning items with IDs like `reasoning_15`, but OpenAI's Responses API requires server-generated `rs_*` IDs, causing `400 Invalid Request` errors from Responses-compatible upstreams. Fix: omit reasoning items entirely during translation
|
||||
- **CI: duplicate OmniRoute.exe in release workflow** — Removed redundant explicit `release-assets/OmniRoute.exe` entry that caused `softprops/action-gh-release` to fail with 404 on duplicate upload. PR #222 by @benzntech
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Electron Auto-Update** — Added auto-update functionality to the desktop app using `electron-updater`. Includes IPC handlers for check/download/install, "Check for Updates" in system tray menu, desktop notification when update is ready, and silent startup check (3s delay). PR #221 by @benzntech
|
||||
|
||||
### 📦 Dependencies
|
||||
|
||||
- Bump `actions/cache` from 4 to 5 (#225)
|
||||
- Bump `actions/download-artifact` from 4 to 8 (#226)
|
||||
- Bump `docker/login-action` from 3 to 4 (#227)
|
||||
- Bump `actions/upload-artifact` from 4 to 7 (#228)
|
||||
- Bump `docker/build-push-action` from 6 to 7 (#229)
|
||||
- Bump `express-rate-limit` from 8.2.1 to 8.3.0 (#230)
|
||||
|
||||
### 📁 Files Changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `open-sse/translator/request/openai-responses.ts` | Remove synthetic reasoning item generation |
|
||||
| `.github/workflows/electron-release.yml` | Remove duplicate exe entry, bump GH Actions |
|
||||
| `.github/workflows/docker-publish.yml` | Bump docker/login-action and build-push-action |
|
||||
| `electron/main.js` | Auto-updater setup, IPC handlers, tray menu |
|
||||
| `electron/package.json` | Added electron-updater dep and GitHub publish config |
|
||||
| `electron/preload.js` | Exposed update APIs via contextBridge |
|
||||
| `package-lock.json` | Updated express-rate-limit |
|
||||
|
||||
---
|
||||
|
||||
## [2.0.4] — 2026-03-06
|
||||
|
||||
> ### 🐛 Bug Fixes — Round-Robin Persistence & Docker Compatibility
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **#218 — Round-robin sticks to one account** — Added `last_used_at` column to `provider_connections` schema. Round-robin routing relied on `lastUsedAt` to rotate between accounts, but the column was missing from the database — the value was always `null`, causing selection to fall back to the same account. Includes auto-migration for existing databases
|
||||
- **#217 — `Cannot find module 'zod'` in Docker/standalone builds** — Added `zod` to `serverExternalPackages` in `next.config.mjs`. Next.js standalone builds weren't tracing `zod` through dynamic imports, causing crashes on Docker startup. Data is **not lost** — the crash prevented the server from reading the existing database
|
||||
|
||||
### 📁 Files Changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `src/lib/db/core.ts` | Schema + migration + JSON migration for `last_used_at` |
|
||||
| `src/lib/db/providers.ts` | INSERT + UPDATE SQL for `last_used_at` |
|
||||
| `next.config.mjs` | `serverExternalPackages: ['better-sqlite3', 'zod']` |
|
||||
|
||||
---
|
||||
|
||||
## [2.0.3] — 2026-03-05
|
||||
|
||||
> ### 🐛 Bug Fixes & Quota System Hardening
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **#215 — Deferred tools 400 error** — Skip `cache_control` on tools with `defer_loading=true` when assigning prompt caching to the last tool. Previously, the API rejected requests with 400 when MCP tools (Playwright, etc.) had deferred loading enabled. Fix applied in both `claudeHelper.ts` and `openai-to-claude.ts` translation layers. PR #216 by @DavyMassoneto
|
||||
- **Stale compiled schemas.js** — Deleted stale compiled `schemas.js` (912 lines) that shadowed the TypeScript `.ts` source, causing `cloudSyncActionSchema` warnings in the dashboard. PR #216 by @DavyMassoneto
|
||||
- **#202 — False quota exhaustion** — Fixed empty API responses (`{}`) creating quota objects with `utilization ?? 0` = 0% remaining, incorrectly marking accounts as exhausted. Added `hasUtilization()` guard. PR #214 by @DavyMassoneto
|
||||
- **Invalid date crash** — `parseDate()` now validates dates before comparison, handling `Invalid Date` from malformed `resetAt` values gracefully. PR #214 by @DavyMassoneto
|
||||
- **`total=0` false infinite quota** — `normalizeQuotas` now defaults to 0% remaining when `total` is zero (was incorrectly reporting 100%). PR #214 by @DavyMassoneto
|
||||
- **Tailwind v4 build failure** — Tailwind v4 scanned `*.sqlite-shm`/`.sqlite-wal` binary files, triggering "Invalid code point" errors. Added `@source not` exclusions in `globals.css`. PR #214 by @DavyMassoneto
|
||||
|
||||
### ✨ Improvements
|
||||
|
||||
- **Quota-aware account selection** — All load-balancing strategies (sticky, round-robin, p2c, random, least-used, cost-optimized, fill-first) now prioritize accounts with available quota over exhausted ones. PR #214 by @DavyMassoneto
|
||||
- **Concurrent refresh protection** — `tickRunning` flag prevents overlapping background quota refresh ticks; `refreshingSet` deduplicates per-connection refreshes. Thundering herd prevention with `MAX_CONCURRENT_REFRESHES=5`. PR #214 by @DavyMassoneto
|
||||
- **`clearModelUnavailability` on success** — Model unavailability is now cleared on every successful request, not only on fallback paths. PR #214 by @DavyMassoneto
|
||||
- **Centralized `anthropic-version`** — Hardcoded `anthropic-version` header (3 occurrences) centralized into `CLAUDE_CONFIG.apiVersion`. PR #214 by @DavyMassoneto
|
||||
- **Extracted `safePercentage()` utility** — Shared percentage validation function extracted to `src/shared/utils/formatting.ts`, eliminating duplication between backend and frontend. PR #214 by @DavyMassoneto
|
||||
- **`isRecord()` type guard** — Replaces inline `typeof` chain in usage API route. PR #214 by @DavyMassoneto
|
||||
|
||||
### 📁 Files Changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `open-sse/translator/helpers/claudeHelper.ts` | Skip `cache_control` on deferred tools |
|
||||
| `open-sse/translator/request/openai-to-claude.ts` | Same fix in translator layer |
|
||||
| `src/shared/validation/schemas.js` | **DELETED** — stale compiled JS |
|
||||
| `.gitignore` | Exclude Tailwind binary scanning |
|
||||
| `open-sse/services/usage.ts` | Legacy endpoint fallback logging |
|
||||
| `src/domain/quotaCache.ts` | **NEW** — Core quota cache with hardening |
|
||||
| `src/shared/utils/formatting.ts` | **NEW** — `safePercentage()` utility |
|
||||
| `src/instrumentation.ts` | Startup log for quota cache |
|
||||
| `src/sse/handlers/chat.ts` | `clearModelUnavailability` + `markAccountExhaustedFrom429` |
|
||||
| `src/sse/services/auth.ts` | Quota-aware account selection |
|
||||
| `src/app/globals.css` | Tailwind `@source not` exclusions |
|
||||
| `src/app/api/usage/[connectionId]/route.ts` | `isRecord()` type guard |
|
||||
| `src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.tsx` | Use `remainingPercentage` directly |
|
||||
| `src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx` | Use shared `safePercentage()` |
|
||||
|
||||
---
|
||||
|
||||
## [2.0.2] — 2026-03-05
|
||||
|
||||
> ### 🐛 Bug Fixes & ✨ Endpoint-Aware Model Management
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **#212 — API Key creation crash** — Auto-generate `API_KEY_SECRET` at startup (like `JWT_SECRET`) to prevent HMAC crashes
|
||||
- **#213 — Circuit breaker scope** — Changed circuit breaker key from provider-level to model-level; a 429 on one account no longer blocks all accounts for the same provider
|
||||
- **#200 — Custom provider connection check** — Added connectivity fallback for OpenAI-compatible providers (Ollama, LM Studio); if `/models` and `/chat/completions` fail, a simple HTTP ping to the base URL marks the provider as connected
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **#204 — API Format selector** — Custom models can now specify `apiFormat`: `chat-completions` (default) or `responses` (for the Responses API)
|
||||
- **#205 — Combo endpoint support** — Combos now accept an `endpoint` field in the schema (`chat` | `embeddings` | `images`), enabling fallback/rotation combos for non-chat endpoints
|
||||
- **#206 — Supported Endpoints mapping** — When adding custom models, users can check which endpoints the model supports (💬 Chat, 📐 Embeddings, 🖼️ Images, 🔊 Audio). Models tagged for embeddings appear in `/v1/embeddings` and models tagged for images appear in `/v1/images/generations`
|
||||
- **Visual badges** — Model rows now display colored badges for non-default API formats and endpoint types
|
||||
- **Model catalog metadata** — `/v1/models` response includes `api_format`, `type`, and `supported_endpoints` for custom models
|
||||
|
||||
### 📁 Files Changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `src/instrumentation.ts` | Auto-generate `API_KEY_SECRET` |
|
||||
| `open-sse/services/combo.ts` | Circuit breaker keyed per-model |
|
||||
| `src/lib/providers/validation.ts` | Connectivity fallback ping |
|
||||
| `src/lib/db/models.ts` | `apiFormat` + `supportedEndpoints` fields |
|
||||
| `src/shared/schemas/validation.ts` | `endpoint` in `comboSchema` |
|
||||
| `src/shared/validation/schemas.ts` | Extended `providerModelMutationSchema` |
|
||||
| `src/app/api/provider-models/route.ts` | Pass new fields through API |
|
||||
| `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` | API format dropdown, endpoint checkboxes, badges |
|
||||
| `src/app/api/v1/models/catalog.ts` | Custom model metadata enrichment |
|
||||
| `src/app/api/v1/embeddings/route.ts` | Include custom embedding models |
|
||||
| `src/app/api/v1/images/generations/route.ts` | Include custom image models |
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] — 2026-03-05
|
||||
|
||||
> ### 🚀 Major Release — MCP Multi-Transport, A2A Protocol, Auto-Combo Engine & Full Type Safety Overhaul
|
||||
@@ -131,13 +345,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
| `src/lib/a2a/skills/` | `smartRouting.ts`, `quotaManagement.ts` |
|
||||
| `src/app/a2a/` | `route.ts` (JSON-RPC 2.0 dispatch handler) |
|
||||
| `src/app/api/mcp/sse/` | `route.ts` (SSE transport endpoint) |
|
||||
| `src/app/api/mcp/stream/` | `route.ts` (Streamable HTTP transport endpoint) |
|
||||
| `src/app/api/mcp/stream/` | `route.ts` (Streamable HTTP transport endpoint) |
|
||||
| `open-sse/services/autoCombo/` | `scoring.ts`, `taskFitness.ts`, `engine.ts`, `selfHealing.ts`, `modePacks.ts`, `persistence.ts`, `index.ts` |
|
||||
| `src/shared/contracts/` | `quota.ts` (shared API contracts) |
|
||||
| `src/shared/constants/` | `mcpScopes.ts` |
|
||||
| `src/shared/validation/` | `settingsSchemas.ts` (extracted settings Zod schema) |
|
||||
| `src/lib/db/migrations/` | `002_mcp_a2a_tables.sql` |
|
||||
| `src/app/(dashboard)/` | `dashboard/mcp/page.tsx`, `dashboard/a2a/page.tsx`, `dashboard/auto-combo/page.tsx`, `dashboard/endpoint/ApiEndpointsTab.tsx` |
|
||||
| `src/app/(dashboard)/` | `dashboard/mcp/page.tsx`, `dashboard/a2a/page.tsx`, `dashboard/auto-combo/page.tsx`, `dashboard/endpoint/ApiEndpointsTab.tsx` |
|
||||
| `vscode-extension/src/services/` | `mcpClient.ts`, `a2aClient.ts`, `policyEngine.ts`, `preflightDialog.ts`, `budgetGuard.ts`, `healthMonitor.ts`, `modePackSelector.ts`, `humanCheckpoint.ts` |
|
||||
| `scripts/` | `check-cycles.mjs`, `check-docs-sync.mjs`, `check-route-validation.mjs`, `check-t11-any-budget.mjs`, `run-playwright-tests.mjs`, `runtime-env.mjs` |
|
||||
| `tests/` | `t06-schema-hardening.test.mjs`, `t07-no-log-key-config.test.mjs`, `t08-mcp-scope-enforcement.test.mjs`, `ecosystem.test.ts` |
|
||||
@@ -158,12 +372,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
| `889e2ba` | 2026-03-04 | feat: add error pages, harden DB layer and compliance module |
|
||||
| `cbd0b1c` | 2026-03-04 | refactor: harden open-sse services, eliminate any casts, add dashboard pages |
|
||||
| `b33a853` | 2026-03-04 | feat: Introduce A2A lifecycle management, add type safety to ComfyUI and stream handling |
|
||||
| `a1a2610` | 2026-03-04 | feat: v2.0.0 - MCP server, A2A agent, proxy improvements and docs update |
|
||||
| `a1a2610` | 2026-03-04 | feat: v2.0.0 - MCP server, A2A agent, proxy improvements and docs update |
|
||||
| `d615ca5` | 2026-03-05 | feat: configurable tool name prefix (#199) and custom rpm/tpm rate limits (#198) |
|
||||
| `6d8868b` | 2026-03-05 | fix: extract validation helpers to fix webpack barrel-file resolution bug |
|
||||
| `bc2e60c` | 2026-03-05 | feat: Introduce new A2A and MCP API routes, enhance dashboard UI, E2E tests |
|
||||
| `79c23df` | 2026-03-05 | feat: Add i18n for media/themes, enhance combos with strategy guides, E2E tests |
|
||||
| `2490ba5` | 2026-03-05 | feat: Introduce combo readiness checks and strategy recommendations |
|
||||
| `2490ba5` | 2026-03-05 | feat: Introduce combo readiness checks and strategy recommendations |
|
||||
| `48dda26` | 2026-03-05 | fix: CORS headers on early-return error responses + auto-combo validation (#208, #209) |
|
||||
| `078a42b` | 2026-03-05 | feat: consolidate Endpoint, MCP, A2A into tabbed Endpoints page |
|
||||
| `6f1e6a0` | 2026-03-05 | feat: add MCP/A2A enable/disable toggle switches on Endpoints page |
|
||||
|
||||
+4
-3
@@ -287,6 +287,7 @@ Result: Never stop coding, minimal cost
|
||||
- **مكافحة الرعد القطيع** — Mutex + حماية الإشارة ضد عواصف إعادة المحاولة المتزامنة
|
||||
- **السلاسل الاحتياطية المجمعة** — إذا فشل الموفر الأساسي، فسيتم دخوله تلقائيًا عبر السلسلة دون أي تدخل
|
||||
- **Combo Circuit Breaker** — التعطيل التلقائي لمقدمي الخدمات الفاشلين ضمن سلسلة التحرير والسرد
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **لوحة معلومات الصحة** — مراقبة وقت التشغيل، وحالات قاطع الدائرة، وعمليات التأمين، وإحصائيات ذاكرة التخزين المؤقت، ووقت الاستجابة p50/p95/p99
|
||||
|
||||
</details>
|
||||
@@ -874,10 +875,10 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
### 🤖 عمليات الوكيل والبروتوكول (الإصدار 2.0)| ميزة | ماذا يفعل |
|
||||
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| 🔧 **خادم MCP (16 أداة)** | تتحكم أدوات IDE/agent في التوجيه والصحة والمجموعات والحدود والعمليات | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`)
|
||||
| 🔧 **خادم MCP (16 أداة)** | تتحكم أدوات IDE/agent في التوجيه والصحة والمجموعات والحدود والعمليات | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`)
|
||||
| 🤝 **خادم A2A (JSON-RPC + SSE)** | تنفيذ المهام من وكيل إلى وكيل مع تدفقات المزامنة والتدفق |
|
||||
| 🧭 **Consolidated Endpoints Page** | Tabbed management page with Endpoint Proxy, MCP, A2A, and API Endpoints tabs |
|
||||
| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) |
|
||||
| 🧭 **Consolidated Endpoints Page** | Tabbed management page with Endpoint Proxy, MCP, A2A, and API Endpoints tabs |
|
||||
| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) |
|
||||
| 🛰️ **نبضات وقت تشغيل MCP** | حالة العملية الحقيقية (معرف المنتج، وقت التشغيل، عمر نبضات القلب، النقل، وضع النطاق) |
|
||||
| 📋 **مسار تدقيق MCP** | سجلات التدقيق القابلة للتصفية مع النجاح/الفشل والإسناد الرئيسي |
|
||||
| 🔐 **تنفيذ نطاق MCP** | 9 أذونات نطاق تفصيلية للوصول إلى الأدوات الخاضعة للرقابة |
|
||||
|
||||
+4
-3
@@ -287,6 +287,7 @@ OpenAI използва един формат, Claude (Anthropic) използв
|
||||
- **Anti-Thundering Herd** — Mutex + семафорна защита срещу едновременни повторни бури
|
||||
- **Combo Fallback Chains** — Ако основният доставчик се провали, автоматично преминава през веригата без намеса
|
||||
- **Combo Circuit Breaker** — Автоматично деактивира неизправните доставчици в рамките на комбинирана верига
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Health Dashboard** — Мониторинг на времето на работа, състояния на прекъсвачи, блокировки, статистика на кеша, латентност на p50/p95/p99
|
||||
|
||||
</details>
|
||||
@@ -875,10 +876,10 @@ OmniRoute v2.0 е създаден като операционна платфо
|
||||
### 🤖 Операции на агент и протокол (v2.0)| Характеристика | Какво прави |
|
||||
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| 🔧 **MCP сървър (16 инструмента)** | Инструментите за IDE/агент контролират маршрутизиране, здраве, комбинации, ограничения и операции | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`)
|
||||
| 🔧 **MCP сървър (16 инструмента)** | Инструментите за IDE/агент контролират маршрутизиране, здраве, комбинации, ограничения и операции | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`)
|
||||
| 🤝 **A2A сървър (JSON-RPC + SSE)** | Изпълнение на задачи от агент към агент със синхронизиране и поточно предаване |
|
||||
| 🧭 **Consolidated Endpoints Page** | Tabbed management page with Endpoint Proxy, MCP, A2A, and API Endpoints tabs |
|
||||
| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) |
|
||||
| 🧭 **Consolidated Endpoints Page** | Tabbed management page with Endpoint Proxy, MCP, A2A, and API Endpoints tabs |
|
||||
| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) |
|
||||
| 🛰️ **MCP Runtime Heartbeat** | Реално състояние на процеса (pid, време на работа, възраст на сърдечния ритъм, транспорт, режим на обхвата) |
|
||||
| 📋 **MCP одитна пътека** | Филтрируеми журнали за одит с успех/неуспех и ключово приписване |
|
||||
| 🔐 **Прилагане на обхват на MCP** | 9 подробни разрешения за обхват за контролиран достъп до инструменти |
|
||||
|
||||
+4
-2
@@ -283,10 +283,12 @@ Når en AI-gateway eksponeres for netværket (LAN, VPS, Docker), kan enhver med
|
||||
**Sådan løser OmniRoute det:**
|
||||
|
||||
- **Circuit Breaker pr. udbyder** — Automatisk åbning/lukning med konfigurerbare tærskler og nedkøling (lukket/åbent/halvt åbent)
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Eksponentiel backoff** — Progressive forsinkelser af genforsøg
|
||||
- **Anti-tordenbesætning** — Mutex + semaforbeskyttelse mod samtidige genforsøgsstorme
|
||||
- **Combo Fallback Chains** — Hvis den primære udbyder fejler, falder den automatisk gennem kæden uden indgriben
|
||||
- **Combo Circuit Breaker** - Deaktiverer automatisk fejlende udbydere i en kombinationskæde
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Health Dashboard** — Oppetidsovervågning, strømafbrydertilstande, lockouts, cachestatistik, p50/p95/p99 latency
|
||||
|
||||
</details>
|
||||
@@ -875,10 +877,10 @@ OmniRoute v2.0 er bygget som en operationel platform, ikke kun en relæ-proxy.
|
||||
### 🤖 Agent- og protokoloperationer (v2.0)| Funktion | Hvad det gør |
|
||||
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| 🔧 **MCP-server (16 værktøjer)** | IDE/agent-værktøjer kontrollerer routing, sundhed, kombinationer, grænser og operationer | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`)
|
||||
| 🔧 **MCP-server (16 værktøjer)** | IDE/agent-værktøjer kontrollerer routing, sundhed, kombinationer, grænser og operationer | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`)
|
||||
| 🤝 **A2A-server (JSON-RPC + SSE)** | Agent-til-agent opgaveudførelse med synkronisering og streaming flows |
|
||||
| 🧭 **Consolidated Endpoints Page** | Dedikerede administrationssider (`/dashboard/mcp`, `/dashboard/a2a`) |
|
||||
| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) |
|
||||
| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) |
|
||||
| 🛰️ **MCP Runtime Heartbeat** | Reel processtatus (pid, oppetid, hjerteslagsalder, transport, omfangstilstand) |
|
||||
| 📋 **MCP Audit Trail** | Filtrerbare revisionslogfiler med succes/fejl og nøgletilskrivning |
|
||||
| 🔐 **MCP Scope Enforcement** | 9 granulære omfangstilladelser til kontrolleret værktøjsadgang |
|
||||
|
||||
+3
-2
@@ -287,6 +287,7 @@ Wenn ein KI-Gateway dem Netzwerk (LAN, VPS, Docker) zugänglich gemacht wird, ka
|
||||
- **Anti-Thundering Herd** – Mutex + Semaphor-Schutz gegen gleichzeitige Wiederholungsstürme
|
||||
- **Combo-Fallback-Ketten** – Wenn der primäre Anbieter ausfällt, fällt er automatisch durch die Kette, ohne dass ein Eingreifen erforderlich ist
|
||||
- **Combo Circuit Breaker** – Deaktiviert automatisch ausgefallene Anbieter innerhalb einer Combo-Kette
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Gesundheits-Dashboard** – Betriebszeitüberwachung, Leistungsschalterzustände, Sperren, Cache-Statistiken, p50/p95/p99-Latenz
|
||||
|
||||
</details>
|
||||
@@ -881,10 +882,10 @@ OmniRoute v2.0 ist als Betriebsplattform konzipiert und nicht nur als Relay-Prox
|
||||
### 🤖 Agenten- und Protokolloperationen (v2.0)| Funktion | Was es tut |
|
||||
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| 🔧 **MCP-Server (16 Tools)** | IDE-/Agent-Tools steuern Routing, Zustand, Kombinationen, Grenzwerte und Vorgänge | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`)
|
||||
| 🔧 **MCP-Server (16 Tools)** | IDE-/Agent-Tools steuern Routing, Zustand, Kombinationen, Grenzwerte und Vorgänge | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`)
|
||||
| 🤝 **A2A-Server (JSON-RPC + SSE)** | Ausführung von Agent-zu-Agent-Aufgaben mit Synchronisierungs- und Streaming-Flows |
|
||||
| 🧭 **Consolidated Endpoints Page** | Dedizierte Verwaltungsseiten (`/dashboard/mcp`, `/dashboard/a2a`) |
|
||||
| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) |
|
||||
| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) |
|
||||
| 🛰️ **MCP Runtime Heartbeat** | Echter Prozessstatus (PID, Betriebszeit, Heartbeat-Alter, Transport, Scope-Modus) |
|
||||
| 📋 **MCP Audit Trail** | Filterbare Audit-Protokolle mit Erfolg/Misserfolg und Schlüsselzuordnung |
|
||||
| 🔐 **Durchsetzung des MCP-Geltungsbereichs** | 9 granulare Umfangsberechtigungen für kontrollierten Werkzeugzugriff |
|
||||
|
||||
@@ -868,6 +868,7 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
| Característica | Qué Hace |
|
||||
| ---------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 🔌 **Circuit Breaker** | Auto-apertura/cierre por proveedor con umbrales configurables |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🛡️ **Anti-Thundering Herd** | Mutex + semáforo rate-limit para proveedores con API key |
|
||||
| 🧠 **Caché Semántico** | Caché de dos niveles (firma + semántico) reduce costo y latencia |
|
||||
| ⚡ **Idempotencia de Solicitud** | Ventana de dedup de 5s para solicitudes duplicadas |
|
||||
|
||||
@@ -294,6 +294,7 @@ Tekoälypalveluntarjoajat voivat muuttua epävakaiksi, palauttaa 5xx-virheitä t
|
||||
- **Anti-Thundering Herd** — Mutex + semaforisuoja samanaikaisia myrskyjä vastaan
|
||||
- **Yhdistelmävaraketjut** – Jos ensisijainen toimittaja epäonnistuu, putoaa automaattisesti ketjun läpi ilman väliintuloa
|
||||
- **Combo Circuit Breaker** — Poistaa automaattisesti käytöstä vialliset palveluntarjoajat yhdistelmäketjussa
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Health Dashboard** — käytettävyyden valvonta, katkaisijoiden tilat, lukitukset, välimuistitilastot, p50/p95/p99-viive
|
||||
|
||||
</details>
|
||||
|
||||
@@ -866,6 +866,7 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
| Fonctionnalité | Ce qu'elle fait |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 🔌 **Circuit Breaker** | Ouverture/fermeture auto par fournisseur avec seuils configurables |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🛡️ **Anti-Thundering Herd** | Mutex + sémaphore de rate-limit pour les fournisseurs avec clé API |
|
||||
| 🧠 **Cache sémantique** | Cache à deux niveaux (signature + sémantique) réduit coût et latence |
|
||||
| ⚡ **Idempotence des requêtes** | Fenêtre de dédup 5s pour les requêtes dupliquées |
|
||||
|
||||
+9
-8
@@ -1495,14 +1495,15 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux
|
||||
|
||||
ל-OmniRoute יש **210+ תכונות מתוכננות** לאורך שלבי פיתוח מרובים. להלן תחומי המפתח:
|
||||
|
||||
| קטגוריה | תכונות מתוכננות | הבהרה |
|
||||
| --------------------- | --------------- | ---------------------------------------------------------------------------------------- |
|
||||
| 🧠 **ניתוב ומודיעין** | 25+ | ניתוב עם זמן האחזור הנמוך ביותר, ניתוב מבוסס תגים, בדיקה מוקדמת של מכסה, בחירת חשבון P2C |
|
||||
| 🔒 **אבטחה ותאימות** | 20+ | הקשחת SSRF, הסוואה של אישורים, הגבלת קצב לכל נקודת קצה, היקף מפתח ניהול |
|
||||
| 📊 **צפיות** | 15+ | אינטגרציה של OpenTelemetry, ניטור מכסות בזמן אמת, מעקב עלויות לכל דגם |
|
||||
| 🔄 **שילובי ספקים** | 20+ | רישום מודלים דינמיים, צינון ספקים, Codex מרובה חשבונות, ניתוח מכסת Copilot |
|
||||
| ⚡ **ביצועים** | 15+ | שכבת מטמון כפולה, מטמון הנחיה, מטמון תגובה, סטרימינג Keepalive, API אצווה |
|
||||
| 🌐 **מערכת אקולוגית** | 10+ | WebSocket API, טעינה חוזרת של תצורה, חנות תצורה מבוזרת, מצב מסחרי |
|
||||
| קטגוריה | תכונות מתוכננות | הבהרה |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🧠 **ניתוב ומודיעין** | 25+ | ניתוב עם זמן האחזור הנמוך ביותר, ניתוב מבוסס תגים, בדיקה מוקדמת של מכסה, בחירת חשבון P2C |
|
||||
| 🔒 **אבטחה ותאימות** | 20+ | הקשחת SSRF, הסוואה של אישורים, הגבלת קצב לכל נקודת קצה, היקף מפתח ניהול |
|
||||
| 📊 **צפיות** | 15+ | אינטגרציה של OpenTelemetry, ניטור מכסות בזמן אמת, מעקב עלויות לכל דגם |
|
||||
| 🔄 **שילובי ספקים** | 20+ | רישום מודלים דינמיים, צינון ספקים, Codex מרובה חשבונות, ניתוח מכסת Copilot |
|
||||
| ⚡ **ביצועים** | 15+ | שכבת מטמון כפולה, מטמון הנחיה, מטמון תגובה, סטרימינג Keepalive, API אצווה |
|
||||
| 🌐 **מערכת אקולוגית** | 10+ | WebSocket API, טעינה חוזרת של תצורה, חנות תצורה מבוזרת, מצב מסחרי |
|
||||
|
||||
### 🔜 בקרוב
|
||||
|
||||
|
||||
@@ -294,6 +294,7 @@ Az AI-szolgáltatók instabillá válhatnak, 5xx-es hibákat adnak vissza, vagy
|
||||
- **Mennydörgés elleni csorda** - Mutex + szemafor védelem az egyidejű újrapróbálkozási viharok ellen
|
||||
- **Kombinált tartalék láncok** – Ha az elsődleges szolgáltató meghibásodik, automatikusan, beavatkozás nélkül átesik a láncon
|
||||
- **Combo Circuit Breaker** – Automatikusan letiltja a hibás szolgáltatókat a kombinált láncon belül
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Egészségügyi irányítópult** — Üzemidő-figyelés, áramkör-megszakító állapotok, zárolások, gyorsítótár-statisztika, p50/p95/p99 késleltetés
|
||||
|
||||
</details>
|
||||
|
||||
@@ -294,6 +294,7 @@ Penyedia AI bisa menjadi tidak stabil, menampilkan kesalahan 5xx, atau mencapai
|
||||
- **Kawanan Anti-Guntur** — Perlindungan mutex + semaphore terhadap badai percobaan ulang secara bersamaan
|
||||
- **Combo Fallback Chains** — Jika penyedia utama gagal, otomatis gagal dalam rantai tanpa intervensi
|
||||
- **Combo Circuit Breaker** — Menonaktifkan secara otomatis penyedia yang gagal dalam rantai kombo
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Dasbor Kesehatan** — Pemantauan waktu aktif, status pemutus sirkuit, penguncian, statistik cache, latensi p50/p95/p99
|
||||
|
||||
</details>
|
||||
|
||||
+9
-8
@@ -1138,14 +1138,15 @@ Cost: $0 forever!
|
||||
|
||||
ओम्निरूट ने कई विकास चरणों में **210+ सुविधाओं की योजना बनाई है**। यहां प्रमुख क्षेत्र हैं:
|
||||
|
||||
| श्रेणी | नियोजित विशेषताएं | हाइलाइट्स |
|
||||
| --------------------------- | ----------------- | ------------------------------------------------------------------------------------------- |
|
||||
| 🧠 **रूटिंग और इंटेलिजेंस** | 25+ | न्यूनतम-विलंबता रूटिंग, टैग-आधारित रूटिंग, कोटा प्रीफ़्लाइट, पी2सी खाता चयन |
|
||||
| 🔒 **सुरक्षा एवं अनुपालन** | 20+ | एसएसआरएफ हार्डनिंग, क्रेडेंशियल क्लोकिंग, प्रति समापन बिंदु दर-सीमा, प्रबंधन कुंजी स्कोपिंग |
|
||||
| 📊 **अवलोकनशीलता** | 15+ | ओपन टेलीमेट्री एकीकरण, वास्तविक समय कोटा निगरानी, प्रति मॉडल लागत ट्रैकिंग |
|
||||
| 🔄 **प्रदाता एकीकरण** | 20+ | डायनेमिक मॉडल रजिस्ट्री, प्रदाता कूलडाउन, मल्टी-अकाउंट कोडेक्स, कोपायलट कोटा पार्सिंग |
|
||||
| ⚡ **प्रदर्शन** | 15+ | दोहरी कैश परत, शीघ्र कैश, प्रतिक्रिया कैश, स्ट्रीमिंग कीपलाइव, बैच एपीआई |
|
||||
| 🌐 **पारिस्थितिकी तंत्र** | 10+ | वेबसॉकेट एपीआई, कॉन्फिग हॉट-रीलोड, वितरित कॉन्फिग स्टोर, वाणिज्यिक मोड |
|
||||
| श्रेणी | नियोजित विशेषताएं | हाइलाइट्स |
|
||||
| ---------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🧠 **रूटिंग और इंटेलिजेंस** | 25+ | न्यूनतम-विलंबता रूटिंग, टैग-आधारित रूटिंग, कोटा प्रीफ़्लाइट, पी2सी खाता चयन |
|
||||
| 🔒 **सुरक्षा एवं अनुपालन** | 20+ | एसएसआरएफ हार्डनिंग, क्रेडेंशियल क्लोकिंग, प्रति समापन बिंदु दर-सीमा, प्रबंधन कुंजी स्कोपिंग |
|
||||
| 📊 **अवलोकनशीलता** | 15+ | ओपन टेलीमेट्री एकीकरण, वास्तविक समय कोटा निगरानी, प्रति मॉडल लागत ट्रैकिंग |
|
||||
| 🔄 **प्रदाता एकीकरण** | 20+ | डायनेमिक मॉडल रजिस्ट्री, प्रदाता कूलडाउन, मल्टी-अकाउंट कोडेक्स, कोपायलट कोटा पार्सिंग |
|
||||
| ⚡ **प्रदर्शन** | 15+ | दोहरी कैश परत, शीघ्र कैश, प्रतिक्रिया कैश, स्ट्रीमिंग कीपलाइव, बैच एपीआई |
|
||||
| 🌐 **पारिस्थितिकी तंत्र** | 10+ | वेबसॉकेट एपीआई, कॉन्फिग हॉट-रीलोड, वितरित कॉन्फिग स्टोर, वाणिज्यिक मोड |
|
||||
|
||||
### 🔜 जल्द आ रहा है
|
||||
|
||||
|
||||
@@ -865,6 +865,7 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
| Funzionalità | Cosa Fa |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 🔌 **Circuit Breaker** | Apertura/chiusura auto per provider con soglie configurabili |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🛡️ **Anti-Thundering Herd** | Mutex + semaforo rate-limit per provider con API key |
|
||||
| 🧠 **Cache semantica** | Cache a due livelli (firma + semantica) riduce costi e latenza |
|
||||
| ⚡ **Idempotenza richieste** | Finestra dedup 5s per richieste duplicate |
|
||||
|
||||
@@ -532,6 +532,7 @@ AIは単なるチャット補完ではありません。開発者は、画像の
|
||||
- MCP ダッシュボードからコンボのアクティブ化を直接切り替えます
|
||||
- 事前定義されたポリシーパックから復元プロファイルを適用
|
||||
- 同じ操作パネルからサーキットブレーカーの状態をリセット
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -864,6 +865,7 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
| 特集 | 何をするのか |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 🔌 **サーキットブレーカー** | 設定可能なしきい値によるプロバイダーごとの自動開閉 |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🛡️ **対雷鳴の群れ** | API キープロバイダーのミューテックス + セマフォのレート制限 |
|
||||
| 🧠 **セマンティック キャッシュ** | 2 層キャッシュ (シグネチャ + セマンティック) によりコストと遅延が削減 |
|
||||
| ⚡ **冪等性のリクエスト** | 重複リクエストに対する 5 秒の重複除去ウィンドウ |
|
||||
@@ -964,6 +966,7 @@ Combo: "my-coding-stack"
|
||||
|
||||
- システムステータス (稼働時間、バージョン、メモリ使用量)
|
||||
- プロバイダーごとのサーキットブレーカーの状態 (クローズ/オープン/ハーフオープン)
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- レート制限ステータスとアクティブなロックアウト
|
||||
- 署名キャッシュ統計
|
||||
- レイテンシ テレメトリ (p50/p95/p99) + プロンプト キャッシュ
|
||||
|
||||
+9
-8
@@ -1495,14 +1495,15 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux
|
||||
|
||||
OmniRoute에는 여러 개발 단계에 걸쳐 **210개 이상의 기능이 계획되어 있습니다**. 주요 영역은 다음과 같습니다.
|
||||
|
||||
| 카테고리 | 계획된 기능 | 하이라이트 |
|
||||
| --------------------------- | ----------- | ------------------------------------------------------------------------------ |
|
||||
| 🧠 **라우팅 및 인텔리전스** | 25세 이상 | 최저 대기 시간 라우팅, 태그 기반 라우팅, 실행 전 할당량, P2C 계정 선택 |
|
||||
| 🔒 **보안 및 규정 준수** | 20세 이상 | SSRF 강화, 자격 증명 클로킹, 엔드포인트당 속도 제한, 관리 키 범위 지정 |
|
||||
| 📊 **관측성** | 15세 이상 | OpenTelemetry 통합, 실시간 할당량 모니터링, 모델별 비용 추적 |
|
||||
| 🔄 **공급자 통합** | 20세 이상 | 동적 모델 레지스트리, 공급자 쿨다운, 다중 계정 Codex, Copilot 할당량 구문 분석 |
|
||||
| ⚡ **성능** | 15세 이상 | 듀얼 캐시 레이어, 프롬프트 캐시, 응답 캐시, 스트리밍 Keepalive, 배치 API |
|
||||
| 🌐 **생태계** | 10세 이상 | WebSocket API, 구성 핫 리로드, 분산 구성 저장소, 상용 모드 |
|
||||
| 카테고리 | 계획된 기능 | 하이라이트 |
|
||||
| ---------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------ |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🧠 **라우팅 및 인텔리전스** | 25세 이상 | 최저 대기 시간 라우팅, 태그 기반 라우팅, 실행 전 할당량, P2C 계정 선택 |
|
||||
| 🔒 **보안 및 규정 준수** | 20세 이상 | SSRF 강화, 자격 증명 클로킹, 엔드포인트당 속도 제한, 관리 키 범위 지정 |
|
||||
| 📊 **관측성** | 15세 이상 | OpenTelemetry 통합, 실시간 할당량 모니터링, 모델별 비용 추적 |
|
||||
| 🔄 **공급자 통합** | 20세 이상 | 동적 모델 레지스트리, 공급자 쿨다운, 다중 계정 Codex, Copilot 할당량 구문 분석 |
|
||||
| ⚡ **성능** | 15세 이상 | 듀얼 캐시 레이어, 프롬프트 캐시, 응답 캐시, 스트리밍 Keepalive, 배치 API |
|
||||
| 🌐 **생태계** | 10세 이상 | WebSocket API, 구성 핫 리로드, 분산 구성 저장소, 상용 모드 |
|
||||
|
||||
### 🔜 출시 예정
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ _Your universal API proxy — one endpoint, 36+ providers, zero downtime. Now wi
|
||||
| **Settings** |  |
|
||||
| **CLI Tools** |  |
|
||||
| **Usage Logs** |  |
|
||||
| **Endpoints** |  |
|
||||
| **Endpoints** |  |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -289,7 +289,7 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit
|
||||
|
||||
**How OmniRoute solves it:**
|
||||
|
||||
- **Circuit Breaker per-provider** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open)
|
||||
- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks
|
||||
- **Exponential Backoff** — Progressive retry delays
|
||||
- **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms
|
||||
- **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention
|
||||
@@ -890,19 +890,19 @@ OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
|
||||
|
||||
### 🤖 Agent & Protocol Operations (v2.0)
|
||||
|
||||
| Feature | What It Does |
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| 🔧 **MCP Server (16 tools)** | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`) |
|
||||
| 🤝 **A2A Server (JSON-RPC + SSE)** | Agent-to-agent task execution with sync and streaming flows |
|
||||
| 🧭 **Consolidated Endpoints Page** | Tabbed management page with Endpoint Proxy, MCP, A2A, and API Endpoints tabs |
|
||||
| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) |
|
||||
| 🛰️ **MCP Runtime Heartbeat** | Real process status (pid, uptime, heartbeat age, transport, scope mode) |
|
||||
| 📋 **MCP Audit Trail** | Filterable audit logs with success/failure and key attribution |
|
||||
| 🔐 **MCP Scope Enforcement** | 9 granular scope permissions for controlled tool access |
|
||||
| 📡 **A2A Task Lifecycle Management** | List/filter tasks, inspect events/artifacts, cancel running tasks |
|
||||
| 📋 **Agent Card Discovery** | `/.well-known/agent.json` for client auto-discovery |
|
||||
| 🧪 **Protocol E2E Test Harness** | Real MCP SDK + A2A client flows in `test:protocols:e2e` |
|
||||
| ⚙️ **Operational Controls** | Switch combo, apply resilience profiles, reset breakers from one control surface |
|
||||
| Feature | What It Does |
|
||||
| ------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 🔧 **MCP Server (16 tools)** | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`) |
|
||||
| 🤝 **A2A Server (JSON-RPC + SSE)** | Agent-to-agent task execution with sync and streaming flows |
|
||||
| 🧭 **Consolidated Endpoints Page** | Tabbed management page with Endpoint Proxy, MCP, A2A, and API Endpoints tabs |
|
||||
| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) |
|
||||
| 🛰️ **MCP Runtime Heartbeat** | Real process status (pid, uptime, heartbeat age, transport, scope mode) |
|
||||
| 📋 **MCP Audit Trail** | Filterable audit logs with success/failure and key attribution |
|
||||
| 🔐 **MCP Scope Enforcement** | 9 granular scope permissions for controlled tool access |
|
||||
| 📡 **A2A Task Lifecycle Management** | List/filter tasks, inspect events/artifacts, cancel running tasks |
|
||||
| 📋 **Agent Card Discovery** | `/.well-known/agent.json` for client auto-discovery |
|
||||
| 🧪 **Protocol E2E Test Harness** | Real MCP SDK + A2A client flows in `test:protocols:e2e` |
|
||||
| ⚙️ **Operational Controls** | Switch combo, apply resilience profiles, reset breakers from one control surface |
|
||||
|
||||
### 🧠 Routing & Intelligence
|
||||
|
||||
@@ -938,7 +938,8 @@ OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
|
||||
|
||||
| Feature | What It Does |
|
||||
| ----------------------------------- | ---------------------------------------------------------- |
|
||||
| 🔌 **Circuit Breakers** | Provider-level trip/recover with threshold controls |
|
||||
| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events |
|
||||
| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers |
|
||||
| ⚡ **Request Idempotency** | Duplicate protection window |
|
||||
|
||||
+9
-8
@@ -1495,14 +1495,15 @@ Jika anda ingin mendapatkan credenciais próprias agora, ada kemungkinan penggun
|
||||
|
||||
OmniRoute mempunyai **210+ ciri yang dirancang** merentas berbilang fasa pembangunan. Berikut adalah bidang utama:
|
||||
|
||||
| Kategori | Ciri Terancang | Sorotan |
|
||||
| ------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| 🧠 **Penghalaan & Perisikan** | 25+ | Penghalaan kependaman terendah, penghalaan berasaskan teg, kuota prapenerbangan, pemilihan akaun P2C |
|
||||
| 🔒 **Keselamatan & Pematuhan** | 20+ | Pengerasan SSRF, penyelubungan kelayakan, had kadar setiap titik akhir, skop kunci pengurusan |
|
||||
| 📊 **Kebolehlihatan** | 15+ | Penyepaduan OpenTelemetry, pemantauan kuota masa nyata, penjejakan kos setiap model |
|
||||
| 🔄 **Integrasi Pembekal** | 20+ | Pendaftaran model dinamik, penyejukan pembekal, Codex berbilang akaun, penghuraian kuota Copilot |
|
||||
| ⚡ **Prestasi** | 15+ | Lapisan cache dwi, cache gesaan, cache respons, penstriman keepalive, API kelompok |
|
||||
| 🌐 **Ekosistem** | 10+ | API WebSocket, konfigurasi hot-reload, kedai konfigurasi teragih, mod komersial |
|
||||
| Kategori | Ciri Terancang | Sorotan |
|
||||
| ------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🧠 **Penghalaan & Perisikan** | 25+ | Penghalaan kependaman terendah, penghalaan berasaskan teg, kuota prapenerbangan, pemilihan akaun P2C |
|
||||
| 🔒 **Keselamatan & Pematuhan** | 20+ | Pengerasan SSRF, penyelubungan kelayakan, had kadar setiap titik akhir, skop kunci pengurusan |
|
||||
| 📊 **Kebolehlihatan** | 15+ | Penyepaduan OpenTelemetry, pemantauan kuota masa nyata, penjejakan kos setiap model |
|
||||
| 🔄 **Integrasi Pembekal** | 20+ | Pendaftaran model dinamik, penyejukan pembekal, Codex berbilang akaun, penghuraian kuota Copilot |
|
||||
| ⚡ **Prestasi** | 15+ | Lapisan cache dwi, cache gesaan, cache respons, penstriman keepalive, API kelompok |
|
||||
| 🌐 **Ekosistem** | 10+ | API WebSocket, konfigurasi hot-reload, kedai konfigurasi teragih, mod komersial |
|
||||
|
||||
### 🔜 Akan Datang
|
||||
|
||||
|
||||
+9
-8
@@ -1495,14 +1495,15 @@ Als u geen geloofwaardige geloofwaardigheid meer heeft, is het mogelijk om de st
|
||||
|
||||
OmniRoute heeft **210+ functies gepland** over meerdere ontwikkelingsfasen. Dit zijn de belangrijkste gebieden:
|
||||
|
||||
| Categorie | Geplande functies | Hoogtepunten |
|
||||
| ------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| 🧠 **Routing en intelligentie** | 25+ | Routering met de laagste latentie, op tags gebaseerde routering, quota-preflight, P2C-accountselectie |
|
||||
| 🔒 **Beveiliging en naleving** | 20+ | SSRF-verharding, cloaking van inloggegevens, snelheidslimiet per eindpunt, scoping van beheersleutels |
|
||||
| 📊 **Waarneembaarheid** | 15+ | OpenTelemetry-integratie, realtime quotabewaking, kostenregistratie per model |
|
||||
| 🔄 **Provider-integraties** | 20+ | Dynamisch modelregister, cooldowns van providers, Codex met meerdere accounts, parseren van Copilot-quota |
|
||||
| ⚡ **Prestaties** | 15+ | Dubbele cachelaag, promptcache, responscache, streaming keepalive, batch-API |
|
||||
| 🌐 **Ecosysteem** | 10+ | WebSocket API, configuratie hot-reload, gedistribueerde configuratieopslag, commerciële modus |
|
||||
| Categorie | Geplande functies | Hoogtepunten |
|
||||
| ------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🧠 **Routing en intelligentie** | 25+ | Routering met de laagste latentie, op tags gebaseerde routering, quota-preflight, P2C-accountselectie |
|
||||
| 🔒 **Beveiliging en naleving** | 20+ | SSRF-verharding, cloaking van inloggegevens, snelheidslimiet per eindpunt, scoping van beheersleutels |
|
||||
| 📊 **Waarneembaarheid** | 15+ | OpenTelemetry-integratie, realtime quotabewaking, kostenregistratie per model |
|
||||
| 🔄 **Provider-integraties** | 20+ | Dynamisch modelregister, cooldowns van providers, Codex met meerdere accounts, parseren van Copilot-quota |
|
||||
| ⚡ **Prestaties** | 15+ | Dubbele cachelaag, promptcache, responscache, streaming keepalive, batch-API |
|
||||
| 🌐 **Ecosysteem** | 10+ | WebSocket API, configuratie hot-reload, gedistribueerde configuratieopslag, commerciële modus |
|
||||
|
||||
### 🔜 Binnenkort beschikbaar
|
||||
|
||||
|
||||
@@ -864,6 +864,7 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
| Funksjon | Hva det gjør |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| 🔌 **Circuit Breaker** | Automatisk åpning/lukking per leverandør med konfigurerbare terskler |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🛡️ **Anti-tordenflokk** | Mutex + semaforhastighetsgrense for API-nøkkelleverandører |
|
||||
| 🧠 **Semantisk cache** | To-lags cache (signatur + semantisk) reduserer kostnader og ventetid |
|
||||
| ⚡ **Be om idempotens** | 5s dedup-vindu for dupliserte forespørsler |
|
||||
|
||||
+2
-1
@@ -289,7 +289,7 @@ Ang mga tagapagbigay ng AI ay maaaring maging hindi matatag, magbalik ng 5xx na
|
||||
|
||||
**Paano ito niresolba ng OmniRoute:**
|
||||
|
||||
- **Circuit Breaker per-provider** — Awtomatikong buksan/sarado na may mga na-configure na threshold at cooldown (Sarado/Bukas/Kalahating Bukas)
|
||||
- **Circuit Breaker per-model** — Awtomatikong buksan/sarado na may mga na-configure na threshold at cooldown (Sarado/Bukas/Kalahating Bukas)
|
||||
- **Exponential Backoff** — Progressive retry delays
|
||||
- **Anti-Thundering Herd** — Mutex + semaphore na proteksyon laban sa kasabay na muling pagsubok na mga bagyo
|
||||
- **Combo Fallback Chains** — Kung nabigo ang pangunahing provider, awtomatikong mahuhulog sa chain nang walang interbensyon
|
||||
@@ -864,6 +864,7 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
| Tampok | Ano ang Ginagawa Nito |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| 🔌 **Circuit Breaker** | Awtomatikong buksan/isara ang bawat provider na may mga na-configure na threshold |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore rate-limit para sa mga API key provider |
|
||||
| 🧠 **Semantic Cache** | Binabawasan ng two-tier na cache (pirma + semantiko) ang gastos at latency |
|
||||
| ⚡ **Humiling ng Idempotency** | 5s dedup window para sa mga duplicate na kahilingan |
|
||||
|
||||
+9
-8
@@ -1495,14 +1495,15 @@ Jeśli chcesz uzyskać dostęp do **podręcznika URL**:
|
||||
|
||||
OmniRoute ma **ponad 210 funkcji zaplanowanych** w wielu fazach rozwoju. Oto kluczowe obszary:
|
||||
|
||||
| Kategoria | Planowane funkcje | Najważniejsze |
|
||||
| -------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🧠 **Routing i inteligencja** | 25+ | Routing z najmniejszym opóźnieniem, routing oparty na tagach, wstępna inspekcja przydziału, wybór konta P2C |
|
||||
| 🔒 **Bezpieczeństwo i zgodność** | 20+ | Wzmocnienie SSRF, maskowanie poświadczeń, limit szybkości na punkt końcowy, zakres kluczy zarządzania |
|
||||
| 📊 **Obserwowalność** | 15+ | Integracja OpenTelemetry, monitorowanie kwot w czasie rzeczywistym, śledzenie kosztów według modelu |
|
||||
| 🔄 **Integracja dostawców** | 20+ | Rejestr modeli dynamicznych, czasy odnowienia dostawcy, Kodeks dla wielu kont, analiza przydziału Copilot |
|
||||
| ⚡ **Wydajność** | 15+ | Podwójna warstwa pamięci podręcznej, pamięć podręczna podpowiedzi, pamięć podręczna odpowiedzi, utrzymywanie transmisji strumieniowej, wsadowe API |
|
||||
| 🌐 **Ekosystem** | 10+ | WebSocket API, ładowanie konfiguracji na gorąco, rozproszony magazyn konfiguracji, tryb komercyjny |
|
||||
| Kategoria | Planowane funkcje | Najważniejsze |
|
||||
| -------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🧠 **Routing i inteligencja** | 25+ | Routing z najmniejszym opóźnieniem, routing oparty na tagach, wstępna inspekcja przydziału, wybór konta P2C |
|
||||
| 🔒 **Bezpieczeństwo i zgodność** | 20+ | Wzmocnienie SSRF, maskowanie poświadczeń, limit szybkości na punkt końcowy, zakres kluczy zarządzania |
|
||||
| 📊 **Obserwowalność** | 15+ | Integracja OpenTelemetry, monitorowanie kwot w czasie rzeczywistym, śledzenie kosztów według modelu |
|
||||
| 🔄 **Integracja dostawców** | 20+ | Rejestr modeli dynamicznych, czasy odnowienia dostawcy, Kodeks dla wielu kont, analiza przydziału Copilot |
|
||||
| ⚡ **Wydajność** | 15+ | Podwójna warstwa pamięci podręcznej, pamięć podręczna podpowiedzi, pamięć podręczna odpowiedzi, utrzymywanie transmisji strumieniowej, wsadowe API |
|
||||
| 🌐 **Ekosystem** | 10+ | WebSocket API, ładowanie konfiguracji na gorąco, rozproszony magazyn konfiguracji, tryb komercyjny |
|
||||
|
||||
### 🔜 Już wkrótce
|
||||
|
||||
|
||||
+3
-2
@@ -289,7 +289,7 @@ Provedores de IA podem ficar instáveis, retornar erro 5xx ou atingir limites te
|
||||
|
||||
**Como o OmniRoute resolve isso:**
|
||||
|
||||
- **Circuit Breaker por provedor** — Abre/fecha automaticamente com limiares e cooldown configuráveis (Closed/Open/Half-Open)
|
||||
- **Circuit Breaker por modelo** — Abre/fecha automaticamente com limiares e cooldown configuráveis (Closed/Open/Half-Open)
|
||||
- **Exponential Backoff** — Atrasos progressivos de retry
|
||||
- **Anti-Thundering Herd** — Proteção com mutex + semáforo contra tempestade de retries concorrentes
|
||||
- **Cadeias de Fallback em Combo** — Se o primário falhar, avança automaticamente na cadeia sem intervenção
|
||||
@@ -871,7 +871,8 @@ Por que isso é relevante:
|
||||
|
||||
| Funcionalidade | O que Faz |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| 🔌 **Circuit Breaker** | Auto-abertura/fechamento por provedor com limites configuráveis |
|
||||
| 🔌 **Circuit Breaker** | Trip/recover por modelo com limites configuráveis |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🛡️ **Anti-Thundering Herd** | Mutex + semáforo rate-limit para provedores com API key |
|
||||
| 🧠 **Cache Semântico** | Cache de duas camadas (assinatura + semântico) reduz custo e latência |
|
||||
| ⚡ **Idempotência de Requisição** | Janela de dedup de 5s para requisições duplicadas |
|
||||
|
||||
@@ -294,6 +294,7 @@ Os provedores de IA podem ficar instáveis, retornar erros 5xx ou atingir limite
|
||||
- **Rebanho Anti-Trovão** — Proteção Mutex + semáforo contra tempestades de novas tentativas simultâneas
|
||||
- **Combo Fallback Chains** — Se o provedor primário falhar, ele cairá automaticamente na cadeia sem intervenção
|
||||
- **Combo Circuit Breaker** — Desativa automaticamente provedores com falha em uma cadeia de combinação
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Health Dashboard** — Monitoramento de tempo de atividade, estados de disjuntores, bloqueios, estatísticas de cache, latência p50/p95/p99
|
||||
|
||||
</details>
|
||||
|
||||
@@ -290,10 +290,12 @@ Furnizorii de AI pot deveni instabili, pot returna erori 5xx sau pot atinge limi
|
||||
**Cum o rezolvă OmniRoute:**
|
||||
|
||||
- **Circuit Breaker per furnizor** - Deschidere/închidere automată cu praguri configurabile și răcire (Închis/Deschis/Pe jumătate deschis)
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Backoff exponențial** — Întârzieri progresive ale reîncercării
|
||||
- **Anti-Thundering Herd** — Mutex + protecție semafor împotriva furtunilor concurente de reîncercare
|
||||
- **Combo Fallback Chains** — Dacă furnizorul principal eșuează, trece automat prin lanț fără nicio intervenție
|
||||
- **Combo Circuit Breaker** — Dezactivează automat furnizorii care eșuează dintr-un lanț combinat
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Tabloul de bord pentru sănătate** — Monitorizare timp de funcționare, stări întrerupătoare de circuit, blocări, statistici cache, latență p50/p95/p99
|
||||
|
||||
</details>
|
||||
|
||||
@@ -864,6 +864,7 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
| Функция | Что делает |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 🔌 **Circuit Breaker** | Авто-открытие/закрытие по провайдеру с настраиваемыми порогами |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🛡️ **Anti-Thundering Herd** | Mutex + семафор для API key провайдеров |
|
||||
| 🧠 **Семантический кеш** | Двухуровневый кеш (сигнатура + семантика) снижает стоимость |
|
||||
| ⚡ **Идемпотентность запросов** | 5с окно дедупликации для дублирующихся запросов |
|
||||
|
||||
@@ -295,6 +295,7 @@ Poskytovatelia AI sa môžu stať nestabilnými, vrátiť chyby 5xx alebo dosiah
|
||||
- **Anti-Thundering Herd** - ochrana Mutex + semafor proti súbežným opakovaným búrkam
|
||||
- **Combo Fallback Chains** – Ak primárny poskytovateľ zlyhá, automaticky prepadne reťazcom bez akéhokoľvek zásahu
|
||||
- **Combo Circuit Breaker** – Automaticky deaktivuje zlyhávajúcich poskytovateľov v rámci kombinovaného reťazca
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
– **Health Dashboard** – Monitorovanie dostupnosti, stavy ističov, blokovania, štatistiky vyrovnávacej pamäte, latencia p50/p95/p99
|
||||
|
||||
</details>
|
||||
|
||||
@@ -864,6 +864,7 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
| Funktion | Vad det gör |
|
||||
| -------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| 🔌 **Circuit Breaker** | Autoöppna/stäng per leverantör med konfigurerbara trösklar |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| 🛡️ **Anti-ånflock** | Mutex + semaforhastighetsgräns för API-nyckelleverantörer |
|
||||
| 🧠 **Semantisk cache** | Tvåskiktscache (signatur + semantisk) minskar kostnaden och fördröjningen |
|
||||
| ⚡ **Begär idempotens** | 5s dedup-fönster för dubblettförfrågningar |
|
||||
|
||||
@@ -294,6 +294,7 @@ OpenAI ใช้รูปแบบหนึ่ง Claude (Anthropic) ใช้
|
||||
- **Anti-Thundering Herd** — Mutex + การป้องกันเซมาฟอร์จากพายุที่ลองใหม่พร้อมกัน
|
||||
- **Combo Fallback Chains** — หากผู้ให้บริการหลักล้มเหลว จะตกผ่านห่วงโซ่โดยอัตโนมัติโดยไม่มีการแทรกแซง
|
||||
- **Combo Circuit Breaker** — ปิดการใช้งานผู้ให้บริการที่ล้มเหลวภายในคอมโบเชนโดยอัตโนมัติ
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **แดชบอร์ดสุขภาพ** — การตรวจสอบสถานะการออนไลน์ สถานะของเซอร์กิตเบรกเกอร์ การล็อก สถิติแคช เวลาแฝง p50/p95/p99
|
||||
|
||||
</details>
|
||||
|
||||
@@ -294,6 +294,7 @@ OpenAI використовує один формат, Claude (Anthropic) вик
|
||||
- **Anti-Thundering Herd** — Mutex + захист семафора від одночасних повторних штормів
|
||||
- **Комбіновані запасні ланцюги** — якщо основний постачальник виходить з ладу, автоматично проходить через ланцюжок без втручання.
|
||||
- **Combo Circuit Breaker** — автоматично вимикає несправні постачальники в комбінованому ланцюжку
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Health Dashboard** — Моніторинг безвідмовної роботи, стани автоматичного вимикача, блокування, статистика кешу, затримка p50/p95/p99
|
||||
|
||||
</details>
|
||||
|
||||
@@ -294,6 +294,7 @@ Các nhà cung cấp AI có thể trở nên không ổn định, trả về l
|
||||
- **Bầy chống sấm sét** — Mutex + bảo vệ semaphore chống lại các cơn bão thử lại đồng thời
|
||||
- **Chuỗi dự phòng kết hợp** — Nếu nhà cung cấp chính không thành công, nó sẽ tự động rơi qua chuỗi mà không cần can thiệp
|
||||
- **Combo Circuit Breaker** — Tự động vô hiệu hóa các nhà cung cấp bị lỗi trong chuỗi kết hợp
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
- **Bảng thông tin sức khỏe** — Giám sát thời gian hoạt động, trạng thái ngắt mạch, khóa, số liệu thống kê bộ nhớ đệm, độ trễ p50/p95/p99
|
||||
|
||||
</details>
|
||||
|
||||
+10
-9
@@ -1348,15 +1348,16 @@ codex "your prompt"
|
||||
|
||||
## 📖 文档
|
||||
|
||||
| 文档 | 描述 |
|
||||
| ----------------------------------- | ---------------------------- |
|
||||
| [用户指南](docs/USER_GUIDE.md) | 提供商、组合、CLI 集成、部署 |
|
||||
| [API 参考](docs/API_REFERENCE.md) | 所有端点及示例 |
|
||||
| [故障排除](docs/TROUBLESHOOTING.md) | 常见问题和解决方案 |
|
||||
| [架构](docs/ARCHITECTURE.md) | 系统架构和内部机制 |
|
||||
| [贡献指南](CONTRIBUTING.md) | 开发设置和指南 |
|
||||
| [OpenAPI 规范](docs/openapi.yaml) | OpenAPI 3.0 规范 |
|
||||
| [安全策略](SECURITY.md) | 漏洞报告和安全实践 |
|
||||
| 文档 | 描述 |
|
||||
| ----------------------------------- | ------------------------------------------------------ |
|
||||
| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
|
||||
| [用户指南](docs/USER_GUIDE.md) | 提供商、组合、CLI 集成、部署 |
|
||||
| [API 参考](docs/API_REFERENCE.md) | 所有端点及示例 |
|
||||
| [故障排除](docs/TROUBLESHOOTING.md) | 常见问题和解决方案 |
|
||||
| [架构](docs/ARCHITECTURE.md) | 系统架构和内部机制 |
|
||||
| [贡献指南](CONTRIBUTING.md) | 开发设置和指南 |
|
||||
| [OpenAPI 规范](docs/openapi.yaml) | OpenAPI 3.0 规范 |
|
||||
| [安全策略](SECURITY.md) | 漏洞报告和安全实践 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,10 +26,12 @@ const {
|
||||
nativeImage,
|
||||
shell,
|
||||
session,
|
||||
Notification,
|
||||
} = require("electron");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
|
||||
// ── Single Instance Lock ───────────────────────────────────
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
@@ -62,6 +64,11 @@ let serverPort = 20128;
|
||||
|
||||
const getServerUrl = () => `http://localhost:${serverPort}`;
|
||||
|
||||
// ── Auto-Updater Configuration ──────────────────────────────
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.logger = console;
|
||||
|
||||
// ── Helper: Send IPC event to renderer (#5) ────────────────
|
||||
function sendToRenderer(channel, data) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
@@ -103,6 +110,77 @@ async function waitForServerExit(proc, timeoutMs = 5000) {
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Auto-Updater Event Handlers ─────────────────────────────
|
||||
function setupAutoUpdater() {
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
sendToRenderer("update-status", { status: "checking" });
|
||||
console.log("[Electron] Checking for updates...");
|
||||
});
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
sendToRenderer("update-status", { status: "available", version: info.version });
|
||||
console.log("[Electron] Update available:", info.version);
|
||||
});
|
||||
|
||||
autoUpdater.on("update-not-available", (info) => {
|
||||
sendToRenderer("update-status", { status: "not-available", version: info.version });
|
||||
console.log("[Electron] No update available");
|
||||
});
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
sendToRenderer("update-status", {
|
||||
status: "downloading",
|
||||
percent: Math.round(progress.percent),
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
sendToRenderer("update-status", { status: "downloaded", version: info.version });
|
||||
console.log("[Electron] Update downloaded:", info.version);
|
||||
|
||||
if (Notification.isSupported()) {
|
||||
const notification = new Notification({
|
||||
title: "OmniRoute Update Ready",
|
||||
body: `Version ${info.version} is ready to install. Click to restart.`,
|
||||
});
|
||||
notification.on("click", () => {
|
||||
autoUpdater.quitAndInstall();
|
||||
});
|
||||
notification.show();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (error) => {
|
||||
sendToRenderer("update-status", { status: "error", message: error.message });
|
||||
console.error("[Electron] Update error:", error);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkForUpdates(silent = false) {
|
||||
if (isDev) {
|
||||
console.log("[Electron] Dev mode — skipping auto-update");
|
||||
if (!silent) {
|
||||
sendToRenderer("update-status", { status: "error", message: "Updates disabled in dev mode" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
await autoUpdater.checkForUpdates();
|
||||
}
|
||||
|
||||
async function downloadUpdate() {
|
||||
await autoUpdater.downloadUpdate();
|
||||
}
|
||||
|
||||
function installUpdate() {
|
||||
if (nextServer) {
|
||||
nextServer.kill("SIGTERM");
|
||||
nextServer = null;
|
||||
}
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
|
||||
// ── Content Security Policy (#15) ──────────────────────────
|
||||
function setupContentSecurityPolicy() {
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
@@ -236,6 +314,11 @@ function createTray() {
|
||||
],
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Check for Updates",
|
||||
click: () => checkForUpdates(false),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
@@ -391,6 +474,36 @@ function setupIpcHandlers() {
|
||||
});
|
||||
|
||||
ipcMain.on("window-close", () => mainWindow?.close());
|
||||
|
||||
// Auto-update IPC handlers
|
||||
ipcMain.handle("check-for-updates", async () => {
|
||||
try {
|
||||
await checkForUpdates(false);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("[Electron] Check for updates failed:", error);
|
||||
sendToRenderer("update-status", { status: "error", message: error.message });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("download-update", async () => {
|
||||
try {
|
||||
await downloadUpdate();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("[Electron] Download update failed:", error);
|
||||
sendToRenderer("update-status", { status: "error", message: error.message });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("install-update", () => {
|
||||
installUpdate();
|
||||
// No return value — app will quit and restart
|
||||
});
|
||||
|
||||
ipcMain.handle("get-app-version", () => app.getVersion());
|
||||
}
|
||||
|
||||
// ── App Lifecycle ──────────────────────────────────────────
|
||||
@@ -407,6 +520,14 @@ app.whenReady().then(async () => {
|
||||
createWindow();
|
||||
createTray();
|
||||
setupIpcHandlers();
|
||||
setupAutoUpdater();
|
||||
|
||||
// Check for updates after a short delay (don't block startup)
|
||||
if (!isDev) {
|
||||
setTimeout(() => {
|
||||
checkForUpdates(true);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// macOS: recreate window when dock icon clicked
|
||||
app.on("activate", () => {
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"build:linux": "electron-builder --linux",
|
||||
"pack": "electron-builder --dir"
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^40.6.1",
|
||||
"electron-builder": "^25.1.8"
|
||||
@@ -28,6 +30,11 @@
|
||||
"output": "dist-electron",
|
||||
"buildResources": "assets"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "diegosouzapw",
|
||||
"repo": "OmniRoute"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
|
||||
+18
-2
@@ -13,9 +13,18 @@ const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
// ── Channel Whitelist ──────────────────────────────────────
|
||||
const VALID_CHANNELS = {
|
||||
invoke: ["get-app-info", "open-external", "get-data-dir", "restart-server"],
|
||||
invoke: [
|
||||
"get-app-info",
|
||||
"open-external",
|
||||
"get-data-dir",
|
||||
"restart-server",
|
||||
"check-for-updates",
|
||||
"download-update",
|
||||
"install-update",
|
||||
"get-app-version",
|
||||
],
|
||||
send: ["window-minimize", "window-maximize", "window-close"],
|
||||
receive: ["server-status", "port-changed"],
|
||||
receive: ["server-status", "port-changed", "update-status"],
|
||||
};
|
||||
|
||||
// ── Fix #16: Generic IPC wrappers ──────────────────────────
|
||||
@@ -48,6 +57,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
openExternal: (url) => safeInvoke("open-external", url),
|
||||
getDataDir: () => safeInvoke("get-data-dir"),
|
||||
restartServer: () => safeInvoke("restart-server"),
|
||||
getAppVersion: () => safeInvoke("get-app-version"),
|
||||
|
||||
// ── Auto-Update ──────────────────────────────────────────
|
||||
checkForUpdates: () => safeInvoke("check-for-updates"),
|
||||
downloadUpdate: () => safeInvoke("download-update"),
|
||||
installUpdate: () => safeInvoke("install-update"),
|
||||
|
||||
// ── Send (fire-and-forget) ───────────────────────────────
|
||||
minimizeWindow: () => safeSend("window-minimize"),
|
||||
@@ -58,6 +73,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// Fix #6: Returns a disposer function for precise cleanup
|
||||
onServerStatus: (callback) => safeOn("server-status", callback),
|
||||
onPortChanged: (callback) => safeOn("port-changed", callback),
|
||||
onUpdateStatus: (callback) => safeOn("update-status", callback),
|
||||
|
||||
// ── Static Properties ────────────────────────────────────
|
||||
isElectron: true,
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
const nextConfig = {
|
||||
turbopack: {},
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["better-sqlite3"],
|
||||
serverExternalPackages: ["better-sqlite3", "zod"],
|
||||
transpilePackages: ["@omniroute/open-sse"],
|
||||
allowedDevOrigins: ["192.168.*"],
|
||||
typescript: {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* CLI Fingerprint Definitions
|
||||
*
|
||||
* Defines per-provider "fingerprints" that control the exact ordering of HTTP headers
|
||||
* and JSON body fields to match the native CLI tools exactly.
|
||||
*
|
||||
* When `cliCompatMode` is enabled for a provider, OmniRoute reorders outgoing requests
|
||||
* to be indistinguishable from the real CLI binary, reducing account flagging risk.
|
||||
*
|
||||
* Header order and body field order were captured via mitmproxy traffic analysis.
|
||||
*/
|
||||
|
||||
export interface CliFingerprint {
|
||||
/** Ordered list of header names (case-sensitive). Unlisted headers are appended. */
|
||||
headerOrder: string[];
|
||||
/** Ordered list of top-level JSON body fields. Unlisted fields are appended. */
|
||||
bodyFieldOrder: string[];
|
||||
/** User-Agent string to inject (overrides default) */
|
||||
userAgent?: string;
|
||||
/** Extra headers to add */
|
||||
extraHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprint registry - keyed by provider alias (lowercase).
|
||||
* Based on mitmproxy traffic captures from native CLI tools.
|
||||
*/
|
||||
export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
|
||||
codex: {
|
||||
headerOrder: [
|
||||
"Host",
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"Accept",
|
||||
"User-Agent",
|
||||
"Accept-Encoding",
|
||||
],
|
||||
bodyFieldOrder: [
|
||||
"model",
|
||||
"messages",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"max_tokens",
|
||||
"stream",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"response_format",
|
||||
"n",
|
||||
"stop",
|
||||
],
|
||||
userAgent: "codex-cli",
|
||||
},
|
||||
claude: {
|
||||
headerOrder: [
|
||||
"Host",
|
||||
"Content-Type",
|
||||
"x-api-key",
|
||||
"anthropic-version",
|
||||
"Accept",
|
||||
"User-Agent",
|
||||
"Accept-Encoding",
|
||||
],
|
||||
bodyFieldOrder: [
|
||||
"model",
|
||||
"max_tokens",
|
||||
"messages",
|
||||
"system",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"stream",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"metadata",
|
||||
],
|
||||
userAgent: "claude-code",
|
||||
},
|
||||
github: {
|
||||
headerOrder: [
|
||||
"Host",
|
||||
"Authorization",
|
||||
"X-Request-Id",
|
||||
"Vscode-Sessionid",
|
||||
"Vscode-Machineid",
|
||||
"Editor-Version",
|
||||
"Editor-Plugin-Version",
|
||||
"Copilot-Integration-Id",
|
||||
"Openai-Organization",
|
||||
"Openai-Intent",
|
||||
"Content-Type",
|
||||
"User-Agent",
|
||||
"Accept",
|
||||
"Accept-Encoding",
|
||||
],
|
||||
bodyFieldOrder: [
|
||||
"messages",
|
||||
"model",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"max_tokens",
|
||||
"n",
|
||||
"stream",
|
||||
"intent",
|
||||
"intent_threshold",
|
||||
"intent_content",
|
||||
],
|
||||
userAgent: "GitHubCopilotChat",
|
||||
},
|
||||
antigravity: {
|
||||
headerOrder: [
|
||||
"Host",
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"Accept",
|
||||
"Accept-Encoding",
|
||||
],
|
||||
bodyFieldOrder: ["project", "model", "userAgent", "requestType", "requestId", "request"],
|
||||
userAgent: "antigravity",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Reorder an object's keys according to a specified order.
|
||||
* Keys not in the order list are appended at the end in their original order.
|
||||
*/
|
||||
export function orderFields<T extends Record<string, unknown>>(obj: T, fieldOrder: string[]): T {
|
||||
if (!fieldOrder?.length || !obj || typeof obj !== "object") return obj;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
const remaining = new Set(Object.keys(obj));
|
||||
|
||||
// First, add fields in the specified order
|
||||
for (const key of fieldOrder) {
|
||||
if (key in obj) {
|
||||
result[key] = obj[key];
|
||||
remaining.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Then append remaining fields in original order
|
||||
for (const key of remaining) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder HTTP headers according to a fingerprint.
|
||||
* Returns a new object with headers in the specified order.
|
||||
*/
|
||||
export function orderHeaders(
|
||||
headers: Record<string, string>,
|
||||
headerOrder: string[]
|
||||
): Record<string, string> {
|
||||
if (!headerOrder?.length || !headers) return headers;
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
const remaining = new Map<string, string>();
|
||||
|
||||
// Build case-insensitive lookup
|
||||
const headerMap = new Map<string, [string, string]>();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
headerMap.set(key.toLowerCase(), [key, value]);
|
||||
}
|
||||
|
||||
// Add ordered headers first
|
||||
for (const orderedKey of headerOrder) {
|
||||
const entry = headerMap.get(orderedKey.toLowerCase());
|
||||
if (entry) {
|
||||
result[entry[0]] = entry[1];
|
||||
headerMap.delete(orderedKey.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining headers
|
||||
for (const [, [key, value]] of headerMap) {
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a CLI fingerprint to headers and body.
|
||||
* Returns { headers, bodyString } with the correct ordering.
|
||||
*/
|
||||
export function applyFingerprint(
|
||||
provider: string,
|
||||
headers: Record<string, string>,
|
||||
body: unknown
|
||||
): { headers: Record<string, string>; bodyString: string } {
|
||||
const fingerprint = CLI_FINGERPRINTS[provider?.toLowerCase()];
|
||||
|
||||
if (!fingerprint) {
|
||||
return { headers, bodyString: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
// Apply user agent override
|
||||
if (fingerprint.userAgent) {
|
||||
headers["User-Agent"] = fingerprint.userAgent;
|
||||
}
|
||||
|
||||
// Apply extra headers
|
||||
if (fingerprint.extraHeaders) {
|
||||
Object.assign(headers, fingerprint.extraHeaders);
|
||||
}
|
||||
|
||||
// Reorder headers
|
||||
const orderedHeaders = orderHeaders(headers, fingerprint.headerOrder);
|
||||
|
||||
// Reorder body fields
|
||||
const orderedBody =
|
||||
body && typeof body === "object" && !Array.isArray(body)
|
||||
? orderFields(body as Record<string, unknown>, fingerprint.bodyFieldOrder)
|
||||
: body;
|
||||
|
||||
return {
|
||||
headers: orderedHeaders,
|
||||
bodyString: JSON.stringify(orderedBody),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime cache for CLI compat providers set via Settings UI.
|
||||
* Updated by the settings API when users toggle providers.
|
||||
*/
|
||||
let _cliCompatProviders: Set<string> = new Set();
|
||||
|
||||
/**
|
||||
* Update the runtime cache of CLI-compat-enabled providers.
|
||||
* Called from the settings API when cliCompatProviders is updated.
|
||||
*/
|
||||
export function setCliCompatProviders(providers: string[]): void {
|
||||
_cliCompatProviders = new Set((providers || []).map((p) => p.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current list of CLI-compat-enabled providers.
|
||||
*/
|
||||
export function getCliCompatProviders(): string[] {
|
||||
return Array.from(_cliCompatProviders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI compatibility mode is enabled for a provider.
|
||||
* Reads from: 1) Runtime cache (Settings UI), 2) Environment variables.
|
||||
*/
|
||||
export function isCliCompatEnabled(provider: string): boolean {
|
||||
const key = provider?.toLowerCase().replace(/[^a-z0-9]/g, "_");
|
||||
|
||||
// 1. Check runtime cache (set via Settings UI)
|
||||
if (_cliCompatProviders.has(provider?.toLowerCase())) return true;
|
||||
|
||||
// 2. Check environment variable: CLI_COMPAT_<PROVIDER>=1
|
||||
const envKey = `CLI_COMPAT_${key?.toUpperCase()}`;
|
||||
if (process.env[envKey] === "1" || process.env[envKey] === "true") return true;
|
||||
|
||||
// 3. Global enable: CLI_COMPAT_ALL=1
|
||||
if (process.env.CLI_COMPAT_ALL === "1" || process.env.CLI_COMPAT_ALL === "true") return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -192,10 +193,20 @@ export class BaseExecutor {
|
||||
? mergeAbortSignals(signal, timeoutSignal)
|
||||
: signal || timeoutSignal;
|
||||
|
||||
// Apply CLI fingerprint ordering if enabled for this provider
|
||||
let finalHeaders = headers;
|
||||
let bodyString = JSON.stringify(transformedBody);
|
||||
|
||||
if (isCliCompatEnabled(this.provider)) {
|
||||
const fingerprinted = applyFingerprint(this.provider, headers, transformedBody);
|
||||
finalHeaders = fingerprinted.headers;
|
||||
bodyString = fingerprinted.bodyString;
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
headers: finalHeaders,
|
||||
body: bodyString,
|
||||
};
|
||||
if (combinedSignal) fetchOptions.signal = combinedSignal;
|
||||
|
||||
|
||||
@@ -30,9 +30,23 @@ import {
|
||||
* @param {object} options.body - Request body
|
||||
* @param {object} options.credentials - Provider credentials { apiKey, accessToken }
|
||||
* @param {object} options.log - Logger
|
||||
* @param {string} [options.resolvedProvider] - Pre-resolved provider ID (from route layer custom model resolution)
|
||||
*/
|
||||
export async function handleImageGeneration({ body, credentials, log }) {
|
||||
const { provider, model } = parseImageModel(body.model);
|
||||
export async function handleImageGeneration({ body, credentials, log, resolvedProvider = null }) {
|
||||
let provider, model;
|
||||
|
||||
if (resolvedProvider) {
|
||||
// Provider was already resolved by the route layer (custom model from DB)
|
||||
// Extract model name from the full "provider/model" string
|
||||
provider = resolvedProvider;
|
||||
const modelStr = body.model || "";
|
||||
model = modelStr.startsWith(provider + "/") ? modelStr.slice(provider.length + 1) : modelStr;
|
||||
} else {
|
||||
// Standard path: resolve from built-in image registry
|
||||
const parsed = parseImageModel(body.model);
|
||||
provider = parsed.provider;
|
||||
model = parsed.model;
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
@@ -43,12 +57,42 @@ export async function handleImageGeneration({ body, credentials, log }) {
|
||||
}
|
||||
|
||||
const providerConfig = getImageProvider(provider);
|
||||
|
||||
// For custom models without a built-in provider config, use OpenAI-compatible handler
|
||||
// with a synthetic config based on the provider's credentials
|
||||
if (!providerConfig) {
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
error: `Unknown image provider: ${provider}`,
|
||||
if (!resolvedProvider) {
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
error: `Unknown image provider: ${provider}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Custom model: use OpenAI-compatible format with provider's base URL
|
||||
// The credentials were already resolved by the route layer
|
||||
if (log) {
|
||||
log.info("IMAGE", `Custom model ${provider}/${model} — using OpenAI-compatible handler`);
|
||||
}
|
||||
|
||||
const syntheticConfig = {
|
||||
id: provider,
|
||||
baseUrl:
|
||||
credentials?.baseUrl ||
|
||||
`https://generativelanguage.googleapis.com/v1beta/openai/images/generations`,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
format: "openai",
|
||||
};
|
||||
|
||||
return handleOpenAIImageGeneration({
|
||||
model,
|
||||
provider,
|
||||
providerConfig: syntheticConfig,
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
// Route to format-specific handler
|
||||
|
||||
@@ -310,7 +310,8 @@ export async function handleComboChat({
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
||||
const profile = getProviderProfile(provider);
|
||||
const breaker = getCircuitBreaker(`combo:${provider}`, {
|
||||
const breakerKey = `combo:${modelStr}`;
|
||||
const breaker = getCircuitBreaker(breakerKey, {
|
||||
failureThreshold: profile.circuitBreakerThreshold,
|
||||
resetTimeout: profile.circuitBreakerReset,
|
||||
});
|
||||
@@ -440,8 +441,7 @@ export async function handleComboChat({
|
||||
|
||||
// Early exit: check if all models have breaker OPEN
|
||||
const allBreakersOpen = orderedModels.every((m) => {
|
||||
const p = parseModel(m).provider || parseModel(m).providerAlias || "unknown";
|
||||
return !getCircuitBreaker(`combo:${p}`).canExecute();
|
||||
return !getCircuitBreaker(`combo:${m}`).canExecute();
|
||||
});
|
||||
|
||||
// All models failed
|
||||
@@ -532,7 +532,8 @@ async function handleRoundRobinCombo({
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
||||
const profile = getProviderProfile(provider);
|
||||
const breaker = getCircuitBreaker(`combo:${provider}`, {
|
||||
const breakerKey = `combo:${modelStr}`;
|
||||
const breaker = getCircuitBreaker(breakerKey, {
|
||||
failureThreshold: profile.circuitBreakerThreshold,
|
||||
resetTimeout: profile.circuitBreakerReset,
|
||||
});
|
||||
@@ -694,8 +695,7 @@ async function handleRoundRobinCombo({
|
||||
|
||||
// Early exit: check if all models have breaker OPEN
|
||||
const allBreakersOpen = orderedModels.every((m) => {
|
||||
const p = parseModel(m).provider || parseModel(m).providerAlias || "unknown";
|
||||
return !getCircuitBreaker(`combo:${p}`).canExecute();
|
||||
return !getCircuitBreaker(`combo:${m}`).canExecute();
|
||||
});
|
||||
|
||||
if (allBreakersOpen) {
|
||||
|
||||
+19
-16
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { safePercentage } from "@/shared/utils/formatting";
|
||||
|
||||
// GitHub API config
|
||||
const GITHUB_CONFIG = {
|
||||
@@ -34,6 +35,7 @@ const CLAUDE_CONFIG = {
|
||||
oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage",
|
||||
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
|
||||
settingsUrl: "https://api.anthropic.com/v1/settings",
|
||||
apiVersion: "2023-06-01",
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -469,7 +471,7 @@ async function getClaudeUsage(accessToken) {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -477,36 +479,34 @@ async function getClaudeUsage(accessToken) {
|
||||
const data = await oauthResponse.json();
|
||||
const quotas: Record<string, any> = {};
|
||||
|
||||
// utilization = percentage USED (e.g., 22 means 22% used, 78% remaining)
|
||||
// utilization = percentage REMAINING (e.g., 90 means 90% remaining, 10% used)
|
||||
const hasUtilization = (window: any) =>
|
||||
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
|
||||
|
||||
const createQuotaObject = (window: any) => {
|
||||
const used = window?.utilization ?? 0;
|
||||
const remaining = 100 - used;
|
||||
const remaining = safePercentage(window.utilization) as number;
|
||||
const used = 100 - remaining;
|
||||
return {
|
||||
used,
|
||||
total: 100,
|
||||
remaining,
|
||||
resetAt: parseResetTime(window?.resets_at),
|
||||
resetAt: parseResetTime(window.resets_at),
|
||||
remainingPercentage: remaining,
|
||||
unlimited: false,
|
||||
};
|
||||
};
|
||||
|
||||
if (data.five_hour && typeof data.five_hour === "object") {
|
||||
if (hasUtilization(data.five_hour)) {
|
||||
quotas["session (5h)"] = createQuotaObject(data.five_hour);
|
||||
}
|
||||
|
||||
if (data.seven_day && typeof data.seven_day === "object") {
|
||||
if (hasUtilization(data.seven_day)) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(data.seven_day);
|
||||
}
|
||||
|
||||
// Parse model-specific weekly windows (e.g., seven_day_sonnet, seven_day_opus)
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (
|
||||
key.startsWith("seven_day_") &&
|
||||
key !== "seven_day" &&
|
||||
value &&
|
||||
typeof value === "object"
|
||||
) {
|
||||
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(value)) {
|
||||
const modelName = key.replace("seven_day_", "");
|
||||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(value);
|
||||
}
|
||||
@@ -519,7 +519,10 @@ async function getClaudeUsage(accessToken) {
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: Try legacy settings/org endpoint (for API key users with org admin access)
|
||||
// Fallback: OAuth endpoint returned non-OK, try legacy settings/org endpoint
|
||||
console.warn(
|
||||
`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`
|
||||
);
|
||||
return await getClaudeUsageLegacy(accessToken);
|
||||
} catch (error) {
|
||||
return { message: `Claude connected. Unable to fetch usage: ${(error as any).message}` };
|
||||
@@ -536,7 +539,7 @@ async function getClaudeUsageLegacy(accessToken) {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -550,7 +553,7 @@ async function getClaudeUsageLegacy(accessToken) {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -185,15 +185,19 @@ export function prepareClaudeRequest(body, provider = null) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tools: remove all cache_control, add only to last tool with ttl 1h
|
||||
// 3. Tools: remove all cache_control, add only to last non-deferred tool with ttl 1h
|
||||
// Tools with defer_loading=true cannot have cache_control (API rejects it)
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
body.tools = body.tools.map((tool, i) => {
|
||||
body.tools = body.tools.map((tool) => {
|
||||
const { cache_control, ...rest } = tool;
|
||||
if (i === body.tools.length - 1) {
|
||||
return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } };
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
for (let i = body.tools.length - 1; i >= 0; i--) {
|
||||
if (!body.tools[i].defer_loading) {
|
||||
body.tools[i].cache_control = { type: "ephemeral", ttl: "1h" };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
|
||||
@@ -275,30 +275,11 @@ export function openaiToOpenAIResponsesRequest(
|
||||
|
||||
// Convert assistant messages
|
||||
if (role === "assistant") {
|
||||
// Add reasoning content before assistant output
|
||||
if (msg.reasoning_content) {
|
||||
input.push({
|
||||
type: "reasoning",
|
||||
id: `reasoning_${input.length}`,
|
||||
summary: [{ type: "summary_text", text: toString(msg.reasoning_content) }],
|
||||
});
|
||||
}
|
||||
// Skip reasoning_content — OpenAI Responses API requires server-generated
|
||||
// rs_* IDs for reasoning items. Synthesizing client-side IDs (e.g. reasoning_N)
|
||||
// causes 400 errors from Responses-compatible upstreams. (#224)
|
||||
|
||||
// Handle thinking blocks in array content
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const blockValue of msg.content) {
|
||||
const block = toRecord(blockValue);
|
||||
if (block.type === "thinking" || block.type === "redacted_thinking") {
|
||||
input.push({
|
||||
type: "reasoning",
|
||||
id: `reasoning_${input.length}`,
|
||||
summary: [
|
||||
{ type: "summary_text", text: toString(block.thinking || block.data, "...") },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skip thinking blocks in array content — same rs_* ID constraint applies
|
||||
|
||||
// Build assistant output content
|
||||
const outputContent: unknown[] = [];
|
||||
|
||||
@@ -175,8 +175,13 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||
};
|
||||
});
|
||||
|
||||
if (result.tools.length > 0) {
|
||||
result.tools[result.tools.length - 1].cache_control = { type: "ephemeral", ttl: "1h" };
|
||||
// Add cache_control to last tool that doesn't have defer_loading
|
||||
// Tools with defer_loading=true cannot have cache_control (API rejects it)
|
||||
for (let i = result.tools.length - 1; i >= 0; i--) {
|
||||
if (!result.tools[i].defer_loading) {
|
||||
result.tools[i].cache_control = { type: "ephemeral", ttl: "1h" };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+6
-15
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -6596,12 +6596,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz",
|
||||
"integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -6613,15 +6613,6 @@
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit/node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.10",
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
object: string;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ENDPOINT_OPTIONS = [
|
||||
{ value: "chat", label: "Chat Completions" },
|
||||
{ value: "responses", label: "Responses" },
|
||||
{ value: "images", label: "Image Generation" },
|
||||
{ value: "embeddings", label: "Embeddings" },
|
||||
];
|
||||
|
||||
const DEFAULT_BODIES: Record<string, object> = {
|
||||
chat: {
|
||||
model: "",
|
||||
messages: [{ role: "user", content: "Hello! Say hi in one sentence." }],
|
||||
max_tokens: 100,
|
||||
stream: false,
|
||||
},
|
||||
responses: {
|
||||
model: "",
|
||||
input: "Hello! Say hi in one sentence.",
|
||||
stream: false,
|
||||
},
|
||||
images: {
|
||||
model: "",
|
||||
prompt: "A beautiful sunset over mountains",
|
||||
n: 1,
|
||||
size: "1024x1024",
|
||||
},
|
||||
embeddings: {
|
||||
model: "",
|
||||
input: "Hello world",
|
||||
encoding_format: "float",
|
||||
},
|
||||
};
|
||||
|
||||
const ENDPOINT_PATHS: Record<string, string> = {
|
||||
chat: "/v1/chat/completions",
|
||||
responses: "/v1/responses",
|
||||
images: "/v1/images/generations",
|
||||
embeddings: "/v1/embeddings",
|
||||
};
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [providers, setProviders] = useState<ProviderOption[]>([]);
|
||||
const [selectedProvider, setSelectedProvider] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [selectedEndpoint, setSelectedEndpoint] = useState("chat");
|
||||
const [requestBody, setRequestBody] = useState("");
|
||||
const [responseBody, setResponseBody] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [responseStatus, setResponseStatus] = useState<number | null>(null);
|
||||
const [responseDuration, setResponseDuration] = useState<number | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Fetch models
|
||||
useEffect(() => {
|
||||
fetch("/v1/models")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const modelList = (data?.data || []) as ModelInfo[];
|
||||
setModels(modelList);
|
||||
|
||||
// Extract unique providers from model ids (provider/model format)
|
||||
const providerSet = new Set<string>();
|
||||
modelList.forEach((m) => {
|
||||
const parts = m.id.split("/");
|
||||
if (parts.length >= 2) providerSet.add(parts[0]);
|
||||
});
|
||||
const providerOpts = Array.from(providerSet)
|
||||
.sort()
|
||||
.map((p) => ({ value: p, label: p }));
|
||||
setProviders(providerOpts);
|
||||
if (providerOpts.length > 0) setSelectedProvider(providerOpts[0].value);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Filter models by selected provider
|
||||
const filteredModels = models
|
||||
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
|
||||
.map((m) => ({ value: m.id, label: m.id }));
|
||||
|
||||
// Helper to generate default body for a given endpoint and model
|
||||
const generateDefaultBody = (endpoint: string, model: string) => {
|
||||
const template = { ...DEFAULT_BODIES[endpoint] };
|
||||
if ("model" in template) {
|
||||
(template as any).model = model;
|
||||
}
|
||||
return JSON.stringify(template, null, 2);
|
||||
};
|
||||
|
||||
// When provider changes, auto-select first model and reset body
|
||||
const handleProviderChange = (newProvider: string) => {
|
||||
setSelectedProvider(newProvider);
|
||||
const providerModels = models
|
||||
.filter((m) => !newProvider || m.id.startsWith(newProvider + "/"))
|
||||
.map((m) => m.id);
|
||||
const firstModel = providerModels[0] || "";
|
||||
setSelectedModel(firstModel);
|
||||
setRequestBody(generateDefaultBody(selectedEndpoint, firstModel));
|
||||
setResponseBody("");
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
};
|
||||
|
||||
// When model changes, update body
|
||||
const handleModelChange = (newModel: string) => {
|
||||
setSelectedModel(newModel);
|
||||
setRequestBody(generateDefaultBody(selectedEndpoint, newModel));
|
||||
setResponseBody("");
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
};
|
||||
|
||||
// When endpoint changes, update body
|
||||
const handleEndpointChange = (newEndpoint: string) => {
|
||||
setSelectedEndpoint(newEndpoint);
|
||||
setRequestBody(generateDefaultBody(newEndpoint, selectedModel));
|
||||
setResponseBody("");
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
};
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!requestBody.trim()) return;
|
||||
setLoading(true);
|
||||
setResponseBody("");
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(requestBody);
|
||||
const path = ENDPOINT_PATHS[selectedEndpoint];
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
setResponseStatus(res.status);
|
||||
setResponseDuration(Date.now() - startTime);
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (contentType.includes("text/event-stream")) {
|
||||
// Handle streaming
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let accumulated = "";
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
accumulated += decoder.decode(value, { stream: true });
|
||||
setResponseBody(accumulated);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setResponseBody(JSON.stringify(data, null, 2));
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") {
|
||||
setResponseBody(JSON.stringify({ cancelled: true }, null, 2));
|
||||
} else {
|
||||
setResponseBody(JSON.stringify({ error: err.message }, null, 2));
|
||||
}
|
||||
setResponseDuration(Date.now() - startTime);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [requestBody, selectedEndpoint]);
|
||||
|
||||
const handleCancel = () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
science
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">Model Playground</p>
|
||||
<p>
|
||||
Test any model directly from the dashboard. Pick a provider, model, and endpoint type,
|
||||
then send a request to see the raw response.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col sm:flex-row items-end gap-4">
|
||||
{/* Provider */}
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onChange={(e: any) => handleProviderChange(e.target.value)}
|
||||
options={providers}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
<Select
|
||||
value={selectedModel}
|
||||
onChange={(e: any) => handleModelChange(e.target.value)}
|
||||
options={filteredModels}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Endpoint */}
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Endpoint
|
||||
</label>
|
||||
<Select
|
||||
value={selectedEndpoint}
|
||||
onChange={(e: any) => handleEndpointChange(e.target.value)}
|
||||
options={ENDPOINT_OPTIONS}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<div className="shrink-0">
|
||||
{loading ? (
|
||||
<Button icon="stop" variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
disabled={!requestBody.trim() || !selectedModel}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Split Editor View */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Request Panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
upload
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Request</h3>
|
||||
<Badge variant="info" size="sm">
|
||||
POST {ENDPOINT_PATHS[selectedEndpoint]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleCopy(requestBody)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
|
||||
if ("model" in template) (template as any).model = selectedModel;
|
||||
setRequestBody(JSON.stringify(template, null, 2));
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Reset to default"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={requestBody}
|
||||
onChange={(value: string | undefined) => setRequestBody(value || "")}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
formatOnPaste: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Response Panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
download
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Response</h3>
|
||||
{responseStatus !== null && (
|
||||
<Badge
|
||||
variant={responseStatus >= 200 && responseStatus < 300 ? "success" : "error"}
|
||||
size="sm"
|
||||
>
|
||||
{responseStatus}
|
||||
</Badge>
|
||||
)}
|
||||
{responseDuration !== null && (
|
||||
<span className="text-xs text-text-muted">{responseDuration}ms</span>
|
||||
)}
|
||||
{loading && (
|
||||
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleCopy(responseBody)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={responseBody}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1338,6 +1338,8 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
const [customModels, setCustomModels] = useState([]);
|
||||
const [newModelId, setNewModelId] = useState("");
|
||||
const [newModelName, setNewModelName] = useState("");
|
||||
const [newApiFormat, setNewApiFormat] = useState("chat-completions");
|
||||
const [newEndpoints, setNewEndpoints] = useState(["chat"]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1370,11 +1372,15 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
provider: providerId,
|
||||
modelId: newModelId.trim(),
|
||||
modelName: newModelName.trim() || undefined,
|
||||
apiFormat: newApiFormat,
|
||||
supportedEndpoints: newEndpoints,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewModelId("");
|
||||
setNewModelName("");
|
||||
setNewApiFormat("chat-completions");
|
||||
setNewEndpoints(["chat"]);
|
||||
await fetchCustomModels();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1407,38 +1413,89 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
<p className="text-xs text-text-muted mb-3">{t("customModelsHint")}</p>
|
||||
|
||||
{/* Add form */}
|
||||
<div className="flex items-end gap-2 mb-3">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="custom-model-id" className="text-xs text-text-muted mb-1 block">
|
||||
{t("modelId")}
|
||||
</label>
|
||||
<input
|
||||
id="custom-model-id"
|
||||
type="text"
|
||||
value={newModelId}
|
||||
onChange={(e) => setNewModelId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder={t("customModelPlaceholder")}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<div className="flex flex-col gap-3 mb-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="custom-model-id" className="text-xs text-text-muted mb-1 block">
|
||||
{t("modelId")}
|
||||
</label>
|
||||
<input
|
||||
id="custom-model-id"
|
||||
type="text"
|
||||
value={newModelId}
|
||||
onChange={(e) => setNewModelId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder={t("customModelPlaceholder")}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label htmlFor="custom-model-name" className="text-xs text-text-muted mb-1 block">
|
||||
{t("displayName")}
|
||||
</label>
|
||||
<input
|
||||
id="custom-model-name"
|
||||
type="text"
|
||||
value={newModelName}
|
||||
onChange={(e) => setNewModelName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder={t("optional")}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModelId.trim() || adding}>
|
||||
{adding ? t("adding") : t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label htmlFor="custom-model-name" className="text-xs text-text-muted mb-1 block">
|
||||
{t("displayName")}
|
||||
</label>
|
||||
<input
|
||||
id="custom-model-name"
|
||||
type="text"
|
||||
value={newModelName}
|
||||
onChange={(e) => setNewModelName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder={t("optional")}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
|
||||
{/* API Format + Supported Endpoints */}
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div className="w-48">
|
||||
<label htmlFor="custom-api-format" className="text-xs text-text-muted mb-1 block">
|
||||
API Format
|
||||
</label>
|
||||
<select
|
||||
id="custom-api-format"
|
||||
value={newApiFormat}
|
||||
onChange={(e) => setNewApiFormat(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="chat-completions">Chat Completions</option>
|
||||
<option value="responses">Responses API</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-xs text-text-muted mb-1 block">Supported Endpoints</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{["chat", "embeddings", "images", "audio"].map((ep) => (
|
||||
<label
|
||||
key={ep}
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newEndpoints.includes(ep)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setNewEndpoints((prev) => [...prev, ep]);
|
||||
} else {
|
||||
setNewEndpoints((prev) => prev.filter((x) => x !== ep));
|
||||
}
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{ep === "chat"
|
||||
? "💬 Chat"
|
||||
: ep === "embeddings"
|
||||
? "📐 Embeddings"
|
||||
: ep === "images"
|
||||
? "🖼️ Images"
|
||||
: "🔊 Audio"}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModelId.trim() || adding}>
|
||||
{adding ? t("adding") : t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
@@ -1457,7 +1514,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
<span className="material-symbols-outlined text-base text-primary">tune</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{model.name || model.id}</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">
|
||||
{fullModel}
|
||||
</code>
|
||||
@@ -1470,6 +1527,26 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
{copied === copyKey ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
{model.apiFormat === "responses" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 font-medium">
|
||||
Responses
|
||||
</span>
|
||||
)}
|
||||
{model.supportedEndpoints?.includes("embeddings") && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-500/15 text-purple-400 font-medium">
|
||||
📐 Embed
|
||||
</span>
|
||||
)}
|
||||
{model.supportedEndpoints?.includes("images") && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-400 font-medium">
|
||||
🖼️ Images
|
||||
</span>
|
||||
)}
|
||||
{model.supportedEndpoints?.includes("audio") && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
|
||||
🔊 Audio
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -248,6 +248,76 @@ export default function SecurityTab() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* CLI Fingerprint Matching */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
fingerprint
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{t("cliFingerprint")}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">{t("cliFingerprintDesc")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["codex", "claude", "github", "antigravity"] as const).map((providerId) => {
|
||||
const providerMeta = Object.values(AI_PROVIDERS).find(
|
||||
(p: any) => p.id === providerId
|
||||
) as any;
|
||||
const isEnabled = (settings.cliCompatProviders || []).includes(providerId);
|
||||
const displayName = providerMeta?.name || providerId;
|
||||
const icon = providerMeta?.icon || "terminal";
|
||||
const color = providerMeta?.color || "#888";
|
||||
return (
|
||||
<button
|
||||
key={providerId}
|
||||
onClick={() => {
|
||||
const current: string[] = settings.cliCompatProviders || [];
|
||||
const updated = current.includes(providerId)
|
||||
? current.filter((p) => p !== providerId)
|
||||
: [...current, providerId];
|
||||
updateSetting("cliCompatProviders", updated);
|
||||
}}
|
||||
disabled={loading}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all border ${
|
||||
isEnabled
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-black/[0.02] dark:bg-white/[0.02] border-transparent text-text-muted hover:bg-black/[0.05] dark:hover:bg-white/[0.05]"
|
||||
}`}
|
||||
title={
|
||||
isEnabled
|
||||
? t("disableFingerprintTitle", { provider: displayName })
|
||||
: t("enableFingerprintTitle", { provider: displayName })
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px]"
|
||||
style={{ color: isEnabled ? undefined : color }}
|
||||
>
|
||||
{isEnabled ? "fingerprint" : icon}
|
||||
</span>
|
||||
{displayName}
|
||||
{isEnabled && (
|
||||
<span className="material-symbols-outlined text-[12px] text-emerald-500">
|
||||
check
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(settings.cliCompatProviders || []).length > 0 && (
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">verified</span>
|
||||
{t("cliFingerprintEnabled", {
|
||||
count: (settings.cliCompatProviders || []).length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<SessionInfoCard />
|
||||
<IPFilterSection />
|
||||
</div>
|
||||
|
||||
@@ -150,10 +150,9 @@ export default function ProviderLimitCard({
|
||||
{!loading && !error && !message && quotas?.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{quotas.map((quota, index) => {
|
||||
// For Antigravity, use remainingPercentage if available, otherwise calculate
|
||||
const percentage =
|
||||
quota.remainingPercentage !== undefined
|
||||
? Math.round(((quota.total - quota.used) / quota.total) * 100)
|
||||
? Math.round(quota.remainingPercentage)
|
||||
: calculatePercentage(quota.used, quota.total);
|
||||
const unlimited = quota.total === 0 || quota.total === null;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts";
|
||||
import { safePercentage } from "@/shared/utils/formatting";
|
||||
|
||||
/**
|
||||
* Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit)
|
||||
@@ -110,7 +111,7 @@ export function parseQuotaData(provider, data) {
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
remainingPercentage: quota.remainingPercentage,
|
||||
remainingPercentage: safePercentage(quota.remainingPercentage),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -159,7 +160,7 @@ export function parseQuotaData(provider, data) {
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
remainingPercentage: quota.remainingPercentage,
|
||||
remainingPercentage: safePercentage(quota.remainingPercentage),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* API Route: /api/acp/agents
|
||||
*
|
||||
* Returns the list of detected CLI agents and their availability status.
|
||||
* Used by the dashboard to show ACP transport options.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { detectInstalledAgents } from "@/lib/acp";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const agents = detectInstalledAgents();
|
||||
return NextResponse.json({
|
||||
agents: agents.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
binary: a.binary,
|
||||
version: a.version,
|
||||
installed: a.installed,
|
||||
providerAlias: a.providerAlias,
|
||||
protocol: a.protocol,
|
||||
})),
|
||||
available: agents.filter((a) => a.installed).length,
|
||||
total: agents.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to detect agents" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -221,9 +221,15 @@ export async function POST(
|
||||
let connection: any;
|
||||
if (tokenData.email) {
|
||||
const existing = await getProviderConnections({ provider });
|
||||
const match = existing.find(
|
||||
(c: any) => c.email === tokenData.email && c.authType === "oauth"
|
||||
);
|
||||
const match = existing.find((c: any) => {
|
||||
if (c.email !== tokenData.email || c.authType !== "oauth") return false;
|
||||
// For Codex, also check workspaceId to avoid overwriting different workspace connections
|
||||
if (provider === "codex" && tokenData.providerSpecificData?.workspaceId) {
|
||||
const existingWorkspace = c.providerSpecificData?.workspaceId;
|
||||
return existingWorkspace === tokenData.providerSpecificData.workspaceId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const matchId = typeof match?.id === "string" ? match.id : null;
|
||||
if (matchId) {
|
||||
connection = await updateProviderConnection(matchId, {
|
||||
@@ -285,9 +291,15 @@ export async function POST(
|
||||
let connection: any;
|
||||
if (result.tokens.email) {
|
||||
const existing = await getProviderConnections({ provider });
|
||||
const match = existing.find(
|
||||
(c: any) => c.email === result.tokens.email && c.authType === "oauth"
|
||||
);
|
||||
const match = existing.find((c: any) => {
|
||||
if (c.email !== result.tokens.email || c.authType !== "oauth") return false;
|
||||
// For Codex, also check workspaceId to avoid overwriting different workspace connections
|
||||
if (provider === "codex" && result.tokens.providerSpecificData?.workspaceId) {
|
||||
const existingWorkspace = c.providerSpecificData?.workspaceId;
|
||||
return existingWorkspace === result.tokens.providerSpecificData.workspaceId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const matchId = typeof match?.id === "string" ? match.id : null;
|
||||
if (matchId) {
|
||||
connection = await updateProviderConnection(matchId, {
|
||||
@@ -399,9 +411,15 @@ export async function POST(
|
||||
let connection: any;
|
||||
if (tokenData.email) {
|
||||
const existing = await getProviderConnections({ provider });
|
||||
const match = existing.find(
|
||||
(c: any) => c.email === tokenData.email && c.authType === "oauth"
|
||||
);
|
||||
const match = existing.find((c: any) => {
|
||||
if (c.email !== tokenData.email || c.authType !== "oauth") return false;
|
||||
// For Codex, also check workspaceId to avoid overwriting different workspace connections
|
||||
if (provider === "codex" && tokenData.providerSpecificData?.workspaceId) {
|
||||
const existingWorkspace = c.providerSpecificData?.workspaceId;
|
||||
return existingWorkspace === tokenData.providerSpecificData.workspaceId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const matchId = typeof match?.id === "string" ? match.id : null;
|
||||
if (matchId) {
|
||||
connection = await updateProviderConnection(matchId, {
|
||||
|
||||
@@ -64,9 +64,16 @@ export async function POST(request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return Response.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { provider, modelId, modelName, source } = validation.data;
|
||||
const { provider, modelId, modelName, source, apiFormat, supportedEndpoints } = validation.data;
|
||||
|
||||
const model = await addCustomModel(provider, modelId, modelName, source || "manual");
|
||||
const model = await addCustomModel(
|
||||
provider,
|
||||
modelId,
|
||||
modelName,
|
||||
source || "manual",
|
||||
apiFormat,
|
||||
supportedEndpoints
|
||||
);
|
||||
return Response.json({ model });
|
||||
} catch (error) {
|
||||
console.error("Error adding provider model:", error);
|
||||
|
||||
@@ -5,12 +5,18 @@ import bcrypt from "bcryptjs";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { setCliCompatProviders } from "../../../../open-sse/config/cliFingerprints";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const { password, ...safeSettings } = settings;
|
||||
|
||||
// Sync CLI fingerprint providers to runtime cache on load
|
||||
if (settings.cliCompatProviders) {
|
||||
setCliCompatProviders(settings.cliCompatProviders as string[]);
|
||||
}
|
||||
|
||||
const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true";
|
||||
const runtimePorts = getRuntimePorts();
|
||||
|
||||
@@ -74,6 +80,11 @@ export async function PATCH(request) {
|
||||
clearHealthCheckLogCache();
|
||||
}
|
||||
|
||||
// Sync CLI fingerprint providers to runtime cache
|
||||
if ("cliCompatProviders" in body) {
|
||||
setCliCompatProviders(body.cliCompatProviders || []);
|
||||
}
|
||||
|
||||
const { password, ...safeSettings } = settings;
|
||||
return NextResponse.json(safeSettings);
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,11 @@ import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
|
||||
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { setQuotaCache } from "@/domain/quotaCache";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, any> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to cloud if enabled
|
||||
@@ -147,6 +152,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ conn
|
||||
const usage = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
getUsageForProvider(connection)
|
||||
);
|
||||
|
||||
// Populate quota cache for quota-aware account selection
|
||||
if (isRecord(usage?.quotas)) {
|
||||
setQuotaCache(connectionId, connection.provider, usage.quotas);
|
||||
}
|
||||
|
||||
return Response.json(usage);
|
||||
} catch (error) {
|
||||
console.error("[Usage API] Error fetching usage:", error);
|
||||
|
||||
@@ -13,6 +13,8 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { v1EmbeddingsSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
import { getAllCustomModels } from "@/lib/localDb";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
@@ -30,23 +32,43 @@ export async function OPTIONS() {
|
||||
* GET /v1/embeddings — list available embedding models
|
||||
*/
|
||||
export async function GET() {
|
||||
const models = getAllEmbeddingModels();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
object: "list",
|
||||
data: models.map((m) => ({
|
||||
id: m.id,
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: m.provider,
|
||||
type: "embedding",
|
||||
dimensions: m.dimensions,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
const builtInModels = getAllEmbeddingModels();
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const data = builtInModels.map((m) => ({
|
||||
id: m.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: m.provider,
|
||||
type: "embedding",
|
||||
dimensions: m.dimensions,
|
||||
}));
|
||||
|
||||
// Include custom models tagged for embeddings
|
||||
try {
|
||||
const customModelsMap = (await getAllCustomModels()) as Record<string, any>;
|
||||
for (const [providerId, models] of Object.entries(customModelsMap)) {
|
||||
if (!Array.isArray(models)) continue;
|
||||
for (const model of models) {
|
||||
if (!model?.id || !Array.isArray(model.supportedEndpoints)) continue;
|
||||
if (!model.supportedEndpoints.includes("embeddings")) continue;
|
||||
const fullId = `${providerId}/${model.id}`;
|
||||
if (data.some((d) => d.id === fullId)) continue;
|
||||
data.push({
|
||||
id: fullId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: providerId,
|
||||
type: "embedding",
|
||||
dimensions: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {}
|
||||
|
||||
return new Response(JSON.stringify({ object: "list", data }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,8 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { v1ImageGenerationSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
import { getAllCustomModels } from "@/lib/localDb";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
@@ -31,23 +33,43 @@ export async function OPTIONS() {
|
||||
* GET /v1/images/generations — list available image models
|
||||
*/
|
||||
export async function GET() {
|
||||
const models = getAllImageModels();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
object: "list",
|
||||
data: models.map((m) => ({
|
||||
id: m.id,
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: m.provider,
|
||||
type: "image",
|
||||
supported_sizes: m.supportedSizes,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
const builtInModels = getAllImageModels();
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const data = builtInModels.map((m) => ({
|
||||
id: m.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: m.provider,
|
||||
type: "image",
|
||||
supported_sizes: m.supportedSizes,
|
||||
}));
|
||||
|
||||
// Include custom models tagged for images
|
||||
try {
|
||||
const customModelsMap = (await getAllCustomModels()) as Record<string, any>;
|
||||
for (const [providerId, models] of Object.entries(customModelsMap)) {
|
||||
if (!Array.isArray(models)) continue;
|
||||
for (const model of models) {
|
||||
if (!model?.id || !Array.isArray(model.supportedEndpoints)) continue;
|
||||
if (!model.supportedEndpoints.includes("images")) continue;
|
||||
const fullId = `${providerId}/${model.id}`;
|
||||
if (data.some((d) => d.id === fullId)) continue;
|
||||
data.push({
|
||||
id: fullId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: providerId,
|
||||
type: "image",
|
||||
supported_sizes: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {}
|
||||
|
||||
return new Response(JSON.stringify({ object: "list", data }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +107,30 @@ export async function POST(request) {
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
// Parse model to get provider
|
||||
const { provider } = parseImageModel(body.model);
|
||||
let { provider } = parseImageModel(body.model);
|
||||
let isCustomModel = false;
|
||||
|
||||
// If not in built-in registry, check custom models tagged for images
|
||||
if (!provider) {
|
||||
try {
|
||||
const customModelsMap = (await getAllCustomModels()) as Record<string, any>;
|
||||
for (const [providerId, models] of Object.entries(customModelsMap)) {
|
||||
if (!Array.isArray(models)) continue;
|
||||
for (const model of models) {
|
||||
if (!model?.id || !Array.isArray(model.supportedEndpoints)) continue;
|
||||
if (!model.supportedEndpoints.includes("images")) continue;
|
||||
const fullId = `${providerId}/${model.id}`;
|
||||
if (fullId === body.model) {
|
||||
provider = providerId;
|
||||
isCustomModel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (provider) break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
@@ -106,9 +151,23 @@ export async function POST(request) {
|
||||
`No credentials for image provider: ${provider}`
|
||||
);
|
||||
}
|
||||
} else if (isCustomModel) {
|
||||
// Custom models need credentials from the provider connection
|
||||
credentials = await getProviderCredentials(provider);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`No credentials for custom image provider: ${provider}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await handleImageGeneration({ body, credentials, log });
|
||||
const result = await handleImageGeneration({
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
...(isCustomModel && { resolvedProvider: provider }),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return new Response(JSON.stringify((result as any).data), {
|
||||
|
||||
@@ -364,6 +364,17 @@ export async function getUnifiedModelsResponse(
|
||||
const aliasId = `${alias}/${modelId}`;
|
||||
if (models.some((m) => m.id === aliasId)) continue;
|
||||
|
||||
// Determine type from supportedEndpoints
|
||||
const endpoints = Array.isArray(model.supportedEndpoints)
|
||||
? model.supportedEndpoints
|
||||
: ["chat"];
|
||||
const apiFormat =
|
||||
typeof model.apiFormat === "string" ? model.apiFormat : "chat-completions";
|
||||
let modelType: string | undefined;
|
||||
if (endpoints.includes("embeddings")) modelType = "embedding";
|
||||
else if (endpoints.includes("images")) modelType = "image";
|
||||
else if (endpoints.includes("audio")) modelType = "audio";
|
||||
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
@@ -373,6 +384,11 @@ export async function getUnifiedModelsResponse(
|
||||
root: modelId,
|
||||
parent: null,
|
||||
custom: true,
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
...(apiFormat !== "chat-completions" ? { api_format: apiFormat } : {}),
|
||||
...(endpoints.length > 1 || !endpoints.includes("chat")
|
||||
? { supported_endpoints: endpoints }
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Only add provider-prefixed version if different from alias
|
||||
@@ -388,6 +404,7 @@ export async function getUnifiedModelsResponse(
|
||||
root: modelId,
|
||||
parent: aliasId,
|
||||
custom: true,
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
directives ensure all utility classes in route groups are included. */
|
||||
@source "../app/(dashboard)";
|
||||
@source "../../open-sse";
|
||||
@source not "../../*.sqlite*";
|
||||
@source not "../../.claude*";
|
||||
@source not "../../.claude-memory";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Quota Cache — Domain Layer
|
||||
*
|
||||
* In-memory cache of provider quota data per connectionId.
|
||||
* Populated by:
|
||||
* - Dashboard usage endpoint (GET /api/usage/[connectionId])
|
||||
* - 429 responses marking account as exhausted
|
||||
*
|
||||
* Background refresh runs every 1 minute:
|
||||
* - Active accounts (quota > 0%): refetch every 5 minutes
|
||||
* - Exhausted accounts: refetch every 5 minutes (or immediately after resetAt passes)
|
||||
*
|
||||
* @module domain/quotaCache
|
||||
*/
|
||||
|
||||
import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
|
||||
import { getProviderConnectionById, resolveProxyForConnection } from "@/lib/localDb";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { safePercentage } from "@/shared/utils/formatting";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface QuotaInfo {
|
||||
remainingPercentage: number;
|
||||
resetAt: string | null;
|
||||
}
|
||||
|
||||
interface QuotaCacheEntry {
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
quotas: Record<string, QuotaInfo>;
|
||||
fetchedAt: number;
|
||||
exhausted: boolean;
|
||||
nextResetAt: string | null;
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTIVE_TTL_MS = 5 * 60 * 1000; // 5 minutes for active accounts
|
||||
const EXHAUSTED_TTL_MS = 5 * 60 * 1000; // 5 minutes for 429-sourced entries (no resetAt)
|
||||
const EXHAUSTED_REFRESH_MS = 5 * 60 * 1000; // 5 minutes: recheck exhausted accounts (aligned with TTL)
|
||||
const REFRESH_INTERVAL_MS = 60 * 1000; // Background tick every 1 minute
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const cache = new Map<string, QuotaCacheEntry>();
|
||||
const MAX_CONCURRENT_REFRESHES = 5;
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let tickRunning = false;
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function isExhausted(quotas: Record<string, QuotaInfo>): boolean {
|
||||
const entries = Object.values(quotas);
|
||||
if (entries.length === 0) return false;
|
||||
return entries.every((q) => q.remainingPercentage <= 0);
|
||||
}
|
||||
|
||||
function parseDate(value: string): number | null {
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function earliestResetAt(quotas: Record<string, QuotaInfo>): string | null {
|
||||
let earliest: string | null = null;
|
||||
let earliestMs = Infinity;
|
||||
for (const q of Object.values(quotas)) {
|
||||
if (!q.resetAt) continue;
|
||||
const ms = parseDate(q.resetAt);
|
||||
if (ms !== null && ms < earliestMs) {
|
||||
earliestMs = ms;
|
||||
earliest = q.resetAt;
|
||||
}
|
||||
}
|
||||
return earliest;
|
||||
}
|
||||
|
||||
function normalizeQuotas(rawQuotas: Record<string, any>): Record<string, QuotaInfo> {
|
||||
const result: Record<string, QuotaInfo> = {};
|
||||
for (const [key, q] of Object.entries(rawQuotas)) {
|
||||
if (q && typeof q === "object") {
|
||||
result[key] = {
|
||||
remainingPercentage:
|
||||
safePercentage(q.remainingPercentage) ??
|
||||
(q.total > 0 ? Math.round(((q.total - (q.used || 0)) / q.total) * 100) : 0),
|
||||
resetAt: q.resetAt || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Store quota data for a connection (called by usage endpoint and background refresh).
|
||||
*/
|
||||
export function setQuotaCache(
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
rawQuotas: Record<string, any>
|
||||
) {
|
||||
const quotas = normalizeQuotas(rawQuotas);
|
||||
const exhausted = isExhausted(quotas);
|
||||
cache.set(connectionId, {
|
||||
connectionId,
|
||||
provider,
|
||||
quotas,
|
||||
fetchedAt: Date.now(),
|
||||
exhausted,
|
||||
nextResetAt: exhausted ? earliestResetAt(quotas) : null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached quota entry (returns null if not cached).
|
||||
*/
|
||||
export function getQuotaCache(connectionId: string): QuotaCacheEntry | null {
|
||||
return cache.get(connectionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account's quota is exhausted based on cached data.
|
||||
* Returns false if no cache entry exists (unknown = assume available).
|
||||
*/
|
||||
export function isAccountQuotaExhausted(connectionId: string): boolean {
|
||||
const entry = cache.get(connectionId);
|
||||
if (!entry) return false;
|
||||
if (!entry.exhausted) return false;
|
||||
|
||||
// If resetAt has passed, assume available until refresh confirms
|
||||
if (entry.nextResetAt) {
|
||||
const resetMs = parseDate(entry.nextResetAt);
|
||||
if (resetMs !== null && resetMs <= Date.now()) return false;
|
||||
}
|
||||
|
||||
// Exhausted entries without resetAt expire after fixed TTL
|
||||
const age = Date.now() - entry.fetchedAt;
|
||||
if (!entry.nextResetAt && age > EXHAUSTED_TTL_MS) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an account as quota-exhausted from a 429 response (no quota data available).
|
||||
* Uses 5-minute fixed TTL since we don't know the actual resetAt.
|
||||
*/
|
||||
export function markAccountExhaustedFrom429(connectionId: string, provider: string) {
|
||||
cache.set(connectionId, {
|
||||
connectionId,
|
||||
provider,
|
||||
quotas: {},
|
||||
fetchedAt: Date.now(),
|
||||
exhausted: true,
|
||||
nextResetAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Background Refresh ─────────────────────────────────────────────────────
|
||||
|
||||
const refreshingSet = new Set<string>();
|
||||
|
||||
async function refreshEntry(entry: QuotaCacheEntry) {
|
||||
if (refreshingSet.has(entry.connectionId)) return;
|
||||
refreshingSet.add(entry.connectionId);
|
||||
|
||||
try {
|
||||
const connection = await getProviderConnectionById(entry.connectionId);
|
||||
if (!connection || connection.authType !== "oauth" || !connection.isActive) {
|
||||
cache.delete(entry.connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyInfo = await resolveProxyForConnection(entry.connectionId);
|
||||
const usage = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
getUsageForProvider(connection)
|
||||
);
|
||||
|
||||
if (usage?.quotas) {
|
||||
setQuotaCache(entry.connectionId, entry.provider, usage.quotas);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[QuotaCache] Refresh failed for ${entry.connectionId.slice(0, 8)}:`,
|
||||
(err as any)?.message || err
|
||||
);
|
||||
} finally {
|
||||
refreshingSet.delete(entry.connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
function needsRefresh(entry: QuotaCacheEntry, now: number): boolean {
|
||||
const age = now - entry.fetchedAt;
|
||||
if (entry.exhausted) {
|
||||
if (entry.nextResetAt) {
|
||||
const resetMs = parseDate(entry.nextResetAt);
|
||||
if (resetMs !== null && resetMs <= now) return true;
|
||||
}
|
||||
return age >= EXHAUSTED_REFRESH_MS;
|
||||
}
|
||||
return age >= ACTIVE_TTL_MS;
|
||||
}
|
||||
|
||||
async function backgroundRefreshTick() {
|
||||
if (tickRunning) return;
|
||||
tickRunning = true;
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
const pending = [...cache.values()].filter((e) => needsRefresh(e, now));
|
||||
|
||||
// Refresh in batches to avoid thundering herd
|
||||
for (let i = 0; i < pending.length; i += MAX_CONCURRENT_REFRESHES) {
|
||||
const batch = pending.slice(i, i + MAX_CONCURRENT_REFRESHES);
|
||||
await Promise.allSettled(batch.map(refreshEntry));
|
||||
}
|
||||
} finally {
|
||||
tickRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the background refresh timer.
|
||||
*/
|
||||
export function startBackgroundRefresh() {
|
||||
if (refreshTimer) return;
|
||||
refreshTimer = setInterval(backgroundRefreshTick, REFRESH_INTERVAL_MS);
|
||||
refreshTimer?.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the background refresh timer.
|
||||
*/
|
||||
export function stopBackgroundRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache stats (for debugging/dashboard).
|
||||
*/
|
||||
export function getQuotaCacheStats() {
|
||||
const entries: Array<{
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
exhausted: boolean;
|
||||
nextResetAt: string | null;
|
||||
ageMs: number;
|
||||
}> = [];
|
||||
|
||||
for (const entry of cache.values()) {
|
||||
entries.push({
|
||||
connectionId: entry.connectionId.slice(0, 8) + "...",
|
||||
provider: entry.provider,
|
||||
exhausted: entry.exhausted,
|
||||
nextResetAt: entry.nextResetAt,
|
||||
ageMs: Date.now() - entry.fetchedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return { total: cache.size, entries };
|
||||
}
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "بنفسجي ساحر",
|
||||
"themeOrange": "أورانج (Orange)",
|
||||
"themeCyan": "كيان",
|
||||
"endpoints": "نقاط النهاية"
|
||||
"endpoints": "نقاط النهاية",
|
||||
"playground": "ملعب النماذج"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "المواضيع",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Виолетово",
|
||||
"themeOrange": "Оранжево",
|
||||
"themeCyan": "Циан",
|
||||
"endpoints": "Крайни точки"
|
||||
"endpoints": "Крайни точки",
|
||||
"playground": "Площадка"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Теми",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Turkis",
|
||||
"endpoints": "Endpoints"
|
||||
"endpoints": "Endpoints",
|
||||
"playground": "Legeplads"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Temaer",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violett",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endpunkte"
|
||||
"endpoints": "Endpunkte",
|
||||
"playground": "Spielwiese"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themen",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"media": "Media",
|
||||
"settings": "Settings",
|
||||
"translator": "Translator",
|
||||
"playground": "Playground",
|
||||
"docs": "Docs",
|
||||
"issues": "Issues",
|
||||
"endpoints": "Endpoints",
|
||||
@@ -1492,6 +1493,11 @@
|
||||
"providersBlocked": "{count} provider(s) blocked from /models",
|
||||
"blockProviderTitle": "Block {provider}",
|
||||
"unblockProviderTitle": "Unblock {provider}",
|
||||
"cliFingerprint": "CLI Fingerprint Matching",
|
||||
"cliFingerprintDesc": "Match native CLI binary signatures when proxying requests. Reorders headers and body fields to look identical to the official CLI tools. Your proxy IP is preserved.",
|
||||
"cliFingerprintEnabled": "{count} provider(s) with CLI fingerprint active",
|
||||
"enableFingerprintTitle": "Enable fingerprint for {provider}",
|
||||
"disableFingerprintTitle": "Disable fingerprint for {provider}",
|
||||
"routingStrategy": "Routing Strategy",
|
||||
"fillFirst": "Fill First",
|
||||
"fillFirstDesc": "Use accounts in priority order",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violeta",
|
||||
"themeOrange": "Naranja",
|
||||
"themeCyan": "cian",
|
||||
"endpoints": "Endpoints"
|
||||
"endpoints": "Endpoints",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Temas",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violetti",
|
||||
"themeOrange": "Oranssi",
|
||||
"themeCyan": "Syaani",
|
||||
"endpoints": "Päätepisteet"
|
||||
"endpoints": "Päätepisteet",
|
||||
"playground": "Leikkipaikka"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Teemat",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violette",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Points d'accès"
|
||||
"endpoints": "Points d'accès",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Thèmes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "נקודות קצה"
|
||||
"endpoints": "נקודות קצה",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "narancssárga",
|
||||
"themeCyan": "Cián",
|
||||
"endpoints": "Végpontok"
|
||||
"endpoints": "Végpontok",
|
||||
"playground": "Játszótér"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Témák",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endpoint"
|
||||
"endpoints": "Endpoint",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endpoint"
|
||||
"endpoints": "Endpoint",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endpoint"
|
||||
"endpoints": "Endpoint",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "エンドポイント"
|
||||
"endpoints": "エンドポイント",
|
||||
"playground": "プレイグラウンド"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "엔드포인트"
|
||||
"endpoints": "엔드포인트",
|
||||
"playground": "플레이그라운드"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Titik Akhir"
|
||||
"endpoints": "Titik Akhir",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Eindpunten"
|
||||
"endpoints": "Eindpunten",
|
||||
"playground": "Speeltuin"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endepunkter"
|
||||
"endpoints": "Endepunkter",
|
||||
"playground": "Lekeplass"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Mga Endpoint"
|
||||
"endpoints": "Mga Endpoint",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Punkty końcowe"
|
||||
"endpoints": "Punkty końcowe",
|
||||
"playground": "Plac zabaw"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endpoints"
|
||||
"endpoints": "Endpoints",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1480,6 +1481,11 @@
|
||||
"providersBlocked": "{count} provedor(es) bloqueado(s) do /models",
|
||||
"blockProviderTitle": "Bloquear {provider}",
|
||||
"unblockProviderTitle": "Desbloquear {provider}",
|
||||
"cliFingerprint": "Assinatura Digital CLI",
|
||||
"cliFingerprintDesc": "Reproduz a assinatura digital dos CLIs nativos ao fazer proxy. Reorganiza headers e campos do body para parecer idêntico às ferramentas CLI oficiais. O IP do proxy é preservado.",
|
||||
"cliFingerprintEnabled": "{count} provider(s) com fingerprint CLI ativo",
|
||||
"enableFingerprintTitle": "Ativar fingerprint para {provider}",
|
||||
"disableFingerprintTitle": "Desativar fingerprint para {provider}",
|
||||
"routingStrategy": "Estratégia de Roteamento",
|
||||
"fillFirst": "Preencher Primeiro",
|
||||
"fillFirstDesc": "Usar contas em ordem de prioridade",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeGreen": "Verde",
|
||||
"themeViolet": "Violeta",
|
||||
"themeOrange": "Laranja",
|
||||
"themeCyan": "Ciano"
|
||||
"themeCyan": "Ciano",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Puncte finale"
|
||||
"endpoints": "Puncte finale",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Фиолетовый",
|
||||
"themeOrange": "Оранжевый",
|
||||
"themeCyan": "Голубой",
|
||||
"endpoints": "Конечные точки"
|
||||
"endpoints": "Конечные точки",
|
||||
"playground": "Площадка"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Темы",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Koncové body"
|
||||
"endpoints": "Koncové body",
|
||||
"playground": "Ihrisko"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Ändpunkter"
|
||||
"endpoints": "Ändpunkter",
|
||||
"playground": "Lekplats"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "จุดปลายทาง"
|
||||
"endpoints": "จุดปลายทาง",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "ธีมส์",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Кінцеві точки"
|
||||
"endpoints": "Кінцеві точки",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Điểm cuối"
|
||||
"endpoints": "Điểm cuối",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "端点"
|
||||
"endpoints": "端点",
|
||||
"playground": "模型试验场"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
+18
-6
@@ -8,22 +8,27 @@
|
||||
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
|
||||
*/
|
||||
|
||||
function ensureJwtSecret(): void {
|
||||
function ensureSecrets(): void {
|
||||
// eslint-disable-next-line no-eval
|
||||
const crypto = eval("require")("crypto");
|
||||
|
||||
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.trim() === "") {
|
||||
// Use eval to hide require from webpack's static analysis
|
||||
// This code only runs in Node.js runtime (guarded by NEXT_RUNTIME check)
|
||||
// eslint-disable-next-line no-eval
|
||||
const crypto = eval("require")("crypto");
|
||||
const generated = crypto.randomBytes(48).toString("base64");
|
||||
process.env.JWT_SECRET = generated;
|
||||
console.log("[STARTUP] JWT_SECRET auto-generated (random 64-char secret)");
|
||||
}
|
||||
|
||||
if (!process.env.API_KEY_SECRET || process.env.API_KEY_SECRET.trim() === "") {
|
||||
const generated = crypto.randomBytes(32).toString("hex");
|
||||
process.env.API_KEY_SECRET = generated;
|
||||
console.log("[STARTUP] API_KEY_SECRET auto-generated (random 64-char hex secret)");
|
||||
}
|
||||
}
|
||||
|
||||
export async function register() {
|
||||
// Only run on the server (not during build or in Edge runtime)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
ensureJwtSecret();
|
||||
ensureSecrets();
|
||||
// Console log file capture (must be first — before any logging occurs)
|
||||
const { initConsoleInterceptor } = await import("@/lib/consoleInterceptor");
|
||||
initConsoleInterceptor();
|
||||
@@ -34,6 +39,13 @@ export async function register() {
|
||||
const { initApiBridgeServer } = await import("@/lib/apiBridgeServer");
|
||||
initApiBridgeServer();
|
||||
|
||||
// Quota cache: start background refresh for quota-aware account selection
|
||||
// Dynamic import required — quotaCache depends on better-sqlite3 (Node-only),
|
||||
// and instrumentation.ts is bundled for all runtimes including Edge.
|
||||
const { startBackgroundRefresh } = await import("@/domain/quotaCache");
|
||||
startBackgroundRefresh();
|
||||
console.log("[STARTUP] Quota cache background refresh started");
|
||||
|
||||
// Compliance: Initialize audit_log table + cleanup expired logs
|
||||
try {
|
||||
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* ACP Module — Public API
|
||||
*
|
||||
* Re-exports the registry and manager for convenient imports.
|
||||
*/
|
||||
|
||||
export { detectInstalledAgents, getAgentById, getAvailableAgents } from "./registry";
|
||||
export type { CliAgentInfo } from "./registry";
|
||||
|
||||
export { AcpManager, acpManager } from "./manager";
|
||||
export type { AcpSession } from "./manager";
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* ACP (Agent Client Protocol) — Process Spawner & Manager
|
||||
*
|
||||
* Spawns CLI agents as child processes and manages their lifecycle.
|
||||
* Communication happens via stdin/stdout (JSON-RPC style) or piped HTTP.
|
||||
*
|
||||
* This module provides a "CLI-as-backend" transport: instead of intercepting
|
||||
* HTTP API calls, OmniRoute spawns the CLI directly and feeds prompts through
|
||||
* its native interface.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
export interface AcpSession {
|
||||
/** Unique session ID */
|
||||
id: string;
|
||||
/** Agent ID (e.g., "codex", "claude") */
|
||||
agentId: string;
|
||||
/** Child process handle */
|
||||
process: ChildProcess;
|
||||
/** Whether the process is alive */
|
||||
alive: boolean;
|
||||
/** Accumulated stdout buffer */
|
||||
stdoutBuffer: string;
|
||||
/** Accumulated stderr buffer */
|
||||
stderrBuffer: string;
|
||||
/** Created timestamp */
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP Session Manager
|
||||
*
|
||||
* Manages the lifecycle of CLI agent processes.
|
||||
* Each session represents one running CLI agent instance.
|
||||
*/
|
||||
export class AcpManager extends EventEmitter {
|
||||
private sessions: Map<string, AcpSession> = new Map();
|
||||
|
||||
/**
|
||||
* Spawn a new CLI agent process.
|
||||
*/
|
||||
spawn(
|
||||
agentId: string,
|
||||
binary: string,
|
||||
args: string[] = [],
|
||||
env: Record<string, string> = {}
|
||||
): AcpSession {
|
||||
const sessionId = `acp-${agentId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const child = spawn(binary, args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, ...env },
|
||||
shell: false,
|
||||
});
|
||||
|
||||
const session: AcpSession = {
|
||||
id: sessionId,
|
||||
agentId,
|
||||
process: child,
|
||||
alive: true,
|
||||
stdoutBuffer: "",
|
||||
stderrBuffer: "",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
session.stdoutBuffer += chunk.toString();
|
||||
this.emit("stdout", { sessionId, data: chunk.toString() });
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
session.stderrBuffer += chunk.toString();
|
||||
this.emit("stderr", { sessionId, data: chunk.toString() });
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
session.alive = false;
|
||||
this.emit("exit", { sessionId, code, signal });
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
session.alive = false;
|
||||
this.emit("error", { sessionId, error: err });
|
||||
});
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input to a running session's stdin.
|
||||
*/
|
||||
sendInput(sessionId: string, input: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session?.alive || !session.process.stdin?.writable) return false;
|
||||
|
||||
session.process.stdin.write(input);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a prompt to a CLI agent and collect the response.
|
||||
* This is a higher-level method that handles the send/receive cycle.
|
||||
*/
|
||||
async sendPrompt(sessionId: string, prompt: string, timeoutMs: number = 120000): Promise<string> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session?.alive) throw new Error(`Session ${sessionId} is not alive`);
|
||||
|
||||
// Clear buffer before sending
|
||||
session.stdoutBuffer = "";
|
||||
|
||||
// Send prompt
|
||||
this.sendInput(sessionId, prompt + "\n");
|
||||
|
||||
// Wait for response (collect until process goes idle or timeout)
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`ACP timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
let idleTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const onData = ({ sessionId: sid }: { sessionId: string }) => {
|
||||
if (sid !== sessionId) return;
|
||||
// Reset idle timer on new data
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
clearTimeout(timer);
|
||||
this.removeListener("stdout", onData);
|
||||
this.removeListener("exit", onExit);
|
||||
resolve(session.stdoutBuffer);
|
||||
}, 2000); // 2s idle = response complete
|
||||
};
|
||||
|
||||
const onExit = ({ sessionId: sid }: { sessionId: string }) => {
|
||||
if (sid !== sessionId) return;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(idleTimer);
|
||||
this.removeListener("stdout", onData);
|
||||
this.removeListener("exit", onExit);
|
||||
resolve(session.stdoutBuffer);
|
||||
};
|
||||
|
||||
this.on("stdout", onData);
|
||||
this.on("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a session and clean up.
|
||||
*/
|
||||
kill(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
|
||||
if (session.alive) {
|
||||
session.process.kill("SIGTERM");
|
||||
// Force kill after 5s
|
||||
setTimeout(() => {
|
||||
if (session.alive) {
|
||||
session.process.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions.
|
||||
*/
|
||||
getActiveSessions(): AcpSession[] {
|
||||
return Array.from(this.sessions.values()).filter((s) => s.alive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific session.
|
||||
*/
|
||||
getSession(sessionId: string): AcpSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all sessions.
|
||||
*/
|
||||
killAll(): void {
|
||||
for (const [id] of this.sessions) {
|
||||
this.kill(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton manager instance
|
||||
export const acpManager = new AcpManager();
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* ACP (Agent Client Protocol) — CLI Agent Registry
|
||||
*
|
||||
* Discovers installed CLI tools on the system by checking standard paths
|
||||
* and running version commands. Used to offer ACP transport as an alternative
|
||||
* to the HTTP proxy method.
|
||||
*
|
||||
* Reference: https://github.com/iOfficeAI/AionUi (auto-detects CLI agents)
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process";
|
||||
|
||||
export interface CliAgentInfo {
|
||||
/** Agent identifier (e.g., "codex", "claude", "goose") */
|
||||
id: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Binary name to spawn */
|
||||
binary: string;
|
||||
/** Version detection command */
|
||||
versionCommand: string;
|
||||
/** Detected version (null if not installed) */
|
||||
version: string | null;
|
||||
/** Whether the agent is installed and available */
|
||||
installed: boolean;
|
||||
/** Provider ID that this agent maps to in OmniRoute */
|
||||
providerAlias: string;
|
||||
/** Arguments to pass when spawning for ACP */
|
||||
spawnArgs: string[];
|
||||
/** Protocol used for communication */
|
||||
protocol: "stdio" | "http";
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of known CLI agents that support ACP or similar protocols.
|
||||
*/
|
||||
const AGENT_DEFINITIONS: Omit<CliAgentInfo, "version" | "installed">[] = [
|
||||
{
|
||||
id: "codex",
|
||||
name: "OpenAI Codex CLI",
|
||||
binary: "codex",
|
||||
versionCommand: "codex --version",
|
||||
providerAlias: "codex",
|
||||
spawnArgs: ["--quiet"],
|
||||
protocol: "stdio",
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude Code CLI",
|
||||
binary: "claude",
|
||||
versionCommand: "claude --version",
|
||||
providerAlias: "claude",
|
||||
spawnArgs: ["--print", "--output-format", "json"],
|
||||
protocol: "stdio",
|
||||
},
|
||||
{
|
||||
id: "goose",
|
||||
name: "Goose CLI",
|
||||
binary: "goose",
|
||||
versionCommand: "goose --version",
|
||||
providerAlias: "goose",
|
||||
spawnArgs: [],
|
||||
protocol: "stdio",
|
||||
},
|
||||
{
|
||||
id: "gemini-cli",
|
||||
name: "Gemini CLI",
|
||||
binary: "gemini",
|
||||
versionCommand: "gemini --version",
|
||||
providerAlias: "gemini-cli",
|
||||
spawnArgs: [],
|
||||
protocol: "stdio",
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
binary: "openclaw",
|
||||
versionCommand: "openclaw --version",
|
||||
providerAlias: "openclaw",
|
||||
spawnArgs: [],
|
||||
protocol: "stdio",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect installed CLI agents on the system.
|
||||
* Runs version commands to verify availability.
|
||||
*/
|
||||
export function detectInstalledAgents(): CliAgentInfo[] {
|
||||
return AGENT_DEFINITIONS.map((def) => {
|
||||
let version: string | null = null;
|
||||
let installed = false;
|
||||
|
||||
try {
|
||||
const output = execSync(def.versionCommand, {
|
||||
timeout: 5000,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
// Extract version number from output
|
||||
const versionMatch = output.match(/(\d+\.\d+\.\d+(?:-\w+)?)/);
|
||||
version = versionMatch ? versionMatch[1] : output.split("\n")[0];
|
||||
installed = true;
|
||||
} catch {
|
||||
// Not installed or not runnable
|
||||
}
|
||||
|
||||
return { ...def, version, installed };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific agent by ID.
|
||||
*/
|
||||
export function getAgentById(id: string): CliAgentInfo | undefined {
|
||||
const agents = detectInstalledAgents();
|
||||
return agents.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents that are installed and available for ACP.
|
||||
*/
|
||||
export function getAvailableAgents(): CliAgentInfo[] {
|
||||
return detectInstalledAgents().filter((a) => a.installed);
|
||||
}
|
||||
+8
-2
@@ -79,6 +79,7 @@ const SCHEMA_SQL = `
|
||||
token_type TEXT,
|
||||
consecutive_use_count INTEGER DEFAULT 0,
|
||||
rate_limit_protection INTEGER DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -311,6 +312,10 @@ function ensureProviderConnectionsColumns(db: SqliteDatabase) {
|
||||
);
|
||||
console.log("[DB] Added provider_connections.rate_limit_protection column");
|
||||
}
|
||||
if (!columnNames.has("last_used_at")) {
|
||||
db.exec("ALTER TABLE provider_connections ADD COLUMN last_used_at TEXT");
|
||||
console.log("[DB] Added provider_connections.last_used_at column");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn("[DB] Failed to verify provider_connections schema:", message);
|
||||
@@ -483,7 +488,7 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) {
|
||||
rate_limited_until, health_check_interval, last_health_check_at,
|
||||
last_tested, api_key, id_token, provider_specific_data,
|
||||
expires_in, display_name, global_priority, default_model,
|
||||
token_type, consecutive_use_count, rate_limit_protection, created_at, updated_at
|
||||
token_type, consecutive_use_count, rate_limit_protection, last_used_at, created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @provider, @authType, @name, @email, @priority, @isActive,
|
||||
@accessToken, @refreshToken, @expiresAt, @tokenExpiresAt,
|
||||
@@ -492,7 +497,7 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) {
|
||||
@rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt,
|
||||
@lastTested, @apiKey, @idToken, @providerSpecificData,
|
||||
@expiresIn, @displayName, @globalPriority, @defaultModel,
|
||||
@tokenType, @consecutiveUseCount, @rateLimitProtection, @createdAt, @updatedAt
|
||||
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @createdAt, @updatedAt
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -533,6 +538,7 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) {
|
||||
defaultModel: conn.defaultModel || null,
|
||||
tokenType: conn.tokenType || null,
|
||||
consecutiveUseCount: conn.consecutiveUseCount || 0,
|
||||
lastUsedAt: conn.lastUsedAt || null,
|
||||
rateLimitProtection:
|
||||
conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0,
|
||||
createdAt: conn.createdAt || new Date().toISOString(),
|
||||
|
||||
+15
-2
@@ -115,7 +115,14 @@ export async function getAllCustomModels() {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function addCustomModel(providerId, modelId, modelName, source = "manual") {
|
||||
export async function addCustomModel(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
modelName?: string,
|
||||
source = "manual",
|
||||
apiFormat: "chat-completions" | "responses" = "chat-completions",
|
||||
supportedEndpoints: string[] = ["chat"]
|
||||
) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?")
|
||||
@@ -126,7 +133,13 @@ export async function addCustomModel(providerId, modelId, modelName, source = "m
|
||||
const exists = models.find((m) => m.id === modelId);
|
||||
if (exists) return exists;
|
||||
|
||||
const model = { id: modelId, name: modelName || modelId, source };
|
||||
const model = {
|
||||
id: modelId,
|
||||
name: modelName || modelId,
|
||||
source,
|
||||
apiFormat,
|
||||
supportedEndpoints,
|
||||
};
|
||||
models.push(model);
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user