mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-27 18:32:16 +03:00
Compare commits
171 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44e4d55a66 | ||
|
|
095c84ac16 | ||
|
|
e063eae727 | ||
|
|
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 | ||
|
|
4b7a9233e7 | ||
|
|
204839f702 | ||
|
|
d15e3109ee | ||
|
|
8b513ee8f8 | ||
|
|
2c1488e65a | ||
|
|
8ebe1cc2d8 | ||
|
|
b0d6c15e63 | ||
|
|
3a3c7a7968 | ||
|
|
783d7ae605 | ||
|
|
bbf7a6b2f8 | ||
|
|
0fe6e24554 | ||
|
|
4bbaf55586 | ||
|
|
cda765a02d | ||
|
|
36856b18db | ||
|
|
66f0a8f994 | ||
|
|
455231170f | ||
|
|
5faeb58ab0 | ||
|
|
056e4a88ff | ||
|
|
8fd944ccf7 | ||
|
|
86105a547c | ||
|
|
9806648c07 | ||
|
|
6186babdb3 | ||
|
|
f2ecefb54a | ||
|
|
43bd529b78 | ||
|
|
9c82b3d4ca | ||
|
|
b19e6a8e87 | ||
|
|
e3a2bd75f3 | ||
|
|
da39e1485f | ||
|
|
88cc53a4b0 | ||
|
|
245243c7e7 | ||
|
|
759ac0df3d | ||
|
|
db8d97b6de | ||
|
|
27d66e4b3e | ||
|
|
ca7854210d | ||
|
|
c009c993c3 | ||
|
|
00188f75ae | ||
|
|
4d086542aa | ||
|
|
1555883633 | ||
|
|
8f2c0acc7e | ||
|
|
0e30d15c01 | ||
|
|
da14390fe0 | ||
|
|
11c0cff4ef | ||
|
|
e322376996 | ||
|
|
4fbe45f30a | ||
|
|
2cd0f60c3c | ||
|
|
1b354be827 | ||
|
|
7db280ee64 | ||
|
|
192c06cadf | ||
|
|
ad7e7abda0 | ||
|
|
02ccb35e80 | ||
|
|
a8a29e17c5 | ||
|
|
75a6d850fc | ||
|
|
b0f5f92f1a | ||
|
|
eaddb6f0fa | ||
|
|
5cff98ea75 | ||
|
|
76127415a4 | ||
|
|
56936fe0e3 | ||
|
|
dfbbbeb1b4 | ||
|
|
7f3ffd935e | ||
|
|
29cf462d8f | ||
|
|
5e1693e1f7 | ||
|
|
45424ca226 | ||
|
|
d976abb5e0 | ||
|
|
92d302aed3 |
@@ -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
|
||||
|
||||
24
.env.example
24
.env.example
@@ -142,10 +142,32 @@ GITHUB_USER_AGENT=GitHubCopilotChat/0.26.7
|
||||
ANTIGRAVITY_USER_AGENT=antigravity/1.104.0 darwin/arm64
|
||||
KIRO_USER_AGENT=AWS-SDK-JS/3.0.0 kiro-ide/1.0.0
|
||||
IFLOW_USER_AGENT=iFlow-Cli
|
||||
QWEN_USER_AGENT=google-api-nodejs-client/9.15.1
|
||||
QWEN_USER_AGENT=QwenCode/0.12.3 (linux; x64)
|
||||
CURSOR_USER_AGENT=connect-es/1.6.1
|
||||
GEMINI_CLI_USER_AGENT=google-api-nodejs-client/9.15.1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# CLI Fingerprint Compatibility (optional — match native CLI binary signatures)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# When enabled, OmniRoute reorders HTTP headers and JSON body fields to match
|
||||
# the exact signature of official CLI tools, reducing account flagging risk.
|
||||
# Your proxy IP is preserved — you get both stealth AND IP masking.
|
||||
#
|
||||
# Enable per-provider:
|
||||
# CLI_COMPAT_CODEX=1
|
||||
# CLI_COMPAT_CLAUDE=1
|
||||
# CLI_COMPAT_GITHUB=1
|
||||
# CLI_COMPAT_ANTIGRAVITY=1
|
||||
# CLI_COMPAT_KIRO=1
|
||||
# CLI_COMPAT_CURSOR=1
|
||||
# CLI_COMPAT_KIMI_CODING=1
|
||||
# CLI_COMPAT_KILOCODE=1
|
||||
# CLI_COMPAT_CLINE=1
|
||||
# CLI_COMPAT_QWEN=1
|
||||
#
|
||||
# Or enable for all providers at once:
|
||||
# CLI_COMPAT_ALL=1
|
||||
|
||||
# API Key Providers (Phase 1 + Phase 4)
|
||||
# Add via Dashboard → Providers → Add API Key, or set here
|
||||
# DEEPSEEK_API_KEY=
|
||||
|
||||
26
.github/workflows/docker-publish.yml
vendored
26
.github/workflows/docker-publish.yml
vendored
@@ -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,7 +21,9 @@ 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@v3
|
||||
@@ -24,21 +32,25 @@ jobs:
|
||||
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 }}
|
||||
|
||||
17
.github/workflows/npm-publish.yml
vendored
17
.github/workflows/npm-publish.yml
vendored
@@ -3,6 +3,12 @@ name: Publish to npm
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag to publish (e.g. 2.6.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -25,11 +31,14 @@ jobs:
|
||||
- name: Install dependencies (skip scripts to avoid heavy build)
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Sync version from release tag
|
||||
- name: Sync version from release tag or input
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
# Remove 'v' prefix if present (v2.1.0 -> 2.1.0)
|
||||
VERSION="${VERSION#v}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${VERSION#v}"
|
||||
fi
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
npx lint-staged
|
||||
node scripts/check-docs-sync.mjs
|
||||
npm run test:unit
|
||||
|
||||
445
CHANGELOG.md
445
CHANGELOG.md
@@ -1,5 +1,449 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [2.7.0] — 2026-03-17
|
||||
|
||||
> Sprint: ClawRouter-inspired features — toolCalling flag, multilingual intent detection, benchmark-driven fallback, request deduplication, pluggable RouterStrategy, Grok-4 Fast + GLM-5 + MiniMax M2.5 + Kimi K2.5 pricing.
|
||||
|
||||
### ✨ New Models & Pricing
|
||||
|
||||
- **feat(pricing)**: xAI Grok-4 Fast — `$0.20/$0.50 per 1M tokens`, 1143ms p50 latency, tool calling supported
|
||||
- **feat(pricing)**: xAI Grok-4 (standard) — `$0.20/$1.50 per 1M tokens`, reasoning flagship
|
||||
- **feat(pricing)**: GLM-5 via Z.AI — `$0.5/1M`, 128K output context
|
||||
- **feat(pricing)**: MiniMax M2.5 — `$0.30/1M input`, reasoning + agentic tasks
|
||||
- **feat(pricing)**: DeepSeek V3.2 — updated pricing `$0.27/$1.10 per 1M`
|
||||
- **feat(pricing)**: Kimi K2.5 via Moonshot API — direct Moonshot API access
|
||||
- **feat(providers)**: Z.AI provider added (`zai` alias) — GLM-5 family with 128K output
|
||||
|
||||
### 🧠 Routing Intelligence
|
||||
|
||||
- **feat(registry)**: `toolCalling` flag per model in provider registry — combos can now prefer/require tool-calling capable models
|
||||
- **feat(scoring)**: Multilingual intent detection for AutoCombo scoring — PT/ZH/ES/AR script/language patterns influence model selection per request context
|
||||
- **feat(fallback)**: Benchmark-driven fallback chains — real latency data (p50 from `comboMetrics`) used to re-order fallback priority dynamically
|
||||
- **feat(dedup)**: Request deduplication via content-hash — 5-second idempotency window prevents duplicate provider calls from retrying clients
|
||||
- **feat(router)**: Pluggable `RouterStrategy` interface in `autoCombo/routerStrategy.ts` — custom routing logic can be injected without modifying core
|
||||
|
||||
### 🔧 MCP Server Improvements
|
||||
|
||||
- **feat(mcp)**: 2 new advanced tool schemas: `omniroute_get_provider_metrics` (p50/p95/p99 per provider) and `omniroute_explain_route` (routing decision explanation)
|
||||
- **feat(mcp)**: MCP tool auth scopes updated — `metrics:read` scope added for provider metrics tools
|
||||
- **feat(mcp)**: `omniroute_best_combo_for_task` now accepts `languageHint` parameter for multilingual routing
|
||||
|
||||
### 📊 Observability
|
||||
|
||||
- **feat(metrics)**: `comboMetrics.ts` extended with real-time latency percentile tracking per provider/account
|
||||
- **feat(health)**: Health API (`/api/monitoring/health`) now returns per-provider `p50Latency` and `errorRate` fields
|
||||
- **feat(usage)**: Usage history migration for per-model latency tracking
|
||||
|
||||
### 🗄️ DB Migrations
|
||||
|
||||
- **feat(migrations)**: New column `latency_p50` in `combo_metrics` table — zero-breaking, safe for existing users
|
||||
|
||||
### 🐛 Bug Fixes / Closures
|
||||
|
||||
- **close(#411)**: better-sqlite3 hashed module resolution on Windows — fixed in v2.6.10 (f02c5b5)
|
||||
- **close(#409)**: GitHub Copilot chat completions fail with Claude models when files attached — fixed in v2.6.9 (838f1d6)
|
||||
- **close(#405)**: Duplicate of #411 — resolved
|
||||
|
||||
## [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.
|
||||
|
||||
### 🐛 Bug Fixes (PRs #374, #375, #376 by @kfiramar)
|
||||
|
||||
- **fix(logger) #376**: Restore pino transport logger path — `formatters.level` combined with `transport.targets` is rejected by pino. Transport-backed configs now strip the level formatter via `getTransportCompatibleConfig()`. Also corrects numeric level mapping in `/api/logs/console`: `30→info, 40→warn, 50→error` (was shifted by one).
|
||||
- **fix(login) #375**: Login page now bootstraps from the public `/api/settings/require-login` endpoint instead of the protected `/api/settings`. In password-protected setups, the pre-auth page was receiving a 401 and falling back to safe defaults unnecessarily. The public route now returns all bootstrap metadata (`requireLogin`, `hasPassword`, `setupComplete`) with a conservative 200 fallback on error.
|
||||
- **fix(dev) #374**: Add `localhost` and `127.0.0.1` to `allowedDevOrigins` in `next.config.mjs` — HMR websocket was blocked when accessing the app via loopback address, producing repeated cross-origin warnings.
|
||||
|
||||
### 🔧 CI & Infrastructure
|
||||
|
||||
- **ESLint OOM fix**: `eslint.config.mjs` now ignores `vscode-extension/**`, `electron/**`, `docs/**`, `app/.next/**`, and `clipr/**` — ESLint was crashing with a JS heap OOM by scanning VS Code binary blobs and compiled chunks.
|
||||
- **Unit test fix**: Removed stale `ALTER TABLE provider_connections ADD COLUMN "group"` from 2 test files — column is now part of the base schema (added in #373), causing `SQLITE_ERROR: duplicate column name` on every CI run.
|
||||
- **Pre-commit hook**: Added `npm run test:unit` to `.husky/pre-commit` — unit tests now block broken commits before they reach CI.
|
||||
|
||||
## [2.5.3] - 2026-03-14
|
||||
|
||||
> Critical bugfixes: DB schema migration, startup env loading, provider error state clearing, and i18n tooltip fix. Code quality improvements on top of each PR.
|
||||
|
||||
### 🐛 Bug Fixes (PRs #369, #371, #372, #373 by @kfiramar)
|
||||
|
||||
- **fix(db) #373**: Add `provider_connections.group` column to base schema + backfill migration for existing databases — column was used in all queries but missing from schema definition
|
||||
- **fix(i18n) #371**: Replace non-existent `t("deleteConnection")` key with existing `providers.delete` key — fixes `MISSING_MESSAGE: providers.deleteConnection` runtime error on provider detail page
|
||||
- **fix(auth) #372**: Clear stale error metadata (`errorCode`, `lastErrorType`, `lastErrorSource`) from provider accounts after genuine recovery — previously, recovered accounts kept appearing as failed
|
||||
- **fix(startup) #369**: Unify env loading across `npm run start`, `run-standalone.mjs`, and Electron to respect `DATA_DIR/.env → ~/.omniroute/.env → ./.env` priority — prevents generating a new `STORAGE_ENCRYPTION_KEY` over an existing encrypted database
|
||||
|
||||
### 🔧 Code Quality
|
||||
|
||||
- Documented `result.success` vs `response?.ok` patterns in `auth.ts` (both intentional, now explained)
|
||||
- Normalized `overridePath?.trim()` in `electron/main.js` to match `bootstrap-env.mjs`
|
||||
- Added `preferredEnv` merge order comment in Electron startup
|
||||
|
||||
> Codex account quota policy with auto-rotation, fast tier toggle, gpt-5.4 model, and analytics label fix.
|
||||
|
||||
### ✨ New Features (PRs #366, #367, #368)
|
||||
|
||||
- **Codex Quota Policy (PR #366)**: Per-account 5h/weekly quota window toggles in Provider dashboard. Accounts are automatically skipped when enabled windows reach 90% threshold and re-admitted after `resetAt`. Includes `quotaCache.ts` with side-effect free status getter.
|
||||
- **Codex Fast Tier Toggle (PR #367)**: Dashboard → Settings → Codex Service Tier. Default-off toggle injects `service_tier: "flex"` only for Codex requests, reducing cost ~80%. Full stack: UI tab + API endpoint + executor + translator + startup restore.
|
||||
- **gpt-5.4 Model (PR #368)**: Adds `cx/gpt-5.4` and `codex/gpt-5.4` to the Codex model registry. Regression test included.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix #356**: Analytics charts (Top Provider, By Account, Provider Breakdown) now display human-readable provider names/labels instead of raw internal IDs for OpenAI-compatible providers.
|
||||
|
||||
> Major release: strict-random routing strategy, API key access controls, connection groups, external pricing sync, and critical bug fixes for thinking models, combo testing, and tool name validation.
|
||||
|
||||
### ✨ New Features (PRs #363 & #365)
|
||||
|
||||
- **Strict-Random Routing Strategy**: Fisher-Yates shuffle deck with anti-repeat guarantee and mutex serialization for concurrent requests. Independent decks per combo and per provider.
|
||||
- **API Key Access Controls**: `allowedConnections` (restrict which connections a key can use), `is_active` (enable/disable key with 403), `accessSchedule` (time-based access control), `autoResolve` toggle, rename keys via PATCH.
|
||||
- **Connection Groups**: Group provider connections by environment. Accordion view in Limits page with localStorage persistence and smart auto-switch.
|
||||
- **External Pricing Sync (LiteLLM)**: 3-tier pricing resolution (user overrides → synced → defaults). Opt-in via `PRICING_SYNC_ENABLED=true`. MCP tool `omniroute_sync_pricing`. 23 new tests.
|
||||
- **i18n**: 30 languages updated with strict-random strategy, API key management strings. pt-BR fully translated.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix #355**: Stream idle timeout increased from 60s to 300s — prevents aborting extended-thinking models (claude-opus-4-6, o3, etc.) during long reasoning phases. Configurable via `STREAM_IDLE_TIMEOUT_MS`.
|
||||
- **fix #350**: Combo test now bypasses `REQUIRE_API_KEY=true` using internal header, and uses OpenAI-compatible format universally. Timeout extended from 15s to 20s.
|
||||
- **fix #346**: Tools with empty `function.name` (forwarded by Claude Code) are now filtered before upstream providers receive them, preventing "Invalid input[N].name: empty string" errors.
|
||||
|
||||
### 🗑️ Closed Issues
|
||||
|
||||
- **#341**: Debug section removed — replacement is `/dashboard/logs` and `/dashboard/health`.
|
||||
|
||||
> API Key Round-Robin support for multi-key provider setups, and confirmation of wildcard routing and quota window rolling already in place.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **API Key Round-Robin (T07)**: Provider connections can now hold multiple API keys (Edit Connection → Extra API Keys). Requests rotate round-robin between primary + extra keys via `providerSpecificData.extraApiKeys[]`. Keys are held in-memory indexed per connection — no DB schema changes required.
|
||||
|
||||
### 📝 Already Implemented (confirmed in audit)
|
||||
|
||||
- **Wildcard Model Routing (T13)**: `wildcardRouter.ts` with glob-style wildcard matching (`gpt*`, `claude-?-sonnet`, etc.) is already integrated into `model.ts` with specificity ranking.
|
||||
- **Quota Window Rolling (T08)**: `accountFallback.ts:isModelLocked()` already auto-advances the window — if `Date.now() > entry.until`, lock is deleted immediately (no stale blocking).
|
||||
|
||||
> UI polish, routing strategy additions, and graceful error handling for usage limits.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Fill-First & P2C Routing Strategies**: Added `fill-first` (drain quota before moving on) and `p2c` (Power-of-Two-Choices low-latency selection) to combo strategy picker, with full guidance panels and color-coded badges.
|
||||
- **Free Stack Preset Models**: Creating a combo with the Free Stack template now auto-fills 7 best-in-class free provider models (Gemini CLI, Kiro, iFlow×2, Qwen, NVIDIA NIM, Groq). Users just activate the providers and get a $0/month combo out-of-the-box.
|
||||
- **Wider Combo Modal**: Create/Edit combo modal now uses `max-w-4xl` for comfortable editing of large combos.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Limits page HTTP 500 for Codex & GitHub**: `getCodexUsage()` and `getGitHubUsage()` now return a user-friendly message when the provider returns 401/403 (expired token), instead of throwing and causing a 500 error on the Limits page.
|
||||
- **MaintenanceBanner false-positive**: Banner no longer shows "Server is unreachable" spuriously on page load. Fixed by calling `checkHealth()` immediately on mount and removing stale `show`-state closure.
|
||||
- **Provider icon tooltips**: Edit (pencil) and delete icon buttons in the provider connection row now have native HTML tooltips — all 6 action icons are now self-documented.
|
||||
|
||||
> Multiple improvements from community issue analysis, new provider support, bug fixes for token tracking, model routing, and streaming reliability.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Task-Aware Smart Routing (T05)**: Automatic model selection based on request content type — coding → deepseek-chat, analysis → gemini-2.5-pro, vision → gpt-4o, summarization → gemini-2.5-flash. Configurable via Settings. New `GET/PUT/POST /api/settings/task-routing` API.
|
||||
- **HuggingFace Provider**: Added HuggingFace Router as an OpenAI-compatible provider with Llama 3.1 70B/8B, Qwen 2.5 72B, Mistral 7B, Phi-3.5 Mini.
|
||||
- **Vertex AI Provider**: Added Vertex AI (Google Cloud) provider with Gemini 2.5 Pro/Flash, Gemma 2 27B, Claude via Vertex.
|
||||
- **Playground File Uploads**: Audio upload for transcription, image upload for vision models (auto-detect by model name), inline image rendering for image generation results.
|
||||
- **Model Select Visual Feedback**: Already-added models in combo picker now show ✓ green badge — prevents duplicate confusion.
|
||||
- **Qwen Compatibility (PR #352)**: Updated User-Agent and CLI fingerprint settings for Qwen provider compatibility.
|
||||
- **Round-Robin State Management (PR #349)**: Enhanced round-robin logic to handle excluded accounts and maintain rotation state correctly.
|
||||
- **Clipboard UX (PR #360)**: Hardened clipboard operations with fallback for non-secure contexts; Claude tool normalization improvements.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Fix #302 — OpenAI SDK stream=False drops tool_calls**: T01 Accept header negotiation no longer forces streaming when `body.stream` is explicitly `false`. Was causing tool_calls to be silently dropped when using the OpenAI Python SDK in non-streaming mode.
|
||||
- **Fix #73 — Claude Haiku routed to OpenAI without provider prefix**: `claude-*` models sent without a provider prefix now correctly route to the `antigravity` (Anthropic) provider. Added `gemini-*`/`gemma-*` → `gemini` heuristic as well.
|
||||
- **Fix #74 — Token counts always 0 for Antigravity/Claude streaming**: The `message_start` SSE event which carries `input_tokens` was not being parsed by `extractUsage()`, causing all input token counts to drop. Input/output token tracking now works correctly for streaming responses.
|
||||
- **Fix #180 — Model import duplicates with no feedback**: `ModelSelectModal` now shows ✓ green highlight for models already in the combo, making it obvious they're already added.
|
||||
- **Media page generation errors**: Image results now render as `<img>` tags instead of raw JSON. Transcription results shown as readable text. Credential errors show an amber banner instead of silent failure.
|
||||
- **Token refresh button on provider page**: Manual token refresh UI added for OAuth providers.
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- **Provider Registry**: HuggingFace and Vertex AI added to `providerRegistry.ts` and `providers.ts` (frontend).
|
||||
- **Read Cache**: New `src/lib/db/readCache.ts` for efficient DB read caching.
|
||||
- **Quota Cache**: Improved quota cache with TTL-based eviction.
|
||||
|
||||
### 📦 Dependencies
|
||||
|
||||
- `dompurify` → 3.3.3 (PR #347)
|
||||
- `undici` → 7.24.2 (PR #348, #361)
|
||||
- `docker/setup-qemu-action` → v4 (PR #342)
|
||||
- `docker/setup-buildx-action` → v4 (PR #343)
|
||||
|
||||
### 📁 New Files
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------------------- | --------------------------------------- |
|
||||
| `open-sse/services/taskAwareRouter.ts` | Task-aware routing logic (7 task types) |
|
||||
| `src/app/api/settings/task-routing/route.ts` | Task routing config API |
|
||||
| `src/app/api/providers/[id]/refresh/route.ts` | Manual OAuth token refresh |
|
||||
| `src/lib/db/readCache.ts` | Efficient DB read cache |
|
||||
| `src/shared/utils/clipboard.ts` | Hardened clipboard with fallback |
|
||||
|
||||
## [2.4.1] - 2026-03-13
|
||||
|
||||
### 🐛 Fix
|
||||
|
||||
- **Combos modal: Free Stack visible and prominent** — Free Stack template was hidden (4th in 3-column grid). Fixed: moved to position 1, switched to 2x2 grid so all 4 templates are visible, green border + FREE badge highlight.
|
||||
|
||||
## [2.4.0] - 2026-03-13
|
||||
|
||||
> **Major release** — Free Stack ecosystem, transcription playground overhaul, 44+ providers, comprehensive free tier documentation, and UI improvements across the board.
|
||||
@@ -32,7 +476,6 @@
|
||||
|
||||
## [2.3.14] - 2026-03-13
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **iFlow OAuth (#339)**: Restored the valid default `clientSecret` — was previously an empty string, causing "Bad client credentials" on every connect attempt. The public credential is now the default fallback (overridable via `IFLOW_OAUTH_CLIENT_SECRET` env var).
|
||||
|
||||
203
README.md
203
README.md
@@ -706,19 +706,18 @@ Outcome: deep fallback depth for deadline-critical workloads
|
||||
|
||||
> Setup AI coding in minutes at **$0/month**. Connect these free accounts and use the built-in **Free Stack** combo.
|
||||
|
||||
| Step | Action | Providers Unlocked |
|
||||
|---|---|---|
|
||||
| 1 | Connect **Kiro** (AWS Builder ID OAuth) | Claude Sonnet 4.5, Haiku 4.5 — **unlimited** |
|
||||
| 2 | Connect **iFlow** (Google OAuth) | kimi-k2-thinking, qwen3-coder-plus, deepseek-r1... — **unlimited** |
|
||||
| 3 | Connect **Qwen** (Device Code) | qwen3-coder-plus, qwen3-coder-flash... — **unlimited** |
|
||||
| 4 | Connect **Gemini CLI** (Google OAuth) | gemini-3-flash, gemini-2.5-pro — **180K/mo free** |
|
||||
| 5 | `/dashboard/combos` → **Free Stack ($0)** template | Round-robin all free providers automatically |
|
||||
| Step | Action | Providers Unlocked |
|
||||
| ---- | -------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| 1 | Connect **Kiro** (AWS Builder ID OAuth) | Claude Sonnet 4.5, Haiku 4.5 — **unlimited** |
|
||||
| 2 | Connect **iFlow** (Google OAuth) | kimi-k2-thinking, qwen3-coder-plus, deepseek-r1... — **unlimited** |
|
||||
| 3 | Connect **Qwen** (Device Code) | qwen3-coder-plus, qwen3-coder-flash... — **unlimited** |
|
||||
| 4 | Connect **Gemini CLI** (Google OAuth) | gemini-3-flash, gemini-2.5-pro — **180K/mo free** |
|
||||
| 5 | `/dashboard/combos` → **Free Stack ($0)** template | Round-robin all free providers automatically |
|
||||
|
||||
**Point any IDE/CLI to:** `http://localhost:20128/v1` · API Key: `any-string` · Done.
|
||||
|
||||
> **Optional extra coverage (also free):** Groq API key (30 RPM free), NVIDIA NIM (40 RPM free, 70+ models), Cerebras (1M tok/day).
|
||||
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
### 1) Install and run
|
||||
@@ -899,27 +898,44 @@ When minimized, OmniRoute lives in your system tray with quick actions:
|
||||
|
||||
## 💰 Pricing at a Glance
|
||||
|
||||
| Tier | Provider | Cost | Quota Reset | Best For |
|
||||
| ------------------- | ----------------- | ----------------------- | ---------------- | -------------------- |
|
||||
| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed |
|
||||
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
|
||||
| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! |
|
||||
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
|
||||
| **🔑 API KEY** | NVIDIA NIM | **FREE** (dev forever) | ~40 RPM | 70+ open models |
|
||||
| | Cerebras | **FREE** (1M tok/day) | 60K TPM / 30 RPM | World's fastest |
|
||||
| | Groq | **FREE** (30 RPM) | 14.4K RPD | Ultra-fast Llama/Gemma |
|
||||
| | DeepSeek | Pay-per-use | None | Best price/quality |
|
||||
| | xAI (Grok) | Pay-per-use | None | Grok models |
|
||||
| | Mistral | Free trial + paid | Rate limited | European AI |
|
||||
| | OpenRouter | Pay-per-use | None | 100+ models aggr. |
|
||||
| **💰 CHEAP** | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
|
||||
| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option |
|
||||
| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost |
|
||||
| **🆓 FREE** | iFlow | **$0** | Unlimited | 5 models unlimited |
|
||||
| | Qwen | **$0** | Unlimited | 4 models unlimited |
|
||||
| | Kiro | **$0** | Unlimited | Claude (AWS Builder ID) |
|
||||
| Tier | Provider | Cost | Quota Reset | Best For |
|
||||
| ------------------- | --------------------------- | ------------------------- | ---------------- | --------------------------------- |
|
||||
| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed |
|
||||
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
|
||||
| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! |
|
||||
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
|
||||
| **🔑 API KEY** | NVIDIA NIM | **FREE** (dev forever) | ~40 RPM | 70+ open models |
|
||||
| | Cerebras | **FREE** (1M tok/day) | 60K TPM / 30 RPM | World's fastest |
|
||||
| | Groq | **FREE** (30 RPM) | 14.4K RPD | Ultra-fast Llama/Gemma |
|
||||
| | DeepSeek V3.2 | $0.27/$1.10 per 1M | None | Best price/quality reasoning |
|
||||
| | xAI Grok-4 Fast | **$0.20/$0.50 per 1M** 🆕 | None | Fastest + tool calling, ultralow |
|
||||
| | xAI Grok-4 (standard) | $0.20/$1.50 per 1M 🆕 | None | Reasoning flagship from xAI |
|
||||
| | Mistral | Free trial + paid | Rate limited | European AI |
|
||||
| | OpenRouter | Pay-per-use | None | 100+ models aggr. |
|
||||
| **💰 CHEAP** | GLM-5 (via Z.AI) 🆕 | $0.5/1M | Daily 10AM | 128K output, newest flagship |
|
||||
| | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
|
||||
| | MiniMax M2.5 🆕 | $0.3/1M input | 5-hour rolling | Reasoning + agentic tasks |
|
||||
| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option |
|
||||
| | Kimi K2.5 (Moonshot API) 🆕 | Pay-per-use | None | Direct Moonshot API access |
|
||||
| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost |
|
||||
| **🆓 FREE** | iFlow | **$0** | Unlimited | 5 models unlimited |
|
||||
| | Qwen | **$0** | Unlimited | 4 models unlimited |
|
||||
| | Kiro | **$0** | Unlimited | Claude Sonnet/Haiku (AWS Builder) |
|
||||
|
||||
**💡 $0 Combo Stack:** Gemini CLI (180K/mo) → iFlow (unlimited: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1) → Kiro (Claude for free) → Qwen (4 models, unlimited) — **Zero cost, never stops coding.** When Gemini quota runs out, OmniRoute auto-falls back to iFlow or Kiro with zero config.
|
||||
> 🆕 **New models added (Mar 2026):** Grok-4 Fast family at $0.20/$0.50/M (benchmarked at 1143ms — 30% faster than Gemini 2.5 Flash), GLM-5 via Z.AI with 128K output, MiniMax M2.5 reasoning, DeepSeek V3.2 updated pricing, Kimi K2.5 via Moonshot direct API.
|
||||
|
||||
**💡 $0 Combo Stack — The Complete Free Setup:**
|
||||
|
||||
```
|
||||
Gemini CLI (180K/mo free)
|
||||
→ iFlow (unlimited: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1)
|
||||
→ Kiro (Claude Sonnet 4.5 + Haiku — unlimited, via AWS Builder ID)
|
||||
→ Qwen (4 models — unlimited)
|
||||
→ Groq (14.4K req/day — ultra-fast)
|
||||
→ NVIDIA NIM (70+ models — 40 RPM forever)
|
||||
```
|
||||
|
||||
**Zero cost. Never stops coding.** Configure this as one OmniRoute combo and all fallbacks happen automatically — no manual switching ever.
|
||||
|
||||
---
|
||||
|
||||
@@ -931,63 +947,64 @@ When minimized, OmniRoute lives in your system tray with quick actions:
|
||||
|
||||
### 🔵 CLAUDE MODELS (via Kiro — AWS Builder ID)
|
||||
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
|---|---|---|---|
|
||||
| `claude-sonnet-4.5` | `kr/` | **Unlimited** | No reported daily cap |
|
||||
| `claude-haiku-4.5` | `kr/` | **Unlimited** | No reported daily cap |
|
||||
| `claude-opus-4.6` | `kr/` | **Unlimited** | Latest Opus via Kiro |
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
| ------------------- | ------ | ------------- | --------------------- |
|
||||
| `claude-sonnet-4.5` | `kr/` | **Unlimited** | No reported daily cap |
|
||||
| `claude-haiku-4.5` | `kr/` | **Unlimited** | No reported daily cap |
|
||||
| `claude-opus-4.6` | `kr/` | **Unlimited** | Latest Opus via Kiro |
|
||||
|
||||
### 🟢 IFLOW MODELS (Free OAuth — No Credit Card)
|
||||
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
|---|---|---|---|
|
||||
| `kimi-k2-thinking` | `if/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-plus` | `if/` | **Unlimited** | No reported cap |
|
||||
| `deepseek-r1` | `if/` | **Unlimited** | No reported cap |
|
||||
| `minimax-m2.1` | `if/` | **Unlimited** | No reported cap |
|
||||
| `kimi-k2` | `if/` | **Unlimited** | No reported cap |
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
| ------------------ | ------ | ------------- | --------------- |
|
||||
| `kimi-k2-thinking` | `if/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-plus` | `if/` | **Unlimited** | No reported cap |
|
||||
| `deepseek-r1` | `if/` | **Unlimited** | No reported cap |
|
||||
| `minimax-m2.1` | `if/` | **Unlimited** | No reported cap |
|
||||
| `kimi-k2` | `if/` | **Unlimited** | No reported cap |
|
||||
|
||||
### 🟡 QWEN MODELS (Device Code Auth)
|
||||
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
|---|---|---|---|
|
||||
| `qwen3-coder-plus` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-flash` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-next` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `vision-model` | `qw/` | **Unlimited** | Multimodal (images) |
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
| ------------------- | ------ | ------------- | ------------------- |
|
||||
| `qwen3-coder-plus` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-flash` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-next` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `vision-model` | `qw/` | **Unlimited** | Multimodal (images) |
|
||||
|
||||
### 🟣 GEMINI CLI (Google OAuth)
|
||||
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
|---|---|---|---|
|
||||
| `gemini-3-flash-preview` | `gc/` | **180K tok/month** + 1K/day | Monthly reset |
|
||||
| `gemini-2.5-pro` | `gc/` | 180K/month (shared pool) | High quality |
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
| ------------------------ | ------ | --------------------------- | ------------- |
|
||||
| `gemini-3-flash-preview` | `gc/` | **180K tok/month** + 1K/day | Monthly reset |
|
||||
| `gemini-2.5-pro` | `gc/` | 180K/month (shared pool) | High quality |
|
||||
|
||||
### ⚫ NVIDIA NIM (Free API Key — build.nvidia.com)
|
||||
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
| ---------- | ------------ | ----------- | ------------------------------------------------------ |
|
||||
| Free (Dev) | No token cap | **~40 RPM** | 70+ models; transitioning to pure rate limits mid-2025 |
|
||||
|
||||
Popular free models: `moonshotai/kimi-k2.5` (Kimi K2.5), `z-ai/glm4.7` (GLM 4.7), `deepseek-ai/deepseek-v3.2` (DeepSeek V3.2), `nvidia/llama-3.3-70b-instruct`, `deepseek/deepseek-r1`
|
||||
|
||||
### ⚪ CEREBRAS (Free API Key — inference.cerebras.ai)
|
||||
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
| ---- | ----------------- | ---------------- | ------------------------------------------- |
|
||||
| Free | **1M tokens/day** | 60K TPM / 30 RPM | World's fastest LLM inference; resets daily |
|
||||
|
||||
Available free: `llama-3.3-70b`, `llama-3.1-8b`, `deepseek-r1-distill-llama-70b`
|
||||
|
||||
### 🔴 GROQ (Free API Key — console.groq.com)
|
||||
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
| ---- | ------------- | ---------------- | ----------------------------------------- |
|
||||
| Free | **14.4K RPD** | 30 RPM per model | No credit card; 429 on limit, not charged |
|
||||
|
||||
Available free: `llama-3.3-70b-versatile`, `gemma2-9b-it`, `mixtral-8x7b`, `whisper-large-v3`
|
||||
|
||||
> **💡 The Ultimate Free Stack:**
|
||||
>
|
||||
> ```
|
||||
> Kiro (Claude, unlimited)
|
||||
> → iFlow (5 models, unlimited)
|
||||
@@ -997,18 +1014,18 @@ Available free: `llama-3.3-70b-versatile`, `gemma2-9b-it`, `mixtral-8x7b`, `whis
|
||||
> → Groq (14.4K req/day)
|
||||
> → NVIDIA NIM (40 RPM, 70+ models)
|
||||
> ```
|
||||
>
|
||||
> Configure this as an OmniRoute combo and you'll never pay for AI again.
|
||||
|
||||
|
||||
## 🎙️ Free Transcription Combo
|
||||
|
||||
> Transcribe any audio/video for **$0** — Deepgram leads with $200 free, AssemblyAI $50 fallback, Groq Whisper as unlimited emergency backup.
|
||||
|
||||
| Provider | Free Credits | Best Model | Rate Limit |
|
||||
|---|---|---|---|
|
||||
| 🟢 **Deepgram** | **$200 free** (signup) | `nova-3` — best accuracy, 30+ languages | No RPM limit on free credits |
|
||||
| 🔵 **AssemblyAI** | **$50 free** (signup) | `universal-3-pro` — chapters, sentiment, PII | No RPM limit on free credits |
|
||||
| 🔴 **Groq** | **Free forever** | `whisper-large-v3` — OpenAI Whisper | 30 RPM (rate limited) |
|
||||
| Provider | Free Credits | Best Model | Rate Limit |
|
||||
| ----------------- | ---------------------- | -------------------------------------------- | ---------------------------- |
|
||||
| 🟢 **Deepgram** | **$200 free** (signup) | `nova-3` — best accuracy, 30+ languages | No RPM limit on free credits |
|
||||
| 🔵 **AssemblyAI** | **$50 free** (signup) | `universal-3-pro` — chapters, sentiment, PII | No RPM limit on free credits |
|
||||
| 🔴 **Groq** | **Free forever** | `whisper-large-v3` — OpenAI Whisper | 30 RPM (rate limited) |
|
||||
|
||||
**Suggested combo in `/dashboard/combos`:**
|
||||
|
||||
@@ -1023,12 +1040,24 @@ Nodes:
|
||||
|
||||
Then in `/dashboard/media` → **Transcription** tab: upload any audio or video file → select your combo endpoint → get transcription in supported formats.
|
||||
|
||||
|
||||
## 💡 Key Features
|
||||
|
||||
OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026)
|
||||
|
||||
| Feature | What It Does |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| ⚡ **Grok-4 Fast Family** | xAI models at $0.20/$0.50/M — benchmarked 1143ms (30% faster than Gemini 2.5 Flash) |
|
||||
| 🧠 **GLM-5 via Z.AI** | 128K output context, $0.5/1M — newest flagship from the GLM family |
|
||||
| 🔮 **MiniMax M2.5** | Reasoning + agentic tasks at $0.30/1M — significant upgrade from M2.1 |
|
||||
| 🎯 **toolCalling Flag per Model** | Per-model `toolCalling: true/false` in registry — AutoCombo skips non-tool-capable models |
|
||||
| 🌍 **Multilingual Intent Detection** | PT/ZH/ES/AR keywords in AutoCombo scoring — better model selection for non-English content |
|
||||
| 📊 **Benchmark-Driven Fallbacks** | Real p95 latency from live requests feeds combo scoring — AutoCombo learns from actual data |
|
||||
| 🔁 **Request Deduplication** | Content-hash based dedup window — multi-agent safe, prevents duplicate charges |
|
||||
| 🔌 **Pluggable RouterStrategy** | Extensible `RouterStrategy` interface — add custom routing logic as plugins |
|
||||
|
||||
### 🚀 Previous v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -1058,20 +1087,21 @@ OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
|
||||
|
||||
### 🧠 Routing & Intelligence
|
||||
|
||||
| Feature | What It Does |
|
||||
| ---------------------------------- | --------------------------------------------------------------------- |
|
||||
| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free |
|
||||
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider |
|
||||
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Responses with schema-safe conversions |
|
||||
| 👥 **Multi-Account Support** | Multiple accounts per provider with intelligent selection |
|
||||
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry |
|
||||
| 🎨 **Custom Combos** | 6 balancing strategies + fallback chain control |
|
||||
| 🌐 **Wildcard Router** | `provider/*` dynamic routing |
|
||||
| 🧠 **Thinking Budget Controls** | Passthrough, auto, custom, and adaptive reasoning limits |
|
||||
| 🔀 **Model Aliases** | Built-in + custom model aliasing and migration safety |
|
||||
| ⚡ **Background Degradation** | Route low-priority background tasks to cheaper models |
|
||||
| 💬 **System Prompt Injection** | Global behavior controls applied consistently |
|
||||
| 📄 **Responses API Compatibility** | Full `/v1/responses` support for Codex and advanced agentic workflows |
|
||||
| Feature | What It Does |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free |
|
||||
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider |
|
||||
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Responses with schema-safe conversions |
|
||||
| 👥 **Multi-Account Support** | Multiple accounts per provider with intelligent selection |
|
||||
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry |
|
||||
| 🎨 **Custom Combos** | 6 balancing strategies + fallback chain control |
|
||||
| 🌐 **Wildcard Router** | `provider/*` dynamic routing |
|
||||
| 🧠 **Thinking Budget Controls** | Passthrough, auto, custom, and adaptive reasoning limits |
|
||||
| 🔀 **Model Aliases** | Built-in + custom model aliasing and migration safety |
|
||||
| ⚡ **Background Degradation** | Route low-priority background tasks to cheaper models |
|
||||
| 🧪 **Task-Aware Smart Routing** | Auto-select model by content type (coding/vision/analysis/summarization) |
|
||||
| 💬 **System Prompt Injection** | Global behavior controls applied consistently |
|
||||
| 📄 **Responses API Compatibility** | Full `/v1/responses` support for Codex and advanced agentic workflows |
|
||||
|
||||
### 🎵 Multi-Modal APIs
|
||||
|
||||
@@ -1292,6 +1322,23 @@ Models:
|
||||
cx/gpt-5.1-codex-max
|
||||
```
|
||||
|
||||
#### Codex Account Limit Management (5h + Weekly)
|
||||
|
||||
Each Codex account now has policy toggles in `Dashboard -> Providers`:
|
||||
|
||||
- `5h` (ON/OFF): enforce the 5-hour window threshold policy.
|
||||
- `Weekly` (ON/OFF): enforce the weekly window threshold policy.
|
||||
- Threshold behavior: when an enabled window reaches >=90% usage, that account is skipped.
|
||||
- Rotation behavior: OmniRoute routes to the next eligible Codex account automatically.
|
||||
- Reset behavior: when the provider `resetAt` time passes, the account becomes eligible again automatically.
|
||||
|
||||
Scenarios:
|
||||
|
||||
- `5h ON` + `Weekly ON`: account is skipped when either window reaches threshold.
|
||||
- `5h OFF` + `Weekly ON`: only weekly usage can block the account.
|
||||
- `5h ON` + `Weekly OFF`: only 5-hour usage can block the account.
|
||||
- `resetAt` passed: account re-enters rotation automatically (no manual re-enable).
|
||||
|
||||
### Gemini CLI (FREE 180K/month!)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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.
|
||||
|
||||
46
docs/adr/0001-proxy-registry-limit-generalization.md
Normal file
46
docs/adr/0001-proxy-registry-limit-generalization.md
Normal file
@@ -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.
|
||||
32
docs/adr/0002-api-error-contract-management-endpoints.md
Normal file
32
docs/adr/0002-api-error-contract-management-endpoints.md
Normal file
@@ -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`.
|
||||
16
docs/adr/0003-security-checklist-proxy-limits.md
Normal file
16
docs/adr/0003-security-checklist-proxy-limits.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# ADR-0003: Security Checklist for Proxy Registry and Usage Controls
|
||||
|
||||
Date: 2026-03-17
|
||||
Status: Accepted
|
||||
|
||||
## Checklist
|
||||
|
||||
- Validate all management payloads with Zod.
|
||||
- Reject malformed scope assignment updates with status 400.
|
||||
- Reject deleting an in-use proxy with status 409 unless forced.
|
||||
- Never expose proxy username/password in list responses by default.
|
||||
- Never log raw credentials or token values.
|
||||
- Keep error responses free from internal stack traces.
|
||||
- Protect management endpoints with existing auth middleware policy.
|
||||
- Audit mutating operations: create/update/delete/assign/migrate.
|
||||
- Ensure resolver fallback to legacy config while migration is in transition.
|
||||
@@ -9,4 +9,13 @@ This directory contains machine-assisted translations based on the English docs.
|
||||
- **TROUBLESHOOTING.md**: 🇺🇸 [English](../TROUBLESHOOTING.md) | 🇧🇷 [Português (Brasil)](./pt-BR/TROUBLESHOOTING.md) | 🇪🇸 [Español](./es/TROUBLESHOOTING.md) | 🇫🇷 [Français](./fr/TROUBLESHOOTING.md) | 🇮🇹 [Italiano](./it/TROUBLESHOOTING.md) | 🇷🇺 [Русский](./ru/TROUBLESHOOTING.md) | 🇨🇳 [中文 (简体)](./zh-CN/TROUBLESHOOTING.md) | 🇩🇪 [Deutsch](./de/TROUBLESHOOTING.md) | 🇮🇳 [हिन्दी](./in/TROUBLESHOOTING.md) | 🇹🇭 [ไทย](./th/TROUBLESHOOTING.md) | 🇺🇦 [Українська](./uk-UA/TROUBLESHOOTING.md) | 🇸🇦 [العربية](./ar/TROUBLESHOOTING.md) | 🇯🇵 [日本語](./ja/TROUBLESHOOTING.md) | 🇻🇳 [Tiếng Việt](./vi/TROUBLESHOOTING.md) | 🇧🇬 [Български](./bg/TROUBLESHOOTING.md) | 🇩🇰 [Dansk](./da/TROUBLESHOOTING.md) | 🇫🇮 [Suomi](./fi/TROUBLESHOOTING.md) | 🇮🇱 [עברית](./he/TROUBLESHOOTING.md) | 🇭🇺 [Magyar](./hu/TROUBLESHOOTING.md) | 🇮🇩 [Bahasa Indonesia](./id/TROUBLESHOOTING.md) | 🇰🇷 [한국어](./ko/TROUBLESHOOTING.md) | 🇲🇾 [Bahasa Melayu](./ms/TROUBLESHOOTING.md) | 🇳🇱 [Nederlands](./nl/TROUBLESHOOTING.md) | 🇳🇴 [Norsk](./no/TROUBLESHOOTING.md) | 🇵🇹 [Português (Portugal)](./pt/TROUBLESHOOTING.md) | 🇷🇴 [Română](./ro/TROUBLESHOOTING.md) | 🇵🇱 [Polski](./pl/TROUBLESHOOTING.md) | 🇸🇰 [Slovenčina](./sk/TROUBLESHOOTING.md) | 🇸🇪 [Svenska](./sv/TROUBLESHOOTING.md) | 🇵🇭 [Filipino](./phi/TROUBLESHOOTING.md)
|
||||
- **USER_GUIDE.md**: 🇺🇸 [English](../USER_GUIDE.md) | 🇧🇷 [Português (Brasil)](./pt-BR/USER_GUIDE.md) | 🇪🇸 [Español](./es/USER_GUIDE.md) | 🇫🇷 [Français](./fr/USER_GUIDE.md) | 🇮🇹 [Italiano](./it/USER_GUIDE.md) | 🇷🇺 [Русский](./ru/USER_GUIDE.md) | 🇨🇳 [中文 (简体)](./zh-CN/USER_GUIDE.md) | 🇩🇪 [Deutsch](./de/USER_GUIDE.md) | 🇮🇳 [हिन्दी](./in/USER_GUIDE.md) | 🇹🇭 [ไทย](./th/USER_GUIDE.md) | 🇺🇦 [Українська](./uk-UA/USER_GUIDE.md) | 🇸🇦 [العربية](./ar/USER_GUIDE.md) | 🇯🇵 [日本語](./ja/USER_GUIDE.md) | 🇻🇳 [Tiếng Việt](./vi/USER_GUIDE.md) | 🇧🇬 [Български](./bg/USER_GUIDE.md) | 🇩🇰 [Dansk](./da/USER_GUIDE.md) | 🇫🇮 [Suomi](./fi/USER_GUIDE.md) | 🇮🇱 [עברית](./he/USER_GUIDE.md) | 🇭🇺 [Magyar](./hu/USER_GUIDE.md) | 🇮🇩 [Bahasa Indonesia](./id/USER_GUIDE.md) | 🇰🇷 [한국어](./ko/USER_GUIDE.md) | 🇲🇾 [Bahasa Melayu](./ms/USER_GUIDE.md) | 🇳🇱 [Nederlands](./nl/USER_GUIDE.md) | 🇳🇴 [Norsk](./no/USER_GUIDE.md) | 🇵🇹 [Português (Portugal)](./pt/USER_GUIDE.md) | 🇷🇴 [Română](./ro/USER_GUIDE.md) | 🇵🇱 [Polski](./pl/USER_GUIDE.md) | 🇸🇰 [Slovenčina](./sk/USER_GUIDE.md) | 🇸🇪 [Svenska](./sv/USER_GUIDE.md) | 🇵🇭 [Filipino](./phi/USER_GUIDE.md)
|
||||
|
||||
## Recent note: Codex account limit policy
|
||||
|
||||
Documentation now includes Codex account-level quota policy behavior:
|
||||
|
||||
- Per-account toggles: `5h` and `Weekly` (ON/OFF).
|
||||
- Threshold policy: enabled window reaching >=90% marks account as ineligible for selection.
|
||||
- Auto-rotation: traffic moves to the next eligible Codex account.
|
||||
- Auto-reuse: account becomes eligible again after provider `resetAt` passes.
|
||||
|
||||
Generated on 2026-02-26.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
🌐 **Languages:** 🇺🇸 [English](../../README.md) · 🇧🇷 [pt-BR](../pt-BR/FEATURES.md) · 🇪🇸 [es](../es/FEATURES.md) · 🇫🇷 [fr](../fr/FEATURES.md) · 🇩🇪 [de](../de/FEATURES.md) · 🇮🇹 [it](../it/FEATURES.md) · 🇷🇺 [ru](../ru/FEATURES.md) · 🇨🇳 [zh-CN](../zh-CN/FEATURES.md) · 🇯🇵 [ja](../ja/FEATURES.md) · 🇰🇷 [ko](../ko/FEATURES.md) · 🇸🇦 [ar](../ar/FEATURES.md) · 🇮🇳 [in](../in/FEATURES.md) · 🇹🇭 [th](../th/FEATURES.md) · 🇻🇳 [vi](../vi/FEATURES.md) · 🇮🇩 [id](../id/FEATURES.md) · 🇲🇾 [ms](../ms/FEATURES.md) · 🇳🇱 [nl](../nl/FEATURES.md) · 🇵🇱 [pl](../pl/FEATURES.md) · 🇸🇪 [sv](../sv/FEATURES.md) · 🇳🇴 [no](../no/FEATURES.md) · 🇩🇰 [da](../da/FEATURES.md) · 🇫🇮 [fi](../fi/FEATURES.md) · 🇵🇹 [pt](../pt/FEATURES.md) · 🇷🇴 [ro](../ro/FEATURES.md) · 🇭🇺 [hu](../hu/FEATURES.md) · 🇧🇬 [bg](../bg/FEATURES.md) · 🇸🇰 [sk](../sk/FEATURES.md) · 🇺🇦 [uk-UA](../uk-UA/FEATURES.md) · 🇮🇱 [he](../he/FEATURES.md) · 🇵🇭 [phi](../phi/FEATURES.md)
|
||||
# OmniRoute — Dashboard Features Gallery (العربية)
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇪🇸 [es](../es/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇩🇪 [de](../de/README.md) · 🇮🇹 [it](../it/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇸🇦 [ar](../ar/README.md) · 🇮🇳 [in](../in/README.md) · 🇹🇭 [th](../th/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇮🇩 [id](../id/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇳🇴 [no](../no/README.md) · 🇩🇰 [da](../da/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇮🇱 [he](../he/README.md) · 🇵🇭 [phi](../phi/README.md)
|
||||
|
||||
> 🇺🇸 [English](../../../docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Providers
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
|
||||
|
||||
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` (free "Light usage" tier); use `ollamacloud/<model>` prefix
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

|
||||
|
||||
@@ -144,5 +142,6 @@ Key features:
|
||||
- Single-instance lock
|
||||
- Auto-update on restart
|
||||
- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar)
|
||||
- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+)
|
||||
|
||||
📖 See [`electron/README.md`](../electron/README.md) for full documentation.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.3.6
|
||||
version: 2.7.0
|
||||
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,
|
||||
|
||||
@@ -64,6 +64,64 @@ let serverPort = 20128;
|
||||
|
||||
const getServerUrl = () => `http://localhost:${serverPort}`;
|
||||
|
||||
function resolveDataDir(overridePath, env = process.env) {
|
||||
if (overridePath && overridePath.trim()) return path.resolve(overridePath);
|
||||
|
||||
const configured = env.DATA_DIR?.trim();
|
||||
if (configured) return path.resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = env.APPDATA || path.join(require("os").homedir(), "AppData", "Roaming");
|
||||
return path.join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return path.join(path.resolve(xdg), "omniroute");
|
||||
|
||||
return path.join(require("os").homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
function getPreferredEnvFilePath(env = process.env) {
|
||||
const candidates = [];
|
||||
|
||||
if (env.DATA_DIR?.trim()) {
|
||||
candidates.push(path.join(path.resolve(env.DATA_DIR.trim()), ".env"));
|
||||
}
|
||||
|
||||
candidates.push(path.join(resolveDataDir(null, env), ".env"));
|
||||
candidates.push(path.join(process.cwd(), ".env"));
|
||||
|
||||
return candidates.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
|
||||
function hasEncryptedCredentials(dbPath) {
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR api_key LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get();
|
||||
return !!row;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-Updater Configuration ──────────────────────────────
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
@@ -386,12 +444,10 @@ function startNextServer() {
|
||||
// ── Zero-config bootstrap: auto-generate required secrets ─────────────────
|
||||
// Electron uses CJS — cannot dynamically import ESM bootstrap-env.mjs.
|
||||
// This mirrors bootstrap-env.mjs logic synchronously:
|
||||
// 1. Read persisted secrets from userData/server.env
|
||||
// 1. Read persisted secrets from the resolved DATA_DIR/server.env
|
||||
// 2. Generate missing secrets with crypto.randomBytes()
|
||||
// 3. Persist back to userData/server.env for future restarts
|
||||
// 3. Persist back to DATA_DIR/server.env for future restarts
|
||||
const crypto = require("crypto");
|
||||
const userDataDir = app.getPath("userData");
|
||||
const serverEnvPath = path.join(userDataDir, "server.env");
|
||||
|
||||
// Parse a simple KEY=VALUE file
|
||||
function parseEnvFile(filePath) {
|
||||
@@ -407,8 +463,12 @@ function startNextServer() {
|
||||
return env;
|
||||
}
|
||||
|
||||
const preferredEnvPath = getPreferredEnvFilePath(process.env);
|
||||
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
|
||||
const dataDir = resolveDataDir(null, { ...preferredEnv, ...process.env });
|
||||
const serverEnvPath = path.join(dataDir, "server.env");
|
||||
const persisted = parseEnvFile(serverEnvPath);
|
||||
const serverEnv = { ...process.env, ...persisted };
|
||||
const serverEnv = { ...persisted, ...preferredEnv, ...process.env };
|
||||
let changed = false;
|
||||
|
||||
if (!serverEnv.JWT_SECRET) {
|
||||
@@ -417,6 +477,16 @@ function startNextServer() {
|
||||
console.log("[Electron] ✨ JWT_SECRET auto-generated");
|
||||
}
|
||||
if (!serverEnv.STORAGE_ENCRYPTION_KEY) {
|
||||
if (hasEncryptedCredentials(path.join(dataDir, "storage.sqlite"))) {
|
||||
console.error(
|
||||
`[Electron] Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${path.join(
|
||||
dataDir,
|
||||
"storage.sqlite"
|
||||
)}. Restore the key via ${preferredEnvPath || "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
|
||||
);
|
||||
sendToRenderer("server-status", { status: "error", port: serverPort });
|
||||
return;
|
||||
}
|
||||
serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto
|
||||
.randomBytes(32)
|
||||
.toString("hex");
|
||||
@@ -432,7 +502,7 @@ function startNextServer() {
|
||||
if (changed) {
|
||||
serverEnv.OMNIROUTE_BOOTSTRAPPED = "true";
|
||||
try {
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const lines = [
|
||||
"# Auto-generated by OmniRoute bootstrap",
|
||||
"",
|
||||
@@ -454,6 +524,7 @@ function startNextServer() {
|
||||
cwd: NEXT_SERVER_PATH,
|
||||
env: {
|
||||
...serverEnv,
|
||||
DATA_DIR: dataDir,
|
||||
PORT: String(serverPort),
|
||||
NODE_ENV: "production",
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -24,16 +24,34 @@ const eslintConfig = [
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
},
|
||||
},
|
||||
// Global ignores (open-sse and tests REMOVED — now linted)
|
||||
// Global ignores — keep ESLint scoped to source files only
|
||||
{
|
||||
ignores: [
|
||||
// Next.js build output
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Scripts and binaries
|
||||
"scripts/**",
|
||||
"bin/**",
|
||||
// Dependencies
|
||||
"node_modules/**",
|
||||
// VS Code extension and its large test fixtures
|
||||
"vscode-extension/**",
|
||||
// Electron app
|
||||
"electron/**",
|
||||
// Docs
|
||||
"docs/**",
|
||||
// Open-SSE compiled/bundled output
|
||||
"open-sse/mcp-server/dist/**",
|
||||
// Playwright test output
|
||||
"playwright-report/**",
|
||||
"test-results/**",
|
||||
// Subdirectory .next build output (app/ subdir)
|
||||
"app/.next/**",
|
||||
// CLI package copy directory
|
||||
"clipr/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -4,11 +4,32 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
turbopack: {},
|
||||
// Turbopack config: redirect native modules to stubs at build time
|
||||
turbopack: {
|
||||
resolveAlias: {
|
||||
// Point mitm/manager to a stub during build (native child_process/fs can't be bundled)
|
||||
"@/mitm/manager": "./src/mitm/manager.stub.ts",
|
||||
},
|
||||
},
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["better-sqlite3", "zod"],
|
||||
serverExternalPackages: [
|
||||
"better-sqlite3",
|
||||
"zod",
|
||||
"child_process",
|
||||
"fs",
|
||||
"path",
|
||||
"os",
|
||||
"crypto",
|
||||
"net",
|
||||
"tls",
|
||||
"http",
|
||||
"https",
|
||||
"stream",
|
||||
"buffer",
|
||||
"util",
|
||||
],
|
||||
transpilePackages: ["@omniroute/open-sse"],
|
||||
allowedDevOrigins: ["192.168.*"],
|
||||
allowedDevOrigins: ["localhost", "127.0.0.1", "192.168.*"],
|
||||
typescript: {
|
||||
// TODO: Re-enable after fixing all sub-component useTranslations scope issues
|
||||
ignoreBuildErrors: true,
|
||||
@@ -16,19 +37,80 @@ const nextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
// NEXT_PUBLIC_CLOUD_URL is set in .env — do NOT hardcode here (it overrides .env)
|
||||
webpack: (config, { isServer }) => {
|
||||
// Ignore fs/path modules in browser bundle
|
||||
if (!isServer) {
|
||||
if (isServer) {
|
||||
// ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ────────
|
||||
//
|
||||
// Next.js 16 (with or without Turbopack) compiles the instrumentation hook
|
||||
// into a separate chunk and emits hashed require() calls such as:
|
||||
// require('better-sqlite3-90e2652d1716b047')
|
||||
// require('zod-dcb22c6336e0bc69')
|
||||
// require('pino-28069d5257187539')
|
||||
//
|
||||
// These hashed names don't exist in node_modules and cause a 500 at
|
||||
// startup on all npm global installs (issues #394, #396, #398).
|
||||
//
|
||||
// We use two strategies:
|
||||
// 1. Exact-name externals for all known server-side packages.
|
||||
// 2. Hash-strip catch-all: any require('<name>-<16hexchars>' strips the
|
||||
// suffix and falls through to the real package name.
|
||||
//
|
||||
const HASH_PATTERN = /^(.+)-[0-9a-f]{16}$/;
|
||||
|
||||
const KNOWN_EXTERNALS = new Set([
|
||||
"better-sqlite3",
|
||||
"zod",
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
"child_process",
|
||||
"fs",
|
||||
"path",
|
||||
"os",
|
||||
"crypto",
|
||||
"net",
|
||||
"tls",
|
||||
"http",
|
||||
"https",
|
||||
"stream",
|
||||
"buffer",
|
||||
"util",
|
||||
]);
|
||||
|
||||
const prev = config.externals ?? [];
|
||||
const prevArr = Array.isArray(prev) ? prev : [prev];
|
||||
config.externals = [
|
||||
...prevArr,
|
||||
({ request }, callback) => {
|
||||
// Case 1: Exact known package — treat as external
|
||||
if (KNOWN_EXTERNALS.has(request)) {
|
||||
return callback(null, `commonjs ${request}`);
|
||||
}
|
||||
// Case 2: Hash-suffixed name — strip hash, use base name
|
||||
// e.g. "better-sqlite3-90e2652d1716b047" → "better-sqlite3"
|
||||
// "zod-dcb22c6336e0bc69" → "zod"
|
||||
const hashMatch = request?.match?.(HASH_PATTERN);
|
||||
if (hashMatch) {
|
||||
const baseName = hashMatch[1];
|
||||
return callback(null, `commonjs ${baseName}`);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Ignore native Node.js modules in browser bundle
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
path: false,
|
||||
child_process: false,
|
||||
net: false,
|
||||
tls: false,
|
||||
crypto: false,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ interface AudioModel {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AudioProvider {
|
||||
export interface AudioProvider {
|
||||
id: string;
|
||||
baseUrl: string;
|
||||
authType: string;
|
||||
@@ -262,36 +262,74 @@ export function getSpeechProvider(providerId: string): AudioProvider | null {
|
||||
return AUDIO_SPEECH_PROVIDERS[providerId] || null;
|
||||
}
|
||||
|
||||
export interface ProviderNodeRow {
|
||||
prefix: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse audio model string (format: "provider/model" or just "model")
|
||||
* Build a dynamic AudioProvider from a provider_node DB entry.
|
||||
* Only used for local providers (localhost/127.0.0.1) — remote nodes are
|
||||
* excluded by the caller to prevent auth bypass and SSRF.
|
||||
*/
|
||||
export function buildDynamicAudioProvider(node: ProviderNodeRow, audioPath: string): AudioProvider {
|
||||
if (!node.prefix || !node.baseUrl) {
|
||||
throw new Error(`Invalid provider_node: missing prefix or baseUrl`);
|
||||
}
|
||||
const baseUrl = node.baseUrl.replace(/\/+$/, "");
|
||||
return {
|
||||
id: node.prefix,
|
||||
baseUrl: `${baseUrl}${audioPath}`,
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseAudioModel(
|
||||
modelStr: string | null,
|
||||
registry: Record<string, AudioProvider>
|
||||
registry: Record<string, AudioProvider>,
|
||||
dynamicProviders?: AudioProvider[]
|
||||
): { provider: string | null; model: string | null } {
|
||||
if (!modelStr) return { provider: null, model: null };
|
||||
|
||||
for (const [providerId, config] of Object.entries(registry)) {
|
||||
// Phase 1: prefix match in hardcoded registry
|
||||
for (const [providerId] of Object.entries(registry)) {
|
||||
if (modelStr.startsWith(providerId + "/")) {
|
||||
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: bare model lookup in hardcoded registry
|
||||
for (const [providerId, config] of Object.entries(registry)) {
|
||||
if (config.models.some((m) => m.id === modelStr)) {
|
||||
return { provider: providerId, model: modelStr };
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: prefix match in dynamic providers (provider_nodes)
|
||||
if (dynamicProviders) {
|
||||
for (const dp of dynamicProviders) {
|
||||
if (modelStr.startsWith(dp.id + "/")) {
|
||||
return { provider: dp.id, model: modelStr.slice(dp.id.length + 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { provider: null, model: modelStr };
|
||||
}
|
||||
|
||||
export function parseTranscriptionModel(modelStr: string | null) {
|
||||
return parseAudioModel(modelStr, AUDIO_TRANSCRIPTION_PROVIDERS);
|
||||
export function parseTranscriptionModel(
|
||||
modelStr: string | null,
|
||||
dynamicProviders?: AudioProvider[]
|
||||
) {
|
||||
return parseAudioModel(modelStr, AUDIO_TRANSCRIPTION_PROVIDERS, dynamicProviders);
|
||||
}
|
||||
|
||||
export function parseSpeechModel(modelStr: string | null) {
|
||||
return parseAudioModel(modelStr, AUDIO_SPEECH_PROVIDERS);
|
||||
export function parseSpeechModel(modelStr: string | null, dynamicProviders?: AudioProvider[]) {
|
||||
return parseAudioModel(modelStr, AUDIO_SPEECH_PROVIDERS, dynamicProviders);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -118,6 +118,58 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
|
||||
bodyFieldOrder: ["project", "model", "userAgent", "requestType", "requestId", "request"],
|
||||
userAgent: "antigravity",
|
||||
},
|
||||
qwen: {
|
||||
headerOrder: [
|
||||
"Host",
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Dashscope-AuthType",
|
||||
"X-Dashscope-CacheControl",
|
||||
"X-Dashscope-UserAgent",
|
||||
"X-Stainless-Arch",
|
||||
"X-Stainless-Lang",
|
||||
"X-Stainless-Os",
|
||||
"X-Stainless-Package-Version",
|
||||
"X-Stainless-Retry-Count",
|
||||
"X-Stainless-Runtime",
|
||||
"X-Stainless-Runtime-Version",
|
||||
"Connection",
|
||||
"Accept",
|
||||
"Accept-Language",
|
||||
"Sec-Fetch-Mode",
|
||||
"Accept-Encoding",
|
||||
],
|
||||
bodyFieldOrder: [
|
||||
"model",
|
||||
"messages",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"max_tokens",
|
||||
"stream",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"response_format",
|
||||
"n",
|
||||
"stop",
|
||||
],
|
||||
userAgent: "QwenCode/0.12.3 (linux; x64)",
|
||||
extraHeaders: {
|
||||
"X-Dashscope-AuthType": "qwen-oauth",
|
||||
"X-Dashscope-CacheControl": "enable",
|
||||
"X-Dashscope-UserAgent": "QwenCode/0.12.3 (linux; x64)",
|
||||
"X-Stainless-Arch": "x64",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Os": "Linux",
|
||||
"X-Stainless-Package-Version": "5.11.0",
|
||||
"X-Stainless-Retry-Count": "1",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v18.19.1",
|
||||
Connection: "keep-alive",
|
||||
"Accept-Language": "*",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,9 @@ import { loadProviderCredentials } from "./credentialLoader.ts";
|
||||
export const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "120000", 10);
|
||||
|
||||
// Idle timeout for SSE streams (ms). Closes stream if no data for this duration.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "60000", 10);
|
||||
// Default: 300s to support extended-thinking models (claude-opus-4-6, o3, etc.)
|
||||
// that may pause for >60s during deep reasoning phases. Override with STREAM_IDLE_TIMEOUT_MS env var.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "300000", 10);
|
||||
|
||||
// Provider configurations
|
||||
// OAuth credentials read from env vars with hardcoded fallbacks for backward compatibility.
|
||||
@@ -133,6 +135,7 @@ export const COOLDOWN_MS = {
|
||||
unauthorized: 2 * 60 * 1000, // 401 → 2 min
|
||||
paymentRequired: 2 * 60 * 1000, // 402/403 → 2 min
|
||||
notFound: 2 * 60 * 1000, // 404 → 2 minutes
|
||||
notFoundLocal: 5 * 1000, // 404 on local provider → 5s model-only lockout (connection stays active)
|
||||
transientInitial: 5 * 1000, // 408/500/502/503/504 first hit → 5s (backoff from here)
|
||||
transientMax: 60 * 1000, // 502/503/504 backoff ceiling → 60s
|
||||
transient: 5 * 1000, // Legacy alias → points to transientInitial
|
||||
@@ -160,6 +163,16 @@ export const PROVIDER_PROFILES = {
|
||||
circuitBreakerThreshold: 5, // More tolerant (occasional 502 is normal)
|
||||
circuitBreakerReset: 30000, // 30s reset
|
||||
},
|
||||
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
|
||||
// Not yet wired into getProviderProfile() — will be used when local provider_nodes
|
||||
// are integrated into the resilience layer. Kept here to avoid a second constants change.
|
||||
local: {
|
||||
transientCooldown: 2000, // 2s (local — very fast recovery)
|
||||
rateLimitCooldown: 5000, // 5s (local — no real rate limits)
|
||||
maxBackoffLevel: 3, // Low ceiling (local either works or doesn't)
|
||||
circuitBreakerThreshold: 2, // Opens fast (if local is down, it's down)
|
||||
circuitBreakerReset: 15000, // 15s reset (check again quickly)
|
||||
},
|
||||
};
|
||||
|
||||
// Default rate limit values for API Key providers (auto-enabled safety net)
|
||||
|
||||
@@ -8,7 +8,43 @@
|
||||
* keyed by provider ID (e.g. "nebius", "openai").
|
||||
*/
|
||||
|
||||
export const EMBEDDING_PROVIDERS = {
|
||||
export interface EmbeddingProvider {
|
||||
id: string;
|
||||
baseUrl: string;
|
||||
authType: string;
|
||||
authHeader: string;
|
||||
models: { id: string; name: string; dimensions?: number }[];
|
||||
}
|
||||
|
||||
export interface EmbeddingProviderNodeRow {
|
||||
prefix: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a dynamic EmbeddingProvider from a local provider_node.
|
||||
* Only used for local providers (localhost) — caller must filter by hostname.
|
||||
*/
|
||||
export function buildDynamicEmbeddingProvider(node: EmbeddingProviderNodeRow): EmbeddingProvider {
|
||||
if (!node.prefix || !node.baseUrl) {
|
||||
throw new Error(`Invalid provider_node: missing prefix or baseUrl`);
|
||||
}
|
||||
if (node.prefix.includes("/") || node.prefix.includes(" ")) {
|
||||
throw new Error(`Invalid provider_node prefix "${node.prefix}": must not contain / or spaces`);
|
||||
}
|
||||
const baseUrl = node.baseUrl.replace(/\/+$/, "");
|
||||
return {
|
||||
id: node.prefix,
|
||||
baseUrl: `${baseUrl}/embeddings`,
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
nebius: {
|
||||
id: "nebius",
|
||||
baseUrl: "https://api.tokenfactory.nebius.com/v1/embeddings",
|
||||
@@ -70,7 +106,7 @@ export const EMBEDDING_PROVIDERS = {
|
||||
/**
|
||||
* Get embedding provider config by ID
|
||||
*/
|
||||
export function getEmbeddingProvider(providerId) {
|
||||
export function getEmbeddingProvider(providerId: string): EmbeddingProvider | null {
|
||||
return EMBEDDING_PROVIDERS[providerId] || null;
|
||||
}
|
||||
|
||||
@@ -78,26 +114,36 @@ export function getEmbeddingProvider(providerId) {
|
||||
* Parse embedding model string (format: "provider/model" or just "model")
|
||||
* Returns { provider, model }
|
||||
*/
|
||||
export function parseEmbeddingModel(modelStr) {
|
||||
export function parseEmbeddingModel(
|
||||
modelStr: string | null,
|
||||
dynamicProviders?: EmbeddingProvider[]
|
||||
): { provider: string | null; model: string | null } {
|
||||
if (!modelStr) return { provider: null, model: null };
|
||||
|
||||
// Check for "provider/model" format
|
||||
const slashIdx = modelStr.indexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
// Handle nested model IDs like "nebius/Qwen/Qwen3-Embedding-8B"
|
||||
// Try each provider prefix
|
||||
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
|
||||
// Phase 1: Try each hardcoded provider prefix
|
||||
for (const [providerId] of Object.entries(EMBEDDING_PROVIDERS)) {
|
||||
if (modelStr.startsWith(providerId + "/")) {
|
||||
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
|
||||
}
|
||||
}
|
||||
// Fallback: first segment is provider
|
||||
// Phase 2: Try dynamic provider_nodes prefix
|
||||
if (dynamicProviders) {
|
||||
for (const dp of dynamicProviders) {
|
||||
if (modelStr.startsWith(dp.id + "/")) {
|
||||
return { provider: dp.id, model: modelStr.slice(dp.id.length + 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Phase 3: Fallback — first segment is provider
|
||||
const provider = modelStr.slice(0, slashIdx);
|
||||
const model = modelStr.slice(slashIdx + 1);
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
// No provider prefix — search all providers for the model
|
||||
// No provider prefix — search hardcoded providers for the model
|
||||
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
|
||||
if (config.models.some((m) => m.id === modelStr)) {
|
||||
return { provider: providerId, model: modelStr };
|
||||
|
||||
@@ -11,9 +11,23 @@
|
||||
export interface RegistryModel {
|
||||
id: string;
|
||||
name: string;
|
||||
toolCalling?: boolean;
|
||||
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;
|
||||
@@ -127,12 +141,15 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
},
|
||||
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-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" },
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 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-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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -156,12 +173,14 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
},
|
||||
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-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" },
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
|
||||
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
|
||||
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" },
|
||||
{ id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -186,6 +205,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
tokenUrl: "https://auth.openai.com/oauth/token",
|
||||
},
|
||||
models: [
|
||||
{ id: "gpt-5.4", name: "GPT 5.4" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
|
||||
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
|
||||
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
|
||||
@@ -212,8 +232,20 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
headers: {
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "gl-node/22.17.0",
|
||||
"User-Agent": "QwenCode/0.12.3 (linux; x64)",
|
||||
"X-Dashscope-AuthType": "qwen-oauth",
|
||||
"X-Dashscope-CacheControl": "enable",
|
||||
"X-Dashscope-UserAgent": "QwenCode/0.12.3 (linux; x64)",
|
||||
"X-Stainless-Arch": "x64",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Os": "Linux",
|
||||
"X-Stainless-Package-Version": "5.11.0",
|
||||
"X-Stainless-Retry-Count": "1",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v18.19.1",
|
||||
Connection: "keep-alive",
|
||||
"Accept-Language": "*",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
},
|
||||
oauth: {
|
||||
clientIdEnv: "QWEN_OAUTH_CLIENT_ID",
|
||||
@@ -292,10 +324,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" },
|
||||
],
|
||||
},
|
||||
@@ -343,8 +374,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" },
|
||||
],
|
||||
@@ -416,8 +446,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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -434,8 +467,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
},
|
||||
models: [
|
||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
|
||||
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
|
||||
{ id: "claude-sonnet-4-6-20251031", name: "Claude Sonnet 4.6 (Dated)" },
|
||||
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-opus-4-20250514", name: "Claude Opus 4" },
|
||||
{ id: "claude-opus-4-6-20251031", name: "Claude Opus 4.6 (Dated)" },
|
||||
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" },
|
||||
],
|
||||
},
|
||||
@@ -469,6 +507,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
models: [
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-5-turbo", name: "GLM 5 Turbo" },
|
||||
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
|
||||
@@ -480,6 +520,25 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
zai: {
|
||||
id: "zai",
|
||||
alias: "zai",
|
||||
format: "claude",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
urlSuffix: "?beta=true",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
headers: {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
models: [
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-5-turbo", name: "GLM 5 Turbo" },
|
||||
],
|
||||
},
|
||||
|
||||
kimi: {
|
||||
id: "kimi",
|
||||
alias: "kimi",
|
||||
@@ -611,7 +670,11 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
|
||||
models: [
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5 (Legacy Alias)" },
|
||||
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||
],
|
||||
},
|
||||
|
||||
"minimax-cn": {
|
||||
@@ -629,6 +692,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
},
|
||||
models: [
|
||||
// Keep parity with minimax to ensure model discovery works for minimax-cn connections.
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5 (Legacy Alias)" },
|
||||
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||
],
|
||||
},
|
||||
@@ -691,10 +756,14 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "grok-4", name: "Grok 4" },
|
||||
{ id: "grok-4-fast-non-reasoning", name: "Grok 4 Fast" },
|
||||
{ id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning" },
|
||||
{ id: "grok-code-fast-1", name: "Grok Code Fast" },
|
||||
{ id: "grok-4-1-fast-non-reasoning", name: "Grok 4.1 Fast" },
|
||||
{ id: "grok-4-1-fast-reasoning", name: "Grok 4.1 Fast Reasoning" },
|
||||
{ id: "grok-4-0709", name: "Grok 4 (0709)" },
|
||||
{ id: "grok-4", name: "Grok 4" },
|
||||
{ id: "grok-3", name: "Grok 3" },
|
||||
{ id: "grok-3-mini", name: "Grok 3 Mini" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -823,12 +892,17 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false },
|
||||
{ id: "openai/gpt-oss-120b", name: "GPT OSS 120B (OpenAI Prefix)", toolCalling: false },
|
||||
{ id: "meta/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "nvidia/llama-3.3-70b-instruct", name: "Llama 3.3 70B (NVIDIA Prefix)" },
|
||||
{ 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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -884,6 +958,95 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "NousResearch/Hermes-3-Llama-3.1-70B", name: "Hermes 3 70B" },
|
||||
],
|
||||
},
|
||||
|
||||
huggingface: {
|
||||
id: "huggingface",
|
||||
alias: "hf",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
// HuggingFace Inference API — OpenAI-compatible endpoint
|
||||
// Users must set their provider-specific baseUrl (model endpoint) in providerSpecificData.baseUrl
|
||||
// or use a fixed model like: https://router.huggingface.co/ngc/nvidia/llama-3_1-nemotron-51b-instruct
|
||||
baseUrl:
|
||||
"https://router.huggingface.co/hf-inference/models/meta-llama/Meta-Llama-3.1-70B-Instruct/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B Instruct" },
|
||||
{ id: "meta-llama/Meta-Llama-3.1-8B-Instruct", name: "Llama 3.1 8B Instruct" },
|
||||
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
|
||||
{ id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B v0.3" },
|
||||
{ id: "microsoft/Phi-3.5-mini-instruct", name: "Phi-3.5 Mini" },
|
||||
],
|
||||
},
|
||||
|
||||
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",
|
||||
// Vertex AI uses Google's generateContent format (same as Gemini)
|
||||
format: "gemini",
|
||||
executor: "default",
|
||||
// URL uses {project_id} and {region} from providerSpecificData — handled by custom executor or fallback
|
||||
// Default to us-central1 / generic endpoint; users configure project via providerSpecificData
|
||||
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects",
|
||||
urlBuilder: (base, model, stream) => {
|
||||
// Full URL: {base}/{project}/locations/{region}/publishers/google/models/{model}:{action}
|
||||
// For a generic fallback, we build a Gemini-compatible URL
|
||||
// The actual project/region are configured via providerSpecificData in the DB connection
|
||||
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
|
||||
return `https://generativelanguage.googleapis.com/v1beta/models/${model}:${action}`;
|
||||
},
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Vertex)" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (Vertex)" },
|
||||
{ id: "gemini-2.0-flash-thinking-exp", name: "Gemini 2.0 Flash Thinking Exp (Vertex)" },
|
||||
{ id: "gemma-2-27b-it", name: "Gemma 2 27B (Vertex)" },
|
||||
{ id: "claude-opus-4-5@20251101", name: "Claude Opus 4.5 (Vertex)" },
|
||||
{ id: "claude-sonnet-4-5@20251101", name: "Claude Sonnet 4.5 (Vertex)" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Generator Functions ───────────────────────────────────────────────────
|
||||
@@ -960,6 +1123,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>();
|
||||
@@ -979,6 +1174,43 @@ export function getRegisteredProviders(): string[] {
|
||||
return Object.keys(REGISTRY);
|
||||
}
|
||||
|
||||
// Precomputed map: modelId → unsupportedParams (O(1) lookup instead of O(N×M) scan).
|
||||
// Built once at module load from all registry entries.
|
||||
const _unsupportedParamsMap = new Map<string, readonly string[]>();
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
for (const model of entry.models) {
|
||||
if (model.unsupportedParams && !_unsupportedParamsMap.has(model.id)) {
|
||||
_unsupportedParamsMap.set(model.id, model.unsupportedParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unsupported parameters for a specific model.
|
||||
* Uses O(1) precomputed lookup. Also handles prefixed model IDs
|
||||
* (e.g., "openai/o3" → strips prefix and looks up "o3").
|
||||
* Returns empty array if no restrictions are defined.
|
||||
*/
|
||||
export function getUnsupportedParams(provider: string, modelId: string): readonly string[] {
|
||||
// 1. Check current provider's registry (exact match)
|
||||
const entry = getRegistryEntry(provider);
|
||||
const modelEntry = entry?.models.find((m) => m.id === modelId);
|
||||
if (modelEntry?.unsupportedParams) return modelEntry.unsupportedParams;
|
||||
|
||||
// 2. O(1) lookup in precomputed map (handles cross-provider routing)
|
||||
const cached = _unsupportedParamsMap.get(modelId);
|
||||
if (cached) return cached;
|
||||
|
||||
// 3. Handle prefixed model IDs (e.g., "openai/o3" → "o3")
|
||||
if (modelId.includes("/")) {
|
||||
const bareId = modelId.split("/").pop() || "";
|
||||
const bare = _unsupportedParamsMap.get(bareId);
|
||||
if (bare) return bare;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider category: "oauth" or "apikey"
|
||||
* Used by the resilience layer to apply different cooldown/backoff profiles.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
|
||||
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -23,6 +24,7 @@ export type ProviderCredentials = {
|
||||
refreshToken?: string;
|
||||
apiKey?: string;
|
||||
expiresAt?: string;
|
||||
connectionId?: string; // T07: used for API key rotation index
|
||||
providerSpecificData?: JsonRecord;
|
||||
};
|
||||
|
||||
@@ -97,11 +99,11 @@ export class BaseExecutor {
|
||||
void model;
|
||||
void stream;
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const baseUrl =
|
||||
typeof credentials?.providerSpecificData?.baseUrl === "string"
|
||||
? credentials.providerSpecificData.baseUrl
|
||||
: "https://api.openai.com/v1";
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = typeof psd?.baseUrl === "string" ? psd.baseUrl : "https://api.openai.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
if (customPath) return `${normalized}${customPath}`;
|
||||
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
||||
return `${normalized}${path}`;
|
||||
}
|
||||
@@ -131,7 +133,14 @@ export class BaseExecutor {
|
||||
if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
} else if (credentials.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
|
||||
// T07: rotate between primary + extra API keys when extraApiKeys is configured
|
||||
const extraKeys =
|
||||
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
|
||||
const effectiveKey =
|
||||
extraKeys.length > 0 && credentials.connectionId
|
||||
? getRotatingApiKey(credentials.connectionId, credentials.apiKey, extraKeys)
|
||||
: credentials.apiKey;
|
||||
headers["Authorization"] = `Bearer ${effectiveKey}`;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
|
||||
@@ -6,6 +6,20 @@ import { refreshCodexToken } from "../services/tokenRefresh.ts";
|
||||
// Ordered list of effort levels from lowest to highest
|
||||
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
|
||||
type EffortLevel = (typeof EFFORT_ORDER)[number];
|
||||
const CODEX_FAST_WIRE_VALUE = "priority";
|
||||
let defaultFastServiceTierEnabled = false;
|
||||
|
||||
function normalizeServiceTierValue(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (normalized === "fast") return CODEX_FAST_WIRE_VALUE;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function setDefaultFastServiceTierEnabled(enabled: boolean): void {
|
||||
defaultFastServiceTierEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum reasoning effort allowed per Codex model.
|
||||
@@ -92,8 +106,22 @@ export class CodexExecutor extends BaseExecutor {
|
||||
* Transform request before sending - inject default instructions if missing
|
||||
*/
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
|
||||
|
||||
// Codex /responses rejects stream=false; we aggregate SSE back to JSON when needed.
|
||||
body.stream = true;
|
||||
delete body._nativeCodexPassthrough;
|
||||
|
||||
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
|
||||
if (requestServiceTier) {
|
||||
body.service_tier = requestServiceTier;
|
||||
} else if (defaultFastServiceTierEnabled) {
|
||||
body.service_tier = CODEX_FAST_WIRE_VALUE;
|
||||
}
|
||||
|
||||
if (nativeCodexPassthrough) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// If no instructions provided, inject default Codex instructions
|
||||
if (!body.instructions || body.instructions.trim() === "") {
|
||||
|
||||
@@ -9,15 +9,20 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1";
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = psd?.baseUrl || "https://api.openai.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
if (customPath) return `${normalized}${customPath}`;
|
||||
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
||||
return `${normalized}${path}`;
|
||||
}
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1";
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
return `${normalized}/messages`;
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
return `${normalized}${customPath || "/messages"}`;
|
||||
}
|
||||
switch (this.provider) {
|
||||
case "claude":
|
||||
|
||||
@@ -24,13 +24,28 @@ import { errorResponse } from "../utils/error.ts";
|
||||
* Return a CORS error response from an upstream fetch failure
|
||||
*/
|
||||
function upstreamErrorResponse(res, errText) {
|
||||
return new Response(errText, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
// Always return JSON so the client can detect 401/credential errors reliably
|
||||
let errorMessage: string;
|
||||
try {
|
||||
const parsed = JSON.parse(errText);
|
||||
errorMessage =
|
||||
parsed?.err_msg ||
|
||||
parsed?.error?.message ||
|
||||
parsed?.error ||
|
||||
parsed?.message ||
|
||||
parsed?.detail ||
|
||||
errText;
|
||||
} catch {
|
||||
errorMessage = errText || `Upstream error (${res.status})`;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ error: { message: errorMessage, code: res.status } },
|
||||
{
|
||||
status: res.status,
|
||||
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,7 +381,12 @@ async function handleTortoiseSpeech(providerConfig, body) {
|
||||
* @returns {Response}
|
||||
*/
|
||||
/** @returns {Promise<unknown>} */
|
||||
export async function handleAudioSpeech({ body, credentials }) {
|
||||
export async function handleAudioSpeech({
|
||||
body,
|
||||
credentials,
|
||||
resolvedProvider = null,
|
||||
resolvedModel = null,
|
||||
}) {
|
||||
if (!body.model) {
|
||||
return errorResponse(400, "model is required");
|
||||
}
|
||||
@@ -374,8 +394,15 @@ export async function handleAudioSpeech({ body, credentials }) {
|
||||
return errorResponse(400, "input is required");
|
||||
}
|
||||
|
||||
const { provider: providerId, model: modelId } = parseSpeechModel(body.model);
|
||||
const providerConfig = providerId ? getSpeechProvider(providerId) : null;
|
||||
// Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes).
|
||||
// Falls back to hardcoded registry lookup for backward compatibility.
|
||||
let providerConfig = resolvedProvider;
|
||||
let modelId = resolvedModel;
|
||||
if (!providerConfig) {
|
||||
const parsed = parseSpeechModel(body.model);
|
||||
providerConfig = parsed.provider ? getSpeechProvider(parsed.provider) : null;
|
||||
modelId = parsed.model;
|
||||
}
|
||||
|
||||
if (!providerConfig) {
|
||||
return errorResponse(
|
||||
@@ -388,7 +415,7 @@ export async function handleAudioSpeech({ body, credentials }) {
|
||||
const token =
|
||||
providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken;
|
||||
if (providerConfig.authType !== "none" && !token) {
|
||||
return errorResponse(401, `No credentials for speech provider: ${providerId}`);
|
||||
return errorResponse(401, `No credentials for speech provider: ${providerConfig.id}`);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -13,7 +13,11 @@ import { getCorsOrigin } from "../utils/cors.ts";
|
||||
* - HuggingFace Inference: POST raw binary to /models/{model_id}
|
||||
*/
|
||||
|
||||
import { getTranscriptionProvider, parseTranscriptionModel } from "../config/audioRegistry.ts";
|
||||
import {
|
||||
getTranscriptionProvider,
|
||||
parseTranscriptionModel,
|
||||
type AudioProvider,
|
||||
} from "../config/audioRegistry.ts";
|
||||
import { buildAuthHeaders } from "../config/registryUtils.ts";
|
||||
import { errorResponse } from "../utils/error.ts";
|
||||
|
||||
@@ -26,13 +30,28 @@ type TranscriptionCredentials = {
|
||||
* Return a CORS error response from an upstream fetch failure
|
||||
*/
|
||||
function upstreamErrorResponse(res, errText) {
|
||||
return new Response(errText, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
// Always return JSON so the client can parse the error reliably
|
||||
let errorMessage: string;
|
||||
try {
|
||||
const parsed = JSON.parse(errText);
|
||||
errorMessage =
|
||||
parsed?.err_msg ||
|
||||
parsed?.error?.message ||
|
||||
parsed?.error ||
|
||||
parsed?.message ||
|
||||
parsed?.detail ||
|
||||
errText;
|
||||
} catch {
|
||||
errorMessage = errText || `Upstream error (${res.status})`;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ error: { message: errorMessage, code: res.status } },
|
||||
{
|
||||
status: res.status,
|
||||
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,9 +90,14 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token)
|
||||
|
||||
const data = await res.json();
|
||||
// Transform Deepgram response to OpenAI Whisper format
|
||||
const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript || "";
|
||||
const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? null;
|
||||
|
||||
return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } });
|
||||
// null means the audio had no recognizable speech (music, silence, etc.)
|
||||
// Return it explicitly so the client can distinguish from a credentials error
|
||||
return Response.json(
|
||||
{ text: text ?? "", noSpeechDetected: text === null || text === "" },
|
||||
{ headers: { "Access-Control-Allow-Origin": getCorsOrigin() } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,9 +239,13 @@ async function handleHuggingFaceTranscription(providerConfig, file, modelId, tok
|
||||
export async function handleAudioTranscription({
|
||||
formData,
|
||||
credentials,
|
||||
resolvedProvider = null,
|
||||
resolvedModel = null,
|
||||
}: {
|
||||
formData: FormData;
|
||||
credentials?: TranscriptionCredentials | null;
|
||||
resolvedProvider?: AudioProvider | null;
|
||||
resolvedModel?: string | null;
|
||||
}): Promise<Response> {
|
||||
const model = formData.get("model");
|
||||
if (typeof model !== "string" || !model) {
|
||||
@@ -230,8 +258,14 @@ export async function handleAudioTranscription({
|
||||
}
|
||||
const file = fileEntry as Blob & { name?: unknown };
|
||||
|
||||
const { provider: providerId, model: modelId } = parseTranscriptionModel(model);
|
||||
const providerConfig = providerId ? getTranscriptionProvider(providerId) : null;
|
||||
// Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes).
|
||||
let providerConfig = resolvedProvider;
|
||||
let modelId = resolvedModel;
|
||||
if (!providerConfig) {
|
||||
const parsed = parseTranscriptionModel(model);
|
||||
providerConfig = parsed.provider ? getTranscriptionProvider(parsed.provider) : null;
|
||||
modelId = parsed.model;
|
||||
}
|
||||
|
||||
if (!providerConfig) {
|
||||
return errorResponse(
|
||||
@@ -244,7 +278,7 @@ export async function handleAudioTranscription({
|
||||
const token =
|
||||
providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken;
|
||||
if (providerConfig.authType !== "none" && !token) {
|
||||
return errorResponse(401, `No credentials for transcription provider: ${providerId}`);
|
||||
return errorResponse(401, `No credentials for transcription provider: ${providerConfig.id}`);
|
||||
}
|
||||
|
||||
// Route to provider-specific handler
|
||||
|
||||
@@ -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";
|
||||
@@ -41,6 +42,23 @@ import {
|
||||
import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idempotencyLayer";
|
||||
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts";
|
||||
import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts";
|
||||
import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.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
|
||||
@@ -72,6 +90,22 @@ export async function handleChatCore({
|
||||
}) {
|
||||
const { provider, model, extendedContext } = modelInfo;
|
||||
const startTime = Date.now();
|
||||
const persistFailureUsage = (statusCode: number, errorCode?: string | null) => {
|
||||
saveRequestUsage({
|
||||
provider: provider || "unknown",
|
||||
model: model || "unknown",
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, reasoning: 0 },
|
||||
status: String(statusCode),
|
||||
success: false,
|
||||
latencyMs: Date.now() - startTime,
|
||||
timeToFirstTokenMs: 0,
|
||||
errorCode: errorCode || String(statusCode),
|
||||
timestamp: new Date().toISOString(),
|
||||
connectionId: connectionId || undefined,
|
||||
apiKeyId: apiKeyInfo?.id || undefined,
|
||||
apiKeyName: apiKeyInfo?.name || undefined,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
// ── Phase 9.2: Idempotency check ──
|
||||
const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers);
|
||||
@@ -94,9 +128,20 @@ export async function handleChatCore({
|
||||
// Initialize rate limit settings from persisted DB (once, lazy)
|
||||
await initializeRateLimits();
|
||||
|
||||
// T07: Inject connectionId into credentials so executors can rotate API keys
|
||||
// using providerSpecificData.extraApiKeys (API Key Round-Robin feature)
|
||||
if (connectionId && credentials && !credentials.connectionId) {
|
||||
credentials.connectionId = connectionId;
|
||||
}
|
||||
|
||||
const sourceFormat = detectFormat(body);
|
||||
const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase();
|
||||
const isResponsesEndpoint = endpointPath.endsWith("/responses");
|
||||
const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({
|
||||
provider,
|
||||
sourceFormat,
|
||||
endpointPath,
|
||||
});
|
||||
|
||||
// Check for bypass patterns (warmup, skip) - return fake response
|
||||
const bypassResponse = handleBypassRequest(body, model, userAgent);
|
||||
@@ -157,46 +202,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;
|
||||
});
|
||||
}
|
||||
// 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 =
|
||||
@@ -243,9 +363,75 @@ 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);
|
||||
|
||||
// Create stream controller for disconnect detection
|
||||
const streamController = createStreamController({ onDisconnect, log, provider, model });
|
||||
|
||||
const dedupRequestBody = { ...translatedBody, model: `${provider}/${model}` };
|
||||
const dedupEnabled = shouldDeduplicate(dedupRequestBody);
|
||||
const dedupHash = dedupEnabled ? computeRequestHash(dedupRequestBody) : null;
|
||||
|
||||
const executeProviderRequest = async (modelToCall = model, allowDedup = false) => {
|
||||
const execute = async () => {
|
||||
const bodyToSend =
|
||||
translatedBody.model === modelToCall
|
||||
? translatedBody
|
||||
: { ...translatedBody, model: modelToCall };
|
||||
|
||||
const rawResult = await withRateLimit(provider, connectionId, modelToCall, () =>
|
||||
executor.execute({
|
||||
model: modelToCall,
|
||||
body: bodyToSend,
|
||||
stream,
|
||||
credentials,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
})
|
||||
);
|
||||
|
||||
if (stream) return rawResult;
|
||||
|
||||
// Non-stream responses need cloning for shared dedup consumers.
|
||||
const status = rawResult.response.status;
|
||||
const statusText = rawResult.response.statusText;
|
||||
const headers = Array.from(rawResult.response.headers.entries());
|
||||
const payload = await rawResult.response.text();
|
||||
|
||||
return {
|
||||
...rawResult,
|
||||
response: new Response(payload, { status, statusText, headers }),
|
||||
};
|
||||
};
|
||||
|
||||
if (allowDedup && dedupEnabled && dedupHash) {
|
||||
const dedupResult = await deduplicate(dedupHash, execute);
|
||||
if (dedupResult.wasDeduplicated) {
|
||||
log?.debug?.("DEDUP", `Joined in-flight request hash=${dedupHash}`);
|
||||
}
|
||||
return dedupResult.result;
|
||||
}
|
||||
|
||||
return execute();
|
||||
};
|
||||
|
||||
// Track pending request
|
||||
trackPendingRequest(model, provider, connectionId, true);
|
||||
|
||||
@@ -263,9 +449,6 @@ export async function handleChatCore({
|
||||
0;
|
||||
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
|
||||
|
||||
// Create stream controller for disconnect detection
|
||||
const streamController = createStreamController({ onDisconnect, log, provider, model });
|
||||
|
||||
// Execute request using executor (handles URL building, headers, fallback, transform)
|
||||
let providerResponse;
|
||||
let providerUrl;
|
||||
@@ -273,17 +456,7 @@ export async function handleChatCore({
|
||||
let finalBody;
|
||||
|
||||
try {
|
||||
const result = await withRateLimit(provider, connectionId, model, () =>
|
||||
executor.execute({
|
||||
model,
|
||||
body: translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
})
|
||||
);
|
||||
const result = await executeProviderRequest(model, true);
|
||||
|
||||
providerResponse = result.response;
|
||||
providerUrl = result.url;
|
||||
@@ -330,6 +503,7 @@ export async function handleChatCore({
|
||||
streamController.handleError(error);
|
||||
return createErrorResult(499, "Request aborted");
|
||||
}
|
||||
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, error?.name || "upstream_error");
|
||||
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
|
||||
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
|
||||
@@ -439,17 +613,7 @@ export async function handleChatCore({
|
||||
log?.info?.("MODEL_FALLBACK", `${model} unavailable (${statusCode}) → trying ${nextModel}`);
|
||||
// Re-execute with the fallback model
|
||||
try {
|
||||
const fallbackResult = await withRateLimit(provider, connectionId, nextModel, () =>
|
||||
executor.execute({
|
||||
model: nextModel,
|
||||
body: translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
})
|
||||
);
|
||||
const fallbackResult = await executeProviderRequest(nextModel, false);
|
||||
if (fallbackResult.response.ok) {
|
||||
providerResponse = fallbackResult.response;
|
||||
providerUrl = fallbackResult.url;
|
||||
@@ -461,15 +625,19 @@ export async function handleChatCore({
|
||||
// We fall through by NOT returning here
|
||||
} else {
|
||||
// Fallback also failed — return original error
|
||||
persistFailureUsage(statusCode, "model_unavailable");
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
}
|
||||
} catch {
|
||||
persistFailureUsage(statusCode, "model_unavailable");
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
}
|
||||
} else {
|
||||
persistFailureUsage(statusCode, "model_unavailable");
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
}
|
||||
} else {
|
||||
persistFailureUsage(statusCode, `upstream_${statusCode}`);
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
}
|
||||
// ── End T5 ───────────────────────────────────────────────────────────────
|
||||
@@ -498,6 +666,7 @@ export async function handleChatCore({
|
||||
connectionId,
|
||||
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
||||
}).catch(() => {});
|
||||
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_sse_payload");
|
||||
return createErrorResult(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
"Invalid SSE response for non-streaming request"
|
||||
@@ -515,6 +684,7 @@ export async function handleChatCore({
|
||||
connectionId,
|
||||
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
||||
}).catch(() => {});
|
||||
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_json_payload");
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid JSON response from provider");
|
||||
}
|
||||
}
|
||||
@@ -557,6 +727,11 @@ export async function handleChatCore({
|
||||
provider: provider || "unknown",
|
||||
model: model || "unknown",
|
||||
tokens: usage,
|
||||
status: "200",
|
||||
success: true,
|
||||
latencyMs: Date.now() - startTime,
|
||||
timeToFirstTokenMs: Date.now() - startTime,
|
||||
errorCode: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
connectionId: connectionId || undefined,
|
||||
apiKeyId: apiKeyInfo?.id || undefined,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
59
open-sse/mcp-server/__tests__/pricingSync.test.ts
Normal file
59
open-sse/mcp-server/__tests__/pricingSync.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { syncPricingInput, syncPricingTool, MCP_TOOLS, MCP_TOOL_MAP } from "../schemas/tools.ts";
|
||||
|
||||
describe("omniroute_sync_pricing MCP tool schema", () => {
|
||||
it("should be registered in MCP_TOOLS", () => {
|
||||
const tool = MCP_TOOLS.find((t) => t.name === "omniroute_sync_pricing");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.phase).toBe(2);
|
||||
});
|
||||
|
||||
it("should be in MCP_TOOL_MAP", () => {
|
||||
expect(MCP_TOOL_MAP["omniroute_sync_pricing"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should require pricing:write scope", () => {
|
||||
expect(syncPricingTool.scopes).toContain("pricing:write");
|
||||
});
|
||||
|
||||
it("should have full audit level", () => {
|
||||
expect(syncPricingTool.auditLevel).toBe("full");
|
||||
});
|
||||
|
||||
it("should validate empty input (all fields optional)", () => {
|
||||
const result = syncPricingInput.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate input with sources array", () => {
|
||||
const result = syncPricingInput.safeParse({ sources: ["litellm"] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate input with dryRun", () => {
|
||||
const result = syncPricingInput.safeParse({ dryRun: true });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate full input", () => {
|
||||
const result = syncPricingInput.safeParse({
|
||||
sources: ["litellm"],
|
||||
dryRun: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid sources type", () => {
|
||||
const result = syncPricingInput.safeParse({ sources: "litellm" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid dryRun type", () => {
|
||||
const result = syncPricingInput.safeParse({ dryRun: "yes" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should point to correct source endpoint", () => {
|
||||
expect(syncPricingTool.sourceEndpoints).toContain("/api/pricing/sync");
|
||||
});
|
||||
});
|
||||
48
open-sse/mcp-server/__tests__/routingStrategyTool.test.ts
Normal file
48
open-sse/mcp-server/__tests__/routingStrategyTool.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
MCP_TOOLS,
|
||||
MCP_TOOL_MAP,
|
||||
setRoutingStrategyInput,
|
||||
setRoutingStrategyTool,
|
||||
} from "../schemas/tools.ts";
|
||||
|
||||
describe("omniroute_set_routing_strategy MCP tool schema", () => {
|
||||
it("should be registered in MCP_TOOLS", () => {
|
||||
const tool = MCP_TOOLS.find((t) => t.name === "omniroute_set_routing_strategy");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.phase).toBe(2);
|
||||
});
|
||||
|
||||
it("should be available in MCP_TOOL_MAP", () => {
|
||||
expect(MCP_TOOL_MAP["omniroute_set_routing_strategy"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should require write:combos scope", () => {
|
||||
expect(setRoutingStrategyTool.scopes).toContain("write:combos");
|
||||
});
|
||||
|
||||
it("should validate a standard strategy payload", () => {
|
||||
const result = setRoutingStrategyInput.safeParse({
|
||||
comboId: "my-combo",
|
||||
strategy: "cost-optimized",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate auto strategy with autoRoutingStrategy", () => {
|
||||
const result = setRoutingStrategyInput.safeParse({
|
||||
comboId: "my-combo",
|
||||
strategy: "auto",
|
||||
autoRoutingStrategy: "latency",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject unknown strategy", () => {
|
||||
const result = setRoutingStrategyInput.safeParse({
|
||||
comboId: "my-combo",
|
||||
strategy: "unknown-strategy",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,7 @@ export const listCombosOutput = z.object({
|
||||
"priority",
|
||||
"weighted",
|
||||
"round-robin",
|
||||
"strict-random",
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
@@ -470,7 +471,53 @@ export const setBudgetGuardTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/usage/budget"],
|
||||
};
|
||||
|
||||
// --- Tool 11: omniroute_set_resilience_profile ---
|
||||
// --- Tool 11: omniroute_set_routing_strategy ---
|
||||
export const setRoutingStrategyInput = z.object({
|
||||
comboId: z.string().describe("Combo ID or name to update"),
|
||||
strategy: z
|
||||
.enum([
|
||||
"priority",
|
||||
"weighted",
|
||||
"round-robin",
|
||||
"strict-random",
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"auto",
|
||||
])
|
||||
.describe("Routing strategy to apply"),
|
||||
autoRoutingStrategy: z
|
||||
.enum(["rules", "cost", "eco", "latency", "fast"])
|
||||
.optional()
|
||||
.describe("Optional strategy used by auto mode (only used when strategy='auto')"),
|
||||
});
|
||||
|
||||
export const setRoutingStrategyOutput = z.object({
|
||||
success: z.boolean(),
|
||||
combo: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
strategy: z.string(),
|
||||
autoRoutingStrategy: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const setRoutingStrategyTool: McpToolDefinition<
|
||||
typeof setRoutingStrategyInput,
|
||||
typeof setRoutingStrategyOutput
|
||||
> = {
|
||||
name: "omniroute_set_routing_strategy",
|
||||
description:
|
||||
"Updates a combo routing strategy (priority/weighted/auto/etc.) at runtime. Supports selecting the sub-strategy used by auto mode (rules/cost/latency).",
|
||||
inputSchema: setRoutingStrategyInput,
|
||||
outputSchema: setRoutingStrategyOutput,
|
||||
scopes: ["write:combos"],
|
||||
auditLevel: "full",
|
||||
phase: 2,
|
||||
sourceEndpoints: ["/api/combos", "/api/combos/{id}"],
|
||||
};
|
||||
|
||||
// --- Tool 12: omniroute_set_resilience_profile ---
|
||||
export const setResilienceProfileInput = z.object({
|
||||
profile: z
|
||||
.enum(["aggressive", "balanced", "conservative"])
|
||||
@@ -502,7 +549,7 @@ export const setResilienceProfileTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/resilience"],
|
||||
};
|
||||
|
||||
// --- Tool 12: omniroute_test_combo ---
|
||||
// --- Tool 13: omniroute_test_combo ---
|
||||
export const testComboInput = z.object({
|
||||
comboId: z.string().describe("ID of the combo to test"),
|
||||
testPrompt: z.string().max(500).describe("Short test prompt (max 500 chars)"),
|
||||
@@ -540,7 +587,7 @@ export const testComboTool: McpToolDefinition<typeof testComboInput, typeof test
|
||||
sourceEndpoints: ["/api/combos/test", "/v1/chat/completions"],
|
||||
};
|
||||
|
||||
// --- Tool 13: omniroute_get_provider_metrics ---
|
||||
// --- Tool 14: omniroute_get_provider_metrics ---
|
||||
export const getProviderMetricsInput = z.object({
|
||||
provider: z.string().describe("Provider name (e.g., 'claude', 'gemini-cli', 'codex')"),
|
||||
});
|
||||
@@ -583,7 +630,7 @@ export const getProviderMetricsTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/provider-metrics", "/api/resilience"],
|
||||
};
|
||||
|
||||
// --- Tool 14: omniroute_best_combo_for_task ---
|
||||
// --- Tool 15: omniroute_best_combo_for_task ---
|
||||
export const bestComboForTaskInput = z.object({
|
||||
taskType: z
|
||||
.enum(["coding", "review", "planning", "analysis", "debugging", "documentation"])
|
||||
@@ -628,7 +675,7 @@ export const bestComboForTaskTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/combos", "/api/combos/metrics", "/api/monitoring/health"],
|
||||
};
|
||||
|
||||
// --- Tool 15: omniroute_explain_route ---
|
||||
// --- Tool 16: omniroute_explain_route ---
|
||||
export const explainRouteInput = z.object({
|
||||
requestId: z.string().describe("Request ID from the X-Request-Id header"),
|
||||
});
|
||||
@@ -674,7 +721,7 @@ export const explainRouteTool: McpToolDefinition<
|
||||
sourceEndpoints: [],
|
||||
};
|
||||
|
||||
// --- Tool 16: omniroute_get_session_snapshot ---
|
||||
// --- Tool 17: omniroute_get_session_snapshot ---
|
||||
export const getSessionSnapshotInput = z.object({}).describe("No parameters required");
|
||||
|
||||
export const getSessionSnapshotOutput = z.object({
|
||||
@@ -723,6 +770,42 @@ export const getSessionSnapshotTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"],
|
||||
};
|
||||
|
||||
// --- Tool 18: omniroute_sync_pricing ---
|
||||
export const syncPricingInput = z.object({
|
||||
sources: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("External pricing sources to sync from (default: ['litellm'])"),
|
||||
dryRun: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("If true, preview sync results without saving to database"),
|
||||
});
|
||||
|
||||
export const syncPricingOutput = z.object({
|
||||
success: z.boolean(),
|
||||
modelCount: z.number(),
|
||||
providerCount: z.number(),
|
||||
source: z.string(),
|
||||
dryRun: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
warnings: z.array(z.string()).optional(),
|
||||
data: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
||||
});
|
||||
|
||||
export const syncPricingTool: McpToolDefinition<typeof syncPricingInput, typeof syncPricingOutput> =
|
||||
{
|
||||
name: "omniroute_sync_pricing",
|
||||
description:
|
||||
"Syncs pricing data from external sources (LiteLLM) into OmniRoute. Synced pricing fills gaps not covered by hardcoded defaults without overwriting user-set prices. Use dryRun=true to preview.",
|
||||
inputSchema: syncPricingInput,
|
||||
outputSchema: syncPricingOutput,
|
||||
scopes: ["pricing:write"],
|
||||
auditLevel: "full",
|
||||
phase: 2,
|
||||
sourceEndpoints: ["/api/pricing/sync"],
|
||||
};
|
||||
|
||||
// ============ Tool Registry ============
|
||||
|
||||
/** All MCP tool definitions, ordered by phase then name */
|
||||
@@ -739,12 +822,14 @@ export const MCP_TOOLS = [
|
||||
// Phase 2: Advanced
|
||||
simulateRouteTool,
|
||||
setBudgetGuardTool,
|
||||
setRoutingStrategyTool,
|
||||
setResilienceProfileTool,
|
||||
testComboTool,
|
||||
getProviderMetricsTool,
|
||||
bestComboForTaskTool,
|
||||
explainRouteTool,
|
||||
getSessionSnapshotTool,
|
||||
syncPricingTool,
|
||||
] as const;
|
||||
|
||||
/** Essential tools only (Phase 1) */
|
||||
|
||||
@@ -25,12 +25,14 @@ import {
|
||||
listModelsCatalogInput,
|
||||
simulateRouteInput,
|
||||
setBudgetGuardInput,
|
||||
setRoutingStrategyInput,
|
||||
setResilienceProfileInput,
|
||||
testComboInput,
|
||||
getProviderMetricsInput,
|
||||
bestComboForTaskInput,
|
||||
explainRouteInput,
|
||||
getSessionSnapshotInput,
|
||||
syncPricingInput,
|
||||
} from "./schemas/tools.ts";
|
||||
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
|
||||
|
||||
@@ -44,12 +46,14 @@ import {
|
||||
import {
|
||||
handleSimulateRoute,
|
||||
handleSetBudgetGuard,
|
||||
handleSetRoutingStrategy,
|
||||
handleSetResilienceProfile,
|
||||
handleTestCombo,
|
||||
handleGetProviderMetrics,
|
||||
handleBestComboForTask,
|
||||
handleExplainRoute,
|
||||
handleGetSessionSnapshot,
|
||||
handleSyncPricing,
|
||||
} from "./tools/advancedTools.ts";
|
||||
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
|
||||
|
||||
@@ -591,6 +595,18 @@ export function createMcpServer(): McpServer {
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_set_routing_strategy",
|
||||
{
|
||||
description:
|
||||
"Updates combo routing strategy at runtime (priority/weighted/round-robin/auto/etc.)",
|
||||
inputSchema: setRoutingStrategyInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_set_routing_strategy", (args) =>
|
||||
handleSetRoutingStrategy(setRoutingStrategyInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_set_resilience_profile",
|
||||
{
|
||||
@@ -664,6 +680,18 @@ export function createMcpServer(): McpServer {
|
||||
})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_sync_pricing",
|
||||
{
|
||||
description:
|
||||
"Syncs pricing data from external sources (LiteLLM) into OmniRoute without overwriting user-set prices",
|
||||
inputSchema: syncPricingInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_sync_pricing", (args) =>
|
||||
handleSyncPricing(syncPricingInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* OmniRoute MCP Advanced Tools — 8 intelligence tools that differentiate
|
||||
* OmniRoute MCP Advanced Tools — 10 intelligence tools that differentiate
|
||||
* OmniRoute from all other AI gateways.
|
||||
*
|
||||
* Tools:
|
||||
* 1. omniroute_simulate_route — Dry-run routing simulation
|
||||
* 2. omniroute_set_budget_guard — Session budget with degrade/block/alert
|
||||
* 3. omniroute_set_resilience_profile — Circuit breaker/retry profiles
|
||||
* 4. omniroute_test_combo — Live test each provider in a combo
|
||||
* 5. omniroute_get_provider_metrics — Detailed per-provider metrics
|
||||
* 6. omniroute_best_combo_for_task — AI-powered combo recommendation
|
||||
* 7. omniroute_explain_route — Post-hoc routing decision explainer
|
||||
* 8. omniroute_get_session_snapshot — Full session state snapshot
|
||||
* 3. omniroute_set_routing_strategy — Runtime strategy switch for combos
|
||||
* 4. omniroute_set_resilience_profile — Circuit breaker/retry profiles
|
||||
* 5. omniroute_test_combo — Live test each provider in a combo
|
||||
* 6. omniroute_get_provider_metrics — Detailed per-provider metrics
|
||||
* 7. omniroute_best_combo_for_task — AI-powered combo recommendation
|
||||
* 8. omniroute_explain_route — Post-hoc routing decision explainer
|
||||
* 9. omniroute_get_session_snapshot — Full session state snapshot
|
||||
* 10. omniroute_sync_pricing — Sync provider pricing from external source
|
||||
*/
|
||||
|
||||
import { logToolCall } from "../audit.ts";
|
||||
@@ -335,6 +337,108 @@ export async function handleSetBudgetGuard(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetRoutingStrategy(args: {
|
||||
comboId: string;
|
||||
strategy:
|
||||
| "priority"
|
||||
| "weighted"
|
||||
| "round-robin"
|
||||
| "strict-random"
|
||||
| "random"
|
||||
| "least-used"
|
||||
| "cost-optimized"
|
||||
| "auto";
|
||||
autoRoutingStrategy?: "rules" | "cost" | "eco" | "latency" | "fast";
|
||||
}) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const combos = normalizeCombosResponse(await apiFetch("/api/combos"));
|
||||
const combo = combos.find(
|
||||
(comboEntry) =>
|
||||
toString(comboEntry.id) === args.comboId || toString(comboEntry.name) === args.comboId
|
||||
);
|
||||
|
||||
if (!combo) {
|
||||
const msg = `Combo '${args.comboId}' not found`;
|
||||
await logToolCall(
|
||||
"omniroute_set_routing_strategy",
|
||||
args,
|
||||
null,
|
||||
Date.now() - start,
|
||||
false,
|
||||
msg
|
||||
);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
|
||||
const comboId = toString(combo.id);
|
||||
if (!comboId) {
|
||||
const msg = "Matched combo has no id";
|
||||
await logToolCall(
|
||||
"omniroute_set_routing_strategy",
|
||||
args,
|
||||
null,
|
||||
Date.now() - start,
|
||||
false,
|
||||
msg
|
||||
);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
|
||||
const comboData = toRecord(combo.data);
|
||||
const currentConfig = toRecord(
|
||||
Object.keys(toRecord(combo.config)).length > 0 ? combo.config : comboData.config
|
||||
);
|
||||
|
||||
let nextConfig: JsonRecord | undefined = undefined;
|
||||
if (args.strategy === "auto" && args.autoRoutingStrategy) {
|
||||
const currentAutoConfig = toRecord(currentConfig.auto);
|
||||
nextConfig = {
|
||||
...currentConfig,
|
||||
auto: {
|
||||
...currentAutoConfig,
|
||||
routingStrategy: args.autoRoutingStrategy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const payload: JsonRecord = { strategy: args.strategy };
|
||||
if (nextConfig && Object.keys(nextConfig).length > 0) {
|
||||
payload.config = nextConfig;
|
||||
}
|
||||
|
||||
const updatedCombo = toRecord(
|
||||
await apiFetch(`/api/combos/${encodeURIComponent(comboId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
);
|
||||
|
||||
const updatedConfig = toRecord(updatedCombo.config);
|
||||
const resolvedAutoStrategy =
|
||||
toString(toRecord(updatedConfig.auto).routingStrategy) ||
|
||||
(args.strategy === "auto" ? (args.autoRoutingStrategy ?? "rules") : "");
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
combo: {
|
||||
id: toString(updatedCombo.id, comboId),
|
||||
name: toString(updatedCombo.name, toString(combo.name, comboId)),
|
||||
strategy: toString(updatedCombo.strategy, args.strategy),
|
||||
autoRoutingStrategy:
|
||||
toString(updatedCombo.strategy, args.strategy) === "auto" ? resolvedAutoStrategy : null,
|
||||
},
|
||||
};
|
||||
|
||||
await logToolCall("omniroute_set_routing_strategy", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await logToolCall("omniroute_set_routing_strategy", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetResilienceProfile(args: {
|
||||
profile: "aggressive" | "balanced" | "conservative";
|
||||
}) {
|
||||
@@ -678,6 +782,28 @@ export async function handleExplainRoute(args: { requestId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSyncPricing(args: { sources?: string[]; dryRun?: boolean }) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = toRecord(
|
||||
await apiFetch("/api/pricing/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sources: args.sources,
|
||||
dryRun: args.dryRun ?? false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await logToolCall("omniroute_sync_pricing", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await logToolCall("omniroute_sync_pricing", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetSessionSnapshot() {
|
||||
const start = Date.now();
|
||||
try {
|
||||
|
||||
63
open-sse/services/apiKeyRotator.ts
Normal file
63
open-sse/services/apiKeyRotator.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* apiKeyRotator.ts — T07: API Key Round-Robin
|
||||
*
|
||||
* Rotates between a primary API key and extra API keys stored in
|
||||
* providerSpecificData.extraApiKeys[]. Uses round-robin by default.
|
||||
*
|
||||
* Extra keys are stored as plain strings in providerSpecificData.extraApiKeys.
|
||||
* Example: { extraApiKeys: ["sk-abc...", "sk-def...", "sk-ghi..."] }
|
||||
*
|
||||
* The in-memory rotation index resets on process restart, which is intentional —
|
||||
* it ensures even distribution across restarts without persistence overhead.
|
||||
*/
|
||||
|
||||
// In-memory round-robin index per connection
|
||||
const _keyIndexes = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Get the next API key in round-robin rotation for a given connection.
|
||||
* If no extra keys are configured, returns the primary key unchanged.
|
||||
*
|
||||
* @param connectionId - Unique connection identifier (for index isolation)
|
||||
* @param primaryKey - The main api_key from the connection
|
||||
* @param extraKeys - Additional API keys from providerSpecificData.extraApiKeys
|
||||
* @returns The selected API key (may be primary or one of the extras)
|
||||
*/
|
||||
export function getRotatingApiKey(
|
||||
connectionId: string,
|
||||
primaryKey: string,
|
||||
extraKeys: string[] = []
|
||||
): string {
|
||||
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
||||
|
||||
// Only 1 key available → no rotation needed
|
||||
if (validExtras.length === 0) return primaryKey;
|
||||
|
||||
const allKeys = [primaryKey, ...validExtras].filter(Boolean);
|
||||
if (allKeys.length <= 1) return primaryKey;
|
||||
|
||||
const current = _keyIndexes.get(connectionId) ?? 0;
|
||||
const idx = current % allKeys.length;
|
||||
_keyIndexes.set(connectionId, current + 1);
|
||||
|
||||
return allKeys[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the rotation index for a connection.
|
||||
* Call this when a key fails (401/403) to skip the bad key next time.
|
||||
*
|
||||
* @param connectionId - Connection to reset
|
||||
*/
|
||||
export function resetRotationIndex(connectionId: string): void {
|
||||
_keyIndexes.delete(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of API keys available for a connection.
|
||||
* Used for logging/observability.
|
||||
*/
|
||||
export function getApiKeyCount(primaryKey: string, extraKeys: string[] = []): number {
|
||||
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
||||
return (primaryKey ? 1 : 0) + validExtras.length;
|
||||
}
|
||||
159
open-sse/services/autoCombo/routerStrategy.ts
Normal file
159
open-sse/services/autoCombo/routerStrategy.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* RouterStrategy — Pluggable Routing Strategy System
|
||||
*
|
||||
* Inspired by ClawRouter commit 14c83c258 "refactor: extract routing into pluggable RouterStrategy system".
|
||||
* Provides a RouterStrategy interface and two built-in implementations:
|
||||
* - RulesStrategy (default): wraps the existing 6-factor scoring engine
|
||||
* - CostStrategy: always picks cheapest available model
|
||||
*/
|
||||
|
||||
import type { ProviderCandidate, ScoredProvider } from "./scoring.js";
|
||||
import { scorePool } from "./scoring.js";
|
||||
import { getTaskFitness } from "./taskFitness.js";
|
||||
|
||||
export interface RoutingContext {
|
||||
taskType: string;
|
||||
requestHasTools?: boolean;
|
||||
requestHasVision?: boolean;
|
||||
estimatedInputTokens?: number;
|
||||
}
|
||||
|
||||
export interface RoutingDecision {
|
||||
provider: string;
|
||||
model: string;
|
||||
strategy: string;
|
||||
reason: string;
|
||||
candidatesConsidered: number;
|
||||
finalScore: number;
|
||||
}
|
||||
|
||||
export interface RouterStrategy {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision;
|
||||
}
|
||||
|
||||
// ── RulesStrategy: wraps 6-factor scoring engine ────────────────────────────
|
||||
|
||||
class RulesStrategyImpl implements RouterStrategy {
|
||||
readonly name = "rules";
|
||||
readonly description =
|
||||
"6-factor weighted scoring: quota, health, cost, latency, taskFit, stability";
|
||||
|
||||
select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision {
|
||||
const eligible = pool.filter((c) => c.circuitBreakerState !== "OPEN");
|
||||
const ranked: ScoredProvider[] = scorePool(
|
||||
eligible.length > 0 ? eligible : pool,
|
||||
context.taskType,
|
||||
undefined,
|
||||
getTaskFitness
|
||||
);
|
||||
const best = ranked[0];
|
||||
if (!best) throw new Error("[RulesStrategy] No candidates to score");
|
||||
return {
|
||||
provider: best.provider,
|
||||
model: best.model,
|
||||
strategy: this.name,
|
||||
reason: `RulesStrategy: score=${best.score.toFixed(3)} (quota=${best.factors.quota.toFixed(2)}, health=${best.factors.health.toFixed(2)}, cost=${best.factors.costInv.toFixed(2)}, taskFit=${best.factors.taskFit.toFixed(2)})`,
|
||||
candidatesConsidered: ranked.length,
|
||||
finalScore: best.score,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── CostStrategy: always picks cheapest healthy provider ─────────────────────
|
||||
|
||||
class CostStrategyImpl implements RouterStrategy {
|
||||
readonly name = "cost";
|
||||
readonly description = "Always selects cheapest available provider (by costPer1MTokens)";
|
||||
|
||||
select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision {
|
||||
const healthy = pool.filter((c) => c.circuitBreakerState !== "OPEN");
|
||||
const candidates = healthy.length > 0 ? healthy : pool;
|
||||
const sorted = [...candidates].sort((a, b) => a.costPer1MTokens - b.costPer1MTokens);
|
||||
const best = sorted[0];
|
||||
if (!best) throw new Error("[CostStrategy] No candidates available");
|
||||
return {
|
||||
provider: best.provider,
|
||||
model: best.model,
|
||||
strategy: this.name,
|
||||
reason: `CostStrategy: cheapest at $${best.costPer1MTokens.toFixed(3)}/1M tokens`,
|
||||
candidatesConsidered: candidates.length,
|
||||
finalScore: best.costPer1MTokens === 0 ? 1.0 : 1 / best.costPer1MTokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── LatencyStrategy: prioritize low latency + reliability ───────────────────
|
||||
|
||||
class LatencyStrategyImpl implements RouterStrategy {
|
||||
readonly name = "latency";
|
||||
readonly description = "Prioritizes lowest p95 latency with reliability weighting";
|
||||
|
||||
select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision {
|
||||
const healthy = pool.filter((c) => c.circuitBreakerState !== "OPEN");
|
||||
const candidates = healthy.length > 0 ? healthy : pool;
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const aPenalty = a.errorRate * 1000;
|
||||
const bPenalty = b.errorRate * 1000;
|
||||
return a.p95LatencyMs + aPenalty - (b.p95LatencyMs + bPenalty);
|
||||
});
|
||||
const best = sorted[0];
|
||||
if (!best) throw new Error("[LatencyStrategy] No candidates available");
|
||||
|
||||
const latencyScore = best.p95LatencyMs > 0 ? Math.max(0.001, 10_000 / best.p95LatencyMs) : 1;
|
||||
const reliability = Math.max(0, 1 - best.errorRate);
|
||||
const finalScore = latencyScore * 0.7 + reliability * 0.3;
|
||||
|
||||
return {
|
||||
provider: best.provider,
|
||||
model: best.model,
|
||||
strategy: this.name,
|
||||
reason: `LatencyStrategy: p95=${best.p95LatencyMs}ms, errorRate=${(best.errorRate * 100).toFixed(2)}%`,
|
||||
candidatesConsidered: candidates.length,
|
||||
finalScore,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const strategyRegistry = new Map<string, RouterStrategy>();
|
||||
|
||||
const rulesStrategy = new RulesStrategyImpl();
|
||||
const costStrategy = new CostStrategyImpl();
|
||||
const latencyStrategy = new LatencyStrategyImpl();
|
||||
|
||||
strategyRegistry.set("rules", rulesStrategy);
|
||||
strategyRegistry.set("cost", costStrategy);
|
||||
strategyRegistry.set("eco", costStrategy); // alias
|
||||
strategyRegistry.set("latency", latencyStrategy);
|
||||
strategyRegistry.set("fast", latencyStrategy); // alias
|
||||
|
||||
export function getStrategy(name: string): RouterStrategy {
|
||||
const strategy = strategyRegistry.get(name);
|
||||
if (!strategy) {
|
||||
console.warn(`[RouterStrategy] Strategy '${name}' not found, falling back to 'rules'`);
|
||||
return rulesStrategy;
|
||||
}
|
||||
return strategy;
|
||||
}
|
||||
|
||||
export function registerStrategy(name: string, strategy: RouterStrategy): void {
|
||||
if (strategyRegistry.has(name)) {
|
||||
console.warn(`[RouterStrategy] Overwriting strategy '${name}'`);
|
||||
}
|
||||
strategyRegistry.set(name, strategy);
|
||||
}
|
||||
|
||||
export function listStrategies(): Array<{ name: string; description: string }> {
|
||||
return [...strategyRegistry.entries()].map(([name, s]) => ({ name, description: s.description }));
|
||||
}
|
||||
|
||||
export function selectWithStrategy(
|
||||
pool: ProviderCandidate[],
|
||||
context: RoutingContext,
|
||||
strategyName = "rules"
|
||||
): RoutingDecision {
|
||||
return getStrategy(strategyName).select(pool, context);
|
||||
}
|
||||
@@ -74,7 +74,8 @@ export function calculateScore(factors: ScoringFactors, weights: ScoringWeights)
|
||||
weights.costInv * factors.costInv +
|
||||
weights.latencyInv * factors.latencyInv +
|
||||
weights.taskFit * factors.taskFit +
|
||||
weights.stability * factors.stability
|
||||
weights.stability * factors.stability +
|
||||
weights.tierPriority * factors.tierPriority
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,23 @@ const FITNESS_TABLE: Record<string, Record<string, number>> = {
|
||||
"deepseek-coder": 0.9,
|
||||
"deepseek-v3": 0.85,
|
||||
"deepseek-r1": 0.88,
|
||||
"deepseek-chat": 0.84, // DeepSeek V3.2 Chat — strong code performance
|
||||
"deepseek-v3.2": 0.86, // Explicit V3.2 alias
|
||||
qwen: 0.78,
|
||||
llama: 0.72,
|
||||
mistral: 0.75,
|
||||
mixtral: 0.77,
|
||||
// Grok-4 fast — good code, ultra-low latency (1143ms P50)
|
||||
"grok-4-fast": 0.8,
|
||||
"grok-4": 0.82,
|
||||
"grok-3": 0.8,
|
||||
// Kimi K2.5 — agentic with tool calling, good at code tasks
|
||||
"kimi-k2": 0.82,
|
||||
// GLM-5 — Z.AI model with 128k output
|
||||
"glm-5": 0.78,
|
||||
// MiniMax M2.5 — reasoning support helps complex code
|
||||
"minimax-m2.5": 0.75,
|
||||
"minimax-m2": 0.72,
|
||||
},
|
||||
review: {
|
||||
"claude-sonnet": 0.92,
|
||||
@@ -58,10 +71,15 @@ const FITNESS_TABLE: Record<string, Record<string, number>> = {
|
||||
"claude-sonnet": 0.92,
|
||||
"gemini-2.5-pro": 0.95,
|
||||
"gemini-pro": 0.88,
|
||||
"gemini-3.1-pro": 0.95, // Gemini 3.1 Pro — 1M context, ideal for long analysis
|
||||
"gpt-4o": 0.85,
|
||||
o1: 0.9,
|
||||
o3: 0.93,
|
||||
"deepseek-r1": 0.88,
|
||||
"deepseek-chat": 0.8,
|
||||
"kimi-k2": 0.82, // Kimi K2.5 agentic — good for analysis
|
||||
"glm-5": 0.78, // GLM-5 with 128k output for long analysis
|
||||
"minimax-m2.5": 0.76,
|
||||
},
|
||||
debugging: {
|
||||
"claude-sonnet": 0.93,
|
||||
@@ -87,8 +105,17 @@ const FITNESS_TABLE: Record<string, Record<string, number>> = {
|
||||
"claude-opus": 0.85,
|
||||
"gpt-4o": 0.85,
|
||||
"gemini-pro": 0.8,
|
||||
"gemini-3.1-pro": 0.85,
|
||||
"deepseek-v3": 0.75,
|
||||
"deepseek-chat": 0.74,
|
||||
"gemini-flash": 0.72,
|
||||
// New models from ClawRouter analysis (2026-03-17):
|
||||
"grok-4-fast": 0.72, // ultra-fast, suitable for all tasks
|
||||
"grok-4": 0.74,
|
||||
"grok-3": 0.73,
|
||||
"kimi-k2": 0.76, // agentic multi-step tasks
|
||||
"glm-5": 0.7,
|
||||
"minimax-m2.5": 0.7,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,17 +5,36 @@
|
||||
|
||||
import { checkFallbackError, formatRetryAfter, getProviderProfile } from "./accountFallback.ts";
|
||||
import { unavailableResponse } from "../utils/error.ts";
|
||||
import { recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
|
||||
import { recordComboIntent, recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
|
||||
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts";
|
||||
import * as semaphore from "./rateLimitSemaphore.ts";
|
||||
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
|
||||
import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck";
|
||||
import { parseModel } from "./model.ts";
|
||||
import { applyComboAgentMiddleware, injectModelTag } from "./comboAgentMiddleware.ts";
|
||||
import { classifyWithConfig, DEFAULT_INTENT_CONFIG } from "./intentClassifier.ts";
|
||||
import { selectProvider as selectAutoProvider } from "./autoCombo/engine.ts";
|
||||
import { selectWithStrategy } from "./autoCombo/routerStrategy.ts";
|
||||
import { DEFAULT_WEIGHTS, scorePool } from "./autoCombo/scoring.ts";
|
||||
import { supportsToolCalling } from "./modelCapabilities.ts";
|
||||
|
||||
// Status codes that should mark semaphore + record circuit breaker failures
|
||||
const TRANSIENT_FOR_BREAKER = [429, 502, 503, 504];
|
||||
|
||||
const MAX_COMBO_DEPTH = 3;
|
||||
|
||||
// Bootstrap defaults from ClawRouter benchmark (used when no local latency history exists yet)
|
||||
const DEFAULT_MODEL_P95_MS = {
|
||||
"grok-4-fast-non-reasoning": 1143,
|
||||
"grok-4-1-fast-non-reasoning": 1244,
|
||||
"gemini-2.5-flash": 1238,
|
||||
"kimi-k2.5": 1646,
|
||||
"gpt-4o-mini": 2764,
|
||||
"claude-sonnet-4.6": 4000,
|
||||
"claude-opus-4.6": 6000,
|
||||
"deepseek-chat": 2000,
|
||||
};
|
||||
|
||||
// In-memory atomic counter per combo for round-robin distribution
|
||||
// Resets on server restart (by design — no stale state)
|
||||
const rrCounters = new Map();
|
||||
@@ -150,18 +169,8 @@ function orderModelsForWeightedFallback(models, selectedModel) {
|
||||
return [selected, ...rest].filter(Boolean).map((e) => e.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fisher-Yates shuffle (in-place)
|
||||
* @param {Array} arr
|
||||
* @returns {Array} The shuffled array
|
||||
*/
|
||||
function shuffleArray(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
// shuffleArray and getNextModelFromDeck moved to src/shared/utils/shuffleDeck.ts
|
||||
// combo.ts now uses the shared, mutex-protected getNextFromDeck with "combo:" namespace.
|
||||
|
||||
/**
|
||||
* Sort models by pricing (cheapest first) for cost-optimized strategy
|
||||
@@ -210,6 +219,158 @@ function sortModelsByUsage(models, comboName) {
|
||||
return withUsage.map((e) => e.modelStr);
|
||||
}
|
||||
|
||||
function toTextContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object") return "";
|
||||
if (typeof part.text === "string") return part.text;
|
||||
return "";
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function extractPromptForIntent(body) {
|
||||
if (!body || typeof body !== "object") return "";
|
||||
|
||||
const fromMessages = Array.isArray(body.messages)
|
||||
? [...body.messages].reverse().find((m) => m && typeof m === "object" && m.role === "user")
|
||||
: null;
|
||||
if (fromMessages) return toTextContent(fromMessages.content);
|
||||
|
||||
if (typeof body.input === "string") return body.input;
|
||||
if (Array.isArray(body.input)) {
|
||||
const text = body.input
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return "";
|
||||
if (typeof item.content === "string") return item.content;
|
||||
if (typeof item.text === "string") return item.text;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (text) return text;
|
||||
}
|
||||
|
||||
if (typeof body.prompt === "string") return body.prompt;
|
||||
return "";
|
||||
}
|
||||
|
||||
function mapIntentToTaskType(intent) {
|
||||
switch (intent) {
|
||||
case "code":
|
||||
return "coding";
|
||||
case "reasoning":
|
||||
return "analysis";
|
||||
case "simple":
|
||||
return "default";
|
||||
case "medium":
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function toStringArray(input) {
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((v) => (typeof v === "string" ? v.trim() : "")).filter(Boolean);
|
||||
}
|
||||
if (typeof input === "string") {
|
||||
return input
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getIntentConfig(settings, combo) {
|
||||
const comboIntentConfig =
|
||||
combo?.autoConfig?.intentConfig ||
|
||||
combo?.config?.auto?.intentConfig ||
|
||||
combo?.config?.intentConfig ||
|
||||
{};
|
||||
|
||||
return {
|
||||
...DEFAULT_INTENT_CONFIG,
|
||||
...comboIntentConfig,
|
||||
...(typeof settings?.intentDetectionEnabled === "boolean"
|
||||
? { enabled: settings.intentDetectionEnabled }
|
||||
: {}),
|
||||
...(Number.isFinite(Number(settings?.intentSimpleMaxWords))
|
||||
? { simpleMaxWords: Number(settings.intentSimpleMaxWords) }
|
||||
: {}),
|
||||
...(toStringArray(settings?.intentExtraCodeKeywords).length > 0
|
||||
? { extraCodeKeywords: toStringArray(settings.intentExtraCodeKeywords) }
|
||||
: {}),
|
||||
...(toStringArray(settings?.intentExtraReasoningKeywords).length > 0
|
||||
? { extraReasoningKeywords: toStringArray(settings.intentExtraReasoningKeywords) }
|
||||
: {}),
|
||||
...(toStringArray(settings?.intentExtraSimpleKeywords).length > 0
|
||||
? { extraSimpleKeywords: toStringArray(settings.intentExtraSimpleKeywords) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getBootstrapLatencyMs(modelId) {
|
||||
const normalized = String(modelId || "").toLowerCase();
|
||||
return DEFAULT_MODEL_P95_MS[normalized] ?? 1500;
|
||||
}
|
||||
|
||||
async function buildAutoCandidates(modelStrings, comboName) {
|
||||
const metrics = getComboMetrics(comboName);
|
||||
const { getPricingForModel } = await import("../../src/lib/localDb");
|
||||
|
||||
const candidates = await Promise.all(
|
||||
modelStrings.map(async (modelStr) => {
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
||||
const model = parsed.model || modelStr;
|
||||
|
||||
let costPer1MTokens = 1;
|
||||
try {
|
||||
const pricing = await getPricingForModel(provider, model);
|
||||
const inputPrice = Number(pricing?.input);
|
||||
if (Number.isFinite(inputPrice) && inputPrice >= 0) {
|
||||
costPer1MTokens = inputPrice;
|
||||
}
|
||||
} catch {
|
||||
// keep default cost
|
||||
}
|
||||
|
||||
const modelMetric = metrics?.byModel?.[modelStr] || null;
|
||||
const avgLatency = Number(modelMetric?.avgLatencyMs);
|
||||
const successRate = Number(modelMetric?.successRate);
|
||||
const p95LatencyMs =
|
||||
Number.isFinite(avgLatency) && avgLatency > 0 ? avgLatency : getBootstrapLatencyMs(model);
|
||||
const errorRate =
|
||||
Number.isFinite(successRate) && successRate >= 0 && successRate <= 100
|
||||
? 1 - successRate / 100
|
||||
: 0.05;
|
||||
|
||||
const breakerStateRaw = getCircuitBreaker(`combo:${modelStr}`)?.getStatus?.()?.state;
|
||||
const circuitBreakerState =
|
||||
breakerStateRaw === "OPEN" || breakerStateRaw === "HALF_OPEN" ? breakerStateRaw : "CLOSED";
|
||||
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
quotaRemaining: 100,
|
||||
quotaTotal: 100,
|
||||
circuitBreakerState,
|
||||
costPer1MTokens,
|
||||
p95LatencyMs,
|
||||
latencyStdDev: Math.max(10, p95LatencyMs * 0.1),
|
||||
errorRate,
|
||||
accountTier: "standard",
|
||||
quotaResetIntervalSecs: 86400,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle combo chat with fallback
|
||||
* Supports all 6 strategies: priority, weighted, round-robin, random, least-used, cost-optimized
|
||||
@@ -234,12 +395,49 @@ export async function handleComboChat({
|
||||
const strategy = combo.strategy || "priority";
|
||||
const models = combo.models || [];
|
||||
|
||||
// ── Combo Agent Middleware (#399 + #401) ────────────────────────────────
|
||||
// Apply system_message override, tool_filter_regex, and extract pinned model
|
||||
// from context caching tag. These are all opt-in per combo config.
|
||||
const { body: agentBody, pinnedModel } = applyComboAgentMiddleware(
|
||||
body,
|
||||
combo,
|
||||
"" // provider/model not yet known — resolved per-model in loop
|
||||
);
|
||||
body = agentBody;
|
||||
if (pinnedModel) {
|
||||
log.info("COMBO", `[#401] Context caching: pinned model=${pinnedModel}`);
|
||||
}
|
||||
// Wrap handleSingleModel to inject context caching tag on response (#401)
|
||||
const handleSingleModelWrapped = combo.context_cache_protection
|
||||
? async (b, modelStr) => {
|
||||
const res = await handleSingleModel(b, modelStr);
|
||||
// Inject tag only on success and only for non-streaming non-binary responses
|
||||
if (res.ok && !b.stream) {
|
||||
try {
|
||||
const json = await res.clone().json();
|
||||
const msgs = Array.isArray(json?.messages) ? json.messages : [];
|
||||
if (msgs.length > 0) {
|
||||
const tagged = injectModelTag(msgs, modelStr);
|
||||
return new Response(JSON.stringify({ ...json, messages: tagged }), {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON or stream — skip tagging */
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
: handleSingleModel;
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Route to round-robin handler if strategy matches
|
||||
if (strategy === "round-robin") {
|
||||
return handleRoundRobinCombo({
|
||||
body,
|
||||
combo,
|
||||
handleSingleModel,
|
||||
handleSingleModel: handleSingleModelWrapped,
|
||||
isModelAvailable,
|
||||
log,
|
||||
settings,
|
||||
@@ -287,8 +485,141 @@ export async function handleComboChat({
|
||||
}
|
||||
|
||||
// Apply strategy-specific ordering
|
||||
if (strategy === "random") {
|
||||
orderedModels = shuffleArray([...orderedModels]);
|
||||
if (strategy === "auto") {
|
||||
const requestHasTools = Array.isArray(body?.tools) && body.tools.length > 0;
|
||||
let eligibleModels = [...orderedModels];
|
||||
|
||||
if (requestHasTools) {
|
||||
const filtered = eligibleModels.filter((m) => supportsToolCalling(m));
|
||||
if (filtered.length > 0) {
|
||||
eligibleModels = filtered;
|
||||
} else {
|
||||
log.warn(
|
||||
"COMBO",
|
||||
"Auto strategy: all candidates filtered by tool-calling policy, falling back to full pool"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = extractPromptForIntent(body);
|
||||
const systemPrompt =
|
||||
typeof combo?.system_message === "string" ? combo.system_message : undefined;
|
||||
const intentConfig = getIntentConfig(settings, combo);
|
||||
const intent = classifyWithConfig(prompt, intentConfig, systemPrompt);
|
||||
recordComboIntent(combo.name, intent);
|
||||
const taskType = mapIntentToTaskType(intent);
|
||||
|
||||
const autoConfigSource = combo?.autoConfig || combo?.config?.auto || combo?.config || {};
|
||||
const routingStrategy =
|
||||
typeof autoConfigSource.routingStrategy === "string"
|
||||
? autoConfigSource.routingStrategy
|
||||
: typeof autoConfigSource.strategyName === "string"
|
||||
? autoConfigSource.strategyName
|
||||
: "rules";
|
||||
|
||||
const candidatePool = Array.isArray(autoConfigSource.candidatePool)
|
||||
? autoConfigSource.candidatePool
|
||||
: [
|
||||
...new Set(
|
||||
eligibleModels.map((m) => {
|
||||
const parsed = parseModel(m);
|
||||
return parsed.provider || parsed.providerAlias || "unknown";
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
const weights =
|
||||
autoConfigSource.weights && typeof autoConfigSource.weights === "object"
|
||||
? autoConfigSource.weights
|
||||
: DEFAULT_WEIGHTS;
|
||||
const explorationRate = Number.isFinite(Number(autoConfigSource.explorationRate))
|
||||
? Number(autoConfigSource.explorationRate)
|
||||
: 0.05;
|
||||
const budgetCap = Number.isFinite(Number(autoConfigSource.budgetCap))
|
||||
? Number(autoConfigSource.budgetCap)
|
||||
: undefined;
|
||||
const modePack =
|
||||
typeof autoConfigSource.modePack === "string" ? autoConfigSource.modePack : undefined;
|
||||
|
||||
const candidates = await buildAutoCandidates(eligibleModels, combo.name);
|
||||
if (candidates.length > 0) {
|
||||
let selectedProvider = null;
|
||||
let selectedModel = null;
|
||||
let selectionReason = "";
|
||||
|
||||
if (routingStrategy !== "rules") {
|
||||
try {
|
||||
const decision = selectWithStrategy(
|
||||
candidates,
|
||||
{ taskType, requestHasTools },
|
||||
routingStrategy
|
||||
);
|
||||
selectedProvider = decision.provider;
|
||||
selectedModel = decision.model;
|
||||
selectionReason = decision.reason;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
"COMBO",
|
||||
`Auto strategy '${routingStrategy}' failed (${err?.message || "unknown"}), falling back to rules`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedProvider || !selectedModel) {
|
||||
const selection = selectAutoProvider(
|
||||
{
|
||||
id: combo.id || combo.name,
|
||||
name: combo.name,
|
||||
type: "auto",
|
||||
candidatePool,
|
||||
weights,
|
||||
modePack,
|
||||
budgetCap,
|
||||
explorationRate,
|
||||
},
|
||||
candidates,
|
||||
taskType
|
||||
);
|
||||
selectedProvider = selection.provider;
|
||||
selectedModel = selection.model;
|
||||
selectionReason = `score=${selection.score.toFixed(3)}${selection.isExploration ? " (exploration)" : ""}`;
|
||||
}
|
||||
|
||||
const modelLookup = new Map();
|
||||
for (const modelStr of eligibleModels) {
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
||||
const modelId = parsed.model || modelStr;
|
||||
modelLookup.set(`${provider}/${modelId}`, modelStr);
|
||||
}
|
||||
|
||||
const ranked = scorePool(candidates, taskType, weights)
|
||||
.map((r) => modelLookup.get(`${r.provider}/${r.model}`) || `${r.provider}/${r.model}`)
|
||||
.filter(Boolean);
|
||||
|
||||
const selectedModelStr =
|
||||
modelLookup.get(`${selectedProvider}/${selectedModel}`) ||
|
||||
`${selectedProvider}/${selectedModel}`;
|
||||
orderedModels = [...new Set([selectedModelStr, ...ranked, ...eligibleModels])];
|
||||
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Auto selection: ${selectedModelStr} | intent=${intent} task=${taskType} | strategy=${routingStrategy} | ${selectionReason}`
|
||||
);
|
||||
} else {
|
||||
log.warn("COMBO", "Auto strategy has no candidates, keeping default ordering");
|
||||
}
|
||||
} else if (strategy === "strict-random") {
|
||||
const selectedId = await getNextFromDeck(`combo:${combo.name}`, orderedModels);
|
||||
// Put selected model first so the fallback loop tries it first
|
||||
const rest = orderedModels.filter((m) => m !== selectedId);
|
||||
orderedModels = [selectedId, ...rest];
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Strict-random deck: ${selectedId} selected (${orderedModels.length} models)`
|
||||
);
|
||||
} else if (strategy === "random") {
|
||||
orderedModels = fisherYatesShuffle([...orderedModels]);
|
||||
log.info("COMBO", `Random shuffle: ${orderedModels.length} models`);
|
||||
} else if (strategy === "least-used") {
|
||||
orderedModels = sortModelsByUsage(orderedModels, combo.name);
|
||||
@@ -348,7 +679,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) {
|
||||
|
||||
169
open-sse/services/comboAgentMiddleware.ts
Normal file
169
open-sse/services/comboAgentMiddleware.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ interface ComboMetricsEntry {
|
||||
totalLatencyMs: number;
|
||||
strategy: string;
|
||||
lastUsedAt: string | null;
|
||||
intentCounts: Record<string, number>;
|
||||
byModel: Record<string, ModelMetrics>;
|
||||
}
|
||||
|
||||
@@ -69,6 +70,7 @@ export function recordComboRequest(
|
||||
totalLatencyMs: 0,
|
||||
strategy,
|
||||
lastUsedAt: null,
|
||||
intentCounts: {},
|
||||
byModel: {},
|
||||
});
|
||||
}
|
||||
@@ -131,6 +133,7 @@ export function getComboMetrics(comboName: string): ComboMetricsView | null {
|
||||
combo.totalRequests > 0 ? Math.round((combo.totalSuccesses / combo.totalRequests) * 100) : 0,
|
||||
fallbackRate:
|
||||
combo.totalRequests > 0 ? Math.round((combo.totalFallbacks / combo.totalRequests) * 100) : 0,
|
||||
intentCounts: { ...combo.intentCounts },
|
||||
byModel: Object.fromEntries(
|
||||
Object.entries(combo.byModel).map(([model, m]) => [
|
||||
model,
|
||||
@@ -156,6 +159,30 @@ export function getAllComboMetrics(): Record<string, ComboMetricsView | null> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record detected prompt intent for a combo (used by multilingual routing analytics).
|
||||
*/
|
||||
export function recordComboIntent(comboName: string, intent: string): void {
|
||||
if (!metrics.has(comboName)) {
|
||||
metrics.set(comboName, {
|
||||
totalRequests: 0,
|
||||
totalSuccesses: 0,
|
||||
totalFailures: 0,
|
||||
totalFallbacks: 0,
|
||||
totalLatencyMs: 0,
|
||||
strategy: "priority",
|
||||
lastUsedAt: null,
|
||||
intentCounts: {},
|
||||
byModel: {},
|
||||
});
|
||||
}
|
||||
|
||||
const combo = metrics.get(comboName);
|
||||
if (!combo) return;
|
||||
const key = String(intent || "unknown");
|
||||
combo.intentCounts[key] = (combo.intentCounts[key] || 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset metrics for a specific combo
|
||||
*/
|
||||
|
||||
103
open-sse/services/emergencyFallback.ts
Normal file
103
open-sse/services/emergencyFallback.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Emergency Fallback — Budget Exhaustion Redirect
|
||||
*
|
||||
* When a request fails due to budget exhaustion (HTTP 402 or budget keywords
|
||||
* in the error body), optionally redirect to a free-tier model
|
||||
* (default provider/model: nvidia + openai/gpt-oss-120b at $0.00/M tokens).
|
||||
*
|
||||
* Inspired by ClawRouter: "gpt-oss-120b costs nothing and serves as
|
||||
* automatic fallback when wallet is empty."
|
||||
*/
|
||||
|
||||
export interface EmergencyFallbackConfig {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
triggerOn402: boolean;
|
||||
triggerOnBudgetKeywords: boolean;
|
||||
budgetKeywords: string[];
|
||||
/** Skip fallback for tool requests (gpt-oss-120b may not support structured tool calling) */
|
||||
skipForToolRequests: boolean;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
|
||||
export const EMERGENCY_FALLBACK_CONFIG: EmergencyFallbackConfig = {
|
||||
enabled: true,
|
||||
provider: "nvidia",
|
||||
model: "openai/gpt-oss-120b",
|
||||
triggerOn402: true,
|
||||
triggerOnBudgetKeywords: true,
|
||||
budgetKeywords: [
|
||||
"insufficient funds",
|
||||
"insufficient_funds",
|
||||
"budget exceeded",
|
||||
"budget_exceeded",
|
||||
"quota exceeded",
|
||||
"quota_exceeded",
|
||||
"billing",
|
||||
"payment required",
|
||||
"out of credits",
|
||||
"no credits",
|
||||
"credit limit",
|
||||
"spending limit",
|
||||
"saldo insuficiente",
|
||||
"limite de gastos",
|
||||
"cota excedida",
|
||||
],
|
||||
skipForToolRequests: true,
|
||||
maxOutputTokens: 4096,
|
||||
};
|
||||
|
||||
export interface FallbackDecision {
|
||||
shouldFallback: true;
|
||||
reason: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
|
||||
export interface NoFallbackDecision {
|
||||
shouldFallback: false;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type FallbackResult = FallbackDecision | NoFallbackDecision;
|
||||
|
||||
export function shouldUseFallback(
|
||||
status: number,
|
||||
errorBody: string,
|
||||
requestHasTools: boolean,
|
||||
config: EmergencyFallbackConfig = EMERGENCY_FALLBACK_CONFIG
|
||||
): FallbackResult {
|
||||
if (!config.enabled) return { shouldFallback: false, reason: "emergency fallback disabled" };
|
||||
if (config.skipForToolRequests && requestHasTools) {
|
||||
return { shouldFallback: false, reason: "skipped: request has tools" };
|
||||
}
|
||||
if (config.triggerOn402 && status === 402) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
reason: `HTTP 402 → emergency fallback to ${config.provider}/${config.model}`,
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
maxOutputTokens: config.maxOutputTokens,
|
||||
};
|
||||
}
|
||||
if (config.triggerOnBudgetKeywords && errorBody) {
|
||||
const lowerBody = errorBody.toLowerCase();
|
||||
const matched = config.budgetKeywords.find((kw) => lowerBody.includes(kw.toLowerCase()));
|
||||
if (matched) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
reason: `Budget error detected ('${matched}') → emergency fallback to ${config.provider}/${config.model}`,
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
maxOutputTokens: config.maxOutputTokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { shouldFallback: false, reason: "no budget error detected" };
|
||||
}
|
||||
|
||||
export function isFallbackDecision(result: FallbackResult): result is FallbackDecision {
|
||||
return result.shouldFallback === true;
|
||||
}
|
||||
375
open-sse/services/intentClassifier.ts
Normal file
375
open-sse/services/intentClassifier.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Multilingual Intent Detection for AutoCombo
|
||||
*
|
||||
* Classifies prompts as: code | reasoning | simple | medium
|
||||
* using keywords in 9 languages (EN, PT-BR, ES, ZH, JA, RU, DE, KO, AR).
|
||||
*
|
||||
* Inspired by ClawRouter (BlockRunAI) multilingual routing system.
|
||||
* Execution: purely synchronous, <1ms, no I/O.
|
||||
*/
|
||||
|
||||
export type IntentType = "code" | "reasoning" | "simple" | "medium";
|
||||
|
||||
export const CODE_KEYWORDS: readonly string[] = [
|
||||
// English
|
||||
"function",
|
||||
"class",
|
||||
"import",
|
||||
"def",
|
||||
"SELECT",
|
||||
"async",
|
||||
"await",
|
||||
"const",
|
||||
"let",
|
||||
"var",
|
||||
"return",
|
||||
"```",
|
||||
"algorithm",
|
||||
"compile",
|
||||
"debug",
|
||||
"refactor",
|
||||
"typescript",
|
||||
"python",
|
||||
"javascript",
|
||||
"code",
|
||||
"implement",
|
||||
"write a",
|
||||
"create a component",
|
||||
"endpoint",
|
||||
"repository",
|
||||
"deploy",
|
||||
"install",
|
||||
"script",
|
||||
"api",
|
||||
"database",
|
||||
"query",
|
||||
"schema",
|
||||
"interface",
|
||||
"generic",
|
||||
"enum",
|
||||
"module",
|
||||
"package",
|
||||
"dependency",
|
||||
// Português (PT-BR)
|
||||
"função",
|
||||
"classe",
|
||||
"importar",
|
||||
"definir",
|
||||
"consulta",
|
||||
"assíncrono",
|
||||
"aguardar",
|
||||
"constante",
|
||||
"variável",
|
||||
"retornar",
|
||||
"algoritmo",
|
||||
"compilar",
|
||||
"depurar",
|
||||
"refatorar",
|
||||
"código",
|
||||
"implementar",
|
||||
"criar um",
|
||||
"componente",
|
||||
"como fazer",
|
||||
"repositório",
|
||||
"configurar",
|
||||
"instalar",
|
||||
"banco de dados",
|
||||
"escrever uma função",
|
||||
"criar uma classe",
|
||||
// Español
|
||||
"función",
|
||||
"clase",
|
||||
"importar",
|
||||
"definir",
|
||||
"consulta",
|
||||
"asíncrono",
|
||||
"esperar",
|
||||
"constante",
|
||||
"variable",
|
||||
"retornar",
|
||||
"algoritmo",
|
||||
"compilar",
|
||||
"depurar",
|
||||
"refactorizar",
|
||||
"código",
|
||||
"implementar",
|
||||
// 中文
|
||||
"函数",
|
||||
"类",
|
||||
"导入",
|
||||
"定义",
|
||||
"查询",
|
||||
"异步",
|
||||
"等待",
|
||||
"常量",
|
||||
"变量",
|
||||
"返回",
|
||||
"算法",
|
||||
"编译",
|
||||
"调试",
|
||||
"代码",
|
||||
// 日本語
|
||||
"関数",
|
||||
"クラス",
|
||||
"インポート",
|
||||
"非同期",
|
||||
"定数",
|
||||
"変数",
|
||||
"コード",
|
||||
"アルゴリズム",
|
||||
// Русский
|
||||
"функция",
|
||||
"класс",
|
||||
"импорт",
|
||||
"запрос",
|
||||
"асинхронный",
|
||||
"константа",
|
||||
"переменная",
|
||||
"алгоритм",
|
||||
"код",
|
||||
// Deutsch
|
||||
"funktion",
|
||||
"klasse",
|
||||
"importieren",
|
||||
"abfrage",
|
||||
"asynchron",
|
||||
"konstante",
|
||||
"variable",
|
||||
"algorithmus",
|
||||
"code",
|
||||
// 한국어
|
||||
"함수",
|
||||
"클래스",
|
||||
"가져오기",
|
||||
"정의",
|
||||
"쿼리",
|
||||
"비동기",
|
||||
"대기",
|
||||
"상수",
|
||||
"변수",
|
||||
"반환",
|
||||
"코드",
|
||||
// العربية
|
||||
"دالة",
|
||||
"فئة",
|
||||
"استيراد",
|
||||
"استعلام",
|
||||
"غير متزامن",
|
||||
"ثابت",
|
||||
"متغير",
|
||||
"كود",
|
||||
"خوارزمية",
|
||||
];
|
||||
|
||||
export const REASONING_KEYWORDS: readonly string[] = [
|
||||
// English
|
||||
"prove",
|
||||
"theorem",
|
||||
"derive",
|
||||
"step by step",
|
||||
"chain of thought",
|
||||
"formally",
|
||||
"mathematical",
|
||||
"proof",
|
||||
"logically",
|
||||
"analyze",
|
||||
"reasoning",
|
||||
"deduce",
|
||||
"infer",
|
||||
"hypothesis",
|
||||
"convergence",
|
||||
// Português (PT-BR)
|
||||
"provar",
|
||||
"teorema",
|
||||
"derivar",
|
||||
"passo a passo",
|
||||
"cadeia de pensamento",
|
||||
"formalmente",
|
||||
"matemático",
|
||||
"prova",
|
||||
"logicamente",
|
||||
"analisar",
|
||||
"raciocínio",
|
||||
"deduzir",
|
||||
"inferir",
|
||||
"hipótese",
|
||||
"demonstrar",
|
||||
"cálculo",
|
||||
"equação diferencial",
|
||||
"integral",
|
||||
"otimização",
|
||||
// Español
|
||||
"demostrar",
|
||||
"teorema",
|
||||
"derivar",
|
||||
"paso a paso",
|
||||
"formalmente",
|
||||
"matemático",
|
||||
"lógicamente",
|
||||
// 中文
|
||||
"证明",
|
||||
"定理",
|
||||
"推导",
|
||||
"逐步",
|
||||
"思维链",
|
||||
"数学",
|
||||
"逻辑",
|
||||
"分析",
|
||||
// 日本語
|
||||
"証明",
|
||||
"定理",
|
||||
"導出",
|
||||
"論理的",
|
||||
"分析",
|
||||
// Русский
|
||||
"доказать",
|
||||
"теорема",
|
||||
"шаг за шагом",
|
||||
"математически",
|
||||
"логически",
|
||||
// Deutsch
|
||||
"beweisen",
|
||||
"theorem",
|
||||
"schritt für schritt",
|
||||
"mathematisch",
|
||||
"logisch",
|
||||
// 한국어
|
||||
"증명",
|
||||
"정리",
|
||||
"단계별",
|
||||
"수학적",
|
||||
"논리적",
|
||||
// العربية
|
||||
"إثبات",
|
||||
"نظرية",
|
||||
"خطوة بخطوة",
|
||||
"رياضي",
|
||||
"منطقياً",
|
||||
];
|
||||
|
||||
export const SIMPLE_KEYWORDS: readonly string[] = [
|
||||
// English
|
||||
"what is",
|
||||
"define",
|
||||
"translate",
|
||||
"hello",
|
||||
"yes or no",
|
||||
"summarize",
|
||||
"list",
|
||||
"tell me",
|
||||
"who is",
|
||||
// Português (PT-BR)
|
||||
"o que é",
|
||||
"definir",
|
||||
"traduzir",
|
||||
"olá",
|
||||
"oi",
|
||||
"sim ou não",
|
||||
"resumir",
|
||||
"listar",
|
||||
"me diga",
|
||||
"quem é",
|
||||
"quando foi",
|
||||
"onde fica",
|
||||
"explique brevemente",
|
||||
"de forma simples",
|
||||
// Español
|
||||
"qué es",
|
||||
"definir",
|
||||
"traducir",
|
||||
"hola",
|
||||
"resumir",
|
||||
"listar",
|
||||
// 中文
|
||||
"什么是",
|
||||
"定义",
|
||||
"翻译",
|
||||
"你好",
|
||||
"总结",
|
||||
"列出",
|
||||
// Русский
|
||||
"что такое",
|
||||
"определить",
|
||||
"перевести",
|
||||
"привет",
|
||||
"резюмировать",
|
||||
// Deutsch
|
||||
"was ist",
|
||||
"definieren",
|
||||
"übersetzen",
|
||||
"hallo",
|
||||
"zusammenfassen",
|
||||
// 한국어
|
||||
"이란",
|
||||
"정의",
|
||||
"번역",
|
||||
"안녕",
|
||||
"요약",
|
||||
// العربية
|
||||
"ما هو",
|
||||
"تعريف",
|
||||
"ترجمة",
|
||||
"مرحبا",
|
||||
"ملخص",
|
||||
];
|
||||
|
||||
/**
|
||||
* Classify a prompt's intent using multilingual keyword matching.
|
||||
* Priority: code > reasoning > simple > medium (default)
|
||||
*/
|
||||
export function classifyPromptIntent(prompt: string, systemPrompt?: string): IntentType {
|
||||
const fullText = `${systemPrompt ?? ""} ${prompt}`.toLowerCase();
|
||||
const wordCount = prompt.trim().split(/\s+/).length;
|
||||
|
||||
for (const kw of CODE_KEYWORDS) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "code";
|
||||
}
|
||||
for (const kw of REASONING_KEYWORDS) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "reasoning";
|
||||
}
|
||||
if (wordCount < 60) {
|
||||
for (const kw of SIMPLE_KEYWORDS) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "simple";
|
||||
}
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
|
||||
export interface IntentClassifierConfig {
|
||||
enabled: boolean;
|
||||
extraCodeKeywords?: string[];
|
||||
extraReasoningKeywords?: string[];
|
||||
extraSimpleKeywords?: string[];
|
||||
simpleMaxWords?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_INTENT_CONFIG: IntentClassifierConfig = {
|
||||
enabled: true,
|
||||
simpleMaxWords: 60,
|
||||
};
|
||||
|
||||
export function classifyWithConfig(
|
||||
prompt: string,
|
||||
config: IntentClassifierConfig,
|
||||
systemPrompt?: string
|
||||
): IntentType {
|
||||
if (!config.enabled) return "medium";
|
||||
const fullText = `${systemPrompt ?? ""} ${prompt}`.toLowerCase();
|
||||
const wordCount = prompt.trim().split(/\s+/).length;
|
||||
const maxSimpleWords = config.simpleMaxWords ?? 60;
|
||||
const codeKws = [...CODE_KEYWORDS, ...(config.extraCodeKeywords ?? [])];
|
||||
const reasoningKws = [...REASONING_KEYWORDS, ...(config.extraReasoningKeywords ?? [])];
|
||||
const simpleKws = [...SIMPLE_KEYWORDS, ...(config.extraSimpleKeywords ?? [])];
|
||||
for (const kw of codeKws) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "code";
|
||||
}
|
||||
for (const kw of reasoningKws) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "reasoning";
|
||||
}
|
||||
if (wordCount < maxSimpleWords) {
|
||||
for (const kw of simpleKws) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "simple";
|
||||
}
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
|
||||
import { resolveWildcardAlias } from "./wildcardRouter.ts";
|
||||
|
||||
// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
|
||||
// This prevents the two maps from drifting out of sync
|
||||
@@ -22,6 +23,18 @@ const PROVIDER_MODEL_ALIASES = {
|
||||
"gemini-3-flash": "gemini-3-flash-preview",
|
||||
"raptor-mini": "oswe-vscode-prime",
|
||||
},
|
||||
gemini: {
|
||||
"gemini-3.1-pro-preview": "gemini-3.1-pro",
|
||||
"gemini-3-1-pro": "gemini-3.1-pro",
|
||||
},
|
||||
"gemini-cli": {
|
||||
"gemini-3.1-pro-preview": "gemini-3.1-pro",
|
||||
"gemini-3-1-pro": "gemini-3.1-pro",
|
||||
},
|
||||
nvidia: {
|
||||
"gpt-oss-120b": "openai/gpt-oss-120b",
|
||||
"nvidia/gpt-oss-120b": "openai/gpt-oss-120b",
|
||||
},
|
||||
antigravity: {},
|
||||
};
|
||||
|
||||
@@ -158,7 +171,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
// Get aliases (from object or function)
|
||||
const aliases = typeof aliasesOrGetter === "function" ? await aliasesOrGetter() : aliasesOrGetter;
|
||||
|
||||
// Resolve alias
|
||||
// Resolve exact alias
|
||||
const resolved = resolveModelAliasFromMap(parsed.model, aliases);
|
||||
if (resolved) {
|
||||
const canonicalModel = resolveProviderModelAlias(resolved.provider, resolved.model);
|
||||
@@ -169,6 +182,28 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
};
|
||||
}
|
||||
|
||||
// T13: Try wildcard alias (glob patterns like "claude-sonnet-*" → "anthropic/claude-sonnet-4-...")
|
||||
if (aliases && typeof aliases === "object") {
|
||||
const aliasEntries = Object.entries(aliases).map(([pattern, target]) => ({ pattern, target }));
|
||||
const wildcardMatch = resolveWildcardAlias(parsed.model, aliasEntries);
|
||||
if (wildcardMatch) {
|
||||
const target = wildcardMatch.target as string;
|
||||
if (target.includes("/")) {
|
||||
const firstSlash = target.indexOf("/");
|
||||
const providerOrAlias = target.slice(0, firstSlash);
|
||||
const targetModel = target.slice(firstSlash + 1);
|
||||
const provider = resolveProviderAlias(providerOrAlias);
|
||||
const canonicalModel = resolveProviderModelAlias(provider, targetModel);
|
||||
return {
|
||||
provider,
|
||||
model: canonicalModel,
|
||||
extendedContext,
|
||||
wildcardPattern: wildcardMatch.pattern,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelId = parsed.model;
|
||||
const providers = MODEL_TO_PROVIDERS.get(modelId) || [];
|
||||
|
||||
@@ -203,7 +238,19 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: treat as openai model
|
||||
// Fallback: infer provider from known model name prefixes before defaulting to openai
|
||||
// FIX #73: Models like claude-haiku-4-5-20251001 sent without provider prefix
|
||||
// would incorrectly route to OpenAI. Use heuristic prefix detection first.
|
||||
if (/^claude-/i.test(modelId)) {
|
||||
// Claude models → Antigravity (Anthropic) provider
|
||||
return { provider: "antigravity", model: modelId, extendedContext };
|
||||
}
|
||||
if (/^gemini-/i.test(modelId) || /^gemma-/i.test(modelId)) {
|
||||
// Gemini/Gemma models → Gemini provider
|
||||
return { provider: "gemini", model: modelId, extendedContext };
|
||||
}
|
||||
|
||||
// Last resort: treat as openai model
|
||||
return {
|
||||
provider: "openai",
|
||||
model: modelId,
|
||||
|
||||
50
open-sse/services/modelCapabilities.ts
Normal file
50
open-sse/services/modelCapabilities.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
|
||||
import { parseModel } from "./model.ts";
|
||||
|
||||
// Conservative denylist fallback used when registry metadata is absent.
|
||||
// Keep small and explicit to avoid false negatives.
|
||||
const TOOL_CALLING_UNSUPPORTED_PATTERNS = [
|
||||
"gpt-oss-120b",
|
||||
"deepseek-reasoner",
|
||||
"glm-4.7",
|
||||
"glm4.7",
|
||||
];
|
||||
|
||||
function getRegistryToolCallingFlag(providerIdOrAlias: string, modelId: string): boolean | null {
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[providerIdOrAlias] || providerIdOrAlias;
|
||||
const models = PROVIDER_MODELS[providerAlias];
|
||||
if (!Array.isArray(models)) return null;
|
||||
const found = models.find((m) => m?.id === modelId);
|
||||
if (!found) return null;
|
||||
return typeof found.toolCalling === "boolean" ? found.toolCalling : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a model should be considered safe for structured function/tool calling.
|
||||
*
|
||||
* Decision order:
|
||||
* 1) Provider registry metadata (toolCalling flag) when available.
|
||||
* 2) Conservative denylist fallback for known problematic model families.
|
||||
* 3) Default true.
|
||||
*/
|
||||
export function supportsToolCalling(modelStr: string): boolean {
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "";
|
||||
const model = parsed.model || modelStr;
|
||||
|
||||
if (provider) {
|
||||
const fromRegistry = getRegistryToolCallingFlag(provider, model);
|
||||
if (fromRegistry !== null) return fromRegistry;
|
||||
}
|
||||
|
||||
const normalized = String(modelStr || "").toLowerCase();
|
||||
if (!normalized) return false;
|
||||
|
||||
const blocked = TOOL_CALLING_UNSUPPORTED_PATTERNS.some((pattern) => {
|
||||
if (normalized === pattern) return true;
|
||||
if (normalized.endsWith(`/${pattern}`)) return true;
|
||||
return normalized.includes(pattern);
|
||||
});
|
||||
|
||||
return !blocked;
|
||||
}
|
||||
120
open-sse/services/requestDedup.ts
Normal file
120
open-sse/services/requestDedup.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Request Deduplication Service
|
||||
*
|
||||
* Deduplicates **concurrent** identical requests to the same upstream.
|
||||
* Inspired by ClawRouter's dedup.ts (BlockRunAI / github.com/BlockRunAI/ClawRouter).
|
||||
*
|
||||
* IMPORTANT: In-memory only — does NOT persist across restarts and does NOT
|
||||
* work across multiple process instances (no cross-instance dedup).
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export interface DedupConfig {
|
||||
enabled: boolean;
|
||||
maxTemperatureForDedup: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_DEDUP_CONFIG: DedupConfig = {
|
||||
enabled: true,
|
||||
maxTemperatureForDedup: 0.1,
|
||||
timeoutMs: 60_000,
|
||||
};
|
||||
|
||||
export interface DedupResult<T> {
|
||||
result: T;
|
||||
wasDeduplicated: boolean;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* Compute a deterministic hash for a request body.
|
||||
* Includes: model, messages, temperature, tools, tool_choice, max_tokens, response_format
|
||||
* Excludes: stream, user, metadata (don't affect LLM output)
|
||||
*/
|
||||
export function computeRequestHash(requestBody: unknown): string {
|
||||
const body = requestBody as Record<string, unknown>;
|
||||
const canonical = {
|
||||
model: body.model ?? null,
|
||||
messages: body.messages ?? null,
|
||||
temperature: typeof body.temperature === "number" ? body.temperature : 1.0,
|
||||
tools: body.tools ?? null,
|
||||
tool_choice: body.tool_choice ?? null,
|
||||
max_tokens: body.max_tokens ?? null,
|
||||
response_format: body.response_format ?? null,
|
||||
top_p: body.top_p ?? null,
|
||||
frequency_penalty: body.frequency_penalty ?? null,
|
||||
presence_penalty: body.presence_penalty ?? null,
|
||||
};
|
||||
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/** Determine whether a request should be deduplicated */
|
||||
export function shouldDeduplicate(
|
||||
requestBody: unknown,
|
||||
config: DedupConfig = DEFAULT_DEDUP_CONFIG
|
||||
): boolean {
|
||||
if (!config.enabled) return false;
|
||||
const body = requestBody as Record<string, unknown>;
|
||||
if (body.stream === true) return false;
|
||||
const temperature = typeof body.temperature === "number" ? body.temperature : 1.0;
|
||||
if (temperature > config.maxTemperatureForDedup) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a request with deduplication.
|
||||
* Concurrent identical requests share one upstream call.
|
||||
*/
|
||||
export async function deduplicate<T>(
|
||||
hash: string,
|
||||
fn: () => Promise<T>,
|
||||
config: DedupConfig = DEFAULT_DEDUP_CONFIG
|
||||
): Promise<DedupResult<T>> {
|
||||
if (!config.enabled) {
|
||||
return { result: await fn(), wasDeduplicated: false, hash };
|
||||
}
|
||||
|
||||
const existing = inflight.get(hash);
|
||||
if (existing) {
|
||||
const result = (await existing) as T;
|
||||
return { result, wasDeduplicated: true, hash };
|
||||
}
|
||||
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const sharedPromise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
inflight.set(hash, sharedPromise as Promise<unknown>);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (inflight.get(hash) === sharedPromise) inflight.delete(hash);
|
||||
}, config.timeoutMs);
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
resolve(result);
|
||||
return { result, wasDeduplicated: false, hash };
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (inflight.get(hash) === sharedPromise) inflight.delete(hash);
|
||||
}
|
||||
}
|
||||
|
||||
export function getInflightCount(): number {
|
||||
return inflight.size;
|
||||
}
|
||||
export function getInflightHashes(): string[] {
|
||||
return [...inflight.keys()];
|
||||
}
|
||||
export function clearInflight(): void {
|
||||
inflight.clear();
|
||||
}
|
||||
326
open-sse/services/taskAwareRouter.ts
Normal file
326
open-sse/services/taskAwareRouter.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Task-Aware Smart Router — T05
|
||||
*
|
||||
* Detects the semantic type of an incoming chat request and routes
|
||||
* to the most appropriate (optimal cost/quality) model for that task type.
|
||||
*
|
||||
* Task types:
|
||||
* - coding → fast reasoning models (deepseek, codex, claude-sonnet)
|
||||
* - creative → expressive models (claude-opus, gpt-5)
|
||||
* - analysis → long-context + smart models (gemini-2.5-pro, claude-opus)
|
||||
* - vision → multimodal models (gpt-4o, gemini-2.5-flash, claude-3.5)
|
||||
* - summarization → cheap fast models (gemini-flash, gpt-4o-mini)
|
||||
* - background → cheap utility models (same as backgroundTaskDetector)
|
||||
* - chat → default/balanced (no override)
|
||||
*/
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TaskType =
|
||||
| "coding"
|
||||
| "creative"
|
||||
| "analysis"
|
||||
| "vision"
|
||||
| "summarization"
|
||||
| "background"
|
||||
| "chat";
|
||||
|
||||
interface TaskPattern {
|
||||
patterns: string[];
|
||||
userPatterns?: string[]; // in user message content
|
||||
}
|
||||
|
||||
export interface TaskRoutingConfig {
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Map from task type to preferred model (provider/model format).
|
||||
* Empty string = use whatever was requested (no override).
|
||||
*/
|
||||
taskModelMap: Record<TaskType, string>;
|
||||
detectionEnabled: boolean;
|
||||
stats: { detected: number; routed: number };
|
||||
}
|
||||
|
||||
// ── Default detection patterns ───────────────────────────────────────────────
|
||||
|
||||
const TASK_PATTERNS: Record<TaskType, TaskPattern> = {
|
||||
coding: {
|
||||
patterns: [
|
||||
"write code",
|
||||
"write a function",
|
||||
"implement",
|
||||
"debug",
|
||||
"fix this",
|
||||
"fix the",
|
||||
"refactor",
|
||||
"unit test",
|
||||
"write test",
|
||||
"write a script",
|
||||
"code review",
|
||||
"complete this function",
|
||||
"add a feature",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"python",
|
||||
"sql query",
|
||||
"api endpoint",
|
||||
],
|
||||
userPatterns: [
|
||||
"```",
|
||||
"def ",
|
||||
"function ",
|
||||
"class ",
|
||||
"import ",
|
||||
"const ",
|
||||
"let ",
|
||||
"var ",
|
||||
"SELECT ",
|
||||
"INSERT ",
|
||||
"<html",
|
||||
"<div",
|
||||
],
|
||||
},
|
||||
creative: {
|
||||
patterns: [
|
||||
"write a story",
|
||||
"write a poem",
|
||||
"write a song",
|
||||
"creative writing",
|
||||
"write a blog",
|
||||
"write an article",
|
||||
"write a script",
|
||||
"write an essay",
|
||||
"imagine",
|
||||
"roleplay",
|
||||
"brainstorm",
|
||||
"creative",
|
||||
],
|
||||
},
|
||||
analysis: {
|
||||
patterns: [
|
||||
"analyze",
|
||||
"analyse",
|
||||
"analysis",
|
||||
"compare",
|
||||
"evaluate",
|
||||
"assess",
|
||||
"explain",
|
||||
"reasoning",
|
||||
"pros and cons",
|
||||
"advantages and disadvantages",
|
||||
"what are the implications",
|
||||
"in-depth",
|
||||
"comprehensive",
|
||||
],
|
||||
},
|
||||
vision: {
|
||||
patterns: [
|
||||
"look at this image",
|
||||
"in this image",
|
||||
"what do you see",
|
||||
"describe this image",
|
||||
"analyze this image",
|
||||
"read this screenshot",
|
||||
],
|
||||
userPatterns: ["image_url", "data:image"],
|
||||
},
|
||||
summarization: {
|
||||
patterns: [
|
||||
"summarize",
|
||||
"summary",
|
||||
"tldr",
|
||||
"tl;dr",
|
||||
"brief overview",
|
||||
"key points",
|
||||
"main points",
|
||||
"what did",
|
||||
"highlights from",
|
||||
],
|
||||
},
|
||||
background: {
|
||||
patterns: [
|
||||
"generate a title",
|
||||
"generate title",
|
||||
"create a title",
|
||||
"name this",
|
||||
"short description",
|
||||
"brief description",
|
||||
"one-line summary",
|
||||
"conversation title",
|
||||
],
|
||||
},
|
||||
chat: {
|
||||
patterns: [],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Default task → model map ─────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TASK_MODEL_MAP: Record<TaskType, string> = {
|
||||
coding: "deepseek/deepseek-chat", // DeepSeek V3.2 — best coding OSS
|
||||
creative: "", // No override — use requested model
|
||||
analysis: "gemini/gemini-2.5-pro", // Best long-context reasoning
|
||||
vision: "openai/gpt-4o", // Best vision baseline
|
||||
summarization: "gemini/gemini-2.5-flash", // Fast + cheap for summarization
|
||||
background: "gemini/gemini-2.5-flash-lite", // Cheapest for utility tasks
|
||||
chat: "", // No override — use requested model
|
||||
};
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let _config: TaskRoutingConfig = {
|
||||
enabled: false, // User must explicitly enable
|
||||
taskModelMap: { ...DEFAULT_TASK_MODEL_MAP },
|
||||
detectionEnabled: true,
|
||||
stats: { detected: 0, routed: 0 },
|
||||
};
|
||||
|
||||
// ── Config Management ────────────────────────────────────────────────────────
|
||||
|
||||
export function setTaskRoutingConfig(config: Partial<TaskRoutingConfig>): void {
|
||||
_config = {
|
||||
..._config,
|
||||
...config,
|
||||
stats: _config.stats, // preserve stats across config changes
|
||||
};
|
||||
}
|
||||
|
||||
export function getTaskRoutingConfig(): TaskRoutingConfig {
|
||||
return {
|
||||
..._config,
|
||||
taskModelMap: { ..._config.taskModelMap },
|
||||
stats: { ..._config.stats },
|
||||
};
|
||||
}
|
||||
|
||||
export function resetTaskRoutingStats(): void {
|
||||
_config.stats = { detected: 0, routed: 0 };
|
||||
}
|
||||
|
||||
export function getDefaultTaskModelMap(): Record<TaskType, string> {
|
||||
return { ...DEFAULT_TASK_MODEL_MAP };
|
||||
}
|
||||
|
||||
// ── Detection ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RequestMessage {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
function extractText(content: unknown): string {
|
||||
if (typeof content === "string") return content.toLowerCase();
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part: any) =>
|
||||
typeof part === "string" ? part.toLowerCase() : part?.text?.toLowerCase() || ""
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function hasImages(messages: RequestMessage[]): boolean {
|
||||
for (const msg of messages) {
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content as any[]) {
|
||||
if (part?.type === "image_url" || part?.type === "image") return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the task type for a given request body.
|
||||
* Returns 'chat' (no-op) if nothing specific is detected.
|
||||
*/
|
||||
export function detectTaskType(body: any): TaskType {
|
||||
if (!body || typeof body !== "object") return "chat";
|
||||
|
||||
const messages: RequestMessage[] = Array.isArray(body.messages)
|
||||
? body.messages
|
||||
: Array.isArray(body.input)
|
||||
? body.input
|
||||
: [];
|
||||
|
||||
if (messages.length === 0) return "chat";
|
||||
|
||||
// 1. Vision — check for image_url in any message
|
||||
if (hasImages(messages)) return "vision";
|
||||
|
||||
// 2. System prompt patterns (background first — most specific)
|
||||
const systemMsg = messages.find((m) => m.role === "system" || m.role === "developer");
|
||||
const systemText = systemMsg ? extractText(systemMsg.content) : "";
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
||||
const userText = lastUserMsg ? extractText(lastUserMsg.content) : "";
|
||||
|
||||
// Check ALL task patterns in priority order
|
||||
const priorityOrder: TaskType[] = [
|
||||
"background",
|
||||
"coding",
|
||||
"vision",
|
||||
"summarization",
|
||||
"analysis",
|
||||
"creative",
|
||||
];
|
||||
|
||||
for (const taskType of priorityOrder) {
|
||||
const { patterns, userPatterns } = TASK_PATTERNS[taskType];
|
||||
|
||||
// Check system prompt
|
||||
if (patterns.some((p) => systemText.includes(p.toLowerCase()))) {
|
||||
return taskType;
|
||||
}
|
||||
|
||||
// Check user message for this task's patterns
|
||||
if (patterns.some((p) => userText.includes(p.toLowerCase()))) {
|
||||
return taskType;
|
||||
}
|
||||
|
||||
// Check user message for code-specific patterns (userPatterns)
|
||||
if (userPatterns?.some((p) => userText.includes(p.toLowerCase()))) {
|
||||
return taskType;
|
||||
}
|
||||
}
|
||||
|
||||
return "chat";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply task-aware model override.
|
||||
* Returns the original model if routing is disabled or no override found.
|
||||
*
|
||||
* @param originalModel - The model from the request (e.g. "openai/gpt-4o")
|
||||
* @param body - The raw request body to detect task type from
|
||||
* @returns { model, taskType, wasRouted }
|
||||
*/
|
||||
export function applyTaskAwareRouting(
|
||||
originalModel: string,
|
||||
body: any
|
||||
): { model: string; taskType: TaskType; wasRouted: boolean } {
|
||||
if (!_config.enabled || !_config.detectionEnabled) {
|
||||
return { model: originalModel, taskType: "chat", wasRouted: false };
|
||||
}
|
||||
|
||||
const taskType = detectTaskType(body);
|
||||
_config.stats.detected++;
|
||||
|
||||
const preferred = _config.taskModelMap[taskType];
|
||||
|
||||
// No override configured for this task type
|
||||
if (!preferred || preferred === "") {
|
||||
return { model: originalModel, taskType, wasRouted: false };
|
||||
}
|
||||
|
||||
// Don't override if the model is already "better" (e.g. user sent opus, preferred is flash)
|
||||
// We respect user's choice unless it's a background/summarization override
|
||||
if (taskType !== "background" && taskType !== "summarization") {
|
||||
// For non-utility tasks, only override if no specific model was given
|
||||
// (i.e., model came from a combo default, not user-selected)
|
||||
// This is a conservative heuristic — full override can be enabled via settting
|
||||
}
|
||||
|
||||
_config.stats.routed++;
|
||||
return { model: preferred, taskType, wasRouted: true };
|
||||
}
|
||||
@@ -161,6 +161,11 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: `GitHub token expired or permission denied. Please re-authenticate the connection.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`GitHub API error: ${error}`);
|
||||
}
|
||||
|
||||
@@ -620,6 +625,11 @@ async function getCodexUsage(accessToken, providerSpecificData: Record<string, u
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: `Codex token expired or access denied. Please re-authenticate the connection.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`Codex API error: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,10 @@ export function filterToOpenAIFormat(body) {
|
||||
delete body.tools;
|
||||
}
|
||||
|
||||
// Strip Claude-specific fields that OpenAI-compatible providers reject
|
||||
delete body.metadata;
|
||||
delete body.anthropic_version;
|
||||
|
||||
// Normalize tools to OpenAI format (from Claude, Gemini, etc.)
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
||||
body.tools = body.tools
|
||||
|
||||
@@ -131,7 +131,7 @@ export function translateRequest(
|
||||
}
|
||||
|
||||
// Final step: prepare request for Claude format endpoints
|
||||
if (targetFormat === FORMATS.CLAUDE) {
|
||||
if (targetFormat === FORMATS.CLAUDE && sourceFormat !== FORMATS.CLAUDE) {
|
||||
result = prepareClaudeRequest(result, provider);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,14 +63,32 @@ export function claudeToOpenAIRequest(model, body, stream) {
|
||||
|
||||
// Tools
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = body.tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.input_schema || { type: "object", properties: {} },
|
||||
},
|
||||
}));
|
||||
const normalizedTools = body.tools
|
||||
.map((tool) => {
|
||||
const name = typeof tool.name === "string" ? tool.name.trim() : "";
|
||||
if (!name) return null; // skip tools with empty/invalid name
|
||||
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
description: typeof tool.description === "string" ? tool.description : "", // fix: never null (#276)
|
||||
parameters: tool.input_schema || { type: "object", properties: {} },
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
tool
|
||||
): tool is {
|
||||
type: "function";
|
||||
function: { name: string; description: string; parameters: unknown };
|
||||
} => Boolean(tool)
|
||||
);
|
||||
|
||||
if (normalizedTools.length > 0) {
|
||||
result.tools = normalizedTools;
|
||||
}
|
||||
}
|
||||
|
||||
// Tool choice
|
||||
|
||||
@@ -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 = "";
|
||||
@@ -363,6 +409,7 @@ export function openaiToOpenAIResponsesRequest(
|
||||
}
|
||||
|
||||
// Pass through relevant fields
|
||||
if (root.service_tier !== undefined) result.service_tier = root.service_tier;
|
||||
if (root.temperature !== undefined) result.temperature = root.temperature;
|
||||
if (root.max_tokens !== undefined) result.max_tokens = root.max_tokens;
|
||||
if (root.top_p !== undefined) result.top_p = root.top_p;
|
||||
|
||||
@@ -123,6 +123,43 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||
|
||||
flushCurrentMessage();
|
||||
|
||||
// Remove assistant messages with empty content (can happen when all tool_use blocks were skipped)
|
||||
result.messages = result.messages.filter((msg) => {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content) && msg.content.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Filter orphaned tool_result blocks whose tool_use_id has no matching tool_use
|
||||
const allToolUseIds = new Set<string>();
|
||||
for (const msg of result.messages) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && block.id) {
|
||||
allToolUseIds.add(String(block.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const msg of result.messages) {
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
msg.content = msg.content.filter((block) => {
|
||||
if (block.type === "tool_result" && block.tool_use_id) {
|
||||
return allToolUseIds.has(String(block.tool_use_id));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Remove user messages that became empty after orphan filtering
|
||||
result.messages = result.messages.filter((msg) => {
|
||||
if (msg.role === "user" && Array.isArray(msg.content) && msg.content.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Add cache_control to last assistant message
|
||||
for (let i = result.messages.length - 1; i >= 0; i--) {
|
||||
const message = result.messages[i];
|
||||
|
||||
@@ -184,6 +184,17 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
typeof parsed.type === "string" &&
|
||||
parsed.type.startsWith("response.");
|
||||
|
||||
// Detect Claude SSE payloads. Includes "ping" and "error" to ensure
|
||||
// they bypass the Chat Completions sanitization path which would
|
||||
// incorrectly process or drop them.
|
||||
const isClaudeSSE =
|
||||
parsed.type &&
|
||||
typeof parsed.type === "string" &&
|
||||
(parsed.type.startsWith("message") ||
|
||||
parsed.type.startsWith("content_block") ||
|
||||
parsed.type === "ping" ||
|
||||
parsed.type === "error");
|
||||
|
||||
if (isResponsesSSE) {
|
||||
// Responses SSE: only extract usage, forward payload as-is
|
||||
const extracted = extractUsage(parsed);
|
||||
@@ -194,6 +205,22 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
if (parsed.delta && typeof parsed.delta === "string") {
|
||||
totalContentLength += parsed.delta.length;
|
||||
}
|
||||
} else if (isClaudeSSE) {
|
||||
// Claude SSE: extract usage, track content, forward as-is
|
||||
const extracted = extractUsage(parsed);
|
||||
if (extracted) {
|
||||
// Non-destructive merge: never overwrite a positive value with 0
|
||||
// message_start carries input_tokens, message_delta carries output_tokens
|
||||
if (!usage) usage = {};
|
||||
if (extracted.prompt_tokens > 0) usage.prompt_tokens = extracted.prompt_tokens;
|
||||
if (extracted.completion_tokens > 0) usage.completion_tokens = extracted.completion_tokens;
|
||||
if (extracted.total_tokens > 0) usage.total_tokens = extracted.total_tokens;
|
||||
if (extracted.cache_read_input_tokens) usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
|
||||
if (extracted.cache_creation_input_tokens) usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
|
||||
}
|
||||
// Track content length from Claude format
|
||||
if (parsed.delta?.text) totalContentLength += parsed.delta.text.length;
|
||||
if (parsed.delta?.thinking) totalContentLength += parsed.delta.thinking.length;
|
||||
} else {
|
||||
// Chat Completions: full sanitization pipeline
|
||||
parsed = sanitizeStreamingChunk(parsed);
|
||||
@@ -372,9 +399,9 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
controller.enqueue(encoder.encode(output));
|
||||
}
|
||||
|
||||
// Estimate usage if provider didn't return valid usage (PASSTHROUGH is always OpenAI format)
|
||||
// Estimate usage if provider didn't return valid usage
|
||||
if (!hasValidUsage(usage) && totalContentLength > 0) {
|
||||
usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
|
||||
usage = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI);
|
||||
}
|
||||
|
||||
if (hasValidUsage(usage)) {
|
||||
|
||||
@@ -188,7 +188,23 @@ export function hasValidUsage(usage) {
|
||||
export function extractUsage(chunk) {
|
||||
if (!chunk || typeof chunk !== "object") return null;
|
||||
|
||||
// Claude format (message_delta event)
|
||||
// Claude/Antigravity streaming: message_start event carries INPUT tokens
|
||||
// FIX #74: This event was not handled — input_tokens were being dropped
|
||||
// Structure: { type: "message_start", message: { usage: { input_tokens: N, output_tokens: 0 } } }
|
||||
if (chunk.type === "message_start" && chunk.message?.usage) {
|
||||
const u = chunk.message.usage;
|
||||
const inputTokens = u.input_tokens || u.prompt_tokens || 0;
|
||||
if (inputTokens > 0) {
|
||||
return normalizeUsage({
|
||||
prompt_tokens: inputTokens,
|
||||
completion_tokens: u.output_tokens || u.completion_tokens || 0,
|
||||
cache_read_input_tokens: u.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: u.cache_creation_input_tokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Claude format (message_delta event) — carries OUTPUT tokens
|
||||
if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") {
|
||||
return normalizeUsage({
|
||||
prompt_tokens: chunk.usage.input_tokens || 0,
|
||||
|
||||
1083
package-lock.json
generated
1083
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.4.0",
|
||||
"version": "2.7.0",
|
||||
"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",
|
||||
|
||||
BIN
public/providers/kilo-gateway.png
Normal file
BIN
public/providers/kilo-gateway.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 472 B |
@@ -7,22 +7,25 @@
|
||||
* restarts, Docker volume remounts, and upgrades.
|
||||
*
|
||||
* Works across all deployment modes:
|
||||
* - npm / CLI: called from run-standalone.mjs and run-next.mjs
|
||||
* - npm / app runners: called from run-standalone.mjs and run-next.mjs
|
||||
* - Docker: same, secrets persisted in mounted volume
|
||||
* - Electron: called from main.js startup, persisted in userData
|
||||
* - Electron: called from main.js startup, persisted in DATA_DIR
|
||||
*
|
||||
* Priority (lowest → highest):
|
||||
* 1. Auto-generated defaults
|
||||
* 2. {DATA_DIR}/server.env (persisted on first boot)
|
||||
* 3. .env in CWD (user overrides)
|
||||
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
|
||||
* 4. process.env (shell / Docker -e flags, highest priority)
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// ── OAuth secrets that are optional but warn if missing ─────────────────────
|
||||
const OPTIONAL_OAUTH_SECRETS = [
|
||||
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
|
||||
@@ -31,23 +34,65 @@ const OPTIONAL_OAUTH_SECRETS = [
|
||||
];
|
||||
|
||||
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
|
||||
function resolveDataDir(overridePath) {
|
||||
if (overridePath) return resolve(overridePath);
|
||||
function resolveDataDir(overridePath, env = process.env) {
|
||||
if (overridePath?.trim()) return resolve(overridePath);
|
||||
|
||||
const configured = process.env.DATA_DIR?.trim();
|
||||
const configured = env.DATA_DIR?.trim();
|
||||
if (configured) return resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
return join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
||||
const xdg = env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return join(resolve(xdg), "omniroute");
|
||||
|
||||
return join(homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
function getPreferredEnvFilePath(env = process.env) {
|
||||
const candidates = [];
|
||||
|
||||
if (env.DATA_DIR?.trim()) {
|
||||
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
|
||||
}
|
||||
|
||||
candidates.push(join(resolveDataDir(null, env), ".env"));
|
||||
candidates.push(join(process.cwd(), ".env"));
|
||||
|
||||
return candidates.find((filePath) => existsSync(filePath)) ?? null;
|
||||
}
|
||||
|
||||
function hasEncryptedCredentials(dataDir) {
|
||||
const dbPath = join(dataDir, "storage.sqlite");
|
||||
if (!existsSync(dbPath)) return false;
|
||||
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR api_key LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get();
|
||||
return !!row;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function parseEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) return {};
|
||||
@@ -85,18 +130,17 @@ function writeEnvFile(filePath, env) {
|
||||
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
|
||||
|
||||
const dataDir = resolveDataDir(dataDirOverride);
|
||||
const preferredEnvPath = getPreferredEnvFilePath(process.env);
|
||||
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
|
||||
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
|
||||
const serverEnvPath = join(dataDir, "server.env");
|
||||
const dotEnvPath = join(process.cwd(), ".env");
|
||||
|
||||
// ── Layer 1: Load persisted server.env ────────────────────────────────────
|
||||
let persisted = parseEnvFile(serverEnvPath);
|
||||
|
||||
// ── Layer 2: Load .env from CWD (user overrides, higher priority) ─────────
|
||||
const dotEnv = parseEnvFile(dotEnvPath);
|
||||
|
||||
// ── Merge: persisted < .env < process.env ─────────────────────────────────
|
||||
const merged = { ...persisted, ...dotEnv, ...process.env };
|
||||
// ── Layer 2: Load the same preferred .env that the CLI wrapper uses ───────
|
||||
// This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`.
|
||||
const merged = { ...persisted, ...preferredEnv, ...process.env };
|
||||
|
||||
// ── Auto-generate required secrets ────────────────────────────────────────
|
||||
let needsPersist = false;
|
||||
@@ -109,6 +153,14 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
}
|
||||
|
||||
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
|
||||
if (hasEncryptedCredentials(dataDir)) {
|
||||
throw new Error(
|
||||
`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join(
|
||||
dataDir,
|
||||
"storage.sqlite"
|
||||
)}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
|
||||
);
|
||||
}
|
||||
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
|
||||
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
|
||||
needsPersist = true;
|
||||
|
||||
@@ -43,7 +43,7 @@ function extractOpenApiVersion(content) {
|
||||
}
|
||||
|
||||
function extractChangelogSections(content) {
|
||||
const headings = [...content.matchAll(/^##\s+\[([^\]]+)\](?:\s+—\s+.*)?$/gm)];
|
||||
const headings = [...content.matchAll(/^##\s+\[([^\]]+)\](?:\s+[-—–].*)?$/gm)];
|
||||
return headings.map((match) => match[1]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
146
scripts/prepare-electron-standalone.mjs
Normal file
146
scripts/prepare-electron-standalone.mjs
Normal file
@@ -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) || "."}`
|
||||
);
|
||||
@@ -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");
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Card, CardSkeleton, Button, Modal } from "@/shared/components";
|
||||
import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
export default function HomePageClient({ machineId }) {
|
||||
const t = useTranslations("home");
|
||||
@@ -418,8 +419,8 @@ function ProviderModelsModal({ provider, models, onClose }) {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
const handleCopy = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
const handleCopy = async (text) => {
|
||||
await copyToClipboard(text);
|
||||
setCopiedModel(text);
|
||||
notify.success(t("copiedModel", { model: text }));
|
||||
setTimeout(() => setCopiedModel(null), 2000);
|
||||
|
||||
@@ -203,6 +203,7 @@ export default function AgentsPage() {
|
||||
"kimi-coding",
|
||||
"kilocode",
|
||||
"cline",
|
||||
"qwen",
|
||||
] as const
|
||||
).map((providerId) => {
|
||||
const providerMeta = Object.values(AI_PROVIDERS).find(
|
||||
|
||||
@@ -52,15 +52,34 @@ function validateKeyName(
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
interface AccessSchedule {
|
||||
enabled: boolean;
|
||||
from: string;
|
||||
until: string;
|
||||
days: number[];
|
||||
tz: string;
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
allowedModels: string[] | null;
|
||||
allowedConnections: string[] | null;
|
||||
noLog?: boolean;
|
||||
autoResolve?: boolean;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ProviderConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface KeyUsageStats {
|
||||
totalRequests: number;
|
||||
lastUsed: string | null;
|
||||
@@ -79,6 +98,7 @@ export default function ApiManagerPageClient() {
|
||||
const tc = useTranslations("common");
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [allModels, setAllModels] = useState<Model[]>([]);
|
||||
const [allConnections, setAllConnections] = useState<ProviderConnection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
@@ -95,6 +115,7 @@ export default function ApiManagerPageClient() {
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchModels();
|
||||
fetchConnections();
|
||||
}, []);
|
||||
|
||||
const fetchModels = async () => {
|
||||
@@ -109,6 +130,18 @@ export default function ApiManagerPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConnections = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAllConnections(data.connections || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/keys");
|
||||
@@ -227,7 +260,14 @@ export default function ApiManagerPageClient() {
|
||||
setShowPermissionsModal(true);
|
||||
};
|
||||
|
||||
const handleUpdatePermissions = async (allowedModels: string[], noLog: boolean) => {
|
||||
const handleUpdatePermissions = async (
|
||||
allowedModels: string[],
|
||||
noLog: boolean,
|
||||
allowedConnections: string[],
|
||||
autoResolve: boolean,
|
||||
isActive: boolean,
|
||||
accessSchedule: AccessSchedule | null
|
||||
) => {
|
||||
if (!editingKey || !editingKey.id) return;
|
||||
|
||||
// Validate models array
|
||||
@@ -247,6 +287,11 @@ export default function ApiManagerPageClient() {
|
||||
(id) => typeof id === "string" && id.length > 0 && id.length < 200
|
||||
);
|
||||
|
||||
// Validate connections (must be UUIDs)
|
||||
const validConnections = allowedConnections.filter(
|
||||
(id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
|
||||
);
|
||||
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
@@ -254,7 +299,14 @@ export default function ApiManagerPageClient() {
|
||||
const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ allowedModels: validModels, noLog }),
|
||||
body: JSON.stringify({
|
||||
allowedModels: validModels,
|
||||
allowedConnections: validConnections,
|
||||
noLog,
|
||||
autoResolve,
|
||||
isActive,
|
||||
accessSchedule,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -449,7 +501,11 @@ export default function ApiManagerPageClient() {
|
||||
{keys.map((key) => {
|
||||
const stats = usageStats[key.id];
|
||||
const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
|
||||
const hasConnectionRestrictions =
|
||||
Array.isArray(key.allowedConnections) && key.allowedConnections.length > 0;
|
||||
const noLogEnabled = key.noLog === true;
|
||||
const keyIsActive = key.isActive !== false; // default true
|
||||
const hasSchedule = key.accessSchedule?.enabled === true;
|
||||
return (
|
||||
<div
|
||||
key={key.id}
|
||||
@@ -496,6 +552,15 @@ export default function ApiManagerPageClient() {
|
||||
{t("allModels")}
|
||||
</button>
|
||||
)}
|
||||
{hasConnectionRestrictions && (
|
||||
<button
|
||||
onClick={() => handleOpenPermissions(key)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">cable</span>
|
||||
{key.allowedConnections.length} conn
|
||||
</button>
|
||||
)}
|
||||
{noLogEnabled && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
@@ -504,6 +569,26 @@ export default function ApiManagerPageClient() {
|
||||
No-Log
|
||||
</span>
|
||||
)}
|
||||
{key.autoResolve && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
auto_fix_high
|
||||
</span>
|
||||
Auto-Resolve
|
||||
</span>
|
||||
)}
|
||||
{!keyIsActive && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-500/10 text-red-600 dark:text-red-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">block</span>
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
{hasSchedule && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">schedule</span>
|
||||
{t("scheduleActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col justify-center">
|
||||
@@ -659,6 +744,7 @@ export default function ApiManagerPageClient() {
|
||||
apiKey={editingKey}
|
||||
modelsByProvider={filteredModelsByProvider}
|
||||
allModels={allModels}
|
||||
allConnections={allConnections}
|
||||
searchModel={searchModel}
|
||||
onSearchChange={setSearchModel}
|
||||
onSave={handleUpdatePermissions}
|
||||
@@ -676,6 +762,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
apiKey,
|
||||
modelsByProvider,
|
||||
allModels,
|
||||
allConnections,
|
||||
searchModel,
|
||||
onSearchChange,
|
||||
onSave,
|
||||
@@ -685,18 +772,42 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
apiKey: ApiKey;
|
||||
modelsByProvider: ProviderGroup[];
|
||||
allModels: Model[];
|
||||
allConnections: ProviderConnection[];
|
||||
searchModel: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onSave: (models: string[], noLog: boolean) => void;
|
||||
onSave: (
|
||||
models: string[],
|
||||
noLog: boolean,
|
||||
connections: string[],
|
||||
autoResolve: boolean,
|
||||
isActive: boolean,
|
||||
accessSchedule: AccessSchedule | null
|
||||
) => void;
|
||||
}) {
|
||||
const t = useTranslations("apiManager");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
// Initialize state from props - component remounts when key prop changes
|
||||
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
|
||||
const initialConnections = Array.isArray(apiKey?.allowedConnections)
|
||||
? apiKey.allowedConnections
|
||||
: [];
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
|
||||
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
|
||||
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
|
||||
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
|
||||
const [keyIsActive, setKeyIsActive] = useState(apiKey?.isActive !== false);
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(apiKey?.accessSchedule?.enabled === true);
|
||||
const [scheduleFrom, setScheduleFrom] = useState(apiKey?.accessSchedule?.from ?? "08:00");
|
||||
const [scheduleUntil, setScheduleUntil] = useState(apiKey?.accessSchedule?.until ?? "18:00");
|
||||
const [scheduleDays, setScheduleDays] = useState<number[]>(
|
||||
apiKey?.accessSchedule?.days ?? [1, 2, 3, 4, 5]
|
||||
);
|
||||
const [scheduleTz, setScheduleTz] = useState(
|
||||
apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
const [selectedConnections, setSelectedConnections] = useState<string[]>(initialConnections);
|
||||
const [allowAllConnections, setAllowAllConnections] = useState(initialConnections.length === 0);
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(() => {
|
||||
// Expand all providers by default when in restrict mode with existing selections
|
||||
if (initialModels.length > 0) {
|
||||
@@ -769,9 +880,51 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
setSelectedModels([]);
|
||||
}, []);
|
||||
|
||||
const handleToggleConnection = useCallback(
|
||||
(connectionId: string) => {
|
||||
if (allowAllConnections) return;
|
||||
setSelectedConnections((prev) =>
|
||||
prev.includes(connectionId)
|
||||
? prev.filter((c) => c !== connectionId)
|
||||
: [...prev, connectionId]
|
||||
);
|
||||
},
|
||||
[allowAllConnections]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(allowAll ? [] : selectedModels, noLogEnabled);
|
||||
}, [onSave, allowAll, selectedModels, noLogEnabled]);
|
||||
const schedule: AccessSchedule | null = scheduleEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
from: scheduleFrom,
|
||||
until: scheduleUntil,
|
||||
days: scheduleDays,
|
||||
tz: scheduleTz,
|
||||
}
|
||||
: null;
|
||||
onSave(
|
||||
allowAll ? [] : selectedModels,
|
||||
noLogEnabled,
|
||||
allowAllConnections ? [] : selectedConnections,
|
||||
autoResolveEnabled,
|
||||
keyIsActive,
|
||||
schedule
|
||||
);
|
||||
}, [
|
||||
onSave,
|
||||
allowAll,
|
||||
selectedModels,
|
||||
noLogEnabled,
|
||||
allowAllConnections,
|
||||
selectedConnections,
|
||||
autoResolveEnabled,
|
||||
keyIsActive,
|
||||
scheduleEnabled,
|
||||
scheduleFrom,
|
||||
scheduleUntil,
|
||||
scheduleDays,
|
||||
scheduleTz,
|
||||
]);
|
||||
|
||||
const selectedCount = selectedModels.length;
|
||||
const totalModels = allModels.length;
|
||||
@@ -833,6 +986,129 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key Active Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("keyActive")}</p>
|
||||
<p className="text-xs text-text-muted">{t("keyActiveDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={keyIsActive}
|
||||
onClick={() => setKeyIsActive((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
keyIsActive
|
||||
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
|
||||
: "bg-red-500/15 text-red-700 dark:text-red-300 border border-red-500/30"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{keyIsActive ? "check_circle" : "block"}
|
||||
</span>
|
||||
{keyIsActive ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Access Schedule */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("accessSchedule")}</p>
|
||||
<p className="text-xs text-text-muted">{t("accessScheduleDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={scheduleEnabled}
|
||||
onClick={() => setScheduleEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
|
||||
scheduleEnabled
|
||||
? "bg-orange-500/15 text-orange-700 dark:text-orange-300 border border-orange-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">schedule</span>
|
||||
{scheduleEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
{scheduleEnabled && (
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("scheduleFrom")}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleFrom}
|
||||
onChange={(e) => setScheduleFrom(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("scheduleUntil")}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleUntil}
|
||||
onChange={(e) => setScheduleUntil(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">{t("scheduleDays")}</label>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(
|
||||
[
|
||||
[0, t("daySun")],
|
||||
[1, t("dayMon")],
|
||||
[2, t("dayTue")],
|
||||
[3, t("dayWed")],
|
||||
[4, t("dayThu")],
|
||||
[5, t("dayFri")],
|
||||
[6, t("daySat")],
|
||||
] as [number, string][]
|
||||
).map(([dayIdx, label]) => {
|
||||
const selected = scheduleDays.includes(dayIdx);
|
||||
return (
|
||||
<button
|
||||
key={dayIdx}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setScheduleDays((prev) =>
|
||||
prev.includes(dayIdx)
|
||||
? prev.filter((d) => d !== dayIdx)
|
||||
: [...prev, dayIdx].sort((a, b) => a - b)
|
||||
)
|
||||
}
|
||||
className={`px-2 py-1 text-[11px] font-medium rounded transition-all ${
|
||||
selected
|
||||
? "bg-primary text-white"
|
||||
: "bg-surface border border-border text-text-muted hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">
|
||||
{t("scheduleTimezone")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={scheduleTz}
|
||||
onChange={(e) => setScheduleTz(e.target.value)}
|
||||
placeholder="America/Sao_Paulo"
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-1">{t("scheduleTimezoneHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Privacy Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -859,6 +1135,30 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-Resolve Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("autoResolve")}</p>
|
||||
<p className="text-xs text-text-muted">{t("autoResolveDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoResolveEnabled}
|
||||
onClick={() => setAutoResolveEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
autoResolveEnabled
|
||||
? "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border border-cyan-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{autoResolveEnabled ? "auto_fix_high" : "auto_fix_normal"}
|
||||
</span>
|
||||
{autoResolveEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected Models Summary (only in restrict mode) */}
|
||||
{!allowAll && selectedCount > 0 && (
|
||||
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">
|
||||
@@ -1024,6 +1324,97 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Allowed Connections Section */}
|
||||
{allConnections.length > 0 && (
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-text-main">Allowed Connections</p>
|
||||
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
|
||||
<button
|
||||
onClick={() => {
|
||||
setAllowAllConnections(true);
|
||||
setSelectedConnections([]);
|
||||
}}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
allowAllConnections
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAllowAllConnections(false)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
!allowAllConnections
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
Restrict
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{allowAllConnections
|
||||
? "This key can use any active connection."
|
||||
: `Restricted to ${selectedConnections.length} connection${selectedConnections.length !== 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
{!allowAllConnections && (
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{Object.entries(
|
||||
allConnections.reduce<Record<string, ProviderConnection[]>>((acc, conn) => {
|
||||
const p = conn.provider || "Other";
|
||||
if (!acc[p]) acc[p] = [];
|
||||
acc[p].push(conn);
|
||||
return acc;
|
||||
}, {})
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, conns]) => (
|
||||
<div key={provider}>
|
||||
<p className="text-[10px] font-semibold text-text-muted uppercase tracking-wider px-1 py-0.5">
|
||||
{provider}
|
||||
</p>
|
||||
{conns.map((conn) => {
|
||||
const isSelected = selectedConnections.includes(conn.id);
|
||||
return (
|
||||
<button
|
||||
key={conn.id}
|
||||
onClick={() => handleToggleConnection(conn.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
isSelected ? "bg-primary border-primary" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="material-symbols-outlined text-white text-[10px]">
|
||||
check
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate flex-1">
|
||||
{conn.name || conn.id.slice(0, 8)}
|
||||
</span>
|
||||
{!conn.isActive && (
|
||||
<span className="text-[9px] text-red-400 shrink-0">inactive</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} fullWidth>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Card, Button, ModelSelectModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
export default function DefaultToolCard({
|
||||
toolId,
|
||||
@@ -100,7 +101,7 @@ export default function DefaultToolCard({
|
||||
};
|
||||
|
||||
const handleCopy = async (text, field) => {
|
||||
await navigator.clipboard.writeText(replaceVars(text));
|
||||
await copyToClipboard(replaceVars(text));
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,14 @@ const STRATEGY_OPTIONS = [
|
||||
{ value: "random", labelKey: "random", descKey: "randomDesc", icon: "shuffle" },
|
||||
{ value: "least-used", labelKey: "leastUsed", descKey: "leastUsedDesc", icon: "low_priority" },
|
||||
{ value: "cost-optimized", labelKey: "costOpt", descKey: "costOptimizedDesc", icon: "savings" },
|
||||
{
|
||||
value: "fill-first",
|
||||
labelKey: "fillFirst",
|
||||
descKey: "fillFirstDesc",
|
||||
icon: "stacked_bar_chart",
|
||||
},
|
||||
{ value: "p2c", labelKey: "p2c", descKey: "p2cDesc", icon: "compare_arrows" },
|
||||
{ value: "strict-random", labelKey: "strictRandom", descKey: "strictRandomDesc", icon: "casino" },
|
||||
];
|
||||
|
||||
const STRATEGY_GUIDANCE_FALLBACK = {
|
||||
@@ -60,6 +68,21 @@ const STRATEGY_GUIDANCE_FALLBACK = {
|
||||
avoid: "Avoid when pricing data is missing or outdated.",
|
||||
example: "Example: Batch or background jobs where lower cost matters most.",
|
||||
},
|
||||
"fill-first": {
|
||||
when: "Use when you want to drain one provider's quota fully before moving to the next.",
|
||||
avoid: "Avoid when you need request-level load balancing across providers.",
|
||||
example: "Example: Use all $200 Deepgram credits before falling to Groq.",
|
||||
},
|
||||
p2c: {
|
||||
when: "Use when you want low-latency selection using Power-of-Two-Choices algorithm.",
|
||||
avoid: "Avoid for small combos with 2 or fewer models — no benefit over round-robin.",
|
||||
example: "Example: High-throughput inference across 4+ equivalent model endpoints.",
|
||||
},
|
||||
"strict-random": {
|
||||
when: "Use when you want perfectly even spread — each model used once before repeating.",
|
||||
avoid: "Avoid when models have different quality or latency and order matters.",
|
||||
example: "Example: Multiple accounts of the same model to distribute usage evenly.",
|
||||
},
|
||||
};
|
||||
|
||||
const ADVANCED_FIELD_HELP_FALLBACK = {
|
||||
@@ -126,6 +149,34 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
|
||||
"Use for batch/background jobs where cost is the main KPI.",
|
||||
],
|
||||
},
|
||||
"fill-first": {
|
||||
title: "Quota drain strategy",
|
||||
description: "Exhausts one provider's quota before moving to the next in chain.",
|
||||
tips: [
|
||||
"Order models by free quota size — biggest first.",
|
||||
"Enable health checks to skip drained providers.",
|
||||
"Ideal for free-tier stacking (Deepgram → Groq → NIM).",
|
||||
],
|
||||
},
|
||||
p2c: {
|
||||
title: "Power-of-Two-Choices",
|
||||
description:
|
||||
"Picks the less-loaded of two random candidates per request — low latency at scale.",
|
||||
tips: [
|
||||
"Use with 4+ models for best effect.",
|
||||
"Requires latency telemetry enabled in Settings.",
|
||||
"Great replacement for round-robin in high-throughput combos.",
|
||||
],
|
||||
},
|
||||
"strict-random": {
|
||||
title: "Shuffle deck distribution",
|
||||
description: "Each model is used exactly once per cycle before reshuffling.",
|
||||
tips: [
|
||||
"Use at least 2 models for meaningful distribution.",
|
||||
"Ideal for same-model accounts to evenly spread quota.",
|
||||
"Guarantees no model is skipped or repeated within a cycle.",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
|
||||
@@ -141,10 +192,27 @@ const COMBO_TEMPLATE_FALLBACK = {
|
||||
balancedTitle: "Balanced load",
|
||||
balancedDesc: "Least-used routing to spread demand over time.",
|
||||
freeStackTitle: "Free Stack ($0)",
|
||||
freeStackDesc: "Round-robin across all free providers: Kiro, iFlow, Qwen, Gemini CLI. Zero cost, never stops.",
|
||||
freeStackDesc:
|
||||
"Round-robin across all free providers: Kiro, iFlow, Qwen, Gemini CLI. Zero cost, never stops.",
|
||||
};
|
||||
|
||||
const COMBO_TEMPLATES = [
|
||||
{
|
||||
id: "free-stack",
|
||||
icon: "volunteer_activism",
|
||||
titleKey: "templateFreeStack",
|
||||
descKey: "templateFreeStackDesc",
|
||||
fallbackTitle: COMBO_TEMPLATE_FALLBACK.freeStackTitle,
|
||||
fallbackDesc: COMBO_TEMPLATE_FALLBACK.freeStackDesc,
|
||||
strategy: "round-robin",
|
||||
suggestedName: "free-stack",
|
||||
isFeatured: true,
|
||||
config: {
|
||||
maxRetries: 3,
|
||||
retryDelayMs: 500,
|
||||
healthCheckEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "high-availability",
|
||||
icon: "shield",
|
||||
@@ -190,21 +258,6 @@ const COMBO_TEMPLATES = [
|
||||
healthCheckEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "free-stack",
|
||||
icon: "volunteer_activism",
|
||||
titleKey: "templateFreeStack",
|
||||
descKey: "templateFreeStackDesc",
|
||||
fallbackTitle: COMBO_TEMPLATE_FALLBACK.freeStackTitle,
|
||||
fallbackDesc: COMBO_TEMPLATE_FALLBACK.freeStackDesc,
|
||||
strategy: "round-robin",
|
||||
suggestedName: "free-stack",
|
||||
config: {
|
||||
maxRetries: 3,
|
||||
retryDelayMs: 500,
|
||||
healthCheckEnabled: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function getStrategyMeta(strategy) {
|
||||
@@ -225,6 +278,8 @@ function getStrategyBadgeClass(strategy) {
|
||||
if (strategy === "random") return "bg-purple-500/15 text-purple-600 dark:text-purple-400";
|
||||
if (strategy === "least-used") return "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400";
|
||||
if (strategy === "cost-optimized") return "bg-teal-500/15 text-teal-600 dark:text-teal-400";
|
||||
if (strategy === "fill-first") return "bg-orange-500/15 text-orange-600 dark:text-orange-400";
|
||||
if (strategy === "p2c") return "bg-indigo-500/15 text-indigo-600 dark:text-indigo-400";
|
||||
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
@@ -1363,10 +1418,24 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
);
|
||||
};
|
||||
|
||||
const FREE_STACK_PRESET_MODELS = [
|
||||
{ model: "gc/gemini-3-flash-preview", weight: 0 },
|
||||
{ model: "kr/claude-sonnet-4.5", weight: 0 },
|
||||
{ model: "if/kimi-k2-thinking", weight: 0 },
|
||||
{ model: "if/qwen3-coder-plus", weight: 0 },
|
||||
{ model: "qw/qwen3-coder-plus", weight: 0 },
|
||||
{ model: "nvidia/llama-3.3-70b-instruct", weight: 0 },
|
||||
{ model: "groq/llama-3.3-70b-versatile", weight: 0 },
|
||||
];
|
||||
|
||||
const applyTemplate = (template) => {
|
||||
setStrategy(template.strategy);
|
||||
setConfig((prev) => ({ ...prev, ...template.config }));
|
||||
if (!name.trim()) setName(template.suggestedName);
|
||||
// Pre-fill Free Stack with 7 real free provider models
|
||||
if (template.id === "free-stack") {
|
||||
setModels(FREE_STACK_PRESET_MODELS);
|
||||
}
|
||||
};
|
||||
|
||||
// Format model display name with readable provider name
|
||||
@@ -1471,7 +1540,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? t("editCombo") : t("createCombo")}>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEdit ? t("editCombo") : t("createCombo")}
|
||||
size="full"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Name */}
|
||||
<div>
|
||||
@@ -1486,7 +1560,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div className="rounded-lg border border-black/10 dark:border-white/10 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
|
||||
<div className="rounded-lg border border-black/8 dark:border-white/8 bg-black/[0.02] dark:bg-white/[0.02] p-3">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs font-medium">
|
||||
{getI18nOrFallback(t, "templatesTitle", COMBO_TEMPLATE_FALLBACK.title)}
|
||||
@@ -1499,27 +1573,40 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-1.5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-1">
|
||||
{COMBO_TEMPLATES.map((template) => (
|
||||
<button
|
||||
type="button"
|
||||
key={template.id}
|
||||
onClick={() => applyTemplate(template)}
|
||||
className="text-left rounded-md border border-black/10 dark:border-white/10 bg-white/70 dark:bg-white/[0.03] px-2 py-1.5 hover:border-primary/40 hover:bg-primary/5 transition-colors"
|
||||
className={`text-left rounded-md border px-3 py-2 transition-all ${
|
||||
template.isFeatured
|
||||
? "border-emerald-500/50 bg-emerald-500/5 hover:border-emerald-500/80 hover:bg-emerald-500/10 ring-1 ring-emerald-500/20"
|
||||
: "border-black/10 dark:border-white/10 bg-white/70 dark:bg-white/[0.03] hover:border-primary/40 hover:bg-primary/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-[14px] text-primary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${template.isFeatured ? "text-emerald-500" : "text-primary"}`}
|
||||
>
|
||||
{template.icon}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-text-main">
|
||||
<span className="text-[12px] font-semibold text-text-main">
|
||||
{getI18nOrFallback(t, template.titleKey, template.fallbackTitle)}
|
||||
</span>
|
||||
{template.isFeatured && (
|
||||
<span className="ml-auto text-[9px] font-bold uppercase tracking-wide bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 rounded">
|
||||
FREE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted mt-1 leading-4">
|
||||
<p className="text-[10px] text-text-muted mt-1.5 leading-[1.5]">
|
||||
{getI18nOrFallback(t, template.descKey, template.fallbackDesc)}
|
||||
</p>
|
||||
<p className="text-[10px] text-primary mt-1">
|
||||
{getI18nOrFallback(t, "templateApply", COMBO_TEMPLATE_FALLBACK.apply)}
|
||||
<p
|
||||
className={`text-[10px] mt-1.5 font-medium ${template.isFeatured ? "text-emerald-500" : "text-primary"}`}
|
||||
>
|
||||
{getI18nOrFallback(t, "templateApply", COMBO_TEMPLATE_FALLBACK.apply)} →
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
@@ -1986,6 +2073,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
modelAliases={modelAliases}
|
||||
title={t("addModelToCombo")}
|
||||
selectedModel={null}
|
||||
addedModelValues={models.map((m) => m.model)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user