Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f02c5b5c69 | |||
| 838f1d645c | |||
| 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 |
@@ -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
|
||||
|
||||
@@ -32,6 +32,27 @@ Version format: `2.x.y` — examples:
|
||||
npm version patch --no-git-tag-version
|
||||
```
|
||||
|
||||
> **⚠️ ATOMIC COMMIT RULE — Version bump MUST happen before committing feature files.**
|
||||
>
|
||||
> **CORRECT order:**
|
||||
>
|
||||
> 1. `npm version patch --no-git-tag-version` ← bump first
|
||||
> 2. implement features / fix bugs
|
||||
> 3. `git add -A && git commit -m "chore(release): v2.x.y — all changes in ONE commit"`
|
||||
>
|
||||
> **OR if features are already staged:**
|
||||
>
|
||||
> 1. implement features (do NOT commit yet)
|
||||
> 2. `npm version patch --no-git-tag-version` ← bump before committing
|
||||
> 3. `git add -A && git commit -m "chore(release): v2.x.y — all changes in ONE commit"`
|
||||
>
|
||||
> **NEVER do this (creates version mismatch in git history):**
|
||||
>
|
||||
> - ~~commit features → then bump version → commit package.json separately~~
|
||||
>
|
||||
> This ensures that `git show v2.x.y` always contains both code changes and the version bump together.
|
||||
> The GitHub release tag will point to a commit that includes ALL changes for that version.
|
||||
|
||||
### 2. Regenerate lock file (REQUIRED after version bump)
|
||||
|
||||
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
|
||||
@@ -85,12 +106,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"
|
||||
|
||||
|
||||
+257
@@ -2,6 +2,263 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [2.6.10] — 2026-03-17
|
||||
|
||||
> Windows fix: better-sqlite3 prebuilt download without node-gyp/Python/MSVC (#426).
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(install/#426)**: On Windows, `npm install -g omniroute` used to fail with `better_sqlite3.node is not a valid Win32 application` because the bundled native binary was compiled for Linux. Adds **Strategy 1.5** to `scripts/postinstall.mjs`: uses `@mapbox/node-pre-gyp install --fallback-to-build=false` (bundled within `better-sqlite3`) to download the correct prebuilt binary for the current OS/arch without requiring any build tools (no node-gyp, no Python, no MSVC). Falls back to `npm rebuild` only if the download fails. Adds platform-specific error messages with clear manual fix instructions.
|
||||
|
||||
---
|
||||
|
||||
## [2.6.9] — 2026-03-17
|
||||
|
||||
> CI fixes (t11 any-budget), bug fix #409 (file attachments via Copilot+Claude), release workflow correction.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(ci)**: Remove word "any" from comments in `openai-responses.ts` and `chatCore.ts` that were failing the t11 `\bany\b` budget check (false positive from regex counting comments)
|
||||
- **fix(chatCore)**: Normalize unsupported content part types before forwarding to providers (#409 — Cursor sends `{type:"file"}` when `.md` files are attached; Copilot and other OpenAI-compat providers reject with "type has to be either 'image_url' or 'text'"; fix converts `file`/`document` blocks to `text` and drops unknown types)
|
||||
|
||||
### 🔧 Workflow
|
||||
|
||||
- **chore(generate-release)**: Add ATOMIC COMMIT RULE — version bump (`npm version patch`) MUST happen before committing feature files to ensure tag always points to a commit containing all version changes together
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
|
||||
+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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.5.4
|
||||
version: 2.6.10
|
||||
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,
|
||||
|
||||
+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"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
+88
-6
@@ -4,9 +4,30 @@ 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: ["localhost", "127.0.0.1", "192.168.*"],
|
||||
typescript: {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -135,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
|
||||
@@ -162,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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -305,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" },
|
||||
],
|
||||
},
|
||||
@@ -356,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" },
|
||||
],
|
||||
@@ -429,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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -836,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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -919,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",
|
||||
@@ -1022,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>();
|
||||
@@ -1041,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.
|
||||
|
||||
@@ -99,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}`;
|
||||
}
|
||||
|
||||
@@ -106,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() === "") {
|
||||
@@ -117,13 +131,6 @@ export class CodexExecutor extends BaseExecutor {
|
||||
// Ensure store is false (Codex requirement)
|
||||
body.store = false;
|
||||
|
||||
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
|
||||
if (requestServiceTier) {
|
||||
body.service_tier = requestServiceTier;
|
||||
} else if (defaultFastServiceTierEnabled) {
|
||||
body.service_tier = CODEX_FAST_WIRE_VALUE;
|
||||
}
|
||||
|
||||
// Extract thinking level from model name suffix
|
||||
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
|
||||
const effortLevels = ["none", "low", "medium", "high", "xhigh"];
|
||||
|
||||
@@ -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
|
||||
|
||||
+149
-47
@@ -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
|
||||
@@ -103,6 +120,11 @@ export async function handleChatCore({
|
||||
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);
|
||||
@@ -163,56 +185,121 @@ 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 the slightest.
|
||||
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;
|
||||
});
|
||||
}
|
||||
// ── #346: Strip tools with empty function.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."
|
||||
if (Array.isArray(body.tools)) {
|
||||
body.tools = body.tools.filter((tool: Record<string, unknown>) => {
|
||||
const fn = tool.function as Record<string, unknown> | undefined;
|
||||
return fn?.name && String(fn.name).trim().length > 0;
|
||||
});
|
||||
}
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── #409: Normalize unsupported content part types ──
|
||||
// Cursor and other clients send {type:"file"} when attaching .md or other files.
|
||||
// Providers (Copilot, OpenAI) only accept "text" and "image_url" in content arrays.
|
||||
// Convert: file → text (extract content), drop unrecognized types with a warning.
|
||||
if (Array.isArray(translatedBody.messages)) {
|
||||
for (const msg of translatedBody.messages) {
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
msg.content = (msg.content as Record<string, unknown>[]).flatMap(
|
||||
(block: Record<string, unknown>) => {
|
||||
if (block.type === "text" || block.type === "image_url" || block.type === "image") {
|
||||
return [block];
|
||||
}
|
||||
// file / document → extract text content
|
||||
if (block.type === "file" || block.type === "document") {
|
||||
const fileContent =
|
||||
(block.file as Record<string, unknown>)?.content ??
|
||||
(block.file as Record<string, unknown>)?.text ??
|
||||
block.content ??
|
||||
block.text;
|
||||
const fileName =
|
||||
(block.file as Record<string, unknown>)?.name ?? block.name ?? "attachment";
|
||||
if (typeof fileContent === "string" && fileContent.length > 0) {
|
||||
return [{ type: "text", text: `[${fileName}]\n${fileContent}` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
// Unknown types: drop silently
|
||||
log?.debug?.("CONTENT", `Dropped unsupported content part type="${block.type}"`);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
translatedBody = translateRequest(
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
model,
|
||||
translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
provider,
|
||||
reqLogger
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const parsedStatus = Number(error?.statusCode);
|
||||
const statusCode =
|
||||
@@ -259,6 +346,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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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];
|
||||
@@ -225,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,
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 assistant messages)
|
||||
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 = "";
|
||||
|
||||
@@ -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
+639
-435
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.5.4",
|
||||
"version": "2.6.10",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -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 |
+63
-7
@@ -14,6 +14,7 @@
|
||||
*
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/426
|
||||
*/
|
||||
|
||||
import { existsSync, copyFileSync, mkdirSync } from "node:fs";
|
||||
@@ -80,8 +81,54 @@ if (existsSync(rootBinary)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 1.5: Use node-pre-gyp to download the correct prebuilt binary
|
||||
// This works on Windows without requiring node-gyp, Python, or MSVC.
|
||||
// better-sqlite3 ships prebuilts for win32-x64, win32-arm64, darwin-x64/arm64.
|
||||
console.log(" 📥 Attempting to download prebuilt binary via node-pre-gyp...");
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
// better-sqlite3 bundles @mapbox/node-pre-gyp — use it directly
|
||||
const preGypBin = join(
|
||||
ROOT,
|
||||
"app",
|
||||
"node_modules",
|
||||
".bin",
|
||||
process.platform === "win32" ? "node-pre-gyp.cmd" : "node-pre-gyp"
|
||||
);
|
||||
const preGypFallback = join(
|
||||
ROOT,
|
||||
"app",
|
||||
"node_modules",
|
||||
"@mapbox",
|
||||
"node-pre-gyp",
|
||||
"bin",
|
||||
"node-pre-gyp"
|
||||
);
|
||||
const preGypCmd = existsSync(preGypBin) ? preGypBin : preGypFallback;
|
||||
|
||||
if (existsSync(preGypCmd)) {
|
||||
execSync(`"${process.execPath}" "${preGypCmd}" install --fallback-to-build=false`, {
|
||||
cwd: join(ROOT, "app", "node_modules", "better-sqlite3"),
|
||||
stdio: "inherit",
|
||||
timeout: 60_000,
|
||||
});
|
||||
mkdirSync(dirname(appBinary), { recursive: true });
|
||||
try {
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
console.log(" ✅ Prebuilt binary downloaded and loaded successfully!\n");
|
||||
process.exit(0);
|
||||
} catch (loadErr) {
|
||||
console.warn(` ⚠️ Downloaded binary failed to load: ${loadErr.message}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(" ⚠️ node-pre-gyp not found, skipping prebuilt download.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ node-pre-gyp download failed: ${err.message.split("\n")[0]}`);
|
||||
}
|
||||
|
||||
// Strategy 2: Fall back to npm rebuild (may work if build tools are available)
|
||||
console.log(" ⚠️ Root binary not available or incompatible, attempting npm rebuild...");
|
||||
console.log(" ⚠️ Attempting npm rebuild (requires build tools)...");
|
||||
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
@@ -103,14 +150,23 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing worked, warn but don't fail the install — let the package stay
|
||||
// installed so users can fix manually or use the pre-flight check in the CLI
|
||||
console.warn(" ⚠️ Could not fix better-sqlite3 native module automatically.");
|
||||
// If nothing worked, warn but don't fail the install
|
||||
console.warn("\n ⚠️ Could not fix better-sqlite3 native module automatically.");
|
||||
console.warn(" The server may not start correctly.");
|
||||
console.warn(" Try manually:");
|
||||
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
if (process.platform === "darwin") {
|
||||
console.warn(" Manual fix options:");
|
||||
if (process.platform === "win32") {
|
||||
console.warn(" Option A (easiest — no build tools needed):");
|
||||
console.warn(` cd "${join(ROOT, "app", "node_modules", "better-sqlite3")}"`);
|
||||
console.warn(" npx @mapbox/node-pre-gyp install --fallback-to-build=false");
|
||||
console.warn(" Option B (requires Build Tools for Visual Studio):");
|
||||
console.warn(` cd "${join(ROOT, "app")}" && npm rebuild better-sqlite3`);
|
||||
console.warn(" Install from: https://visualstudio.microsoft.com/visual-cpp-build-tools/");
|
||||
console.warn(" Also ensure Python is installed: https://python.org");
|
||||
} else if (process.platform === "darwin") {
|
||||
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
console.warn(" If build tools are missing: xcode-select --install");
|
||||
} else {
|
||||
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
}
|
||||
console.warn("");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3075,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) {
|
||||
@@ -3090,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]);
|
||||
|
||||
@@ -3107,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;
|
||||
@@ -3127,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();
|
||||
@@ -3182,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")}
|
||||
@@ -3232,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,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}
|
||||
|
||||
@@ -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() || "";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,14 +7,31 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const sources = Array.isArray(body.sources)
|
||||
? body.sources.filter((s: unknown): s is string => typeof s === "string")
|
||||
: undefined;
|
||||
const dryRun = body.dryRun === true;
|
||||
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 });
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { name, prefix, apiType, baseUrl } = validation.data;
|
||||
const { name, prefix, apiType, baseUrl, chatPath, modelsPath } = validation.data;
|
||||
const node: any = await getProviderNodeById(id);
|
||||
|
||||
if (!node) {
|
||||
@@ -68,6 +68,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
name: name.trim(),
|
||||
prefix: prefix.trim(),
|
||||
baseUrl: sanitizedBaseUrl,
|
||||
chatPath: chatPath || null,
|
||||
modelsPath: modelsPath || null,
|
||||
};
|
||||
|
||||
if (node.type === "openai-compatible") {
|
||||
@@ -88,6 +90,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
prefix: prefix.trim(),
|
||||
baseUrl: sanitizedBaseUrl,
|
||||
nodeName: updated.name,
|
||||
chatPath: updated.chatPath || undefined,
|
||||
modelsPath: updated.modelsPath || undefined,
|
||||
} as JsonRecord;
|
||||
if (node.type === "openai-compatible") {
|
||||
providerSpecificData.apiType = apiType;
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function POST(request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { name, prefix, apiType, baseUrl, type } = validation.data;
|
||||
const { name, prefix, apiType, baseUrl, type, chatPath, modelsPath } = validation.data;
|
||||
|
||||
// Determine type
|
||||
const nodeType = type || "openai-compatible";
|
||||
@@ -62,6 +62,8 @@ export async function POST(request) {
|
||||
apiType,
|
||||
baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(),
|
||||
name: name.trim(),
|
||||
chatPath: chatPath || null,
|
||||
modelsPath: modelsPath || null,
|
||||
});
|
||||
return NextResponse.json({ node }, { status: 201 });
|
||||
}
|
||||
@@ -82,6 +84,8 @@ export async function POST(request) {
|
||||
prefix: prefix.trim(),
|
||||
baseUrl: sanitizedBaseUrl,
|
||||
name: name.trim(),
|
||||
chatPath: chatPath || null,
|
||||
modelsPath: modelsPath || null,
|
||||
});
|
||||
return NextResponse.json({ node }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function POST(request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, apiKey, type } = validation.data;
|
||||
const { baseUrl, apiKey, type, modelsPath } = validation.data;
|
||||
|
||||
// Anthropic Compatible Validation
|
||||
if (type === "anthropic-compatible") {
|
||||
@@ -35,7 +35,7 @@ export async function POST(request) {
|
||||
}
|
||||
|
||||
// Use /models endpoint for validation as many compatible providers support it (like OpenAI)
|
||||
const modelsUrl = `${normalizedBase}/models`;
|
||||
const modelsUrl = `${normalizedBase}${modelsPath || "/models"}`;
|
||||
|
||||
const res = await fetch(modelsUrl, {
|
||||
method: "GET",
|
||||
@@ -50,7 +50,7 @@ export async function POST(request) {
|
||||
}
|
||||
|
||||
// OpenAI Compatible Validation (Default)
|
||||
const modelsUrl = `${baseUrl.replace(/\/$/, "")}/models`;
|
||||
const modelsUrl = `${baseUrl.replace(/\/$/, "")}${modelsPath || "/models"}`;
|
||||
const res = await fetch(modelsUrl, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
|
||||
@@ -255,6 +255,22 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.models || data.data || [],
|
||||
},
|
||||
synthetic: {
|
||||
url: "https://api.synthetic.new/openai/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
"kilo-gateway": {
|
||||
url: "https://api.kilo.ai/api/gateway/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ const OAUTH_TEST_CONFIG = {
|
||||
claude: {
|
||||
// Claude doesn't have userinfo, we verify token exists and not expired
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
codex: {
|
||||
// Codex OAuth tokens are ChatGPT session tokens, NOT standard OpenAI API keys.
|
||||
|
||||
@@ -82,6 +82,8 @@ export async function POST(request: Request) {
|
||||
apiType: node.apiType,
|
||||
baseUrl: node.baseUrl,
|
||||
nodeName: node.name,
|
||||
...(node.chatPath ? { chatPath: node.chatPath } : {}),
|
||||
...(node.modelsPath ? { modelsPath: node.modelsPath } : {}),
|
||||
};
|
||||
} else if (isAnthropicCompatibleProvider(provider)) {
|
||||
const node: any = await getProviderNodeById(provider);
|
||||
@@ -101,6 +103,8 @@ export async function POST(request: Request) {
|
||||
prefix: node.prefix,
|
||||
baseUrl: node.baseUrl,
|
||||
nodeName: node.name,
|
||||
...(node.chatPath ? { chatPath: node.chatPath } : {}),
|
||||
...(node.modelsPath ? { modelsPath: node.modelsPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function POST() {
|
||||
try {
|
||||
// Reset all circuit breakers
|
||||
const { getAllCircuitBreakerStatuses, getCircuitBreaker } =
|
||||
await import("@/../../src/shared/utils/circuitBreaker");
|
||||
await import("@/shared/utils/circuitBreaker");
|
||||
|
||||
const statuses = getAllCircuitBreakerStatuses();
|
||||
let resetCount = 0;
|
||||
@@ -23,11 +23,9 @@ export async function POST() {
|
||||
resetCount,
|
||||
message: `Reset ${resetCount} circuit breaker(s)`,
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to reset resilience state";
|
||||
console.error("[API] POST /api/resilience/reset error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || "Failed to reset resilience state" },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
||||
export async function GET() {
|
||||
try {
|
||||
// Dynamic imports for open-sse modules
|
||||
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 { PROVIDER_PROFILES, DEFAULT_API_LIMITS } =
|
||||
await import("@omniroute/open-sse/config/constants");
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { assignProxyToScope, getProxyAssignments, resolveProxyForConnection } from "@/lib/localDb";
|
||||
import { proxyAssignmentSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const proxyId = searchParams.get("proxyId");
|
||||
const scope = searchParams.get("scope");
|
||||
const scopeId = searchParams.get("scopeId");
|
||||
const resolveConnectionId = searchParams.get("resolveConnectionId");
|
||||
|
||||
if (resolveConnectionId) {
|
||||
const resolved = await resolveProxyForConnection(resolveConnectionId);
|
||||
return Response.json(resolved);
|
||||
}
|
||||
|
||||
const assignments = await getProxyAssignments({
|
||||
proxyId: proxyId || undefined,
|
||||
scope: scope || undefined,
|
||||
});
|
||||
const filtered = scopeId
|
||||
? assignments.filter((entry) => entry.scopeId === scopeId)
|
||||
: assignments;
|
||||
return Response.json({ items: filtered, total: filtered.length });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to load proxy assignments");
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "Invalid JSON body",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(proxyAssignmentSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const { scope, scopeId, proxyId } = validation.data;
|
||||
const assigned = await assignProxyToScope(scope, scopeId || null, proxyId || null);
|
||||
clearDispatcherCache();
|
||||
return Response.json({ success: true, assignment: assigned });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to update assignment");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { bulkAssignProxyToScope } from "@/lib/localDb";
|
||||
import { bulkProxyAssignmentSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "Invalid JSON body",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(bulkProxyAssignmentSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const { scope, scopeIds, proxyId } = validation.data;
|
||||
const normalizedScope = scope === "key" ? "account" : scope;
|
||||
const result = await bulkAssignProxyToScope(normalizedScope, scopeIds || [], proxyId || null);
|
||||
clearDispatcherCache();
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
scope: normalizedScope,
|
||||
requested: normalizedScope === "global" ? 1 : (scopeIds || []).length,
|
||||
updated: result.updated,
|
||||
failed: result.failed,
|
||||
});
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to run bulk assignment");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getProxyHealthStats } from "@/lib/localDb";
|
||||
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const hours = Number(searchParams.get("hours") || 24);
|
||||
const items = await getProxyHealthStats({ hours });
|
||||
return Response.json({ items, total: items.length, windowHours: hours });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to load proxy health stats");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { migrateLegacyProxyConfigToRegistry } from "@/lib/localDb";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { z } from "zod";
|
||||
|
||||
const migrateLegacyProxySchema = z.object({
|
||||
force: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let rawBody: unknown;
|
||||
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "Invalid JSON body",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(migrateLegacyProxySchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const force = validation.data.force === true;
|
||||
const result = await migrateLegacyProxyConfigToRegistry({ force });
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to migrate legacy proxy config");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
createProxy,
|
||||
deleteProxyById,
|
||||
getProxyById,
|
||||
getProxyWhereUsed,
|
||||
listProxies,
|
||||
updateProxy,
|
||||
} from "@/lib/localDb";
|
||||
import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
const whereUsed = searchParams.get("whereUsed") === "1";
|
||||
|
||||
if (id && whereUsed) {
|
||||
const usage = await getProxyWhereUsed(id);
|
||||
return Response.json(usage);
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const proxy = await getProxyById(id, { includeSecrets: false });
|
||||
if (!proxy) {
|
||||
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
|
||||
}
|
||||
return Response.json(proxy);
|
||||
}
|
||||
|
||||
const proxies = await listProxies({ includeSecrets: false });
|
||||
return Response.json({ items: proxies, total: proxies.length });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to load proxies");
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "Invalid JSON body",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(createProxyRegistrySchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const created = await createProxy(validation.data);
|
||||
return Response.json(created, { status: 201 });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to create proxy");
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "Invalid JSON body",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(updateProxyRegistrySchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const { id, ...changes } = validation.data;
|
||||
const updated = await updateProxy(id, changes);
|
||||
if (!updated) {
|
||||
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
|
||||
}
|
||||
|
||||
return Response.json(updated);
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to update proxy");
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
const force = searchParams.get("force") === "1";
|
||||
|
||||
if (!id) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "id is required",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await deleteProxyById(id, { force });
|
||||
if (!deleted) {
|
||||
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to delete proxy");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
|
||||
import { updateProxyConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import type { z } from "zod";
|
||||
|
||||
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
|
||||
@@ -135,11 +136,7 @@ export async function GET(request: Request) {
|
||||
const config = await getProxyConfig();
|
||||
return Response.json(config);
|
||||
} catch (error) {
|
||||
const routeError = toApiRouteError(error);
|
||||
return Response.json(
|
||||
{ error: { message: routeError.message, type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
return createErrorResponseFromUnknown(error, "Failed to load proxy config");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,25 +149,22 @@ export async function PUT(request: Request) {
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: { message: "Invalid JSON body", type: "invalid_request" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "Invalid JSON body",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(updateProxyConfigSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
const body = validation.data;
|
||||
const normalizedBody = normalizeProxyPayload(body);
|
||||
@@ -181,7 +175,7 @@ export async function PUT(request: Request) {
|
||||
const routeError = toApiRouteError(error);
|
||||
const status = Number(routeError.status) || 500;
|
||||
const type = routeError.type || (status === 400 ? "invalid_request" : "server_error");
|
||||
return Response.json({ error: { message: routeError.message, type } }, { status });
|
||||
return createErrorResponse({ status, message: routeError.message, type });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,20 +190,17 @@ export async function DELETE(request: Request) {
|
||||
const id = searchParams.get("id");
|
||||
|
||||
if (!level) {
|
||||
return Response.json(
|
||||
{ error: { message: "level is required", type: "invalid_request" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "level is required",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await deleteProxyForLevel(level, id);
|
||||
clearDispatcherCache();
|
||||
return Response.json(updated);
|
||||
} catch (error) {
|
||||
const routeError = toApiRouteError(error);
|
||||
return Response.json(
|
||||
{ error: { message: routeError.message, type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
return createErrorResponseFromUnknown(error, "Failed to delete proxy");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@omniroute/open-sse/utils/proxyDispatcher.ts";
|
||||
import { testProxySchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
|
||||
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
|
||||
|
||||
@@ -38,61 +39,46 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: { message: "Invalid JSON body", type: "invalid_request" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "Invalid JSON body",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(testProxySchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
const { proxy } = validation.data;
|
||||
|
||||
const proxyType = String(proxy.type || "http").toLowerCase();
|
||||
if (proxyType === "socks5" && !isSocks5ProxyEnabled()) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)",
|
||||
type: "invalid_request",
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
if (proxyType.startsWith("socks") && proxyType !== "socks5") {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: `proxy.type must be ${supportedTypesMessage()}`,
|
||||
type: "invalid_request",
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: `proxy.type must be ${supportedTypesMessage()}`,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
if (!getSupportedProxyTypes().has(proxyType)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: `proxy.type must be ${supportedTypesMessage()}`,
|
||||
type: "invalid_request",
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: `proxy.type must be ${supportedTypesMessage()}`,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
let proxyUrl: string;
|
||||
@@ -108,27 +94,19 @@ export async function POST(request: Request) {
|
||||
{ allowSocks5: isSocks5ProxyEnabled() }
|
||||
);
|
||||
if (!normalizedProxyUrl) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid proxy configuration",
|
||||
type: "invalid_request",
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "Invalid proxy configuration",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
proxyUrl = normalizedProxyUrl;
|
||||
} catch (proxyError) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: getErrorMessage(proxyError, "Invalid proxy configuration"),
|
||||
type: "invalid_request",
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: getErrorMessage(proxyError, "Invalid proxy configuration"),
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const publicProxyUrl = proxyUrlForLogs(proxyUrl);
|
||||
@@ -180,7 +158,6 @@ export async function POST(request: Request) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "Unexpected server error");
|
||||
return Response.json({ error: { message, type: "server_error" } }, { status: 500 });
|
||||
return createErrorResponseFromUnknown(error, "Unexpected server error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { timingSafeEqual } from "crypto";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getDefaultTaskModelMap,
|
||||
} from "@omniroute/open-sse/services/taskAwareRouter.ts";
|
||||
import { updateSettings } from "@/lib/db/settings";
|
||||
import { taskRoutingActionSchema, updateTaskRoutingSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
/**
|
||||
* GET /api/settings/task-routing
|
||||
@@ -29,15 +31,29 @@ export async function GET() {
|
||||
* Body: { enabled?: boolean, taskModelMap?: { coding?: "...", ... }, detectionEnabled?: boolean }
|
||||
*/
|
||||
export async function PUT(request: Request) {
|
||||
let rawBody: Record<string, unknown>;
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
setTaskRoutingConfig(rawBody as any);
|
||||
const validation = validateBody(updateTaskRoutingSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const config = validation.data;
|
||||
|
||||
setTaskRoutingConfig(config);
|
||||
|
||||
// Persist to database (excluding stats)
|
||||
const { stats, ...persistable } = getTaskRoutingConfig();
|
||||
@@ -56,15 +72,29 @@ export async function PUT(request: Request) {
|
||||
* For "detect": pass { action: "detect", body: <request-body> } to test detection
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let rawBody: any;
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (rawBody.action === "reset-stats") {
|
||||
const validation = validateBody(taskRoutingActionSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const actionRequest = validation.data;
|
||||
|
||||
if (actionRequest.action === "reset-stats") {
|
||||
resetTaskRoutingStats();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -72,9 +102,9 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
if (rawBody.action === "detect") {
|
||||
if (actionRequest.action === "detect") {
|
||||
const { detectTaskType } = await import("@omniroute/open-sse/services/taskAwareRouter.ts");
|
||||
const taskType = detectTaskType(rawBody.body || {});
|
||||
const taskType = detectTaskType(actionRequest.body || {});
|
||||
const config = getTaskRoutingConfig();
|
||||
return NextResponse.json({
|
||||
taskType,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { resolveDataDir } from "@/lib/dataPaths";
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* GET /api/system/version — Returns current version and latest available on npm
|
||||
* POST /api/system/update — Triggers npm install -g omniroute@latest + pm2 restart
|
||||
*
|
||||
* Security: Requires admin authentication (same as other management routes).
|
||||
* Safety: Update only runs if a newer version is available on npm.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Fetch latest version from npm registry (no install, just metadata) */
|
||||
async function getLatestNpmVersion(): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("npm", ["info", "omniroute", "version", "--json"], {
|
||||
timeout: 10000,
|
||||
});
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
return typeof parsed === "string" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current installed version from package.json */
|
||||
function getCurrentVersion(): string {
|
||||
try {
|
||||
|
||||
return require("../../../../../package.json").version as string;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare semver strings — returns true if a > b */
|
||||
function isNewer(a: string | null, b: string): boolean {
|
||||
if (!a) return false;
|
||||
const parse = (v: string) => v.split(".").map(Number);
|
||||
const [aMaj, aMin, aPat] = parse(a);
|
||||
const [bMaj, bMin, bPat] = parse(b);
|
||||
if (aMaj !== bMaj) return aMaj > bMaj;
|
||||
if (aMin !== bMin) return aMin > bMin;
|
||||
return aPat > bPat;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isAuthenticated(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const current = getCurrentVersion();
|
||||
const latest = await getLatestNpmVersion();
|
||||
const updateAvailable = isNewer(latest, current);
|
||||
|
||||
return NextResponse.json({
|
||||
current,
|
||||
latest: latest ?? "unavailable",
|
||||
updateAvailable,
|
||||
channel: "npm",
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isAuthenticated(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const current = getCurrentVersion();
|
||||
const latest = await getLatestNpmVersion();
|
||||
|
||||
if (!latest) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Could not reach npm registry" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNewer(latest, current)) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: `Already on latest version (${current})`,
|
||||
current,
|
||||
latest,
|
||||
});
|
||||
}
|
||||
|
||||
// Run update in background — client gets immediate acknowledgment
|
||||
const install = async () => {
|
||||
try {
|
||||
await execFileAsync("npm", ["install", "-g", `omniroute@${latest}`, "--ignore-scripts"], {
|
||||
timeout: 300000, // 5 minutes
|
||||
});
|
||||
// Restart PM2 — non-fatal if pm2 not available (Docker/manual setups)
|
||||
await execFileAsync("pm2", ["restart", "omniroute"]).catch(() => null);
|
||||
console.log(`[AutoUpdate] Successfully updated to v${latest}`);
|
||||
} catch (err) {
|
||||
console.error(`[AutoUpdate] Update failed:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
// Fire-and-forget
|
||||
install();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Update to v${latest} started. Restarting in ~30 seconds.`,
|
||||
from: current,
|
||||
to: latest,
|
||||
});
|
||||
}
|
||||
@@ -6,10 +6,16 @@ import {
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import { parseSpeechModel, getSpeechProvider } from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import {
|
||||
parseSpeechModel,
|
||||
getSpeechProvider,
|
||||
buildDynamicAudioProvider,
|
||||
type ProviderNodeRow,
|
||||
} from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { getProviderNodes } from "@/lib/localDb";
|
||||
import { v1AudioSpeechSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
@@ -55,7 +61,31 @@ export async function POST(request) {
|
||||
const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
const { provider } = parseSpeechModel(body.model);
|
||||
// Load local provider_nodes for audio routing (only localhost — prevents auth bypass/SSRF)
|
||||
let dynamicProviders: ReturnType<typeof buildDynamicAudioProvider>[] = [];
|
||||
try {
|
||||
const nodes = await getProviderNodes();
|
||||
dynamicProviders = (Array.isArray(nodes) ? nodes : [])
|
||||
.filter((n: ProviderNodeRow) => {
|
||||
if (n.apiType !== "chat" && n.apiType !== "responses") return false;
|
||||
try {
|
||||
const hostname = new URL(n.baseUrl).hostname;
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "[::1]"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((n) => buildDynamicAudioProvider(n, "/audio/speech"));
|
||||
} catch {
|
||||
// DB error — fall back to hardcoded providers only
|
||||
}
|
||||
|
||||
const { provider, model: resolvedModel } = parseSpeechModel(body.model, dynamicProviders);
|
||||
if (!provider) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
@@ -63,8 +93,9 @@ export async function POST(request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check provider config for auth bypass
|
||||
const providerConfig = getSpeechProvider(provider);
|
||||
// Check provider config — hardcoded first, then dynamic
|
||||
const providerConfig =
|
||||
getSpeechProvider(provider) || dynamicProviders.find((dp) => dp.id === provider) || null;
|
||||
|
||||
// Get credentials — skip for local providers (authType: "none")
|
||||
let credentials = null;
|
||||
@@ -75,7 +106,12 @@ export async function POST(request) {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await handleAudioSpeech({ body, credentials });
|
||||
const response = await handleAudioSpeech({
|
||||
body,
|
||||
credentials,
|
||||
resolvedProvider: providerConfig,
|
||||
resolvedModel,
|
||||
});
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user