Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce2c30c437 | |||
| d56fae0a7b | |||
| e45ef00bef | |||
| e9f31f7394 | |||
| 7c10a98eb2 | |||
| f260483101 | |||
| 389e6e5c9e | |||
| 1cfd5866be | |||
| c7ceac7f41 | |||
| cd6eca0424 | |||
| 8c6136fea0 | |||
| 9644444028 | |||
| 9c4154291d | |||
| 533f5f6da6 | |||
| 1b8de756cd | |||
| 650b415537 | |||
| 04b50329fc | |||
| 25aab8c55c | |||
| ceda2e70c1 | |||
| 2908303d4b | |||
| a9f69711c6 | |||
| a8ab16a720 | |||
| 8091b6b508 | |||
| a00ef0fc7e | |||
| 5ce6d615a4 | |||
| e06b69cdac | |||
| d261ae7883 | |||
| 6fa77a63d7 | |||
| f76c1b32d6 | |||
| 0aede2ef63 | |||
| 1e3a2e0a27 | |||
| 1bdabf43db | |||
| 05e568feb0 | |||
| 81e2519436 | |||
| ef623c9bb5 | |||
| da581525a6 | |||
| 6ff7b6570c | |||
| 8b2081837e | |||
| ce978b602a | |||
| 9b00f5d550 | |||
| d98ec59c79 | |||
| d79b55be5a | |||
| 1f9a402dcd | |||
| f9bcc9418b | |||
| 08256a3502 | |||
| 9b255e643a | |||
| ca1f918e9e | |||
| bb3fe1cd48 | |||
| 5d7772ecb0 | |||
| 56ce618eca | |||
| b0381c7542 | |||
| b328ed5fa9 | |||
| 7d72f1711f | |||
| d139b4557f | |||
| cd05e03d63 | |||
| e25029939d | |||
| 53de27417d | |||
| 74d3374d5c | |||
| 3ae00bebe4 | |||
| f9df72c4d7 | |||
| d0fb4576a8 | |||
| 0e4b0b3540 | |||
| df1105d0c6 | |||
| 44478c36a3 | |||
| fa267274b0 | |||
| 0db272946a | |||
| 91015b6499 | |||
| 2979a36a7c | |||
| 72f6d6b7b9 | |||
| d81a7bcedf | |||
| 8fbbe8b82b | |||
| 271f5f9c64 | |||
| 7c992ffd21 | |||
| fc2af8ba87 | |||
| c8a539a6cb | |||
| b7cdaa662a | |||
| 0a25930020 | |||
| 8643f4015f | |||
| 1854711aff | |||
| c905119d82 | |||
| c581ca8339 | |||
| ccf9d9214a | |||
| d37c8b732f | |||
| f707fc1cad | |||
| b1c713de60 | |||
| 0f13965391 | |||
| 8642e2b721 | |||
| 441534853b | |||
| 82f42c8664 | |||
| 5cd318fa9a | |||
| 5506071e9a | |||
| ced98f2da7 | |||
| 282ec65e8b | |||
| 8e06dc5ace | |||
| bfd3e2c01b | |||
| a1957f0923 | |||
| 11a02ba361 | |||
| 4643c19abc | |||
| a3369df62f | |||
| 4297c42597 | |||
| e06e7157ac | |||
| 22f9e6f4c0 | |||
| 4b7a9233e7 | |||
| 204839f702 | |||
| d15e3109ee | |||
| 8b513ee8f8 | |||
| 2c1488e65a | |||
| 8ebe1cc2d8 | |||
| b0d6c15e63 | |||
| 3a3c7a7968 | |||
| 783d7ae605 | |||
| bbf7a6b2f8 | |||
| 0fe6e24554 | |||
| 4bbaf55586 | |||
| cda765a02d | |||
| 36856b18db | |||
| 66f0a8f994 | |||
| 455231170f | |||
| 5faeb58ab0 | |||
| 056e4a88ff | |||
| 8fd944ccf7 | |||
| 86105a547c | |||
| 9806648c07 | |||
| 6186babdb3 | |||
| f2ecefb54a | |||
| 43bd529b78 | |||
| 9c82b3d4ca | |||
| b19e6a8e87 | |||
| e3a2bd75f3 | |||
| da39e1485f | |||
| 88cc53a4b0 | |||
| 245243c7e7 | |||
| 759ac0df3d | |||
| db8d97b6de | |||
| 27d66e4b3e | |||
| ca7854210d | |||
| c009c993c3 | |||
| 00188f75ae | |||
| 4d086542aa | |||
| 1555883633 | |||
| 8f2c0acc7e | |||
| 0e30d15c01 | |||
| da14390fe0 | |||
| 11c0cff4ef | |||
| e322376996 | |||
| 4fbe45f30a | |||
| 2cd0f60c3c | |||
| 1b354be827 | |||
| 7db280ee64 | |||
| 192c06cadf | |||
| ad7e7abda0 | |||
| 02ccb35e80 | |||
| a8a29e17c5 | |||
| 75a6d850fc |
@@ -4,73 +4,81 @@ 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 `npm install -g` + PM2.
|
||||
Deploy OmniRoute to the production VPS using `npm pack + scp` + PM2.
|
||||
|
||||
**VPS:** `69.164.221.35` (Akamai, Ubuntu 24.04, 1GB RAM + 2.5GB swap)
|
||||
**Local VPS:** `192.168.0.15` (same setup)
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
**PM2 entry:** `/usr/lib/node_modules/omniroute/app/server.js`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> PM2 runs from the global npm package at `/usr/lib/node_modules/omniroute`.
|
||||
> **DO NOT** use git clone or local copies. The `npm install -g` command handles
|
||||
> building, publishing, and installing the standalone app in one step.
|
||||
> The Next.js standalone build is at `app/server.js` inside that directory.
|
||||
> The npm registry rejects packages > 100MB, so deployment uses **npm pack + scp**.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Publish to npm
|
||||
### 1. Build + pack locally
|
||||
|
||||
Ensure the version in `package.json` is bumped and the package is published:
|
||||
Run the full build (includes hash-strip patch) and create the .tgz:
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
npm publish
|
||||
cd /home/diegosouzapw/dev/proxys/9router && npm run build:cli && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Install on VPS and restart PM2
|
||||
### 2. Copy to both VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g omniroute@latest && pm2 restart omniroute && pm2 save && echo '✅ Deploy complete!'"
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/ && scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
```
|
||||
|
||||
For the local VPS:
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && pm2 restart omniroute && pm2 save && echo '✅ Akamai done'"
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g omniroute@latest && pm2 restart omniroute && pm2 save && echo '✅ Deploy complete!'"
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && pm2 restart omniroute && pm2 save && echo '✅ Local done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
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/"
|
||||
ssh root@69.164.221.35 "pm2 list && cat \$(npm root -g)/omniroute/app/package.json | grep version | head -1 && curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:20128/"
|
||||
```
|
||||
|
||||
Expected: PM2 shows `online`, version matches published, HTTP returns `307` (redirect to login).
|
||||
Expected: PM2 shows `online`, version matches, HTTP returns `307`.
|
||||
|
||||
## 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
|
||||
1. `npm run build:cli` builds Next.js standalone → `app/` and strips Turbopack hashed require() calls from chunks
|
||||
2. `npm pack --ignore-scripts` packages without re-running the build
|
||||
3. `scp` transfers the .tgz to each VPS (~286MB)
|
||||
4. `npm install -g /tmp/omniroute-*.tgz --ignore-scripts` installs pre-built package
|
||||
5. PM2 runs `app/server.js` from `/usr/lib/node_modules/omniroute`
|
||||
|
||||
## PM2 Setup (one-time)
|
||||
|
||||
If PM2 needs to be reconfigured from scratch:
|
||||
## PM2 Setup (one-time — if reconfiguring 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
|
||||
pm2 delete omniroute ;
|
||||
cp /opt/omniroute-app/.env /usr/lib/node_modules/omniroute/.env &&
|
||||
PORT=20128 pm2 start /usr/lib/node_modules/omniroute/app/server.js --name omniroute --cwd /usr/lib/node_modules/omniroute/app &&
|
||||
pm2 save && pm2 startup
|
||||
"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Copy `.env` from the old installation first. For Akamai it was at `/opt/omniroute-app/.env`,
|
||||
> for the local VPS it was at `/root/omniroute-fresh/.env`.
|
||||
|
||||
## Notes
|
||||
|
||||
- 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.
|
||||
- Nginx proxies `omniroute.online` → `localhost:20128`.
|
||||
- The VPS has only 1GB RAM — builds happen locally via `npm publish`, not on the VPS.
|
||||
- `.env` should be placed at `/usr/lib/node_modules/omniroute/app/.env`
|
||||
- PM2 is configured with `pm2 startup` to auto-restart on reboot
|
||||
- Nginx proxies `omniroute.online` → `localhost:20128`
|
||||
- The VPS has only 1GB RAM — builds happen locally, never on the VPS
|
||||
|
||||
@@ -85,12 +85,49 @@ git push origin main --tags
|
||||
gh release create v2.x.y --title "v2.x.y — summary" --notes "..."
|
||||
```
|
||||
|
||||
### 8. Deploy to VPS (if requested)
|
||||
### 8. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
|
||||
|
||||
See `/deploy-vps` workflow for Akamai VPS or use npm for local VPS:
|
||||
> **CRITICAL**: Docker Hub and npm MUST always publish the same version.
|
||||
> The Docker image is built automatically via GitHub Actions when a new tag is pushed.
|
||||
> After pushing the tag in step 5-6, **verify the workflow runs**:
|
||||
|
||||
```bash
|
||||
ssh root@<VPS_IP> "npm install -g omniroute@2.x.y && pm2 restart omniroute"
|
||||
# Verify the Docker workflow triggered
|
||||
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 3
|
||||
|
||||
# Wait for the Docker build to complete (usually 5–10 min)
|
||||
gh run watch --repo diegosouzapw/OmniRoute
|
||||
|
||||
# After completion, verify on Docker Hub:
|
||||
# https://hub.docker.com/r/diegosouzapw/omniroute/tags
|
||||
```
|
||||
|
||||
If the Docker build was not triggered automatically, trigger it manually:
|
||||
|
||||
```bash
|
||||
gh workflow run docker-publish.yml --repo diegosouzapw/OmniRoute --ref v2.x.y
|
||||
```
|
||||
|
||||
### 9. Deploy to BOTH VPS environments (MANDATORY)
|
||||
|
||||
> Always deploy to **both** environments after every release.
|
||||
> See `/deploy-vps` workflow for detailed steps.
|
||||
|
||||
```bash
|
||||
# Build and pack locally
|
||||
cd /home/diegosouzapw/dev/proxys/9router && npm run build:cli && npm pack --ignore-scripts
|
||||
|
||||
# Deploy to LOCAL VPS (192.168.0.15)
|
||||
scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && pm2 restart omniroute && pm2 save"
|
||||
|
||||
# Deploy to AKAMAI VPS (69.164.221.35)
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && pm2 restart omniroute && pm2 save"
|
||||
|
||||
# Verify both
|
||||
curl -s -o /dev/null -w "LOCAL: HTTP %{http_code}\n" http://192.168.0.15:20128/
|
||||
curl -s -o /dev/null -w "AKAMAI: HTTP %{http_code}\n" http://69.164.221.35:20128/
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -21,8 +21,8 @@ This workflow fetches all open issues from the project's GitHub repository, clas
|
||||
|
||||
// turbo
|
||||
|
||||
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 100 --json number,title,labels,body,comments,createdAt,author`
|
||||
- Parse the JSON output to get a list of all open issues
|
||||
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 500 --json number,title,labels,body,comments,createdAt,author`
|
||||
- Parse the JSON output to get a list of **all** open issues
|
||||
- Sort by oldest first (FIFO)
|
||||
|
||||
### 3. Classify Each Issue
|
||||
|
||||
@@ -18,7 +18,11 @@ This workflow fetches all open PRs from the project's GitHub repository, perform
|
||||
|
||||
### 2. Fetch Open Pull Requests
|
||||
|
||||
- Navigate to `https://github.com/<owner>/<repo>/pulls` and scrape all open PRs
|
||||
// turbo
|
||||
|
||||
- Run: `gh pr list --repo <owner>/<repo> --state open --limit 500 --json number,title,author,headRefName,body,createdAt,additions,deletions,files`
|
||||
- This fetches **all** open PRs without restriction. Get the diff for each with:
|
||||
`gh pr diff <NUMBER> --repo <owner>/<repo>`
|
||||
- For each open PR, collect:
|
||||
- PR number, title, author, branch, number of commits, date
|
||||
- PR description/body
|
||||
|
||||
@@ -3,6 +3,12 @@ name: Publish to Docker Hub
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag to build (e.g. 2.6.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -15,30 +21,36 @@ jobs:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
|
||||
- name: Set up QEMU (for multi-arch builds)
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from release tag
|
||||
- name: Extract version from release tag or input
|
||||
id: version
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${VERSION#v}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${VERSION#v}"
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing Docker image: $IMAGE_NAME:$VERSION"
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: runner-base
|
||||
@@ -58,7 +70,7 @@ jobs:
|
||||
docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Update Docker Hub description
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -3,6 +3,12 @@ name: Publish to npm
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag to publish (e.g. 2.6.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -25,11 +31,14 @@ jobs:
|
||||
- name: Install dependencies (skip scripts to avoid heavy build)
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Sync version from release tag
|
||||
- name: Sync version from release tag or input
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
# Remove 'v' prefix if present (v2.1.0 -> 2.1.0)
|
||||
VERSION="${VERSION#v}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${VERSION#v}"
|
||||
fi
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
npx lint-staged
|
||||
node scripts/check-docs-sync.mjs
|
||||
npm run test:unit
|
||||
|
||||
+321
-1
@@ -2,7 +2,327 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.4.2] - 2026-03-14
|
||||
---
|
||||
|
||||
## [2.6.8] — 2026-03-17
|
||||
|
||||
> Sprint: Combo as Agent (system prompt + tool filter), Context Caching Protection, Auto-Update, Detailed Logs, MITM Kiro IDE.
|
||||
|
||||
### 🗄️ DB Migrations (zero-breaking — safe for existing users)
|
||||
|
||||
- **005_combo_agent_fields.sql**: `ALTER TABLE combos ADD COLUMN system_message TEXT DEFAULT NULL`, `tool_filter_regex TEXT DEFAULT NULL`, `context_cache_protection INTEGER DEFAULT 0`
|
||||
- **006_detailed_request_logs.sql**: New `request_detail_logs` table with 500-entry ring-buffer trigger, opt-in via settings toggle
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **feat(combo)**: System Message Override per Combo (#399 — `system_message` field replaces or injects system prompt before forwarding to provider)
|
||||
- **feat(combo)**: Tool Filter Regex per Combo (#399 — `tool_filter_regex` keeps only tools matching pattern; supports OpenAI + Anthropic formats)
|
||||
- **feat(combo)**: Context Caching Protection (#401 — `context_cache_protection` tags responses with `<omniModel>provider/model</omniModel>` and pins model for session continuity)
|
||||
- **feat(settings)**: Auto-Update via Settings (#320 — `GET /api/system/version` + `POST /api/system/update` — checks npm registry and updates in background with pm2 restart)
|
||||
- **feat(logs)**: Detailed Request Logs (#378 — captures full pipeline bodies at 4 stages: client request, translated request, provider response, client response — opt-in toggle, 64KB trim, 500-entry ring-buffer)
|
||||
- **feat(mitm)**: MITM Kiro IDE profile (#336 — `src/mitm/targets/kiro.ts` targets api.anthropic.com, reuses existing MITM infrastructure)
|
||||
|
||||
---
|
||||
|
||||
## [2.6.7] — 2026-03-17
|
||||
|
||||
> Sprint: SSE improvements, local provider_nodes extensions, proxy registry, Claude passthrough fixes.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **feat(health)**: Background health check for local `provider_nodes` with exponential backoff (30s→300s) and `Promise.allSettled` to avoid blocking (#423, @Regis-RCR)
|
||||
- **feat(embeddings)**: Route `/v1/embeddings` to local `provider_nodes` — `buildDynamicEmbeddingProvider()` with hostname validation (#422, @Regis-RCR)
|
||||
- **feat(audio)**: Route TTS/STT to local `provider_nodes` — `buildDynamicAudioProvider()` with SSRF protection (#416, @Regis-RCR)
|
||||
- **feat(proxy)**: Proxy registry, management APIs, and quota-limit generalization (#429, @Regis-RCR)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(sse)**: Strip Claude-specific fields (`metadata`, `anthropic_version`) when target is OpenAI-compat (#421, @prakersh)
|
||||
- **fix(sse)**: Extract Claude SSE usage (`input_tokens`, `output_tokens`, cache tokens) in passthrough stream mode (#420, @prakersh)
|
||||
- **fix(sse)**: Generate fallback `call_id` for tool calls with missing/empty IDs (#419, @prakersh)
|
||||
- **fix(sse)**: Claude-to-Claude passthrough — forward body completely untouched, no re-translation (#418, @prakersh)
|
||||
- **fix(sse)**: Filter orphaned `tool_result` items after Claude Code context compaction to avoid 400 errors (#417, @prakersh)
|
||||
- **fix(sse)**: Skip empty-name tool calls in Responses API translator to prevent `placeholder_tool` infinite loops (#415, @prakersh)
|
||||
- **fix(sse)**: Strip empty text content blocks before translation (#427, @prakersh)
|
||||
- **fix(api)**: Add `refreshable: true` to Claude OAuth test config (#428, @prakersh)
|
||||
|
||||
### 📦 Dependencies
|
||||
|
||||
- Bump `vitest`, `@vitest/*` and related devDependencies (#414, @dependabot)
|
||||
|
||||
---
|
||||
|
||||
## [2.6.6] — 2026-03-17
|
||||
|
||||
> Hotfix: Turbopack/Docker compatibility — remove `node:` protocol from all `src/` imports.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(build)**: Removed `node:` protocol prefix from `import` statements in 17 files under `src/`. The `node:fs`, `node:path`, `node:url`, `node:os` etc. imports caused `Ecmascript file had an error` on Turbopack builds (Next.js 15 Docker) and on upgrades from older npm global installs. Affected files: `migrationRunner.ts`, `core.ts`, `backup.ts`, `prompts.ts`, `dataPaths.ts`, and 12 others in `src/app/api/` and `src/lib/`.
|
||||
- **chore(workflow)**: Updated `generate-release.md` to make Docker Hub sync and dual-VPS deploy **mandatory** steps in every release.
|
||||
|
||||
---
|
||||
|
||||
## [2.6.5] — 2026-03-17
|
||||
|
||||
> Sprint: reasoning model param filtering, local provider 404 fix, Kilo Gateway provider, dependency bumps.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(api)**: Added **Kilo Gateway** (`api.kilo.ai`) as a new API Key provider (alias `kg`) — 335+ models, 6 free models, 3 auto-routing models (`kilo-auto/frontier`, `kilo-auto/balanced`, `kilo-auto/free`). Passthrough models supported via `/api/gateway/models` endpoint. (PR #408 by @Regis-RCR)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(sse)**: Strip unsupported parameters for reasoning models (o1, o1-mini, o1-pro, o3, o3-mini). Models in the `o1`/`o3` family reject `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `logprobs`, `top_logprobs`, and `n` with HTTP 400. Parameters are now stripped at the `chatCore` layer before forwarding. Uses a declarative `unsupportedParams` field per model and a precomputed O(1) Map for lookup. (PR #412 by @Regis-RCR)
|
||||
- **fix(sse)**: Local provider 404 now results in a **model-only lockout (5 seconds)** instead of a connection-level lockout (2 minutes). When a local inference backend (Ollama, LM Studio, oMLX) returns 404 for an unknown model, the connection remains active and other models continue working immediately. Also fixes a pre-existing bug where `model` was not passed to `markAccountUnavailable()`. Local providers detected via hostname (`localhost`, `127.0.0.1`, `::1`, extensible via `LOCAL_HOSTNAMES` env var). (PR #410 by @Regis-RCR)
|
||||
|
||||
### 📦 Dependencies
|
||||
|
||||
- `better-sqlite3` 12.6.2 → 12.8.0
|
||||
- `undici` 7.24.2 → 7.24.4
|
||||
- `https-proxy-agent` 7 → 8
|
||||
- `agent-base` 7 → 8
|
||||
|
||||
---
|
||||
|
||||
## [2.6.4] — 2026-03-17
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(providers)**: Removed non-existent model names across 5 providers:
|
||||
- **gemini / gemini-cli**: removed `gemini-3.1-pro/flash` and `gemini-3-*-preview` (don't exist in Google API v1beta); replaced with `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.0-flash`, `gemini-1.5-pro/flash`
|
||||
- **antigravity**: removed `gemini-3.1-pro-high/low` and `gemini-3-flash` (invalid internal aliases); replaced with real 2.x models
|
||||
- **github (Copilot)**: removed `gemini-3-flash-preview` and `gemini-3-pro-preview`; replaced with `gemini-2.5-flash`
|
||||
- **nvidia**: corrected `nvidia/llama-3.3-70b-instruct` → `meta/llama-3.3-70b-instruct` (NVIDIA NIM uses `meta/` namespace for Meta models); added `nvidia/llama-3.1-70b-instruct` and `nvidia/llama-3.1-405b-instruct`
|
||||
- **fix(db/combo)**: Updated `free-stack` combo on remote DB: removed `qw/qwen3-coder-plus` (expired refresh token), corrected `nvidia/llama-3.3-70b-instruct` → `nvidia/meta/llama-3.3-70b-instruct`, corrected `gemini/gemini-3.1-flash` → `gemini/gemini-2.5-flash`, added `if/deepseek-v3.2`
|
||||
|
||||
---
|
||||
|
||||
## [2.6.3] — 2026-03-16
|
||||
|
||||
> Sprint: zod/pino hash-strip baked into build pipeline, Synthetic provider added, VPS PM2 path corrected.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(build)**: Turbopack hash-strip now runs at **compile time** for ALL packages — not just `better-sqlite3`. Step 5.6 in `prepublish.mjs` walks every `.js` in `app/.next/server/` and strips the 16-char hex suffix from any hashed `require()`. Fixes `zod-dcb22c...`, `pino-...`, etc. MODULE_NOT_FOUND on global npm installs. Closes #398
|
||||
- **fix(deploy)**: PM2 on both VPS was pointing to stale git-clone directories. Reconfigured to `app/server.js` in the npm global package. Updated `/deploy-vps` workflow to use `npm pack + scp` (npm registry rejects 299MB packages).
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **feat(provider)**: Synthetic ([synthetic.new](https://synthetic.new)) — privacy-focused OpenAI-compatible inference. `passthroughModels: true` for dynamic HuggingFace model catalog. Initial models: Kimi K2.5, MiniMax M2.5, GLM 4.7, DeepSeek V3.2. (PR #404 by @Regis-RCR)
|
||||
|
||||
### 📋 Issues Closed
|
||||
|
||||
- **close #398**: npm hash regression — fixed by compile-time hash-strip in prepublish
|
||||
- **triage #324**: Bug screenshot without steps — requested reproduction details
|
||||
|
||||
---
|
||||
|
||||
## [2.6.2] — 2026-03-16
|
||||
|
||||
> Sprint: module hashing fully fixed, 2 PRs merged (Anthropic tools filter + custom endpoint paths), Alibaba Cloud DashScope provider added, 3 stale issues closed.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(build)**: Extended webpack `externals` hash-strip to cover ALL `serverExternalPackages`, not just `better-sqlite3`. Next.js 16 Turbopack hashes `zod`, `pino`, and every other server-external package into names like `zod-dcb22c6336e0bc69` that don't exist in `node_modules` at runtime. A HASH_PATTERN regex catch-all now strips the 16-char suffix and falls back to the base package name. Also added `NEXT_PRIVATE_BUILD_WORKER=0` in `prepublish.mjs` to reinforce webpack mode, plus a post-build scan that reports any remaining hashed refs. (#396, #398, PR #403)
|
||||
- **fix(chat)**: Anthropic-format tool names (`tool.name` without `.function` wrapper) were silently dropped by the empty-name filter introduced in #346. LiteLLM proxies requests with `anthropic/` prefix in Anthropic Messages API format, causing all tools to be filtered and Anthropic to return `400: tool_choice.any may only be specified while providing tools`. Fixed by falling back to `tool.name` when `tool.function.name` is absent. Added 8 regression unit tests. (PR #397)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **feat(api)**: Custom endpoint paths for OpenAI-compatible provider nodes — configure `chatPath` and `modelsPath` per node (e.g. `/v4/chat/completions`) in the provider connection UI. Includes a DB migration (`003_provider_node_custom_paths.sql`) and URL path sanitization (no `..` traversal, must start with `/`). (PR #400)
|
||||
- **feat(provider)**: Alibaba Cloud DashScope added as OpenAI-compatible provider. International endpoint: `dashscope-intl.aliyuncs.com/compatible-mode/v1`. 12 models: `qwen-max`, `qwen-plus`, `qwen-turbo`, `qwen3-coder-plus/flash`, `qwq-plus`, `qwq-32b`, `qwen3-32b`, `qwen3-235b-a22b`. Auth: Bearer API key.
|
||||
|
||||
### 📋 Issues Closed
|
||||
|
||||
- **close #323**: Cline connection error `[object Object]` — fixed in v2.3.7; instructed user to upgrade from v2.2.9
|
||||
- **close #337**: Kiro credit tracking — implemented in v2.5.5 (#381); pointed user to Dashboard → Usage
|
||||
- **triage #402**: ARM64 macOS DMG damaged — requested macOS version, exact error, and advised `xattr -d com.apple.quarantine` workaround
|
||||
|
||||
---
|
||||
|
||||
## [2.6.1] — 2026-03-15
|
||||
|
||||
> Critical startup fix: v2.6.0 global npm installs crashed with a 500 error due to a Turbopack/webpack module-name hashing bug in the Next.js 16 instrumentation hook.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(build)**: Force `better-sqlite3` to always be required by its exact package name in the webpack server bundle. Next.js 16 compiled the instrumentation hook into a separate chunk and emitted `require('better-sqlite3-<hash>')` — a hashed module name that doesn't exist in `node_modules` — even though the package was listed in `serverExternalPackages`. Added an explicit `externals` function to the server webpack config so the bundler always emits `require('better-sqlite3')`, resolving the startup `500 Internal Server Error` on clean global installs. (#394, PR #395)
|
||||
|
||||
### 🔧 CI
|
||||
|
||||
- **ci**: Added `workflow_dispatch` to `npm-publish.yml` with version sync safeguard for manual triggers (#392)
|
||||
- **ci**: Added `workflow_dispatch` to `docker-publish.yml`, updated GitHub Actions to latest versions (#392)
|
||||
|
||||
---
|
||||
|
||||
## [2.6.0] - 2026-03-15
|
||||
|
||||
> Issue resolution sprint: 4 bugs fixed, logs UX improved, Kiro credit tracking added.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(media)**: ComfyUI and SD WebUI no longer appear in the Media page provider list when unconfigured — fetches `/api/providers` on mount and hides local providers with no connections (#390)
|
||||
- **fix(auth)**: Round-robin no longer re-selects rate-limited accounts immediately after cooldown — `backoffLevel` is now used as primary sort key in the LRU rotation (#340)
|
||||
- **fix(oauth)**: iFlow (and other providers that redirect to their own UI) no longer leave the OAuth modal stuck at "Waiting for Authorization" — popup-closed detector auto-transitions to manual URL input mode (#344)
|
||||
- **fix(logs)**: Request log table is now readable in light mode — status badges, token counts, and combo tags use adaptive `dark:` color classes (#378)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **feat(kiro)**: Kiro credit tracking added to usage fetcher — queries `getUserCredits` from AWS CodeWhisperer endpoint (#337)
|
||||
|
||||
### 🛠 Chores
|
||||
|
||||
- **chore(tests)**: Aligned `test:plan3`, `test:fixes`, `test:security` to use same `tsx/esm` loader as `npm test` — eliminates module resolution false negatives in targeted runs (PR #386)
|
||||
|
||||
---
|
||||
|
||||
## [2.5.9] - 2026-03-15
|
||||
|
||||
> Codex native passthrough fix + route body validation hardening.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(codex)**: Preserve native Responses API passthrough for Codex clients — avoids unnecessary translation mutations (PR #387)
|
||||
- **fix(api)**: Validate request bodies on pricing/sync and task-routing routes — prevents crashes from malformed inputs (PR #388)
|
||||
- **fix(auth)**: JWT secrets persist across restarts via `src/lib/db/secrets.ts` — eliminates 401 errors after pm2 restart (PR #388)
|
||||
|
||||
---
|
||||
|
||||
## [2.5.8] - 2026-03-15
|
||||
|
||||
> Build fix: restore VPS connectivity broken by v2.5.7 incomplete publish.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(build)**: `scripts/prepublish.mjs` still used deprecated `--webpack` flag causing Next.js standalone build to fail silently — npm publish completed without `app/server.js`, breaking VPS deployment
|
||||
|
||||
---
|
||||
|
||||
## [2.5.7] - 2026-03-15
|
||||
|
||||
> Media playground error handling fixes.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(media)**: Transcription "API Key Required" false positive when audio contains no speech (music, silence) — now shows "No speech detected" instead
|
||||
- **fix(media)**: `upstreamErrorResponse` in `audioTranscription.ts` and `audioSpeech.ts` now returns proper JSON (`{error:{message}}`), enabling correct 401/403 credential error detection in the MediaPageClient
|
||||
- **fix(media)**: `parseApiError` now handles Deepgram's `err_msg` field and detects `"api key"` in error messages for accurate credential error classification
|
||||
|
||||
---
|
||||
|
||||
## [2.5.6] - 2026-03-15
|
||||
|
||||
> Critical security/auth fixes: Antigravity OAuth broken + JWT sessions lost after restart.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(oauth) #384**: Antigravity Google OAuth now correctly sends `client_secret` to the token endpoint. The fallback for `ANTIGRAVITY_OAUTH_CLIENT_SECRET` was an empty string, which is falsy — so `client_secret` was never included in the request, causing `"client_secret is missing"` errors for all users without a custom env var. Closes #383.
|
||||
- **fix(auth) #385**: `JWT_SECRET` is now persisted to SQLite (`namespace='secrets'`) on first generation and reloaded on subsequent starts. Previously, a new random secret was generated each process startup, invalidating all existing cookies/sessions after any restart or upgrade. Affects both `JWT_SECRET` and `API_KEY_SECRET`. Closes #382.
|
||||
|
||||
---
|
||||
|
||||
## [2.5.5] - 2026-03-15
|
||||
|
||||
> Model list dedup fix, Electron standalone build hardening, and Kiro credit tracking.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(models) #380**: `GET /api/models` now includes provider aliases when building the active-provider filter — models for `claude` (alias `cc`) and `github` (alias `gh`) were always shown regardless of whether a connection was configured, because `PROVIDER_MODELS` keys are aliases but DB connections are stored under provider IDs. Fixed by expanding each active provider ID to also include its alias via `PROVIDER_ID_TO_ALIAS`. Closes #353.
|
||||
- **fix(electron) #379**: New `scripts/prepare-electron-standalone.mjs` stages a dedicated `/.next/electron-standalone` bundle before Electron packaging. Aborts with a clear error if `node_modules` is a symlink (electron-builder would ship a runtime dependency on the build machine). Cross-platform path sanitization via `path.basename`. By @kfiramar.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(kiro) #381**: Kiro credit balance tracking — usage endpoint now returns credit data for Kiro accounts by calling `codewhisperer.us-east-1.amazonaws.com/getUserCredits` (same endpoint Kiro IDE uses internally). Returns remaining credits, total allowance, renewal date, and subscription tier. Closes #337.
|
||||
|
||||
## [2.5.4] - 2026-03-15
|
||||
|
||||
> Logger startup fix, login bootstrap security fix, and dev HMR reliability improvement. CI infrastructure hardened.
|
||||
|
||||
### 🐛 Bug Fixes (PRs #374, #375, #376 by @kfiramar)
|
||||
|
||||
- **fix(logger) #376**: Restore pino transport logger path — `formatters.level` combined with `transport.targets` is rejected by pino. Transport-backed configs now strip the level formatter via `getTransportCompatibleConfig()`. Also corrects numeric level mapping in `/api/logs/console`: `30→info, 40→warn, 50→error` (was shifted by one).
|
||||
- **fix(login) #375**: Login page now bootstraps from the public `/api/settings/require-login` endpoint instead of the protected `/api/settings`. In password-protected setups, the pre-auth page was receiving a 401 and falling back to safe defaults unnecessarily. The public route now returns all bootstrap metadata (`requireLogin`, `hasPassword`, `setupComplete`) with a conservative 200 fallback on error.
|
||||
- **fix(dev) #374**: Add `localhost` and `127.0.0.1` to `allowedDevOrigins` in `next.config.mjs` — HMR websocket was blocked when accessing the app via loopback address, producing repeated cross-origin warnings.
|
||||
|
||||
### 🔧 CI & Infrastructure
|
||||
|
||||
- **ESLint OOM fix**: `eslint.config.mjs` now ignores `vscode-extension/**`, `electron/**`, `docs/**`, `app/.next/**`, and `clipr/**` — ESLint was crashing with a JS heap OOM by scanning VS Code binary blobs and compiled chunks.
|
||||
- **Unit test fix**: Removed stale `ALTER TABLE provider_connections ADD COLUMN "group"` from 2 test files — column is now part of the base schema (added in #373), causing `SQLITE_ERROR: duplicate column name` on every CI run.
|
||||
- **Pre-commit hook**: Added `npm run test:unit` to `.husky/pre-commit` — unit tests now block broken commits before they reach CI.
|
||||
|
||||
## [2.5.3] - 2026-03-14
|
||||
|
||||
> Critical bugfixes: DB schema migration, startup env loading, provider error state clearing, and i18n tooltip fix. Code quality improvements on top of each PR.
|
||||
|
||||
### 🐛 Bug Fixes (PRs #369, #371, #372, #373 by @kfiramar)
|
||||
|
||||
- **fix(db) #373**: Add `provider_connections.group` column to base schema + backfill migration for existing databases — column was used in all queries but missing from schema definition
|
||||
- **fix(i18n) #371**: Replace non-existent `t("deleteConnection")` key with existing `providers.delete` key — fixes `MISSING_MESSAGE: providers.deleteConnection` runtime error on provider detail page
|
||||
- **fix(auth) #372**: Clear stale error metadata (`errorCode`, `lastErrorType`, `lastErrorSource`) from provider accounts after genuine recovery — previously, recovered accounts kept appearing as failed
|
||||
- **fix(startup) #369**: Unify env loading across `npm run start`, `run-standalone.mjs`, and Electron to respect `DATA_DIR/.env → ~/.omniroute/.env → ./.env` priority — prevents generating a new `STORAGE_ENCRYPTION_KEY` over an existing encrypted database
|
||||
|
||||
### 🔧 Code Quality
|
||||
|
||||
- Documented `result.success` vs `response?.ok` patterns in `auth.ts` (both intentional, now explained)
|
||||
- Normalized `overridePath?.trim()` in `electron/main.js` to match `bootstrap-env.mjs`
|
||||
- Added `preferredEnv` merge order comment in Electron startup
|
||||
|
||||
> Codex account quota policy with auto-rotation, fast tier toggle, gpt-5.4 model, and analytics label fix.
|
||||
|
||||
### ✨ New Features (PRs #366, #367, #368)
|
||||
|
||||
- **Codex Quota Policy (PR #366)**: Per-account 5h/weekly quota window toggles in Provider dashboard. Accounts are automatically skipped when enabled windows reach 90% threshold and re-admitted after `resetAt`. Includes `quotaCache.ts` with side-effect free status getter.
|
||||
- **Codex Fast Tier Toggle (PR #367)**: Dashboard → Settings → Codex Service Tier. Default-off toggle injects `service_tier: "flex"` only for Codex requests, reducing cost ~80%. Full stack: UI tab + API endpoint + executor + translator + startup restore.
|
||||
- **gpt-5.4 Model (PR #368)**: Adds `cx/gpt-5.4` and `codex/gpt-5.4` to the Codex model registry. Regression test included.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix #356**: Analytics charts (Top Provider, By Account, Provider Breakdown) now display human-readable provider names/labels instead of raw internal IDs for OpenAI-compatible providers.
|
||||
|
||||
> Major release: strict-random routing strategy, API key access controls, connection groups, external pricing sync, and critical bug fixes for thinking models, combo testing, and tool name validation.
|
||||
|
||||
### ✨ New Features (PRs #363 & #365)
|
||||
|
||||
- **Strict-Random Routing Strategy**: Fisher-Yates shuffle deck with anti-repeat guarantee and mutex serialization for concurrent requests. Independent decks per combo and per provider.
|
||||
- **API Key Access Controls**: `allowedConnections` (restrict which connections a key can use), `is_active` (enable/disable key with 403), `accessSchedule` (time-based access control), `autoResolve` toggle, rename keys via PATCH.
|
||||
- **Connection Groups**: Group provider connections by environment. Accordion view in Limits page with localStorage persistence and smart auto-switch.
|
||||
- **External Pricing Sync (LiteLLM)**: 3-tier pricing resolution (user overrides → synced → defaults). Opt-in via `PRICING_SYNC_ENABLED=true`. MCP tool `omniroute_sync_pricing`. 23 new tests.
|
||||
- **i18n**: 30 languages updated with strict-random strategy, API key management strings. pt-BR fully translated.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix #355**: Stream idle timeout increased from 60s to 300s — prevents aborting extended-thinking models (claude-opus-4-6, o3, etc.) during long reasoning phases. Configurable via `STREAM_IDLE_TIMEOUT_MS`.
|
||||
- **fix #350**: Combo test now bypasses `REQUIRE_API_KEY=true` using internal header, and uses OpenAI-compatible format universally. Timeout extended from 15s to 20s.
|
||||
- **fix #346**: Tools with empty `function.name` (forwarded by Claude Code) are now filtered before upstream providers receive them, preventing "Invalid input[N].name: empty string" errors.
|
||||
|
||||
### 🗑️ Closed Issues
|
||||
|
||||
- **#341**: Debug section removed — replacement is `/dashboard/logs` and `/dashboard/health`.
|
||||
|
||||
> API Key Round-Robin support for multi-key provider setups, and confirmation of wildcard routing and quota window rolling already in place.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **API Key Round-Robin (T07)**: Provider connections can now hold multiple API keys (Edit Connection → Extra API Keys). Requests rotate round-robin between primary + extra keys via `providerSpecificData.extraApiKeys[]`. Keys are held in-memory indexed per connection — no DB schema changes required.
|
||||
|
||||
### 📝 Already Implemented (confirmed in audit)
|
||||
|
||||
- **Wildcard Model Routing (T13)**: `wildcardRouter.ts` with glob-style wildcard matching (`gpt*`, `claude-?-sonnet`, etc.) is already integrated into `model.ts` with specificity ranking.
|
||||
- **Quota Window Rolling (T08)**: `accountFallback.ts:isModelLocked()` already auto-advances the window — if `Date.now() > entry.until`, lock is deleted immediately (no stale blocking).
|
||||
|
||||
> UI polish, routing strategy additions, and graceful error handling for usage limits.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Fill-First & P2C Routing Strategies**: Added `fill-first` (drain quota before moving on) and `p2c` (Power-of-Two-Choices low-latency selection) to combo strategy picker, with full guidance panels and color-coded badges.
|
||||
- **Free Stack Preset Models**: Creating a combo with the Free Stack template now auto-fills 7 best-in-class free provider models (Gemini CLI, Kiro, iFlow×2, Qwen, NVIDIA NIM, Groq). Users just activate the providers and get a $0/month combo out-of-the-box.
|
||||
- **Wider Combo Modal**: Create/Edit combo modal now uses `max-w-4xl` for comfortable editing of large combos.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Limits page HTTP 500 for Codex & GitHub**: `getCodexUsage()` and `getGitHubUsage()` now return a user-friendly message when the provider returns 401/403 (expired token), instead of throwing and causing a 500 error on the Limits page.
|
||||
- **MaintenanceBanner false-positive**: Banner no longer shows "Server is unreachable" spuriously on page load. Fixed by calling `checkHealth()` immediately on mount and removing stale `show`-state closure.
|
||||
- **Provider icon tooltips**: Edit (pencil) and delete icon buttons in the provider connection row now have native HTML tooltips — all 6 action icons are now self-documented.
|
||||
|
||||
> Multiple improvements from community issue analysis, new provider support, bug fixes for token tracking, model routing, and streaming reliability.
|
||||
|
||||
|
||||
@@ -1292,6 +1292,23 @@ Models:
|
||||
cx/gpt-5.1-codex-max
|
||||
```
|
||||
|
||||
#### Codex Account Limit Management (5h + Weekly)
|
||||
|
||||
Each Codex account now has policy toggles in `Dashboard -> Providers`:
|
||||
|
||||
- `5h` (ON/OFF): enforce the 5-hour window threshold policy.
|
||||
- `Weekly` (ON/OFF): enforce the weekly window threshold policy.
|
||||
- Threshold behavior: when an enabled window reaches >=90% usage, that account is skipped.
|
||||
- Rotation behavior: OmniRoute routes to the next eligible Codex account automatically.
|
||||
- Reset behavior: when the provider `resetAt` time passes, the account becomes eligible again automatically.
|
||||
|
||||
Scenarios:
|
||||
|
||||
- `5h ON` + `Weekly ON`: account is skipped when either window reaches threshold.
|
||||
- `5h OFF` + `Weekly ON`: only weekly usage can block the account.
|
||||
- `5h ON` + `Weekly OFF`: only 5-hour usage can block the account.
|
||||
- `resetAt` passed: account re-enters rotation automatically (no manual re-enable).
|
||||
|
||||
### Gemini CLI (FREE 180K/month!)
|
||||
|
||||
```bash
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -138,5 +138,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-0001: Proxy Registry + Usage Control Generalization
|
||||
|
||||
Date: 2026-03-17
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
OmniRoute sudah punya:
|
||||
|
||||
- Proxy assignment berbasis config-map (`global`, `providers`, `combos`, `keys`).
|
||||
- Quota-aware selection khusus provider tertentu (notably `codex`).
|
||||
|
||||
Gap utama:
|
||||
|
||||
- Proxy belum menjadi aset reusable yang bisa di-manage sebagai entitas (metadata, where-used, safe delete).
|
||||
- Usage policy belum konsisten lintas provider.
|
||||
- Error contract API belum seragam untuk endpoint manajemen.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Tambah **Proxy Registry** sebagai domain baru di DB (`proxy_registry`, `proxy_assignments`).
|
||||
2. Pertahankan kompatibilitas assignment lama (fallback ke `proxyConfig` lama).
|
||||
3. Resolver runtime pakai prioritas:
|
||||
- account -> provider -> global (registry)
|
||||
- fallback ke legacy resolver jika registry belum ada assignment
|
||||
4. Wajib redaction kredensial di output list registry default.
|
||||
5. Standarkan error JSON untuk endpoint manajemen proxy agar konsisten dan punya `requestId`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positif:
|
||||
|
||||
- Proxy reusable dan bisa dilacak pemakaiannya.
|
||||
- Safe delete bisa ditegakkan (409 saat masih dipakai).
|
||||
- Migrasi bertahap tanpa breaking change runtime.
|
||||
|
||||
Negatif:
|
||||
|
||||
- Ada dual-source sementara (registry + legacy config) sampai migrasi selesai.
|
||||
- Butuh endpoint assignment tambahan dan pemetaan scope yang konsisten.
|
||||
|
||||
## Follow-up
|
||||
|
||||
- Migrasi UI provider/account dari input raw proxy ke selector registry.
|
||||
- Tambah health telemetry per proxy dan alerting.
|
||||
- Generalisasi usage control ke provider lain melalui interface policy yang sama.
|
||||
@@ -0,0 +1,32 @@
|
||||
# ADR-0002: Error Contract for Management Endpoints
|
||||
|
||||
Date: 2026-03-17
|
||||
Status: Accepted
|
||||
|
||||
## Decision
|
||||
|
||||
Management endpoints (proxy config, proxy registry, and proxy assignments) return a uniform error body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "Human-readable summary",
|
||||
"type": "invalid_request | not_found | conflict | server_error",
|
||||
"details": {}
|
||||
},
|
||||
"requestId": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
## Status Mapping
|
||||
|
||||
- 400: invalid request / validation failure
|
||||
- 404: resource not found
|
||||
- 409: resource conflict (for example, proxy still assigned)
|
||||
- 500: unexpected server error
|
||||
|
||||
## Notes
|
||||
|
||||
- `requestId` is mandatory for log correlation.
|
||||
- `details` is optional and only used for safe validation details.
|
||||
- Sensitive secrets (proxy credentials, tokens) must never appear in `message` or `details`.
|
||||
@@ -0,0 +1,16 @@
|
||||
# ADR-0003: Security Checklist for Proxy Registry and Usage Controls
|
||||
|
||||
Date: 2026-03-17
|
||||
Status: Accepted
|
||||
|
||||
## Checklist
|
||||
|
||||
- Validate all management payloads with Zod.
|
||||
- Reject malformed scope assignment updates with status 400.
|
||||
- Reject deleting an in-use proxy with status 409 unless forced.
|
||||
- Never expose proxy username/password in list responses by default.
|
||||
- Never log raw credentials or token values.
|
||||
- Keep error responses free from internal stack traces.
|
||||
- Protect management endpoints with existing auth middleware policy.
|
||||
- Audit mutating operations: create/update/delete/assign/migrate.
|
||||
- Ensure resolver fallback to legacy config while migration is in transition.
|
||||
@@ -9,4 +9,13 @@ This directory contains machine-assisted translations based on the English docs.
|
||||
- **TROUBLESHOOTING.md**: 🇺🇸 [English](../TROUBLESHOOTING.md) | 🇧🇷 [Português (Brasil)](./pt-BR/TROUBLESHOOTING.md) | 🇪🇸 [Español](./es/TROUBLESHOOTING.md) | 🇫🇷 [Français](./fr/TROUBLESHOOTING.md) | 🇮🇹 [Italiano](./it/TROUBLESHOOTING.md) | 🇷🇺 [Русский](./ru/TROUBLESHOOTING.md) | 🇨🇳 [中文 (简体)](./zh-CN/TROUBLESHOOTING.md) | 🇩🇪 [Deutsch](./de/TROUBLESHOOTING.md) | 🇮🇳 [हिन्दी](./in/TROUBLESHOOTING.md) | 🇹🇭 [ไทย](./th/TROUBLESHOOTING.md) | 🇺🇦 [Українська](./uk-UA/TROUBLESHOOTING.md) | 🇸🇦 [العربية](./ar/TROUBLESHOOTING.md) | 🇯🇵 [日本語](./ja/TROUBLESHOOTING.md) | 🇻🇳 [Tiếng Việt](./vi/TROUBLESHOOTING.md) | 🇧🇬 [Български](./bg/TROUBLESHOOTING.md) | 🇩🇰 [Dansk](./da/TROUBLESHOOTING.md) | 🇫🇮 [Suomi](./fi/TROUBLESHOOTING.md) | 🇮🇱 [עברית](./he/TROUBLESHOOTING.md) | 🇭🇺 [Magyar](./hu/TROUBLESHOOTING.md) | 🇮🇩 [Bahasa Indonesia](./id/TROUBLESHOOTING.md) | 🇰🇷 [한국어](./ko/TROUBLESHOOTING.md) | 🇲🇾 [Bahasa Melayu](./ms/TROUBLESHOOTING.md) | 🇳🇱 [Nederlands](./nl/TROUBLESHOOTING.md) | 🇳🇴 [Norsk](./no/TROUBLESHOOTING.md) | 🇵🇹 [Português (Portugal)](./pt/TROUBLESHOOTING.md) | 🇷🇴 [Română](./ro/TROUBLESHOOTING.md) | 🇵🇱 [Polski](./pl/TROUBLESHOOTING.md) | 🇸🇰 [Slovenčina](./sk/TROUBLESHOOTING.md) | 🇸🇪 [Svenska](./sv/TROUBLESHOOTING.md) | 🇵🇭 [Filipino](./phi/TROUBLESHOOTING.md)
|
||||
- **USER_GUIDE.md**: 🇺🇸 [English](../USER_GUIDE.md) | 🇧🇷 [Português (Brasil)](./pt-BR/USER_GUIDE.md) | 🇪🇸 [Español](./es/USER_GUIDE.md) | 🇫🇷 [Français](./fr/USER_GUIDE.md) | 🇮🇹 [Italiano](./it/USER_GUIDE.md) | 🇷🇺 [Русский](./ru/USER_GUIDE.md) | 🇨🇳 [中文 (简体)](./zh-CN/USER_GUIDE.md) | 🇩🇪 [Deutsch](./de/USER_GUIDE.md) | 🇮🇳 [हिन्दी](./in/USER_GUIDE.md) | 🇹🇭 [ไทย](./th/USER_GUIDE.md) | 🇺🇦 [Українська](./uk-UA/USER_GUIDE.md) | 🇸🇦 [العربية](./ar/USER_GUIDE.md) | 🇯🇵 [日本語](./ja/USER_GUIDE.md) | 🇻🇳 [Tiếng Việt](./vi/USER_GUIDE.md) | 🇧🇬 [Български](./bg/USER_GUIDE.md) | 🇩🇰 [Dansk](./da/USER_GUIDE.md) | 🇫🇮 [Suomi](./fi/USER_GUIDE.md) | 🇮🇱 [עברית](./he/USER_GUIDE.md) | 🇭🇺 [Magyar](./hu/USER_GUIDE.md) | 🇮🇩 [Bahasa Indonesia](./id/USER_GUIDE.md) | 🇰🇷 [한국어](./ko/USER_GUIDE.md) | 🇲🇾 [Bahasa Melayu](./ms/USER_GUIDE.md) | 🇳🇱 [Nederlands](./nl/USER_GUIDE.md) | 🇳🇴 [Norsk](./no/USER_GUIDE.md) | 🇵🇹 [Português (Portugal)](./pt/USER_GUIDE.md) | 🇷🇴 [Română](./ro/USER_GUIDE.md) | 🇵🇱 [Polski](./pl/USER_GUIDE.md) | 🇸🇰 [Slovenčina](./sk/USER_GUIDE.md) | 🇸🇪 [Svenska](./sv/USER_GUIDE.md) | 🇵🇭 [Filipino](./phi/USER_GUIDE.md)
|
||||
|
||||
## Recent note: Codex account limit policy
|
||||
|
||||
Documentation now includes Codex account-level quota policy behavior:
|
||||
|
||||
- Per-account toggles: `5h` and `Weekly` (ON/OFF).
|
||||
- Threshold policy: enabled window reaching >=90% marks account as ineligible for selection.
|
||||
- Auto-rotation: traffic moves to the next eligible Codex account.
|
||||
- Auto-reuse: account becomes eligible again after provider `resetAt` passes.
|
||||
|
||||
Generated on 2026-02-26.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (العربية)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Български)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Dansk)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Deutsch)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Español)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Suomi)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Français)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (עברית)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Magyar)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Bahasa Indonesia)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1059,6 +1059,23 @@ Models:
|
||||
cx/gpt-5.1-codex-max
|
||||
```
|
||||
|
||||
#### Manajemen Limit Akun Codex (5h + Mingguan)
|
||||
|
||||
Setiap akun Codex sekarang punya toggle kebijakan di `Dashboard -> Providers`:
|
||||
|
||||
- `5h` (ON/OFF): menerapkan kebijakan ambang untuk jendela 5 jam.
|
||||
- `Weekly` (ON/OFF): menerapkan kebijakan ambang untuk jendela mingguan.
|
||||
- Perilaku ambang: saat jendela yang aktif mencapai >=90% penggunaan, akun tersebut di-skip.
|
||||
- Perilaku rotasi: OmniRoute otomatis merutekan ke akun Codex berikutnya yang masih eligible.
|
||||
- Perilaku reset: saat waktu `resetAt` provider sudah lewat, akun otomatis bisa dipakai lagi.
|
||||
|
||||
Skenario:
|
||||
|
||||
- `5h ON` + `Weekly ON`: akun di-skip jika salah satu jendela mencapai ambang.
|
||||
- `5h OFF` + `Weekly ON`: hanya penggunaan mingguan yang bisa memblokir akun.
|
||||
- `5h ON` + `Weekly OFF`: hanya penggunaan 5 jam yang bisa memblokir akun.
|
||||
- `resetAt` sudah lewat: akun otomatis masuk rotasi lagi (tanpa enable manual).
|
||||
|
||||
### Gemini CLI (GRATIS 180K/bulan!)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (हिन्दी)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Italiano)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (日本語)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (한국어)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Bahasa Melayu)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Nederlands)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Norsk)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Filipino)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Polski)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Português (Portugal))
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Română)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Русский)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Slovenčina)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Svenska)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (ไทย)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Українська)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (Tiếng Việt)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (中文(简体))
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.4.2
|
||||
version: 2.6.8
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
+77
-6
@@ -64,6 +64,64 @@ let serverPort = 20128;
|
||||
|
||||
const getServerUrl = () => `http://localhost:${serverPort}`;
|
||||
|
||||
function resolveDataDir(overridePath, env = process.env) {
|
||||
if (overridePath && overridePath.trim()) return path.resolve(overridePath);
|
||||
|
||||
const configured = env.DATA_DIR?.trim();
|
||||
if (configured) return path.resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = env.APPDATA || path.join(require("os").homedir(), "AppData", "Roaming");
|
||||
return path.join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return path.join(path.resolve(xdg), "omniroute");
|
||||
|
||||
return path.join(require("os").homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
function getPreferredEnvFilePath(env = process.env) {
|
||||
const candidates = [];
|
||||
|
||||
if (env.DATA_DIR?.trim()) {
|
||||
candidates.push(path.join(path.resolve(env.DATA_DIR.trim()), ".env"));
|
||||
}
|
||||
|
||||
candidates.push(path.join(resolveDataDir(null, env), ".env"));
|
||||
candidates.push(path.join(process.cwd(), ".env"));
|
||||
|
||||
return candidates.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
|
||||
function hasEncryptedCredentials(dbPath) {
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR api_key LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get();
|
||||
return !!row;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-Updater Configuration ──────────────────────────────
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
@@ -386,12 +444,10 @@ function startNextServer() {
|
||||
// ── Zero-config bootstrap: auto-generate required secrets ─────────────────
|
||||
// Electron uses CJS — cannot dynamically import ESM bootstrap-env.mjs.
|
||||
// This mirrors bootstrap-env.mjs logic synchronously:
|
||||
// 1. Read persisted secrets from userData/server.env
|
||||
// 1. Read persisted secrets from the resolved DATA_DIR/server.env
|
||||
// 2. Generate missing secrets with crypto.randomBytes()
|
||||
// 3. Persist back to userData/server.env for future restarts
|
||||
// 3. Persist back to DATA_DIR/server.env for future restarts
|
||||
const crypto = require("crypto");
|
||||
const userDataDir = app.getPath("userData");
|
||||
const serverEnvPath = path.join(userDataDir, "server.env");
|
||||
|
||||
// Parse a simple KEY=VALUE file
|
||||
function parseEnvFile(filePath) {
|
||||
@@ -407,8 +463,12 @@ function startNextServer() {
|
||||
return env;
|
||||
}
|
||||
|
||||
const preferredEnvPath = getPreferredEnvFilePath(process.env);
|
||||
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
|
||||
const dataDir = resolveDataDir(null, { ...preferredEnv, ...process.env });
|
||||
const serverEnvPath = path.join(dataDir, "server.env");
|
||||
const persisted = parseEnvFile(serverEnvPath);
|
||||
const serverEnv = { ...process.env, ...persisted };
|
||||
const serverEnv = { ...persisted, ...preferredEnv, ...process.env };
|
||||
let changed = false;
|
||||
|
||||
if (!serverEnv.JWT_SECRET) {
|
||||
@@ -417,6 +477,16 @@ function startNextServer() {
|
||||
console.log("[Electron] ✨ JWT_SECRET auto-generated");
|
||||
}
|
||||
if (!serverEnv.STORAGE_ENCRYPTION_KEY) {
|
||||
if (hasEncryptedCredentials(path.join(dataDir, "storage.sqlite"))) {
|
||||
console.error(
|
||||
`[Electron] Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${path.join(
|
||||
dataDir,
|
||||
"storage.sqlite"
|
||||
)}. Restore the key via ${preferredEnvPath || "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
|
||||
);
|
||||
sendToRenderer("server-status", { status: "error", port: serverPort });
|
||||
return;
|
||||
}
|
||||
serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto
|
||||
.randomBytes(32)
|
||||
.toString("hex");
|
||||
@@ -432,7 +502,7 @@ function startNextServer() {
|
||||
if (changed) {
|
||||
serverEnv.OMNIROUTE_BOOTSTRAPPED = "true";
|
||||
try {
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const lines = [
|
||||
"# Auto-generated by OmniRoute bootstrap",
|
||||
"",
|
||||
@@ -454,6 +524,7 @@ function startNextServer() {
|
||||
cwd: NEXT_SERVER_PATH,
|
||||
env: {
|
||||
...serverEnv,
|
||||
DATA_DIR: dataDir,
|
||||
PORT: String(serverPort),
|
||||
NODE_ENV: "production",
|
||||
},
|
||||
|
||||
+13
-18
@@ -12,13 +12,14 @@
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --no-sandbox",
|
||||
"build": "electron-builder",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:mac": "electron-builder --mac",
|
||||
"build:mac-x64": "electron-builder --mac --x64",
|
||||
"build:mac-arm64": "electron-builder --mac --arm64",
|
||||
"build:linux": "electron-builder --linux",
|
||||
"pack": "electron-builder --dir"
|
||||
"prepare:bundle": "node ../scripts/prepare-electron-standalone.mjs",
|
||||
"build": "npm run prepare:bundle && electron-builder",
|
||||
"build:win": "npm run prepare:bundle && electron-builder --win",
|
||||
"build:mac": "npm run prepare:bundle && electron-builder --mac",
|
||||
"build:mac-x64": "npm run prepare:bundle && electron-builder --mac --x64",
|
||||
"build:mac-arm64": "npm run prepare:bundle && electron-builder --mac --arm64",
|
||||
"build:linux": "npm run prepare:bundle && electron-builder --linux",
|
||||
"pack": "npm run prepare:bundle && electron-builder --dir"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.3"
|
||||
@@ -47,24 +48,18 @@
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../.next/standalone",
|
||||
"from": "../.next/electron-standalone",
|
||||
"to": "app",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../.next/static",
|
||||
"to": "app/.next/static",
|
||||
"from": "assets",
|
||||
"to": "assets",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../public",
|
||||
"to": "app/public",
|
||||
"filter": [
|
||||
"**/*"
|
||||
"icon.png",
|
||||
"tray-icon.png"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
+19
-1
@@ -24,16 +24,34 @@ const eslintConfig = [
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
},
|
||||
},
|
||||
// Global ignores (open-sse and tests REMOVED — now linted)
|
||||
// Global ignores — keep ESLint scoped to source files only
|
||||
{
|
||||
ignores: [
|
||||
// Next.js build output
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Scripts and binaries
|
||||
"scripts/**",
|
||||
"bin/**",
|
||||
// Dependencies
|
||||
"node_modules/**",
|
||||
// VS Code extension and its large test fixtures
|
||||
"vscode-extension/**",
|
||||
// Electron app
|
||||
"electron/**",
|
||||
// Docs
|
||||
"docs/**",
|
||||
// Open-SSE compiled/bundled output
|
||||
"open-sse/mcp-server/dist/**",
|
||||
// Playwright test output
|
||||
"playwright-report/**",
|
||||
"test-results/**",
|
||||
// Subdirectory .next build output (app/ subdir)
|
||||
"app/.next/**",
|
||||
// CLI package copy directory
|
||||
"clipr/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
+89
-7
@@ -4,11 +4,32 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
turbopack: {},
|
||||
// Turbopack config: redirect native modules to stubs at build time
|
||||
turbopack: {
|
||||
resolveAlias: {
|
||||
// Point mitm/manager to a stub during build (native child_process/fs can't be bundled)
|
||||
"@/mitm/manager": "./src/mitm/manager.stub.ts",
|
||||
},
|
||||
},
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["better-sqlite3", "zod"],
|
||||
serverExternalPackages: [
|
||||
"better-sqlite3",
|
||||
"zod",
|
||||
"child_process",
|
||||
"fs",
|
||||
"path",
|
||||
"os",
|
||||
"crypto",
|
||||
"net",
|
||||
"tls",
|
||||
"http",
|
||||
"https",
|
||||
"stream",
|
||||
"buffer",
|
||||
"util",
|
||||
],
|
||||
transpilePackages: ["@omniroute/open-sse"],
|
||||
allowedDevOrigins: ["192.168.*"],
|
||||
allowedDevOrigins: ["localhost", "127.0.0.1", "192.168.*"],
|
||||
typescript: {
|
||||
// TODO: Re-enable after fixing all sub-component useTranslations scope issues
|
||||
ignoreBuildErrors: true,
|
||||
@@ -16,19 +37,80 @@ const nextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
// NEXT_PUBLIC_CLOUD_URL is set in .env — do NOT hardcode here (it overrides .env)
|
||||
webpack: (config, { isServer }) => {
|
||||
// Ignore fs/path modules in browser bundle
|
||||
if (!isServer) {
|
||||
if (isServer) {
|
||||
// ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ────────
|
||||
//
|
||||
// Next.js 16 (with or without Turbopack) compiles the instrumentation hook
|
||||
// into a separate chunk and emits hashed require() calls such as:
|
||||
// require('better-sqlite3-90e2652d1716b047')
|
||||
// require('zod-dcb22c6336e0bc69')
|
||||
// require('pino-28069d5257187539')
|
||||
//
|
||||
// These hashed names don't exist in node_modules and cause a 500 at
|
||||
// startup on all npm global installs (issues #394, #396, #398).
|
||||
//
|
||||
// We use two strategies:
|
||||
// 1. Exact-name externals for all known server-side packages.
|
||||
// 2. Hash-strip catch-all: any require('<name>-<16hexchars>' strips the
|
||||
// suffix and falls through to the real package name.
|
||||
//
|
||||
const HASH_PATTERN = /^(.+)-[0-9a-f]{16}$/;
|
||||
|
||||
const KNOWN_EXTERNALS = new Set([
|
||||
"better-sqlite3",
|
||||
"zod",
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
"child_process",
|
||||
"fs",
|
||||
"path",
|
||||
"os",
|
||||
"crypto",
|
||||
"net",
|
||||
"tls",
|
||||
"http",
|
||||
"https",
|
||||
"stream",
|
||||
"buffer",
|
||||
"util",
|
||||
]);
|
||||
|
||||
const prev = config.externals ?? [];
|
||||
const prevArr = Array.isArray(prev) ? prev : [prev];
|
||||
config.externals = [
|
||||
...prevArr,
|
||||
({ request }, callback) => {
|
||||
// Case 1: Exact known package — treat as external
|
||||
if (KNOWN_EXTERNALS.has(request)) {
|
||||
return callback(null, `commonjs ${request}`);
|
||||
}
|
||||
// Case 2: Hash-suffixed name — strip hash, use base name
|
||||
// e.g. "better-sqlite3-90e2652d1716b047" → "better-sqlite3"
|
||||
// "zod-dcb22c6336e0bc69" → "zod"
|
||||
const hashMatch = request?.match?.(HASH_PATTERN);
|
||||
if (hashMatch) {
|
||||
const baseName = hashMatch[1];
|
||||
return callback(null, `commonjs ${baseName}`);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Ignore native Node.js modules in browser bundle
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
path: false,
|
||||
child_process: false,
|
||||
net: false,
|
||||
tls: false,
|
||||
crypto: false,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ interface AudioModel {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AudioProvider {
|
||||
export interface AudioProvider {
|
||||
id: string;
|
||||
baseUrl: string;
|
||||
authType: string;
|
||||
@@ -262,36 +262,74 @@ export function getSpeechProvider(providerId: string): AudioProvider | null {
|
||||
return AUDIO_SPEECH_PROVIDERS[providerId] || null;
|
||||
}
|
||||
|
||||
export interface ProviderNodeRow {
|
||||
prefix: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse audio model string (format: "provider/model" or just "model")
|
||||
* Build a dynamic AudioProvider from a provider_node DB entry.
|
||||
* Only used for local providers (localhost/127.0.0.1) — remote nodes are
|
||||
* excluded by the caller to prevent auth bypass and SSRF.
|
||||
*/
|
||||
export function buildDynamicAudioProvider(node: ProviderNodeRow, audioPath: string): AudioProvider {
|
||||
if (!node.prefix || !node.baseUrl) {
|
||||
throw new Error(`Invalid provider_node: missing prefix or baseUrl`);
|
||||
}
|
||||
const baseUrl = node.baseUrl.replace(/\/+$/, "");
|
||||
return {
|
||||
id: node.prefix,
|
||||
baseUrl: `${baseUrl}${audioPath}`,
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseAudioModel(
|
||||
modelStr: string | null,
|
||||
registry: Record<string, AudioProvider>
|
||||
registry: Record<string, AudioProvider>,
|
||||
dynamicProviders?: AudioProvider[]
|
||||
): { provider: string | null; model: string | null } {
|
||||
if (!modelStr) return { provider: null, model: null };
|
||||
|
||||
for (const [providerId, config] of Object.entries(registry)) {
|
||||
// Phase 1: prefix match in hardcoded registry
|
||||
for (const [providerId] of Object.entries(registry)) {
|
||||
if (modelStr.startsWith(providerId + "/")) {
|
||||
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: bare model lookup in hardcoded registry
|
||||
for (const [providerId, config] of Object.entries(registry)) {
|
||||
if (config.models.some((m) => m.id === modelStr)) {
|
||||
return { provider: providerId, model: modelStr };
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: prefix match in dynamic providers (provider_nodes)
|
||||
if (dynamicProviders) {
|
||||
for (const dp of dynamicProviders) {
|
||||
if (modelStr.startsWith(dp.id + "/")) {
|
||||
return { provider: dp.id, model: modelStr.slice(dp.id.length + 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { provider: null, model: modelStr };
|
||||
}
|
||||
|
||||
export function parseTranscriptionModel(modelStr: string | null) {
|
||||
return parseAudioModel(modelStr, AUDIO_TRANSCRIPTION_PROVIDERS);
|
||||
export function parseTranscriptionModel(
|
||||
modelStr: string | null,
|
||||
dynamicProviders?: AudioProvider[]
|
||||
) {
|
||||
return parseAudioModel(modelStr, AUDIO_TRANSCRIPTION_PROVIDERS, dynamicProviders);
|
||||
}
|
||||
|
||||
export function parseSpeechModel(modelStr: string | null) {
|
||||
return parseAudioModel(modelStr, AUDIO_SPEECH_PROVIDERS);
|
||||
export function parseSpeechModel(modelStr: string | null, dynamicProviders?: AudioProvider[]) {
|
||||
return parseAudioModel(modelStr, AUDIO_SPEECH_PROVIDERS, dynamicProviders);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,9 @@ import { loadProviderCredentials } from "./credentialLoader.ts";
|
||||
export const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "120000", 10);
|
||||
|
||||
// Idle timeout for SSE streams (ms). Closes stream if no data for this duration.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "60000", 10);
|
||||
// Default: 300s to support extended-thinking models (claude-opus-4-6, o3, etc.)
|
||||
// that may pause for >60s during deep reasoning phases. Override with STREAM_IDLE_TIMEOUT_MS env var.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "300000", 10);
|
||||
|
||||
// Provider configurations
|
||||
// OAuth credentials read from env vars with hardcoded fallbacks for backward compatibility.
|
||||
@@ -133,6 +135,7 @@ export const COOLDOWN_MS = {
|
||||
unauthorized: 2 * 60 * 1000, // 401 → 2 min
|
||||
paymentRequired: 2 * 60 * 1000, // 402/403 → 2 min
|
||||
notFound: 2 * 60 * 1000, // 404 → 2 minutes
|
||||
notFoundLocal: 5 * 1000, // 404 on local provider → 5s model-only lockout (connection stays active)
|
||||
transientInitial: 5 * 1000, // 408/500/502/503/504 first hit → 5s (backoff from here)
|
||||
transientMax: 60 * 1000, // 502/503/504 backoff ceiling → 60s
|
||||
transient: 5 * 1000, // Legacy alias → points to transientInitial
|
||||
@@ -160,6 +163,16 @@ export const PROVIDER_PROFILES = {
|
||||
circuitBreakerThreshold: 5, // More tolerant (occasional 502 is normal)
|
||||
circuitBreakerReset: 30000, // 30s reset
|
||||
},
|
||||
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
|
||||
// Not yet wired into getProviderProfile() — will be used when local provider_nodes
|
||||
// are integrated into the resilience layer. Kept here to avoid a second constants change.
|
||||
local: {
|
||||
transientCooldown: 2000, // 2s (local — very fast recovery)
|
||||
rateLimitCooldown: 5000, // 5s (local — no real rate limits)
|
||||
maxBackoffLevel: 3, // Low ceiling (local either works or doesn't)
|
||||
circuitBreakerThreshold: 2, // Opens fast (if local is down, it's down)
|
||||
circuitBreakerReset: 15000, // 15s reset (check again quickly)
|
||||
},
|
||||
};
|
||||
|
||||
// Default rate limit values for API Key providers (auto-enabled safety net)
|
||||
|
||||
@@ -8,7 +8,43 @@
|
||||
* keyed by provider ID (e.g. "nebius", "openai").
|
||||
*/
|
||||
|
||||
export const EMBEDDING_PROVIDERS = {
|
||||
export interface EmbeddingProvider {
|
||||
id: string;
|
||||
baseUrl: string;
|
||||
authType: string;
|
||||
authHeader: string;
|
||||
models: { id: string; name: string; dimensions?: number }[];
|
||||
}
|
||||
|
||||
export interface EmbeddingProviderNodeRow {
|
||||
prefix: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a dynamic EmbeddingProvider from a local provider_node.
|
||||
* Only used for local providers (localhost) — caller must filter by hostname.
|
||||
*/
|
||||
export function buildDynamicEmbeddingProvider(node: EmbeddingProviderNodeRow): EmbeddingProvider {
|
||||
if (!node.prefix || !node.baseUrl) {
|
||||
throw new Error(`Invalid provider_node: missing prefix or baseUrl`);
|
||||
}
|
||||
if (node.prefix.includes("/") || node.prefix.includes(" ")) {
|
||||
throw new Error(`Invalid provider_node prefix "${node.prefix}": must not contain / or spaces`);
|
||||
}
|
||||
const baseUrl = node.baseUrl.replace(/\/+$/, "");
|
||||
return {
|
||||
id: node.prefix,
|
||||
baseUrl: `${baseUrl}/embeddings`,
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
nebius: {
|
||||
id: "nebius",
|
||||
baseUrl: "https://api.tokenfactory.nebius.com/v1/embeddings",
|
||||
@@ -70,7 +106,7 @@ export const EMBEDDING_PROVIDERS = {
|
||||
/**
|
||||
* Get embedding provider config by ID
|
||||
*/
|
||||
export function getEmbeddingProvider(providerId) {
|
||||
export function getEmbeddingProvider(providerId: string): EmbeddingProvider | null {
|
||||
return EMBEDDING_PROVIDERS[providerId] || null;
|
||||
}
|
||||
|
||||
@@ -78,26 +114,36 @@ export function getEmbeddingProvider(providerId) {
|
||||
* Parse embedding model string (format: "provider/model" or just "model")
|
||||
* Returns { provider, model }
|
||||
*/
|
||||
export function parseEmbeddingModel(modelStr) {
|
||||
export function parseEmbeddingModel(
|
||||
modelStr: string | null,
|
||||
dynamicProviders?: EmbeddingProvider[]
|
||||
): { provider: string | null; model: string | null } {
|
||||
if (!modelStr) return { provider: null, model: null };
|
||||
|
||||
// Check for "provider/model" format
|
||||
const slashIdx = modelStr.indexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
// Handle nested model IDs like "nebius/Qwen/Qwen3-Embedding-8B"
|
||||
// Try each provider prefix
|
||||
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
|
||||
// Phase 1: Try each hardcoded provider prefix
|
||||
for (const [providerId] of Object.entries(EMBEDDING_PROVIDERS)) {
|
||||
if (modelStr.startsWith(providerId + "/")) {
|
||||
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
|
||||
}
|
||||
}
|
||||
// Fallback: first segment is provider
|
||||
// Phase 2: Try dynamic provider_nodes prefix
|
||||
if (dynamicProviders) {
|
||||
for (const dp of dynamicProviders) {
|
||||
if (modelStr.startsWith(dp.id + "/")) {
|
||||
return { provider: dp.id, model: modelStr.slice(dp.id.length + 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Phase 3: Fallback — first segment is provider
|
||||
const provider = modelStr.slice(0, slashIdx);
|
||||
const model = modelStr.slice(slashIdx + 1);
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
// No provider prefix — search all providers for the model
|
||||
// No provider prefix — search hardcoded providers for the model
|
||||
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
|
||||
if (config.models.some((m) => m.id === modelStr)) {
|
||||
return { provider: providerId, model: modelStr };
|
||||
|
||||
@@ -12,8 +12,21 @@ export interface RegistryModel {
|
||||
id: string;
|
||||
name: string;
|
||||
targetFormat?: string;
|
||||
unsupportedParams?: readonly string[];
|
||||
}
|
||||
|
||||
// Reasoning models reject temperature, top_p, penalties, logprobs, n.
|
||||
// Frozen to prevent accidental mutation (shared across all model entries).
|
||||
const REASONING_UNSUPPORTED: readonly string[] = Object.freeze([
|
||||
"temperature",
|
||||
"top_p",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"logprobs",
|
||||
"top_logprobs",
|
||||
"n",
|
||||
]);
|
||||
|
||||
export interface RegistryOAuth {
|
||||
clientIdEnv?: string;
|
||||
clientIdDefault?: string;
|
||||
@@ -126,13 +139,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
clientSecretDefault: "",
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
|
||||
{ id: "gemini-3.1-flash", name: "Gemini 3.1 Flash" },
|
||||
{ id: "gemini-3-pro-preview", name: "Gemini 3.0 Pro Preview" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3.0 Flash Preview" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
|
||||
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
|
||||
{ id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash Exp" },
|
||||
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" },
|
||||
{ id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -155,13 +168,12 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
clientSecretDefault: "",
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
|
||||
{ id: "gemini-3.1-flash", name: "Gemini 3.1 Flash" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3.0 Flash Preview" },
|
||||
{ id: "gemini-3-pro-preview", name: "Gemini 3.0 Pro Preview" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
|
||||
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
|
||||
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" },
|
||||
{ id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -186,6 +198,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
tokenUrl: "https://auth.openai.com/oauth/token",
|
||||
},
|
||||
models: [
|
||||
{ id: "gpt-5.4", name: "GPT 5.4" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
|
||||
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
|
||||
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
|
||||
@@ -304,10 +317,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
models: [
|
||||
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
|
||||
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
|
||||
{ id: "gemini-3.1-flash", name: "Gemini 3.1 Flash" },
|
||||
{ id: "gemini-3-flash", name: "Gemini 3.0 Flash" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
|
||||
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" },
|
||||
],
|
||||
},
|
||||
@@ -355,8 +367,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
|
||||
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "grok-code-fast-1", name: "Grok Code Fast 1" },
|
||||
{ id: "oswe-vscode-prime", name: "Raptor Mini" },
|
||||
],
|
||||
@@ -428,8 +439,11 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "gpt-4o", name: "GPT-4o" },
|
||||
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
|
||||
{ id: "gpt-4-turbo", name: "GPT-4 Turbo" },
|
||||
{ id: "o1", name: "O1" },
|
||||
{ id: "o1-mini", name: "O1 Mini" },
|
||||
{ id: "o1", name: "O1", unsupportedParams: REASONING_UNSUPPORTED },
|
||||
{ id: "o1-mini", name: "O1 Mini", unsupportedParams: REASONING_UNSUPPORTED },
|
||||
{ id: "o1-pro", name: "O1 Pro", unsupportedParams: REASONING_UNSUPPORTED },
|
||||
{ id: "o3", name: "O3", unsupportedParams: REASONING_UNSUPPORTED },
|
||||
{ id: "o3-mini", name: "O3 Mini", unsupportedParams: REASONING_UNSUPPORTED },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -835,12 +849,14 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "meta/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
|
||||
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "z-ai/glm4.7", name: "GLM 4.7" },
|
||||
{ id: "deepseek-ai/deepseek-v3.2", name: "DeepSeek V3.2" },
|
||||
{ id: "nvidia/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
|
||||
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1" },
|
||||
{ id: "nvidia/llama-3.1-70b-instruct", name: "Llama 3.1 70B" },
|
||||
{ id: "nvidia/llama-3.1-405b-instruct", name: "Llama 3.1 405B" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -918,6 +934,46 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
synthetic: {
|
||||
id: "synthetic",
|
||||
alias: "synthetic",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.synthetic.new/openai/v1/chat/completions",
|
||||
modelsUrl: "https://api.synthetic.new/openai/v1/models",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "hf:nvidia/Kimi-K2.5-NVFP4", name: "Kimi K2.5 (NVFP4)" },
|
||||
{ id: "hf:MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "hf:zai-org/GLM-4.7-Flash", name: "GLM 4.7 Flash" },
|
||||
{ id: "hf:zai-org/GLM-4.7", name: "GLM 4.7" },
|
||||
{ id: "hf:moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
|
||||
{ id: "hf:deepseek-ai/DeepSeek-V3.2", name: "DeepSeek V3.2" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
"kilo-gateway": {
|
||||
id: "kilo-gateway",
|
||||
alias: "kg",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.kilo.ai/api/gateway/chat/completions",
|
||||
modelsUrl: "https://api.kilo.ai/api/gateway/models",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "kilo-auto/frontier", name: "Kilo Auto Frontier" },
|
||||
{ id: "kilo-auto/balanced", name: "Kilo Auto Balanced" },
|
||||
{ id: "kilo-auto/free", name: "Kilo Auto Free" },
|
||||
{ id: "nvidia/nemotron-3-super-120b-a12b:free", name: "Nemotron 3 Super 120B (Free)" },
|
||||
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
|
||||
{ id: "arcee-ai/trinity-large-preview:free", name: "Trinity Large Preview (Free)" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
vertex: {
|
||||
id: "vertex",
|
||||
alias: "vertex",
|
||||
@@ -1021,6 +1077,38 @@ export function generateAliasMap(): Record<string, string> {
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── Local Provider Detection ──────────────────────────────────────────────
|
||||
|
||||
// Evaluated once at module load time — process restart required for env var changes.
|
||||
const LOCAL_HOSTNAMES = new Set([
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"[::1]",
|
||||
...(typeof process !== "undefined" && process.env.LOCAL_HOSTNAMES
|
||||
? process.env.LOCAL_HOSTNAMES.split(",")
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean)
|
||||
: []),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Detect if a base URL points to a local inference backend.
|
||||
* Used for shorter 404 cooldowns (model-only, not connection) and health check targets.
|
||||
*
|
||||
* Operators can extend via LOCAL_HOSTNAMES env var (comma-separated) for Docker
|
||||
* hostnames (e.g., LOCAL_HOSTNAMES=omlx,mlx-audio).
|
||||
*/
|
||||
export function isLocalProvider(baseUrl?: string | null): boolean {
|
||||
if (!baseUrl) return false;
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return LOCAL_HOSTNAMES.has(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registry Lookup Helpers ───────────────────────────────────────────────
|
||||
|
||||
const _byAlias = new Map<string, RegistryEntry>();
|
||||
@@ -1040,6 +1128,43 @@ export function getRegisteredProviders(): string[] {
|
||||
return Object.keys(REGISTRY);
|
||||
}
|
||||
|
||||
// Precomputed map: modelId → unsupportedParams (O(1) lookup instead of O(N×M) scan).
|
||||
// Built once at module load from all registry entries.
|
||||
const _unsupportedParamsMap = new Map<string, readonly string[]>();
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
for (const model of entry.models) {
|
||||
if (model.unsupportedParams && !_unsupportedParamsMap.has(model.id)) {
|
||||
_unsupportedParamsMap.set(model.id, model.unsupportedParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unsupported parameters for a specific model.
|
||||
* Uses O(1) precomputed lookup. Also handles prefixed model IDs
|
||||
* (e.g., "openai/o3" → strips prefix and looks up "o3").
|
||||
* Returns empty array if no restrictions are defined.
|
||||
*/
|
||||
export function getUnsupportedParams(provider: string, modelId: string): readonly string[] {
|
||||
// 1. Check current provider's registry (exact match)
|
||||
const entry = getRegistryEntry(provider);
|
||||
const modelEntry = entry?.models.find((m) => m.id === modelId);
|
||||
if (modelEntry?.unsupportedParams) return modelEntry.unsupportedParams;
|
||||
|
||||
// 2. O(1) lookup in precomputed map (handles cross-provider routing)
|
||||
const cached = _unsupportedParamsMap.get(modelId);
|
||||
if (cached) return cached;
|
||||
|
||||
// 3. Handle prefixed model IDs (e.g., "openai/o3" → "o3")
|
||||
if (modelId.includes("/")) {
|
||||
const bareId = modelId.split("/").pop() || "";
|
||||
const bare = _unsupportedParamsMap.get(bareId);
|
||||
if (bare) return bare;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider category: "oauth" or "apikey"
|
||||
* Used by the resilience layer to apply different cooldown/backoff profiles.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
|
||||
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -23,6 +24,7 @@ export type ProviderCredentials = {
|
||||
refreshToken?: string;
|
||||
apiKey?: string;
|
||||
expiresAt?: string;
|
||||
connectionId?: string; // T07: used for API key rotation index
|
||||
providerSpecificData?: JsonRecord;
|
||||
};
|
||||
|
||||
@@ -97,11 +99,11 @@ export class BaseExecutor {
|
||||
void model;
|
||||
void stream;
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const baseUrl =
|
||||
typeof credentials?.providerSpecificData?.baseUrl === "string"
|
||||
? credentials.providerSpecificData.baseUrl
|
||||
: "https://api.openai.com/v1";
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = typeof psd?.baseUrl === "string" ? psd.baseUrl : "https://api.openai.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
if (customPath) return `${normalized}${customPath}`;
|
||||
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
||||
return `${normalized}${path}`;
|
||||
}
|
||||
@@ -131,7 +133,14 @@ export class BaseExecutor {
|
||||
if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
} else if (credentials.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
|
||||
// T07: rotate between primary + extra API keys when extraApiKeys is configured
|
||||
const extraKeys =
|
||||
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
|
||||
const effectiveKey =
|
||||
extraKeys.length > 0 && credentials.connectionId
|
||||
? getRotatingApiKey(credentials.connectionId, credentials.apiKey, extraKeys)
|
||||
: credentials.apiKey;
|
||||
headers["Authorization"] = `Bearer ${effectiveKey}`;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
|
||||
@@ -6,6 +6,20 @@ import { refreshCodexToken } from "../services/tokenRefresh.ts";
|
||||
// Ordered list of effort levels from lowest to highest
|
||||
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
|
||||
type EffortLevel = (typeof EFFORT_ORDER)[number];
|
||||
const CODEX_FAST_WIRE_VALUE = "priority";
|
||||
let defaultFastServiceTierEnabled = false;
|
||||
|
||||
function normalizeServiceTierValue(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (normalized === "fast") return CODEX_FAST_WIRE_VALUE;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function setDefaultFastServiceTierEnabled(enabled: boolean): void {
|
||||
defaultFastServiceTierEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum reasoning effort allowed per Codex model.
|
||||
@@ -92,8 +106,22 @@ export class CodexExecutor extends BaseExecutor {
|
||||
* Transform request before sending - inject default instructions if missing
|
||||
*/
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
|
||||
|
||||
// Codex /responses rejects stream=false; we aggregate SSE back to JSON when needed.
|
||||
body.stream = true;
|
||||
delete body._nativeCodexPassthrough;
|
||||
|
||||
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
|
||||
if (requestServiceTier) {
|
||||
body.service_tier = requestServiceTier;
|
||||
} else if (defaultFastServiceTierEnabled) {
|
||||
body.service_tier = CODEX_FAST_WIRE_VALUE;
|
||||
}
|
||||
|
||||
if (nativeCodexPassthrough) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// If no instructions provided, inject default Codex instructions
|
||||
if (!body.instructions || body.instructions.trim() === "") {
|
||||
|
||||
@@ -9,15 +9,20 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1";
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = psd?.baseUrl || "https://api.openai.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
if (customPath) return `${normalized}${customPath}`;
|
||||
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
||||
return `${normalized}${path}`;
|
||||
}
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1";
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
return `${normalized}/messages`;
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
return `${normalized}${customPath || "/messages"}`;
|
||||
}
|
||||
switch (this.provider) {
|
||||
case "claude":
|
||||
|
||||
@@ -24,13 +24,28 @@ import { errorResponse } from "../utils/error.ts";
|
||||
* Return a CORS error response from an upstream fetch failure
|
||||
*/
|
||||
function upstreamErrorResponse(res, errText) {
|
||||
return new Response(errText, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
// Always return JSON so the client can detect 401/credential errors reliably
|
||||
let errorMessage: string;
|
||||
try {
|
||||
const parsed = JSON.parse(errText);
|
||||
errorMessage =
|
||||
parsed?.err_msg ||
|
||||
parsed?.error?.message ||
|
||||
parsed?.error ||
|
||||
parsed?.message ||
|
||||
parsed?.detail ||
|
||||
errText;
|
||||
} catch {
|
||||
errorMessage = errText || `Upstream error (${res.status})`;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ error: { message: errorMessage, code: res.status } },
|
||||
{
|
||||
status: res.status,
|
||||
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,7 +381,12 @@ async function handleTortoiseSpeech(providerConfig, body) {
|
||||
* @returns {Response}
|
||||
*/
|
||||
/** @returns {Promise<unknown>} */
|
||||
export async function handleAudioSpeech({ body, credentials }) {
|
||||
export async function handleAudioSpeech({
|
||||
body,
|
||||
credentials,
|
||||
resolvedProvider = null,
|
||||
resolvedModel = null,
|
||||
}) {
|
||||
if (!body.model) {
|
||||
return errorResponse(400, "model is required");
|
||||
}
|
||||
@@ -374,8 +394,15 @@ export async function handleAudioSpeech({ body, credentials }) {
|
||||
return errorResponse(400, "input is required");
|
||||
}
|
||||
|
||||
const { provider: providerId, model: modelId } = parseSpeechModel(body.model);
|
||||
const providerConfig = providerId ? getSpeechProvider(providerId) : null;
|
||||
// Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes).
|
||||
// Falls back to hardcoded registry lookup for backward compatibility.
|
||||
let providerConfig = resolvedProvider;
|
||||
let modelId = resolvedModel;
|
||||
if (!providerConfig) {
|
||||
const parsed = parseSpeechModel(body.model);
|
||||
providerConfig = parsed.provider ? getSpeechProvider(parsed.provider) : null;
|
||||
modelId = parsed.model;
|
||||
}
|
||||
|
||||
if (!providerConfig) {
|
||||
return errorResponse(
|
||||
@@ -388,7 +415,7 @@ export async function handleAudioSpeech({ body, credentials }) {
|
||||
const token =
|
||||
providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken;
|
||||
if (providerConfig.authType !== "none" && !token) {
|
||||
return errorResponse(401, `No credentials for speech provider: ${providerId}`);
|
||||
return errorResponse(401, `No credentials for speech provider: ${providerConfig.id}`);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -13,7 +13,11 @@ import { getCorsOrigin } from "../utils/cors.ts";
|
||||
* - HuggingFace Inference: POST raw binary to /models/{model_id}
|
||||
*/
|
||||
|
||||
import { getTranscriptionProvider, parseTranscriptionModel } from "../config/audioRegistry.ts";
|
||||
import {
|
||||
getTranscriptionProvider,
|
||||
parseTranscriptionModel,
|
||||
type AudioProvider,
|
||||
} from "../config/audioRegistry.ts";
|
||||
import { buildAuthHeaders } from "../config/registryUtils.ts";
|
||||
import { errorResponse } from "../utils/error.ts";
|
||||
|
||||
@@ -26,13 +30,28 @@ type TranscriptionCredentials = {
|
||||
* Return a CORS error response from an upstream fetch failure
|
||||
*/
|
||||
function upstreamErrorResponse(res, errText) {
|
||||
return new Response(errText, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
// Always return JSON so the client can parse the error reliably
|
||||
let errorMessage: string;
|
||||
try {
|
||||
const parsed = JSON.parse(errText);
|
||||
errorMessage =
|
||||
parsed?.err_msg ||
|
||||
parsed?.error?.message ||
|
||||
parsed?.error ||
|
||||
parsed?.message ||
|
||||
parsed?.detail ||
|
||||
errText;
|
||||
} catch {
|
||||
errorMessage = errText || `Upstream error (${res.status})`;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ error: { message: errorMessage, code: res.status } },
|
||||
{
|
||||
status: res.status,
|
||||
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,9 +90,14 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token)
|
||||
|
||||
const data = await res.json();
|
||||
// Transform Deepgram response to OpenAI Whisper format
|
||||
const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript || "";
|
||||
const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? null;
|
||||
|
||||
return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } });
|
||||
// null means the audio had no recognizable speech (music, silence, etc.)
|
||||
// Return it explicitly so the client can distinguish from a credentials error
|
||||
return Response.json(
|
||||
{ text: text ?? "", noSpeechDetected: text === null || text === "" },
|
||||
{ headers: { "Access-Control-Allow-Origin": getCorsOrigin() } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,9 +239,13 @@ async function handleHuggingFaceTranscription(providerConfig, file, modelId, tok
|
||||
export async function handleAudioTranscription({
|
||||
formData,
|
||||
credentials,
|
||||
resolvedProvider = null,
|
||||
resolvedModel = null,
|
||||
}: {
|
||||
formData: FormData;
|
||||
credentials?: TranscriptionCredentials | null;
|
||||
resolvedProvider?: AudioProvider | null;
|
||||
resolvedModel?: string | null;
|
||||
}): Promise<Response> {
|
||||
const model = formData.get("model");
|
||||
if (typeof model !== "string" || !model) {
|
||||
@@ -230,8 +258,14 @@ export async function handleAudioTranscription({
|
||||
}
|
||||
const file = fileEntry as Blob & { name?: unknown };
|
||||
|
||||
const { provider: providerId, model: modelId } = parseTranscriptionModel(model);
|
||||
const providerConfig = providerId ? getTranscriptionProvider(providerId) : null;
|
||||
// Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes).
|
||||
let providerConfig = resolvedProvider;
|
||||
let modelId = resolvedModel;
|
||||
if (!providerConfig) {
|
||||
const parsed = parseTranscriptionModel(model);
|
||||
providerConfig = parsed.provider ? getTranscriptionProvider(parsed.provider) : null;
|
||||
modelId = parsed.model;
|
||||
}
|
||||
|
||||
if (!providerConfig) {
|
||||
return errorResponse(
|
||||
@@ -244,7 +278,7 @@ export async function handleAudioTranscription({
|
||||
const token =
|
||||
providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken;
|
||||
if (providerConfig.authType !== "none" && !token) {
|
||||
return errorResponse(401, `No credentials for transcription provider: ${providerId}`);
|
||||
return errorResponse(401, `No credentials for transcription provider: ${providerConfig.id}`);
|
||||
}
|
||||
|
||||
// Route to provider-specific handler
|
||||
|
||||
+119
-37
@@ -13,6 +13,7 @@ import { refreshWithRetry } from "../services/tokenRefresh.ts";
|
||||
import { createRequestLogger } from "../utils/requestLogger.ts";
|
||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
|
||||
import { resolveModelAlias } from "../services/modelDeprecation.ts";
|
||||
import { getUnsupportedParams } from "../config/providerRegistry.ts";
|
||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.ts";
|
||||
import { HTTP_STATUS } from "../config/constants.ts";
|
||||
import { handleBypassRequest } from "../utils/bypassHandler.ts";
|
||||
@@ -42,6 +43,22 @@ import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idem
|
||||
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts";
|
||||
import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts";
|
||||
|
||||
export function shouldUseNativeCodexPassthrough({
|
||||
provider,
|
||||
sourceFormat,
|
||||
endpointPath,
|
||||
}: {
|
||||
provider?: string | null;
|
||||
sourceFormat?: string | null;
|
||||
endpointPath?: string | null;
|
||||
}): boolean {
|
||||
if (provider !== "codex") return false;
|
||||
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false;
|
||||
return String(endpointPath || "")
|
||||
.toLowerCase()
|
||||
.endsWith("/responses");
|
||||
}
|
||||
|
||||
/**
|
||||
* Core chat handler - shared between SSE and Worker
|
||||
* Returns { success, response, status, error } for caller to handle fallback
|
||||
@@ -94,9 +111,20 @@ export async function handleChatCore({
|
||||
// Initialize rate limit settings from persisted DB (once, lazy)
|
||||
await initializeRateLimits();
|
||||
|
||||
// T07: Inject connectionId into credentials so executors can rotate API keys
|
||||
// using providerSpecificData.extraApiKeys (API Key Round-Robin feature)
|
||||
if (connectionId && credentials && !credentials.connectionId) {
|
||||
credentials.connectionId = connectionId;
|
||||
}
|
||||
|
||||
const sourceFormat = detectFormat(body);
|
||||
const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase();
|
||||
const isResponsesEndpoint = endpointPath.endsWith("/responses");
|
||||
const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({
|
||||
provider,
|
||||
sourceFormat,
|
||||
endpointPath,
|
||||
});
|
||||
|
||||
// Check for bypass patterns (warmup, skip) - return fake response
|
||||
const bypassResponse = handleBypassRequest(body, model, userAgent);
|
||||
@@ -157,46 +185,85 @@ export async function handleChatCore({
|
||||
|
||||
// Translate request (pass reqLogger for intermediate logging)
|
||||
let translatedBody = body;
|
||||
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
|
||||
try {
|
||||
// Issue #199: Disable tool name prefix when routing Claude-format requests
|
||||
// to non-Claude backends (prefix causes tool name mismatches)
|
||||
const claudeProviders = ["claude", "anthropic"];
|
||||
if (targetFormat === FORMATS.CLAUDE && !claudeProviders.includes(provider?.toLowerCase?.())) {
|
||||
translatedBody = { ...translatedBody, _disableToolPrefix: true };
|
||||
}
|
||||
if (nativeCodexPassthrough) {
|
||||
translatedBody = { ...body, _nativeCodexPassthrough: true };
|
||||
log?.debug?.("FORMAT", "native codex passthrough enabled");
|
||||
} else if (isClaudePassthrough) {
|
||||
// Claude-to-Claude passthrough: forward body completely untouched.
|
||||
// No translation, no field stripping, no thinking normalization.
|
||||
// We are just a gateway -- do not interfere with the request in any way.
|
||||
translatedBody = { ...body };
|
||||
log?.debug?.("FORMAT", "claude->claude passthrough -- forwarding untouched");
|
||||
} else {
|
||||
translatedBody = { ...body };
|
||||
|
||||
// ── #291: Strip empty name fields from messages/input items ──
|
||||
// Upstream providers (OpenAI, Codex) reject name:"" with 400 errors.
|
||||
// Clients like PocketPaw may forward empty name fields from assistant turns.
|
||||
if (Array.isArray(body.messages)) {
|
||||
body.messages = body.messages.map((msg: Record<string, unknown>) => {
|
||||
if (msg.name === "") {
|
||||
const { name: _n, ...rest } = msg;
|
||||
return rest;
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
if (Array.isArray(body.input)) {
|
||||
body.input = body.input.map((item: Record<string, unknown>) => {
|
||||
if (item.name === "") {
|
||||
const { name: _n, ...rest } = item;
|
||||
return rest;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
// Issue #199: Disable tool name prefix when routing Claude-format requests
|
||||
// to non-Claude backends (prefix causes tool name mismatches)
|
||||
const claudeProviders = ["claude", "anthropic"];
|
||||
if (targetFormat === FORMATS.CLAUDE && !claudeProviders.includes(provider?.toLowerCase?.())) {
|
||||
translatedBody._disableToolPrefix = true;
|
||||
}
|
||||
|
||||
translatedBody = translateRequest(
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
model,
|
||||
translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
provider,
|
||||
reqLogger
|
||||
);
|
||||
// ── #291: Strip empty name fields from messages/input items ──
|
||||
// Upstream providers (OpenAI, Codex) reject name:"" with 400 errors.
|
||||
// Clients like PocketPaw may forward empty name fields from assistant turns.
|
||||
if (Array.isArray(translatedBody.messages)) {
|
||||
translatedBody.messages = translatedBody.messages.map((msg: Record<string, unknown>) => {
|
||||
if (msg.name === "") {
|
||||
const { name: _n, ...rest } = msg;
|
||||
return rest;
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
if (Array.isArray(translatedBody.input)) {
|
||||
translatedBody.input = translatedBody.input.map((item: Record<string, unknown>) => {
|
||||
if (item.name === "") {
|
||||
const { name: _n, ...rest } = item;
|
||||
return rest;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
// ── #346: Strip tools with empty name ──
|
||||
// Claude Code sometimes forwards tool definitions with empty names, causing
|
||||
// OpenAI-compatible upstream providers to reject with:
|
||||
// "Invalid 'input[N].name': empty string. Expected minimum length 1."
|
||||
// Handles both OpenAI format ({ function: { name } }) and Anthropic format ({ name }).
|
||||
if (Array.isArray(translatedBody.tools)) {
|
||||
translatedBody.tools = translatedBody.tools.filter((tool: Record<string, unknown>) => {
|
||||
const fn = tool.function as Record<string, unknown> | undefined;
|
||||
const name = fn?.name ?? tool.name;
|
||||
return name && String(name).trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Strip empty text content blocks from messages.
|
||||
// Anthropic API rejects {"type":"text","text":""} with 400 "text content blocks must be non-empty".
|
||||
// Some clients (LiteLLM passthrough, @ai-sdk/anthropic) may forward these empty blocks as-is.
|
||||
if (Array.isArray(translatedBody.messages)) {
|
||||
for (const msg of translatedBody.messages) {
|
||||
if (Array.isArray(msg.content)) {
|
||||
msg.content = msg.content.filter((block: Record<string, unknown>) =>
|
||||
block.type !== "text" || (typeof block.text === "string" && block.text.length > 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
translatedBody = translateRequest(
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
model,
|
||||
translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
provider,
|
||||
reqLogger
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const parsedStatus = Number(error?.statusCode);
|
||||
const statusCode =
|
||||
@@ -243,6 +310,21 @@ export async function handleChatCore({
|
||||
// Update model in body
|
||||
translatedBody.model = model;
|
||||
|
||||
// Strip unsupported parameters for reasoning models (o1, o3, etc.)
|
||||
const unsupported = getUnsupportedParams(provider, model);
|
||||
if (unsupported.length > 0) {
|
||||
const stripped: string[] = [];
|
||||
for (const param of unsupported) {
|
||||
if (Object.hasOwn(translatedBody, param)) {
|
||||
stripped.push(param);
|
||||
delete translatedBody[param];
|
||||
}
|
||||
}
|
||||
if (stripped.length > 0) {
|
||||
log?.warn?.("PARAMS", `Stripped unsupported params for ${model}: ${stripped.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get executor for this provider
|
||||
const executor = getExecutor(provider);
|
||||
|
||||
|
||||
@@ -13,18 +13,48 @@
|
||||
* }
|
||||
*/
|
||||
|
||||
import { getEmbeddingProvider, parseEmbeddingModel } from "../config/embeddingRegistry.ts";
|
||||
import {
|
||||
getEmbeddingProvider,
|
||||
parseEmbeddingModel,
|
||||
type EmbeddingProvider,
|
||||
} from "../config/embeddingRegistry.ts";
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
|
||||
/**
|
||||
* Handle embedding request
|
||||
* @param {object} options
|
||||
* @param {object} options.body - Request body
|
||||
* @param {object} options.credentials - Provider credentials { apiKey, accessToken }
|
||||
* @param {object} options.log - Logger
|
||||
* Handle embedding request.
|
||||
* Supports both hardcoded cloud providers and dynamic local provider_nodes.
|
||||
* When resolvedProvider is passed, uses it directly (injection pattern from route handler).
|
||||
* Falls back to hardcoded registry lookup for backward compatibility.
|
||||
*/
|
||||
export async function handleEmbedding({ body, credentials, log }) {
|
||||
const { provider, model } = parseEmbeddingModel(body.model);
|
||||
export async function handleEmbedding({
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
resolvedProvider = null,
|
||||
resolvedModel = null,
|
||||
}: {
|
||||
body: Record<string, unknown>;
|
||||
credentials: { apiKey?: string; accessToken?: string } | null;
|
||||
log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||
resolvedProvider?: EmbeddingProvider | null;
|
||||
resolvedModel?: string | null;
|
||||
}) {
|
||||
// Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes).
|
||||
let provider: string | null;
|
||||
let model: string | null;
|
||||
let providerConfig: EmbeddingProvider | null;
|
||||
|
||||
if (resolvedProvider) {
|
||||
provider = resolvedProvider.id;
|
||||
model = resolvedModel;
|
||||
providerConfig = resolvedProvider;
|
||||
} else {
|
||||
const parsed = parseEmbeddingModel(body.model as string);
|
||||
provider = parsed.provider;
|
||||
model = parsed.model;
|
||||
providerConfig = provider ? getEmbeddingProvider(provider) : null;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Summarized request body for call log (avoid storing large embedding input arrays)
|
||||
@@ -42,7 +72,6 @@ export async function handleEmbedding({ body, credentials, log }) {
|
||||
};
|
||||
}
|
||||
|
||||
const providerConfig = getEmbeddingProvider(provider);
|
||||
if (!providerConfig) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -66,11 +95,15 @@ export async function handleEmbedding({ body, credentials, log }) {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const token = credentials.apiKey || credentials.accessToken;
|
||||
if (providerConfig.authHeader === "bearer") {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
} else if (providerConfig.authHeader === "x-api-key") {
|
||||
headers["x-api-key"] = token;
|
||||
// Skip credential injection for local providers (authType: "none")
|
||||
const token =
|
||||
providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken;
|
||||
if (token) {
|
||||
if (providerConfig.authHeader === "bearer") {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
} else if (providerConfig.authHeader === "x-api-key") {
|
||||
headers["x-api-key"] = token;
|
||||
}
|
||||
}
|
||||
|
||||
if (log) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { syncPricingInput, syncPricingTool, MCP_TOOLS, MCP_TOOL_MAP } from "../schemas/tools.ts";
|
||||
|
||||
describe("omniroute_sync_pricing MCP tool schema", () => {
|
||||
it("should be registered in MCP_TOOLS", () => {
|
||||
const tool = MCP_TOOLS.find((t) => t.name === "omniroute_sync_pricing");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.phase).toBe(2);
|
||||
});
|
||||
|
||||
it("should be in MCP_TOOL_MAP", () => {
|
||||
expect(MCP_TOOL_MAP["omniroute_sync_pricing"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should require pricing:write scope", () => {
|
||||
expect(syncPricingTool.scopes).toContain("pricing:write");
|
||||
});
|
||||
|
||||
it("should have full audit level", () => {
|
||||
expect(syncPricingTool.auditLevel).toBe("full");
|
||||
});
|
||||
|
||||
it("should validate empty input (all fields optional)", () => {
|
||||
const result = syncPricingInput.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate input with sources array", () => {
|
||||
const result = syncPricingInput.safeParse({ sources: ["litellm"] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate input with dryRun", () => {
|
||||
const result = syncPricingInput.safeParse({ dryRun: true });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate full input", () => {
|
||||
const result = syncPricingInput.safeParse({
|
||||
sources: ["litellm"],
|
||||
dryRun: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid sources type", () => {
|
||||
const result = syncPricingInput.safeParse({ sources: "litellm" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid dryRun type", () => {
|
||||
const result = syncPricingInput.safeParse({ dryRun: "yes" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should point to correct source endpoint", () => {
|
||||
expect(syncPricingTool.sourceEndpoints).toContain("/api/pricing/sync");
|
||||
});
|
||||
});
|
||||
@@ -723,6 +723,42 @@ export const getSessionSnapshotTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"],
|
||||
};
|
||||
|
||||
// --- Tool 17: omniroute_sync_pricing ---
|
||||
export const syncPricingInput = z.object({
|
||||
sources: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("External pricing sources to sync from (default: ['litellm'])"),
|
||||
dryRun: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("If true, preview sync results without saving to database"),
|
||||
});
|
||||
|
||||
export const syncPricingOutput = z.object({
|
||||
success: z.boolean(),
|
||||
modelCount: z.number(),
|
||||
providerCount: z.number(),
|
||||
source: z.string(),
|
||||
dryRun: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
warnings: z.array(z.string()).optional(),
|
||||
data: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
||||
});
|
||||
|
||||
export const syncPricingTool: McpToolDefinition<typeof syncPricingInput, typeof syncPricingOutput> =
|
||||
{
|
||||
name: "omniroute_sync_pricing",
|
||||
description:
|
||||
"Syncs pricing data from external sources (LiteLLM) into OmniRoute. Synced pricing fills gaps not covered by hardcoded defaults without overwriting user-set prices. Use dryRun=true to preview.",
|
||||
inputSchema: syncPricingInput,
|
||||
outputSchema: syncPricingOutput,
|
||||
scopes: ["pricing:write"],
|
||||
auditLevel: "full",
|
||||
phase: 2,
|
||||
sourceEndpoints: ["/api/pricing/sync"],
|
||||
};
|
||||
|
||||
// ============ Tool Registry ============
|
||||
|
||||
/** All MCP tool definitions, ordered by phase then name */
|
||||
@@ -745,6 +781,7 @@ export const MCP_TOOLS = [
|
||||
bestComboForTaskTool,
|
||||
explainRouteTool,
|
||||
getSessionSnapshotTool,
|
||||
syncPricingTool,
|
||||
] as const;
|
||||
|
||||
/** Essential tools only (Phase 1) */
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
bestComboForTaskInput,
|
||||
explainRouteInput,
|
||||
getSessionSnapshotInput,
|
||||
syncPricingInput,
|
||||
} from "./schemas/tools.ts";
|
||||
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
|
||||
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
handleBestComboForTask,
|
||||
handleExplainRoute,
|
||||
handleGetSessionSnapshot,
|
||||
handleSyncPricing,
|
||||
} from "./tools/advancedTools.ts";
|
||||
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
|
||||
|
||||
@@ -664,6 +666,18 @@ export function createMcpServer(): McpServer {
|
||||
})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_sync_pricing",
|
||||
{
|
||||
description:
|
||||
"Syncs pricing data from external sources (LiteLLM) into OmniRoute without overwriting user-set prices",
|
||||
inputSchema: syncPricingInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_sync_pricing", (args) =>
|
||||
handleSyncPricing(syncPricingInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -678,6 +678,28 @@ export async function handleExplainRoute(args: { requestId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSyncPricing(args: { sources?: string[]; dryRun?: boolean }) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = toRecord(
|
||||
await apiFetch("/api/pricing/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sources: args.sources,
|
||||
dryRun: args.dryRun ?? false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await logToolCall("omniroute_sync_pricing", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await logToolCall("omniroute_sync_pricing", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetSessionSnapshot() {
|
||||
const start = Date.now();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* apiKeyRotator.ts — T07: API Key Round-Robin
|
||||
*
|
||||
* Rotates between a primary API key and extra API keys stored in
|
||||
* providerSpecificData.extraApiKeys[]. Uses round-robin by default.
|
||||
*
|
||||
* Extra keys are stored as plain strings in providerSpecificData.extraApiKeys.
|
||||
* Example: { extraApiKeys: ["sk-abc...", "sk-def...", "sk-ghi..."] }
|
||||
*
|
||||
* The in-memory rotation index resets on process restart, which is intentional —
|
||||
* it ensures even distribution across restarts without persistence overhead.
|
||||
*/
|
||||
|
||||
// In-memory round-robin index per connection
|
||||
const _keyIndexes = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Get the next API key in round-robin rotation for a given connection.
|
||||
* If no extra keys are configured, returns the primary key unchanged.
|
||||
*
|
||||
* @param connectionId - Unique connection identifier (for index isolation)
|
||||
* @param primaryKey - The main api_key from the connection
|
||||
* @param extraKeys - Additional API keys from providerSpecificData.extraApiKeys
|
||||
* @returns The selected API key (may be primary or one of the extras)
|
||||
*/
|
||||
export function getRotatingApiKey(
|
||||
connectionId: string,
|
||||
primaryKey: string,
|
||||
extraKeys: string[] = []
|
||||
): string {
|
||||
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
||||
|
||||
// Only 1 key available → no rotation needed
|
||||
if (validExtras.length === 0) return primaryKey;
|
||||
|
||||
const allKeys = [primaryKey, ...validExtras].filter(Boolean);
|
||||
if (allKeys.length <= 1) return primaryKey;
|
||||
|
||||
const current = _keyIndexes.get(connectionId) ?? 0;
|
||||
const idx = current % allKeys.length;
|
||||
_keyIndexes.set(connectionId, current + 1);
|
||||
|
||||
return allKeys[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the rotation index for a connection.
|
||||
* Call this when a key fails (401/403) to skip the bad key next time.
|
||||
*
|
||||
* @param connectionId - Connection to reset
|
||||
*/
|
||||
export function resetRotationIndex(connectionId: string): void {
|
||||
_keyIndexes.delete(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of API keys available for a connection.
|
||||
* Used for logging/observability.
|
||||
*/
|
||||
export function getApiKeyCount(primaryKey: string, extraKeys: string[] = []): number {
|
||||
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
||||
return (primaryKey ? 1 : 0) + validExtras.length;
|
||||
}
|
||||
+54
-16
@@ -9,7 +9,9 @@ import { recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
|
||||
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts";
|
||||
import * as semaphore from "./rateLimitSemaphore.ts";
|
||||
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
|
||||
import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck";
|
||||
import { parseModel } from "./model.ts";
|
||||
import { applyComboAgentMiddleware, injectModelTag } from "./comboAgentMiddleware.ts";
|
||||
|
||||
// Status codes that should mark semaphore + record circuit breaker failures
|
||||
const TRANSIENT_FOR_BREAKER = [429, 502, 503, 504];
|
||||
@@ -150,18 +152,8 @@ function orderModelsForWeightedFallback(models, selectedModel) {
|
||||
return [selected, ...rest].filter(Boolean).map((e) => e.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fisher-Yates shuffle (in-place)
|
||||
* @param {Array} arr
|
||||
* @returns {Array} The shuffled array
|
||||
*/
|
||||
function shuffleArray(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
// shuffleArray and getNextModelFromDeck moved to src/shared/utils/shuffleDeck.ts
|
||||
// combo.ts now uses the shared, mutex-protected getNextFromDeck with "combo:" namespace.
|
||||
|
||||
/**
|
||||
* Sort models by pricing (cheapest first) for cost-optimized strategy
|
||||
@@ -234,12 +226,49 @@ export async function handleComboChat({
|
||||
const strategy = combo.strategy || "priority";
|
||||
const models = combo.models || [];
|
||||
|
||||
// ── Combo Agent Middleware (#399 + #401) ────────────────────────────────
|
||||
// Apply system_message override, tool_filter_regex, and extract pinned model
|
||||
// from context caching tag. These are all opt-in per combo config.
|
||||
const { body: agentBody, pinnedModel } = applyComboAgentMiddleware(
|
||||
body,
|
||||
combo,
|
||||
"" // provider/model not yet known — resolved per-model in loop
|
||||
);
|
||||
body = agentBody;
|
||||
if (pinnedModel) {
|
||||
log.info("COMBO", `[#401] Context caching: pinned model=${pinnedModel}`);
|
||||
}
|
||||
// Wrap handleSingleModel to inject context caching tag on response (#401)
|
||||
const handleSingleModelWrapped = combo.context_cache_protection
|
||||
? async (b, modelStr) => {
|
||||
const res = await handleSingleModel(b, modelStr);
|
||||
// Inject tag only on success and only for non-streaming non-binary responses
|
||||
if (res.ok && !b.stream) {
|
||||
try {
|
||||
const json = await res.clone().json();
|
||||
const msgs = Array.isArray(json?.messages) ? json.messages : [];
|
||||
if (msgs.length > 0) {
|
||||
const tagged = injectModelTag(msgs, modelStr);
|
||||
return new Response(JSON.stringify({ ...json, messages: tagged }), {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON or stream — skip tagging */
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
: handleSingleModel;
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Route to round-robin handler if strategy matches
|
||||
if (strategy === "round-robin") {
|
||||
return handleRoundRobinCombo({
|
||||
body,
|
||||
combo,
|
||||
handleSingleModel,
|
||||
handleSingleModel: handleSingleModelWrapped,
|
||||
isModelAvailable,
|
||||
log,
|
||||
settings,
|
||||
@@ -287,8 +316,17 @@ export async function handleComboChat({
|
||||
}
|
||||
|
||||
// Apply strategy-specific ordering
|
||||
if (strategy === "random") {
|
||||
orderedModels = shuffleArray([...orderedModels]);
|
||||
if (strategy === "strict-random") {
|
||||
const selectedId = await getNextFromDeck(`combo:${combo.name}`, orderedModels);
|
||||
// Put selected model first so the fallback loop tries it first
|
||||
const rest = orderedModels.filter((m) => m !== selectedId);
|
||||
orderedModels = [selectedId, ...rest];
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Strict-random deck: ${selectedId} selected (${orderedModels.length} models)`
|
||||
);
|
||||
} else if (strategy === "random") {
|
||||
orderedModels = fisherYatesShuffle([...orderedModels]);
|
||||
log.info("COMBO", `Random shuffle: ${orderedModels.length} models`);
|
||||
} else if (strategy === "least-used") {
|
||||
orderedModels = sortModelsByUsage(orderedModels, combo.name);
|
||||
@@ -348,7 +386,7 @@ export async function handleComboChat({
|
||||
`Trying model ${i + 1}/${orderedModels.length}: ${modelStr}${retry > 0 ? ` (retry ${retry})` : ""}`
|
||||
);
|
||||
|
||||
const result = await handleSingleModel(body, modelStr);
|
||||
const result = await handleSingleModelWrapped(body, modelStr);
|
||||
|
||||
// Success — return response
|
||||
if (result.ok) {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* comboAgentMiddleware.ts — Combo Agent Features
|
||||
*
|
||||
* Implements the "combo as agent" features from issues #399 and #401:
|
||||
*
|
||||
* 1. **System Message Override** (#399): If the combo defines a `system_message`,
|
||||
* it is injected as the first system message, replacing any existing system message.
|
||||
*
|
||||
* 2. **Tool Filter Regex** (#399): If the combo defines a `tool_filter_regex`,
|
||||
* only tools whose name matches the pattern are forwarded to the provider.
|
||||
*
|
||||
* 3. **Context Caching Protection** (#401): If the combo enables
|
||||
* `context_cache_protection`, the proxy:
|
||||
* a. On response: injects `<omniModel>provider/model</omniModel>` tag into
|
||||
* the first assistant message content string.
|
||||
* b. On request: scans the message history for the tag, and if found,
|
||||
* overrides the requested model with the pinned one.
|
||||
*
|
||||
* All features are opt-in per combo and backward compatible with existing setups.
|
||||
*/
|
||||
|
||||
interface ComboConfig {
|
||||
system_message?: string | null;
|
||||
tool_filter_regex?: string | null;
|
||||
context_cache_protection?: number | boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ── Context Caching Tag ─────────────────────────────────────────────────────
|
||||
|
||||
const CACHE_TAG_PATTERN = /<omniModel>([^<]+)<\/omniModel>/;
|
||||
|
||||
/**
|
||||
* Inject the model tag into the last assistant message (or append a new one).
|
||||
* Only modifies string content — does not touch array content to avoid breaking
|
||||
* Claude/Gemini multi-part message formats.
|
||||
*/
|
||||
export function injectModelTag(messages: Message[], providerModel: string): Message[] {
|
||||
// Remove any existing tag first to avoid duplication on context compaction
|
||||
const cleaned = messages.map((msg) => {
|
||||
if (msg.role === "assistant" && typeof msg.content === "string") {
|
||||
return { ...msg, content: msg.content.replace(CACHE_TAG_PATTERN, "").trimEnd() };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
// Find last assistant message with string content
|
||||
const lastAssistantIdx = cleaned.map((m) => m.role).lastIndexOf("assistant");
|
||||
if (lastAssistantIdx === -1) return cleaned;
|
||||
|
||||
const msg = cleaned[lastAssistantIdx];
|
||||
if (typeof msg.content !== "string") return cleaned;
|
||||
|
||||
const tagged = [...cleaned];
|
||||
tagged[lastAssistantIdx] = {
|
||||
...msg,
|
||||
content: `${msg.content}\n<omniModel>${providerModel}</omniModel>`,
|
||||
};
|
||||
return tagged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan message history for the model tag injected by a previous response.
|
||||
* Returns the pinned "provider/model" string, or null if not found.
|
||||
*/
|
||||
export function extractPinnedModel(messages: Message[]): string | null {
|
||||
// Scan from newest to oldest for efficiency
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "assistant" && typeof msg.content === "string") {
|
||||
const match = CACHE_TAG_PATTERN.exec(msg.content);
|
||||
if (match) return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── System Message Override ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace or inject a system message at the beginning of the messages array.
|
||||
* Existing system messages are removed if a combo override is set.
|
||||
*/
|
||||
export function applySystemMessageOverride(messages: Message[], systemMessage: string): Message[] {
|
||||
// Remove all existing system messages
|
||||
const filtered = messages.filter((m) => m.role !== "system");
|
||||
// Inject combo system message at start
|
||||
return [{ role: "system", content: systemMessage }, ...filtered];
|
||||
}
|
||||
|
||||
// ── Tool Filter Regex ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter the tools array, keeping only tools whose name matches the regex.
|
||||
* Returns the original array unchanged if pattern is null/empty.
|
||||
*/
|
||||
export function applyToolFilter(
|
||||
tools: unknown[] | undefined,
|
||||
pattern: string | null | undefined
|
||||
): unknown[] | undefined {
|
||||
if (!tools || !pattern) return tools;
|
||||
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(pattern);
|
||||
} catch {
|
||||
// Invalid regex — return tools unchanged rather than crashing
|
||||
console.warn(`[ComboAgent] Invalid tool_filter_regex: "${pattern}"`);
|
||||
return tools;
|
||||
}
|
||||
|
||||
return tools.filter((tool) => {
|
||||
const t = tool as Record<string, unknown>;
|
||||
// Support both OpenAI format ({ function: { name } }) and Anthropic ({ name })
|
||||
const name = (t.function as Record<string, unknown> | undefined)?.name ?? t.name ?? "";
|
||||
return regex.test(String(name));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Main Middleware ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply all combo agent features to the request body.
|
||||
* Safe to call with null/undefined comboConfig — returns body unchanged.
|
||||
*/
|
||||
export function applyComboAgentMiddleware(
|
||||
body: Record<string, unknown>,
|
||||
comboConfig: ComboConfig | null | undefined,
|
||||
providerModel: string // "provider/model" string for context caching
|
||||
): { body: Record<string, unknown>; pinnedModel: string | null } {
|
||||
if (!comboConfig) return { body, pinnedModel: null };
|
||||
|
||||
let messages: Message[] = Array.isArray(body.messages) ? [...body.messages] : [];
|
||||
let pinnedModel: string | null = null;
|
||||
|
||||
// 1. Context caching: check for pinned model in history
|
||||
if (comboConfig.context_cache_protection) {
|
||||
pinnedModel = extractPinnedModel(messages);
|
||||
if (pinnedModel) {
|
||||
// Model is pinned — caller should override model selection
|
||||
}
|
||||
}
|
||||
|
||||
// 2. System message override
|
||||
if (comboConfig.system_message && comboConfig.system_message.trim()) {
|
||||
messages = applySystemMessageOverride(messages, comboConfig.system_message);
|
||||
}
|
||||
|
||||
// 3. Tool filter
|
||||
const filteredTools = applyToolFilter(
|
||||
body.tools as unknown[] | undefined,
|
||||
comboConfig.tool_filter_regex
|
||||
);
|
||||
|
||||
return {
|
||||
body: {
|
||||
...body,
|
||||
messages,
|
||||
...(filteredTools !== body.tools && { tools: filteredTools }),
|
||||
},
|
||||
pinnedModel,
|
||||
};
|
||||
}
|
||||
@@ -161,6 +161,11 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: `GitHub token expired or permission denied. Please re-authenticate the connection.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`GitHub API error: ${error}`);
|
||||
}
|
||||
|
||||
@@ -620,6 +625,11 @@ async function getCodexUsage(accessToken, providerSpecificData: Record<string, u
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: `Codex token expired or access denied. Please re-authenticate the connection.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`Codex API error: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,10 @@ export function filterToOpenAIFormat(body) {
|
||||
delete body.tools;
|
||||
}
|
||||
|
||||
// Strip Claude-specific fields that OpenAI-compatible providers reject
|
||||
delete body.metadata;
|
||||
delete body.anthropic_version;
|
||||
|
||||
// Normalize tools to OpenAI format (from Claude, Gemini, etc.)
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
||||
body.tools = body.tools
|
||||
|
||||
@@ -131,7 +131,7 @@ export function translateRequest(
|
||||
}
|
||||
|
||||
// Final step: prepare request for Claude format endpoints
|
||||
if (targetFormat === FORMATS.CLAUDE) {
|
||||
if (targetFormat === FORMATS.CLAUDE && sourceFormat !== FORMATS.CLAUDE) {
|
||||
result = prepareClaudeRequest(result, provider);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { register } from "../registry.ts";
|
||||
import { FORMATS } from "../formats.ts";
|
||||
import { generateToolCallId } from "../helpers/toolCallHelper.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -120,6 +121,12 @@ export function openaiResponsesToOpenAIRequest(
|
||||
}
|
||||
|
||||
if (itemType === "function_call") {
|
||||
// Skip tool calls with empty names to avoid infinite placeholder_tool loops
|
||||
const fnName = toString(item.name).trim();
|
||||
if (!fnName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start or append assistant message with tool_calls
|
||||
if (!currentAssistantMsg) {
|
||||
currentAssistantMsg = {
|
||||
@@ -136,7 +143,7 @@ export function openaiResponsesToOpenAIRequest(
|
||||
id: toString(item.call_id),
|
||||
type: "function",
|
||||
function: {
|
||||
name: toString(item.name),
|
||||
name: fnName,
|
||||
arguments: item.arguments,
|
||||
},
|
||||
});
|
||||
@@ -201,6 +208,24 @@ export function openaiResponsesToOpenAIRequest(
|
||||
});
|
||||
}
|
||||
|
||||
// Filter orphaned tool results (no matching tool_call in any assistant message)
|
||||
const allToolCallIds = new Set<string>();
|
||||
for (const m of messages) {
|
||||
const rec = toRecord(m);
|
||||
if (Array.isArray(rec.tool_calls)) {
|
||||
for (const tc of rec.tool_calls as { id?: string }[]) {
|
||||
if (tc.id) allToolCallIds.add(String(tc.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
result.messages = messages.filter((m) => {
|
||||
const rec = toRecord(m);
|
||||
if (rec.role === "tool" && rec.tool_call_id) {
|
||||
return allToolCallIds.has(String(rec.tool_call_id));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Cleanup Responses API specific fields
|
||||
delete result.input;
|
||||
delete result.instructions;
|
||||
@@ -319,10 +344,15 @@ export function openaiToOpenAIResponsesRequest(
|
||||
for (const toolCallValue of msg.tool_calls) {
|
||||
const toolCall = toRecord(toolCallValue);
|
||||
const fn = toRecord(toolCall.function);
|
||||
// Skip tool calls with empty names to avoid infinite placeholder_tool loops
|
||||
const fnName = toString(fn.name).trim();
|
||||
if (!fnName) {
|
||||
continue;
|
||||
}
|
||||
input.push({
|
||||
type: "function_call",
|
||||
call_id: toString(toolCall.id),
|
||||
name: toString(fn.name),
|
||||
call_id: toString(toolCall.id).trim() || generateToolCallId(),
|
||||
name: fnName,
|
||||
arguments: toString(fn.arguments, "{}"),
|
||||
});
|
||||
}
|
||||
@@ -339,6 +369,22 @@ export function openaiToOpenAIResponsesRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// Filter orphaned function_call_output items (no matching function_call)
|
||||
// This happens when Claude Code compaction removes messages but leaves tool results
|
||||
const knownCallIds = new Set(
|
||||
input
|
||||
.filter(
|
||||
(item: { type?: string; call_id?: string }) => item.type === "function_call" && item.call_id
|
||||
)
|
||||
.map((item: { type?: string; call_id?: string }) => item.call_id)
|
||||
);
|
||||
result.input = input.filter((item: { type?: string; call_id?: string }) => {
|
||||
if (item.type === "function_call_output" && item.call_id) {
|
||||
return knownCallIds.has(item.call_id);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no system message, keep empty instructions
|
||||
if (!hasSystemMessage) {
|
||||
result.instructions = "";
|
||||
@@ -363,6 +409,7 @@ export function openaiToOpenAIResponsesRequest(
|
||||
}
|
||||
|
||||
// Pass through relevant fields
|
||||
if (root.service_tier !== undefined) result.service_tier = root.service_tier;
|
||||
if (root.temperature !== undefined) result.temperature = root.temperature;
|
||||
if (root.max_tokens !== undefined) result.max_tokens = root.max_tokens;
|
||||
if (root.top_p !== undefined) result.top_p = root.top_p;
|
||||
|
||||
@@ -123,6 +123,43 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||
|
||||
flushCurrentMessage();
|
||||
|
||||
// Remove assistant messages with empty content (can happen when all tool_use blocks were skipped)
|
||||
result.messages = result.messages.filter((msg) => {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content) && msg.content.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Filter orphaned tool_result blocks whose tool_use_id has no matching tool_use
|
||||
const allToolUseIds = new Set<string>();
|
||||
for (const msg of result.messages) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && block.id) {
|
||||
allToolUseIds.add(String(block.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const msg of result.messages) {
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
msg.content = msg.content.filter((block) => {
|
||||
if (block.type === "tool_result" && block.tool_use_id) {
|
||||
return allToolUseIds.has(String(block.tool_use_id));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Remove user messages that became empty after orphan filtering
|
||||
result.messages = result.messages.filter((msg) => {
|
||||
if (msg.role === "user" && Array.isArray(msg.content) && msg.content.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Add cache_control to last assistant message
|
||||
for (let i = result.messages.length - 1; i >= 0; i--) {
|
||||
const message = result.messages[i];
|
||||
|
||||
@@ -184,6 +184,17 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
typeof parsed.type === "string" &&
|
||||
parsed.type.startsWith("response.");
|
||||
|
||||
// Detect Claude SSE payloads. Includes "ping" and "error" to ensure
|
||||
// they bypass the Chat Completions sanitization path which would
|
||||
// incorrectly process or drop them.
|
||||
const isClaudeSSE =
|
||||
parsed.type &&
|
||||
typeof parsed.type === "string" &&
|
||||
(parsed.type.startsWith("message") ||
|
||||
parsed.type.startsWith("content_block") ||
|
||||
parsed.type === "ping" ||
|
||||
parsed.type === "error");
|
||||
|
||||
if (isResponsesSSE) {
|
||||
// Responses SSE: only extract usage, forward payload as-is
|
||||
const extracted = extractUsage(parsed);
|
||||
@@ -194,6 +205,22 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
if (parsed.delta && typeof parsed.delta === "string") {
|
||||
totalContentLength += parsed.delta.length;
|
||||
}
|
||||
} else if (isClaudeSSE) {
|
||||
// Claude SSE: extract usage, track content, forward as-is
|
||||
const extracted = extractUsage(parsed);
|
||||
if (extracted) {
|
||||
// Non-destructive merge: never overwrite a positive value with 0
|
||||
// message_start carries input_tokens, message_delta carries output_tokens
|
||||
if (!usage) usage = {};
|
||||
if (extracted.prompt_tokens > 0) usage.prompt_tokens = extracted.prompt_tokens;
|
||||
if (extracted.completion_tokens > 0) usage.completion_tokens = extracted.completion_tokens;
|
||||
if (extracted.total_tokens > 0) usage.total_tokens = extracted.total_tokens;
|
||||
if (extracted.cache_read_input_tokens) usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
|
||||
if (extracted.cache_creation_input_tokens) usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
|
||||
}
|
||||
// Track content length from Claude format
|
||||
if (parsed.delta?.text) totalContentLength += parsed.delta.text.length;
|
||||
if (parsed.delta?.thinking) totalContentLength += parsed.delta.thinking.length;
|
||||
} else {
|
||||
// Chat Completions: full sanitization pipeline
|
||||
parsed = sanitizeStreamingChunk(parsed);
|
||||
@@ -372,9 +399,9 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
controller.enqueue(encoder.encode(output));
|
||||
}
|
||||
|
||||
// Estimate usage if provider didn't return valid usage (PASSTHROUGH is always OpenAI format)
|
||||
// Estimate usage if provider didn't return valid usage
|
||||
if (!hasValidUsage(usage) && totalContentLength > 0) {
|
||||
usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
|
||||
usage = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI);
|
||||
}
|
||||
|
||||
if (hasValidUsage(usage)) {
|
||||
|
||||
Generated
+640
-435
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.4.2",
|
||||
"version": "2.6.8",
|
||||
"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": {
|
||||
@@ -47,7 +47,7 @@
|
||||
"homepage": "https://omniroute.online",
|
||||
"scripts": {
|
||||
"dev": "node scripts/run-next.mjs dev",
|
||||
"build": "next build --webpack",
|
||||
"build": "next build",
|
||||
"build:cli": "node scripts/prepublish.mjs",
|
||||
"start": "node scripts/run-next.mjs start",
|
||||
"lint": "eslint .",
|
||||
@@ -58,9 +58,9 @@
|
||||
"electron:build:linux": "npm run build && cd electron && npm run build:linux",
|
||||
"test": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:plan3": "node --test tests/unit/plan3-p0.test.mjs",
|
||||
"test:fixes": "node --test tests/unit/fixes-p1.test.mjs",
|
||||
"test:security": "node --test tests/unit/security-fase01.test.mjs",
|
||||
"test:plan3": "node --import tsx/esm --test tests/unit/plan3-p0.test.mjs",
|
||||
"test:fixes": "node --import tsx/esm --test tests/unit/fixes-p1.test.mjs",
|
||||
"test:security": "node --import tsx/esm --test tests/unit/security-fase01.test.mjs",
|
||||
"check:cycles": "node scripts/check-cycles.mjs",
|
||||
"check:route-validation:t06": "node scripts/check-route-validation.mjs",
|
||||
"check:any-budget:t11": "node scripts/check-t11-any-budget.mjs",
|
||||
@@ -90,7 +90,7 @@
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"jose": "^6.1.3",
|
||||
"lowdb": "^7.0.1",
|
||||
"monaco-editor": "^0.55.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 472 B |
+68
-16
@@ -7,22 +7,25 @@
|
||||
* restarts, Docker volume remounts, and upgrades.
|
||||
*
|
||||
* Works across all deployment modes:
|
||||
* - npm / CLI: called from run-standalone.mjs and run-next.mjs
|
||||
* - npm / app runners: called from run-standalone.mjs and run-next.mjs
|
||||
* - Docker: same, secrets persisted in mounted volume
|
||||
* - Electron: called from main.js startup, persisted in userData
|
||||
* - Electron: called from main.js startup, persisted in DATA_DIR
|
||||
*
|
||||
* Priority (lowest → highest):
|
||||
* 1. Auto-generated defaults
|
||||
* 2. {DATA_DIR}/server.env (persisted on first boot)
|
||||
* 3. .env in CWD (user overrides)
|
||||
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
|
||||
* 4. process.env (shell / Docker -e flags, highest priority)
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// ── OAuth secrets that are optional but warn if missing ─────────────────────
|
||||
const OPTIONAL_OAUTH_SECRETS = [
|
||||
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
|
||||
@@ -31,23 +34,65 @@ const OPTIONAL_OAUTH_SECRETS = [
|
||||
];
|
||||
|
||||
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
|
||||
function resolveDataDir(overridePath) {
|
||||
if (overridePath) return resolve(overridePath);
|
||||
function resolveDataDir(overridePath, env = process.env) {
|
||||
if (overridePath?.trim()) return resolve(overridePath);
|
||||
|
||||
const configured = process.env.DATA_DIR?.trim();
|
||||
const configured = env.DATA_DIR?.trim();
|
||||
if (configured) return resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
return join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
||||
const xdg = env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return join(resolve(xdg), "omniroute");
|
||||
|
||||
return join(homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
function getPreferredEnvFilePath(env = process.env) {
|
||||
const candidates = [];
|
||||
|
||||
if (env.DATA_DIR?.trim()) {
|
||||
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
|
||||
}
|
||||
|
||||
candidates.push(join(resolveDataDir(null, env), ".env"));
|
||||
candidates.push(join(process.cwd(), ".env"));
|
||||
|
||||
return candidates.find((filePath) => existsSync(filePath)) ?? null;
|
||||
}
|
||||
|
||||
function hasEncryptedCredentials(dataDir) {
|
||||
const dbPath = join(dataDir, "storage.sqlite");
|
||||
if (!existsSync(dbPath)) return false;
|
||||
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR api_key LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get();
|
||||
return !!row;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function parseEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) return {};
|
||||
@@ -85,18 +130,17 @@ function writeEnvFile(filePath, env) {
|
||||
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
|
||||
|
||||
const dataDir = resolveDataDir(dataDirOverride);
|
||||
const preferredEnvPath = getPreferredEnvFilePath(process.env);
|
||||
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
|
||||
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
|
||||
const serverEnvPath = join(dataDir, "server.env");
|
||||
const dotEnvPath = join(process.cwd(), ".env");
|
||||
|
||||
// ── Layer 1: Load persisted server.env ────────────────────────────────────
|
||||
let persisted = parseEnvFile(serverEnvPath);
|
||||
|
||||
// ── Layer 2: Load .env from CWD (user overrides, higher priority) ─────────
|
||||
const dotEnv = parseEnvFile(dotEnvPath);
|
||||
|
||||
// ── Merge: persisted < .env < process.env ─────────────────────────────────
|
||||
const merged = { ...persisted, ...dotEnv, ...process.env };
|
||||
// ── Layer 2: Load the same preferred .env that the CLI wrapper uses ───────
|
||||
// This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`.
|
||||
const merged = { ...persisted, ...preferredEnv, ...process.env };
|
||||
|
||||
// ── Auto-generate required secrets ────────────────────────────────────────
|
||||
let needsPersist = false;
|
||||
@@ -109,6 +153,14 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
}
|
||||
|
||||
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
|
||||
if (hasEncryptedCredentials(dataDir)) {
|
||||
throw new Error(
|
||||
`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join(
|
||||
dataDir,
|
||||
"storage.sqlite"
|
||||
)}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
|
||||
);
|
||||
}
|
||||
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
|
||||
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
|
||||
needsPersist = true;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const ROOT = join(__dirname, "..");
|
||||
|
||||
const STANDALONE_DIR = join(ROOT, ".next", "standalone");
|
||||
const ELECTRON_STANDALONE_DIR = join(ROOT, ".next", "electron-standalone");
|
||||
const STATIC_SRC = join(ROOT, ".next", "static");
|
||||
const STATIC_DEST = join(ELECTRON_STANDALONE_DIR, ".next", "static");
|
||||
const PUBLIC_SRC = join(ROOT, "public");
|
||||
const PUBLIC_DEST = join(ELECTRON_STANDALONE_DIR, "public");
|
||||
|
||||
function resolveStandaloneBundleDir() {
|
||||
const directServer = join(STANDALONE_DIR, "server.js");
|
||||
if (existsSync(directServer)) {
|
||||
return STANDALONE_DIR;
|
||||
}
|
||||
|
||||
const nestedCandidates = [
|
||||
join(STANDALONE_DIR, "projects", "OmniRoute"),
|
||||
join(STANDALONE_DIR, basename(ROOT)),
|
||||
];
|
||||
|
||||
for (const candidate of nestedCandidates) {
|
||||
if (existsSync(join(candidate, "server.js"))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Standalone server bundle not found in ${STANDALONE_DIR}. Run \`npm run build\` first.`
|
||||
);
|
||||
}
|
||||
|
||||
function createPathPattern(filePath) {
|
||||
return filePath
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\//g, "[\\\\/]");
|
||||
}
|
||||
|
||||
function sanitizeBuildPaths(bundleDir) {
|
||||
const buildRoot = ROOT.replace(/\\/g, "/");
|
||||
const bundleRoot = bundleDir.replace(/\\/g, "/");
|
||||
const replacements = [buildRoot, bundleRoot];
|
||||
const targets = [
|
||||
join(ELECTRON_STANDALONE_DIR, "server.js"),
|
||||
join(ELECTRON_STANDALONE_DIR, ".next", "required-server-files.json"),
|
||||
];
|
||||
|
||||
for (const filePath of targets) {
|
||||
if (!existsSync(filePath)) continue;
|
||||
|
||||
let content = readFileSync(filePath, "utf8");
|
||||
let updated = content;
|
||||
|
||||
for (const original of replacements) {
|
||||
updated = updated.replace(new RegExp(createPathPattern(original), "g"), ".");
|
||||
}
|
||||
|
||||
if (updated !== content) {
|
||||
writeFileSync(filePath, updated, "utf8");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePackage(pkgPath, sourcePath) {
|
||||
if (existsSync(pkgPath) || !existsSync(sourcePath)) return;
|
||||
mkdirSync(dirname(pkgPath), { recursive: true });
|
||||
cpSync(sourcePath, pkgPath, { recursive: true, dereference: true });
|
||||
}
|
||||
|
||||
function assertBundleIsPackagable(bundleDir) {
|
||||
const nodeModulesPath = join(bundleDir, "node_modules");
|
||||
if (!existsSync(nodeModulesPath)) return;
|
||||
|
||||
if (lstatSync(nodeModulesPath).isSymbolicLink()) {
|
||||
throw new Error(
|
||||
[
|
||||
"Next standalone emitted app/node_modules as a symlink.",
|
||||
"electron-builder preserves extraResources symlinks, which would make the packaged app",
|
||||
"depend on the original build machine path at runtime.",
|
||||
"",
|
||||
`Offending path: ${nodeModulesPath}`,
|
||||
"Use a real node_modules directory in the build worktree before packaging Electron.",
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function logContextualError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[electron] failed to prepare standalone bundle: ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
process.on("uncaughtException", logContextualError);
|
||||
|
||||
const bundleDir = resolveStandaloneBundleDir();
|
||||
assertBundleIsPackagable(bundleDir);
|
||||
|
||||
rmSync(ELECTRON_STANDALONE_DIR, { recursive: true, force: true });
|
||||
mkdirSync(ELECTRON_STANDALONE_DIR, { recursive: true });
|
||||
|
||||
cpSync(bundleDir, ELECTRON_STANDALONE_DIR, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
|
||||
sanitizeBuildPaths(bundleDir);
|
||||
|
||||
if (existsSync(STATIC_SRC)) {
|
||||
mkdirSync(dirname(STATIC_DEST), { recursive: true });
|
||||
cpSync(STATIC_SRC, STATIC_DEST, { recursive: true, dereference: true });
|
||||
}
|
||||
|
||||
if (existsSync(PUBLIC_SRC)) {
|
||||
cpSync(PUBLIC_SRC, PUBLIC_DEST, { recursive: true, dereference: true });
|
||||
}
|
||||
|
||||
ensurePackage(
|
||||
join(ELECTRON_STANDALONE_DIR, "node_modules", "@swc", "helpers"),
|
||||
join(ROOT, "node_modules", "@swc", "helpers")
|
||||
);
|
||||
|
||||
ensurePackage(
|
||||
join(ELECTRON_STANDALONE_DIR, "node_modules", "better-sqlite3"),
|
||||
join(ROOT, "node_modules", "better-sqlite3")
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`
|
||||
);
|
||||
+117
-2
@@ -10,7 +10,16 @@
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, cpSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
cpSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -34,7 +43,17 @@ execSync("npm install", { cwd: ROOT, stdio: "inherit" });
|
||||
|
||||
// ── Step 3: Build Next.js ──────────────────────────────────
|
||||
console.log(" 🏗️ Building Next.js (standalone)...");
|
||||
execSync("npx next build --webpack", { cwd: ROOT, stdio: "inherit" });
|
||||
execSync("npx next build", {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
// Force webpack codegen — Turbopack emits hashed require() calls for
|
||||
// server external packages that break npm global installs (#394, #396, #398).
|
||||
EXPERIMENTAL_TURBOPACK: "0",
|
||||
NEXT_PRIVATE_BUILD_WORKER: "0",
|
||||
},
|
||||
});
|
||||
|
||||
// ── Step 4: Verify standalone output ───────────────────────
|
||||
const standaloneDir = join(ROOT, ".next", "standalone");
|
||||
@@ -47,6 +66,46 @@ if (!existsSync(serverJs)) {
|
||||
}
|
||||
|
||||
// ── Step 5: Copy standalone output to app/ ─────────────────
|
||||
// ── Step 4.5: Check build for hashed external references ──────────────────────
|
||||
// Warn if Turbopack-style hash suffixes are found — they will be resolved at
|
||||
// runtime by the externals patch in next.config.mjs, but log for visibility.
|
||||
{
|
||||
const HASH_RE = /require\(["']([\w@./-]+-[0-9a-f]{16})["']\)/;
|
||||
const scanDir = (dir, hits = []) => {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return hits;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const f = join(dir, e);
|
||||
try {
|
||||
if (statSync(f).isDirectory()) {
|
||||
scanDir(f, hits);
|
||||
continue;
|
||||
}
|
||||
if (!f.endsWith(".js")) continue;
|
||||
const m = readFileSync(f, "utf8").match(HASH_RE);
|
||||
if (m) hits.push({ file: f.replace(standaloneDir, "app"), mod: m[1] });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
};
|
||||
const hits = scanDir(join(standaloneDir, ".next", "server"));
|
||||
if (hits.length > 0) {
|
||||
console.warn(
|
||||
" ⚠️ Hashed externals in build (will be auto-fixed at runtime by externals patch):"
|
||||
);
|
||||
hits.slice(0, 5).forEach((h) => console.warn());
|
||||
if (hits.length > 5) console.warn();
|
||||
} else {
|
||||
console.log(" ✅ Build clean — no hashed externals found.");
|
||||
}
|
||||
}
|
||||
|
||||
console.log(" 📋 Copying standalone build to app/...");
|
||||
mkdirSync(APP_DIR, { recursive: true });
|
||||
cpSync(standaloneDir, APP_DIR, { recursive: true });
|
||||
@@ -83,6 +142,62 @@ if (sanitisedCount > 0) {
|
||||
console.log(" ℹ️ No hardcoded paths found to sanitise");
|
||||
}
|
||||
|
||||
// ── Step 5.6: Strip Turbopack hashed externals from compiled chunks ─────────
|
||||
// Even when Turbopack is disabled at build time, some instrumentation chunks
|
||||
// may still emit require('package-<16hexchars>') instead of require('package').
|
||||
// These hashed names don't exist in node_modules and cause MODULE_NOT_FOUND at
|
||||
// runtime. We strip the hex suffix from all .js files in app/.next/server/
|
||||
// to ensure all require() calls use the real package names.
|
||||
{
|
||||
const serverOutput = join(APP_DIR, ".next", "server");
|
||||
const HASH_RE = /(['"\\])([a-z@][a-z0-9@./_-]+-[0-9a-f]{16})\1/g;
|
||||
let patchedFiles = 0;
|
||||
let patchedMatches = 0;
|
||||
const walkDir = (dir) => {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry);
|
||||
try {
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
walkDir(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.endsWith(".js")) continue;
|
||||
const src = readFileSync(full, "utf8");
|
||||
let count = 0;
|
||||
const patched = src.replace(HASH_RE, (_, q, name) => {
|
||||
const base = name.replace(/-[0-9a-f]{16}$/, "");
|
||||
count++;
|
||||
return `${q}${base}${q}`;
|
||||
});
|
||||
if (count > 0) {
|
||||
writeFileSync(full, patched);
|
||||
patchedFiles++;
|
||||
patchedMatches += count;
|
||||
}
|
||||
} catch {
|
||||
/* skip unreadable files */
|
||||
}
|
||||
}
|
||||
};
|
||||
if (existsSync(serverOutput)) {
|
||||
walkDir(serverOutput);
|
||||
if (patchedMatches > 0) {
|
||||
console.log(
|
||||
` 🔧 Hash-strip: patched ${patchedMatches} hashed require() in ${patchedFiles} server chunk file(s)`
|
||||
);
|
||||
} else {
|
||||
console.log(" ✅ Hash-strip: no hashed externals found in compiled chunks.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 6: Copy static assets ─────────────────────────────
|
||||
const staticSrc = join(ROOT, ".next", "static");
|
||||
const staticDest = join(APP_DIR, ".next", "static");
|
||||
|
||||
@@ -52,15 +52,34 @@ function validateKeyName(
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
interface AccessSchedule {
|
||||
enabled: boolean;
|
||||
from: string;
|
||||
until: string;
|
||||
days: number[];
|
||||
tz: string;
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
allowedModels: string[] | null;
|
||||
allowedConnections: string[] | null;
|
||||
noLog?: boolean;
|
||||
autoResolve?: boolean;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ProviderConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface KeyUsageStats {
|
||||
totalRequests: number;
|
||||
lastUsed: string | null;
|
||||
@@ -79,6 +98,7 @@ export default function ApiManagerPageClient() {
|
||||
const tc = useTranslations("common");
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [allModels, setAllModels] = useState<Model[]>([]);
|
||||
const [allConnections, setAllConnections] = useState<ProviderConnection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
@@ -95,6 +115,7 @@ export default function ApiManagerPageClient() {
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchModels();
|
||||
fetchConnections();
|
||||
}, []);
|
||||
|
||||
const fetchModels = async () => {
|
||||
@@ -109,6 +130,18 @@ export default function ApiManagerPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConnections = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAllConnections(data.connections || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/keys");
|
||||
@@ -227,7 +260,14 @@ export default function ApiManagerPageClient() {
|
||||
setShowPermissionsModal(true);
|
||||
};
|
||||
|
||||
const handleUpdatePermissions = async (allowedModels: string[], noLog: boolean) => {
|
||||
const handleUpdatePermissions = async (
|
||||
allowedModels: string[],
|
||||
noLog: boolean,
|
||||
allowedConnections: string[],
|
||||
autoResolve: boolean,
|
||||
isActive: boolean,
|
||||
accessSchedule: AccessSchedule | null
|
||||
) => {
|
||||
if (!editingKey || !editingKey.id) return;
|
||||
|
||||
// Validate models array
|
||||
@@ -247,6 +287,11 @@ export default function ApiManagerPageClient() {
|
||||
(id) => typeof id === "string" && id.length > 0 && id.length < 200
|
||||
);
|
||||
|
||||
// Validate connections (must be UUIDs)
|
||||
const validConnections = allowedConnections.filter(
|
||||
(id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
|
||||
);
|
||||
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
@@ -254,7 +299,14 @@ export default function ApiManagerPageClient() {
|
||||
const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ allowedModels: validModels, noLog }),
|
||||
body: JSON.stringify({
|
||||
allowedModels: validModels,
|
||||
allowedConnections: validConnections,
|
||||
noLog,
|
||||
autoResolve,
|
||||
isActive,
|
||||
accessSchedule,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -449,7 +501,11 @@ export default function ApiManagerPageClient() {
|
||||
{keys.map((key) => {
|
||||
const stats = usageStats[key.id];
|
||||
const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
|
||||
const hasConnectionRestrictions =
|
||||
Array.isArray(key.allowedConnections) && key.allowedConnections.length > 0;
|
||||
const noLogEnabled = key.noLog === true;
|
||||
const keyIsActive = key.isActive !== false; // default true
|
||||
const hasSchedule = key.accessSchedule?.enabled === true;
|
||||
return (
|
||||
<div
|
||||
key={key.id}
|
||||
@@ -496,6 +552,15 @@ export default function ApiManagerPageClient() {
|
||||
{t("allModels")}
|
||||
</button>
|
||||
)}
|
||||
{hasConnectionRestrictions && (
|
||||
<button
|
||||
onClick={() => handleOpenPermissions(key)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">cable</span>
|
||||
{key.allowedConnections.length} conn
|
||||
</button>
|
||||
)}
|
||||
{noLogEnabled && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
@@ -504,6 +569,26 @@ export default function ApiManagerPageClient() {
|
||||
No-Log
|
||||
</span>
|
||||
)}
|
||||
{key.autoResolve && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
auto_fix_high
|
||||
</span>
|
||||
Auto-Resolve
|
||||
</span>
|
||||
)}
|
||||
{!keyIsActive && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-500/10 text-red-600 dark:text-red-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">block</span>
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
{hasSchedule && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">schedule</span>
|
||||
{t("scheduleActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col justify-center">
|
||||
@@ -659,6 +744,7 @@ export default function ApiManagerPageClient() {
|
||||
apiKey={editingKey}
|
||||
modelsByProvider={filteredModelsByProvider}
|
||||
allModels={allModels}
|
||||
allConnections={allConnections}
|
||||
searchModel={searchModel}
|
||||
onSearchChange={setSearchModel}
|
||||
onSave={handleUpdatePermissions}
|
||||
@@ -676,6 +762,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
apiKey,
|
||||
modelsByProvider,
|
||||
allModels,
|
||||
allConnections,
|
||||
searchModel,
|
||||
onSearchChange,
|
||||
onSave,
|
||||
@@ -685,18 +772,42 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
apiKey: ApiKey;
|
||||
modelsByProvider: ProviderGroup[];
|
||||
allModels: Model[];
|
||||
allConnections: ProviderConnection[];
|
||||
searchModel: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onSave: (models: string[], noLog: boolean) => void;
|
||||
onSave: (
|
||||
models: string[],
|
||||
noLog: boolean,
|
||||
connections: string[],
|
||||
autoResolve: boolean,
|
||||
isActive: boolean,
|
||||
accessSchedule: AccessSchedule | null
|
||||
) => void;
|
||||
}) {
|
||||
const t = useTranslations("apiManager");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
// Initialize state from props - component remounts when key prop changes
|
||||
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
|
||||
const initialConnections = Array.isArray(apiKey?.allowedConnections)
|
||||
? apiKey.allowedConnections
|
||||
: [];
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
|
||||
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
|
||||
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
|
||||
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
|
||||
const [keyIsActive, setKeyIsActive] = useState(apiKey?.isActive !== false);
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(apiKey?.accessSchedule?.enabled === true);
|
||||
const [scheduleFrom, setScheduleFrom] = useState(apiKey?.accessSchedule?.from ?? "08:00");
|
||||
const [scheduleUntil, setScheduleUntil] = useState(apiKey?.accessSchedule?.until ?? "18:00");
|
||||
const [scheduleDays, setScheduleDays] = useState<number[]>(
|
||||
apiKey?.accessSchedule?.days ?? [1, 2, 3, 4, 5]
|
||||
);
|
||||
const [scheduleTz, setScheduleTz] = useState(
|
||||
apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
const [selectedConnections, setSelectedConnections] = useState<string[]>(initialConnections);
|
||||
const [allowAllConnections, setAllowAllConnections] = useState(initialConnections.length === 0);
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(() => {
|
||||
// Expand all providers by default when in restrict mode with existing selections
|
||||
if (initialModels.length > 0) {
|
||||
@@ -769,9 +880,51 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
setSelectedModels([]);
|
||||
}, []);
|
||||
|
||||
const handleToggleConnection = useCallback(
|
||||
(connectionId: string) => {
|
||||
if (allowAllConnections) return;
|
||||
setSelectedConnections((prev) =>
|
||||
prev.includes(connectionId)
|
||||
? prev.filter((c) => c !== connectionId)
|
||||
: [...prev, connectionId]
|
||||
);
|
||||
},
|
||||
[allowAllConnections]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(allowAll ? [] : selectedModels, noLogEnabled);
|
||||
}, [onSave, allowAll, selectedModels, noLogEnabled]);
|
||||
const schedule: AccessSchedule | null = scheduleEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
from: scheduleFrom,
|
||||
until: scheduleUntil,
|
||||
days: scheduleDays,
|
||||
tz: scheduleTz,
|
||||
}
|
||||
: null;
|
||||
onSave(
|
||||
allowAll ? [] : selectedModels,
|
||||
noLogEnabled,
|
||||
allowAllConnections ? [] : selectedConnections,
|
||||
autoResolveEnabled,
|
||||
keyIsActive,
|
||||
schedule
|
||||
);
|
||||
}, [
|
||||
onSave,
|
||||
allowAll,
|
||||
selectedModels,
|
||||
noLogEnabled,
|
||||
allowAllConnections,
|
||||
selectedConnections,
|
||||
autoResolveEnabled,
|
||||
keyIsActive,
|
||||
scheduleEnabled,
|
||||
scheduleFrom,
|
||||
scheduleUntil,
|
||||
scheduleDays,
|
||||
scheduleTz,
|
||||
]);
|
||||
|
||||
const selectedCount = selectedModels.length;
|
||||
const totalModels = allModels.length;
|
||||
@@ -833,6 +986,129 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key Active Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("keyActive")}</p>
|
||||
<p className="text-xs text-text-muted">{t("keyActiveDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={keyIsActive}
|
||||
onClick={() => setKeyIsActive((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
keyIsActive
|
||||
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
|
||||
: "bg-red-500/15 text-red-700 dark:text-red-300 border border-red-500/30"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{keyIsActive ? "check_circle" : "block"}
|
||||
</span>
|
||||
{keyIsActive ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Access Schedule */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("accessSchedule")}</p>
|
||||
<p className="text-xs text-text-muted">{t("accessScheduleDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={scheduleEnabled}
|
||||
onClick={() => setScheduleEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
|
||||
scheduleEnabled
|
||||
? "bg-orange-500/15 text-orange-700 dark:text-orange-300 border border-orange-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">schedule</span>
|
||||
{scheduleEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
{scheduleEnabled && (
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("scheduleFrom")}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleFrom}
|
||||
onChange={(e) => setScheduleFrom(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("scheduleUntil")}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleUntil}
|
||||
onChange={(e) => setScheduleUntil(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">{t("scheduleDays")}</label>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(
|
||||
[
|
||||
[0, t("daySun")],
|
||||
[1, t("dayMon")],
|
||||
[2, t("dayTue")],
|
||||
[3, t("dayWed")],
|
||||
[4, t("dayThu")],
|
||||
[5, t("dayFri")],
|
||||
[6, t("daySat")],
|
||||
] as [number, string][]
|
||||
).map(([dayIdx, label]) => {
|
||||
const selected = scheduleDays.includes(dayIdx);
|
||||
return (
|
||||
<button
|
||||
key={dayIdx}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setScheduleDays((prev) =>
|
||||
prev.includes(dayIdx)
|
||||
? prev.filter((d) => d !== dayIdx)
|
||||
: [...prev, dayIdx].sort((a, b) => a - b)
|
||||
)
|
||||
}
|
||||
className={`px-2 py-1 text-[11px] font-medium rounded transition-all ${
|
||||
selected
|
||||
? "bg-primary text-white"
|
||||
: "bg-surface border border-border text-text-muted hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">
|
||||
{t("scheduleTimezone")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={scheduleTz}
|
||||
onChange={(e) => setScheduleTz(e.target.value)}
|
||||
placeholder="America/Sao_Paulo"
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-1">{t("scheduleTimezoneHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Privacy Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -859,6 +1135,30 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-Resolve Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("autoResolve")}</p>
|
||||
<p className="text-xs text-text-muted">{t("autoResolveDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoResolveEnabled}
|
||||
onClick={() => setAutoResolveEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
autoResolveEnabled
|
||||
? "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border border-cyan-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{autoResolveEnabled ? "auto_fix_high" : "auto_fix_normal"}
|
||||
</span>
|
||||
{autoResolveEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected Models Summary (only in restrict mode) */}
|
||||
{!allowAll && selectedCount > 0 && (
|
||||
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">
|
||||
@@ -1024,6 +1324,97 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Allowed Connections Section */}
|
||||
{allConnections.length > 0 && (
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-text-main">Allowed Connections</p>
|
||||
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
|
||||
<button
|
||||
onClick={() => {
|
||||
setAllowAllConnections(true);
|
||||
setSelectedConnections([]);
|
||||
}}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
allowAllConnections
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAllowAllConnections(false)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
!allowAllConnections
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
Restrict
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{allowAllConnections
|
||||
? "This key can use any active connection."
|
||||
: `Restricted to ${selectedConnections.length} connection${selectedConnections.length !== 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
{!allowAllConnections && (
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{Object.entries(
|
||||
allConnections.reduce<Record<string, ProviderConnection[]>>((acc, conn) => {
|
||||
const p = conn.provider || "Other";
|
||||
if (!acc[p]) acc[p] = [];
|
||||
acc[p].push(conn);
|
||||
return acc;
|
||||
}, {})
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, conns]) => (
|
||||
<div key={provider}>
|
||||
<p className="text-[10px] font-semibold text-text-muted uppercase tracking-wider px-1 py-0.5">
|
||||
{provider}
|
||||
</p>
|
||||
{conns.map((conn) => {
|
||||
const isSelected = selectedConnections.includes(conn.id);
|
||||
return (
|
||||
<button
|
||||
key={conn.id}
|
||||
onClick={() => handleToggleConnection(conn.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
isSelected ? "bg-primary border-primary" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="material-symbols-outlined text-white text-[10px]">
|
||||
check
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate flex-1">
|
||||
{conn.name || conn.id.slice(0, 8)}
|
||||
</span>
|
||||
{!conn.isActive && (
|
||||
<span className="text-[9px] text-red-400 shrink-0">inactive</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} fullWidth>
|
||||
|
||||
@@ -27,6 +27,14 @@ const STRATEGY_OPTIONS = [
|
||||
{ value: "random", labelKey: "random", descKey: "randomDesc", icon: "shuffle" },
|
||||
{ value: "least-used", labelKey: "leastUsed", descKey: "leastUsedDesc", icon: "low_priority" },
|
||||
{ value: "cost-optimized", labelKey: "costOpt", descKey: "costOptimizedDesc", icon: "savings" },
|
||||
{
|
||||
value: "fill-first",
|
||||
labelKey: "fillFirst",
|
||||
descKey: "fillFirstDesc",
|
||||
icon: "stacked_bar_chart",
|
||||
},
|
||||
{ value: "p2c", labelKey: "p2c", descKey: "p2cDesc", icon: "compare_arrows" },
|
||||
{ value: "strict-random", labelKey: "strictRandom", descKey: "strictRandomDesc", icon: "casino" },
|
||||
];
|
||||
|
||||
const STRATEGY_GUIDANCE_FALLBACK = {
|
||||
@@ -60,6 +68,21 @@ const STRATEGY_GUIDANCE_FALLBACK = {
|
||||
avoid: "Avoid when pricing data is missing or outdated.",
|
||||
example: "Example: Batch or background jobs where lower cost matters most.",
|
||||
},
|
||||
"fill-first": {
|
||||
when: "Use when you want to drain one provider's quota fully before moving to the next.",
|
||||
avoid: "Avoid when you need request-level load balancing across providers.",
|
||||
example: "Example: Use all $200 Deepgram credits before falling to Groq.",
|
||||
},
|
||||
p2c: {
|
||||
when: "Use when you want low-latency selection using Power-of-Two-Choices algorithm.",
|
||||
avoid: "Avoid for small combos with 2 or fewer models — no benefit over round-robin.",
|
||||
example: "Example: High-throughput inference across 4+ equivalent model endpoints.",
|
||||
},
|
||||
"strict-random": {
|
||||
when: "Use when you want perfectly even spread — each model used once before repeating.",
|
||||
avoid: "Avoid when models have different quality or latency and order matters.",
|
||||
example: "Example: Multiple accounts of the same model to distribute usage evenly.",
|
||||
},
|
||||
};
|
||||
|
||||
const ADVANCED_FIELD_HELP_FALLBACK = {
|
||||
@@ -126,6 +149,34 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
|
||||
"Use for batch/background jobs where cost is the main KPI.",
|
||||
],
|
||||
},
|
||||
"fill-first": {
|
||||
title: "Quota drain strategy",
|
||||
description: "Exhausts one provider's quota before moving to the next in chain.",
|
||||
tips: [
|
||||
"Order models by free quota size — biggest first.",
|
||||
"Enable health checks to skip drained providers.",
|
||||
"Ideal for free-tier stacking (Deepgram → Groq → NIM).",
|
||||
],
|
||||
},
|
||||
p2c: {
|
||||
title: "Power-of-Two-Choices",
|
||||
description:
|
||||
"Picks the less-loaded of two random candidates per request — low latency at scale.",
|
||||
tips: [
|
||||
"Use with 4+ models for best effect.",
|
||||
"Requires latency telemetry enabled in Settings.",
|
||||
"Great replacement for round-robin in high-throughput combos.",
|
||||
],
|
||||
},
|
||||
"strict-random": {
|
||||
title: "Shuffle deck distribution",
|
||||
description: "Each model is used exactly once per cycle before reshuffling.",
|
||||
tips: [
|
||||
"Use at least 2 models for meaningful distribution.",
|
||||
"Ideal for same-model accounts to evenly spread quota.",
|
||||
"Guarantees no model is skipped or repeated within a cycle.",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
|
||||
@@ -227,6 +278,8 @@ function getStrategyBadgeClass(strategy) {
|
||||
if (strategy === "random") return "bg-purple-500/15 text-purple-600 dark:text-purple-400";
|
||||
if (strategy === "least-used") return "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400";
|
||||
if (strategy === "cost-optimized") return "bg-teal-500/15 text-teal-600 dark:text-teal-400";
|
||||
if (strategy === "fill-first") return "bg-orange-500/15 text-orange-600 dark:text-orange-400";
|
||||
if (strategy === "p2c") return "bg-indigo-500/15 text-indigo-600 dark:text-indigo-400";
|
||||
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
@@ -1365,10 +1418,24 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
);
|
||||
};
|
||||
|
||||
const FREE_STACK_PRESET_MODELS = [
|
||||
{ model: "gc/gemini-3-flash-preview", weight: 0 },
|
||||
{ model: "kr/claude-sonnet-4.5", weight: 0 },
|
||||
{ model: "if/kimi-k2-thinking", weight: 0 },
|
||||
{ model: "if/qwen3-coder-plus", weight: 0 },
|
||||
{ model: "qw/qwen3-coder-plus", weight: 0 },
|
||||
{ model: "nvidia/llama-3.3-70b-instruct", weight: 0 },
|
||||
{ model: "groq/llama-3.3-70b-versatile", weight: 0 },
|
||||
];
|
||||
|
||||
const applyTemplate = (template) => {
|
||||
setStrategy(template.strategy);
|
||||
setConfig((prev) => ({ ...prev, ...template.config }));
|
||||
if (!name.trim()) setName(template.suggestedName);
|
||||
// Pre-fill Free Stack with 7 real free provider models
|
||||
if (template.id === "free-stack") {
|
||||
setModels(FREE_STACK_PRESET_MODELS);
|
||||
}
|
||||
};
|
||||
|
||||
// Format model display name with readable provider name
|
||||
@@ -1473,7 +1540,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? t("editCombo") : t("createCombo")}>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEdit ? t("editCombo") : t("createCombo")}
|
||||
size="full"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Name */}
|
||||
<div>
|
||||
|
||||
@@ -12,8 +12,8 @@ export default function LimitsPage() {
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ProviderLimits />
|
||||
</Suspense>
|
||||
<RateLimitStatus />
|
||||
<SessionsTab />
|
||||
<RateLimitStatus />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,29 +81,36 @@ const PROVIDER_MODELS: Record<
|
||||
{ id: "openai/dall-e-2", name: "DALL-E 2" },
|
||||
],
|
||||
},
|
||||
{ id: "xai", name: "xAI (Grok)", models: [{ id: "xai/grok-2-image", name: "Grok 2 Image" }] },
|
||||
{
|
||||
id: "xai",
|
||||
name: "xAI (Grok)",
|
||||
models: [{ id: "xai/grok-2-image-1212", name: "Grok 2 Image" }],
|
||||
},
|
||||
{
|
||||
id: "together",
|
||||
name: "Together AI",
|
||||
models: [
|
||||
{ id: "together/stable-diffusion-xl", name: "SDXL" },
|
||||
{ id: "together/FLUX.1-schnell-Free", name: "FLUX.1 Schnell" },
|
||||
{ id: "together/stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL" },
|
||||
{ id: "together/black-forest-labs/FLUX.1-schnell-Free", name: "FLUX.1 Schnell" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fireworks",
|
||||
name: "Fireworks AI",
|
||||
models: [
|
||||
{ id: "fireworks/stable-diffusion-xl-1024-v1-0", name: "SDXL 1024" },
|
||||
{ id: "fireworks/flux-1-dev-fp8", name: "FLUX.1 Dev" },
|
||||
{
|
||||
id: "fireworks/accounts/fireworks/models/stable-diffusion-xl-1024-v1-0",
|
||||
name: "SDXL 1024",
|
||||
},
|
||||
{ id: "fireworks/accounts/fireworks/models/flux-1-dev-fp8", name: "FLUX.1 Dev" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "nebius",
|
||||
name: "Nebius AI",
|
||||
models: [
|
||||
{ id: "nebius/flux-dev", name: "FLUX Dev" },
|
||||
{ id: "nebius/sdxl", name: "SDXL" },
|
||||
{ id: "nebius/black-forest-labs/flux-dev", name: "FLUX Dev" },
|
||||
{ id: "nebius/black-forest-labs/flux-schnell", name: "FLUX Schnell" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -117,7 +124,10 @@ const PROVIDER_MODELS: Record<
|
||||
{
|
||||
id: "nanobanana",
|
||||
name: "NanoBanana",
|
||||
models: [{ id: "nanobanana/flux-schnell", name: "FLUX Schnell" }],
|
||||
models: [
|
||||
{ id: "nanobanana/nanobanana-flash", name: "NanoBanana Flash" },
|
||||
{ id: "nanobanana/nanobanana-pro", name: "NanoBanana Pro" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sdwebui",
|
||||
@@ -328,6 +338,7 @@ function getVoiceList(providerId: string) {
|
||||
function parseApiError(raw: any, statusCode: number): { message: string; isCredentials: boolean } {
|
||||
const msg =
|
||||
raw?.error?.message ||
|
||||
raw?.err_msg ||
|
||||
raw?.error ||
|
||||
raw?.message ||
|
||||
raw?.detail ||
|
||||
@@ -340,6 +351,7 @@ function parseApiError(raw: any, statusCode: number): { message: string; isCrede
|
||||
msg.toLowerCase().includes("invalid api key") ||
|
||||
msg.toLowerCase().includes("unauthorized") ||
|
||||
msg.toLowerCase().includes("authentication") ||
|
||||
msg.toLowerCase().includes("api key") ||
|
||||
statusCode === 401 ||
|
||||
statusCode === 403);
|
||||
|
||||
@@ -417,7 +429,46 @@ export default function MediaPageClient() {
|
||||
// Transcription-specific
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
|
||||
const currentProviders = PROVIDER_MODELS[activeTab] ?? [];
|
||||
// Fix #390: Track which local providers (sdwebui, comfyui) are actually configured
|
||||
// so we can hide them when they haven't been set up in the providers page
|
||||
const LOCAL_PROVIDERS = ["sdwebui", "comfyui"];
|
||||
const [configuredLocalProviders, setConfiguredLocalProviders] = useState<Set<string>>(
|
||||
new Set(LOCAL_PROVIDERS) // Optimistic: show all until we know otherwise
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch configured provider connections to determine which local providers are set up
|
||||
fetch("/api/providers")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const connections: { provider?: string; testStatus?: string }[] = Array.isArray(data)
|
||||
? data
|
||||
: (data?.connections ?? data?.providers ?? []);
|
||||
const configured = new Set<string>();
|
||||
for (const conn of connections) {
|
||||
const pId = conn?.provider;
|
||||
if (pId && LOCAL_PROVIDERS.includes(pId)) {
|
||||
configured.add(pId);
|
||||
}
|
||||
}
|
||||
// Only update if at least one local provider was found, otherwise keep optimistic
|
||||
if (configured.size > 0) {
|
||||
setConfiguredLocalProviders(configured);
|
||||
} else {
|
||||
// No local providers configured — hide sdwebui/comfyui
|
||||
setConfiguredLocalProviders(new Set());
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// On error, keep showing all (fail-open)
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Filter out unconfigured local providers from the provider list
|
||||
const currentProviders = (PROVIDER_MODELS[activeTab] ?? []).filter(
|
||||
(p) => !LOCAL_PROVIDERS.includes(p.id) || configuredLocalProviders.has(p.id)
|
||||
);
|
||||
const currentModels = currentProviders.find((p) => p.id === selectedProvider)?.models ?? [];
|
||||
|
||||
const switchTab = (tab: Modality) => {
|
||||
@@ -519,12 +570,22 @@ export default function MediaPageClient() {
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await res.json();
|
||||
// Warn if text is empty (likely missing credentials that returned silently)
|
||||
// Check for noSpeechDetected flag (music, silence, etc.) — NOT a credential error
|
||||
if (data?.noSpeechDetected) {
|
||||
setError(
|
||||
`No speech detected in the audio file. If you uploaded music or a silent file, try an audio file with spoken words. Provider: "${selectedProvider}".`
|
||||
);
|
||||
setIsCredentialsError(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Warn if text is empty without the noSpeechDetected flag (unexpected)
|
||||
if (data && typeof data.text === "string" && data.text.trim() === "") {
|
||||
setError(
|
||||
`Transcription returned empty text. Make sure you have a valid API key for "${selectedProvider}" configured in /dashboard/providers.`
|
||||
`Transcription returned empty text. The audio may contain no recognizable speech, or the "${selectedProvider}" API key may be invalid. Check Dashboard → Logs → Proxy for details.`
|
||||
);
|
||||
setIsCredentialsError(true);
|
||||
// Only mark as credential error if we can confirm it from context
|
||||
setIsCredentialsError(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,17 @@ import {
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } {
|
||||
const record =
|
||||
policy && typeof policy === "object" && !Array.isArray(policy)
|
||||
? (policy as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
use5h: typeof record.use5h === "boolean" ? record.use5h : true,
|
||||
useWeekly: typeof record.useWeekly === "boolean" ? record.useWeekly : true,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProviderDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
@@ -49,6 +60,7 @@ export default function ProviderDetailPage() {
|
||||
const [headerImgError, setHeaderImgError] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const t = useTranslations("providers");
|
||||
const notify = useNotificationStore();
|
||||
const hasAutoOpened = useRef(false);
|
||||
const userDismissed = useRef(false);
|
||||
const [proxyTarget, setProxyTarget] = useState(null);
|
||||
@@ -311,6 +323,63 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleCodexLimit = async (connectionId, field, enabled) => {
|
||||
try {
|
||||
const target = connections.find((connection) => connection.id === connectionId);
|
||||
if (!target) return;
|
||||
|
||||
const providerSpecificData =
|
||||
target.providerSpecificData && typeof target.providerSpecificData === "object"
|
||||
? target.providerSpecificData
|
||||
: {};
|
||||
const existingPolicy =
|
||||
providerSpecificData.codexLimitPolicy &&
|
||||
typeof providerSpecificData.codexLimitPolicy === "object"
|
||||
? providerSpecificData.codexLimitPolicy
|
||||
: {};
|
||||
|
||||
const nextPolicy = {
|
||||
...normalizeCodexLimitPolicy(existingPolicy),
|
||||
[field]: enabled,
|
||||
};
|
||||
|
||||
const res = await fetch(`/api/providers/${connectionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerSpecificData: {
|
||||
...providerSpecificData,
|
||||
codexLimitPolicy: nextPolicy,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data.error || "Failed to update Codex limit policy");
|
||||
return;
|
||||
}
|
||||
|
||||
setConnections((prev) =>
|
||||
prev.map((connection) =>
|
||||
connection.id === connectionId
|
||||
? {
|
||||
...connection,
|
||||
providerSpecificData: {
|
||||
...(connection.providerSpecificData || {}),
|
||||
codexLimitPolicy: nextPolicy,
|
||||
},
|
||||
}
|
||||
: connection
|
||||
)
|
||||
);
|
||||
notify.success("Codex limit policy updated");
|
||||
} catch (error) {
|
||||
console.error("Error toggling Codex quota policy:", error);
|
||||
notify.error("Failed to update Codex limit policy");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetestConnection = async (connectionId) => {
|
||||
if (!connectionId || retestingId) return;
|
||||
setRetestingId(connectionId);
|
||||
@@ -331,7 +400,6 @@ export default function ProviderDetailPage() {
|
||||
|
||||
// T12: Manual token refresh
|
||||
const [refreshingId, setRefreshingId] = useState<string | null>(null);
|
||||
const notify = useNotificationStore();
|
||||
const handleRefreshToken = async (connectionId: string) => {
|
||||
if (refreshingId) return;
|
||||
setRefreshingId(connectionId);
|
||||
@@ -941,6 +1009,11 @@ export default function ProviderDetailPage() {
|
||||
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
|
||||
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
||||
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
|
||||
isCodex={providerId === "codex"}
|
||||
onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => {
|
||||
@@ -2175,12 +2248,15 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) {
|
||||
function ConnectionRow({
|
||||
connection,
|
||||
isOAuth,
|
||||
isCodex,
|
||||
isFirst,
|
||||
isLast,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onToggleActive,
|
||||
onToggleRateLimit,
|
||||
onToggleCodex5h,
|
||||
onToggleCodexWeekly,
|
||||
onRetest,
|
||||
isRetesting,
|
||||
onEdit,
|
||||
@@ -2242,6 +2318,16 @@ function ConnectionRow({
|
||||
|
||||
const statusPresentation = getStatusPresentation(connection, effectiveStatus, isCooldown, t);
|
||||
const rateLimitEnabled = !!connection.rateLimitProtection;
|
||||
const codexPolicy =
|
||||
connection.providerSpecificData &&
|
||||
typeof connection.providerSpecificData === "object" &&
|
||||
connection.providerSpecificData.codexLimitPolicy &&
|
||||
typeof connection.providerSpecificData.codexLimitPolicy === "object"
|
||||
? connection.providerSpecificData.codexLimitPolicy
|
||||
: {};
|
||||
const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy);
|
||||
const codex5hEnabled = normalizedCodexPolicy.use5h;
|
||||
const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2331,6 +2417,35 @@ function ConnectionRow({
|
||||
<span className="material-symbols-outlined text-[13px]">shield</span>
|
||||
{rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")}
|
||||
</button>
|
||||
{isCodex && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onToggleCodex5h?.(!codex5hEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
codex5hEnabled
|
||||
? "bg-blue-500/15 text-blue-500 hover:bg-blue-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle Codex 5h limit policy"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">timer</span>
|
||||
5h {codex5hEnabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggleCodexWeekly?.(!codexWeeklyEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
codexWeeklyEnabled
|
||||
? "bg-violet-500/15 text-violet-500 hover:bg-violet-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle Codex weekly limit policy"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">date_range</span>
|
||||
Weekly {codexWeeklyEnabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{hasProxy &&
|
||||
(() => {
|
||||
const colorClass =
|
||||
@@ -2411,6 +2526,7 @@ function ConnectionRow({
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary"
|
||||
title={t("edit")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
</button>
|
||||
@@ -2421,7 +2537,11 @@ function ConnectionRow({
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">vpn_lock</span>
|
||||
</button>
|
||||
<button onClick={onDelete} className="p-2 hover:bg-red-500/10 rounded text-red-500">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500"
|
||||
title={t("delete")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2446,14 +2566,18 @@ ConnectionRow.propTypes = {
|
||||
lastErrorSource: PropTypes.string,
|
||||
errorCode: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
globalPriority: PropTypes.number,
|
||||
providerSpecificData: PropTypes.object,
|
||||
}).isRequired,
|
||||
isOAuth: PropTypes.bool.isRequired,
|
||||
isCodex: PropTypes.bool,
|
||||
isFirst: PropTypes.bool.isRequired,
|
||||
isLast: PropTypes.bool.isRequired,
|
||||
onMoveUp: PropTypes.func.isRequired,
|
||||
onMoveDown: PropTypes.func.isRequired,
|
||||
onToggleActive: PropTypes.func.isRequired,
|
||||
onToggleRateLimit: PropTypes.func.isRequired,
|
||||
onToggleCodex5h: PropTypes.func,
|
||||
onToggleCodexWeekly: PropTypes.func,
|
||||
onRetest: PropTypes.func.isRequired,
|
||||
isRetesting: PropTypes.bool,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
@@ -2644,6 +2768,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
|
||||
const [newExtraKey, setNewExtraKey] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
@@ -2653,6 +2779,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
apiKey: "",
|
||||
healthCheckInterval: connection.healthCheckInterval ?? 60,
|
||||
});
|
||||
// Load existing extra keys from providerSpecificData
|
||||
const existing = connection.providerSpecificData?.extraApiKeys;
|
||||
setExtraApiKeys(Array.isArray(existing) ? existing : []);
|
||||
setNewExtraKey("");
|
||||
setTestResult(null);
|
||||
setValidationResult(null);
|
||||
}
|
||||
@@ -2739,6 +2869,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
updates.rateLimitedUntil = null;
|
||||
}
|
||||
}
|
||||
// Persist extra API keys in providerSpecificData
|
||||
if (!isOAuth) {
|
||||
updates.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
|
||||
};
|
||||
}
|
||||
await onSave(updates);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -2823,6 +2960,68 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* T07: Extra API Keys for round-robin rotation */}
|
||||
{!isOAuth && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
Extra API Keys
|
||||
<span className="ml-2 text-[11px] font-normal text-text-muted">
|
||||
(round-robin rotation — optional)
|
||||
</span>
|
||||
</label>
|
||||
{extraApiKeys.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{extraApiKeys.map((key, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<span className="flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border text-text-muted truncate">
|
||||
{`Key #${idx + 2}: ${key.slice(0, 6)}...${key.slice(-4)}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setExtraApiKeys(extraApiKeys.filter((_, i) => i !== idx))}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-red-400 hover:text-red-500"
|
||||
title="Remove this key"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={newExtraKey}
|
||||
onChange={(e) => setNewExtraKey(e.target.value)}
|
||||
placeholder="Add another API key..."
|
||||
className="flex-1 text-sm bg-sidebar/50 border border-border rounded px-3 py-2 text-text-main placeholder:text-text-muted focus:ring-1 focus:ring-primary outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newExtraKey.trim()) {
|
||||
setExtraApiKeys([...extraApiKeys, newExtraKey.trim()]);
|
||||
setNewExtraKey("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newExtraKey.trim()) {
|
||||
setExtraApiKeys([...extraApiKeys, newExtraKey.trim()]);
|
||||
setNewExtraKey("");
|
||||
}
|
||||
}}
|
||||
disabled={!newExtraKey.trim()}
|
||||
className="px-3 py-2 rounded bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-40 text-sm font-medium"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{extraApiKeys.length > 0 && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{extraApiKeys.length + 1} keys total — rotating round-robin on each request.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Connection */}
|
||||
{!isCompatible && (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -2876,11 +3075,14 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
|
||||
prefix: "",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
chatPath: "",
|
||||
modelsPath: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
@@ -2891,7 +3093,10 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
|
||||
baseUrl:
|
||||
node.baseUrl ||
|
||||
(isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"),
|
||||
chatPath: node.chatPath || "",
|
||||
modelsPath: node.modelsPath || "",
|
||||
});
|
||||
setShowAdvanced(!!(node.chatPath || node.modelsPath));
|
||||
}
|
||||
}, [node, isAnthropic]);
|
||||
|
||||
@@ -2908,6 +3113,8 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
|
||||
name: formData.name,
|
||||
prefix: formData.prefix,
|
||||
baseUrl: formData.baseUrl,
|
||||
chatPath: formData.chatPath || "",
|
||||
modelsPath: formData.modelsPath || "",
|
||||
};
|
||||
if (!isAnthropic) {
|
||||
payload.apiType = formData.apiType;
|
||||
@@ -2928,6 +3135,7 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: isAnthropic ? "anthropic-compatible" : "openai-compatible",
|
||||
modelsPath: formData.modelsPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -2983,6 +3191,39 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
|
||||
type: isAnthropic ? t("anthropic") : t("openai"),
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="advanced-settings"
|
||||
>
|
||||
<span
|
||||
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{t("advancedSettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
|
||||
<Input
|
||||
label={t("chatPathLabel")}
|
||||
value={formData.chatPath}
|
||||
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
|
||||
placeholder={isAnthropic ? "/messages" : t("chatPathPlaceholder")}
|
||||
hint={t("chatPathHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("modelsPathLabel")}
|
||||
value={formData.modelsPath}
|
||||
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
|
||||
placeholder={t("modelsPathPlaceholder")}
|
||||
hint={t("modelsPathHint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label={t("apiKeyForCheck")}
|
||||
@@ -3033,6 +3274,8 @@ EditCompatibleNodeModal.propTypes = {
|
||||
prefix: PropTypes.string,
|
||||
apiType: PropTypes.string,
|
||||
baseUrl: PropTypes.string,
|
||||
chatPath: PropTypes.string,
|
||||
modelsPath: PropTypes.string,
|
||||
}),
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
|
||||
@@ -772,11 +772,14 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
prefix: "",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
chatPath: "",
|
||||
modelsPath: "",
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const apiTypeOptions = [
|
||||
{ value: "chat", label: t("chatCompletions") },
|
||||
@@ -804,6 +807,8 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
apiType: formData.apiType,
|
||||
baseUrl: formData.baseUrl,
|
||||
type: "openai-compatible",
|
||||
chatPath: formData.chatPath || "",
|
||||
modelsPath: formData.modelsPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -814,9 +819,12 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
prefix: "",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
chatPath: "",
|
||||
modelsPath: "",
|
||||
});
|
||||
setCheckKey("");
|
||||
setValidationResult(null);
|
||||
setShowAdvanced(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error creating OpenAI Compatible node:", error);
|
||||
@@ -835,6 +843,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: "openai-compatible",
|
||||
modelsPath: formData.modelsPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -876,6 +885,39 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
placeholder={t("openaiBaseUrlPlaceholder")}
|
||||
hint={t("compatibleBaseUrlHint", { type: t("openai") })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="advanced-settings"
|
||||
>
|
||||
<span
|
||||
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{t("advancedSettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
|
||||
<Input
|
||||
label={t("chatPathLabel")}
|
||||
value={formData.chatPath}
|
||||
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
|
||||
placeholder={t("chatPathPlaceholder")}
|
||||
hint={t("chatPathHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("modelsPathLabel")}
|
||||
value={formData.modelsPath}
|
||||
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
|
||||
placeholder={t("modelsPathPlaceholder")}
|
||||
hint={t("modelsPathHint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label={t("apiKeyForCheck")}
|
||||
@@ -933,11 +975,14 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
name: "",
|
||||
prefix: "",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
chatPath: "",
|
||||
modelsPath: "",
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset validation when modal opens
|
||||
@@ -959,6 +1004,8 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
prefix: formData.prefix,
|
||||
baseUrl: formData.baseUrl,
|
||||
type: "anthropic-compatible",
|
||||
chatPath: formData.chatPath || "",
|
||||
modelsPath: formData.modelsPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -968,9 +1015,12 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
name: "",
|
||||
prefix: "",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
chatPath: "",
|
||||
modelsPath: "",
|
||||
});
|
||||
setCheckKey("");
|
||||
setValidationResult(null);
|
||||
setShowAdvanced(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error creating Anthropic Compatible node:", error);
|
||||
@@ -989,6 +1039,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: "anthropic-compatible",
|
||||
modelsPath: formData.modelsPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -1024,6 +1075,39 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
placeholder={t("anthropicBaseUrlPlaceholder")}
|
||||
hint={t("compatibleBaseUrlHint", { type: t("anthropic") })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="advanced-settings"
|
||||
>
|
||||
<span
|
||||
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{t("advancedSettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
|
||||
<Input
|
||||
label={t("chatPathLabel")}
|
||||
value={formData.chatPath}
|
||||
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
|
||||
placeholder="/messages"
|
||||
hint={t("chatPathHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("modelsPathLabel")}
|
||||
value={formData.modelsPath}
|
||||
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
|
||||
placeholder={t("modelsPathPlaceholder")}
|
||||
hint={t("modelsPathHint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label={t("apiKeyForCheck")}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export default function CodexServiceTierTab() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<"" | "saved" | "error">("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/codex-service-tier")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setEnabled(Boolean(data.enabled));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const save = async (nextEnabled: boolean) => {
|
||||
setEnabled(nextEnabled);
|
||||
setSaving(true);
|
||||
setStatus("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/codex-service-tier", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: nextEnabled }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setStatus("saved");
|
||||
setTimeout(() => setStatus(""), 2000);
|
||||
} else {
|
||||
setStatus("error");
|
||||
setEnabled(!nextEnabled);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setEnabled(!nextEnabled);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="p-2 rounded-lg bg-sky-500/10 text-sky-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
bolt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Codex Fast Service Tier</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Inject `service_tier=fast` into Codex requests when the client leaves it unset.
|
||||
</p>
|
||||
</div>
|
||||
{status === "saved" && (
|
||||
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">check_circle</span>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<span className="text-xs font-medium text-rose-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
Failed to save
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Force fast tier for Codex</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Off by default. Applies only to Codex requests and does not override an explicit tier.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => save(!enabled)}
|
||||
disabled={loading || saving}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? "bg-sky-500" : "bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card, Modal } from "@/shared/components";
|
||||
|
||||
type ProxyItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
region?: string | null;
|
||||
notes?: string | null;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type UsageInfo = {
|
||||
count: number;
|
||||
assignments: Array<{ scope: string; scopeId: string | null }>;
|
||||
};
|
||||
|
||||
type HealthInfo = {
|
||||
proxyId: string;
|
||||
totalRequests: number;
|
||||
successRate: number | null;
|
||||
avgLatencyMs: number | null;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
|
||||
const EMPTY_FORM = {
|
||||
id: "",
|
||||
name: "",
|
||||
type: "http",
|
||||
host: "",
|
||||
port: "8080",
|
||||
username: "",
|
||||
password: "",
|
||||
region: "",
|
||||
notes: "",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
export default function ProxyRegistryManager() {
|
||||
const [items, setItems] = useState<ProxyItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
|
||||
const [usageById, setUsageById] = useState<Record<string, UsageInfo>>({});
|
||||
const [healthById, setHealthById] = useState<Record<string, HealthInfo>>({});
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkSaving, setBulkSaving] = useState(false);
|
||||
const [bulkScope, setBulkScope] = useState("provider");
|
||||
const [bulkScopeIds, setBulkScopeIds] = useState("");
|
||||
const [bulkProxyId, setBulkProxyId] = useState("");
|
||||
|
||||
const editingId = useMemo(() => form.id || "", [form.id]);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxies/health?hours=24");
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return;
|
||||
const entries = Array.isArray(data?.items) ? data.items : [];
|
||||
const mapped = Object.fromEntries(
|
||||
entries.map((entry: HealthInfo) => [entry.proxyId, entry])
|
||||
) as Record<string, HealthInfo>;
|
||||
setHealthById(mapped);
|
||||
} catch {
|
||||
// ignore health loading errors in UI
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxies");
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data?.error?.message || "Failed to load proxy registry");
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
setItems(Array.isArray(data?.items) ? data.items : []);
|
||||
void loadHealth();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load proxy registry");
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && !bulkProxyId) {
|
||||
setBulkProxyId(items[0].id);
|
||||
}
|
||||
}, [items, bulkProxyId]);
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: ProxyItem) => {
|
||||
setForm({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
type: item.type || "http",
|
||||
host: item.host || "",
|
||||
port: String(item.port || 8080),
|
||||
username: "",
|
||||
password: "",
|
||||
region: item.region || "",
|
||||
notes: item.notes || "",
|
||||
status: item.status || "active",
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const loadUsage = async (proxyId: string) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/settings/proxies?id=${encodeURIComponent(proxyId)}&whereUsed=1`
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return;
|
||||
setUsageById((prev) => ({
|
||||
...prev,
|
||||
[proxyId]: {
|
||||
count: Number(data?.count || 0),
|
||||
assignments: Array.isArray(data?.assignments) ? data.assignments : [],
|
||||
},
|
||||
}));
|
||||
} catch {
|
||||
// ignore usage loading errors in UI
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || !form.host.trim()) {
|
||||
setError("Name and host are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const normalizedUsername = form.username.trim();
|
||||
const normalizedPassword = form.password.trim();
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
...(editingId ? { id: editingId } : {}),
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
host: form.host.trim(),
|
||||
port: Number(form.port || 8080),
|
||||
region: form.region.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
status: form.status,
|
||||
};
|
||||
if (!editingId || normalizedUsername.length > 0) {
|
||||
payload.username = normalizedUsername;
|
||||
}
|
||||
if (!editingId || normalizedPassword.length > 0) {
|
||||
payload.password = normalizedPassword;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxies", {
|
||||
method: editingId ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data?.error?.message || "Failed to save proxy");
|
||||
return;
|
||||
}
|
||||
|
||||
setModalOpen(false);
|
||||
setForm(EMPTY_FORM);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save proxy");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/settings/proxies?id=${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
const inUse = res.status === 409;
|
||||
if (inUse) {
|
||||
const ok = window.confirm(
|
||||
"This proxy is still assigned. Force delete and remove all assignments?"
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
const forceRes = await fetch(`/api/settings/proxies?id=${encodeURIComponent(id)}&force=1`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!forceRes.ok) {
|
||||
const forcePayload = await forceRes.json().catch(() => ({}));
|
||||
setError(forcePayload?.error?.message || "Failed to force delete proxy");
|
||||
return;
|
||||
}
|
||||
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
|
||||
setError(payload?.error?.message || "Failed to delete proxy");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to delete proxy");
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrate = async () => {
|
||||
setMigrating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxies/migrate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ force: false }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data?.error?.message || "Failed to migrate legacy proxy config");
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to migrate legacy proxy config");
|
||||
} finally {
|
||||
setMigrating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAssign = async () => {
|
||||
setBulkSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const scopeIds =
|
||||
bulkScope === "global"
|
||||
? []
|
||||
: bulkScopeIds
|
||||
.split(/[\n,]/g)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const res = await fetch("/api/settings/proxies/bulk-assign", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
scope: bulkScope,
|
||||
scopeIds,
|
||||
proxyId: bulkProxyId || null,
|
||||
}),
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(payload?.error?.message || "Failed to run bulk assignment");
|
||||
return;
|
||||
}
|
||||
|
||||
setBulkOpen(false);
|
||||
setBulkScopeIds("");
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to run bulk assignment");
|
||||
} finally {
|
||||
setBulkSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Proxy Registry</h3>
|
||||
<p className="text-sm text-text-muted">Store reusable proxies and track assignments.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="upgrade"
|
||||
onClick={handleMigrate}
|
||||
loading={migrating}
|
||||
data-testid="proxy-registry-import-legacy"
|
||||
>
|
||||
Import Legacy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="account_tree"
|
||||
onClick={() => setBulkOpen(true)}
|
||||
data-testid="proxy-registry-open-bulk"
|
||||
>
|
||||
Bulk Assign
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
onClick={openCreate}
|
||||
data-testid="proxy-registry-open-create"
|
||||
>
|
||||
Add Proxy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 px-3 py-2 rounded border border-red-500/30 bg-red-500/10 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-sm text-text-muted">Loading proxies...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-sm text-text-muted">No saved proxies yet.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-text-muted border-b border-border">
|
||||
<th className="py-2 pr-3">Name</th>
|
||||
<th className="py-2 pr-3">Endpoint</th>
|
||||
<th className="py-2 pr-3">Status</th>
|
||||
<th className="py-2 pr-3">Health (24h)</th>
|
||||
<th className="py-2 pr-3">Usage</th>
|
||||
<th className="py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const usage = usageById[item.id];
|
||||
const health = healthById[item.id];
|
||||
return (
|
||||
<tr key={item.id} className="border-b border-border/60">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-medium text-text-main">{item.name}</div>
|
||||
{item.region && (
|
||||
<div className="text-xs text-text-muted">{item.region}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs text-text-muted">
|
||||
{item.type}://{item.host}:{item.port}
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className="text-xs px-2 py-1 rounded border border-border bg-bg-subtle">
|
||||
{item.status || "active"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs text-text-muted">
|
||||
{health ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{health.successRate ?? 0}% success</span>
|
||||
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs text-text-muted">
|
||||
{usage ? `${usage.count} assignment(s)` : "-"}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="visibility"
|
||||
onClick={() => void loadUsage(item.id)}
|
||||
>
|
||||
Usage
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="edit"
|
||||
onClick={() => openEdit(item)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="delete"
|
||||
onClick={() => void handleDelete(item.id)}
|
||||
className="!text-red-400"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
if (!saving) setModalOpen(false);
|
||||
}}
|
||||
title={editingId ? "Edit Proxy" : "Create Proxy"}
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Name</label>
|
||||
<input
|
||||
data-testid="proxy-registry-name-input"
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Type</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.type}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
|
||||
>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Host</label>
|
||||
<input
|
||||
data-testid="proxy-registry-host-input"
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.host}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, host: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Port</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Username</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.username}
|
||||
placeholder={editingId ? "Leave blank to keep current username" : ""}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.password}
|
||||
placeholder={editingId ? "Leave blank to keep current password" : ""}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Region</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.region}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, region: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Status</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.status}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value }))}
|
||||
>
|
||||
<option value="active">active</option>
|
||||
<option value="inactive">inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Notes</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<Button size="sm" variant="secondary" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" icon="save" onClick={handleSave} loading={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={bulkOpen}
|
||||
onClose={() => {
|
||||
if (!bulkSaving) setBulkOpen(false);
|
||||
}}
|
||||
title="Bulk Proxy Assignment"
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Scope</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={bulkScope}
|
||||
onChange={(e) => setBulkScope(e.target.value)}
|
||||
>
|
||||
<option value="global">global</option>
|
||||
<option value="provider">provider</option>
|
||||
<option value="account">account</option>
|
||||
<option value="combo">combo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Proxy</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={bulkProxyId}
|
||||
onChange={(e) => setBulkProxyId(e.target.value)}
|
||||
>
|
||||
<option value="">(clear assignment)</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.type}://{item.host}:{item.port})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bulkScope !== "global" && (
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">
|
||||
Scope IDs (comma or newline)
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="proxy-registry-bulk-scopeids-input"
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
rows={5}
|
||||
value={bulkScopeIds}
|
||||
onChange={(e) => setBulkScopeIds(e.target.value)}
|
||||
placeholder="provider-openai,provider-anthropic"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<Button size="sm" variant="secondary" onClick={() => setBulkOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="done_all"
|
||||
onClick={handleBulkAssign}
|
||||
loading={bulkSaving}
|
||||
data-testid="proxy-registry-bulk-apply"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ProxyConfigModal } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ProxyRegistryManager from "./ProxyRegistryManager";
|
||||
|
||||
export default function ProxyTab() {
|
||||
const [proxyModalOpen, setProxyModalOpen] = useState(false);
|
||||
@@ -41,39 +42,43 @@ export default function ProxyTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
|
||||
vpn_lock
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">{t("globalProxy")}</h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
|
||||
vpn_lock
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">{t("globalProxy")}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">{t("globalProxyDesc")}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{globalProxy ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded text-xs font-bold uppercase bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
{globalProxy.type}://{globalProxy.host}:{globalProxy.port}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-text-muted">{t("noGlobalProxy")}</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={globalProxy ? "secondary" : "primary"}
|
||||
icon="settings"
|
||||
onClick={() => {
|
||||
loadGlobalProxy();
|
||||
setProxyModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{globalProxy ? tc("edit") : t("configure")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">{t("globalProxyDesc")}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{globalProxy ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded text-xs font-bold uppercase bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
{globalProxy.type}://{globalProxy.host}:{globalProxy.port}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-text-muted">{t("noGlobalProxy")}</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={globalProxy ? "secondary" : "primary"}
|
||||
icon="settings"
|
||||
onClick={() => {
|
||||
loadGlobalProxy();
|
||||
setProxyModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{globalProxy ? tc("edit") : t("configure")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<ProxyRegistryManager />
|
||||
</div>
|
||||
|
||||
<ProxyConfigModal
|
||||
isOpen={proxyModalOpen}
|
||||
|
||||
@@ -12,6 +12,7 @@ import ComboDefaultsTab from "./components/ComboDefaultsTab";
|
||||
import ProxyTab from "./components/ProxyTab";
|
||||
import AppearanceTab from "./components/AppearanceTab";
|
||||
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
|
||||
import CodexServiceTierTab from "./components/CodexServiceTierTab";
|
||||
import SystemPromptTab from "./components/SystemPromptTab";
|
||||
import ModelAliasesTab from "./components/ModelAliasesTab";
|
||||
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
|
||||
@@ -85,6 +86,7 @@ export default function SettingsPage() {
|
||||
{activeTab === "ai" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ThinkingBudgetTab />
|
||||
<CodexServiceTierTab />
|
||||
<SystemPromptTab />
|
||||
<CacheStatsCard />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,10 @@ import Badge from "@/shared/components/Badge";
|
||||
import { CardSkeleton } from "@/shared/components/Loading";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
const LS_GROUP_BY = "omniroute:limits:groupBy";
|
||||
const LS_AUTO_REFRESH = "omniroute:limits:autoRefresh";
|
||||
const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 120000;
|
||||
const MIN_FETCH_INTERVAL_MS = 30000; // Debounce per-connection fetches
|
||||
|
||||
@@ -20,6 +24,7 @@ const PROVIDER_CONFIG = {
|
||||
kiro: { label: "Kiro AI", color: "#FF6B35" },
|
||||
codex: { label: "OpenAI Codex", color: "#10A37F" },
|
||||
claude: { label: "Claude Code", color: "#D97757" },
|
||||
glm: { label: "GLM (Z.AI)", color: "#4A90D9" },
|
||||
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
|
||||
};
|
||||
|
||||
@@ -89,12 +94,30 @@ export default function ProviderLimits() {
|
||||
const [quotaData, setQuotaData] = useState({});
|
||||
const [loading, setLoading] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem(LS_AUTO_REFRESH) === "true";
|
||||
});
|
||||
const [lastUpdated, setLastUpdated] = useState(null);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [countdown, setCountdown] = useState(120);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [tierFilter, setTierFilter] = useState("all");
|
||||
const [groupBy, setGroupBy] = useState<"none" | "environment">(() => {
|
||||
if (typeof window === "undefined") return "none";
|
||||
const saved = localStorage.getItem(LS_GROUP_BY);
|
||||
if (saved === "environment" || saved === "none") return saved;
|
||||
return "none";
|
||||
});
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const saved = localStorage.getItem(LS_EXPANDED_GROUPS);
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
const countdownRef = useRef(null);
|
||||
@@ -175,10 +198,12 @@ export default function ProviderLimits() {
|
||||
setCountdown(120);
|
||||
try {
|
||||
const conns = await fetchConnections();
|
||||
const oauthConnections = conns.filter(
|
||||
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||
const usageConnections = conns.filter(
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
(conn.authType === "oauth" || conn.authType === "apikey")
|
||||
);
|
||||
await Promise.all(oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)));
|
||||
await Promise.all(usageConnections.map((conn) => fetchQuota(conn.id, conn.provider)));
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error("Error refreshing all:", error);
|
||||
@@ -231,13 +256,23 @@ export default function ProviderLimits() {
|
||||
const filteredConnections = useMemo(
|
||||
() =>
|
||||
connections.filter(
|
||||
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
(conn.authType === "oauth" || conn.authType === "apikey")
|
||||
),
|
||||
[connections]
|
||||
);
|
||||
|
||||
const sortedConnections = useMemo(() => {
|
||||
const priority = { antigravity: 1, github: 2, codex: 3, claude: 4, kiro: 5, "kimi-coding": 6 };
|
||||
const priority = {
|
||||
antigravity: 1,
|
||||
github: 2,
|
||||
codex: 3,
|
||||
claude: 4,
|
||||
kiro: 5,
|
||||
glm: 6,
|
||||
"kimi-coding": 7,
|
||||
};
|
||||
return [...filteredConnections].sort(
|
||||
(a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9)
|
||||
);
|
||||
@@ -276,6 +311,50 @@ export default function ProviderLimits() {
|
||||
);
|
||||
}, [sortedConnections, tierByConnection, tierFilter]);
|
||||
|
||||
const groupedConnections = useMemo(() => {
|
||||
if (groupBy !== "environment") return null;
|
||||
const groups = new Map();
|
||||
for (const conn of visibleConnections) {
|
||||
const key = conn.group || t("ungrouped");
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(conn);
|
||||
}
|
||||
return groups;
|
||||
}, [groupBy, visibleConnections, t]);
|
||||
|
||||
const handleSetGroupBy = (value: "none" | "environment") => {
|
||||
setGroupBy(value);
|
||||
localStorage.setItem(LS_GROUP_BY, value);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(groupName) ? next.delete(groupName) : next.add(groupName);
|
||||
localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Default inteligente: se não há preferência salva e há connections com grupo, abre em Por Ambiente
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const hasSaved = localStorage.getItem(LS_GROUP_BY) !== null;
|
||||
if (!hasSaved && connections.some((c) => c.group)) {
|
||||
setGroupBy("environment");
|
||||
}
|
||||
}, [connections]);
|
||||
|
||||
// Quando entra em modo environment pela primeira vez sem estado salvo, abre todos os grupos
|
||||
useEffect(() => {
|
||||
if (groupBy !== "environment" || !groupedConnections) return;
|
||||
if (expandedGroups.size === 0) {
|
||||
const allGroups = new Set([...groupedConnections.keys()]);
|
||||
setExpandedGroups(allGroups);
|
||||
localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...allGroups]));
|
||||
}
|
||||
}, [groupBy, groupedConnections]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -313,8 +392,37 @@ export default function ProviderLimits() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Group by toggle */}
|
||||
<div className="flex rounded-lg border border-white/[0.08] overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleSetGroupBy("none")}
|
||||
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none"
|
||||
style={{
|
||||
background: groupBy === "none" ? "rgba(255,255,255,0.1)" : "transparent",
|
||||
color: groupBy === "none" ? "var(--text-main)" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{t("viewFlat")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSetGroupBy("environment")}
|
||||
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none border-l border-white/[0.08]"
|
||||
style={{
|
||||
background: groupBy === "environment" ? "rgba(255,255,255,0.1)" : "transparent",
|
||||
color: groupBy === "environment" ? "var(--text-main)" : "var(--text-muted)",
|
||||
borderLeft: "1px solid rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
{t("viewByEnvironment")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
onClick={() => {
|
||||
const next = !autoRefresh;
|
||||
setAutoRefresh(next);
|
||||
localStorage.setItem(LS_AUTO_REFRESH, String(next));
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-transparent cursor-pointer text-text-main text-[13px]"
|
||||
>
|
||||
<span
|
||||
@@ -382,157 +490,196 @@ export default function ProviderLimits() {
|
||||
<div className="text-center">{t("actions")}</div>
|
||||
</div>
|
||||
|
||||
{visibleConnections.map((conn, idx) => {
|
||||
const quota = quotaData[conn.id];
|
||||
const isLoading = loading[conn.id];
|
||||
const error = errors[conn.id];
|
||||
const config = PROVIDER_CONFIG[conn.provider] || { label: conn.provider, color: "#666" };
|
||||
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
|
||||
{(() => {
|
||||
const renderRow = (conn, isLast) => {
|
||||
const quota = quotaData[conn.id];
|
||||
const isLoading = loading[conn.id];
|
||||
const error = errors[conn.id];
|
||||
const config = PROVIDER_CONFIG[conn.provider] || {
|
||||
label: conn.provider,
|
||||
color: "#666",
|
||||
};
|
||||
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px 1fr 100px 48px",
|
||||
borderBottom:
|
||||
idx < visibleConnections.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Account Info */}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={`/providers/${conn.provider}.png`}
|
||||
alt={conn.provider}
|
||||
width={32}
|
||||
height={32}
|
||||
className="object-contain"
|
||||
sizes="32px"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold text-text-main truncate">
|
||||
{conn.name || config.label}
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px 1fr 100px 48px",
|
||||
borderBottom: !isLast ? "1px solid rgba(255,255,255,0.04)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Account Info */}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={`/providers/${conn.provider}.png`}
|
||||
alt={conn.provider}
|
||||
width={32}
|
||||
height={32}
|
||||
className="object-contain"
|
||||
sizes="32px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
title={
|
||||
quota?.plan
|
||||
? t("rawPlanWithValue", { plan: quota.plan })
|
||||
: t("noPlanFromProvider")
|
||||
}
|
||||
>
|
||||
<Badge variant={tierMeta.variant} size="sm" dot>
|
||||
{tierMeta.label}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted">{config.label}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold text-text-main truncate">
|
||||
{conn.name || config.label}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
title={
|
||||
quota?.plan
|
||||
? t("rawPlanWithValue", { plan: quota.plan })
|
||||
: t("noPlanFromProvider")
|
||||
}
|
||||
>
|
||||
<Badge variant={tierMeta.variant} size="sm" dot>
|
||||
{tierMeta.label}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted">{config.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quota Bars */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pr-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-1.5 text-text-muted text-xs">
|
||||
<span className="material-symbols-outlined animate-spin text-[14px]">
|
||||
progress_activity
|
||||
</span>
|
||||
{t("loadingQuotas")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]">
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
|
||||
<div className="text-xs text-text-muted italic">{quota.message}</div>
|
||||
) : quota?.quotas?.length > 0 ? (
|
||||
quota.quotas.map((q, i) => {
|
||||
const remaining =
|
||||
q.remainingPercentage !== undefined
|
||||
? Math.round(q.remainingPercentage)
|
||||
: calculatePercentage(q.used, q.total);
|
||||
const colors = getBarColor(remaining);
|
||||
const cd = formatCountdown(q.resetAt);
|
||||
const shortName = getShortModelName(q.name);
|
||||
{/* Quota Bars */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pr-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-1.5 text-text-muted text-xs">
|
||||
<span className="material-symbols-outlined animate-spin text-[14px]">
|
||||
progress_activity
|
||||
</span>
|
||||
{t("loadingQuotas")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]">
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
|
||||
<div className="text-xs text-text-muted italic">{quota.message}</div>
|
||||
) : quota?.quotas?.length > 0 ? (
|
||||
quota.quotas.map((q, i) => {
|
||||
const remaining =
|
||||
q.remainingPercentage !== undefined
|
||||
? Math.round(q.remainingPercentage)
|
||||
: calculatePercentage(q.used, q.total);
|
||||
const colors = getBarColor(remaining);
|
||||
const cd = formatCountdown(q.resetAt);
|
||||
const shortName = getShortModelName(q.name);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
|
||||
{/* Model label */}
|
||||
<span
|
||||
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
|
||||
style={{ background: colors.bg, color: colors.text }}
|
||||
>
|
||||
{shortName}
|
||||
</span>
|
||||
|
||||
{/* Countdown */}
|
||||
{cd && (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
⏱ {cd}
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
|
||||
{/* Model label */}
|
||||
<span
|
||||
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
|
||||
style={{ background: colors.bg, color: colors.text }}
|
||||
>
|
||||
{shortName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-sm transition-[width] duration-300 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(remaining, 100)}%`,
|
||||
background: colors.bar,
|
||||
}}
|
||||
/>
|
||||
{/* Countdown */}
|
||||
{cd && (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
⏱ {cd}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-sm transition-[width] duration-300 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(remaining, 100)}%`,
|
||||
background: colors.bar,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Percentage */}
|
||||
<span
|
||||
className="text-[11px] font-semibold min-w-[32px] text-right"
|
||||
style={{ color: colors.text }}
|
||||
>
|
||||
{remaining}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Percentage */}
|
||||
<span
|
||||
className="text-[11px] font-semibold min-w-[32px] text-right"
|
||||
style={{ color: colors.text }}
|
||||
>
|
||||
{remaining}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Last Used */}
|
||||
<div className="text-center text-[11px] text-text-muted">
|
||||
{lastUpdated ? (
|
||||
<span>
|
||||
{lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Used */}
|
||||
<div className="text-center text-[11px] text-text-muted">
|
||||
{lastUpdated ? (
|
||||
<span>
|
||||
{lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading}
|
||||
title={t("refreshQuota")}
|
||||
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading}
|
||||
title={t("refreshQuota")}
|
||||
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
|
||||
>
|
||||
refresh
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (groupedConnections) {
|
||||
const entries = [...groupedConnections.entries()];
|
||||
return entries.map(([groupName, conns]) => (
|
||||
<div
|
||||
key={groupName}
|
||||
className="border border-white/[0.08] rounded-lg overflow-hidden mb-2"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleGroup(groupName)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 bg-white/[0.03] hover:bg-white/[0.05] transition-colors text-left border-none cursor-pointer"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted">
|
||||
{expandedGroups.has(groupName) ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted">
|
||||
folder
|
||||
</span>
|
||||
<span className="text-[12px] font-semibold text-text-main uppercase tracking-wider flex-1">
|
||||
{groupName}
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted bg-white/[0.06] px-2 py-0.5 rounded-full">
|
||||
{conns.length}
|
||||
</span>
|
||||
</button>
|
||||
{expandedGroups.has(groupName) && (
|
||||
<div>{conns.map((conn, idx) => renderRow(conn, idx === conns.length - 1))}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return visibleConnections.map((conn, idx) =>
|
||||
renderRow(conn, idx === visibleConnections.length - 1)
|
||||
);
|
||||
})}
|
||||
})()}
|
||||
|
||||
{visibleConnections.length === 0 && (
|
||||
<div className="py-6 px-4 text-center text-text-muted text-[13px]">
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
"use server";
|
||||
// Node.js-only route: uses child_process, fs, path via mitm/manager
|
||||
// Dynamic imports prevent Turbopack from statically resolving native modules
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getMitmStatus,
|
||||
startMitm,
|
||||
stopMitm,
|
||||
getCachedPassword,
|
||||
setCachedPassword,
|
||||
} from "@/mitm/manager";
|
||||
import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
// GET - Check MITM status
|
||||
export async function GET() {
|
||||
try {
|
||||
const { getMitmStatus, getCachedPassword } = await import("@/mitm/manager");
|
||||
const status = await getMitmStatus();
|
||||
return NextResponse.json({
|
||||
running: status.running,
|
||||
@@ -51,6 +47,7 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { apiKey, sudoPassword } = validation.data;
|
||||
const { startMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager");
|
||||
const isWin = process.platform === "win32";
|
||||
const pwd = sudoPassword || getCachedPassword() || "";
|
||||
|
||||
@@ -101,6 +98,7 @@ export async function DELETE(request) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { sudoPassword } = validation.data;
|
||||
const { stopMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager");
|
||||
const isWin = process.platform === "win32";
|
||||
const pwd = sudoPassword || getCachedPassword() || "";
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function POST(request) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
// Send a minimal chat request to the internal SSE handler
|
||||
// Use OpenAI-compatible format — universally accepted by all providers via the translator
|
||||
const testBody = {
|
||||
model: modelStr,
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
@@ -58,11 +59,15 @@ export async function POST(request) {
|
||||
|
||||
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000); // 15s timeout
|
||||
const timeout = setTimeout(() => controller.abort(), 20000); // 20s timeout (was 15s, slow providers need more)
|
||||
|
||||
const res = await fetch(internalUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Fix #350: bypass REQUIRE_API_KEY for internal admin combo tests
|
||||
"X-Internal-Test": "combo-health-check",
|
||||
},
|
||||
body: JSON.stringify(testBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import { getDbInstance, SQLITE_FILE } from "@/lib/db/core";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDbInstance, SQLITE_FILE } from "@/lib/db/core";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
/**
|
||||
* GET /api/db-backups/exportAll
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import { getDbInstance, resetDbInstance, SQLITE_FILE } from "@/lib/db/core";
|
||||
import { backupDbFile } from "@/lib/db/backup";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
@@ -26,10 +26,10 @@ const LEVEL_ORDER: Record<string, number> = {
|
||||
// Map pino numeric levels to string levels
|
||||
const NUMERIC_LEVEL_MAP: Record<number, string> = {
|
||||
10: "trace",
|
||||
20: "info",
|
||||
30: "warn",
|
||||
40: "error",
|
||||
50: "fatal",
|
||||
20: "debug",
|
||||
30: "info",
|
||||
40: "warn",
|
||||
50: "error",
|
||||
60: "fatal",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* GET /api/logs/detail — List detailed request logs
|
||||
* GET /api/logs/detail/:id — Get specific detailed log
|
||||
* POST /api/logs/detail/toggle — Enable/disable detailed logging
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import {
|
||||
getRequestDetailLogs,
|
||||
getRequestDetailLogCount,
|
||||
isDetailedLoggingEnabled,
|
||||
} from "@/lib/db/detailedLogs";
|
||||
import { updateSettings } from "@/lib/db/settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isAuthenticated(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
|
||||
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||
|
||||
const logs = getRequestDetailLogs(limit, offset);
|
||||
const total = getRequestDetailLogCount();
|
||||
const enabled = await isDetailedLoggingEnabled();
|
||||
|
||||
return NextResponse.json({ enabled, total, logs });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isAuthenticated(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const enabled = body.enabled === true || body.enabled === "1";
|
||||
|
||||
await updateSettings({ detailed_logs_enabled: enabled });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
enabled,
|
||||
message: enabled
|
||||
? "Detailed logging enabled. Pipeline bodies will be captured for new requests."
|
||||
: "Detailed logging disabled.",
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getModelAliases, setModelAlias, getProviderConnections } from "@/models";
|
||||
import { AI_MODELS } from "@/shared/constants/config";
|
||||
import { AI_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { updateModelAliasSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
@@ -18,7 +18,17 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
const active = connections.filter((c: any) => c.isActive !== false);
|
||||
activeProviders = new Set(active.map((c: any) => c.provider));
|
||||
// Include both provider IDs and their aliases in the active set.
|
||||
// PROVIDER_MODELS keys are aliases (e.g. 'cc' for 'claude', 'gh' for 'github').
|
||||
// DB connections are stored under provider IDs ('claude', 'github').
|
||||
// Without this, models for aliased providers always appear unconfigured.
|
||||
activeProviders = new Set<string>();
|
||||
for (const c of active) {
|
||||
const pId = String((c as any).provider);
|
||||
activeProviders.add(pId);
|
||||
const alias = PROVIDER_ID_TO_ALIAS[pId];
|
||||
if (alias) activeProviders.add(alias);
|
||||
}
|
||||
} catch {
|
||||
// If DB unavailable, show all models
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import { APP_CONFIG } from "@/shared/constants/config";
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const { getAllCircuitBreakerStatuses } =
|
||||
await import("@/../../src/shared/utils/circuitBreaker");
|
||||
const { getAllCircuitBreakerStatuses } = await import("@/shared/utils/circuitBreaker");
|
||||
const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager");
|
||||
const { getAllModelLockouts } = await import("@omniroute/open-sse/services/accountFallback");
|
||||
|
||||
@@ -19,6 +18,7 @@ export async function GET() {
|
||||
const circuitBreakers = getAllCircuitBreakerStatuses();
|
||||
const rateLimitStatus = getAllRateLimitStatus();
|
||||
const lockouts = getAllModelLockouts();
|
||||
const { getAllHealthStatuses } = await import("@/lib/localHealthCheck");
|
||||
|
||||
// System info
|
||||
const system = {
|
||||
@@ -47,6 +47,7 @@ export async function GET() {
|
||||
timestamp: new Date().toISOString(),
|
||||
system,
|
||||
providerHealth,
|
||||
localProviders: getAllHealthStatuses(),
|
||||
rateLimitStatus,
|
||||
lockouts,
|
||||
setupComplete: settings?.setupComplete || false,
|
||||
@@ -66,7 +67,7 @@ export async function GET() {
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const { resetAllCircuitBreakers, getAllCircuitBreakerStatuses } =
|
||||
await import("@/../../src/shared/utils/circuitBreaker");
|
||||
await import("@/shared/utils/circuitBreaker");
|
||||
|
||||
const before = getAllCircuitBreakerStatuses();
|
||||
const resetCount = before.length;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { timingSafeEqual } from "crypto";
|
||||
import {
|
||||
getProvider,
|
||||
generateAuthData,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* API Route: /api/pricing/sync
|
||||
*
|
||||
* POST — Trigger a manual pricing sync from external sources.
|
||||
* GET — Get current sync status.
|
||||
* DELETE — Clear all synced pricing data.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pricingSyncRequestSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(pricingSyncRequestSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { sources, dryRun = false } = validation.data;
|
||||
|
||||
const { syncPricingFromSources } = await import("@/lib/pricingSync");
|
||||
const result = await syncPricingFromSources({ sources, dryRun });
|
||||
|
||||
return NextResponse.json(result, { status: result.success ? 200 : 502 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { getSyncStatus } = await import("@/lib/pricingSync");
|
||||
return NextResponse.json(getSyncStatus());
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const { clearSyncedPricing } = await import("@/lib/pricingSync");
|
||||
clearSyncedPricing();
|
||||
return NextResponse.json({ success: true, message: "Synced pricing data cleared" });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user