mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-30 11:52:13 +03:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
476ef48fa5 | ||
|
|
4b9c129e1c | ||
|
|
b75b52eefb | ||
|
|
230f0410f7 | ||
|
|
59f4d42795 | ||
|
|
ebbd7c34c8 | ||
|
|
1e3c08565c | ||
|
|
e4beb49c6d | ||
|
|
8400dbea7d | ||
|
|
c12b7546a8 | ||
|
|
01092fa349 | ||
|
|
41ff569260 | ||
|
|
1b2a4fd659 | ||
|
|
00182afb2f | ||
|
|
a10a4bf491 | ||
|
|
399a911675 | ||
|
|
7ec572ca47 | ||
|
|
66193a2ac8 | ||
|
|
95a43597c9 | ||
|
|
3d78cd6848 | ||
|
|
e21ebaa389 | ||
|
|
d2ce20dc5c | ||
|
|
c766aa5403 | ||
|
|
35d56358b8 | ||
|
|
29c170f83a | ||
|
|
64b8194210 | ||
|
|
f8a23f41a6 | ||
|
|
cf703138f2 | ||
|
|
abe8cae083 | ||
|
|
fa950a8a49 | ||
|
|
a906bda7fc | ||
|
|
8f14452614 | ||
|
|
2666043daf | ||
|
|
20358bc8bf | ||
|
|
b44d1d9b90 | ||
|
|
eadcf43ca0 | ||
|
|
4e79d4708c | ||
|
|
a7df2b0b55 | ||
|
|
7b575f38aa | ||
|
|
f7d45ef31f | ||
|
|
85f2f8d8d8 | ||
|
|
1f9b340d13 | ||
|
|
fb95689601 | ||
|
|
d8e977cb42 | ||
|
|
89886e69a9 | ||
|
|
8ad5f0dbcc | ||
|
|
e9bb874ddc | ||
|
|
9042d5049d | ||
|
|
3a8defed09 | ||
|
|
0428943d84 | ||
|
|
373181dade | ||
|
|
f44fb3d9b7 | ||
|
|
32e0a7cb16 | ||
|
|
4ac29c4d9b | ||
|
|
3d70ad414e | ||
|
|
21f4f8ddce | ||
|
|
f3226b7f8d | ||
|
|
ea9d9e09ba | ||
|
|
ea6f556ed2 | ||
|
|
902d0b9f41 | ||
|
|
1b26018534 | ||
|
|
3963575529 | ||
|
|
294751d8a3 |
@@ -263,8 +263,8 @@ NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^---/{if(flag) {flag=0; exit}}
|
||||
if [ -z "$NOTES" ]; then NOTES="OmniRoute v$VERSION Release"; fi
|
||||
|
||||
git tag -a "v$VERSION" -m "Release v$VERSION"
|
||||
git push origin --tags
|
||||
gh release create "v$VERSION" --title "v$VERSION" --notes "$NOTES" --target main
|
||||
git push origin "v$VERSION"
|
||||
gh release create "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES" --target main || gh release edit "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES"
|
||||
```
|
||||
|
||||
### 14. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
|
||||
|
||||
@@ -82,7 +82,7 @@ For each bug issue, perform the full analysis:
|
||||
2. **Read ALL comments** — including bot triage comments (Kilo, etc.) and owner/community responses. Pay attention to:
|
||||
- Whether someone already responded with a fix
|
||||
- Whether a community member confirmed the issue is resolved
|
||||
- Whether the issue was marked as duplicate by a bot
|
||||
- Whether the issue was marked as duplicate by a bot. **WARNING: DO NOT blindly trust bot duplicate labels (e.g., kilo-duplicate). Bots make mistakes. You MUST read the full conversation and do your own independent analysis to determine if it is truly a duplicate or a distinct bug.**
|
||||
3. **Identify the claimed error** — extract the exact error message, status code, and provider/model involved
|
||||
|
||||
#### 5b. Check Information Sufficiency
|
||||
@@ -98,14 +98,14 @@ Verify the issue contains enough to act on:
|
||||
|
||||
For each bug, classify into one of 5 actions:
|
||||
|
||||
| Disposition | When to Apply | Action |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
|
||||
| **✅ CLOSE — Duplicate** | Bot flagged >85% similarity + user provides no new info | Close referencing the original issue |
|
||||
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
|
||||
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
|
||||
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
|
||||
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
|
||||
| Disposition | When to Apply | Action |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
|
||||
| **✅ CLOSE — Duplicate** | You have independently verified the issue is a duplicate (do NOT rely solely on bot flags) + user provides no new info | Close referencing the original issue |
|
||||
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
|
||||
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
|
||||
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
|
||||
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
|
||||
|
||||
#### 5d. For "FIX — Code Change" Issues
|
||||
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -31,6 +31,7 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run audit:deps
|
||||
- run: npm run lint
|
||||
- run: npm run check:cycles
|
||||
- run: npm run check:route-validation:t06
|
||||
@@ -274,7 +275,7 @@ jobs:
|
||||
echo "SonarQube scan skipped because SONAR_TOKEN or SONAR_HOST_URL is not configured." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: SonarQube Scan
|
||||
if: ${{ env.SONAR_TOKEN != '' && env.SONAR_HOST_URL != '' }}
|
||||
uses: SonarSource/sonarqube-scan-action@v7
|
||||
uses: SonarSource/sonarqube-scan-action@v8
|
||||
env:
|
||||
SONAR_TOKEN: ${{ env.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }}
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
43
.gitignore
vendored
43
.gitignore
vendored
@@ -68,46 +68,6 @@ antigravity-manager-analysis/
|
||||
.sisyphus/
|
||||
.plans/
|
||||
|
||||
# docs (allow specific tracked files)
|
||||
docs/*
|
||||
!docs/ARCHITECTURE.md
|
||||
!docs/CODEBASE_DOCUMENTATION.md
|
||||
!docs/CONTRIBUTING.md
|
||||
!docs/USER_GUIDE.md
|
||||
!docs/API_REFERENCE.md
|
||||
!docs/TERMUX_GUIDE.md
|
||||
!docs/TROUBLESHOOTING.md
|
||||
!docs/EXECUTION_CONTEXT_PROVIDER_SYNC.md
|
||||
!docs/TASK_NEBIUS_BACKEND_ENABLEMENT.md
|
||||
!docs/frontend-backend-provider-gap-report.md
|
||||
!docs/openapi.yaml
|
||||
!docs/RELEASE_CHECKLIST.md
|
||||
!docs/PLANO-IMPLANTACAO.md
|
||||
!docs/TASKS.md
|
||||
!docs/FASE-*.md
|
||||
!docs/adr/
|
||||
!docs/cli-tools/
|
||||
!docs/planning/
|
||||
!docs/improvement-plans/
|
||||
!docs/api/
|
||||
!docs/VM_DEPLOYMENT_GUIDE.md
|
||||
!docs/FEATURES.md
|
||||
!docs/screenshots/
|
||||
!docs/i18n/
|
||||
!docs/i18n/**
|
||||
!docs/features/
|
||||
!docs/features/**
|
||||
!docs/A2A-SERVER.md
|
||||
!docs/AUTO-COMBO.md
|
||||
!docs/MCP-SERVER.md
|
||||
!docs/CLI-TOOLS.md
|
||||
!docs/COVERAGE_PLAN.md
|
||||
!docs/ENVIRONMENT.md
|
||||
!docs/UNINSTALL.md
|
||||
!docs/I18N.md
|
||||
!docs/FLY_IO_DEPLOYMENT_GUIDE.md
|
||||
|
||||
|
||||
# open-sse tests
|
||||
open-sse/test/*
|
||||
|
||||
@@ -139,8 +99,7 @@ app.__qa_backup/
|
||||
# npm publish still includes it via package.json "files" field
|
||||
/app/
|
||||
|
||||
# Electron (subproject dependency lock and build artifacts)
|
||||
electron/package-lock.json
|
||||
# Electron
|
||||
electron/dist-electron/
|
||||
electron/node_modules/
|
||||
icon.iconset/
|
||||
|
||||
@@ -10,7 +10,7 @@ with **MCP Server** (29 tools), **A2A v0.3 Protocol**, and **Electron desktop ap
|
||||
|
||||
## Stack
|
||||
|
||||
- **Runtime**: Next.js 16 (App Router), Node.js ≥18 <24, ES Modules (`"type": "module"`)
|
||||
- **Runtime**: Next.js 16 (App Router), Node.js `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25`, ES Modules (`"type": "module"`)
|
||||
- **Language**: TypeScript 5.9 (`src/`) + JavaScript (`open-sse/`, `electron/`)
|
||||
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
|
||||
- **Streaming**: SSE via `open-sse` internal workspace package
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -4,6 +4,38 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.7.8] — 2026-05-01
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(providers):** add Grok 4.3 and Xiaomi Mimo TTS provider (#1837)
|
||||
- **feat(core):** implement Rate Limit Watchdog with environment override capability to detect and reset stalled queues (#1839)
|
||||
- **feat(providers):** add muse-spark-web provider with multiple models and reasoning support (#1843)
|
||||
- **feat(1proxy):** integrate 1proxy free proxy marketplace with dashboard management and new MCP tools (closes #1788) (#1847)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(codex):** sanitize Responses replay state to prevent internal assistant commentary from leaking (#1868 — thanks @dhaern)
|
||||
- **fix(cli):** add capture-backed Gemini CLI fingerprint (#1866)
|
||||
- **fix(ui):** hide combo compression controls when the global setting is disabled (#1840)
|
||||
- **fix(db):** tolerate missing request_detail_logs table for legacy deployments (#1848)
|
||||
- **fix(core):** remove unneeded \`store\` payload parameter for providers lacking support (closes #1841)
|
||||
- **fix(core):** ensure safeOutboundFetch and A2A routers return 503 Service Unavailable when security guardrails are triggered
|
||||
- **fix(usage):** correct Unix seconds vs milliseconds parsing logic for Kiro AI quota reset (closes #1849)
|
||||
- **fix(ui):** apply robust NaN handling, ensure 24h consistency, and fix missing hour slots in Compression Analytics (closes #1844)
|
||||
- **fix(ui):** implement short number formatting for token consumption metrics on cache pages to prevent overflow (closes #1842)
|
||||
- **fix(combo):** stabilize provider routing at 500+ connections by bounding semaphore queues and adjusting circuit breaker tracking (closes #1846) (#1854)
|
||||
- **fix(maritalk):** update Maritalk model list, use Authorization Key header, and align with latest API endpoints (#1856)
|
||||
- **fix(grok-web):** stabilize tool calling (bash, readFile, webSearch) and response parsing by mapping native Grok intents to standard OpenAI payloads (#1857)
|
||||
- **fix(providers):** correctly map and expose the Upstage embedding and chat model catalogs (#1855)
|
||||
- **fix(executor):** apply proper urlSuffix and custom authHeaders for unknown registry-based providers in DefaultExecutor (closes #1846) (#1861)
|
||||
|
||||
### 🛠️ Maintenance
|
||||
|
||||
- **fix(workflow):** build docker images on version tags (#1838)
|
||||
|
||||
---
|
||||
|
||||
## [3.7.7] — 2026-04-30
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -8,7 +8,7 @@ Thank you for your interest in contributing! This guide covers everything you ne
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** >= 18 < 24 (recommended: 22 LTS)
|
||||
- **Node.js** `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25` (recommended: 24 LTS)
|
||||
- **npm** 10+
|
||||
- **Git**
|
||||
|
||||
|
||||
183
docs/COMPRESSION_GUIDE.md
Normal file
183
docs/COMPRESSION_GUIDE.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# 🗜️ Prompt Compression Guide — OmniRoute
|
||||
|
||||
> Save 15-75% on token costs automatically. For a quick overview, see the [README Compression section](../README.md#%EF%B8%8F-prompt-compression--save-15-75-tokens-automatically).
|
||||
|
||||
## Overview
|
||||
|
||||
OmniRoute implements a modular prompt compression pipeline that runs **proactively** before requests hit upstream providers. This means your token savings happen transparently — no changes needed to your workflow.
|
||||
|
||||
```
|
||||
Client Request
|
||||
→ Compression Strategy Selector
|
||||
→ Combo override? → Use combo setting
|
||||
→ Auto-trigger threshold? → Use auto mode
|
||||
→ Default mode? → Use global setting
|
||||
→ Off? → Skip compression
|
||||
→ Selected Compression Mode
|
||||
→ Off: No compression
|
||||
→ Lite: Safe whitespace/formatting cleanup (~15%)
|
||||
→ Standard: Caveman-speak filler removal (~30%)
|
||||
→ Aggressive: History aging + summarization (~50%)
|
||||
→ Ultra: Heuristic pruning + code-block thinning (~75%)
|
||||
→ Compressed Request → Provider
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compression Modes
|
||||
|
||||
### Off
|
||||
|
||||
No compression applied. All messages pass through unchanged.
|
||||
|
||||
### Lite Mode (~15% savings, <1ms latency)
|
||||
|
||||
The safest mode — zero semantic change, only formatting cleanup:
|
||||
|
||||
| Technique | Description |
|
||||
| ------------------------ | ------------------------------------------------- |
|
||||
| `collapseWhitespace` | Merge consecutive blank lines and trailing spaces |
|
||||
| `dedupSystemPrompt` | Remove duplicate system messages |
|
||||
| `compressToolResults` | Compress verbose tool/function outputs |
|
||||
| `removeRedundantContent` | Strip repeated instructions |
|
||||
| `replaceImageUrls` | Shorten base64 image data URIs |
|
||||
|
||||
**Best for:** Always-on usage, safety-critical workflows.
|
||||
|
||||
### Standard Mode (~30% savings)
|
||||
|
||||
Inspired by [Caveman](https://github.com/JuliusBrussee/caveman) — removes filler words and verbose phrasing while preserving meaning:
|
||||
|
||||
- Removes filler words ("please", "I think", "basically", "actually")
|
||||
- Condenses verbose phrases ("in order to" → "to", "as a result of" → "because")
|
||||
- Strips polite hedging ("Would you mind...", "If you could possibly...")
|
||||
- 30+ regex rules tuned for coding prompts
|
||||
|
||||
**Best for:** Daily coding workflows, cost-conscious teams.
|
||||
|
||||
### Aggressive Mode (~50% savings)
|
||||
|
||||
Smart history management for long sessions:
|
||||
|
||||
- **Message Aging** — older messages get progressively compressed
|
||||
- **Tool Result Summarization** — long tool outputs replaced with summaries
|
||||
- **Structural Integrity Guards** — ensures `tool_use` + `tool_result` pairs stay consistent
|
||||
- **Context Window Awareness** — respects per-model token limits
|
||||
|
||||
**Best for:** Extended debugging sessions, large codebases.
|
||||
|
||||
### Ultra Mode (~75% savings)
|
||||
|
||||
Maximum compression for token-critical scenarios:
|
||||
|
||||
- **Heuristic Pruning** — removes messages below relevance threshold
|
||||
- **Code Block Thinning** — compresses repetitive code examples
|
||||
- **Binary Search Truncation** — finds optimal cut point for context window
|
||||
- All Aggressive mode features included
|
||||
|
||||
**Best for:** When you're hitting context limits repeatedly.
|
||||
|
||||
---
|
||||
|
||||
## Token Savings Visualization
|
||||
|
||||
```
|
||||
Without compression: 47K tokens sent to LLM
|
||||
With Lite: 40K tokens sent (15% saved — safe, always-on)
|
||||
With Standard: 33K tokens sent (30% saved — caveman-speak rules)
|
||||
With Aggressive: 24K tokens sent (50% saved — aging + summarization)
|
||||
With Ultra: 12K tokens sent (75% saved — heuristic pruning)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Dashboard
|
||||
|
||||
Navigate to `Dashboard → Settings → Compression`:
|
||||
|
||||
- **Default Mode** — sets the system-wide compression mode
|
||||
- **Auto-Trigger Threshold** — automatically engage compression when token count exceeds threshold
|
||||
- **Per-Combo Override** — each combo can have its own compression mode
|
||||
|
||||
### Per-Combo Override
|
||||
|
||||
In `Dashboard → Combos → [Your Combo] → Advanced`, set compression mode per combo:
|
||||
|
||||
```txt
|
||||
Combo: "free-forever"
|
||||
Mode: Standard
|
||||
Targets:
|
||||
1. gc/gemini-3-flash
|
||||
2. if/kimi-k2-thinking
|
||||
```
|
||||
|
||||
This lets you use aggressive compression on free providers while keeping lite mode on paid subscriptions.
|
||||
|
||||
### API
|
||||
|
||||
```bash
|
||||
# Get compression settings
|
||||
curl http://localhost:20128/api/settings/compression
|
||||
|
||||
# Update compression settings
|
||||
curl -X PUT http://localhost:20128/api/settings/compression \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"defaultMode":"lite","autoTriggerThreshold":32000}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Gets Protected
|
||||
|
||||
The compression engine **always preserves:**
|
||||
|
||||
- ✅ Code blocks (fenced and inline)
|
||||
- ✅ URLs and file paths
|
||||
- ✅ JSON structures and structured data
|
||||
- ✅ API keys, tokens, and identifiers
|
||||
- ✅ Mathematical expressions
|
||||
- ✅ Tool/function call definitions
|
||||
- ✅ System prompts (in lite mode)
|
||||
|
||||
---
|
||||
|
||||
## Compression Stats
|
||||
|
||||
Every compressed request includes stats in the server logs:
|
||||
|
||||
```json
|
||||
{
|
||||
"originalTokens": 47200,
|
||||
"compressedTokens": 40120,
|
||||
"savingsPercent": 15.0,
|
||||
"techniquesUsed": ["collapseWhitespace", "dedupSystemPrompt"],
|
||||
"mode": "lite",
|
||||
"latencyMs": 0.8
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase Roadmap
|
||||
|
||||
| Phase | Modes | Status |
|
||||
| ------- | ------------------------------------ | ---------- |
|
||||
| Phase 1 | Off, Lite | ✅ Shipped |
|
||||
| Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped |
|
||||
| Phase 3 | Per-model adaptive, ML-based pruning | 🗓️ Planned |
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Standard mode compression rules are inspired by **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Environment Config](ENVIRONMENT.md) — Compression environment variables
|
||||
- [Architecture Guide](ARCHITECTURE.md) — Compression pipeline internals
|
||||
- [User Guide](USER_GUIDE.md) — Getting started with compression
|
||||
119
docs/DOCKER_GUIDE.md
Normal file
119
docs/DOCKER_GUIDE.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 🐳 Docker Guide — OmniRoute
|
||||
|
||||
> Complete Docker deployment reference. For a quick start, see the [README Docker section](../README.md#-docker).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Run](#quick-run)
|
||||
- [With Environment File](#with-environment-file)
|
||||
- [Docker Compose](#docker-compose)
|
||||
- [Docker Compose with Caddy (HTTPS)](#docker-compose-with-caddy-https-auto-tls)
|
||||
- [Cloudflare Quick Tunnel](#cloudflare-quick-tunnel)
|
||||
- [Image Tags](#image-tags)
|
||||
- [Important Notes](#important-notes)
|
||||
|
||||
---
|
||||
|
||||
## Quick Run
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name omniroute \
|
||||
--restart unless-stopped \
|
||||
--stop-timeout 40 \
|
||||
-p 20128:20128 \
|
||||
-v omniroute-data:/app/data \
|
||||
diegosouzapw/omniroute:latest
|
||||
```
|
||||
|
||||
## With Environment File
|
||||
|
||||
```bash
|
||||
# Copy and edit .env first
|
||||
cp .env.example .env
|
||||
|
||||
docker run -d \
|
||||
--name omniroute \
|
||||
--restart unless-stopped \
|
||||
--stop-timeout 40 \
|
||||
--env-file .env \
|
||||
-p 20128:20128 \
|
||||
-v omniroute-data:/app/data \
|
||||
diegosouzapw/omniroute:latest
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
# Base profile (no CLI tools)
|
||||
docker compose --profile base up -d
|
||||
|
||||
# CLI profile (Claude Code, Codex, OpenClaw built-in)
|
||||
docker compose --profile cli up -d
|
||||
```
|
||||
|
||||
## Docker Compose with Caddy (HTTPS Auto-TLS)
|
||||
|
||||
OmniRoute can be securely exposed using Caddy's automatic SSL provisioning. Ensure your domain's DNS A record points to your server's IP.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
omniroute:
|
||||
image: diegosouzapw/omniroute:latest
|
||||
container_name: omniroute
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- omniroute-data:/app/data
|
||||
environment:
|
||||
- PORT=20128
|
||||
- NEXT_PUBLIC_BASE_URL=https://your-domain.com
|
||||
|
||||
caddy:
|
||||
image: caddy:latest
|
||||
container_name: caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
command: caddy reverse-proxy --from https://your-domain.com --to http://omniroute:20128
|
||||
|
||||
volumes:
|
||||
omniroute-data:
|
||||
```
|
||||
|
||||
## Cloudflare Quick Tunnel
|
||||
|
||||
Dashboard support for Docker deployments includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL.
|
||||
|
||||
Endpoint tunnel panels (Cloudflare, Tailscale, ngrok) can be shown or hidden from `Settings → Appearance` without changing active tunnel state.
|
||||
|
||||
### Tunnel Notes
|
||||
|
||||
- Quick Tunnel URLs are temporary and change after every restart.
|
||||
- Quick Tunnels are not auto-restored after an OmniRoute or container restart. Re-enable them from the dashboard when needed.
|
||||
- Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`.
|
||||
- Managed Quick Tunnels default to HTTP/2 transport to avoid noisy QUIC UDP buffer warnings in constrained container environments. Set `CLOUDFLARED_PROTOCOL=quic` or `auto` if you want a different transport.
|
||||
- Docker images bundle system CA roots and pass them to managed `cloudflared`, which avoids TLS trust failures when the tunnel bootstraps inside the container.
|
||||
- Set `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` if you want OmniRoute to use an existing binary instead of downloading one.
|
||||
|
||||
## Image Tags
|
||||
|
||||
| Image | Tag | Size | Description |
|
||||
| ------------------------ | -------- | ------ | --------------------- |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release |
|
||||
| `diegosouzapw/omniroute` | `3.7.8` | ~250MB | Current version |
|
||||
|
||||
Multi-platform: AMD64 + ARM64 native (Apple Silicon, AWS Graviton, Raspberry Pi).
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **SQLite WAL Mode:** `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40`.
|
||||
- **`DISABLE_SQLITE_AUTO_BACKUP`:** Set to `true` if backups are managed externally.
|
||||
- **Data Persistence:** Always mount a volume to `/app/data` to persist your database, keys, and configurations across container restarts.
|
||||
- **Port Configuration:** Override `PORT` environment variable to change the default `20128` port.
|
||||
|
||||
## See Also
|
||||
|
||||
- [VM Deployment Guide](VM_DEPLOYMENT_GUIDE.md) — VM + nginx + Cloudflare setup
|
||||
- [Fly.io Deployment Guide](FLY_IO_DEPLOYMENT_GUIDE.md) — Deploy to Fly.io
|
||||
- [Environment Config](ENVIRONMENT.md) — Complete `.env` reference
|
||||
407
docs/FREE_TIERS.md
Normal file
407
docs/FREE_TIERS.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# 🆓 Free LLM API Providers — Consolidated Directory
|
||||
|
||||
> **The ultimate aggregated reference for all permanently free LLM API providers.**
|
||||
> Consolidated from 6 community repositories. Use with OmniRoute to route through 25+ free providers simultaneously.
|
||||
|
||||
_Last consolidated: May 2026 · Sources: awesome-free-llm-apis, awesome-free-llm-apis2, free-llm-api-resources, Free-LLM-Collection, FREE-LLM-API-Provider, gpt4free_
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Comparison](#quick-comparison)
|
||||
- [Provider APIs (First-Party)](#provider-apis-first-party)
|
||||
- [Inference Providers (Third-Party)](#inference-providers-third-party)
|
||||
- [China-Based Providers](#china-based-providers)
|
||||
- [Trial Credit Providers](#trial-credit-providers)
|
||||
- [Using with OmniRoute](#using-with-omniroute)
|
||||
- [Glossary](#glossary)
|
||||
|
||||
---
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
All free providers at a glance, sorted by generosity of free tier:
|
||||
|
||||
| Provider | Type | Best Free Model | RPM | RPD | Tokens | OpenAI Compat | Speed |
|
||||
| ----------------- | --------- | ---------------------- | ------- | ------------ | ---------------- | --------------- | --------- |
|
||||
| **Groq** | Inference | Llama 3.3 70B | 30 | 14,400 | 6K TPM | ✅ | 🟢 Fast |
|
||||
| **Cerebras** | Inference | Qwen3 235B | 30 | 14,400 | 1M TPD | ✅ | 🟢 Fast |
|
||||
| **Mistral AI** | Provider | Mistral Large 3 | 60 | Unlimited | 1B/month | ✅ | 🟡 Medium |
|
||||
| **Google Gemini** | Provider | Gemini 2.5 Flash | 5–15 | 20–1,500 | 250K TPM | ✅ | 🟢 Fast |
|
||||
| **NVIDIA NIM** | Inference | 129 models | 40 | — | — | ✅ | 🟡 Medium |
|
||||
| **Ollama Cloud** | Inference | 400+ models | — | — | Session limits | ❌ (Ollama API) | 🟡 Medium |
|
||||
| **OpenRouter** | Inference | 35+ free models | 20 | 50–1,000 | — | ✅ | 🟡 Medium |
|
||||
| **GitHub Models** | Inference | GPT-4.1, GPT-5 | 10–15 | 50–150 | 8K in/4K out | ✅ | 🟡 Medium |
|
||||
| **Cloudflare AI** | Inference | 50+ models | — | 10K neurons | — | ⚠️ Partial | 🟡 Medium |
|
||||
| **Hugging Face** | Inference | Thousands | — | — | $0.10/mo credits | ✅ | 🔴 Slow |
|
||||
| **Cohere** | Provider | Command A (111B) | 20 | — | 1K calls/month | ⚠️ Partial | 🟡 Medium |
|
||||
| **Pollinations** | Inference | Text+Image+Video+Audio | — | Hourly reset | — | ✅ | 🟡 Medium |
|
||||
| **Z.AI (Zhipu)** | Provider | GLM-4.7-Flash | — | — | Undocumented | ✅ | 🟡 Medium |
|
||||
| **SiliconFlow** | Inference | Qwen3-8B | 1,000 | — | 50K TPM | ✅ | 🟡 Medium |
|
||||
| **Kilo Code** | Inference | Free auto-router | ~200/hr | — | — | ✅ | 🟡 Medium |
|
||||
| **LLM7.io** | Inference | 30+ models | 15–30 | — | — | ✅ | 🟡 Medium |
|
||||
| **Kluster AI** | Inference | DeepSeek-R1 | — | — | Undocumented | ✅ | 🟡 Medium |
|
||||
| **ModelScope** | Inference | Qwen, DeepSeek | — | 2,000 | ≤500/model/day | ✅ | 🟡 Medium |
|
||||
| **IBM watsonx** | Provider | Granite models | 2/sec | — | 300K/month | ❌ | 🟡 Medium |
|
||||
|
||||
---
|
||||
|
||||
## Provider APIs (First-Party)
|
||||
|
||||
APIs from the companies that train or fine-tune the models.
|
||||
|
||||
### Google Gemini 🇺🇸
|
||||
|
||||
🔗 [Get API Key](https://aistudio.google.com/app/apikey) · Base URL: `https://generativelanguage.googleapis.com/v1beta`
|
||||
|
||||
> ⚠️ Free tier NOT available in EU/UK/Switzerland. Prompts may be used by Google to improve products.
|
||||
|
||||
| Model | Context | Max Output | Modality | RPM | RPD |
|
||||
| --------------------------------- | ------- | ---------- | ---------------------- | --- | ------ |
|
||||
| Gemini 2.5 Flash / Gemini 3 Flash | 1M | 65K | Text+Image+Audio+Video | 5 | 20 |
|
||||
| Gemini 2.5 Flash-Lite | 1M | 65K | Text+Image+Audio+Video | 10 | 20 |
|
||||
| Gemini 3.1 Flash-Lite | 1M | 65K | Text+Image+Audio+Video | 15 | 1,500 |
|
||||
| Gemma 4 26B/31B | — | — | Text | 15 | 1,500 |
|
||||
| Gemma 3 (1B/4B/12B/27B) | — | — | Text | 30 | 14,400 |
|
||||
|
||||
### Mistral AI 🇫🇷
|
||||
|
||||
🔗 [Get API Key](https://console.mistral.ai/api-keys) · Base URL: `https://api.mistral.ai/v1`
|
||||
|
||||
Free "Experiment" plan, no credit card. ~1B tokens/month. Requires phone verification.
|
||||
|
||||
| Model | Context | Max Output | Modality | Rate Limit |
|
||||
| ------------------ | ------- | ---------- | --------------- | --------------- |
|
||||
| Mistral Small 4 | 256K | 256K | Text+Image+Code | 1 RPS, 500K TPM |
|
||||
| Mistral Medium 3 | 128K | 128K | Text | 1 RPS, 500K TPM |
|
||||
| Mistral Large 3 | 256K | 256K | Text | 1 RPS, 500K TPM |
|
||||
| Mistral Nemo (12B) | 128K | 128K | Text | 1 RPS, 500K TPM |
|
||||
| Codestral | 256K | 256K | Code | 30 RPM, 2K RPD |
|
||||
| Pixtral Large | 128K | 128K | Text+Image | 1 RPS, 500K TPM |
|
||||
|
||||
### Cohere 🇨🇦
|
||||
|
||||
🔗 [Get API Key](https://dashboard.cohere.com/api-keys) · Base URL: `https://api.cohere.com/v2`
|
||||
|
||||
Free "Trial" key. 1,000 API calls/month. Non-commercial use only. 20 RPM.
|
||||
|
||||
| Model | Context | Max Output | Modality |
|
||||
| ------------------- | ------- | ---------- | ----------------------- |
|
||||
| Command A (111B) | 256K | 4K | Text |
|
||||
| Command A Reasoning | 256K | 4K | Text (reasoning) |
|
||||
| Command A Vision | 256K | 4K | Text+Image |
|
||||
| Command A Translate | 256K | 4K | Translation |
|
||||
| Command R+ | 128K | 4K | Text |
|
||||
| Command R | 128K | 4K | Text |
|
||||
| Command R7B | 128K | 4K | Text |
|
||||
| Embed 4 | — | — | Embeddings (Text+Image) |
|
||||
| Rerank 3.5 | — | — | Reranking |
|
||||
|
||||
### Z.AI (Zhipu AI) 🇨🇳
|
||||
|
||||
🔗 [Get API Key](https://open.bigmodel.cn/usercenter/apikeys) · Base URL: `https://open.bigmodel.cn/api/paas/v4`
|
||||
|
||||
Permanent free models, no credit card. No published rate limits.
|
||||
|
||||
| Model | Context | Max Output | Modality |
|
||||
| --------------- | ------- | ---------- | ---------- |
|
||||
| GLM-4.7-Flash | 200K | 128K | Text |
|
||||
| GLM-4.5-Flash | 128K | ~8K | Text |
|
||||
| GLM-4.6V-Flash | 128K | ~4K | Text+Image |
|
||||
| GLM-5 / GLM-5.1 | — | — | Text |
|
||||
|
||||
### IBM watsonx 🇺🇸
|
||||
|
||||
🔗 [Pricing](https://www.ibm.com/products/watsonx-ai/pricing)
|
||||
|
||||
Free tier: 2 RPS, 300K tokens/month. Granite foundation models.
|
||||
|
||||
---
|
||||
|
||||
## Inference Providers (Third-Party)
|
||||
|
||||
### Groq 🇺🇸
|
||||
|
||||
🔗 [Get API Key](https://console.groq.com/keys) · Base URL: `https://api.groq.com/openai/v1`
|
||||
|
||||
Ultra-fast LPU inference (~300–500 tok/s). No credit card required.
|
||||
|
||||
| Model | RPM | RPD | TPM | Modality |
|
||||
| ---------------------------------- | --- | ------ | --- | ---------------- |
|
||||
| llama-3.3-70b-versatile | 30 | 1,000 | 12K | Text |
|
||||
| llama-3.1-8b-instant | 30 | 14,400 | 6K | Text |
|
||||
| llama-4-scout-17b-16e-instruct | 30 | 1,000 | 30K | Text+Vision |
|
||||
| llama-4-maverick-17b-128e-instruct | 30 | 1,000 | 6K | Text+Vision |
|
||||
| qwen3-32b | 60 | 1,000 | 6K | Text |
|
||||
| kimi-k2-instruct | 60 | 1,000 | 10K | Text |
|
||||
| gpt-oss-120b / gpt-oss-20b | 30 | 1,000 | 8K | Text |
|
||||
| deepseek-r1-distill-70b | 30 | 14,400 | — | Text (reasoning) |
|
||||
| whisper-large-v3 / v3-turbo | 20 | 2,000 | — | Audio→Text |
|
||||
|
||||
### Cerebras 🇺🇸
|
||||
|
||||
🔗 [Get API Key](https://cloud.cerebras.ai/) · Base URL: `https://api.cerebras.ai/v1`
|
||||
|
||||
Wafer-scale chip inference (~2,600 tok/s). 1M tokens/day cap.
|
||||
|
||||
| Model | RPM | RPH | RPD | TPM | TPD |
|
||||
| ------------------------------ | --- | --- | ------ | --- | --- |
|
||||
| gpt-oss-120b | 30 | 900 | 14,400 | 64K | 1M |
|
||||
| llama3.1-8b | 30 | 900 | 14,400 | 60K | 1M |
|
||||
| qwen-3-235b-a22b-instruct-2507 | 30 | 900 | 14,400 | 60K | 1M |
|
||||
| zai-glm-4.7 | 10 | 100 | 100 | 60K | 1M |
|
||||
|
||||
### NVIDIA NIM 🇺🇸
|
||||
|
||||
🔗 [Explore Models](https://build.nvidia.com/explore/discover) · Base URL: `https://integrate.api.nvidia.com/v1`
|
||||
|
||||
Free with NVIDIA Developer Program. **129 models**, 40 RPM. Phone verification required.
|
||||
|
||||
**Notable models:** DeepSeek-R1, DeepSeek-V3.2, Nemotron Ultra 253B, Llama 3.1 405B, Qwen3 Coder 480B, Mistral Large 3, Kimi K2, GLM-5.1, MiniMax M2.7, Gemma 4 31B, + 100 more.
|
||||
|
||||
### OpenRouter 🇺🇸
|
||||
|
||||
🔗 [Get API Key](https://openrouter.ai/keys) · Base URL: `https://openrouter.ai/api/v1`
|
||||
|
||||
35+ free models (suffix `:free`). 20 RPM.
|
||||
|
||||
| Credits Purchased | RPD |
|
||||
| ----------------- | ----- |
|
||||
| < $10 | 50 |
|
||||
| ≥ $10 (one-time) | 1,000 |
|
||||
|
||||
**Notable free models:** DeepSeek R1, DeepSeek V3, Qwen3 Coder 480B, Llama 4 Scout/Maverick, GPT-OSS 120B, Nemotron 3 Super 120B, MiniMax M2.5, Gemma 4 31B, Devstral, + 23 more.
|
||||
|
||||
### GitHub Models 🇺🇸
|
||||
|
||||
🔗 [Marketplace](https://github.com/marketplace/models) · Base URL: `https://models.inference.ai.azure.com`
|
||||
|
||||
Free for all GitHub users. 45+ models including frontier models.
|
||||
|
||||
| Tier | RPM | RPD | Tokens/Request |
|
||||
| ----------------------- | --- | --- | -------------- |
|
||||
| Low tier models | 15 | 150 | 8K in / 4K out |
|
||||
| High tier models | 10 | 50 | 8K in / 4K out |
|
||||
| DeepSeek-R1 / MAI-DS-R1 | 1 | 8 | 4K in / 4K out |
|
||||
| Grok-3 | 1 | 15 | 4K in / 4K out |
|
||||
|
||||
**Notable models:** GPT-4.1, GPT-4o, GPT-5, GPT-5-mini, o3-mini, o4-mini, DeepSeek-R1, Llama 4 Scout/Maverick, Codestral, Mistral Medium 3, Phi-4, Grok-3.
|
||||
|
||||
### Cloudflare Workers AI 🇺🇸
|
||||
|
||||
🔗 [Get Token](https://dash.cloudflare.com/profile/api-tokens) · 10,000 Neurons/day free. 50+ models.
|
||||
|
||||
**Notable models:** Llama 3.3 70B, Llama 4 Scout, Qwen3 30B-A3B, QwQ 32B, DeepSeek R1 Distill, Gemma 4 26B, GLM 4.7 Flash, Nemotron 3 120B, Kimi K2.5/K2.6, Mistral Small 3.1, GPT-OSS 120B/20B, + 40 more.
|
||||
|
||||
### Hugging Face 🇺🇸
|
||||
|
||||
🔗 [Get Token](https://huggingface.co/settings/tokens) · Base URL: `https://api-inference.huggingface.co/v1`
|
||||
|
||||
$0.10/month free credits (auto-replenished). Thousands of models. Serverless limited to <10GB models.
|
||||
|
||||
### Ollama Cloud 🇺🇸
|
||||
|
||||
🔗 [Get Key](https://ollama.com/settings/keys) · Base URL: `https://api.ollama.com`
|
||||
|
||||
400+ models. Session/weekly limits (unpublished). NOT OpenAI SDK-compatible.
|
||||
|
||||
**Notable models:** GPT-OSS 120B, DeepSeek V3.2/V4, Kimi K2/K2.5/K2.6, GLM-5/5.1, Qwen3 Coder 480B, Gemini 3 Flash, MiniMax M2.7, Cogito 2.1 671B, Nemotron 3 Super 120B.
|
||||
|
||||
### Pollinations AI 🇩🇪
|
||||
|
||||
🔗 [Get Key](https://enter.pollinations.ai) · Base URL: `https://gen.pollinations.ai/v1`
|
||||
|
||||
No sign-up required for basic use. Unique: **text + image + video + audio** all free.
|
||||
|
||||
**Text models:** openai, openai-large, openai-reasoning, gemini, mistral, llama.
|
||||
**Image models:** flux, gpt-image, seedream, kontext.
|
||||
**Video:** wan-fast. **Audio:** tts-1, 30+ ElevenLabs voices.
|
||||
|
||||
### SiliconFlow 🇨🇳
|
||||
|
||||
🔗 [Get Key](https://cloud.siliconflow.cn/account/ak) · Base URL: `https://api.siliconflow.cn/v1`
|
||||
|
||||
14 CNY signup credits. Permanently free models: 1,000 RPM, 50K TPM.
|
||||
|
||||
| Model | Context | Modality |
|
||||
| --------------------------- | ------- | ---------------- |
|
||||
| Qwen/Qwen3-8B | 131K | Text |
|
||||
| DeepSeek-R1-0528-Qwen3-8B | ~33K | Text (reasoning) |
|
||||
| DeepSeek-R1-Distill-Qwen-7B | 131K | Text (reasoning) |
|
||||
| THUDM/glm-4-9b-chat | 32K | Text |
|
||||
| THUDM/GLM-4.1V-9B-Thinking | 66K | Vision+Text |
|
||||
| DeepSeek-OCR | — | Vision (OCR) |
|
||||
|
||||
### Kilo Code 🇺🇸
|
||||
|
||||
🔗 [Get Key](https://kilo.ai) · Base URL: `https://api.kilo.ai/api/gateway`
|
||||
|
||||
Free models, no credit card. ~200 req/hr. Auto-router `kilo-auto/free`.
|
||||
|
||||
### LLM7.io 🇬🇧
|
||||
|
||||
🔗 [Get Token](https://token.llm7.io) · Base URL: `https://api.llm7.io/v1`
|
||||
|
||||
30+ models. 15 RPM (30 RPM with free token). No registration for basic access.
|
||||
|
||||
### Kluster AI 🇺🇸
|
||||
|
||||
🔗 [Get Key](https://platform.kluster.ai/apikeys) · DeepSeek-R1, Llama 4 Maverick, Qwen3-235B + more.
|
||||
|
||||
### OpenCode Zen
|
||||
|
||||
🔗 [Docs](https://opencode.ai/docs/zen/) · Free models (Big Pickle Stealth, MiniMax M2.5 Free, Arcee Large).
|
||||
|
||||
### Vercel AI Gateway
|
||||
|
||||
🔗 [Docs](https://vercel.com/docs/ai-gateway) · $5/month free credits. Routes to various providers.
|
||||
|
||||
---
|
||||
|
||||
## China-Based Providers
|
||||
|
||||
### ModelScope (魔搭社区) 🇨🇳
|
||||
|
||||
🔗 [Get Token](https://modelscope.cn/my/myaccesstoken) · Base URL: `https://api-inference.modelscope.cn/v1`
|
||||
|
||||
2,000 req/day total, ≤500/model/day. Requires Alibaba Cloud account + real-name verification.
|
||||
|
||||
**Models:** DeepSeek V4 Pro/Flash, DeepSeek V3.2, GLM-5/5.1, MiniMax M2.5, Qwen3-235B, Qwen3 Coder 480B, Ling-2.6-1T.
|
||||
|
||||
### Tencent Hunyuan (腾讯混元)
|
||||
|
||||
Hunyuan-Lite: free. Other models: 100M tokens free (1-year expiry).
|
||||
|
||||
### Volcengine (火山引擎)
|
||||
|
||||
500 resource points/day. Tongyi Qwen free (100 calls/day). Doubao models with tiered pricing.
|
||||
|
||||
### ChatAnywhere
|
||||
|
||||
🔗 Base URL: `https://api.chatanywhere.tech` · GPT-5.4-mini, DeepSeek-V4, and more.
|
||||
|
||||
### InternAI (书生)
|
||||
|
||||
🔗 Base URL: `https://chat.intern-ai.org.cn/api/v1` · 10 RPM. Keys valid 6 months.
|
||||
|
||||
**Models:** intern-latest, intern-s1-pro, internvl3.5-241b-a28b.
|
||||
|
||||
### Bigmodel (智谱)
|
||||
|
||||
🔗 Base URL: `https://open.bigmodel.cn/api/paas/v4/` · 30 concurrent requests.
|
||||
|
||||
**Models:** GLM-4-Flash, GLM-4V-Flash, GLM-4.1V-Thinking-Flash, GLM-4.6V-Flash, GLM-4.7-Flash.
|
||||
|
||||
---
|
||||
|
||||
## Trial Credit Providers
|
||||
|
||||
These offer one-time or time-limited credits (not permanent free tiers):
|
||||
|
||||
| Provider | Credits | Expiry | Notable Models |
|
||||
| ---------------------------------------------------------- | ---------------- | -------- | ----------------------------- |
|
||||
| [Baseten](https://app.baseten.co/) | $30 | — | Any model (pay by compute) |
|
||||
| [NLP Cloud](https://nlpcloud.com) | $15 | — | Various open models |
|
||||
| [AI21](https://studio.ai21.com/) | $10 | 3 months | Jamba family |
|
||||
| [Upstage](https://console.upstage.ai/) | $10 | 3 months | Solar Pro/Mini |
|
||||
| [Modal](https://modal.com) | $5/mo | Monthly | Any model (compute time) |
|
||||
| [SambaNova](https://cloud.sambanova.ai/) | $5 | 3 months | Llama 3.3, Qwen3, DeepSeek R1 |
|
||||
| [Scaleway](https://console.scaleway.com/generative-api) | 1M tokens | One-time | Llama 3.3, Gemma 3, GPT-OSS |
|
||||
| [Alibaba Cloud](https://bailian.console.alibabacloud.com/) | 1M tokens/model | — | Qwen family |
|
||||
| [Fireworks](https://fireworks.ai/) | $1 | — | Various open models |
|
||||
| [Nebius](https://tokenfactory.nebius.com/) | $1 | — | Various open models |
|
||||
| [Inference.net](https://inference.net) | $1 (+$25 survey) | — | Various open models |
|
||||
| [Hyperbolic](https://app.hyperbolic.ai/) | $1 | — | DeepSeek V3, Llama 3.3 |
|
||||
| [Novita](https://novita.ai/) | $0.50 | 1 year | Various open models |
|
||||
|
||||
---
|
||||
|
||||
## Using with OmniRoute
|
||||
|
||||
OmniRoute supports **all providers listed above** as connections. Here's how to maximize free usage:
|
||||
|
||||
### 1. Add Multiple Free Providers
|
||||
|
||||
```
|
||||
Dashboard → Providers → Add Connection
|
||||
```
|
||||
|
||||
Add API keys for Groq, Cerebras, Mistral, Google Gemini, OpenRouter, GitHub Models, etc.
|
||||
|
||||
### 2. Create a Free-Tier Combo
|
||||
|
||||
```
|
||||
Dashboard → Combos → Create Combo → Add all free providers as targets
|
||||
```
|
||||
|
||||
Use the **"priority"** or **"round-robin"** strategy to distribute load across free tiers.
|
||||
|
||||
### 3. Recommended Free Combo Strategy
|
||||
|
||||
| Priority | Provider | Why |
|
||||
| -------- | ----------------- | --------------------------------------------- |
|
||||
| 1 | **Groq** | Fastest inference, 14,400 RPD on small models |
|
||||
| 2 | **Cerebras** | 1M TPD, fast wafer-scale chips |
|
||||
| 3 | **Mistral** | 1B tokens/month, large model selection |
|
||||
| 4 | **Google Gemini** | 1M context, multimodal |
|
||||
| 5 | **NVIDIA NIM** | 129 models, 40 RPM |
|
||||
| 6 | **OpenRouter** | 35+ free models as final fallback |
|
||||
|
||||
### 4. Environment Variables
|
||||
|
||||
```bash
|
||||
# These providers work out of the box with OmniRoute:
|
||||
GROQ_API_KEY=your-key
|
||||
CEREBRAS_API_KEY=your-key
|
||||
MISTRAL_API_KEY=your-key
|
||||
GOOGLE_AI_API_KEY=your-key
|
||||
NVIDIA_API_KEY=your-key
|
||||
OPENROUTER_API_KEY=your-key
|
||||
GITHUB_TOKEN=your-token
|
||||
CLOUDFLARE_API_TOKEN=your-token
|
||||
COHERE_API_KEY=your-key
|
||||
SILICONFLOW_API_KEY=your-key
|
||||
```
|
||||
|
||||
### 5. Estimated Free Capacity
|
||||
|
||||
With all top-6 providers combined in a combo:
|
||||
|
||||
| Metric | Combined Free Capacity |
|
||||
| -------------------- | ---------------------- |
|
||||
| **Requests/Day** | ~31,000+ RPD |
|
||||
| **Tokens/Month** | ~32B+ tokens |
|
||||
| **Models Available** | 200+ unique models |
|
||||
| **Cost** | $0.00 |
|
||||
|
||||
---
|
||||
|
||||
## Glossary
|
||||
|
||||
| Term | Meaning |
|
||||
| ----------- | ------------------------------------------- |
|
||||
| **RPM** | Requests per minute |
|
||||
| **RPD** | Requests per day |
|
||||
| **RPH** | Requests per hour |
|
||||
| **RPS** | Requests per second |
|
||||
| **TPM** | Tokens per minute |
|
||||
| **TPD** | Tokens per day |
|
||||
| **Neurons** | Cloudflare's compute unit (~1 output token) |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
This document consolidates data from 6 community repositories:
|
||||
|
||||
| Repository | Focus |
|
||||
| -------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| [awesome-free-llm-apis](https://github.com/mnfst/awesome-free-llm-apis) | Curated list with detailed model tables |
|
||||
| [awesome-free-llm-apis2](https://github.com/) | Extended list with speed tiers and code snippets |
|
||||
| [free-llm-api-resources](https://github.com/) | Auto-generated model lists with trial credits |
|
||||
| [Free-LLM-Collection](https://github.com/for-the-zero/Free-LLM-Collection) | Chinese + global providers with rate limits |
|
||||
| [FREE-LLM-API-Provider](https://github.com/CYBIRD-D/FREE-LLM-API-Provider) | Deep provider analysis with CN platforms |
|
||||
| [gpt4free](https://github.com/xtekky/gpt4free) | Config-based routing with quota awareness |
|
||||
|
||||
> ⚠️ **Disclaimer:** Rate limits change frequently. Always verify with the provider's official documentation before relying on specific limits. Trial credits and time-limited promotions are separated from permanent free tiers.
|
||||
596
docs/PROXY_GUIDE.md
Normal file
596
docs/PROXY_GUIDE.md
Normal file
@@ -0,0 +1,596 @@
|
||||
# 🌐 OmniRoute Proxy Guide
|
||||
|
||||
> **Bypass geographic blocks, protect your identity, and route AI traffic through any proxy — with zero configuration complexity.**
|
||||
|
||||
OmniRoute includes a full-featured proxy management system that lets you route upstream AI provider traffic through HTTP, HTTPS, or SOCKS5 proxies. Whether you're in a blocked region, need IP rotation, or want stealth fingerprinting — this guide covers everything.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Why Use Proxies?](#why-use-proxies)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [3-Level Proxy System](#3-level-proxy-system)
|
||||
- [Proxy Registry (CRUD)](#proxy-registry-crud)
|
||||
- [1proxy Free Marketplace](#1proxy-free-proxy-marketplace)
|
||||
- [Proxy Rotation](#proxy-rotation)
|
||||
- [Anti-Detection & Stealth](#anti-detection--stealth)
|
||||
- [Upstream Proxy Modes](#upstream-proxy-modes)
|
||||
- [Dashboard UI](#dashboard-ui)
|
||||
- [API Reference](#api-reference)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Why Use Proxies?
|
||||
|
||||
Many AI providers restrict access by geographic region. Developers in **Russia, China, Iran, Cuba, Turkey**, and other countries encounter errors like:
|
||||
|
||||
```
|
||||
unsupported_country_region_territory
|
||||
```
|
||||
|
||||
Even outside blocked regions, proxies are useful for:
|
||||
|
||||
| Use Case | Description |
|
||||
| --------------------- | --------------------------------------------------------------- |
|
||||
| **Geographic bypass** | Access OpenAI, Anthropic, Codex, Copilot from blocked countries |
|
||||
| **IP rotation** | Distribute requests across multiple IPs to avoid rate limiting |
|
||||
| **Privacy** | Hide your real IP from upstream providers |
|
||||
| **Compliance** | Route traffic through specific jurisdictions |
|
||||
| **Testing** | Simulate requests from different regions |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ OmniRoute Server │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ Proxy │ │ Proxy │ │ Proxy │ │
|
||||
│ │ Registry │───▶│ Dispatcher │───▶│ Fetch (undici) │ │
|
||||
│ │ (SQLite) │ │ (cached) │ │ │ │
|
||||
│ └─────────────┘ └──────────────┘ └────────┬─────────┘ │
|
||||
│ ▲ │ │
|
||||
│ │ ▼ │
|
||||
│ ┌──────┴──────┐ ┌──────────────────┐ │
|
||||
│ │ 1proxy Sync │ │ Upstream │ │
|
||||
│ │ (free pool) │ │ Provider API │ │
|
||||
│ └─────────────┘ └──────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
| Component | File | Role |
|
||||
| -------------------- | -------------------------------------------- | ---------------------------------------------------------- |
|
||||
| **Proxy Registry** | `src/lib/db/proxies.ts` | CRUD for proxy entries + scope assignments |
|
||||
| **Proxy Dispatcher** | `open-sse/utils/proxyDispatcher.ts` | Creates `undici` ProxyAgent/SOCKS dispatchers with caching |
|
||||
| **Proxy Fetch** | `open-sse/utils/proxyFetch.ts` | Wraps `fetch()` with proxy dispatcher injection |
|
||||
| **Settings Route** | `src/app/api/settings/proxy/route.ts` | Legacy proxy config API (GET/PUT/DELETE) |
|
||||
| **Management Route** | `src/app/api/v1/management/proxies/route.ts` | Registry CRUD API (GET/POST/PATCH/DELETE) |
|
||||
| **1proxy DB** | `src/lib/db/oneproxy.ts` | Free proxy marketplace persistence |
|
||||
| **1proxy Sync** | `src/lib/oneproxySync.ts` | Fetches proxies from 1proxy API |
|
||||
| **1proxy Rotator** | `src/lib/oneproxyRotator.ts` | Rotation strategies (quality/random/sequential) |
|
||||
|
||||
---
|
||||
|
||||
## 3-Level Proxy System
|
||||
|
||||
OmniRoute supports proxy configuration at **four independent scopes**, resolved in priority order:
|
||||
|
||||
```
|
||||
Priority Resolution Order (highest → lowest):
|
||||
|
||||
1. 🔵 Account/Connection Proxy → per API key / OAuth connection
|
||||
2. 🟡 Provider Proxy → per provider (e.g., all OpenAI traffic)
|
||||
3. 🟠 Combo Proxy → per combo/routing configuration
|
||||
4. 🟢 Global Proxy → all traffic, all providers
|
||||
```
|
||||
|
||||
### How Resolution Works
|
||||
|
||||
When OmniRoute sends a request to an upstream provider, it calls `resolveProxyForConnectionFromRegistry()` which checks each level in order:
|
||||
|
||||
1. **Account-level** — Is there a proxy assigned to this specific connection ID?
|
||||
2. **Provider-level** — Is there a proxy assigned to this provider (e.g., `openai`)?
|
||||
3. **Global-level** — Is there a global proxy configured?
|
||||
4. **No proxy** — Direct connection to the provider.
|
||||
|
||||
The first match wins. This means you can set a global proxy as a fallback but override it for specific providers or connections.
|
||||
|
||||
### What Gets Proxied
|
||||
|
||||
| Traffic Type | Proxied? | Notes |
|
||||
| -------------------- | -------- | --------------------------------------------- |
|
||||
| Chat completions | ✅ | All `/v1/chat/completions` requests |
|
||||
| Embeddings | ✅ | `/v1/embeddings` |
|
||||
| Image generation | ✅ | `/v1/images/generations` |
|
||||
| Audio (TTS/STT) | ✅ | `/v1/audio/*` |
|
||||
| OAuth token exchange | ✅ | Solves `unsupported_country_region_territory` |
|
||||
| Connection tests | ✅ | "Test Connection" button uses proxy |
|
||||
| Token refresh | ✅ | Background OAuth renewal |
|
||||
| Model sync | ✅ | Model listing and discovery |
|
||||
|
||||
---
|
||||
|
||||
## Proxy Registry (CRUD)
|
||||
|
||||
The proxy registry is a SQLite table (`proxy_registry`) that stores all your proxies. Each proxy has:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ------- | ----------------------------------- |
|
||||
| `id` | UUID | Unique identifier |
|
||||
| `name` | String | Human-readable label |
|
||||
| `type` | String | Protocol: `http`, `https`, `socks5` |
|
||||
| `host` | String | Proxy hostname or IP |
|
||||
| `port` | Integer | Port number |
|
||||
| `username` | String | Auth username (encrypted at rest) |
|
||||
| `password` | String | Auth password (encrypted at rest) |
|
||||
| `region` | String | Geographic region label |
|
||||
| `notes` | String | Free-text notes |
|
||||
| `status` | String | `active` or `inactive` |
|
||||
| `source` | String | `manual` or `oneproxy` |
|
||||
|
||||
### Creating a Proxy
|
||||
|
||||
**Via Dashboard:**
|
||||
|
||||
1. Go to **Settings → Proxy**
|
||||
2. Click **Add Proxy**
|
||||
3. Fill in the type, host, port, and optional auth credentials
|
||||
4. Save
|
||||
|
||||
**Via API:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:20128/api/v1/management/proxies \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "US Proxy",
|
||||
"type": "http",
|
||||
"host": "proxy.example.com",
|
||||
"port": 8080,
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"region": "US"
|
||||
}'
|
||||
```
|
||||
|
||||
### Updating a Proxy
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://localhost:20128/api/v1/management/proxies \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "proxy-uuid-here",
|
||||
"host": "new-proxy.example.com",
|
||||
"port": 9090
|
||||
}'
|
||||
```
|
||||
|
||||
> **Note:** Credentials are preserved unless you explicitly send non-empty replacements. Sending empty strings for `username`/`password` will keep the stored values.
|
||||
|
||||
### Deleting a Proxy
|
||||
|
||||
```bash
|
||||
# Fails if proxy is assigned to any scope
|
||||
curl -X DELETE "http://localhost:20128/api/v1/management/proxies?id=proxy-uuid"
|
||||
|
||||
# Force delete (removes assignments too)
|
||||
curl -X DELETE "http://localhost:20128/api/v1/management/proxies?id=proxy-uuid&force=1"
|
||||
```
|
||||
|
||||
### Listing Proxies
|
||||
|
||||
```bash
|
||||
curl "http://localhost:20128/api/v1/management/proxies?limit=50&offset=0"
|
||||
```
|
||||
|
||||
### Assigning Proxies to Scopes
|
||||
|
||||
```bash
|
||||
# Assign to global scope
|
||||
curl -X PUT http://localhost:20128/api/settings/proxy \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"level": "global", "proxy": {"type":"http","host":"proxy.example.com","port":8080}}'
|
||||
|
||||
# Assign to a specific provider
|
||||
curl -X PUT http://localhost:20128/api/settings/proxy \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"level": "provider", "id": "openai", "proxy": {"type":"socks5","host":"socks.example.com","port":1080}}'
|
||||
|
||||
# Assign to a specific connection/key
|
||||
curl -X PUT http://localhost:20128/api/settings/proxy \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"level": "key", "id": "connection-uuid", "proxy": {"type":"http","host":"key-proxy.com","port":3128}}'
|
||||
```
|
||||
|
||||
### Resolving Effective Proxy
|
||||
|
||||
Check which proxy would be used for a given connection:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:20128/api/settings/proxy?resolve=connection-uuid"
|
||||
```
|
||||
|
||||
Returns the resolved proxy with its level (`account`, `provider`, or `global`) and source.
|
||||
|
||||
### Bulk Assignment
|
||||
|
||||
Assign one proxy to multiple providers or connections at once:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:20128/api/v1/management/proxies/bulk-assign \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"scope": "provider",
|
||||
"scopeIds": ["openai", "anthropic", "codex"],
|
||||
"proxyId": "proxy-uuid"
|
||||
}'
|
||||
```
|
||||
|
||||
### Import/Export
|
||||
|
||||
Proxies are included in the **Backup/Restore** system. When you export your OmniRoute configuration:
|
||||
|
||||
1. Go to **Dashboard → Settings → Backup**
|
||||
2. Click **Export** — proxy registry and assignments are included
|
||||
3. To restore, click **Import** and upload the backup file
|
||||
|
||||
The proxy registry also supports **upsert by host+port** — if you import a proxy that already exists (same host and port), it updates instead of creating a duplicate.
|
||||
|
||||
### Legacy Migration
|
||||
|
||||
If you configured proxies in an older version (pre-registry), OmniRoute automatically migrates them:
|
||||
|
||||
```
|
||||
Legacy key_value store → proxy_registry + proxy_assignments
|
||||
```
|
||||
|
||||
This happens once on first startup after upgrade. Use `migrateLegacyProxyConfigToRegistry({ force: true })` to re-run.
|
||||
|
||||
---
|
||||
|
||||
## 1proxy Free Proxy Marketplace
|
||||
|
||||
> 🆕 **Contributed by [@oyi77](https://github.com/oyi77)** — PR [#1847](https://github.com/diegosouzapw/OmniRoute/pull/1847) (Issue [#1788](https://github.com/diegosouzapw/OmniRoute/issues/1788))
|
||||
|
||||
OmniRoute integrates with the **[1proxy](https://1proxy-api.aitradepulse.com)** community platform to provide access to **hundreds of free, validated proxies** from around the world. This is perfect for users who don't have their own proxy infrastructure.
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
┌─────────────┐ Sync ┌─────────────────┐ Rotate ┌──────────┐
|
||||
│ 1proxy API │ ────────────▶ │ proxy_registry │ ────────────▶ │ Provider │
|
||||
│ (external) │ up to 500 │ source=oneproxy │ by quality │ API │
|
||||
└─────────────┘ proxies └─────────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
1. **Sync** — OmniRoute fetches validated proxies from the 1proxy API
|
||||
2. **Store** — Proxies are saved in the same `proxy_registry` table with `source = 'oneproxy'`
|
||||
3. **Filter** — Filter by protocol, country, quality score
|
||||
4. **Rotate** — Pick the best proxy using quality, random, or sequential strategies
|
||||
5. **Auto-degrade** — Failed proxies get their quality score reduced; below threshold → marked inactive
|
||||
|
||||
### Syncing Proxies
|
||||
|
||||
**Via Dashboard:**
|
||||
|
||||
1. Go to **Settings → 1proxy** tab
|
||||
2. Click **"Sync Now"**
|
||||
3. View stats: total proxies, active count, average quality, by-country breakdown
|
||||
|
||||
**Via API:**
|
||||
|
||||
```bash
|
||||
# Trigger sync
|
||||
curl -X POST http://localhost:20128/api/settings/oneproxy \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
|
||||
# Response:
|
||||
# { "success": true, "added": 127, "updated": 45, "failed": 2, "total": 172 }
|
||||
```
|
||||
|
||||
### Filtering Proxies
|
||||
|
||||
```bash
|
||||
# Filter by protocol
|
||||
curl "http://localhost:20128/api/settings/oneproxy?protocol=socks5"
|
||||
|
||||
# Filter by country
|
||||
curl "http://localhost:20128/api/settings/oneproxy?countryCode=US"
|
||||
|
||||
# Filter by minimum quality score
|
||||
curl "http://localhost:20128/api/settings/oneproxy?minQuality=80"
|
||||
|
||||
# Combine filters
|
||||
curl "http://localhost:20128/api/settings/oneproxy?protocol=http&countryCode=DE&minQuality=70"
|
||||
```
|
||||
|
||||
### Proxy Quality Scores
|
||||
|
||||
Each 1proxy proxy comes with metadata:
|
||||
|
||||
| Field | Description |
|
||||
| --------------- | -------------------------------------------- |
|
||||
| `qualityScore` | 0-100 rating from 1proxy validation |
|
||||
| `latencyMs` | Measured network latency |
|
||||
| `anonymity` | `transparent`, `anonymous`, or `elite` |
|
||||
| `googleAccess` | Whether the proxy can access Google services |
|
||||
| `countryCode` | Two-letter ISO country code |
|
||||
| `lastValidated` | Timestamp of last validation |
|
||||
|
||||
Quality scores are dynamically adjusted:
|
||||
|
||||
- **Failed requests** reduce the score by 10 points
|
||||
- **Score drops to ≤10** → proxy is marked `inactive`
|
||||
- Inactive proxies are excluded from rotation
|
||||
|
||||
### Rotation Strategies
|
||||
|
||||
```bash
|
||||
# Rotate by quality (best proxy first) — default
|
||||
curl -X POST http://localhost:20128/api/settings/oneproxy/rotate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"strategy": "quality"}'
|
||||
|
||||
# Random rotation
|
||||
curl -X POST http://localhost:20128/api/settings/oneproxy/rotate \
|
||||
-d '{"strategy": "random"}'
|
||||
|
||||
# Sequential (least recently validated first)
|
||||
curl -X POST http://localhost:20128/api/settings/oneproxy/rotate \
|
||||
-d '{"strategy": "sequential"}'
|
||||
```
|
||||
|
||||
### Circuit Breaker
|
||||
|
||||
The 1proxy sync has a built-in circuit breaker:
|
||||
|
||||
- After **5 consecutive sync failures**, further sync attempts are blocked
|
||||
- Reset with: `resetOneproxyCircuitBreaker()` or restart the server
|
||||
- Sync status is available at `GET /api/settings/oneproxy?action=status`
|
||||
|
||||
### Clearing 1proxy Proxies
|
||||
|
||||
```bash
|
||||
# Delete a single 1proxy proxy
|
||||
curl -X DELETE "http://localhost:20128/api/settings/oneproxy?id=proxy-uuid"
|
||||
|
||||
# Clear ALL 1proxy proxies (manual proxies are untouched)
|
||||
curl -X DELETE "http://localhost:20128/api/settings/oneproxy?clearAll=1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Detection & Stealth
|
||||
|
||||
OmniRoute doesn't just route traffic through a proxy — it makes the traffic look legitimate:
|
||||
|
||||
### TLS Fingerprint Spoofing
|
||||
|
||||
Uses `wreq-js` to generate browser-like TLS fingerprints, bypassing bot detection systems that flag non-browser TLS handshakes.
|
||||
|
||||
### CLI Fingerprint Matching
|
||||
|
||||
The **CLI Fingerprint Toggle** (`Settings → Security`) reorders HTTP headers and JSON body fields to match the exact signature of native CLI binaries (Claude Code, Codex, etc.). This works **on top of** the proxy:
|
||||
|
||||
```
|
||||
Your IP (blocked) → Proxy IP (US) → Provider API
|
||||
+ TLS spoof
|
||||
+ CLI fingerprint
|
||||
```
|
||||
|
||||
You get both **IP masking** and **request authenticity** simultaneously.
|
||||
|
||||
### Proxy IP Preservation
|
||||
|
||||
Color-coded badges in the dashboard show which proxy level is active:
|
||||
|
||||
| Badge | Level | Meaning |
|
||||
| ----- | ---------- | ----------------------------------------- |
|
||||
| 🟢 | Global | All traffic goes through this proxy |
|
||||
| 🟡 | Provider | Only this provider's traffic is proxied |
|
||||
| 🔵 | Connection | This specific key/account uses this proxy |
|
||||
|
||||
The badge also shows the resolved proxy IP for verification.
|
||||
|
||||
---
|
||||
|
||||
## Upstream Proxy Modes
|
||||
|
||||
For providers that use the CLIProxyAPI pattern, OmniRoute supports three upstream proxy modes:
|
||||
|
||||
| Mode | Description |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `native` | OmniRoute handles proxy routing directly (default) |
|
||||
| `cliproxyapi` | Delegates to an external CLIProxyAPI instance |
|
||||
| `fallback` | Tries native first, falls back to CLIProxyAPI |
|
||||
|
||||
Configure per-provider:
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:20128/api/upstream-proxy/openai" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"mode": "native", "enabled": true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dashboard UI
|
||||
|
||||
### Settings → Proxy Tab
|
||||
|
||||
- **Global proxy** configuration (set once for all traffic)
|
||||
- **Per-provider proxy** overrides
|
||||
- **Per-connection proxy** assignments
|
||||
- **Connection test** through configured proxy
|
||||
- **Color-coded badges** showing active proxy level
|
||||
|
||||
### Settings → 1proxy Tab
|
||||
|
||||
- **Sync Now** button to fetch free proxies
|
||||
- **Stats cards**: Total, Active, Avg Quality, Last Sync
|
||||
- **Filters**: Protocol, Country Code, Min Quality
|
||||
- **Proxy table** with host, protocol, country, quality score, latency, anonymity, Google access
|
||||
- **Sync status** panel with success/failure tracking and consecutive failure count
|
||||
- **Clear All** to remove all 1proxy entries
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Proxy Settings API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ---------------------------------------------- | ----------------------- |
|
||||
| `GET` | `/api/settings/proxy` | Get full proxy config |
|
||||
| `GET` | `/api/settings/proxy?level=global` | Get global proxy |
|
||||
| `GET` | `/api/settings/proxy?level=provider&id=openai` | Get provider proxy |
|
||||
| `GET` | `/api/settings/proxy?resolve=connectionId` | Resolve effective proxy |
|
||||
| `PUT` | `/api/settings/proxy` | Update proxy config |
|
||||
| `DELETE` | `/api/settings/proxy?level=provider&id=openai` | Remove proxy at level |
|
||||
|
||||
### Proxy Registry API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ------------------------------------------------- | --------------------- |
|
||||
| `GET` | `/api/v1/management/proxies` | List all proxies |
|
||||
| `GET` | `/api/v1/management/proxies?id=uuid` | Get proxy by ID |
|
||||
| `GET` | `/api/v1/management/proxies?id=uuid&where_used=1` | Get proxy assignments |
|
||||
| `POST` | `/api/v1/management/proxies` | Create proxy |
|
||||
| `PATCH` | `/api/v1/management/proxies` | Update proxy |
|
||||
| `DELETE` | `/api/v1/management/proxies?id=uuid` | Delete proxy |
|
||||
| `DELETE` | `/api/v1/management/proxies?id=uuid&force=1` | Force delete |
|
||||
| `POST` | `/api/v1/management/proxies/bulk-assign` | Bulk assign |
|
||||
| `GET` | `/api/v1/management/proxies/assignments` | List assignments |
|
||||
| `GET` | `/api/v1/management/proxies/health` | Proxy health stats |
|
||||
|
||||
### 1proxy API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | -------------------------------------- | ----------------------- |
|
||||
| `GET` | `/api/settings/oneproxy` | List 1proxy proxies |
|
||||
| `GET` | `/api/settings/oneproxy?action=stats` | Get stats + sync status |
|
||||
| `GET` | `/api/settings/oneproxy?action=status` | Get sync status only |
|
||||
| `POST` | `/api/settings/oneproxy` | Trigger sync |
|
||||
| `POST` | `/api/settings/oneproxy/rotate` | Rotate to next proxy |
|
||||
| `DELETE` | `/api/settings/oneproxy?id=uuid` | Delete one |
|
||||
| `DELETE` | `/api/settings/oneproxy?clearAll=1` | Clear all |
|
||||
|
||||
### Upstream Proxy API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | ---------------------------- |
|
||||
| `GET` | `/api/upstream-proxy/:providerId` | Get upstream proxy config |
|
||||
| `PUT` | `/api/upstream-proxy/:providerId` | Set upstream proxy mode |
|
||||
| `DELETE` | `/api/upstream-proxy/:providerId` | Remove upstream proxy config |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------------------------------- | ------------------------------------- | ------------------------------- |
|
||||
| `ENABLE_SOCKS5_PROXY` | `false` | Enable SOCKS5 proxy support |
|
||||
| `ONEPROXY_ENABLED` | `true` | Enable 1proxy integration |
|
||||
| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | 1proxy API endpoint |
|
||||
| `ONEPROXY_MAX_PROXIES` | `500` | Maximum proxies to sync |
|
||||
| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | Minimum quality score to import |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "SOCKS5 proxy is disabled"
|
||||
|
||||
Set `ENABLE_SOCKS5_PROXY=true` in your `.env` file and restart.
|
||||
|
||||
### "socket hang up" errors through proxy
|
||||
|
||||
This is normal with cheap proxies that drop idle connections. OmniRoute already handles this by:
|
||||
|
||||
- Disabling keep-alive on proxy connections (`keepAliveTimeout: 1`)
|
||||
- Disabling pipelining (`pipelining: 0`)
|
||||
- Caching dispatchers to avoid repeated handshakes
|
||||
|
||||
If it persists, try a different proxy or use the 1proxy rotation feature.
|
||||
|
||||
### "unsupported_country_region_territory" during OAuth
|
||||
|
||||
Make sure the proxy is configured **before** starting the OAuth flow. OmniRoute routes OAuth token exchange through the configured proxy. Set a global or provider-level proxy first, then connect.
|
||||
|
||||
### Proxy not being used
|
||||
|
||||
Check the resolution order:
|
||||
|
||||
1. Verify with `GET /api/settings/proxy?resolve=your-connection-id`
|
||||
2. Check if the proxy `status` is `active` (not `inactive`)
|
||||
3. Ensure the proxy assignment scope matches your connection
|
||||
|
||||
### 1proxy sync failing
|
||||
|
||||
Check the sync status:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:20128/api/settings/oneproxy?action=status"
|
||||
```
|
||||
|
||||
If `consecutiveFailures >= 5`, the circuit breaker has tripped. Restart the server to reset, or wait for manual reset.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### `proxy_registry` Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE proxy_registry (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'http',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
password TEXT DEFAULT '',
|
||||
region TEXT,
|
||||
notes TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- 'manual' or 'oneproxy'
|
||||
quality_score INTEGER, -- 0-100 (1proxy only)
|
||||
latency_ms INTEGER, -- milliseconds (1proxy only)
|
||||
anonymity TEXT, -- transparent/anonymous/elite
|
||||
google_access INTEGER DEFAULT 0, -- can access Google? (1proxy)
|
||||
last_validated TEXT, -- ISO timestamp (1proxy)
|
||||
country_code TEXT, -- ISO 2-letter code (1proxy)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### `proxy_assignments` Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE proxy_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
proxy_id TEXT NOT NULL REFERENCES proxy_registry(id),
|
||||
scope TEXT NOT NULL, -- 'global', 'provider', 'account', 'combo'
|
||||
scope_id TEXT, -- provider ID, connection ID, or combo ID
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(scope, scope_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> 📖 **Related documentation:**
|
||||
>
|
||||
> - [User Guide](USER_GUIDE.md) — General setup and configuration
|
||||
> - [API Reference](API_REFERENCE.md) — Full API documentation
|
||||
> - [Environment Config](ENVIRONMENT.md) — All environment variables
|
||||
186
docs/PWA_GUIDE.md
Normal file
186
docs/PWA_GUIDE.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Progressive Web App (PWA) Guide
|
||||
|
||||
OmniRoute ships as a fully installable Progressive Web App. When you access the dashboard from any mobile browser — Android (Chrome) or iOS (Safari) — you can "Add to Home Screen" and get a native app-like experience with no app store required.
|
||||
|
||||
## What Is a PWA?
|
||||
|
||||
A Progressive Web App turns the OmniRoute web dashboard into something that looks and feels like a native mobile app. Once installed, it:
|
||||
|
||||
- Launches from your home screen with its own icon
|
||||
- Opens fullscreen — no browser address bar or tab UI
|
||||
- Works offline with a dedicated connectivity page
|
||||
- Caches static assets for faster loading
|
||||
- Supports both portrait and landscape orientations
|
||||
|
||||
## Installation
|
||||
|
||||
### Android (Chrome)
|
||||
|
||||
1. Open the OmniRoute dashboard in Chrome: `http://YOUR_IP:20128`
|
||||
2. Chrome will show an **"Add OmniRoute to Home screen"** banner automatically, or:
|
||||
- Tap the **⋮** menu (three dots) → **"Add to Home screen"** or **"Install app"**
|
||||
3. Confirm the prompt
|
||||
4. OmniRoute appears on your home screen as a standalone app
|
||||
|
||||
### iOS (Safari)
|
||||
|
||||
1. Open the OmniRoute dashboard in Safari: `http://YOUR_IP:20128`
|
||||
2. Tap the **Share** button (box with arrow)
|
||||
3. Scroll down and tap **"Add to Home Screen"**
|
||||
4. Name it (defaults to "OmniRoute") and tap **Add**
|
||||
5. OmniRoute appears on your home screen with the app icon
|
||||
|
||||
### Desktop (Chrome / Edge)
|
||||
|
||||
1. Open the OmniRoute dashboard
|
||||
2. Click the **install icon** in the address bar (or ⋮ → "Install OmniRoute...")
|
||||
3. Confirm the prompt
|
||||
4. OmniRoute opens as a standalone window — no tabs, no address bar
|
||||
|
||||
## Features
|
||||
|
||||
### Fullscreen Experience
|
||||
|
||||
The manifest is configured with `display: "fullscreen"`, which means the installed app uses the entire screen — no browser chrome, no status bar overlap. This makes the dashboard feel truly native.
|
||||
|
||||
### Offline Support
|
||||
|
||||
OmniRoute includes a service worker (`sw.js`) that provides intelligent caching:
|
||||
|
||||
| Asset Type | Strategy | Behavior |
|
||||
| ------------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| **App Shell** | Cache-first | `/`, `/offline`, manifest, and icons are pre-cached on install |
|
||||
| **Static assets** (CSS, JS, images, fonts) | Network-first with cache fallback | Fetches fresh from the network; falls back to cache if offline |
|
||||
| **Next.js bundles** (`/_next/`) | Network-first with cache update | Fetches from network and updates cache; serves cached version if offline |
|
||||
| **Navigation requests** | Network-only with offline fallback | Always fetches from network; shows `/offline` page if network is unavailable |
|
||||
| **API routes** (`/api/`, `/a2a`, `/dashboard/endpoint`) | Bypass (never cached) | Always goes directly to the server — never intercepted by the service worker |
|
||||
|
||||
### Offline Page
|
||||
|
||||
When the network is unavailable and a user navigates to a new page, the service worker serves a dedicated `/offline` page that:
|
||||
|
||||
- Displays a clear **"Connectivity Issue"** message
|
||||
- Shows a live **online/offline status indicator** that updates in real time
|
||||
- Provides a **"Retry Connection"** button to reload when connectivity returns
|
||||
- Links to the **Status Page** for diagnostics
|
||||
|
||||
### App Icons
|
||||
|
||||
OmniRoute provides icons optimized for each platform:
|
||||
|
||||
| File | Size | Used By |
|
||||
| ---------------------- | ---------------- | ------------------------------------- |
|
||||
| `icon-512.png` | 512×512 | Android install prompt, splash screen |
|
||||
| `apple-touch-icon.png` | 180×180 | iOS home screen icon |
|
||||
| `icon-192.svg` | 192×192 (vector) | Android adaptive icon |
|
||||
| `apple-touch-icon.svg` | 180×180 (vector) | Apple fallback |
|
||||
| `favicon.svg` | Vector | Browser tabs |
|
||||
| `favicon.ico` | Multi-size | Legacy browsers |
|
||||
|
||||
### Automatic Registration
|
||||
|
||||
The service worker is registered automatically via the `<PwaRegister />` component in the root layout. No user action is needed — the app becomes installable as soon as the browser detects the valid manifest and service worker.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Web App Manifest (`manifest.webmanifest`)
|
||||
|
||||
Generated by Next.js via `src/app/manifest.ts`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "OmniRoute",
|
||||
"short_name": "OmniRoute",
|
||||
"description": "OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "fullscreen",
|
||||
"orientation": "any",
|
||||
"background_color": "#0b0f1a",
|
||||
"theme_color": "#0b0f1a",
|
||||
"icons": [
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Service Worker (`public/sw.js`)
|
||||
|
||||
A vanilla service worker (no framework dependencies) with:
|
||||
|
||||
- **Install phase**: Pre-caches the app shell (root, offline page, manifest, icons)
|
||||
- **Activate phase**: Cleans up old cache versions and claims all clients
|
||||
- **Fetch phase**: Intelligent routing based on request type (navigation, static asset, API)
|
||||
- **Cache versioning**: `omniroute-pwa-v2` — bump this to force a fresh cache on update
|
||||
|
||||
### Layout Metadata (`src/app/layout.tsx`)
|
||||
|
||||
The root layout provides all the meta tags required for PWA compliance:
|
||||
|
||||
- `manifest` link to `/manifest.webmanifest`
|
||||
- `apple-web-app-capable: true` for iOS standalone mode
|
||||
- `apple-web-app-status-bar-style: black-translucent`
|
||||
- `mobile-web-app-capable: yes` for Android Chrome
|
||||
- `theme-color: #0b0f1a`
|
||||
- `viewport-fit: cover` for edge-to-edge rendering
|
||||
|
||||
### Component: `PwaRegister`
|
||||
|
||||
Located at `src/shared/components/PwaRegister.tsx`, this client component:
|
||||
|
||||
1. Runs on mount (client-side only)
|
||||
2. Checks for `serviceWorker` support in the browser
|
||||
3. Registers `/sw.js` silently (errors are swallowed to avoid blocking the app)
|
||||
4. Renders nothing (`return null`) — it's a side-effect-only component
|
||||
|
||||
## Use With Termux (Android)
|
||||
|
||||
When running OmniRoute on Android via Termux, the PWA works seamlessly:
|
||||
|
||||
1. Start OmniRoute in Termux: `npx omniroute`
|
||||
2. Open Chrome on the same phone: `http://localhost:20128`
|
||||
3. Install the PWA via "Add to Home Screen"
|
||||
4. The PWA connects to the local Termux server — everything runs on-device
|
||||
|
||||
This combination means your Android phone is both the **server** (Termux) and the **client** (PWA) — a complete self-contained AI gateway.
|
||||
|
||||
## Use From Other Devices
|
||||
|
||||
Install the PWA on any device that has browser access to your OmniRoute server:
|
||||
|
||||
- **Another phone/tablet**: Navigate to `http://PHONE_IP:20128` and install the PWA
|
||||
- **Laptop**: Open Chrome/Edge and install it as a desktop PWA
|
||||
- **Smart TV with browser**: Access the dashboard fullscreen
|
||||
|
||||
## Customization
|
||||
|
||||
### Instance Name
|
||||
|
||||
The PWA title respects the **Instance Name** setting from `Dashboard → Settings`. If you rename your instance to "My AI Gateway", the installed PWA will show that name.
|
||||
|
||||
### Custom Favicon
|
||||
|
||||
If you upload a custom favicon via `Dashboard → Settings`, the PWA icon on desktop will reflect the custom icon. Mobile home screen icons use the pre-built `icon-512.png` and `apple-touch-icon.png` files.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No push notifications** — The service worker does not implement the Push API. Notifications are handled by the Electron app instead.
|
||||
- **No background sync** — Offline actions are not queued for replay. The PWA is primarily a dashboard viewer.
|
||||
- **iOS restrictions** — Safari on iOS does not support all PWA features (e.g., install prompts are manual, and background service workers are limited).
|
||||
- **Cache size** — The service worker caches static assets only. Large response payloads from `/api/` routes are never cached.
|
||||
- **Custom icons on mobile** — Changing the favicon in settings does not update the home screen icon on mobile (this requires regenerating the PWA icons).
|
||||
|
||||
## Files Reference
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `src/app/manifest.ts` | Next.js manifest route (generates `manifest.webmanifest`) |
|
||||
| `public/sw.js` | Service worker with caching logic |
|
||||
| `src/shared/components/PwaRegister.tsx` | Client component that registers the service worker |
|
||||
| `src/app/offline/page.tsx` | Offline fallback page with live status indicator |
|
||||
| `src/app/layout.tsx` | Root layout with PWA metadata (apple-web-app, theme-color, etc.) |
|
||||
| `public/icon-512.png` | 512×512 PNG icon (Android, splash screen) |
|
||||
| `public/apple-touch-icon.png` | 180×180 PNG icon (iOS home screen) |
|
||||
| `public/icon-192.svg` | 192×192 SVG icon (Android adaptive) |
|
||||
| `public/apple-touch-icon.svg` | 180×180 SVG icon (Apple fallback) |
|
||||
@@ -21,7 +21,7 @@ Use this checklist before tagging or publishing a new OmniRoute release.
|
||||
1. Review `docs/ARCHITECTURE.md` for storage/runtime drift.
|
||||
2. Review `docs/TROUBLESHOOTING.md` for env var and operational drift.
|
||||
3. Verify the release/runtime Node.js version still satisfies the supported secure floor:
|
||||
- `>=20.20.2 <21` or `>=22.22.2 <23`
|
||||
- `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25`
|
||||
- `npm run check:node-runtime`
|
||||
4. Validate the npm publish artifact after building the standalone package:
|
||||
- `npm run build:cli`
|
||||
|
||||
145
docs/RESILIENCE_GUIDE.md
Normal file
145
docs/RESILIENCE_GUIDE.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 🛡️ Resilience Guide — OmniRoute
|
||||
|
||||
> How OmniRoute keeps your AI coding workflow running when providers fail.
|
||||
|
||||
## Overview
|
||||
|
||||
OmniRoute implements a multi-layered resilience system that ensures zero downtime:
|
||||
|
||||
```
|
||||
Client Request
|
||||
→ Rate Limit Check (per-IP, per-connection)
|
||||
→ Combo Routing (13 strategies)
|
||||
→ Connection Selection (P2C, round-robin, etc.)
|
||||
→ Request Queue & Pacing
|
||||
→ Execute (provider-specific executor)
|
||||
→ On Failure:
|
||||
→ Connection Cooldown (exponential backoff)
|
||||
→ Circuit Breaker (provider-level)
|
||||
→ Wait For Cooldown (auto-retry)
|
||||
→ Next Combo Target (fallback chain)
|
||||
→ Response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request Queue & Pacing
|
||||
|
||||
Per-connection request buckets smooth bursts before they hit upstream rate caps.
|
||||
|
||||
Configure in `Dashboard → Settings → Resilience`:
|
||||
|
||||
| Setting | Default | Description |
|
||||
| --------------- | ------- | ------------------------------------ |
|
||||
| Queue Size | `10` | Max queued requests per connection |
|
||||
| Pacing Interval | `0ms` | Minimum gap between requests |
|
||||
| Max Concurrent | `5` | Simultaneous requests per connection |
|
||||
|
||||
---
|
||||
|
||||
## Connection Cooldown
|
||||
|
||||
A single connection cools down after retryable failures. Features:
|
||||
|
||||
- **Exponential Backoff** — progressively longer cooldowns after each failure
|
||||
- **`Retry-After` Header Support** — respects upstream hints
|
||||
- **Configurable Base/Max** — tune cooldown duration per use case
|
||||
- **Auto-Recovery** — connection automatically becomes available after cooldown expires
|
||||
|
||||
---
|
||||
|
||||
## Circuit Breaker
|
||||
|
||||
Provider-level protection against cascading failures:
|
||||
|
||||
1. **Connection-scoped `429` rate limits** stay in Connection Cooldown (don't trip the breaker)
|
||||
2. **Provider-wide transient errors** (5xx, network timeouts) increment the failure counter
|
||||
3. **Breaker trips** only after fallback is exhausted AND the provider still fails
|
||||
4. **Recovery** — breaker automatically moves to half-open state after timeout, tests with probe request
|
||||
|
||||
Configure thresholds in `Dashboard → Settings → Resilience`.
|
||||
|
||||
---
|
||||
|
||||
## Wait For Cooldown
|
||||
|
||||
Instead of immediately failing when all connections are in cooldown, OmniRoute can wait for the earliest connection to expire and retry:
|
||||
|
||||
- **Automatic** — server waits for the earliest cooldown to expire
|
||||
- **Transparent** — client sees a slightly delayed response instead of an error
|
||||
- **Configurable** — enable/disable per combo or globally
|
||||
|
||||
---
|
||||
|
||||
## Anti-Thundering Herd
|
||||
|
||||
When multiple concurrent requests hit a failing provider simultaneously:
|
||||
|
||||
- **Mutex Protection** — only one retry attempt at a time per connection
|
||||
- **Semaphore** — limits concurrent retry storms across connections
|
||||
- **Deduplication** — identical requests within 5s window are deduplicated
|
||||
|
||||
---
|
||||
|
||||
## Combo Fallback Chains
|
||||
|
||||
The primary resilience mechanism. Configure in `Dashboard → Combos`:
|
||||
|
||||
```txt
|
||||
Combo: "always-on"
|
||||
1. cc/claude-opus-4-7 ← Primary (subscription)
|
||||
2. cx/gpt-5.2-codex ← Secondary (subscription)
|
||||
3. glm/glm-4.7 ← Cheap backup ($0.5/1M)
|
||||
4. if/kimi-k2-thinking ← Free fallback (unlimited)
|
||||
```
|
||||
|
||||
When provider #1 fails (quota, rate, or health), OmniRoute automatically routes to #2, then #3, then #4 — with zero manual intervention.
|
||||
|
||||
### 13 Routing Strategies
|
||||
|
||||
| Strategy | Description |
|
||||
| ------------------- | ---------------------------------- |
|
||||
| `priority` | First available in order |
|
||||
| `weighted` | Weighted distribution |
|
||||
| `fill-first` | Fill primary before moving |
|
||||
| `round-robin` | Rotate through all targets |
|
||||
| `p2c` | Power-of-two choices (quota-aware) |
|
||||
| `random` | Random selection |
|
||||
| `least-used` | Least recently used |
|
||||
| `cost-optimized` | Cheapest available |
|
||||
| `strict-random` | True random (no tracking) |
|
||||
| `auto` | OmniRoute selects based on context |
|
||||
| `lkgp` | Last Known Good Provider |
|
||||
| `context-optimized` | Best for current context window |
|
||||
| `context-relay` | Session handoff during rotation |
|
||||
|
||||
---
|
||||
|
||||
## TLS Fingerprint Spoofing
|
||||
|
||||
OmniRoute makes proxied traffic look like legitimate browser/CLI requests:
|
||||
|
||||
- **Browser-like TLS** via `wreq-js` — prevents bot detection
|
||||
- **CLI Fingerprint Matching** — reorders headers and body fields to match native CLI binary signatures (Claude Code, Codex, etc.)
|
||||
- **Proxy IP Preservation** — stealth features work on top of proxy IP masking
|
||||
|
||||
---
|
||||
|
||||
## Health Dashboard
|
||||
|
||||
Monitor all resilience components in real-time at `Dashboard → Health`:
|
||||
|
||||
- **Uptime** — server uptime and last restart
|
||||
- **Provider Breaker States** — open/closed/half-open per provider
|
||||
- **Connection Cooldowns** — active cooldowns with expiry times
|
||||
- **Cache Stats** — signature + semantic cache hit rates
|
||||
- **Lockouts** — API key lockouts and IP bans
|
||||
- **Latency** — p50/p95/p99 percentiles
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Architecture Guide](ARCHITECTURE.md) — System architecture and internals
|
||||
- [User Guide](USER_GUIDE.md) — Providers, combos, CLI integration
|
||||
- [Auto-Combo Engine](AUTO-COMBO.md) — 6-factor scoring, mode packs
|
||||
311
docs/SETUP_GUIDE.md
Normal file
311
docs/SETUP_GUIDE.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# 📖 Setup Guide — OmniRoute
|
||||
|
||||
> Complete setup reference for OmniRoute. For the quick version, see the [Quick Start in README](../README.md#-quick-start).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Install Methods](#install-methods)
|
||||
- [CLI Tool Configuration](#cli-tool-configuration)
|
||||
- [Protocol Setup (MCP + A2A)](#protocol-setup-mcp--a2a)
|
||||
- [Timeout Configuration](#timeout-configuration)
|
||||
- [Split-Port Mode](#split-port-mode)
|
||||
- [Void Linux (xbps-src)](#void-linux-xbps-src-template)
|
||||
- [Uninstalling](#uninstalling)
|
||||
|
||||
---
|
||||
|
||||
## Install Methods
|
||||
|
||||
### npm (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g omniroute
|
||||
omniroute
|
||||
```
|
||||
|
||||
Dashboard opens at `http://localhost:20128` and API base URL is `http://localhost:20128/v1`.
|
||||
|
||||
### pnpm
|
||||
|
||||
```bash
|
||||
pnpm install -g omniroute
|
||||
pnpm approve-builds -g # Select all packages → approve
|
||||
omniroute
|
||||
```
|
||||
|
||||
> **pnpm users:** `pnpm approve-builds -g` is required to enable native build scripts for `better-sqlite3` and `@swc/core`.
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
yay -S omniroute-bin
|
||||
systemctl --user enable --now omniroute.service
|
||||
```
|
||||
|
||||
The [AUR package](https://aur.archlinux.org/packages/omniroute-bin) installs OmniRoute and provides a systemd user service.
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
npm install
|
||||
PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm run dev
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
See the [Docker Guide](DOCKER_GUIDE.md) for complete Docker setup including Compose profiles and Caddy HTTPS.
|
||||
|
||||
### CLI Options
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------- | ----------------------------------------------------------- |
|
||||
| `omniroute` | Start server (`PORT=20128`, API and dashboard on same port) |
|
||||
| `omniroute --port 3000` | Set canonical/API port to 3000 |
|
||||
| `omniroute --mcp` | Start MCP server (stdio transport) |
|
||||
| `omniroute --no-open` | Don't auto-open browser |
|
||||
| `omniroute --help` | Show help |
|
||||
|
||||
---
|
||||
|
||||
## CLI Tool Configuration
|
||||
|
||||
### 1) Connect Providers and Create API Key
|
||||
|
||||
1. Open Dashboard → `Providers` and connect at least one provider (OAuth or API key).
|
||||
2. Open Dashboard → `Endpoints` and create an API key.
|
||||
3. (Optional) Open Dashboard → `Combos` and set your fallback chain.
|
||||
|
||||
### 2) Point Your Coding Tool
|
||||
|
||||
```txt
|
||||
Base URL: http://localhost:20128/v1
|
||||
API Key: [copy from Endpoint page]
|
||||
Model: if/kimi-k2-thinking (or any provider/model prefix)
|
||||
```
|
||||
|
||||
Works with Claude Code, Codex CLI, Gemini CLI, Cursor, Cline, OpenClaw, OpenCode, and OpenAI-compatible SDKs.
|
||||
|
||||
For detailed per-tool configuration (Claude Code, Codex CLI, Cursor, Cline, OpenClaw, Kilo Code, Copilot, and more), see the dedicated **[CLI Tools Guide](CLI-TOOLS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Setup (MCP + A2A)
|
||||
|
||||
### MCP Setup (Model Context Protocol)
|
||||
|
||||
Start MCP transport in stdio mode:
|
||||
|
||||
```bash
|
||||
omniroute --mcp
|
||||
```
|
||||
|
||||
Recommended validation flow:
|
||||
|
||||
```bash
|
||||
# 1. Start MCP server
|
||||
omniroute --mcp
|
||||
|
||||
# 2. From your MCP client, call:
|
||||
omniroute_get_health # Should return system health
|
||||
omniroute_list_combos # Should return active combos
|
||||
|
||||
# 3. Or run the full E2E suite:
|
||||
npm run test:protocols:e2e
|
||||
```
|
||||
|
||||
#### MCP Client Configuration
|
||||
|
||||
**Claude Code:**
|
||||
|
||||
```bash
|
||||
claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp/stream
|
||||
```
|
||||
|
||||
**Cursor / Cline:**
|
||||
|
||||
Add to your MCP settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"omniroute": {
|
||||
"command": "omniroute",
|
||||
"args": ["--mcp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Full MCP documentation:** [MCP Server README](../open-sse/mcp-server/README.md) — 29 tools, IDE configs, Python/TS/Go clients.
|
||||
|
||||
### A2A Setup (Agent-to-Agent Protocol)
|
||||
|
||||
Verify the Agent Card:
|
||||
|
||||
```bash
|
||||
curl http://localhost:20128/.well-known/agent.json
|
||||
```
|
||||
|
||||
Send a task:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:20128/a2a \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":"quickstart","method":"message/send","params":{"skill":"quota-management","messages":[{"role":"user","content":"Give me a short quota summary."}]}}'
|
||||
```
|
||||
|
||||
**Full A2A documentation:** [A2A Server README](../src/lib/a2a/README.md) — JSON-RPC 2.0, skills, streaming, task lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## Timeout Configuration
|
||||
|
||||
### Basic Timeouts
|
||||
|
||||
For most deployments, you only need these two variables:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts |
|
||||
| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream |
|
||||
|
||||
Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline.
|
||||
|
||||
### Provider-Specific Notes
|
||||
|
||||
For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration.
|
||||
|
||||
For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default `anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, only forwards client-provided `cache_control` markers.
|
||||
|
||||
### Advanced Timeout Overrides
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- |
|
||||
| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive |
|
||||
| `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers |
|
||||
| `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) |
|
||||
| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout |
|
||||
| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Undici idle keep-alive socket timeout |
|
||||
| `TLS_CLIENT_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Timeout for TLS fingerprint requests made through `wreq-js` |
|
||||
| `API_BRIDGE_PROXY_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` or `30000` | Timeout for `/v1` proxy forwarding from API port to dashboard port |
|
||||
| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | Incoming request timeout on the API bridge server |
|
||||
| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server |
|
||||
| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server |
|
||||
| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) |
|
||||
|
||||
> **Note:** For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`).
|
||||
|
||||
### Reverse Proxy Compatibility
|
||||
|
||||
If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts.
|
||||
|
||||
---
|
||||
|
||||
## Split-Port Mode
|
||||
|
||||
Run API and Dashboard on separate ports for advanced scenarios (reverse proxy, container networking):
|
||||
|
||||
```bash
|
||||
PORT=20128 DASHBOARD_PORT=20129 omniroute
|
||||
# API: http://localhost:20128/v1
|
||||
# Dashboard: http://localhost:20129
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Void Linux (xbps-src) Template
|
||||
|
||||
For Void Linux users, you can build a native package using `xbps-src`. Save this block as `srcpkgs/omniroute/template`:
|
||||
|
||||
```bash
|
||||
# Template file for 'omniroute'
|
||||
pkgname=omniroute
|
||||
version=3.4.1
|
||||
revision=1
|
||||
hostmakedepends="nodejs python3 make"
|
||||
depends="openssl"
|
||||
short_desc="Universal AI gateway with smart routing for multiple LLM providers"
|
||||
maintainer="zenobit <zenobit@disroot.org>"
|
||||
license="MIT"
|
||||
homepage="https://github.com/diegosouzapw/OmniRoute"
|
||||
distfiles="https://github.com/diegosouzapw/OmniRoute/archive/refs/tags/v${version}.tar.gz"
|
||||
checksum=009400afee90a9f32599d8fe734145cfd84098140b7287990183dde45ae2245b
|
||||
system_accounts="_omniroute"
|
||||
omniroute_homedir="/var/lib/omniroute"
|
||||
export NODE_ENV=production
|
||||
export npm_config_engine_strict=false
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
|
||||
do_build() {
|
||||
local _gyp_arch
|
||||
case "$XBPS_TARGET_MACHINE" in
|
||||
aarch64*) _gyp_arch=arm64 ;;
|
||||
armv7*|armv6*) _gyp_arch=arm ;;
|
||||
i686*) _gyp_arch=ia32 ;;
|
||||
*) _gyp_arch=x64 ;;
|
||||
esac
|
||||
|
||||
NODE_ENV=development npm ci --ignore-scripts
|
||||
npm run build
|
||||
cp -r .next/static .next/standalone/.next/static
|
||||
[ -d public ] && cp -r public .next/standalone/public || true
|
||||
|
||||
local _node_gyp=/usr/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js
|
||||
(cd node_modules/better-sqlite3 && node "$_node_gyp" rebuild --arch="$_gyp_arch")
|
||||
|
||||
local _bs3_release=.next/standalone/node_modules/better-sqlite3/build/Release
|
||||
mkdir -p "$_bs3_release"
|
||||
cp node_modules/better-sqlite3/build/Release/better_sqlite3.node "$_bs3_release/"
|
||||
|
||||
rm -rf .next/standalone/node_modules/@img
|
||||
|
||||
for _mod in pino-abstract-transport split2 process-warning; do
|
||||
cp -r "node_modules/$_mod" .next/standalone/node_modules/
|
||||
done
|
||||
}
|
||||
|
||||
do_check() {
|
||||
npm run test:unit
|
||||
}
|
||||
|
||||
do_install() {
|
||||
vmkdir usr/lib/omniroute/.next
|
||||
vcopy .next/standalone/. usr/lib/omniroute/.next/standalone
|
||||
|
||||
for _d in \
|
||||
.next/standalone/.next/server/app/dashboard \
|
||||
.next/standalone/.next/server/app/dashboard/settings \
|
||||
.next/standalone/.next/server/app/dashboard/providers; do
|
||||
touch "${DESTDIR}/usr/lib/omniroute/${_d}/.keep"
|
||||
done
|
||||
|
||||
cat > "${WRKDIR}/omniroute" <<'EOF'
|
||||
#!/bin/sh
|
||||
export PORT="${PORT:-20128}"
|
||||
export DATA_DIR="${DATA_DIR:-${XDG_DATA_HOME:-${HOME}/.local/share}/omniroute}"
|
||||
export APP_LOG_TO_FILE="${APP_LOG_TO_FILE:-false}"
|
||||
mkdir -p "${DATA_DIR}"
|
||||
exec node /usr/lib/omniroute/.next/standalone/server.js "$@"
|
||||
EOF
|
||||
vbin "${WRKDIR}/omniroute"
|
||||
}
|
||||
|
||||
post_install() {
|
||||
vlicense LICENSE
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Uninstalling
|
||||
|
||||
| Command | Action |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------- |
|
||||
| `npm run uninstall` | Removes the system app but **keeps your DB and configurations** in `~/.omniroute`. |
|
||||
| `npm run uninstall:full` | Removes the app AND permanently **erases all configurations, keys, and databases**. |
|
||||
|
||||
> For detailed uninstall instructions across all methods, see [UNINSTALL.md](UNINSTALL.md).
|
||||
@@ -1,130 +0,0 @@
|
||||
# Context Relay
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](context-relay.md) · 🇪🇸 [es](../i18n/es/docs/features/context-relay.md) · 🇫🇷 [fr](../i18n/fr/docs/features/context-relay.md) · 🇩🇪 [de](../i18n/de/docs/features/context-relay.md) · 🇮🇹 [it](../i18n/it/docs/features/context-relay.md) · 🇷🇺 [ru](../i18n/ru/docs/features/context-relay.md) · 🇨🇳 [zh-CN](../i18n/zh-CN/docs/features/context-relay.md) · 🇯🇵 [ja](../i18n/ja/docs/features/context-relay.md) · 🇰🇷 [ko](../i18n/ko/docs/features/context-relay.md) · 🇸🇦 [ar](../i18n/ar/docs/features/context-relay.md) · 🇮🇳 [hi](../i18n/hi/docs/features/context-relay.md) · 🇮🇳 [in](../i18n/in/docs/features/context-relay.md) · 🇹🇭 [th](../i18n/th/docs/features/context-relay.md) · 🇻🇳 [vi](../i18n/vi/docs/features/context-relay.md) · 🇮🇩 [id](../i18n/id/docs/features/context-relay.md) · 🇲🇾 [ms](../i18n/ms/docs/features/context-relay.md) · 🇳🇱 [nl](../i18n/nl/docs/features/context-relay.md) · 🇵🇱 [pl](../i18n/pl/docs/features/context-relay.md) · 🇸🇪 [sv](../i18n/sv/docs/features/context-relay.md) · 🇳🇴 [no](../i18n/no/docs/features/context-relay.md) · 🇩🇰 [da](../i18n/da/docs/features/context-relay.md) · 🇫🇮 [fi](../i18n/fi/docs/features/context-relay.md) · 🇵🇹 [pt](../i18n/pt/docs/features/context-relay.md) · 🇷🇴 [ro](../i18n/ro/docs/features/context-relay.md) · 🇭🇺 [hu](../i18n/hu/docs/features/context-relay.md) · 🇧🇬 [bg](../i18n/bg/docs/features/context-relay.md) · 🇸🇰 [sk](../i18n/sk/docs/features/context-relay.md) · 🇺🇦 [uk-UA](../i18n/uk-UA/docs/features/context-relay.md) · 🇮🇱 [he](../i18n/he/docs/features/context-relay.md) · 🇵🇭 [phi](../i18n/phi/docs/features/context-relay.md) · 🇧🇷 [pt-BR](../i18n/pt-BR/docs/features/context-relay.md) · 🇨🇿 [cs](../i18n/cs/docs/features/context-relay.md) · 🇹🇷 [tr](../i18n/tr/docs/features/context-relay.md)
|
||||
|
||||
---
|
||||
|
||||
`context-relay` is a combo strategy that keeps session continuity when the active account
|
||||
rotates before the conversation is finished.
|
||||
|
||||
The current runtime behaves like priority routing for model selection, then adds a
|
||||
handoff layer on top:
|
||||
|
||||
- before the active account is exhausted, OmniRoute generates a compact structured summary
|
||||
- after authentication selects a different account for the same session, OmniRoute injects
|
||||
that summary as a system message into the next request
|
||||
- once the handoff is consumed successfully, it is removed from storage
|
||||
|
||||
## When To Use It
|
||||
|
||||
Use `context-relay` when all of the following are true:
|
||||
|
||||
- the combo is expected to rotate between multiple accounts of the same provider
|
||||
- losing short-term conversational continuity would hurt task quality
|
||||
- the provider exposes enough quota information to predict an approaching account limit
|
||||
|
||||
This is most useful for long-running coding or research sessions that may outlive a single
|
||||
account window.
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
The current behavior is intentionally split across two runtime layers.
|
||||
|
||||
### 0% to 84% quota used
|
||||
|
||||
No handoff is generated. Requests behave like normal priority routing.
|
||||
|
||||
### 85% to 94% quota used
|
||||
|
||||
If the active provider is enabled in `handoffProviders`, OmniRoute generates a structured
|
||||
handoff summary in the background before the account is fully exhausted.
|
||||
|
||||
Important details:
|
||||
|
||||
- the default warning threshold is `0.85`
|
||||
- the hard stop for generation is `0.95`
|
||||
- only one in-flight handoff generation is allowed per `sessionId + comboName`
|
||||
- if an active handoff already exists for that session/combo, no duplicate summary is generated
|
||||
|
||||
### 95% or more quota used
|
||||
|
||||
No new handoff is generated. At this point the system is already in or near exhaustion and
|
||||
the runtime avoids scheduling another summary request.
|
||||
|
||||
### After account rotation
|
||||
|
||||
When the next request for the same session resolves to a different authenticated account,
|
||||
OmniRoute prepends the stored handoff as a system message. Injection happens only after the
|
||||
real account switch is known.
|
||||
|
||||
## Handoff Payload
|
||||
|
||||
The persisted handoff payload is stored in `context_handoffs` and includes:
|
||||
|
||||
- `sessionId`
|
||||
- `comboName`
|
||||
- `fromAccount`
|
||||
- `summary`
|
||||
- `keyDecisions`
|
||||
- `taskProgress`
|
||||
- `activeEntities`
|
||||
- `messageCount`
|
||||
- `model`
|
||||
- `warningThresholdPct`
|
||||
- `generatedAt`
|
||||
- `expiresAt`
|
||||
|
||||
The summary model is instructed to return a JSON object with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": "Dense summary of what matters for continuity",
|
||||
"keyDecisions": ["Decision 1", "Decision 2"],
|
||||
"taskProgress": "What is done, what is pending, and the next step",
|
||||
"activeEntities": ["fileA.ts", "feature X", "provider Y"]
|
||||
}
|
||||
```
|
||||
|
||||
At injection time, OmniRoute converts that payload into a `<context_handoff>` system
|
||||
message so the next account can continue with the correct local context.
|
||||
|
||||
## Configuration
|
||||
|
||||
`context-relay` supports these config fields:
|
||||
|
||||
- `handoffThreshold`: warning threshold for summary generation, default `0.85`
|
||||
- `handoffModel`: optional model override used only for summary generation
|
||||
- `handoffProviders`: allowlist of providers allowed to trigger handoff generation
|
||||
|
||||
Global defaults can be configured in Settings, and combo-specific values can override them
|
||||
in the Combos page.
|
||||
|
||||
## Architectural Note
|
||||
|
||||
The current implementation does not use a standalone `handleContextRelayCombo` handler.
|
||||
|
||||
Instead:
|
||||
|
||||
- `open-sse/services/combo.ts` decides whether a successful turn should generate a handoff
|
||||
- `src/sse/handlers/chat.ts` injects the handoff only after authentication resolves the
|
||||
actual account used for the request
|
||||
|
||||
This split is intentional in the current codebase because the combo loop alone does not know
|
||||
whether the request stayed on the same account or actually switched accounts.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Effective runtime support is currently centered on `codex` quota rotation.
|
||||
- `handoffProviders` is already modeled as a config surface, but real handoff generation
|
||||
still depends on provider-specific quota plumbing.
|
||||
- The summary is intentionally compact and recent-history based; it is not a full transcript
|
||||
replay mechanism.
|
||||
- Handoffs are scoped by `sessionId + comboName` and expire automatically.
|
||||
- If the session does not switch accounts, the stored handoff is not injected.
|
||||
|
||||
## Recommended Usage Pattern
|
||||
|
||||
- use multiple accounts from the same provider
|
||||
- keep stable `sessionId` values across the session
|
||||
- set `handoffThreshold` early enough to leave room for the background summary request
|
||||
- treat the feature as continuity assistance, not as a replacement for persistent memory
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.7.7
|
||||
version: 3.7.8
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -281,21 +281,25 @@ function installUpdate() {
|
||||
function setupContentSecurityPolicy() {
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
const scriptSrc = isDev
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval'"
|
||||
: "script-src 'self' 'unsafe-inline'";
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
|
||||
: "script-src 'self' 'unsafe-inline' blob:";
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`connect-src 'self' http://localhost:* ws://localhost:* https://*.omniroute.online https://*.omniroute.dev`,
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"frame-src 'none'",
|
||||
"child-src 'none'",
|
||||
"form-action 'self'",
|
||||
scriptSrc,
|
||||
"script-src-attr 'none'",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self'",
|
||||
"media-src 'self' data: blob:",
|
||||
`connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://*.omniroute.online https://*.omniroute.dev`,
|
||||
"worker-src 'self' blob:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
callback({
|
||||
@@ -327,6 +331,7 @@ function createWindow() {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: true,
|
||||
webviewTag: false,
|
||||
},
|
||||
show: false,
|
||||
backgroundColor: "#0a0a0a",
|
||||
|
||||
4402
electron/package-lock.json
generated
Normal file
4402
electron/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.7.7",
|
||||
"version": "3.7.8",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
@@ -5,6 +5,51 @@ import { fileURLToPath } from "node:url";
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
const distDir = process.env.NEXT_DIST_DIR || ".next";
|
||||
const projectRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const scriptSrc =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
|
||||
: "script-src 'self' 'unsafe-inline' blob:";
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
scriptSrc,
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self' data: blob:",
|
||||
"connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: wss:",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: contentSecurityPolicy,
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=()",
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
@@ -102,11 +147,6 @@ const nextConfig = {
|
||||
})
|
||||
);
|
||||
|
||||
// Mark @ngrok/ngrok as external to prevent webpack from trying to bundle its .node binaries
|
||||
config.externals = config.externals || [];
|
||||
config.externals.push({
|
||||
"@ngrok/ngrok": "commonjs @ngrok/ngrok",
|
||||
});
|
||||
// ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ────────
|
||||
//
|
||||
// Next.js 16 (with or without Turbopack) compiles the instrumentation hook
|
||||
@@ -128,6 +168,7 @@ const nextConfig = {
|
||||
const KNOWN_EXTERNALS = new Set([
|
||||
"better-sqlite3",
|
||||
"keytar",
|
||||
"@ngrok/ngrok",
|
||||
"wreq-js",
|
||||
"zod",
|
||||
"pino",
|
||||
@@ -181,11 +222,21 @@ const nextConfig = {
|
||||
tls: false,
|
||||
crypto: false,
|
||||
process: false,
|
||||
os: false,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -262,6 +262,18 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
|
||||
{ id: "generative", name: "Polly Generative" },
|
||||
],
|
||||
},
|
||||
"xiaomi-mimo": {
|
||||
id: "xiaomi-mimo",
|
||||
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
format: "xiaomi-mimo-tts",
|
||||
models: [
|
||||
{ id: "mimo-v2.5-tts", name: "MiMo V2.5 TTS" },
|
||||
{ id: "mimo-v2.5-tts-voicedesign", name: "MiMo V2.5 Voice Design" },
|
||||
{ id: "mimo-v2.5-tts-voiceclone", name: "MiMo V2.5 Voice Clone" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,16 +44,19 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
|
||||
],
|
||||
bodyFieldOrder: [
|
||||
"model",
|
||||
"messages",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"max_tokens",
|
||||
"stream",
|
||||
"input",
|
||||
"instructions",
|
||||
"store",
|
||||
"reasoning",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"response_format",
|
||||
"n",
|
||||
"stop",
|
||||
"include",
|
||||
"service_tier",
|
||||
"prompt_cache_key",
|
||||
"client_metadata",
|
||||
"parallel_tool_calls",
|
||||
"metadata",
|
||||
],
|
||||
// Codex builds mode-specific client headers in its executor/config. The CLI fingerprint must
|
||||
// only preserve ordering here; overriding User-Agent with a generic value would erase the
|
||||
@@ -186,6 +189,19 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
|
||||
],
|
||||
userAgent: getAntigravityUserAgent,
|
||||
},
|
||||
"gemini-cli": {
|
||||
headerOrder: [
|
||||
"Host",
|
||||
"Content-Type",
|
||||
"User-Agent",
|
||||
"X-Goog-Api-Client",
|
||||
"Accept",
|
||||
"Accept-Encoding",
|
||||
"Connection",
|
||||
"Authorization",
|
||||
],
|
||||
bodyFieldOrder: ["model", "project", "user_prompt_id", "request"],
|
||||
},
|
||||
qwen: {
|
||||
headerOrder: [
|
||||
"Host",
|
||||
|
||||
@@ -139,22 +139,22 @@ export const PROVIDER_PROFILES = {
|
||||
transientCooldown: 5000, // 5s (session tokens — short recovery)
|
||||
rateLimitCooldown: 60000, // 60s default when no retry-after header
|
||||
maxBackoffLevel: 8, // Higher ceiling (sessions may stay bad longer)
|
||||
circuitBreakerThreshold: 3, // Opens fast (low limit providers)
|
||||
circuitBreakerThreshold: 8, // Scaled for 500+ connections (was 3)
|
||||
circuitBreakerReset: 60000, // 1min reset
|
||||
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
|
||||
providerFailureThreshold: 3, // 3 transient failures trigger provider cooldown
|
||||
providerFailureWindowMs: 600000, // 10min window for counting failures
|
||||
providerFailureThreshold: 10, // Scaled for 500+ connections (was 3)
|
||||
providerFailureWindowMs: 900000, // 15min window (was 10min)
|
||||
providerCooldownMs: 300000, // 5min cooldown when threshold reached
|
||||
},
|
||||
apikey: {
|
||||
transientCooldown: 3000, // 3s (API providers recover faster)
|
||||
rateLimitCooldown: 0, // 0 = respect retry-after header from provider
|
||||
maxBackoffLevel: 5, // Lower ceiling (API quotas reset at known intervals)
|
||||
circuitBreakerThreshold: 5, // More tolerant (occasional 502 is normal)
|
||||
circuitBreakerThreshold: 12, // Scaled for 500+ connections (was 5)
|
||||
circuitBreakerReset: 30000, // 30s reset
|
||||
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
|
||||
providerFailureThreshold: 5, // 5 transient failures trigger provider cooldown
|
||||
providerFailureWindowMs: 1200000, // 20min window for counting failures
|
||||
providerFailureThreshold: 15, // Scaled for 500+ connections (was 5)
|
||||
providerFailureWindowMs: 1800000, // 30min window (was 20min)
|
||||
providerCooldownMs: 600000, // 10min cooldown when threshold reached
|
||||
},
|
||||
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
|
||||
|
||||
@@ -66,6 +66,17 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
],
|
||||
},
|
||||
|
||||
upstage: {
|
||||
id: "upstage",
|
||||
baseUrl: "https://api.upstage.ai/v1/embeddings",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "embedding-query", name: "Embedding Query", dimensions: 4096 },
|
||||
{ id: "embedding-passage", name: "Embedding Passage", dimensions: 4096 },
|
||||
],
|
||||
},
|
||||
|
||||
mistral: {
|
||||
id: "mistral",
|
||||
baseUrl: "https://api.mistral.ai/v1/embeddings",
|
||||
|
||||
18
open-sse/config/maritalk.ts
Normal file
18
open-sse/config/maritalk.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { stripTrailingSlashes } from "../utils/urlSanitize.ts";
|
||||
|
||||
export const MARITALK_DEFAULT_BASE_URL = "https://chat.maritaca.ai/api";
|
||||
|
||||
export function normalizeMaritalkBaseUrl(value: string | null | undefined): string {
|
||||
const normalized = stripTrailingSlashes((value || MARITALK_DEFAULT_BASE_URL).trim());
|
||||
if (!normalized) return MARITALK_DEFAULT_BASE_URL;
|
||||
|
||||
return normalized.replace(/\/chat\/(?:completions|inference)$/i, "");
|
||||
}
|
||||
|
||||
export function buildMaritalkChatUrl(value: string | null | undefined): string {
|
||||
return `${normalizeMaritalkBaseUrl(value)}/chat/completions`;
|
||||
}
|
||||
|
||||
export function buildMaritalkModelsUrl(value: string | null | undefined): string {
|
||||
return `${normalizeMaritalkBaseUrl(value)}/models`;
|
||||
}
|
||||
@@ -46,25 +46,25 @@ export function getGitHubCopilotChatHeaders(
|
||||
};
|
||||
}
|
||||
|
||||
function getRuntimePlatform(): string {
|
||||
export function getRuntimePlatform(): string {
|
||||
return typeof process !== "undefined" && typeof process.platform === "string"
|
||||
? process.platform
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function getRuntimeArch(): string {
|
||||
export function getRuntimeArch(): string {
|
||||
return typeof process !== "undefined" && typeof process.arch === "string"
|
||||
? process.arch
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function getRuntimeVersion(): string {
|
||||
export function getRuntimeVersion(): string {
|
||||
return typeof process !== "undefined" && typeof process.version === "string"
|
||||
? process.version
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function normalizeStainlessPlatform(platform: string = getRuntimePlatform()): string {
|
||||
export function normalizeStainlessPlatform(platform: string = getRuntimePlatform()): string {
|
||||
const normalized = platform.toLowerCase();
|
||||
if (normalized.includes("ios")) return "iOS";
|
||||
if (normalized === "android") return "Android";
|
||||
@@ -76,7 +76,7 @@ function normalizeStainlessPlatform(platform: string = getRuntimePlatform()): st
|
||||
return normalized ? `Other:${normalized}` : "Unknown";
|
||||
}
|
||||
|
||||
function normalizeStainlessArch(arch: string = getRuntimeArch()): string {
|
||||
export function normalizeStainlessArch(arch: string = getRuntimeArch()): string {
|
||||
if (arch === "x32") return "x32";
|
||||
if (arch === "x86_64" || arch === "x64") return "x64";
|
||||
if (arch === "arm") return "arm";
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* is auto-generated from this registry.
|
||||
*/
|
||||
|
||||
import { platform, arch } from "os";
|
||||
import { ANTIGRAVITY_BASE_URLS } from "./antigravityUpstream.ts";
|
||||
import { ANTIGRAVITY_PUBLIC_MODELS } from "./antigravityModelAliases.ts";
|
||||
import {
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
GLM_SHARED_HEADERS,
|
||||
GLM_SHARED_MODELS,
|
||||
} from "./glmProvider.ts";
|
||||
import { MARITALK_DEFAULT_BASE_URL } from "./maritalk.ts";
|
||||
import {
|
||||
CURSOR_REGISTRY_VERSION,
|
||||
getAntigravityProviderHeaders,
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
getKiroServiceHeaders,
|
||||
getQoderDefaultHeaders,
|
||||
getQwenOauthHeaders,
|
||||
getRuntimePlatform,
|
||||
getRuntimeArch,
|
||||
} from "./providerHeaderProfiles.ts";
|
||||
import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
|
||||
|
||||
@@ -191,7 +193,7 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
|
||||
"aisingapore/Qwen-SEA-LION-v4-32B-IT",
|
||||
"allenai/Olmo-3-32B-Think",
|
||||
]),
|
||||
moonshot: buildModels(["kimi-k2.5", "kimi-latest", "moonshot-v1-auto"]),
|
||||
moonshot: buildModels(["kimi-k2.6", "kimi-k2.5"]),
|
||||
"meta-llama": buildModels([
|
||||
"Llama-3.3-70B-Instruct",
|
||||
"Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
@@ -226,19 +228,9 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
|
||||
gigachat: buildModels(["GigaChat-2-Max", "GigaChat-2-Pro", "GigaChat-2-Lite"]),
|
||||
venice: buildModels(["venice-latest"]),
|
||||
codestral: buildModels(["codestral-2405", "codestral-latest"]),
|
||||
upstage: buildModels(["solar-pro", "solar-mini", "solar-docvision", "solar-embedding-1-large"]),
|
||||
maritalk: buildModels(["sabia-3", "sabia-3-small"]),
|
||||
"xiaomi-mimo": buildModels([
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2.5-tts",
|
||||
"mimo-v2.5-tts-voiceclone",
|
||||
"mimo-v2.5-tts-voicedesign",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"mimo-v2-tts",
|
||||
"mimo-v2-flash",
|
||||
]),
|
||||
upstage: buildModels(["solar-pro3", "solar-mini"]),
|
||||
maritalk: buildModels(["sabia-4", "sabia-3.1", "sabiazinho-4", "sabiazinho-3"]),
|
||||
"xiaomi-mimo": buildModels(["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni", "mimo-v2-flash"]),
|
||||
"inference-net": buildModels([
|
||||
"meta-llama/Llama-3.3-70B-Instruct",
|
||||
"deepseek-ai/DeepSeek-R1",
|
||||
@@ -254,7 +246,7 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
|
||||
};
|
||||
|
||||
function mapStainlessOs() {
|
||||
switch (platform()) {
|
||||
switch (getRuntimePlatform()) {
|
||||
case "darwin":
|
||||
return "MacOS";
|
||||
case "win32":
|
||||
@@ -262,12 +254,12 @@ function mapStainlessOs() {
|
||||
case "linux":
|
||||
return "Linux";
|
||||
default:
|
||||
return `Other::${platform()}`;
|
||||
return `Other::${getRuntimePlatform()}`;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStainlessArch() {
|
||||
switch (arch()) {
|
||||
switch (getRuntimeArch()) {
|
||||
case "x64":
|
||||
return "x64";
|
||||
case "arm64":
|
||||
@@ -275,7 +267,7 @@ function mapStainlessArch() {
|
||||
case "ia32":
|
||||
return "x86";
|
||||
default:
|
||||
return `other::${arch()}`;
|
||||
return `other::${getRuntimeArch()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +390,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "gpt-5.5-medium", name: "GPT 5.5 (Medium)", ...GPT_5_5_CODEX_CAPABILITIES },
|
||||
{ id: "gpt-5.5", name: "GPT 5.5", ...GPT_5_5_CODEX_CAPABILITIES },
|
||||
{ id: "gpt-5.5-low", name: "GPT 5.5 (Low)", ...GPT_5_5_CODEX_CAPABILITIES },
|
||||
{ id: "gpt-5.5-mini", name: "GPT 5.5 Mini", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.4", name: "GPT 5.4", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" },
|
||||
@@ -1094,6 +1085,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "grok-4.3", name: "Grok 4.3" },
|
||||
{ id: "grok-4.20-multi-agent-0309", name: "Grok 4.20 Multi Agent" },
|
||||
{ id: "grok-4.20-0309-reasoning", name: "Grok 4.20 Reasoning" },
|
||||
{ id: "grok-4.20-0309-non-reasoning", name: "Grok 4.20" },
|
||||
@@ -1111,19 +1103,18 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "apikey",
|
||||
authHeader: "cookie",
|
||||
models: [
|
||||
{ id: "gpt-5.3-instant", name: "GPT-5.3 Instant" },
|
||||
{ id: "gpt-5.3", name: "GPT-5.3" },
|
||||
{ id: "gpt-5.3-mini", name: "GPT-5.3 Mini" },
|
||||
{ id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking" },
|
||||
{ id: "gpt-5.4-thinking", name: "GPT-5.4 Thinking" },
|
||||
{ id: "gpt-5.4-thinking-mini", name: "GPT-5.4 Thinking Mini" },
|
||||
{ id: "gpt-5.2-instant", name: "GPT-5.2 Instant" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-thinking", name: "GPT-5.2 Thinking" },
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5", name: "GPT-5" },
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
|
||||
{ id: "o3", name: "o3" },
|
||||
{ id: "gpt-5.5-pro", name: "GPT-5.5 Pro" }, //pro tier only
|
||||
{ id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking" }, //plus, pro tier
|
||||
{ id: "gpt-5.4-pro", name: "GPT-5.4 Pro" }, //pro tier only
|
||||
{ id: "gpt-5.4-thinking", name: "GPT-5.4 Thinking" }, //plus, pro tier
|
||||
{ id: "gpt-5.4-thinking-mini", name: "GPT-5.4 Thinking Mini" }, //free-login only
|
||||
{ id: "gpt-5.3", name: "GPT-5.3" }, //free, free-login, plus, pro tier
|
||||
{ id: "gpt-5.3-mini", name: "GPT-5.3 Mini" }, //limit fallback
|
||||
{ id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, //pro tier only
|
||||
{ id: "gpt-5.2-thinking", name: "GPT-5.2 Thinking" }, //plus ~ tier
|
||||
{ id: "gpt-5.2-instant", name: "GPT-5.2 Instant" }, //plus ~ tier
|
||||
{ id: "o3", name: "o3" }, //plus ~ tier
|
||||
{ id: "gpt-4-5", name: "GPT-4.5" }, //pro tier only
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1137,10 +1128,10 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authHeader: "cookie",
|
||||
passthroughModels: true,
|
||||
models: [
|
||||
{ id: "fast", name: "Grok Fast" },
|
||||
{ id: "expert", name: "Grok 4.20 Thinking" },
|
||||
{ id: "heavy", name: "Grok 4.20 Multi Agent" },
|
||||
{ id: "grok-420-computer-use-sa", name: "Grok 4.3 (Beta)" },
|
||||
{ id: "fast", name: "Grok Fast", toolCalling: true },
|
||||
{ id: "expert", name: "Grok 4.20 Thinking", toolCalling: true },
|
||||
{ id: "heavy", name: "Grok 4.20 Multi Agent", toolCalling: true },
|
||||
{ id: "grok-420-computer-use-sa", name: "Grok 4.3 (Beta)", toolCalling: true },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1197,6 +1188,29 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
"muse-spark-web": {
|
||||
id: "muse-spark-web",
|
||||
alias: "ms-web",
|
||||
format: "openai",
|
||||
executor: "muse-spark-web",
|
||||
baseUrl: "https://www.meta.ai/api/graphql",
|
||||
authType: "apikey",
|
||||
authHeader: "cookie",
|
||||
models: [
|
||||
{ id: "muse-spark", name: "Muse Spark" },
|
||||
{
|
||||
id: "muse-spark-thinking",
|
||||
name: "Muse Spark Thinking",
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "muse-spark-contemplating",
|
||||
name: "Muse Spark Contemplating",
|
||||
supportsReasoning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
together: {
|
||||
id: "together",
|
||||
alias: "together",
|
||||
@@ -1953,9 +1967,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
alias: "maritalk",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://chat.maritaca.ai/api/chat/inference",
|
||||
baseUrl: MARITALK_DEFAULT_BASE_URL,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
authHeader: "key",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.maritalk,
|
||||
},
|
||||
|
||||
@@ -2033,6 +2047,20 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
passthroughModels: true,
|
||||
},
|
||||
// Free tier: 50 RPM, 500,000 TPM
|
||||
"nous-research": {
|
||||
id: "nous-research",
|
||||
alias: "nous",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://inference-api.nousresearch.com/v1",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "Hermes-4-405B", name: "Hermes 4 7B (Nous Research)" },
|
||||
{ id: "Hermes-4-70B", name: "Hermes 4 70B (Nous Research)" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Generator Functions ───────────────────────────────────────────────────
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface BaseProvider<M extends BaseModel = BaseModel> {
|
||||
id: string;
|
||||
baseUrl: string;
|
||||
authType: string; // "apikey" | "oauth" | "none"
|
||||
authHeader: string; // "bearer" | "token" | "xi-api-key" | "x-api-key" | "none"
|
||||
authHeader: string; // "bearer" | "key" | "token" | "xi-api-key" | "x-api-key" | "none"
|
||||
format?: string;
|
||||
models: M[];
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function getAllModelsFromRegistry<P extends BaseProvider>(
|
||||
|
||||
/**
|
||||
* Build auth headers for a provider.
|
||||
* Handles bearer, token, xi-api-key, x-api-key, and none.
|
||||
* Handles bearer, key, token, xi-api-key, x-api-key, and none.
|
||||
*/
|
||||
export function buildAuthHeaders(
|
||||
provider: BaseProvider,
|
||||
@@ -87,6 +87,8 @@ export function buildAuthHeaders(
|
||||
}
|
||||
|
||||
switch (provider.authHeader) {
|
||||
case "key":
|
||||
return { Authorization: `Key ${token}` };
|
||||
case "token":
|
||||
return { Authorization: `Token ${token}` };
|
||||
case "xi-api-key":
|
||||
|
||||
@@ -60,6 +60,15 @@ type AntigravityCollectedStream = {
|
||||
remainingCredits: Array<{ creditType: string; creditAmount: string }> | null;
|
||||
};
|
||||
|
||||
type AntigravityRequestEnvelope = Record<string, unknown> & {
|
||||
project: string;
|
||||
model: string;
|
||||
userAgent: "antigravity";
|
||||
requestType: "agent";
|
||||
requestId: string;
|
||||
request: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-account GOOGLE_ONE_AI credits-exhausted tracker.
|
||||
* Key: accountId (OAuth subject / email). Value: expiry timestamp.
|
||||
@@ -249,7 +258,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
return scrubProxyAndFingerprintHeaders(raw);
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
transformRequest(model, body, stream, credentials): AntigravityRequestEnvelope | Response {
|
||||
// TODO: Consider removing project override like gemini-cli.ts — stored projectId
|
||||
// can become stale for Cloud Code accounts, causing 403 "has not been used in project X".
|
||||
// Antigravity accounts may have more stable project IDs, but the risk exists.
|
||||
@@ -352,14 +361,24 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
project: _project,
|
||||
model: _model,
|
||||
userAgent: _userAgent,
|
||||
requestType: _requestType,
|
||||
requestId: _requestId,
|
||||
request: _request,
|
||||
...passthroughFields
|
||||
} = normalizedBody;
|
||||
|
||||
return {
|
||||
...normalizedBody,
|
||||
project: projectId,
|
||||
model: upstreamModel,
|
||||
userAgent: "antigravity",
|
||||
requestType: "agent",
|
||||
requestId: `agent-${crypto.randomUUID()}`,
|
||||
request: transformedRequest,
|
||||
...passthroughFields,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -572,15 +591,17 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
const url = this.buildUrl(model, upstreamStream, urlIndex);
|
||||
const headers = this.buildHeaders(credentials, upstreamStream);
|
||||
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
|
||||
let transformedBody = await this.transformRequest(model, body, upstreamStream, credentials);
|
||||
const transformed = await this.transformRequest(model, body, upstreamStream, credentials);
|
||||
let requestToolNameMap: Map<string, string> | null = null;
|
||||
|
||||
if (transformedBody instanceof Response) {
|
||||
return { response: transformedBody, url, headers, transformedBody: body };
|
||||
if (transformed instanceof Response) {
|
||||
return { response: transformed, url, headers, transformedBody: body };
|
||||
}
|
||||
|
||||
let transformedBody: Record<string, unknown> = transformed;
|
||||
|
||||
if (transformedBody && typeof transformedBody === "object") {
|
||||
const cloaked = cloakAntigravityToolPayload(transformedBody as Record<string, unknown>);
|
||||
const cloaked = cloakAntigravityToolPayload(transformedBody);
|
||||
transformedBody = cloaked.body;
|
||||
requestToolNameMap = cloaked.toolNameMap;
|
||||
}
|
||||
|
||||
@@ -220,9 +220,11 @@ export class BaseExecutor {
|
||||
buildHeaders(
|
||||
credentials: ProviderCredentials,
|
||||
stream = true,
|
||||
clientHeaders?: Record<string, string> | null
|
||||
clientHeaders?: Record<string, string> | null,
|
||||
model?: string
|
||||
): Record<string, string> {
|
||||
void clientHeaders;
|
||||
void model;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.config.headers,
|
||||
@@ -442,7 +444,7 @@ export class BaseExecutor {
|
||||
|
||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||
const url = this.buildUrl(model, stream, urlIndex, activeCredentials);
|
||||
const headers = this.buildHeaders(activeCredentials, stream, clientHeaders);
|
||||
const headers = this.buildHeaders(activeCredentials, stream, clientHeaders, model);
|
||||
applyConfiguredUserAgent(headers, activeCredentials?.providerSpecificData);
|
||||
|
||||
const ccRequestDefaults = isClaudeCodeCompatible(this.provider)
|
||||
|
||||
@@ -1182,6 +1182,24 @@ export class CodexExecutor extends BaseExecutor {
|
||||
body.service_tier = requestDefaults.serviceTier;
|
||||
}
|
||||
|
||||
// Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input.
|
||||
// This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences.
|
||||
if (!body.input && Array.isArray(body.messages)) {
|
||||
body.input = body.messages.map((msg: any) => ({
|
||||
type: "message",
|
||||
role: typeof msg.role === "string" ? msg.role : "user",
|
||||
content:
|
||||
typeof msg.content === "string"
|
||||
? [{ type: "input_text", text: msg.content }]
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.map((c: any) => {
|
||||
if (c && c.type === "text") return { type: "input_text", text: c.text };
|
||||
return c;
|
||||
})
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Cache-aware system prompt handling (both paths) ──
|
||||
//
|
||||
// Convert system → developer role IN-PLACE so system prompts remain in the
|
||||
@@ -1251,23 +1269,6 @@ export class CodexExecutor extends BaseExecutor {
|
||||
// so any references to previous response items would cause 404 errors.
|
||||
stripStoredItemReferences(body);
|
||||
|
||||
// Issue #1832: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input
|
||||
if (!body.input && Array.isArray(body.messages)) {
|
||||
body.input = body.messages.map((msg: any) => ({
|
||||
type: "message",
|
||||
role: typeof msg.role === "string" ? msg.role : "user",
|
||||
content:
|
||||
typeof msg.content === "string"
|
||||
? [{ type: "input_text", text: msg.content }]
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.map((c: any) => {
|
||||
if (c && c.type === "text") return { type: "input_text", text: c.text };
|
||||
return c;
|
||||
})
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
// Issue #806: Even for native passthrough, some clients (purist completions) might indiscriminately inject
|
||||
// a `messages` or `prompt` array which the strict Codex Responses schema rejects.
|
||||
delete body.messages;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
} from "../services/claudeCodeCompatible.ts";
|
||||
import { getGigachatAccessToken } from "../services/gigachatAuth.ts";
|
||||
import { getRegistryEntry } from "../config/providerRegistry.ts";
|
||||
import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
|
||||
import {
|
||||
getOpenAICompatibleType,
|
||||
@@ -21,6 +22,7 @@ import { buildBedrockChatUrl } from "../config/bedrock.ts";
|
||||
import { buildWatsonxChatUrl } from "../config/watsonx.ts";
|
||||
import { buildOciChatUrl } from "../config/oci.ts";
|
||||
import { buildSapChatUrl, getSapResourceGroup } from "../config/sap.ts";
|
||||
import { buildMaritalkChatUrl } from "../config/maritalk.ts";
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return (baseUrl || "").trim().replace(/\/$/, "");
|
||||
@@ -180,6 +182,10 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return normalizeGigachatChatUrl(baseUrl);
|
||||
}
|
||||
case "maritalk": {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return buildMaritalkChatUrl(baseUrl);
|
||||
}
|
||||
case "lm-studio":
|
||||
case "modal":
|
||||
case "reka":
|
||||
@@ -193,6 +199,11 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return normalizeOpenAIChatUrl(baseUrl);
|
||||
}
|
||||
case "zai":
|
||||
case "glm-coding-apikey": {
|
||||
const zaiBaseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return `${zaiBaseUrl}?beta=true`;
|
||||
}
|
||||
case "claude":
|
||||
case "glm":
|
||||
case "glmt":
|
||||
@@ -206,8 +217,11 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
const resourceUrl = credentials?.providerSpecificData?.resourceUrl;
|
||||
return `https://${resourceUrl || "portal.qwen.ai"}/v1/chat/completions`;
|
||||
}
|
||||
default:
|
||||
return this.config.baseUrl;
|
||||
default: {
|
||||
const url = this.config.baseUrl;
|
||||
const entry = getRegistryEntry(this.provider);
|
||||
return entry?.urlSuffix ? `${url}${entry.urlSuffix}` : url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +298,13 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "maritalk": {
|
||||
const token = effectiveKey || credentials.accessToken;
|
||||
if (token) {
|
||||
headers["Authorization"] = `Key ${token}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "claude":
|
||||
case "anthropic":
|
||||
effectiveKey
|
||||
@@ -295,6 +316,8 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
case "kimi-coding":
|
||||
case "bailian-coding-plan":
|
||||
case "kimi-coding-apikey":
|
||||
case "zai":
|
||||
case "glm-coding-apikey":
|
||||
headers["x-api-key"] = effectiveKey || credentials.accessToken;
|
||||
break;
|
||||
default:
|
||||
@@ -315,9 +338,18 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
}
|
||||
} else {
|
||||
const bearerToken = effectiveKey || credentials.accessToken;
|
||||
if (bearerToken) {
|
||||
headers["Authorization"] = `Bearer ${bearerToken}`;
|
||||
// Use registry authHeader if available, otherwise default to bearer
|
||||
const entry = getRegistryEntry(this.provider);
|
||||
const authHeader = entry?.authHeader || "bearer";
|
||||
const token = effectiveKey || credentials.accessToken;
|
||||
if (token) {
|
||||
if (authHeader === "x-api-key") {
|
||||
headers["x-api-key"] = token;
|
||||
} else if (authHeader === "x-goog-api-key") {
|
||||
headers["x-goog-api-key"] = token;
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,7 +385,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
if (typeof withDefaults === "object" && withDefaults !== null && !Array.isArray(withDefaults)) {
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
if (Object.prototype.hasOwnProperty.call(withDefaults, "stream_options")) {
|
||||
const withoutStreamOptions = { ...withDefaults } as Record<string, any>;
|
||||
const withoutStreamOptions = { ...withDefaults } as Record<string, unknown>;
|
||||
delete withoutStreamOptions.stream_options;
|
||||
withDefaults = withoutStreamOptions;
|
||||
}
|
||||
@@ -365,12 +397,12 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
withDefaults = {
|
||||
...withDefaults,
|
||||
stream_options: {
|
||||
...((withDefaults as any).stream_options || {}),
|
||||
...(((withDefaults as Record<string, unknown>).stream_options as object) || {}),
|
||||
include_usage: true,
|
||||
},
|
||||
};
|
||||
} else if (Object.prototype.hasOwnProperty.call(withDefaults, "stream_options")) {
|
||||
const withoutStreamOptions = { ...withDefaults } as Record<string, any>;
|
||||
const withoutStreamOptions = { ...withDefaults } as Record<string, unknown>;
|
||||
delete withoutStreamOptions.stream_options;
|
||||
withDefaults = withoutStreamOptions;
|
||||
}
|
||||
@@ -378,7 +410,10 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
if (this.provider === "qwen" && typeof withDefaults === "object" && withDefaults !== null) {
|
||||
return sanitizeQwenThinkingToolChoice(withDefaults as any, "QwenExecutor");
|
||||
return sanitizeQwenThinkingToolChoice(
|
||||
withDefaults as Record<string, unknown>,
|
||||
"QwenExecutor"
|
||||
);
|
||||
}
|
||||
return withDefaults;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BaseExecutor } from "./base.ts";
|
||||
import { randomUUID } from "crypto";
|
||||
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
|
||||
import { geminiCLIUserAgent, googApiClientHeader } from "../services/antigravityHeaders.ts";
|
||||
import { getGeminiCliHeaders } from "../services/geminiCliHeaders.ts";
|
||||
import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts";
|
||||
import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts";
|
||||
import {
|
||||
@@ -19,15 +20,13 @@ const ONBOARD_DELAY_MS = 5_000;
|
||||
const DEFAULT_PROJECT_ID = "default-project";
|
||||
const DEFAULT_ONBOARD_TIER = "free-tier";
|
||||
const LOAD_CODE_ASSIST_METADATA = Object.freeze({
|
||||
ideType: "ANTIGRAVITY",
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
duetProject: DEFAULT_PROJECT_ID,
|
||||
});
|
||||
const ONBOARD_METADATA = Object.freeze({
|
||||
ideType: "ANTIGRAVITY",
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
duetProject: DEFAULT_PROJECT_ID,
|
||||
});
|
||||
|
||||
// Per-account cache: accessToken -> { projectId, expiresAt }
|
||||
@@ -51,6 +50,22 @@ function normalizeGeminiModel(model: string): string {
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function generateGeminiCliRequestId(): string {
|
||||
return `agent-${randomUUID()}`;
|
||||
}
|
||||
|
||||
function generateGeminiCliSessionId(): string {
|
||||
return `-${Date.now()}`;
|
||||
}
|
||||
|
||||
function cloneGeminiCliRecord(value: Record<string, any>): Record<string, any> {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function extractProjectId(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
const data = payload as LoadCodeAssistResponse;
|
||||
@@ -105,31 +120,27 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0) {
|
||||
this._currentModel = normalizeGeminiModel(model);
|
||||
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
|
||||
return `${this.config.baseUrl}:${action}`;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const raw = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${credentials.accessToken}`,
|
||||
// Dynamic headers matching native GeminiCLI client
|
||||
"User-Agent": geminiCLIUserAgent(this._currentModel || "unknown"),
|
||||
"X-Goog-Api-Client": googApiClientHeader(),
|
||||
...(stream && { Accept: "text/event-stream" }),
|
||||
};
|
||||
buildHeaders(credentials, stream = true, clientHeaders?: Record<string, string> | null, model?: string) {
|
||||
void clientHeaders;
|
||||
const raw = getGeminiCliHeaders(
|
||||
normalizeGeminiModel(model || "unknown"),
|
||||
credentials.accessToken,
|
||||
stream ? "*/*" : "application/json"
|
||||
);
|
||||
return scrubProxyAndFingerprintHeaders(raw);
|
||||
}
|
||||
|
||||
// Track current model for dynamic UA. BaseExecutor calls buildUrl before buildHeaders.
|
||||
private _currentModel = "unknown";
|
||||
|
||||
async onboardManagedProject(
|
||||
accessToken: string,
|
||||
tierId = DEFAULT_ONBOARD_TIER,
|
||||
options: OnboardOptions = {}
|
||||
options: OnboardOptions = {},
|
||||
model = "unknown"
|
||||
): Promise<string | null> {
|
||||
const currentModel = normalizeGeminiModel(model);
|
||||
const attempts =
|
||||
Number.isInteger(options.attempts) && options.attempts! > 0
|
||||
? Number(options.attempts)
|
||||
@@ -155,11 +166,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
try {
|
||||
response = await fetch(ONBOARD_USER_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"User-Agent": "GeminiCLI/1.0.0",
|
||||
},
|
||||
headers: getGeminiCliHeaders(currentModel, accessToken, "application/json"),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -201,7 +208,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
* Native Gemini CLI refreshes this every 30 seconds — OmniRoute stores it once
|
||||
* at OAuth connection time, so it goes stale. This method keeps it fresh.
|
||||
*/
|
||||
async refreshProject(accessToken: string): Promise<string | null> {
|
||||
async refreshProject(accessToken: string, model = "unknown"): Promise<string | null> {
|
||||
// Check cache
|
||||
const cached = projectCache.get(accessToken);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
@@ -212,7 +219,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
const inflight = inflightRefresh.get(accessToken);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const promise = this._doRefresh(accessToken);
|
||||
const promise = this._doRefresh(accessToken, model);
|
||||
inflightRefresh.set(accessToken, promise);
|
||||
try {
|
||||
return await promise;
|
||||
@@ -221,7 +228,8 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
async _doRefresh(accessToken: string): Promise<string | null> {
|
||||
async _doRefresh(accessToken: string, model = "unknown"): Promise<string | null> {
|
||||
const currentModel = normalizeGeminiModel(model);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), LOAD_CODE_ASSIST_TIMEOUT_MS);
|
||||
@@ -230,12 +238,8 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
try {
|
||||
response = await fetch(LOAD_CODE_ASSIST_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: getGeminiCliHeaders(currentModel, accessToken, "application/json"),
|
||||
body: JSON.stringify({
|
||||
cloudaicompanionProject: DEFAULT_PROJECT_ID,
|
||||
metadata: { ...LOAD_CODE_ASSIST_METADATA },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
@@ -258,7 +262,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
console.warn(
|
||||
"[OmniRoute] loadCodeAssist returned no project — attempting managed project onboarding"
|
||||
);
|
||||
projectId = await this.onboardManagedProject(accessToken, extractDefaultTierId(data));
|
||||
projectId = await this.onboardManagedProject(accessToken, extractDefaultTierId(data), {}, currentModel);
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
@@ -279,25 +283,50 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
async transformRequest(model, body, stream, credentials) {
|
||||
this._currentModel = normalizeGeminiModel(model);
|
||||
const currentModel = normalizeGeminiModel(model);
|
||||
const normalizedBody =
|
||||
shouldStripCloudCodeThinking(this.provider, this._currentModel) &&
|
||||
shouldStripCloudCodeThinking(this.provider, currentModel) &&
|
||||
body &&
|
||||
typeof body === "object"
|
||||
? stripCloudCodeThinkingConfig(body)
|
||||
: body;
|
||||
|
||||
// Refresh the project ID via loadCodeAssist (cached for 30s).
|
||||
if (normalizedBody && typeof normalizedBody === "object" && normalizedBody.request) {
|
||||
if (credentials.accessToken) {
|
||||
const freshProject = await this.refreshProject(credentials.accessToken);
|
||||
if (freshProject) {
|
||||
normalizedBody.project = freshProject;
|
||||
}
|
||||
}
|
||||
const bodyRecord =
|
||||
normalizedBody && typeof normalizedBody === "object"
|
||||
? (normalizedBody as Record<string, any>)
|
||||
: { request: {} };
|
||||
const requestRecord =
|
||||
bodyRecord.request && typeof bodyRecord.request === "object"
|
||||
? cloneGeminiCliRecord(bodyRecord.request as Record<string, any>)
|
||||
: {};
|
||||
|
||||
const envelope: Record<string, any> = {
|
||||
model: currentModel,
|
||||
project: bodyRecord.project || credentials.projectId || "",
|
||||
user_prompt_id: bodyRecord.user_prompt_id || generateGeminiCliRequestId(),
|
||||
request: {
|
||||
...requestRecord,
|
||||
session_id: requestRecord.session_id || generateGeminiCliSessionId(),
|
||||
},
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(bodyRecord)) {
|
||||
if (!(key in envelope) && key !== "request") {
|
||||
envelope[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the project ID via loadCodeAssist (cached for 30s).
|
||||
if (credentials.accessToken) {
|
||||
const freshProject = await this.refreshProject(credentials.accessToken, currentModel);
|
||||
if (freshProject) {
|
||||
envelope.project = freshProject;
|
||||
}
|
||||
}
|
||||
|
||||
if (envelope.request) {
|
||||
// Obfuscate sensitive client names in user content
|
||||
const contents = normalizedBody.request?.contents;
|
||||
const contents = envelope.request?.contents;
|
||||
if (Array.isArray(contents)) {
|
||||
for (const msg of contents) {
|
||||
if (Array.isArray(msg.parts)) {
|
||||
@@ -310,7 +339,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalizedBody;
|
||||
return envelope;
|
||||
}
|
||||
|
||||
async refreshCredentials(credentials, log) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,53 @@ function resolveAwsPollyBaseUrl(providerSpecificData, region) {
|
||||
return stripTrailingSlashes(baseUrl.replace(/\/v1\/speech\/?$/i, ""));
|
||||
}
|
||||
|
||||
function getProviderSpecificData(credentials) {
|
||||
return credentials?.providerSpecificData &&
|
||||
typeof credentials.providerSpecificData === "object" &&
|
||||
!Array.isArray(credentials.providerSpecificData)
|
||||
? credentials.providerSpecificData
|
||||
: {};
|
||||
}
|
||||
|
||||
function normalizeXiaomiMimoSpeechUrl(baseUrl) {
|
||||
const configured = getStringValue(baseUrl) || "https://api.xiaomimimo.com/v1";
|
||||
const normalized = stripTrailingSlashes(configured).replace(/\/chat\/completions$/i, "");
|
||||
return `${normalized}/chat/completions`;
|
||||
}
|
||||
|
||||
function normalizeXiaomiMimoMimeType(format) {
|
||||
switch (getStringValue(format)?.toLowerCase()) {
|
||||
case undefined:
|
||||
case null:
|
||||
case "mp3":
|
||||
case "audio/mp3":
|
||||
case "audio/mpeg":
|
||||
return "audio/mpeg";
|
||||
case "wav":
|
||||
case "audio/wav":
|
||||
return "audio/wav";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getXiaomiMimoAudioData(data) {
|
||||
const messageAudio = data?.choices?.[0]?.message?.audio;
|
||||
const directAudio = data?.audio || data?.output_audio;
|
||||
const firstDataItem = Array.isArray(data?.data) ? data.data[0] : null;
|
||||
|
||||
return (
|
||||
getStringValue(messageAudio?.data) ||
|
||||
getStringValue(messageAudio?.b64_json) ||
|
||||
getStringValue(directAudio?.data) ||
|
||||
getStringValue(directAudio?.b64_json) ||
|
||||
getStringValue(firstDataItem?.b64_json) ||
|
||||
getStringValue(firstDataItem?.audio) ||
|
||||
getStringValue(data?.audioContent) ||
|
||||
getStringValue(data?.audio_content)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAwsPollyEngine(modelId) {
|
||||
const engine = getStringValue(modelId) || "standard";
|
||||
return ["standard", "neural", "long-form", "generative"].includes(engine) ? engine : "standard";
|
||||
@@ -467,6 +514,59 @@ async function handleAwsPollySpeech(providerConfig, body, modelId, token, creden
|
||||
return audioStreamResponse(res, outputFormat === "pcm" ? "audio/pcm" : "audio/mpeg");
|
||||
}
|
||||
|
||||
/**
|
||||
* Xiaomi MiMo TTS uses chat/completions with an audio config instead of OpenAI's /audio/speech
|
||||
* request body.
|
||||
*/
|
||||
async function handleXiaomiMimoSpeech(providerConfig, body, modelId, token, credentials) {
|
||||
const providerSpecificData = getProviderSpecificData(credentials);
|
||||
const url = normalizeXiaomiMimoSpeechUrl(providerSpecificData.baseUrl || providerConfig.baseUrl);
|
||||
const audioMimeType = normalizeXiaomiMimoMimeType(body.response_format);
|
||||
if (!audioMimeType) {
|
||||
return errorResponse(400, "Xiaomi MiMo TTS supports response_format mp3 or wav only");
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...buildAuthHeaders(providerConfig, token),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [{ role: "assistant", content: body.input }],
|
||||
audio: {
|
||||
format: audioMimeType,
|
||||
voice: body.voice || getStringValue(providerSpecificData.defaultVoice) || "mimo_default",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return upstreamErrorResponse(res, await res.text());
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (contentType.startsWith("audio/")) {
|
||||
return audioStreamResponse(res, audioMimeType);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const audioBase64 = getXiaomiMimoAudioData(data);
|
||||
if (!audioBase64) {
|
||||
return errorResponse(502, "Xiaomi MiMo TTS response did not contain audio data");
|
||||
}
|
||||
|
||||
const audioBuffer = Uint8Array.from(atob(audioBase64), (c) => c.charCodeAt(0));
|
||||
return new Response(audioBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
"Content-Type": audioMimeType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Coqui TTS (local, no auth)
|
||||
* POST {baseUrl} with { text, speaker_id } → WAV audio
|
||||
@@ -556,7 +656,7 @@ export async function handleAudioSpeech({
|
||||
if (!providerConfig) {
|
||||
return errorResponse(
|
||||
400,
|
||||
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, aws-polly, coqui, tortoise, qwen`
|
||||
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, aws-polly, xiaomi-mimo, coqui, tortoise, qwen`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -605,6 +705,10 @@ export async function handleAudioSpeech({
|
||||
return handleAwsPollySpeech(providerConfig, body, modelId, token, credentials);
|
||||
}
|
||||
|
||||
if (providerConfig.format === "xiaomi-mimo-tts") {
|
||||
return handleXiaomiMimoSpeech(providerConfig, body, modelId, token, credentials);
|
||||
}
|
||||
|
||||
if (providerConfig.format === "coqui") {
|
||||
return handleCoquiSpeech(providerConfig, body);
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ import {
|
||||
isFallbackDecision,
|
||||
EMERGENCY_FALLBACK_CONFIG,
|
||||
} from "../services/emergencyFallback.ts";
|
||||
import type { CompressionConfig } from "../services/compression/types.ts";
|
||||
import { prepareWebSearchFallbackBody } from "../services/webSearchFallback.ts";
|
||||
import {
|
||||
resolveExplicitStreamAlias,
|
||||
@@ -1561,15 +1562,37 @@ export async function handleChatCore({
|
||||
body?.messages || body?.input || body?.contents || body?.request?.contents || [];
|
||||
if (body && Array.isArray(allMessages) && allMessages.length > 0) {
|
||||
let estimatedTokens = estimateTokens(JSON.stringify(allMessages));
|
||||
let promptCompressionEnabled = false;
|
||||
let compressionSettings: CompressionConfig | null = null;
|
||||
|
||||
try {
|
||||
const { getCompressionSettings } = await import("../../src/lib/db/compression.ts");
|
||||
compressionSettings = await getCompressionSettings();
|
||||
promptCompressionEnabled = compressionSettings.enabled;
|
||||
} catch (err) {
|
||||
log?.warn?.(
|
||||
"COMPRESSION",
|
||||
"Compression settings lookup skipped: " + (err instanceof Error ? err.message : String(err))
|
||||
);
|
||||
}
|
||||
|
||||
// --- Modular Compression Pipeline (Phase 1 Lite + Phase 2 Standard/Caveman + Phase 3 Aggressive) ---
|
||||
// Runs BEFORE the existing reactive compressContext() to proactively reduce tokens.
|
||||
try {
|
||||
const { getCompressionSettings } = await import("../../src/lib/db/compression.ts");
|
||||
const { selectCompressionStrategy, applyCompression } =
|
||||
await import("../services/compression/strategySelector.ts");
|
||||
const { trackCompressionStats } = await import("../services/compression/stats.ts");
|
||||
let config = await getCompressionSettings();
|
||||
let config: CompressionConfig = compressionSettings ?? {
|
||||
enabled: false,
|
||||
defaultMode: "off",
|
||||
autoTriggerTokens: 0,
|
||||
cacheMinutes: 5,
|
||||
preserveSystemPrompt: true,
|
||||
comboOverrides: {},
|
||||
};
|
||||
if (!promptCompressionEnabled || !compressionSettings) {
|
||||
log?.debug?.("COMPRESSION", "Prompt compression disabled or unavailable");
|
||||
}
|
||||
let compressionComboKey = comboName ?? null;
|
||||
if (isCombo && comboName) {
|
||||
try {
|
||||
@@ -1704,6 +1727,12 @@ export async function handleChatCore({
|
||||
}
|
||||
// --- End Modular Compression Pipeline ---
|
||||
|
||||
if (!promptCompressionEnabled) {
|
||||
log?.debug?.(
|
||||
"CONTEXT",
|
||||
"Skipping proactive context compression: Prompt Compression disabled"
|
||||
);
|
||||
}
|
||||
let contextLimit = getTokenLimit(provider, effectiveModel);
|
||||
|
||||
if (isCombo && comboName) {
|
||||
@@ -1748,7 +1777,7 @@ export async function handleChatCore({
|
||||
`Checking compression: ${estimatedTokens} tokens vs ${threshold} threshold (${contextLimit} limit, ${reservedTokens} reserved)`
|
||||
);
|
||||
|
||||
if (estimatedTokens > threshold) {
|
||||
if (promptCompressionEnabled && estimatedTokens > threshold) {
|
||||
log?.info?.(
|
||||
"CONTEXT",
|
||||
`Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)`
|
||||
@@ -2190,6 +2219,11 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI's `store` parameter is not supported by most compatible providers and breaks them
|
||||
if (provider !== "openai" && "store" in translatedBody) {
|
||||
delete translatedBody.store;
|
||||
}
|
||||
|
||||
// Provider-specific max_tokens caps (#711)
|
||||
// Some providers reject requests when max_tokens exceeds their API limit.
|
||||
// Cap before sending to avoid upstream HTTP 400 errors.
|
||||
|
||||
@@ -123,10 +123,17 @@ export function translateNonStreamingResponse(
|
||||
toString(itemObj.call_id) ||
|
||||
toString(itemObj.id) ||
|
||||
`call_${Date.now()}_${toolCalls.length}`;
|
||||
let argsToEmit = itemObj.arguments;
|
||||
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
|
||||
const cleaned: JsonRecord = { ...(argsToEmit as JsonRecord) };
|
||||
for (const [k, v] of Object.entries(cleaned)) {
|
||||
if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k];
|
||||
}
|
||||
argsToEmit = cleaned;
|
||||
}
|
||||
|
||||
const fnArgs =
|
||||
typeof itemObj.arguments === "string"
|
||||
? itemObj.arguments
|
||||
: JSON.stringify(itemObj.arguments || {});
|
||||
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {});
|
||||
const rawName = toString(itemObj.name);
|
||||
// Strip Claude OAuth proxy_ prefix using toolNameMap
|
||||
const resolvedName = toolNameMap?.get(rawName) ?? rawName;
|
||||
|
||||
@@ -1072,6 +1072,115 @@ export const compressionConfigureTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/compression/configure"],
|
||||
};
|
||||
|
||||
// ============ 1proxy Tools ============
|
||||
|
||||
export const oneproxyFetchInput = z.object({
|
||||
protocol: z.string().optional().describe("Filter by protocol: http, https, socks4, socks5"),
|
||||
countryCode: z.string().optional().describe("Filter by country code (e.g. US, DE)"),
|
||||
minQuality: z.number().optional().describe("Minimum quality score (0-100)"),
|
||||
limit: z.number().optional().describe("Maximum number of proxies to return"),
|
||||
});
|
||||
|
||||
export const oneproxyFetchOutput = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
type: z.string(),
|
||||
countryCode: z.string().nullable(),
|
||||
qualityScore: z.number().nullable(),
|
||||
latencyMs: z.number().nullable(),
|
||||
anonymity: z.string().nullable(),
|
||||
googleAccess: z.boolean(),
|
||||
status: z.string(),
|
||||
})
|
||||
),
|
||||
total: z.number(),
|
||||
});
|
||||
|
||||
export const oneproxyFetchTool: McpToolDefinition<
|
||||
typeof oneproxyFetchInput,
|
||||
typeof oneproxyFetchOutput
|
||||
> = {
|
||||
name: "omniroute_oneproxy_fetch",
|
||||
description:
|
||||
"Fetch free proxies from the 1proxy marketplace with optional filters for protocol, country, and quality. Returns validated proxies with quality scores.",
|
||||
inputSchema: oneproxyFetchInput,
|
||||
outputSchema: oneproxyFetchOutput,
|
||||
scopes: ["read:proxies"],
|
||||
auditLevel: "basic",
|
||||
phase: 2,
|
||||
sourceEndpoints: ["/api/settings/oneproxy"],
|
||||
};
|
||||
|
||||
export const oneproxyRotateInput = z.object({
|
||||
strategy: z
|
||||
.enum(["random", "quality", "sequential"])
|
||||
.optional()
|
||||
.describe("Rotation strategy: quality (best first), random, or sequential"),
|
||||
});
|
||||
|
||||
export const oneproxyRotateOutput = z.object({
|
||||
id: z.string(),
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
type: z.string(),
|
||||
countryCode: z.string().nullable(),
|
||||
qualityScore: z.number().nullable(),
|
||||
latencyMs: z.number().nullable(),
|
||||
});
|
||||
|
||||
export const oneproxyRotateTool: McpToolDefinition<
|
||||
typeof oneproxyRotateInput,
|
||||
typeof oneproxyRotateOutput
|
||||
> = {
|
||||
name: "omniroute_oneproxy_rotate",
|
||||
description:
|
||||
"Get the next available free proxy from the 1proxy pool using the specified rotation strategy.",
|
||||
inputSchema: oneproxyRotateInput,
|
||||
outputSchema: oneproxyRotateOutput,
|
||||
scopes: ["read:proxies"],
|
||||
auditLevel: "basic",
|
||||
phase: 2,
|
||||
sourceEndpoints: ["/api/settings/oneproxy/rotate"],
|
||||
};
|
||||
|
||||
export const oneproxyStatsInput = z.object({}).describe("No parameters required");
|
||||
|
||||
export const oneproxyStatsOutput = z.object({
|
||||
stats: z.object({
|
||||
total: z.number(),
|
||||
active: z.number(),
|
||||
avgQuality: z.number().nullable(),
|
||||
lastValidated: z.string().nullable(),
|
||||
byProtocol: z.array(z.object({ protocol: z.string(), count: z.number() })),
|
||||
byCountry: z.array(z.object({ countryCode: z.string(), count: z.number() })),
|
||||
}),
|
||||
status: z.object({
|
||||
lastSyncSuccess: z.boolean(),
|
||||
lastSyncError: z.string().nullable(),
|
||||
lastSyncAt: z.string().nullable(),
|
||||
lastSyncCount: z.number(),
|
||||
consecutiveFailures: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const oneproxyStatsTool: McpToolDefinition<
|
||||
typeof oneproxyStatsInput,
|
||||
typeof oneproxyStatsOutput
|
||||
> = {
|
||||
name: "omniroute_oneproxy_stats",
|
||||
description:
|
||||
"Returns 1proxy sync status and statistics: total proxies, average quality, sync history, and distribution by protocol and country.",
|
||||
inputSchema: oneproxyStatsInput,
|
||||
outputSchema: oneproxyStatsOutput,
|
||||
scopes: ["read:proxies"],
|
||||
auditLevel: "basic",
|
||||
phase: 2,
|
||||
sourceEndpoints: ["/api/settings/oneproxy"],
|
||||
};
|
||||
|
||||
// ============ Tool Registry ============
|
||||
|
||||
/** All MCP tool definitions, ordered by phase then name */
|
||||
@@ -1100,6 +1209,9 @@ export const MCP_TOOLS = [
|
||||
cacheFlushTool,
|
||||
compressionStatusTool,
|
||||
compressionConfigureTool,
|
||||
oneproxyFetchTool,
|
||||
oneproxyRotateTool,
|
||||
oneproxyStatsTool,
|
||||
] as const;
|
||||
|
||||
/** Essential tools only (Phase 1) */
|
||||
|
||||
@@ -42,6 +42,9 @@ import {
|
||||
syncPricingInput,
|
||||
cacheStatsInput,
|
||||
cacheFlushInput,
|
||||
oneproxyFetchInput,
|
||||
oneproxyRotateInput,
|
||||
oneproxyStatsInput,
|
||||
} from "./schemas/tools.ts";
|
||||
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
|
||||
|
||||
@@ -66,6 +69,9 @@ import {
|
||||
handleSyncPricing,
|
||||
handleCacheStats,
|
||||
handleCacheFlush,
|
||||
handleOneproxyFetch,
|
||||
handleOneproxyRotate,
|
||||
handleOneproxyStats,
|
||||
} from "./tools/advancedTools.ts";
|
||||
import { memoryTools } from "./tools/memoryTools.ts";
|
||||
import { skillTools } from "./tools/skillTools.ts";
|
||||
@@ -832,6 +838,44 @@ export function createMcpServer(): McpServer {
|
||||
)
|
||||
);
|
||||
|
||||
// ── 1proxy Tools ──────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_oneproxy_fetch",
|
||||
{
|
||||
description:
|
||||
"Fetch free proxies from the 1proxy marketplace with optional filters for protocol, country, and quality. Returns validated proxies with quality scores.",
|
||||
inputSchema: oneproxyFetchInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_oneproxy_fetch", (args) =>
|
||||
handleOneproxyFetch(oneproxyFetchInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_oneproxy_rotate",
|
||||
{
|
||||
description:
|
||||
"Get the next available free proxy from the 1proxy pool using the specified rotation strategy.",
|
||||
inputSchema: oneproxyRotateInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_oneproxy_rotate", (args) =>
|
||||
handleOneproxyRotate(oneproxyRotateInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_oneproxy_stats",
|
||||
{
|
||||
description:
|
||||
"Returns 1proxy sync status and statistics: total proxies, average quality, sync history, and distribution by protocol and country.",
|
||||
inputSchema: oneproxyStatsInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_oneproxy_stats", (args) =>
|
||||
handleOneproxyStats(oneproxyStatsInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
// ── Memory Tools ──────────────────────────────
|
||||
Object.values(memoryTools).forEach((toolDef) => {
|
||||
server.registerTool(
|
||||
|
||||
@@ -1000,3 +1000,119 @@ export async function handleCacheFlush(args: { signature?: string; model?: strin
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 1proxy Tools ============
|
||||
|
||||
export async function handleOneproxyFetch(
|
||||
args: { protocol?: string; countryCode?: string; minQuality?: number; limit?: number } = {}
|
||||
) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (args.protocol) params.set("protocol", args.protocol);
|
||||
if (args.countryCode) params.set("countryCode", args.countryCode);
|
||||
if (args.minQuality) params.set("minQuality", String(args.minQuality));
|
||||
if (args.limit) params.set("limit", String(args.limit));
|
||||
|
||||
const query = params.toString();
|
||||
const path = query ? `/api/settings/oneproxy?${query}` : "/api/settings/oneproxy";
|
||||
const raw = toRecord(await apiFetch(path));
|
||||
|
||||
const items = toArrayOfRecords(raw.items).map((r) => ({
|
||||
id: toString(r.id, ""),
|
||||
host: toString(r.host, ""),
|
||||
port: toNumber(r.port, 0),
|
||||
type: toString(r.type, "http"),
|
||||
countryCode: typeof r.country_code === "string" ? r.country_code : null,
|
||||
qualityScore: r.quality_score != null ? toNumber(r.quality_score) : null,
|
||||
latencyMs: r.latency_ms != null ? toNumber(r.latency_ms) : null,
|
||||
anonymity: typeof r.anonymity === "string" ? r.anonymity : null,
|
||||
googleAccess: r.google_access === 1 || r.google_access === true,
|
||||
status: toString(r.status, "active"),
|
||||
}));
|
||||
|
||||
const result = { items, total: toNumber(raw.total, items.length) };
|
||||
await logToolCall("omniroute_oneproxy_fetch", 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_oneproxy_fetch", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleOneproxyRotate(
|
||||
args: { strategy?: "random" | "quality" | "sequential" } = {}
|
||||
) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (args.strategy) body.strategy = args.strategy;
|
||||
|
||||
const raw = toRecord(
|
||||
await apiFetch("/api/settings/oneproxy/rotate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
);
|
||||
|
||||
const result = {
|
||||
id: toString(raw.id, ""),
|
||||
host: toString(raw.host, ""),
|
||||
port: toNumber(raw.port, 0),
|
||||
type: toString(raw.type, "http"),
|
||||
countryCode: typeof raw.country_code === "string" ? raw.country_code : null,
|
||||
qualityScore: raw.quality_score != null ? toNumber(raw.quality_score) : null,
|
||||
latencyMs: raw.latency_ms != null ? toNumber(raw.latency_ms) : null,
|
||||
};
|
||||
|
||||
await logToolCall("omniroute_oneproxy_rotate", 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_oneproxy_rotate", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleOneproxyStats(args: Record<string, never> = {}) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const raw = toRecord(await apiFetch("/api/settings/oneproxy?action=stats"));
|
||||
|
||||
const statsRaw = toRecord(raw.stats);
|
||||
const statusRaw = toRecord(raw.status);
|
||||
|
||||
const stats = {
|
||||
total: toNumber(statsRaw.total, 0),
|
||||
active: toNumber(statsRaw.active, 0),
|
||||
avgQuality: statsRaw.avg_quality != null ? toNumber(statsRaw.avg_quality) : null,
|
||||
lastValidated: typeof statsRaw.last_validated === "string" ? statsRaw.last_validated : null,
|
||||
byProtocol: toArrayOfRecords(statsRaw.by_protocol || statsRaw.byProtocol).map((r) => ({
|
||||
protocol: toString(r.protocol, ""),
|
||||
count: toNumber(r.count, 0),
|
||||
})),
|
||||
byCountry: toArrayOfRecords(statsRaw.by_country || statsRaw.byCountry).map((r) => ({
|
||||
countryCode: toString(r.countryCode || r.country_code, ""),
|
||||
count: toNumber(r.count, 0),
|
||||
})),
|
||||
};
|
||||
|
||||
const status = {
|
||||
lastSyncSuccess: toBoolean(statusRaw.last_sync_success, false),
|
||||
lastSyncError:
|
||||
typeof statusRaw.last_sync_error === "string" ? statusRaw.last_sync_error : null,
|
||||
lastSyncAt: typeof statusRaw.last_sync_at === "string" ? statusRaw.last_sync_at : null,
|
||||
lastSyncCount: toNumber(statusRaw.last_sync_count, 0),
|
||||
consecutiveFailures: toNumber(statusRaw.consecutive_failures, 0),
|
||||
};
|
||||
|
||||
const result = { stats, status };
|
||||
await logToolCall("omniroute_oneproxy_stats", 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_oneproxy_stats", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.7.7",
|
||||
"version": "3.7.8",
|
||||
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -56,7 +56,16 @@ type ModelFailureState = {
|
||||
|
||||
// Provider-level failure tracking for circuit breaker behavior
|
||||
// Error codes that count toward provider-level failure threshold
|
||||
const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 429, 500, 502, 503, 504]);
|
||||
// 429 (rate limit) is intentionally excluded: rate limits are connection-scoped
|
||||
// and handled via Connection Cooldown, not provider-wide circuit breaker.
|
||||
// Counting 429 toward provider failure causes cascading provider trips at scale
|
||||
// when many connections hit rate limits simultaneously (Issue #1846).
|
||||
const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 500, 502, 503, 504]);
|
||||
|
||||
// Per-connection failure deduplication: prevents rapid-fire failures from the
|
||||
// same connection from counting multiple times toward the provider breaker.
|
||||
const CONNECTION_FAILURE_DEDUP_MS = 5000;
|
||||
const lastConnectionFailure = new Map<string, number>();
|
||||
|
||||
// T06 (sub2api PR #1037): Signals that indicate permanent account deactivation.
|
||||
// When a 401 body contains these strings, the account is permanently dead
|
||||
@@ -541,14 +550,29 @@ export function getProviderCooldownRemainingMs(provider: string | null | undefin
|
||||
*/
|
||||
export function recordProviderFailure(
|
||||
provider: string | null | undefined,
|
||||
log?: { warn?: (...args: unknown[]) => void }
|
||||
log?: { warn?: (...args: unknown[]) => void },
|
||||
connectionId?: string | null
|
||||
): void {
|
||||
if (!provider) return;
|
||||
|
||||
// Deduplicate rapid-fire failures from the same connection
|
||||
if (connectionId) {
|
||||
const dedupKey = `${provider}:${connectionId}`;
|
||||
const now = Date.now();
|
||||
const lastFailure = lastConnectionFailure.get(dedupKey);
|
||||
if (lastFailure && now - lastFailure < CONNECTION_FAILURE_DEDUP_MS) {
|
||||
return;
|
||||
}
|
||||
// Prevent memory leak by clearing map if it grows too large
|
||||
if (lastConnectionFailure.size > 10000) {
|
||||
lastConnectionFailure.clear();
|
||||
}
|
||||
lastConnectionFailure.set(dedupKey, now);
|
||||
}
|
||||
|
||||
const breaker = getProviderBreaker(provider);
|
||||
if (!breaker) return;
|
||||
|
||||
// Skip if already in cooldown to prevent timer reset (indefinite lockout bug)
|
||||
if (!breaker.canExecute()) return;
|
||||
|
||||
breaker._onFailure();
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface AcquireAccountSemaphoreOptions {
|
||||
maxConcurrency?: number | null;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal | null;
|
||||
maxQueueSize?: number;
|
||||
}
|
||||
|
||||
export interface AccountSemaphoreStatsEntry {
|
||||
@@ -39,6 +40,7 @@ export interface AccountSemaphoreStatsEntry {
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_QUEUE_SIZE = 20;
|
||||
|
||||
const gates = new Map<string, AccountGate>();
|
||||
|
||||
@@ -187,6 +189,7 @@ export function acquire(
|
||||
maxConcurrency = null,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
signal = null,
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
}: AcquireAccountSemaphoreOptions = {}
|
||||
): Promise<() => void> {
|
||||
if (isBypassed(maxConcurrency)) {
|
||||
@@ -205,6 +208,14 @@ export function acquire(
|
||||
return Promise.resolve(createReleaseFn(semaphoreKey));
|
||||
}
|
||||
|
||||
if (gate.queue.length >= maxQueueSize) {
|
||||
const err = new Error(`Semaphore queue full (${maxQueueSize}) for ${semaphoreKey}`) as Error & {
|
||||
code: string;
|
||||
};
|
||||
err.code = "SEMAPHORE_QUEUE_FULL";
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let abortListener: (() => void) | null = null;
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
} from "./antigravityVersion.ts";
|
||||
|
||||
/**
|
||||
* Antigravity and Gemini CLI header utilities.
|
||||
* Antigravity header utilities.
|
||||
*
|
||||
* Generates User-Agent strings and API client headers that match
|
||||
* the real Antigravity and Gemini CLI binaries.
|
||||
* the real Antigravity client flows.
|
||||
*
|
||||
* Based on CLIProxyAPI's misc/header_utils.go.
|
||||
*/
|
||||
@@ -16,12 +16,10 @@ import {
|
||||
type AntigravityHeaderProfile = "loadCodeAssist" | "fetchAvailableModels" | "models";
|
||||
|
||||
const ANTIGRAVITY_VERSION = ANTIGRAVITY_FALLBACK_VERSION;
|
||||
export const GEMINI_CLI_VERSION = "0.39.1";
|
||||
export const GEMINI_SDK_VERSION = "1.30.0";
|
||||
export const NODE_VERSION = "v22.21.1";
|
||||
export const ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT = "google-api-nodejs-client/10.3.0";
|
||||
export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT =
|
||||
"google-cloud-sdk vscode_cloudshelleditor/0.1";
|
||||
export const ANTIGRAVITY_CREDIT_PROBE_API_CLIENT = "google-genai-sdk/1.30.0 gl-node/v22.21.1";
|
||||
const LOAD_CODE_ASSIST_METADATA = Object.freeze({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
@@ -38,32 +36,6 @@ function withOptionalBearerAuth(
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getPlatform(): string {
|
||||
const p = typeof process !== "undefined" ? process.platform : "unknown";
|
||||
switch (p) {
|
||||
case "win32":
|
||||
return "windows";
|
||||
case "darwin":
|
||||
return "macos";
|
||||
default:
|
||||
return p; // "linux", etc.
|
||||
}
|
||||
}
|
||||
|
||||
function getArch(): string {
|
||||
const a = typeof process !== "undefined" ? process.arch : "unknown";
|
||||
switch (a) {
|
||||
case "x64":
|
||||
return "x64";
|
||||
case "ia32":
|
||||
return "x86";
|
||||
case "arm64":
|
||||
return "arm64";
|
||||
default:
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Antigravity User-Agent: "antigravity/VERSION darwin/arm64"
|
||||
*
|
||||
@@ -118,20 +90,9 @@ export function getAntigravityHeaders(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini CLI User-Agent: "GeminiCLI/VERSION/MODEL (OS; ARCH)"
|
||||
* Example: "GeminiCLI/1.0.0/gemini-3-flash (macos; arm64)"
|
||||
*/
|
||||
export function geminiCLIUserAgent(model: string): string {
|
||||
return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${getPlatform()}; ${getArch()})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* X-Goog-Api-Client header value matching the real Gemini SDK.
|
||||
* Example: "google-genai-sdk/1.30.0 gl-node/v22.21.1"
|
||||
*/
|
||||
export function googApiClientHeader(): string {
|
||||
return `google-genai-sdk/${GEMINI_SDK_VERSION} gl-node/${NODE_VERSION}`;
|
||||
/** X-Goog-Api-Client used by Antigravity's credit probe path. */
|
||||
export function getAntigravityCreditProbeApiClientHeader(): string {
|
||||
return ANTIGRAVITY_CREDIT_PROBE_API_CLIENT;
|
||||
}
|
||||
|
||||
export { ANTIGRAVITY_VERSION };
|
||||
|
||||
@@ -38,7 +38,10 @@ for (const [k, v] of Object.entries(TOOL_RENAME_MAP)) {
|
||||
REVERSE_MAP[v] = k;
|
||||
}
|
||||
|
||||
export function remapToolNamesInRequest(body: Record<string, unknown>): void {
|
||||
export function remapToolNamesInRequest(body: Record<string, unknown>): boolean {
|
||||
let hasLowercase = false;
|
||||
let hasTitleCase = false;
|
||||
|
||||
// Remap tool definitions
|
||||
const tools = body.tools as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(tools)) {
|
||||
@@ -46,6 +49,9 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): void {
|
||||
const name = String(tool.name || "");
|
||||
if (TOOL_RENAME_MAP[name]) {
|
||||
tool.name = TOOL_RENAME_MAP[name];
|
||||
hasLowercase = true;
|
||||
} else if (REVERSE_MAP[name]) {
|
||||
hasTitleCase = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +65,12 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): void {
|
||||
for (const block of content) {
|
||||
if (block.type === "tool_use" && typeof block.name === "string") {
|
||||
const mapped = TOOL_RENAME_MAP[block.name];
|
||||
if (mapped) block.name = mapped;
|
||||
if (mapped) {
|
||||
block.name = mapped;
|
||||
hasLowercase = true;
|
||||
} else if (REVERSE_MAP[block.name]) {
|
||||
hasTitleCase = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,11 +80,24 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): void {
|
||||
const toolChoice = body.tool_choice as Record<string, unknown> | undefined;
|
||||
if (toolChoice?.type === "tool" && typeof toolChoice.name === "string") {
|
||||
const mapped = TOOL_RENAME_MAP[toolChoice.name];
|
||||
if (mapped) toolChoice.name = mapped;
|
||||
if (mapped) {
|
||||
toolChoice.name = mapped;
|
||||
hasLowercase = true;
|
||||
} else if (REVERSE_MAP[toolChoice.name]) {
|
||||
hasTitleCase = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLowercase && !hasTitleCase) {
|
||||
body._claudeCodeRequiresLowercaseToolNames = true;
|
||||
}
|
||||
|
||||
return hasLowercase && !hasTitleCase;
|
||||
}
|
||||
|
||||
export function remapToolNamesInResponse(text: string): string {
|
||||
export function remapToolNamesInResponse(text: string, forceLowercase = true): string {
|
||||
if (!forceLowercase) return text;
|
||||
|
||||
// Replace TitleCase tool names back to lowercase in SSE chunks
|
||||
for (const [titleCase, lower] of Object.entries(REVERSE_MAP)) {
|
||||
// Match in "name":"ToolName" patterns
|
||||
|
||||
41
open-sse/services/cloudCodeHeaders.ts
Normal file
41
open-sse/services/cloudCodeHeaders.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export function getRuntimePlatform(): string {
|
||||
return typeof process !== "undefined" && typeof process.platform === "string"
|
||||
? process.platform
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
export function getRuntimeArch(): string {
|
||||
return typeof process !== "undefined" && typeof process.arch === "string"
|
||||
? process.arch
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
export function getRuntimeNodeVersion(): string {
|
||||
return typeof process !== "undefined" && process.versions?.node
|
||||
? process.versions.node
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
export function normalizeCloudCodePlatform(platform = getRuntimePlatform()): string {
|
||||
switch (platform) {
|
||||
case "win32":
|
||||
return "windows";
|
||||
case "darwin":
|
||||
return "macos";
|
||||
default:
|
||||
return platform || "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCloudCodeArch(arch = getRuntimeArch()): string {
|
||||
switch (arch) {
|
||||
case "ia32":
|
||||
return "x86";
|
||||
default:
|
||||
return arch || "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function getCloudCodeNodeApiClientHeader(nodeVersion = getRuntimeNodeVersion()): string {
|
||||
return `gl-node/${nodeVersion.replace(/^v/, "")}`;
|
||||
}
|
||||
@@ -1678,7 +1678,7 @@ export async function handleComboChat({
|
||||
|
||||
// Trigger shared provider circuit breaker for 5xx errors and connection failures
|
||||
if (isProviderFailureCode(result.status)) {
|
||||
recordProviderFailure(provider, log);
|
||||
recordProviderFailure(provider, log, target.connectionId);
|
||||
}
|
||||
|
||||
// Check if this is a transient error worth retrying on same model
|
||||
@@ -1841,8 +1841,11 @@ async function handleRoundRobinCombo({
|
||||
timeoutMs: queueTimeout,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === "SEMAPHORE_TIMEOUT") {
|
||||
log.warn("COMBO-RR", `Semaphore timeout for ${modelStr}, trying next model`);
|
||||
if (err.code === "SEMAPHORE_TIMEOUT" || err.code === "SEMAPHORE_QUEUE_FULL") {
|
||||
log.warn(
|
||||
"COMBO-RR",
|
||||
`Semaphore ${err.code === "SEMAPHORE_QUEUE_FULL" ? "queue full" : "timeout"} for ${modelStr}, trying next model`
|
||||
);
|
||||
if (offset > 0) fallbackCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,51 @@ interface LiteCompressionOptions {
|
||||
supportsVision?: boolean | null;
|
||||
}
|
||||
|
||||
function trimTrailingHorizontalWhitespace(line: string): string {
|
||||
let end = line.length;
|
||||
while (end > 0) {
|
||||
const code = line.charCodeAt(end - 1);
|
||||
if (code !== 32 && code !== 9) break;
|
||||
end--;
|
||||
}
|
||||
return end === line.length ? line : line.slice(0, end);
|
||||
}
|
||||
|
||||
function collapseNewlineRuns(content: string): string {
|
||||
let normalized = "";
|
||||
let newlineRun = 0;
|
||||
|
||||
for (const char of content) {
|
||||
if (char === "\n") {
|
||||
newlineRun++;
|
||||
if (newlineRun <= 2) {
|
||||
normalized += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
newlineRun = 0;
|
||||
normalized += char;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeMessageWhitespace(content: string): string {
|
||||
return collapseNewlineRuns(content).split("\n").map(trimTrailingHorizontalWhitespace).join("\n");
|
||||
}
|
||||
|
||||
function modelSupportsVision(model: string): boolean {
|
||||
const normalized = model.toLowerCase();
|
||||
return (
|
||||
normalized.includes("vision") ||
|
||||
normalized.includes("gpt-4") ||
|
||||
normalized.includes("4o") ||
|
||||
normalized.includes("claude-3") ||
|
||||
normalized.includes("gemini")
|
||||
);
|
||||
}
|
||||
|
||||
export function collapseWhitespace(body: ChatBody): {
|
||||
body: ChatBody;
|
||||
applied: boolean;
|
||||
@@ -25,7 +70,7 @@ export function collapseWhitespace(body: ChatBody): {
|
||||
let applied = false;
|
||||
const messages = body.messages.map((msg) => {
|
||||
if (typeof msg.content !== "string") return msg;
|
||||
const normalized = msg.content.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+$/gm, "");
|
||||
const normalized = normalizeMessageWhitespace(msg.content);
|
||||
if (normalized !== msg.content) applied = true;
|
||||
return { ...msg, content: normalized };
|
||||
});
|
||||
@@ -101,7 +146,11 @@ export function replaceImageUrls(
|
||||
): { body: ChatBody; applied: boolean } {
|
||||
if (!body.messages) return { body, applied: false };
|
||||
const supportsVision =
|
||||
typeof options === "object" && options !== null ? options.supportsVision : undefined;
|
||||
typeof options === "object" && options !== null
|
||||
? options.supportsVision
|
||||
: typeof options === "string"
|
||||
? modelSupportsVision(options)
|
||||
: undefined;
|
||||
if (supportsVision !== false) return { body, applied: false };
|
||||
|
||||
let applied = false;
|
||||
|
||||
@@ -15,8 +15,8 @@ export function extractPreservedBlocks(text: string): { text: string; blocks: Pr
|
||||
return placeholder;
|
||||
};
|
||||
|
||||
// Extract fenced code blocks (```lang\n...\n```)
|
||||
result = result.replace(/```[a-z]*\n[\s\S]*?\n```/g, (match) => addBlock(match));
|
||||
// Extract fenced code blocks with a linear scan to avoid ReDoS-prone patterns.
|
||||
result = extractFencedCodeBlocks(result, addBlock);
|
||||
|
||||
// Extract inline code (`...`)
|
||||
result = result.replace(/`[^`\n]+`/g, (match) => addBlock(match));
|
||||
@@ -40,6 +40,38 @@ export function extractPreservedBlocks(text: string): { text: string; blocks: Pr
|
||||
return { text: result, blocks };
|
||||
}
|
||||
|
||||
function extractFencedCodeBlocks(text: string, addBlock: (content: string) => string): string {
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const start = text.indexOf("```", cursor);
|
||||
if (start === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const openingLineEnd = text.indexOf("\n", start + 3);
|
||||
if (openingLineEnd === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const closeStart = text.indexOf("\n```", openingLineEnd + 1);
|
||||
if (closeStart === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const closeEnd = closeStart + 4;
|
||||
output += text.slice(cursor, start);
|
||||
output += addBlock(text.slice(start, closeEnd));
|
||||
cursor = closeEnd;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function restorePreservedBlocks(text: string, blocks: PreservedBlock[]): string {
|
||||
let result = text;
|
||||
for (const block of blocks) {
|
||||
|
||||
@@ -3,9 +3,86 @@ import type { Summarizer, SummarizerOpts } from "./types.ts";
|
||||
const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/;
|
||||
const INTENT_TRIGGERS =
|
||||
/^(?:request|fix|implement|add|remove|update|refactor|create|delete|change|build)\s*:/i;
|
||||
const FILE_PATH_RE =
|
||||
/[\w./-]+\.(?:ts|tsx|js|jsx|py|md|json|sql|css|html|yaml|yml|sh|rb|go|rs|java|c|cpp|h|hpp)/g;
|
||||
const ERROR_RE = /(?:Error|error|ERROR):\s*\S+|error\s+TS\d+|Exception:\s*\S+/g;
|
||||
const FILE_EXTENSIONS = new Set([
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"py",
|
||||
"md",
|
||||
"json",
|
||||
"sql",
|
||||
"css",
|
||||
"html",
|
||||
"yaml",
|
||||
"yml",
|
||||
"sh",
|
||||
"rb",
|
||||
"go",
|
||||
"rs",
|
||||
"java",
|
||||
"c",
|
||||
"cpp",
|
||||
"h",
|
||||
"hpp",
|
||||
]);
|
||||
|
||||
function stripTokenPunctuation(token: string): string {
|
||||
let start = 0;
|
||||
let end = token.length;
|
||||
const leading = new Set(["'", '"', "`", "(", "[", "{"]);
|
||||
const trailing = new Set(["'", '"', "`", ")", "]", "}", ",", ";", ":"]);
|
||||
|
||||
while (start < end && leading.has(token[start])) start++;
|
||||
while (end > start && trailing.has(token[end - 1])) end--;
|
||||
|
||||
return token.slice(start, end);
|
||||
}
|
||||
|
||||
function getKnownFilePathToken(token: string): string | null {
|
||||
const clean = stripTokenPunctuation(token);
|
||||
const lastDot = clean.lastIndexOf(".");
|
||||
if (lastDot <= 0 || lastDot === clean.length - 1) return null;
|
||||
|
||||
const extension = clean.slice(lastDot + 1).toLowerCase();
|
||||
if (!FILE_EXTENSIONS.has(extension)) return null;
|
||||
if (!clean.includes("/") && !clean.includes(".")) return null;
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
function extractFilePathTokens(text: string): string[] {
|
||||
const paths: string[] = [];
|
||||
for (const token of text.split(/\s+/)) {
|
||||
const filePath = getKnownFilePathToken(token);
|
||||
if (filePath) paths.push(filePath);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function extractErrorSnippets(text: string): string[] {
|
||||
const snippets: string[] = [];
|
||||
for (const segment of text.split(/[.\n]/)) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (
|
||||
trimmed.includes("TypeError:") ||
|
||||
trimmed.includes("ReferenceError:") ||
|
||||
trimmed.includes("SyntaxError:") ||
|
||||
trimmed.includes("RangeError:") ||
|
||||
trimmed.includes("URIError:") ||
|
||||
trimmed.includes("EvalError:") ||
|
||||
trimmed.includes("Error:") ||
|
||||
trimmed.includes("Exception:") ||
|
||||
lower.includes("error ts")
|
||||
) {
|
||||
snippets.push(trimmed);
|
||||
}
|
||||
}
|
||||
return snippets;
|
||||
}
|
||||
|
||||
function extractIntents(messages: Array<{ role: string; content?: string | unknown[] }>): string[] {
|
||||
const intents: string[] = [];
|
||||
@@ -30,9 +107,9 @@ function extractFilePaths(
|
||||
for (const msg of messages) {
|
||||
const text = extractText(msg.content);
|
||||
if (!text) continue;
|
||||
const matches = text.match(FILE_PATH_RE);
|
||||
if (matches) {
|
||||
for (const m of matches) paths.add(m);
|
||||
const matches = extractFilePathTokens(text);
|
||||
for (const m of matches) {
|
||||
paths.add(m);
|
||||
}
|
||||
}
|
||||
return [...paths].slice(0, 20);
|
||||
@@ -43,10 +120,7 @@ function extractErrors(messages: Array<{ role: string; content?: string | unknow
|
||||
for (const msg of messages) {
|
||||
const text = extractText(msg.content);
|
||||
if (!text) continue;
|
||||
const matches = text.match(ERROR_RE);
|
||||
if (matches) {
|
||||
for (const m of matches) errors.push(m.slice(0, 150));
|
||||
}
|
||||
for (const match of extractErrorSnippets(text)) errors.push(match.slice(0, 150));
|
||||
}
|
||||
return errors.slice(0, 10);
|
||||
}
|
||||
@@ -64,14 +138,46 @@ function extractLastDecision(
|
||||
}
|
||||
|
||||
function trimCodeFences(text: string): string {
|
||||
const fenceRe = /```[a-z]*\n([\s\S]*?)\n```/g;
|
||||
return text.replace(fenceRe, (_match, code: string) => {
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const start = text.indexOf("```", cursor);
|
||||
if (start === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const openingLineEnd = text.indexOf("\n", start + 3);
|
||||
if (openingLineEnd === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const closeStart = text.indexOf("\n```", openingLineEnd + 1);
|
||||
if (closeStart === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const closeEnd = closeStart + 4;
|
||||
const opening = text.slice(start, openingLineEnd);
|
||||
const code = text.slice(openingLineEnd + 1, closeStart);
|
||||
const lines = code.split("\n");
|
||||
if (lines.length <= 4) return _match;
|
||||
const head = lines.slice(0, 3).join("\n");
|
||||
const tail = lines[lines.length - 1];
|
||||
return "```" + head + "\n…\n" + tail + "\n```";
|
||||
});
|
||||
|
||||
output += text.slice(cursor, start);
|
||||
if (lines.length <= 4) {
|
||||
output += text.slice(start, closeEnd);
|
||||
} else {
|
||||
const head = lines.slice(0, 3).join("\n");
|
||||
const tail = lines[lines.length - 1];
|
||||
output += `${opening}\n${head}\n…\n${tail}\n\`\`\``;
|
||||
}
|
||||
|
||||
cursor = closeEnd;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function extractText(content?: string | unknown[]): string {
|
||||
|
||||
@@ -6,18 +6,64 @@ export interface CompressionResult {
|
||||
saved: number;
|
||||
}
|
||||
|
||||
const CODE_INDICATORS =
|
||||
/(?:^|\n)\s*(?:import\s|export\s|function\s|class\s|const\s|let\s|var\s|return\s|if\s*\(|for\s*\(|while\s*\()/;
|
||||
const GREP_LINE_RE = /^[\w./-]+:\d+:/m;
|
||||
const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
||||
const SHELL_PROMPT_RE = /\$\s/;
|
||||
const JSON_PREFIX_RE = /^\s*[{[]/;
|
||||
const ERROR_RE = /(?:Error|Exception|Traceback)[:\s]/i;
|
||||
|
||||
function isCodeLikeLine(rawLine: string): boolean {
|
||||
const line = rawLine.trimStart();
|
||||
return (
|
||||
line.startsWith("import ") ||
|
||||
line.startsWith("export ") ||
|
||||
line.startsWith("function ") ||
|
||||
line.startsWith("class ") ||
|
||||
line.startsWith("const ") ||
|
||||
line.startsWith("let ") ||
|
||||
line.startsWith("var ") ||
|
||||
line.startsWith("return ") ||
|
||||
line.startsWith("if(") ||
|
||||
line.startsWith("if (") ||
|
||||
line.startsWith("for(") ||
|
||||
line.startsWith("for (") ||
|
||||
line.startsWith("while(") ||
|
||||
line.startsWith("while (")
|
||||
);
|
||||
}
|
||||
|
||||
function parseGrepLinePath(line: string): string | null {
|
||||
const firstColon = line.indexOf(":");
|
||||
if (firstColon <= 0) return null;
|
||||
|
||||
const secondColon = line.indexOf(":", firstColon + 1);
|
||||
if (secondColon === -1) return null;
|
||||
|
||||
const lineNumber = line.slice(firstColon + 1, secondColon);
|
||||
if (!lineNumber || ![...lineNumber].every((char) => char >= "0" && char <= "9")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = line.slice(0, firstColon);
|
||||
if (!filePath || /\s/.test(filePath)) return null;
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function hasErrorLikeOutput(content: string): boolean {
|
||||
const lower = content.toLowerCase();
|
||||
return (
|
||||
lower.includes("error:") ||
|
||||
lower.includes("error ") ||
|
||||
lower.includes("[error]") ||
|
||||
lower.includes("exception:") ||
|
||||
lower.includes("exception ") ||
|
||||
lower.includes("[exception]") ||
|
||||
lower.includes("traceback")
|
||||
);
|
||||
}
|
||||
|
||||
function compressFileContent(content: string): string | null {
|
||||
const lines = content.split("\n");
|
||||
if (lines.length < 3) return null;
|
||||
if (!CODE_INDICATORS.test(content)) return null;
|
||||
if (!lines.some(isCodeLikeLine)) return null;
|
||||
const keep = 20;
|
||||
const tail = 5;
|
||||
if (lines.length <= keep + tail) return content;
|
||||
@@ -29,12 +75,12 @@ function compressFileContent(content: string): string | null {
|
||||
|
||||
function compressGrepSearch(content: string): string | null {
|
||||
const lines = content.split("\n");
|
||||
const grepLines = lines.filter((l) => GREP_LINE_RE.test(l));
|
||||
const grepLines = lines.filter((line) => parseGrepLinePath(line) !== null);
|
||||
if (grepLines.length === 0) return null;
|
||||
const paths = new Set<string>();
|
||||
for (const line of grepLines) {
|
||||
const match = line.match(/^([\w./-]+):\d+:/);
|
||||
if (match) paths.add(match[1]);
|
||||
const filePath = parseGrepLinePath(line);
|
||||
if (filePath) paths.add(filePath);
|
||||
}
|
||||
const top30 = grepLines.slice(0, 30);
|
||||
const remaining = grepLines.length - top30.length;
|
||||
@@ -99,7 +145,7 @@ function compressJson(content: string): string | null {
|
||||
}
|
||||
|
||||
function compressErrorMessage(content: string): string | null {
|
||||
if (!ERROR_RE.test(content)) return null;
|
||||
if (!hasErrorLikeOutput(content)) return null;
|
||||
const lines = content.split("\n");
|
||||
const errorLine = lines[0] || "";
|
||||
const stackLines = lines.slice(1);
|
||||
|
||||
31
open-sse/services/geminiCliHeaders.ts
Normal file
31
open-sse/services/geminiCliHeaders.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
getCloudCodeNodeApiClientHeader,
|
||||
normalizeCloudCodeArch,
|
||||
normalizeCloudCodePlatform,
|
||||
} from "./cloudCodeHeaders.ts";
|
||||
|
||||
export const GEMINI_CLI_VERSION = "0.40.1";
|
||||
export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "9.15.1";
|
||||
|
||||
export function geminiCliUserAgent(model: string): string {
|
||||
const normalizedModel = model || "unknown";
|
||||
return `GeminiCLI/${GEMINI_CLI_VERSION}/${normalizedModel} (${normalizeCloudCodePlatform()}; ${normalizeCloudCodeArch()}; terminal) google-api-nodejs-client/${GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION}`;
|
||||
}
|
||||
|
||||
export function geminiCliApiClientHeader(): string {
|
||||
return getCloudCodeNodeApiClientHeader();
|
||||
}
|
||||
|
||||
export function getGeminiCliHeaders(
|
||||
model: string,
|
||||
accessToken: string,
|
||||
accept: "application/json" | "*/*"
|
||||
): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"User-Agent": geminiCliUserAgent(model),
|
||||
"X-Goog-Api-Client": geminiCliApiClientHeader(),
|
||||
Accept: accept,
|
||||
};
|
||||
}
|
||||
@@ -332,6 +332,11 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
|
||||
if (token) {
|
||||
headers["x-api-key"] = token;
|
||||
}
|
||||
} else if (authHeader === "key") {
|
||||
const token = credentials.apiKey || credentials.accessToken;
|
||||
if (token) {
|
||||
headers["Authorization"] = `Key ${token}`;
|
||||
}
|
||||
} else if (authHeader === "x-goog-api-key") {
|
||||
if (credentials.apiKey) {
|
||||
headers["x-goog-api-key"] = credentials.apiKey;
|
||||
|
||||
@@ -26,7 +26,7 @@ export type QuotaFetcher = (
|
||||
connection?: Record<string, unknown>
|
||||
) => Promise<QuotaInfo | null>;
|
||||
|
||||
const EXHAUSTION_THRESHOLD = 0.95;
|
||||
const EXHAUSTION_THRESHOLD = 0.98;
|
||||
const WARN_THRESHOLD = 0.8;
|
||||
|
||||
const quotaFetcherRegistry = new Map<string, QuotaFetcher>();
|
||||
|
||||
@@ -28,6 +28,38 @@ function toRecord(value: unknown): JsonRecord | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
|
||||
}
|
||||
|
||||
function isResponsesMessageItem(record: JsonRecord): boolean {
|
||||
return record.type === "message" || (!record.type && typeof record.role === "string");
|
||||
}
|
||||
|
||||
function isInternalAssistantMessage(record: JsonRecord): boolean {
|
||||
if (!isResponsesMessageItem(record)) return false;
|
||||
if (record.role !== "assistant") return false;
|
||||
|
||||
const phase = typeof record.phase === "string" ? record.phase.trim().toLowerCase() : "";
|
||||
if (!phase) return false;
|
||||
|
||||
// OpenCode can send assistant-side commentary/analysis frames in Responses
|
||||
// shape. Those frames are local runtime state, not durable conversation turns
|
||||
// for Codex replay. Re-injecting them makes the model continue hidden notes.
|
||||
return phase !== "final";
|
||||
}
|
||||
|
||||
function sanitizeRememberedConversationItems(items: readonly unknown[]): unknown[] {
|
||||
const sanitized: unknown[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const record = toRecord(item);
|
||||
if (record && isInternalAssistantMessage(record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized.push(structuredClone(item));
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function cleanupRememberedResponseToolCalls(now: number = Date.now()) {
|
||||
for (const [responseId, entry] of rememberedResponseToolCalls.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
@@ -122,7 +154,7 @@ export function rememberResponseFunctionCalls(
|
||||
|
||||
rememberedResponseToolCalls.set(normalizedResponseId, {
|
||||
functionCalls,
|
||||
conversationItems: existingEntry?.conversationItems?.map((item) => structuredClone(item)) || [],
|
||||
conversationItems: sanitizeRememberedConversationItems(existingEntry?.conversationItems || []),
|
||||
updatedAt: now,
|
||||
expiresAt: now + RESPONSE_TOOL_CALL_TTL_MS,
|
||||
});
|
||||
@@ -140,7 +172,10 @@ export function rememberResponseConversationState(
|
||||
|
||||
const normalizedRequestInput = Array.isArray(requestInput) ? requestInput : [];
|
||||
const normalizedOutputItems = Array.isArray(outputItems) ? outputItems : [];
|
||||
const conversationItems = [...normalizedRequestInput, ...normalizedOutputItems];
|
||||
const conversationItems = sanitizeRememberedConversationItems([
|
||||
...normalizedRequestInput,
|
||||
...normalizedOutputItems,
|
||||
]);
|
||||
if (conversationItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -150,7 +185,7 @@ export function rememberResponseConversationState(
|
||||
const existingEntry = rememberedResponseToolCalls.get(normalizedResponseId);
|
||||
rememberedResponseToolCalls.set(normalizedResponseId, {
|
||||
functionCalls: existingEntry?.functionCalls?.map((functionCall) => ({ ...functionCall })) || [],
|
||||
conversationItems: conversationItems.map((item) => structuredClone(item)),
|
||||
conversationItems,
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + RESPONSE_TOOL_CALL_TTL_MS,
|
||||
});
|
||||
@@ -185,7 +220,7 @@ export function getRememberedResponseConversationItems(responseId: unknown): unk
|
||||
return [];
|
||||
}
|
||||
|
||||
return entry.conversationItems.map((item) => structuredClone(item));
|
||||
return sanitizeRememberedConversationItems(entry.conversationItems);
|
||||
}
|
||||
|
||||
export function getRememberedFunctionCallsByIds(
|
||||
|
||||
@@ -841,6 +841,51 @@ export async function getAccessToken(provider, credentials, log, proxyConfig: un
|
||||
return null;
|
||||
}
|
||||
|
||||
// RACE CONDITION PREVENTION:
|
||||
// If the credentials object in memory is stale (e.g. it waited in a semaphore while another
|
||||
// request refreshed the token), using its OLD refreshToken will cause the provider (e.g. OpenAI)
|
||||
// to reject it with 'refresh_token_reused' and revoke the new token family.
|
||||
// We MUST check if the DB has a newer token before proceeding with a network refresh.
|
||||
if (credentials.connectionId) {
|
||||
try {
|
||||
const { getProviderConnectionById } = await import("../../src/lib/db/providers");
|
||||
const dbConnection = await getProviderConnectionById(credentials.connectionId);
|
||||
if (
|
||||
dbConnection &&
|
||||
dbConnection.refreshToken &&
|
||||
dbConnection.refreshToken !== credentials.refreshToken
|
||||
) {
|
||||
log?.info?.(
|
||||
"TOKEN_REFRESH",
|
||||
`Stale token detected in memory for ${provider}. Using refreshed token from DB.`
|
||||
);
|
||||
|
||||
// If the DB token is not expired, we can just return it!
|
||||
const now = Date.now();
|
||||
const dbExpiresAt = dbConnection.expiresAt ? new Date(dbConnection.expiresAt).getTime() : 0;
|
||||
|
||||
if (dbExpiresAt > now + 60000) {
|
||||
// 60 seconds buffer
|
||||
log?.info?.("TOKEN_REFRESH", `DB token is still valid. Skipping OAuth refresh.`);
|
||||
return {
|
||||
accessToken: dbConnection.accessToken,
|
||||
refreshToken: dbConnection.refreshToken,
|
||||
expiresIn: dbConnection.expiresIn,
|
||||
};
|
||||
} else {
|
||||
// DB token is also expired, but it's the NEWEST one. We must use it to refresh.
|
||||
credentials.refreshToken = dbConnection.refreshToken;
|
||||
credentials.accessToken = dbConnection.accessToken;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log?.warn?.(
|
||||
"TOKEN_REFRESH",
|
||||
`Failed to check DB for stale token: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
|
||||
|
||||
// If a refresh is already in-flight, reuse it
|
||||
|
||||
@@ -19,7 +19,7 @@ import { safePercentage } from "@/shared/utils/formatting";
|
||||
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
|
||||
import {
|
||||
antigravityUserAgent,
|
||||
googApiClientHeader,
|
||||
getAntigravityCreditProbeApiClientHeader,
|
||||
getAntigravityHeaders,
|
||||
getAntigravityLoadCodeAssistMetadata,
|
||||
} from "./antigravityHeaders.ts";
|
||||
@@ -72,6 +72,10 @@ const CURSOR_USAGE_CONFIG = {
|
||||
clientVersion: CURSOR_REGISTRY_VERSION,
|
||||
};
|
||||
|
||||
const NANOGPT_CONFIG = {
|
||||
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
|
||||
};
|
||||
|
||||
const MINIMAX_USAGE_CONFIG = {
|
||||
minimax: {
|
||||
usageUrls: [
|
||||
@@ -595,6 +599,86 @@ async function getBailianCodingPlanUsage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NanoGPT Usage
|
||||
* Fetches subscription-level quota from the NanoGPT API.
|
||||
* Returns daily/weekly token limits and daily image limits for PRO accounts.
|
||||
*/
|
||||
async function getNanoGptUsage(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
return { message: "NanoGPT API key not available. Add a key to view usage." };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(NANOGPT_CONFIG.usageUrl, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) return { message: "Invalid NanoGPT API key." };
|
||||
return { message: `NanoGPT quota API error (${res.status})` };
|
||||
}
|
||||
|
||||
const data = toRecord(await res.json());
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
// active -> PRO, otherwise FREE
|
||||
const plan = data.active ? "PRO" : "FREE";
|
||||
|
||||
if (data.active) {
|
||||
// 1. Tokens limit
|
||||
// dailyInputTokens if exists, else weeklyInputTokens
|
||||
let tokenQuota = toRecord(data.dailyInputTokens);
|
||||
let tokenLabel = "Daily Tokens";
|
||||
if (!tokenQuota.resetAt) {
|
||||
const weeklyQuota = toRecord(data.weeklyInputTokens);
|
||||
if (weeklyQuota.remaining !== undefined) {
|
||||
tokenQuota = weeklyQuota;
|
||||
tokenLabel = "Weekly Tokens";
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenQuota.remaining !== undefined) {
|
||||
const used = toNumber(tokenQuota.used, 0);
|
||||
const remaining = toNumber(tokenQuota.remaining, 0);
|
||||
const total = used + remaining;
|
||||
quotas[tokenLabel] = {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage: clampPercentage(100 - toNumber(tokenQuota.percentUsed, 0) * 100),
|
||||
resetAt: parseResetTime(tokenQuota.resetAt),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Images limit
|
||||
const imageQuota = toRecord(data.dailyImages);
|
||||
if (imageQuota.remaining !== undefined) {
|
||||
const used = toNumber(imageQuota.used, 0);
|
||||
const remaining = toNumber(imageQuota.remaining, 0);
|
||||
const total = used + remaining;
|
||||
quotas["Daily Images"] = {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage: clampPercentage(100 - toNumber(imageQuota.percentUsed, 0) * 100),
|
||||
resetAt: parseResetTime(imageQuota.resetAt),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length === 0) {
|
||||
return { plan, message: "NanoGPT connected, but no active limits found." };
|
||||
}
|
||||
}
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: `NanoGPT connected. Unable to fetch usage: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage data for a provider connection
|
||||
* @param {Object} connection - Provider connection with accessToken
|
||||
@@ -635,6 +719,8 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
|
||||
return await getCursorUsage(accessToken);
|
||||
case "bailian-coding-plan":
|
||||
return await getBailianCodingPlanUsage(id, apiKey, providerSpecificData);
|
||||
case "nanogpt":
|
||||
return await getNanoGptUsage(apiKey);
|
||||
default:
|
||||
return { message: `Usage API not implemented for ${provider}` };
|
||||
}
|
||||
@@ -652,7 +738,7 @@ function parseResetTime(resetValue) {
|
||||
if (resetValue instanceof Date) {
|
||||
date = resetValue;
|
||||
} else if (typeof resetValue === "number") {
|
||||
date = new Date(resetValue);
|
||||
date = new Date(resetValue < 1e12 ? resetValue * 1000 : resetValue);
|
||||
} else if (typeof resetValue === "string") {
|
||||
date = new Date(resetValue);
|
||||
} else {
|
||||
@@ -1427,7 +1513,7 @@ async function probeAntigravityCreditBalanceUncached(
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"User-Agent": antigravityUserAgent(),
|
||||
"X-Goog-Api-Client": googApiClientHeader(),
|
||||
"X-Goog-Api-Client": getAntigravityCreditProbeApiClientHeader(),
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
|
||||
|
||||
@@ -223,13 +223,13 @@ export function createResponsesApiTransformStream(logger = null) {
|
||||
if (callId && !state.funcItemDone[idx]) {
|
||||
let args = state.funcArgsBuf[idx] || "{}";
|
||||
|
||||
// Fix #1674: Final cleanup of empty string placeholders that might have been split across delta chunks
|
||||
// Fix #1674 & #1852: Final cleanup of empty string and empty array placeholders
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
let modified = false;
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (v === "") {
|
||||
if (v === "" || (Array.isArray(v) && v.length === 0)) {
|
||||
delete parsed[k];
|
||||
modified = true;
|
||||
}
|
||||
@@ -506,12 +506,13 @@ export function createResponsesApiTransformStream(logger = null) {
|
||||
const refCallId = state.funcCallIds[tcIdx] || newCallId;
|
||||
let deltaStr = tc.function.arguments;
|
||||
|
||||
// Fix #1674: cx/gpt-5.5 injects empty strings for optional parameters.
|
||||
// We strip these directly from the streaming deltas to avoid breaking strict clients like Claude Code.
|
||||
if (deltaStr.includes('""')) {
|
||||
// Fix #1674 & #1852: Strip empty strings and empty arrays from streaming deltas
|
||||
if (deltaStr.includes('""') || deltaStr.includes("[]") || deltaStr.includes("[ ]")) {
|
||||
deltaStr = deltaStr
|
||||
.replace(/,"[a-zA-Z0-9_]+":""/g, "")
|
||||
.replace(/"[a-zA-Z0-9_]+":"",/g, "");
|
||||
.replace(/"[a-zA-Z0-9_]+":"",/g, "")
|
||||
.replace(/,"[a-zA-Z0-9_]+":\s*\[\s*\]/g, "")
|
||||
.replace(/"[a-zA-Z0-9_]+":\s*\[\s*\],?/g, "");
|
||||
}
|
||||
|
||||
if (refCallId) {
|
||||
|
||||
@@ -73,11 +73,13 @@ type GeminiRequest = {
|
||||
type CloudCodeEnvelope = {
|
||||
project: string;
|
||||
model: string;
|
||||
userAgent: string;
|
||||
requestId: string;
|
||||
user_prompt_id?: string;
|
||||
userAgent?: string;
|
||||
requestId?: string;
|
||||
requestType?: string;
|
||||
request: {
|
||||
sessionId: string;
|
||||
session_id?: string;
|
||||
sessionId?: string;
|
||||
contents: GeminiContent[];
|
||||
systemInstruction?: GeminiContent;
|
||||
generationConfig: GeminiGenerationConfig;
|
||||
@@ -393,27 +395,38 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
|
||||
|
||||
const cleanModel = model.includes("/") ? model.split("/").pop()! : model;
|
||||
|
||||
const envelope: CloudCodeEnvelope = {
|
||||
project: projectId,
|
||||
model: cleanModel,
|
||||
userAgent: isAntigravity ? "antigravity" : "gemini-cli",
|
||||
requestId: isAntigravity ? `agent-${generateUUID()}` : generateRequestId(),
|
||||
request: {
|
||||
sessionId: generateSessionId(),
|
||||
contents: geminiCLI.contents,
|
||||
systemInstruction: geminiCLI.systemInstruction,
|
||||
generationConfig: geminiCLI.generationConfig,
|
||||
tools: geminiCLI.tools,
|
||||
},
|
||||
};
|
||||
const envelope: CloudCodeEnvelope = isAntigravity
|
||||
? {
|
||||
project: projectId,
|
||||
model: cleanModel,
|
||||
userAgent: "antigravity",
|
||||
requestType: "agent",
|
||||
requestId: `agent-${generateUUID()}`,
|
||||
request: {
|
||||
sessionId: generateSessionId(),
|
||||
contents: geminiCLI.contents,
|
||||
systemInstruction: geminiCLI.systemInstruction,
|
||||
generationConfig: geminiCLI.generationConfig,
|
||||
tools: geminiCLI.tools,
|
||||
},
|
||||
}
|
||||
: {
|
||||
model: cleanModel,
|
||||
project: projectId,
|
||||
user_prompt_id: generateRequestId(),
|
||||
request: {
|
||||
contents: geminiCLI.contents,
|
||||
systemInstruction: geminiCLI.systemInstruction,
|
||||
generationConfig: geminiCLI.generationConfig,
|
||||
tools: geminiCLI.tools,
|
||||
},
|
||||
};
|
||||
if (geminiCLI._toolNameMap instanceof Map && geminiCLI._toolNameMap.size > 0) {
|
||||
envelope._toolNameMap = geminiCLI._toolNameMap;
|
||||
}
|
||||
|
||||
// Antigravity specific fields
|
||||
if (isAntigravity) {
|
||||
envelope.requestType = "agent";
|
||||
|
||||
// Inject required default system prompt for Antigravity
|
||||
const defaultPart: GeminiPart = { text: ANTIGRAVITY_DEFAULT_SYSTEM };
|
||||
if (envelope.request.systemInstruction?.parts) {
|
||||
@@ -429,7 +442,8 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Keep safetySettings for Gemini CLI
|
||||
// Gemini CLI's native Cloud Code envelope uses snake_case identifiers.
|
||||
envelope.request.session_id = generateSessionId();
|
||||
envelope.request.safetySettings = geminiCLI.safetySettings;
|
||||
}
|
||||
|
||||
|
||||
@@ -633,10 +633,10 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
|
||||
let argsToEmit = item.arguments;
|
||||
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
|
||||
// Fix #1674: Strip empty string placeholders emitted by GPT-5.5 for optional fields
|
||||
// Fix #1674 & #1852: Strip empty string and array placeholders emitted by GPT-5.5 for optional fields
|
||||
const cleaned = { ...argsToEmit };
|
||||
for (const [k, v] of Object.entries(cleaned)) {
|
||||
if (v === "") delete cleaned[k];
|
||||
if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k];
|
||||
}
|
||||
argsToEmit = cleaned;
|
||||
}
|
||||
@@ -685,7 +685,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
|
||||
const cleaned = { ...argsToEmit };
|
||||
for (const [k, v] of Object.entries(cleaned)) {
|
||||
if (v === "") delete cleaned[k];
|
||||
if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k];
|
||||
}
|
||||
argsToEmit = cleaned;
|
||||
}
|
||||
|
||||
@@ -184,10 +184,21 @@ export function openaiToClaudeResponse(chunk, state) {
|
||||
if (tc.function?.arguments) {
|
||||
const toolInfo = state.toolCalls.get(idx);
|
||||
if (toolInfo) {
|
||||
let deltaStr = tc.function.arguments;
|
||||
|
||||
// Fix #1852: Strip empty string and array placeholders from streaming tool arguments
|
||||
if (deltaStr.includes('""') || deltaStr.includes("[]") || deltaStr.includes("[ ]")) {
|
||||
deltaStr = deltaStr
|
||||
.replace(/,"[a-zA-Z0-9_]+":""/g, "")
|
||||
.replace(/"[a-zA-Z0-9_]+":"",/g, "")
|
||||
.replace(/,"[a-zA-Z0-9_]+":\s*\[\s*\]/g, "")
|
||||
.replace(/"[a-zA-Z0-9_]+":\s*\[\s*\],?/g, "");
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: "content_block_delta",
|
||||
index: toolInfo.blockIndex,
|
||||
delta: { type: "input_json_delta", partial_json: tc.function.arguments },
|
||||
delta: { type: "input_json_delta", partial_json: deltaStr },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,9 +982,16 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
if (extracted) {
|
||||
usage = extracted;
|
||||
}
|
||||
// Track content length and accumulate for call log
|
||||
if (parsed.delta && typeof parsed.delta === "string") {
|
||||
// Keep generic Responses deltas for fallback usage estimates,
|
||||
// but only visible text deltas may become assistant content in
|
||||
// logs/replay payloads.
|
||||
if (typeof parsed.delta === "string") {
|
||||
totalContentLength += parsed.delta.length;
|
||||
}
|
||||
if (
|
||||
parsed.type === "response.output_text.delta" &&
|
||||
typeof parsed.delta === "string"
|
||||
) {
|
||||
passthroughAccumulatedContent = appendBoundedText(
|
||||
passthroughAccumulatedContent,
|
||||
parsed.delta
|
||||
|
||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.7.7",
|
||||
"version": "3.7.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.7.7",
|
||||
"version": "3.7.8",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -22,7 +22,6 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
"dompurify": "^3.4.0",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
@@ -33,7 +32,7 @@
|
||||
"lowdb": "^7.0.1",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.3",
|
||||
"next-intl": "^4.8.3",
|
||||
"next-intl": "^4.11.0",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"open": "^11.0.0",
|
||||
"ora": "^9.1.0",
|
||||
@@ -1409,18 +1408,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/icu-messageformat-parser": {
|
||||
"version": "3.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.5.tgz",
|
||||
"integrity": "sha512-ASMon8BNlKHgQQpZx84xI80EXRS90GlsEU4wEulCKCzrMtUdrfEvFc9UEYmRbvEvtFQLZ4qHXnisUy6PuFjwyA==",
|
||||
"version": "3.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.6.tgz",
|
||||
"integrity": "sha512-04ZjRIeQCnR/h32wBP9/S7rkyy1hLAs2fXJcNwc7hseJd//K9TMBqK0ukb4dXqnALKQ9m5ruZeOD2qqEkK9ixg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/icu-skeleton-parser": "2.1.5"
|
||||
"@formatjs/icu-skeleton-parser": "2.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/icu-skeleton-parser": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.5.tgz",
|
||||
"integrity": "sha512-9Kc6tMaAPZKTGevdfcvx5zT3v4BTfamo+djJE29wF6ds1QLhoA09MZNDpWMZaebWzuoOTIXhDvgmqmjSlUOGlw==",
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.6.tgz",
|
||||
"integrity": "sha512-9f1VQ2kaaLHK0WPU1OrAmiNKCKJwyoDmwNzQXbUa6XtFBOgHZ4YZURE8sSedHmMr0kvpB75OtplB0hMYkfdwfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/intl-localematcher": {
|
||||
@@ -6732,9 +6731,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
|
||||
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -8634,9 +8633,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/icu-minify": {
|
||||
"version": "4.9.2",
|
||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.9.2.tgz",
|
||||
"integrity": "sha512-MYt1aKn6Hrag6KO1gns+2XUjguLnL4lUxnHZXCStB2hdml+ooYzMW1VnOkrwKg5aTh5fGjvfZsgJ9DYC21lTmg==",
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.11.0.tgz",
|
||||
"integrity": "sha512-XRvblCwLqWXio5ZLcmDqXvJv7alSACK6UjXuuMOdQWB//d25AQX6xlVlI1FEbc3Q6iPLXXo6HaVLn8LcAFhn1Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -8767,15 +8766,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/intl-messageformat": {
|
||||
"version": "11.2.2",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.2.tgz",
|
||||
"integrity": "sha512-yUfyIkPGqMvvk2onw2xBJeLsjXdiYUYebR8mmZVQYBuZUJsFGVht48Ftm1khgu8EZ0n+izX4rAEj3fLAilkh9g==",
|
||||
"version": "11.2.3",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.3.tgz",
|
||||
"integrity": "sha512-kZthTU+3WLcoWoRg5j6LOkN1TeUBtmkX0OIwSAbcHVIfQAEbGVdmANM8u6GL3eUDOqLwheYoXMUshAh1UdeXlQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "3.1.2",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.5"
|
||||
"@formatjs/fast-memoize": "3.1.3",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/intl-messageformat/node_modules/@formatjs/fast-memoize": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.3.tgz",
|
||||
"integrity": "sha512-Ocd1vPuD68rW6BJDuAOtnnc1GPeVepY5kZXML1psGVFQ+1Q8CfkftT3Tnam+Mxx97Pz08jIEDCotl/GV+Naccg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
@@ -11160,9 +11165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl": {
|
||||
"version": "4.9.2",
|
||||
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.9.2.tgz",
|
||||
"integrity": "sha512-AZoMRsVGLZczB2hisq1OTWmNAYAKwk/jaWH4+9pfl5TCG8kbILZZptZHux9zw7DyN1yzh6X7jmaQvoykHs9Y7Q==",
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.11.0.tgz",
|
||||
"integrity": "sha512-Chp8rgEVUYOX/bCtYy+PXH6lDX3X+GPT9sR9HScHroL283em/4urP9btfdHEMEHJJXdq2W/5wDaDDtWONPdNSA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -11174,11 +11179,11 @@
|
||||
"@formatjs/intl-localematcher": "^0.8.1",
|
||||
"@parcel/watcher": "^2.4.1",
|
||||
"@swc/core": "^1.15.2",
|
||||
"icu-minify": "^4.9.2",
|
||||
"icu-minify": "^4.11.0",
|
||||
"negotiator": "^1.0.0",
|
||||
"next-intl-swc-plugin-extractor": "^4.9.2",
|
||||
"next-intl-swc-plugin-extractor": "^4.11.0",
|
||||
"po-parser": "^2.1.1",
|
||||
"use-intl": "^4.9.2"
|
||||
"use-intl": "^4.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
|
||||
@@ -11191,9 +11196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl-swc-plugin-extractor": {
|
||||
"version": "4.9.2",
|
||||
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.9.2.tgz",
|
||||
"integrity": "sha512-VMlEe18tvYgl+2Fl+/hOKckm0Z0EZooBLt3nFjDMva+gopFwZYhNeLBDrc/R8rulWvwBxz8LN+uKPFSQ0fFJ4A==",
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.11.0.tgz",
|
||||
"integrity": "sha512-WUGBSxGNd8eQ0rAsJHFmRw2H7+SZAXQIY/HAnYM57JaUsj5D2vx4KOz4zFtXlyKDtsw9awHfgWVvBae2/RDF9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next/node_modules/@swc/helpers": {
|
||||
@@ -14346,9 +14351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/use-intl": {
|
||||
"version": "4.9.2",
|
||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.9.2.tgz",
|
||||
"integrity": "sha512-/FCW/P/wJCv6oGTUOWLNvv+i2uowOg7r/1HAZSBfwbcNoBe5tzXgh9uZaoEKaRl9t7MnZqrDNgEJoYd+1ko1oQ==",
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.11.0.tgz",
|
||||
"integrity": "sha512-7ILhTLuo3fnSKhoTGDk5X9591pjtWr6qB4inrlvGkN9OEyKhoiG73GZFoLSs68wz3BsSGtoWa62iWvrYEYU+iA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -14359,7 +14364,7 @@
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "^3.1.0",
|
||||
"@schummar/icu-type-parser": "1.21.5",
|
||||
"icu-minify": "^4.9.2",
|
||||
"icu-minify": "^4.11.0",
|
||||
"intl-messageformat": "^11.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -15201,7 +15206,7 @@
|
||||
},
|
||||
"open-sse": {
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.7.7"
|
||||
"version": "3.7.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.7.7",
|
||||
"version": "3.7.8",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -83,6 +83,8 @@
|
||||
"check:docs-sync": "node scripts/check-docs-sync.mjs",
|
||||
"check:node-runtime": "node --import tsx/esm scripts/check-supported-node-runtime.ts",
|
||||
"check:pack-artifact": "node --import tsx/esm scripts/validate-pack-artifact.ts",
|
||||
"audit:deps": "npm audit --audit-level=moderate && npm run audit:electron",
|
||||
"audit:electron": "npm --prefix electron audit --audit-level=moderate",
|
||||
"typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json",
|
||||
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
|
||||
"env:sync": "node scripts/sync-env.mjs",
|
||||
@@ -116,7 +118,6 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
"dompurify": "^3.4.0",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
@@ -127,7 +128,7 @@
|
||||
"lowdb": "^7.0.1",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.3",
|
||||
"next-intl": "^4.8.3",
|
||||
"next-intl": "^4.11.0",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"open": "^11.0.0",
|
||||
"ora": "^9.1.0",
|
||||
@@ -202,7 +203,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"lodash-es": "^4.18.1",
|
||||
"dompurify": "^3.4.0",
|
||||
"dompurify": "^3.4.2",
|
||||
"path-to-regexp": "^8.4.0",
|
||||
"postcss": "^8.5.10",
|
||||
"hono": "^4.12.14",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* 4. process.env (shell / Docker -e flags, highest priority)
|
||||
*/
|
||||
|
||||
import { randomBytes, createDecipheriv } from "node:crypto";
|
||||
import { randomBytes, createDecipheriv, scryptSync, createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
@@ -246,14 +246,41 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
const iv = Buffer.from(parts[2], "hex");
|
||||
const ct = Buffer.from(parts[3], "hex");
|
||||
const tag = Buffer.from(parts[4], "hex");
|
||||
const key = Buffer.from(merged.STORAGE_ENCRYPTION_KEY, "hex");
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
try {
|
||||
|
||||
// Try decrypting with both key derivation methods matching encryption.ts
|
||||
const tryDecrypt = (derivedKey) => {
|
||||
const decipher = createDecipheriv("aes-256-gcm", derivedKey, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
decipher.update(ct);
|
||||
decipher.final();
|
||||
// Decrypt succeeded — key matches
|
||||
};
|
||||
|
||||
// Dynamic salt (current): scryptSync(secret, sha256(secret).slice(0,16), 32)
|
||||
const dynamicSalt = createHash("sha256")
|
||||
.update(merged.STORAGE_ENCRYPTION_KEY)
|
||||
.digest()
|
||||
.slice(0, 16);
|
||||
const dynamicKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, dynamicSalt, 32);
|
||||
|
||||
// Legacy salt (fallback): scryptSync(secret, "omniroute-field-encryption-v1", 32)
|
||||
const legacySalt = "omniroute-field-encryption-v1";
|
||||
const legacyKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, legacySalt, 32);
|
||||
|
||||
let keyMatched = false;
|
||||
try {
|
||||
tryDecrypt(dynamicKey);
|
||||
keyMatched = true;
|
||||
} catch {
|
||||
// Try legacy key as fallback
|
||||
try {
|
||||
tryDecrypt(legacyKey);
|
||||
keyMatched = true;
|
||||
} catch {
|
||||
// Both failed — key truly doesn't match
|
||||
}
|
||||
}
|
||||
|
||||
if (!keyMatched) {
|
||||
log(
|
||||
"⛔ STORAGE_ENCRYPTION_KEY does not match the key used to encrypt your stored credentials."
|
||||
);
|
||||
|
||||
@@ -59,6 +59,8 @@ async function main() {
|
||||
OMNIROUTE_BASE_URL: baseUrl,
|
||||
}),
|
||||
OMNIROUTE_E2E_BOOTSTRAP_MODE: process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "open",
|
||||
REQUIRE_API_KEY: explicitBaseUrl ? process.env.REQUIRE_API_KEY : "false",
|
||||
ENABLE_CLI_TOOLS: "true",
|
||||
};
|
||||
|
||||
if (!(await isServerReady())) {
|
||||
|
||||
@@ -167,7 +167,11 @@ const testServerEnv = {
|
||||
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "true",
|
||||
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "true",
|
||||
...(bootstrapMode === "open"
|
||||
? {}
|
||||
? {
|
||||
INITIAL_PASSWORD: "",
|
||||
OMNIROUTE_E2E_PASSWORD: "",
|
||||
OMNIROUTE_API_KEY: "",
|
||||
}
|
||||
: {
|
||||
INITIAL_PASSWORD: playwrightPassword,
|
||||
OMNIROUTE_E2E_PASSWORD: playwrightPassword,
|
||||
|
||||
39
scripts/scratch/categorize_ideas.py
Normal file
39
scripts/scratch/categorize_ideas.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
categories = {
|
||||
"viable": [1718, 1731, 1764],
|
||||
"need_details": [1765, 1679, 1594, 1584],
|
||||
"defer": [1845, 1833, 1814, 1786, 1737, 1736, 1735, 1716, 1591, 1590, 1589, 1588, 1587],
|
||||
"notfit": [1826, 1788, 1529, 1586] # 1586 is already exists, goes to notfit
|
||||
}
|
||||
|
||||
# Create dirs
|
||||
os.makedirs("_ideia/viable/need_details", exist_ok=True)
|
||||
os.makedirs("_ideia/defer", exist_ok=True)
|
||||
os.makedirs("_ideia/notfit", exist_ok=True)
|
||||
|
||||
files = os.listdir("_ideia")
|
||||
|
||||
for f in files:
|
||||
if not f.endswith(".md"): continue
|
||||
num_str = f.split('-')[0]
|
||||
if not num_str.isdigit(): continue
|
||||
num = int(num_str)
|
||||
|
||||
src = os.path.join("_ideia", f)
|
||||
|
||||
if num in categories["viable"]:
|
||||
dst = os.path.join("_ideia/viable", f)
|
||||
elif num in categories["need_details"]:
|
||||
dst = os.path.join("_ideia/viable/need_details", f)
|
||||
elif num in categories["defer"]:
|
||||
dst = os.path.join("_ideia/defer", f)
|
||||
elif num in categories["notfit"]:
|
||||
dst = os.path.join("_ideia/notfit", f)
|
||||
else:
|
||||
continue
|
||||
|
||||
shutil.move(src, dst)
|
||||
print(f"Moved {f} to {dst}")
|
||||
96
scripts/scratch/harvest_issues.py
Normal file
96
scripts/scratch/harvest_issues.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
issues = [1845, 1833, 1826, 1814, 1788, 1786, 1765, 1764, 1737, 1736, 1735, 1731, 1718, 1716, 1679, 1594, 1591, 1590, 1589, 1588, 1587, 1586, 1584, 1529]
|
||||
|
||||
os.makedirs('_ideia', exist_ok=True)
|
||||
|
||||
def slugify(value):
|
||||
value = re.sub(r'\[Feature\]', '', value, flags=re.IGNORECASE)
|
||||
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
|
||||
return re.sub(r'[-\s]+', '-', value)
|
||||
|
||||
for num in issues:
|
||||
print(f"Processing #{num}...")
|
||||
try:
|
||||
res = subprocess.run(
|
||||
['gh', 'issue', 'view', str(num), '--repo', 'diegosouzapw/OmniRoute', '--json', 'number,title,labels,body,comments,createdAt,author,assignees'],
|
||||
capture_output=True, text=True, check=True
|
||||
)
|
||||
data = json.loads(res.stdout)
|
||||
|
||||
slug = slugify(data['title'])
|
||||
filename = f"_ideia/{data['number']}-{slug}.md"
|
||||
|
||||
author = data['author']['login']
|
||||
created_at = data['createdAt']
|
||||
body = data['body']
|
||||
comments = data['comments']
|
||||
|
||||
participants = set([author])
|
||||
comments_str = ""
|
||||
if comments:
|
||||
for c in comments:
|
||||
c_author = c['author']['login']
|
||||
participants.add(c_author)
|
||||
comments_str += f"**@{c_author}** ({c['createdAt']}):\n{c['body']}\n---\n"
|
||||
else:
|
||||
comments_str = "*No comments.*"
|
||||
|
||||
participants_list = "\n".join([f"- @{p}" for p in participants])
|
||||
|
||||
content = f"""# Feature: {data['title']}
|
||||
|
||||
> GitHub Issue: #{data['number']} — opened by @{author} on {created_at}
|
||||
> Status: 📋 Cataloged | Priority: TBD
|
||||
|
||||
## 📝 Original Request
|
||||
|
||||
{body}
|
||||
|
||||
## 💬 Community Discussion
|
||||
|
||||
{comments_str}
|
||||
|
||||
### Participants
|
||||
|
||||
{participants_list}
|
||||
|
||||
### Key Points
|
||||
|
||||
- Needs detailed analysis
|
||||
|
||||
## 🎯 Refined Feature Description
|
||||
|
||||
Feature needs manual refinement and interpretation to fill logical gaps and outline high-level technical scope.
|
||||
|
||||
### What it solves
|
||||
|
||||
- TBD
|
||||
|
||||
### How it should work (high level)
|
||||
|
||||
1. TBD
|
||||
2. TBD
|
||||
|
||||
### Affected areas
|
||||
|
||||
- TBD
|
||||
|
||||
## 📎 Attachments & References
|
||||
|
||||
- Check issue body for references
|
||||
|
||||
## 🔗 Related Ideas
|
||||
|
||||
- None yet
|
||||
"""
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing {num}: {e}")
|
||||
|
||||
print("Done.")
|
||||
91
scripts/scratch/refine_viable.py
Normal file
91
scripts/scratch/refine_viable.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
|
||||
viable_data = {
|
||||
1718: {
|
||||
"title": "expose upstream error details in client-facing error responses",
|
||||
"solves": "Debugging upstream errors is difficult because they are hidden behind generic '[400] Error from provider' wrappers.",
|
||||
"how": "1. Modify `buildErrorBody` to accept `upstream_details`.\n2. In `BaseExecutor` or specific handlers, parse the raw upstream response on failure.\n3. Propagate the parsed body to the client response inside `upstream_details`.",
|
||||
"areas": "open-sse/executors/base.ts, open-sse/utils/errors.ts, src/app/api/v1/chat/completions/route.ts",
|
||||
"req_summary": "Expose the upstream error body (e.g. context_length_exceeded) directly in the error response under an `upstream_details` key, without breaking OpenAI compatibility.",
|
||||
"req_approach": "Modify the central error generation functions (like `buildErrorBody`) to optionally accept an `upstreamDetails` object. Update the request executors to pass the JSON parsed error from the upstream response into this new parameter when a request fails.",
|
||||
"req_files": "| File | Changes |\n|---|---|\n| `open-sse/utils/errors.ts` | Update `buildErrorBody` to include `upstream_details`. |\n| `open-sse/executors/base.ts` | Extract response body on failure and pass to error builder. |",
|
||||
"req_effort": "Low. A few files changed. No breaking changes."
|
||||
},
|
||||
1731: {
|
||||
"title": "(combo): provider-level exhaustion tracking to skip same-provider targets",
|
||||
"solves": "Combo routing wastes significant time retrying multiple targets from the same provider when the entire provider is rate-limited or quota-exhausted.",
|
||||
"how": "1. Track 429 quota exhaustion errors at the provider level.\n2. In `combo.ts`, before attempting a target, check if its provider is currently marked as exhausted.\n3. If exhausted, skip the target and move to the next provider.",
|
||||
"areas": "open-sse/services/combo.ts, open-sse/services/accountFallback.ts",
|
||||
"req_summary": "Implement provider-level 429 exhaustion tracking in the combo router so it skips remaining targets of a provider if a 429 quota exhaustion occurs.",
|
||||
"req_approach": "Add a temporary exclusion set in `handleComboChat` that tracks providers that have returned a hard 429. Before evaluating the next target in the combo, check if its provider is in the exclusion set and skip it if true.",
|
||||
"req_files": "| File | Changes |\n|---|---|\n| `open-sse/services/combo.ts` | Add logic to track provider failures and skip matching targets. |\n| `open-sse/services/accountFallback.ts` | Properly bubble up the 429 status. |",
|
||||
"req_effort": "Medium. Needs careful state tracking across the combo loop. No breaking changes."
|
||||
},
|
||||
1764: {
|
||||
"title": "Make installation script detect termux",
|
||||
"solves": "OmniRoute fails to start or install properly on Termux because `wreq-js` attempts to load a native `libgcc` arm64 module which is incompatible.",
|
||||
"how": "1. Update the `postinstall` script to check `process.env.PREFIX` for termux.\n2. If termux is detected, gracefully skip or patch the wreq-js installation/loading.",
|
||||
"areas": "scripts/postinstall.mjs, open-sse/executors/wreq.ts",
|
||||
"req_summary": "Detect termux environments during installation or runtime and gracefully handle the `wreq-js` native module failure, allowing the rest of OmniRoute to function.",
|
||||
"req_approach": "Modify `scripts/postinstall.mjs` or the wreq-js loader logic. If `process.env.PREFIX && process.env.PREFIX.includes('termux')` is true, avoid hard crashing on wreq-js load failures.",
|
||||
"req_files": "| File | Changes |\n|---|---|\n| `scripts/postinstall.mjs` | Add termux detection and warning. |\n| `open-sse/utils/env.ts` (or similar) | Graceful downgrade. |",
|
||||
"req_effort": "Low. Very localized fix."
|
||||
}
|
||||
}
|
||||
|
||||
for root, _, files in os.walk("_ideia/viable"):
|
||||
for f in files:
|
||||
if not f.endswith(".md") or "requirements" in f: continue
|
||||
num = int(f.split('-')[0])
|
||||
if num in viable_data:
|
||||
path = os.path.join(root, f)
|
||||
with open(path, 'r') as file:
|
||||
content = file.read()
|
||||
|
||||
d = viable_data[num]
|
||||
|
||||
# replace TBDs
|
||||
content = content.replace("### What it solves\n\n- TBD", f"### What it solves\n\n- {d['solves']}")
|
||||
content = content.replace("### How it should work (high level)\n\n1. TBD\n2. TBD", f"### How it should work (high level)\n\n{d['how']}")
|
||||
content = content.replace("### Affected areas\n\n- TBD", f"### Affected areas\n\n- {d['areas']}")
|
||||
content = content.replace("Feature needs manual refinement and interpretation to fill logical gaps and outline high-level technical scope.", "Refined and scoped for implementation.")
|
||||
|
||||
with open(path, 'w') as file:
|
||||
file.write(content)
|
||||
|
||||
req_content = f"""# Requirements: {d['title']}
|
||||
|
||||
> Feature Idea: [#{num}](./{f})
|
||||
> Research Date: 2026-05-01
|
||||
> Verdict: ✅ VIABLE
|
||||
|
||||
## 🔍 Research Summary
|
||||
|
||||
{d['req_summary']}
|
||||
|
||||
## 📚 Reference Implementations
|
||||
|
||||
| # | Repository | Stars | Last Updated | Approach | Relevance |
|
||||
| --- | ---------------- | ----- | ------------ | -------- | ------------ |
|
||||
| 1 | OmniRoute Source | - | 2026-05-01 | Internal | High |
|
||||
|
||||
## 📐 Proposed Solution Architecture
|
||||
|
||||
### Approach
|
||||
|
||||
{d['req_approach']}
|
||||
|
||||
### Modified Files
|
||||
|
||||
{d['req_files']}
|
||||
|
||||
## ⚙️ Implementation Effort
|
||||
|
||||
- **Estimated complexity**: {d['req_effort']}
|
||||
- **Breaking changes**: No
|
||||
"""
|
||||
req_path = os.path.join(root, f.replace(".md", ".requirements.md"))
|
||||
with open(req_path, 'w') as req_file:
|
||||
req_file.write(req_content)
|
||||
|
||||
print(f"Refined {num} and created requirements.")
|
||||
47
scripts/scratch/test-migrations.ts
Normal file
47
scripts/scratch/test-migrations.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { runMigrations, getMigrationStatus } from "../../src/lib/db/migrationRunner.ts";
|
||||
|
||||
const db = new Database(":memory:");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _omniroute_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('001', 'initial_schema');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('002', 'mcp_a2a_tables');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('003', 'provider_node_custom_paths');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('004', 'proxy_registry');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('005', 'combo_agent_fields');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('006', 'detailed_request_logs');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('007', 'search_request_type');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('008', 'registered_keys');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('009', 'requested_model');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('010', 'model_combo_mappings');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('011', 'webhooks');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('012', 'fix_token_input_cache_tokens');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('013', 'quota_snapshots');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('014', 'unified_log_artifacts');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('015', 'create_memories');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('016', 'create_skills');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('017', 'version_manager_upstream_proxy');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('018', 'call_logs_detailed_tokens');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('019', 'context_handoffs');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('020', 'combo_sort_order');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('021', 'combo_call_log_targets');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('026', 'call_logs_cache_source');
|
||||
INSERT INTO _omniroute_migrations (version, name) VALUES ('029', 'provider_connection_max_concurrent');
|
||||
`);
|
||||
|
||||
console.log("Status before:", getMigrationStatus(db));
|
||||
|
||||
try {
|
||||
runMigrations(db, { isNewDb: false });
|
||||
} catch (e) {
|
||||
console.error("Migration threw error:", e);
|
||||
}
|
||||
|
||||
console.log("Status after:", getMigrationStatus(db));
|
||||
@@ -403,8 +403,8 @@ export default function AgentsPage() {
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">fingerprint</span>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("fingerprintSettingsHint")}{" "}
|
||||
<Link href="/dashboard/settings" className="text-primary hover:underline">
|
||||
{t("openSettings")}
|
||||
<Link href="/dashboard/settings?tab=routing" className="text-primary hover:underline">
|
||||
{t("settingsRoutingLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function CompressionAnalyticsTab() {
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">show_chart</span>
|
||||
Last 24 Hours
|
||||
Last 24 Hours (Activity)
|
||||
</h3>
|
||||
<div className="flex items-end gap-2 h-48">
|
||||
{stats.last24h.map((entry, idx) => {
|
||||
@@ -258,7 +258,7 @@ export default function CompressionAnalyticsTab() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted rotate-45 origin-left">
|
||||
{entry.hour.substring(0, 2)}
|
||||
{entry.hour.substring(11, 13)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ const MODALITY_CONFIG: Record<
|
||||
placeholder: "Hello! Welcome to OmniRoute, your intelligent AI gateway...",
|
||||
color: "from-green-500 to-teal-500",
|
||||
textLabel: "Text",
|
||||
needsCredentials: ["openai", "elevenlabs", "deepgram"],
|
||||
needsCredentials: ["openai", "elevenlabs", "deepgram", "xiaomi-mimo"],
|
||||
},
|
||||
transcription: {
|
||||
icon: "mic",
|
||||
@@ -183,6 +183,15 @@ const PROVIDER_MODELS: Record<
|
||||
models: [{ id: "huggingface/espnet/kan-bayashi_ljspeech_vits", name: "VITS LJSpeech" }],
|
||||
},
|
||||
{ id: "qwen", name: "Qwen", models: [{ id: "qwen/qwen3-tts", name: "Qwen3 TTS" }] },
|
||||
{
|
||||
id: "xiaomi-mimo",
|
||||
name: "Xiaomi MiMo",
|
||||
models: [
|
||||
{ id: "xiaomi-mimo/mimo-v2.5-tts", name: "MiMo V2.5 TTS" },
|
||||
{ id: "xiaomi-mimo/mimo-v2.5-tts-voicedesign", name: "MiMo V2.5 Voice Design" },
|
||||
{ id: "xiaomi-mimo/mimo-v2.5-tts-voiceclone", name: "MiMo V2.5 Voice Clone" },
|
||||
],
|
||||
},
|
||||
],
|
||||
transcription: [
|
||||
{
|
||||
@@ -271,9 +280,26 @@ const VOICE_PRESETS: Record<string, { id: string; label: string }[]> = {
|
||||
{ id: "Eva", label: "Eva (EN)" },
|
||||
{ id: "Marcus", label: "Marcus (EN)" },
|
||||
],
|
||||
"xiaomi-mimo": [
|
||||
{ id: "冰糖", label: "冰糖 (Chinese Female)" },
|
||||
{ id: "茉莉", label: "茉莉 (Chinese Female)" },
|
||||
{ id: "苏打", label: "苏打 (Chinese Male)" },
|
||||
{ id: "白桦", label: "白桦 (Chinese Male)" },
|
||||
{ id: "Mia", label: "Mia (English Female)" },
|
||||
{ id: "Chloe", label: "Chloe (English Female)" },
|
||||
{ id: "Milo", label: "Milo (English Male)" },
|
||||
{ id: "Dean", label: "Dean (English Male)" },
|
||||
],
|
||||
};
|
||||
|
||||
const SPEECH_FORMATS = ["mp3", "wav", "opus", "flac", "pcm"];
|
||||
const SPEECH_FORMATS_BY_PROVIDER: Record<string, string[]> = {
|
||||
"xiaomi-mimo": ["mp3", "wav"],
|
||||
};
|
||||
|
||||
function getSpeechFormats(providerId: string): string[] {
|
||||
return SPEECH_FORMATS_BY_PROVIDER[providerId] || SPEECH_FORMATS;
|
||||
}
|
||||
|
||||
function getVoiceList(providerId: string) {
|
||||
return VOICE_PRESETS[providerId] ?? VOICE_PRESETS.default;
|
||||
@@ -433,6 +459,15 @@ export default function MediaPageClient() {
|
||||
new Set(LOCAL_PROVIDERS) // Optimistic: show all until we know otherwise
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const audioUrl = result?.audioUrl;
|
||||
return () => {
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
};
|
||||
}, [result?.audioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch configured provider connections to determine which local providers are set up
|
||||
fetch("/api/providers")
|
||||
@@ -485,6 +520,7 @@ export default function MediaPageClient() {
|
||||
setSelectedModel(firstModel);
|
||||
if (tab === "speech") {
|
||||
setSpeechVoice(getVoiceList(firstProvider?.id ?? "")[0]?.id ?? "alloy");
|
||||
setSpeechFormat(getSpeechFormats(firstProvider?.id ?? "")[0] ?? "mp3");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -495,6 +531,8 @@ export default function MediaPageClient() {
|
||||
setSelectedModel(firstModel);
|
||||
if (activeTab === "speech") {
|
||||
setSpeechVoice(getVoiceList(providerId)[0]?.id ?? "alloy");
|
||||
const formats = getSpeechFormats(providerId);
|
||||
setSpeechFormat((current) => (formats.includes(current) ? current : (formats[0] ?? "mp3")));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -649,6 +687,7 @@ export default function MediaPageClient() {
|
||||
|
||||
const config = MODALITY_CONFIG[activeTab];
|
||||
const voiceList = getVoiceList(selectedProvider);
|
||||
const currentSpeechFormats = getSpeechFormats(selectedProvider);
|
||||
const isTopazImageFlow = activeTab === "image" && selectedProvider === "topaz";
|
||||
const isGenerateDisabled =
|
||||
loading ||
|
||||
@@ -765,7 +804,7 @@ export default function MediaPageClient() {
|
||||
onChange={(e) => setSpeechFormat(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-black/10 dark:border-white/10 text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
>
|
||||
{SPEECH_FORMATS.map((f) => (
|
||||
{currentSpeechFormats.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
|
||||
@@ -606,6 +606,7 @@ export default function CombosPage() {
|
||||
const [comboDragOverIndex, setComboDragOverIndex] = useState(null);
|
||||
const [savingComboOrder, setSavingComboOrder] = useState(false);
|
||||
const [comboConfigMode, setComboConfigMode] = useState("guided");
|
||||
const [promptCompressionEnabled, setPromptCompressionEnabled] = useState(false);
|
||||
const [selectedIntelligentComboId, setSelectedIntelligentComboId] = useState<string | null>(null);
|
||||
const comboDragIndexRef = useRef<number | null>(null);
|
||||
const activeFilter = normalizeIntelligentRoutingFilter(searchParams.get("filter"));
|
||||
@@ -650,6 +651,10 @@ export default function CombosPage() {
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((settings) => setComboConfigMode(normalizeComboConfigMode(settings?.comboConfigMode)))
|
||||
.catch(() => setComboConfigMode("guided"));
|
||||
fetch("/api/settings/compression")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((settings) => setPromptCompressionEnabled(settings?.enabled === true))
|
||||
.catch(() => setPromptCompressionEnabled(false));
|
||||
fetch("/api/settings/proxy")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((c) => setProxyConfig(c))
|
||||
@@ -1128,6 +1133,7 @@ export default function CombosPage() {
|
||||
<ComboCard
|
||||
combo={combo}
|
||||
metrics={metrics[combo.name]}
|
||||
compressionEnabled={promptCompressionEnabled}
|
||||
providerNodes={providerNodes}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
@@ -1499,6 +1505,7 @@ function ComboReadinessPanel({ checks, blockers, showDescription = true }) {
|
||||
function ComboCard({
|
||||
combo,
|
||||
metrics,
|
||||
compressionEnabled,
|
||||
copied,
|
||||
onCopy,
|
||||
onEdit,
|
||||
@@ -1524,10 +1531,20 @@ function ComboCard({
|
||||
const tc = useTranslations("common");
|
||||
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
|
||||
const strategyDescription = getStrategyDescription(t, strategy);
|
||||
const initialCompressionMode = combo?.config?.compressionMode || combo.compressionOverride || "";
|
||||
const hasRuntimeConfig = combo?.config && typeof combo.config === "object";
|
||||
const initialCompressionMode =
|
||||
typeof combo?.config?.compressionMode === "string"
|
||||
? combo.config.compressionMode
|
||||
: hasRuntimeConfig
|
||||
? ""
|
||||
: combo.compressionOverride || "";
|
||||
const [compressionOverride, setCompressionOverride] = useState(initialCompressionMode);
|
||||
const [isSavingCompression, setIsSavingCompression] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCompressionOverride(initialCompressionMode);
|
||||
}, [initialCompressionMode]);
|
||||
|
||||
const handleCompressionOverrideChange = async (value) => {
|
||||
setCompressionOverride(value);
|
||||
setIsSavingCompression(true);
|
||||
@@ -1687,20 +1704,22 @@ function ComboCard({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 transition-opacity">
|
||||
<select
|
||||
value={compressionOverride}
|
||||
onChange={(e) => handleCompressionOverrideChange(e.target.value)}
|
||||
disabled={isSavingCompression}
|
||||
className="text-xs py-1 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-bg-main text-text-main focus:border-primary focus:outline-none transition-colors disabled:opacity-50 max-w-[130px] md:max-w-none"
|
||||
title="Compression Override"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="off">Off</option>
|
||||
<option value="lite">Lite</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="aggressive">Aggressive</option>
|
||||
<option value="ultra">Ultra</option>
|
||||
</select>
|
||||
{compressionEnabled && (
|
||||
<select
|
||||
value={compressionOverride}
|
||||
onChange={(e) => handleCompressionOverrideChange(e.target.value)}
|
||||
disabled={isSavingCompression}
|
||||
className="text-xs py-1 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-bg-main text-text-main focus:border-primary focus:outline-none transition-colors disabled:opacity-50 max-w-[130px] md:max-w-none"
|
||||
title="Compression Override"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="off">Off</option>
|
||||
<option value="lite">Lite</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="aggressive">Aggressive</option>
|
||||
<option value="ultra">Ultra</option>
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
|
||||
@@ -99,7 +99,13 @@ export default function MemoryPage() {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `memory-export-${new Date().toISOString()}.json`;
|
||||
link.click();
|
||||
try {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
|
||||
@@ -233,6 +233,14 @@ export default function PlaygroundPage() {
|
||||
const isImageEndpoint = selectedEndpoint === "images";
|
||||
const supportsVision = isChatEndpoint && isVisionModel(selectedModel);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
// Load connections for a given provider — filtered from allConnections
|
||||
const providerConnections = allConnections.filter((c) => {
|
||||
if (!selectedProvider) return false;
|
||||
|
||||
@@ -503,6 +503,7 @@ interface ConnectionRowConnection {
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
expiresAt?: string;
|
||||
tokenExpiresAt?: string;
|
||||
maxConcurrent?: number | null;
|
||||
}
|
||||
|
||||
interface ConnectionRowProps {
|
||||
@@ -5151,6 +5152,15 @@ function ConnectionRow({
|
||||
{t("autoPriority", { priority: connection.globalPriority })}
|
||||
</span>
|
||||
)}
|
||||
{connection.maxConcurrent != null && connection.maxConcurrent > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-zinc-500/15 text-zinc-500 dark:bg-zinc-400/15 dark:text-zinc-400"
|
||||
title={t("accountConcurrencyCapLabel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[11px]">dynamic_feed</span>
|
||||
{connection.maxConcurrent}
|
||||
</span>
|
||||
)}
|
||||
{/* Rate Limit Protection — inline toggle with label */}
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
|
||||
@@ -36,6 +36,13 @@ interface CacheMetrics {
|
||||
const REFRESH_INTERVAL_MS = 10_000;
|
||||
const REFRESH_INTERVAL_SECONDS = REFRESH_INTERVAL_MS / 1000;
|
||||
|
||||
function formatNumberCompact(num: number): string {
|
||||
return Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export default function CacheStatsCard() {
|
||||
const [metrics, setMetrics] = useState<CacheMetrics | null>(null);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
@@ -117,19 +124,19 @@ export default function CacheStatsCard() {
|
||||
<div>
|
||||
<p className="text-text-muted">{t("inputTokens")}</p>
|
||||
<p className="font-mono text-lg text-text-main">
|
||||
{metrics.totalInputTokens.toLocaleString()}
|
||||
{formatNumberCompact(metrics.totalInputTokens)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">{t("cachedTokensRead")}</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
{metrics.totalCachedTokens.toLocaleString()}
|
||||
{formatNumberCompact(metrics.totalCachedTokens)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">{t("cacheCreationWrite")}</p>
|
||||
<p className="font-mono text-lg text-blue-400">
|
||||
{metrics.totalCacheCreationTokens.toLocaleString()}
|
||||
{formatNumberCompact(metrics.totalCacheCreationTokens)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +164,7 @@ export default function CacheStatsCard() {
|
||||
<div>
|
||||
<p className="text-text-muted">{t("tokensSaved")}</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
{metrics.tokensSaved.toLocaleString()}
|
||||
{formatNumberCompact(metrics.tokensSaved)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -189,13 +196,13 @@ export default function CacheStatsCard() {
|
||||
</div>
|
||||
<div className="flex items-center gap-4 font-mono">
|
||||
<span className="text-text-muted" title={t("inputTokens")}>
|
||||
{t("inputShort")}: {stats.inputTokens.toLocaleString()}
|
||||
{t("inputShort")}: {formatNumberCompact(stats.inputTokens)}
|
||||
</span>
|
||||
<span className="text-green-400" title={t("cachedTokensRead")}>
|
||||
{t("cachedShort")}: {stats.cachedTokens.toLocaleString()}
|
||||
{t("cachedShort")}: {formatNumberCompact(stats.cachedTokens)}
|
||||
</span>
|
||||
<span className="text-blue-400" title={t("cacheCreationWrite")}>
|
||||
{t("writeShort")}: {stats.cacheCreationTokens.toLocaleString()}
|
||||
{t("writeShort")}: {formatNumberCompact(stats.cacheCreationTokens)}
|
||||
</span>
|
||||
<span className="text-green-400 w-12 text-right">
|
||||
{providerCacheRate.toFixed(0)}%
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
|
||||
type OneproxyItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
type: string;
|
||||
countryCode: string | null;
|
||||
qualityScore: number | null;
|
||||
latencyMs: number | null;
|
||||
anonymity: string | null;
|
||||
googleAccess: boolean;
|
||||
status: string;
|
||||
lastValidated: string | null;
|
||||
};
|
||||
|
||||
type OneproxyStats = {
|
||||
total: number;
|
||||
active: number;
|
||||
avgQuality: number | null;
|
||||
lastValidated: string | null;
|
||||
byProtocol: Array<{ protocol: string; count: number }>;
|
||||
byCountry: Array<{ countryCode: string; count: number }>;
|
||||
};
|
||||
|
||||
type SyncStatus = {
|
||||
lastSyncSuccess: boolean;
|
||||
lastSyncError: string | null;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncCount: number;
|
||||
consecutiveFailures: number;
|
||||
};
|
||||
|
||||
export default function OneproxyTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [proxies, setProxies] = useState<OneproxyItem[]>([]);
|
||||
const [stats, setStats] = useState<OneproxyStats | null>(null);
|
||||
const [status, setStatus] = useState<SyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||
const [filterProtocol, setFilterProtocol] = useState("");
|
||||
const [filterCountry, setFilterCountry] = useState("");
|
||||
const [minQuality, setMinQuality] = useState("");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filterProtocol) params.set("protocol", filterProtocol);
|
||||
if (filterCountry) params.set("countryCode", filterCountry);
|
||||
if (minQuality) params.set("minQuality", minQuality);
|
||||
|
||||
const [proxiesRes, statsRes] = await Promise.all([
|
||||
fetch(`/api/settings/oneproxy?${params.toString()}`),
|
||||
fetch("/api/settings/oneproxy?action=stats"),
|
||||
]);
|
||||
|
||||
if (proxiesRes.ok) {
|
||||
const data = await proxiesRes.json();
|
||||
setProxies(data.items || []);
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
const data = await statsRes.json();
|
||||
setStats(data.stats);
|
||||
setStatus(data.status);
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterProtocol, filterCountry, minQuality]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/oneproxy", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSyncResult(`Synced ${data.total} proxies (${data.added} new, ${data.updated} updated)`);
|
||||
} else {
|
||||
setSyncResult(`Sync failed: ${data.error}`);
|
||||
}
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setSyncResult(`Sync failed: ${err}`);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!confirm("Clear all 1proxy proxies?")) return;
|
||||
try {
|
||||
await fetch("/api/settings/oneproxy?clearAll=1", { method: "DELETE" });
|
||||
await loadData();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/settings/oneproxy?id=${id}`, { method: "DELETE" });
|
||||
setProxies((prev) => prev.filter((p) => p.id !== id));
|
||||
if (stats) setStats({ ...stats, total: stats.total - 1, active: stats.active - 1 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const qualityColor = (score: number | null) => {
|
||||
if (score == null) return "bg-gray-500";
|
||||
if (score >= 80) return "bg-green-500";
|
||||
if (score >= 50) return "bg-yellow-500";
|
||||
return "bg-red-500";
|
||||
};
|
||||
|
||||
const protocolBadge = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
http: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
https: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
socks4: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
socks5: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
|
||||
};
|
||||
return colors[type] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-main">1proxy Free Proxy Marketplace</h2>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Fetch and rotate free validated proxies from the 1proxy community platform
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSync} disabled={syncing} variant="primary">
|
||||
{syncing ? "Syncing..." : "Sync Now"}
|
||||
</Button>
|
||||
{proxies.length > 0 && (
|
||||
<Button onClick={handleClearAll} variant="danger">
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{syncResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm ${
|
||||
syncResult.startsWith("Synced")
|
||||
? "bg-green-50 text-green-800 dark:bg-green-900/30 dark:text-green-300"
|
||||
: "bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{syncResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">{stats.total}</div>
|
||||
<div className="text-sm text-text-muted">Total Proxies</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
|
||||
<div className="text-sm text-text-muted">Active</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">
|
||||
{stats.avgQuality != null ? `${stats.avgQuality}` : "—"}
|
||||
</div>
|
||||
<div className="text-sm text-text-muted">Avg Quality</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">
|
||||
{status?.lastSyncAt ? new Date(status.lastSyncAt).toLocaleTimeString() : "Never"}
|
||||
</div>
|
||||
<div className="text-sm text-text-muted">Last Sync</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={filterProtocol}
|
||||
onChange={(e) => setFilterProtocol(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border"
|
||||
>
|
||||
<option value="">All Protocols</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="socks4">SOCKS4</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Country code (e.g. US)"
|
||||
value={filterCountry}
|
||||
onChange={(e) => setFilterCountry(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-40"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min quality"
|
||||
value={minQuality}
|
||||
onChange={(e) => setMinQuality(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-32"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-text-muted">Loading proxies...</div>
|
||||
) : proxies.length === 0 ? (
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
No 1proxy proxies found. Click "Sync Now" to fetch free proxies.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Host</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Protocol</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Country</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Quality</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Latency</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Anonymity</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Google</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{proxies.map((proxy) => (
|
||||
<tr
|
||||
key={proxy.id}
|
||||
className="border-b border-border/50 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<td className="py-2 px-3 font-mono text-text-main">
|
||||
{proxy.host}:{proxy.port}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${protocolBadge(proxy.type)}`}
|
||||
>
|
||||
{proxy.type.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-text-main">{proxy.countryCode || "—"}</td>
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${qualityColor(proxy.qualityScore)}`}
|
||||
/>
|
||||
<span className="text-text-main">{proxy.qualityScore ?? "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-text-main">
|
||||
{proxy.latencyMs != null ? `${proxy.latencyMs}ms` : "—"}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-text-main">{proxy.anonymity || "—"}</td>
|
||||
<td className="py-2 px-3">
|
||||
{proxy.googleAccess ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<button
|
||||
onClick={() => handleDelete(proxy.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{status && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-2">Sync Status</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-muted">Last sync: </span>
|
||||
<span className={status.lastSyncSuccess ? "text-green-600" : "text-red-600"}>
|
||||
{status.lastSyncSuccess ? "Success" : "Failed"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Proxies fetched: </span>
|
||||
<span className="text-text-main">{status.lastSyncCount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Consecutive failures: </span>
|
||||
<span className="text-text-main">{status.consecutiveFailures}</span>
|
||||
</div>
|
||||
{status.lastSyncError && (
|
||||
<div className="col-span-full">
|
||||
<span className="text-text-muted">Error: </span>
|
||||
<span className="text-red-600 text-xs">{status.lastSyncError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ProxyConfigModal, Toggle } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ProxyRegistryManager from "./ProxyRegistryManager";
|
||||
import OneproxyTab from "./OneproxyTab";
|
||||
|
||||
export default function ProxyTab() {
|
||||
const [proxyModalOpen, setProxyModalOpen] = useState(false);
|
||||
@@ -137,6 +138,7 @@ export default function ProxyTab() {
|
||||
</Card>
|
||||
|
||||
<ProxyRegistryManager />
|
||||
<OneproxyTab />
|
||||
<Card className="p-6 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
import FallbackChainsEditor from "./FallbackChainsEditor";
|
||||
import {
|
||||
CLI_COMPAT_PROVIDER_DISPLAY,
|
||||
CLI_COMPAT_TOGGLE_IDS,
|
||||
normalizeCliCompatProviderId,
|
||||
} from "@/shared/constants/cliCompatProviders";
|
||||
|
||||
export default function RoutingTab() {
|
||||
const [settings, setSettings] = useState<any>({
|
||||
alwaysPreserveClientCache: "auto",
|
||||
antigravitySignatureCacheMode: "enabled",
|
||||
cliCompatProviders: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
|
||||
@@ -40,6 +46,26 @@ export default function RoutingTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const cliCompatProviders = useMemo(
|
||||
() =>
|
||||
Array.isArray(settings.cliCompatProviders)
|
||||
? settings.cliCompatProviders.map((providerId: string) => normalizeCliCompatProviderId(providerId))
|
||||
: [],
|
||||
[settings.cliCompatProviders]
|
||||
);
|
||||
const cliCompatProviderSet = useMemo(() => new Set(cliCompatProviders), [cliCompatProviders]);
|
||||
|
||||
const toggleCliCompatProvider = (providerId: string, enabled: boolean) => {
|
||||
const normalizedProviderId = normalizeCliCompatProviderId(providerId);
|
||||
const nextProviders = new Set(cliCompatProviders);
|
||||
if (enabled) {
|
||||
nextProviders.add(normalizedProviderId);
|
||||
} else {
|
||||
nextProviders.delete(normalizedProviderId);
|
||||
}
|
||||
updateSetting({ cliCompatProviders: Array.from(nextProviders) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
@@ -224,6 +250,62 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
fingerprint
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("cliFingerprint")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("cliFingerprintDesc")}</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{t("cliFingerprintEnabled", { count: cliCompatProviderSet.size })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{CLI_COMPAT_TOGGLE_IDS.map((providerId) => {
|
||||
const normalizedProviderId = normalizeCliCompatProviderId(providerId);
|
||||
const providerDisplay = CLI_COMPAT_PROVIDER_DISPLAY[providerId];
|
||||
const checked = cliCompatProviderSet.has(normalizedProviderId);
|
||||
const label = providerDisplay?.name || providerId;
|
||||
const description = providerDisplay?.description || providerId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={providerId}
|
||||
type="button"
|
||||
onClick={() => toggleCliCompatProvider(providerId, !checked)}
|
||||
disabled={loading}
|
||||
aria-pressed={checked}
|
||||
title={checked ? t("disableFingerprintTitle", { provider: label }) : t("enableFingerprintTitle", { provider: label })}
|
||||
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-all ${
|
||||
checked
|
||||
? "border-indigo-500/50 bg-indigo-500/5 ring-1 ring-indigo-500/20"
|
||||
: "border-border/50 hover:border-border hover:bg-surface/30"
|
||||
} ${loading ? "cursor-not-allowed opacity-60" : ""}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined mt-0.5 text-[18px] ${checked ? "text-indigo-400" : "text-text-muted"}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{checked ? "check_circle" : "radio_button_unchecked"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block text-sm font-medium ${checked ? "text-indigo-400" : ""}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-text-muted">{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">
|
||||
|
||||
@@ -40,6 +40,7 @@ const PROVIDER_CONFIG = {
|
||||
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
|
||||
minimax: { label: "MiniMax", color: "#7C3AED" },
|
||||
"minimax-cn": { label: "MiniMax CN", color: "#DC2626" },
|
||||
nanogpt: { label: "NanoGPT", color: "#4F46E5" },
|
||||
};
|
||||
|
||||
const TIER_FILTERS = [
|
||||
@@ -301,6 +302,7 @@ export default function ProviderLimits() {
|
||||
"kimi-coding": 9,
|
||||
minimax: 10,
|
||||
"minimax-cn": 11,
|
||||
nanogpt: 12,
|
||||
};
|
||||
return [...filteredConnections].sort(
|
||||
(a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9)
|
||||
|
||||
@@ -289,6 +289,14 @@ export function parseQuotaData(provider, data) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "nanogpt":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Generic fallback for unknown providers
|
||||
if (data.quotas) {
|
||||
|
||||
@@ -106,6 +106,7 @@ async function rejectIfA2ADisabled(id: string | number | null) {
|
||||
// ============ Route Handler ============
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
console.log("==> HIT A2A ROUTER:", req.url);
|
||||
// Auth check
|
||||
if (!authenticate(req)) {
|
||||
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
|
||||
@@ -180,6 +181,7 @@ export async function POST(req: NextRequest) {
|
||||
metadata: result.metadata,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("A2A ERROR TRACE:", err);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
tm.updateTask(task.id, "failed", [{ type: "error", content: msg }], msg);
|
||||
return jsonRpcError(id, -32603, `Skill execution failed: ${msg}`);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-2).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const REQUIRED_TABLES = ["provider_connections", "provider_nodes", "combos", "ap
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-3).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ function tryIdeAuth(): {
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-5).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/lib/localDb";
|
||||
import {
|
||||
SAFE_OUTBOUND_FETCH_PRESETS,
|
||||
SafeOutboundFetchError,
|
||||
getSafeOutboundFetchErrorStatus,
|
||||
safeOutboundFetch,
|
||||
} from "@/shared/network/safeOutboundFetch";
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
} from "@/lib/providerModels/modelDiscovery";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type LocalCatalogModel = { id: string; name?: string };
|
||||
|
||||
const antigravityDiscoveryInflight = new Map<
|
||||
string,
|
||||
@@ -118,6 +120,19 @@ function isNamedOpenAIStyleProvider(provider: string): boolean {
|
||||
return NAMED_OPENAI_STYLE_PROVIDERS.has(provider);
|
||||
}
|
||||
|
||||
function mergeLocalCatalogModels<T extends LocalCatalogModel, U extends LocalCatalogModel>(
|
||||
registryCatalogModels: T[],
|
||||
specialtyCatalogModels: U[]
|
||||
): Array<T | U> {
|
||||
if (registryCatalogModels.length === 0) return specialtyCatalogModels;
|
||||
|
||||
const registryModelIds = new Set(registryCatalogModels.map((model) => model.id));
|
||||
return [
|
||||
...registryCatalogModels,
|
||||
...specialtyCatalogModels.filter((model) => !registryModelIds.has(model.id)),
|
||||
];
|
||||
}
|
||||
|
||||
function buildOptionalBearerHeaders(token: string | null | undefined): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
@@ -402,55 +417,48 @@ export function getStaticModelsForProvider(
|
||||
return staticModelsFn();
|
||||
}
|
||||
|
||||
const specialtyModels: Array<{ id: string; name: string }> = [];
|
||||
const appendModels = (models: Array<{ id: string; name?: string }>) => {
|
||||
for (const model of models) {
|
||||
if (specialtyModels.some((existing) => existing.id === model.id)) continue;
|
||||
specialtyModels.push({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const embeddingProvider = getEmbeddingProvider(provider);
|
||||
if (embeddingProvider) {
|
||||
return embeddingProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(embeddingProvider.models);
|
||||
}
|
||||
|
||||
const rerankProvider = getRerankProvider(provider);
|
||||
if (rerankProvider) {
|
||||
return rerankProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(rerankProvider.models);
|
||||
}
|
||||
|
||||
const imageProvider = getImageProvider(provider);
|
||||
if (imageProvider) {
|
||||
return imageProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(imageProvider.models);
|
||||
}
|
||||
|
||||
const videoProvider = getVideoProvider(provider);
|
||||
if (videoProvider) {
|
||||
return videoProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(videoProvider.models);
|
||||
}
|
||||
|
||||
const speechProvider = getSpeechProvider(provider);
|
||||
if (speechProvider) {
|
||||
return speechProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(speechProvider.models);
|
||||
}
|
||||
|
||||
const transcriptionProvider = getTranscriptionProvider(provider);
|
||||
if (transcriptionProvider) {
|
||||
return transcriptionProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(transcriptionProvider.models);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return specialtyModels.length > 0 ? specialtyModels : undefined;
|
||||
}
|
||||
|
||||
// Provider models endpoints configuration
|
||||
@@ -825,9 +833,8 @@ export async function GET(
|
||||
const specialtyCatalogModels = getStaticModelsForProvider(provider) || [];
|
||||
|
||||
const toLocalCatalogModels = () => {
|
||||
const localCatalog =
|
||||
registryCatalogModels.length > 0 ? registryCatalogModels : specialtyCatalogModels;
|
||||
return localCatalog.map((model: any) => ({
|
||||
const localCatalog = mergeLocalCatalogModels(registryCatalogModels, specialtyCatalogModels);
|
||||
return localCatalog.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
|
||||
@@ -1755,13 +1762,12 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
const localCatalog =
|
||||
registryCatalogModels.length > 0 ? registryCatalogModels : specialtyCatalogModels;
|
||||
const localCatalog = mergeLocalCatalogModels(registryCatalogModels, specialtyCatalogModels);
|
||||
if (!config && localCatalog.length > 0) {
|
||||
return buildResponse({
|
||||
provider,
|
||||
connectionId,
|
||||
models: localCatalog.map((m: any) => ({
|
||||
models: localCatalog.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
|
||||
@@ -1895,6 +1901,10 @@ export async function GET(
|
||||
|
||||
return buildApiDiscoveryResponse(allModels);
|
||||
} catch (error) {
|
||||
if (error instanceof SafeOutboundFetchError && error.code === "URL_GUARD_BLOCKED") {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const status = getSafeOutboundFetchErrorStatus(error);
|
||||
if (status) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch models";
|
||||
|
||||
@@ -124,10 +124,12 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
rateLimitedUntil,
|
||||
lastTested,
|
||||
healthCheckInterval,
|
||||
group,
|
||||
maxConcurrent,
|
||||
providerSpecificData: incomingPsd,
|
||||
} = body;
|
||||
|
||||
const existing = await getProviderConnectionById(id);
|
||||
const existing = (await getProviderConnectionById(id)) as Record<string, any> | null;
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
@@ -148,6 +150,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
if (rateLimitedUntil !== undefined) updateData.rateLimitedUntil = rateLimitedUntil;
|
||||
if (lastTested !== undefined) updateData.lastTested = lastTested;
|
||||
if (healthCheckInterval !== undefined) updateData.healthCheckInterval = healthCheckInterval;
|
||||
if (group !== undefined) updateData.group = group;
|
||||
if (maxConcurrent !== undefined) updateData.maxConcurrent = maxConcurrent;
|
||||
|
||||
// Merge providerSpecificData (partial update — preserve existing keys not sent by caller)
|
||||
if (incomingPsd !== undefined && incomingPsd !== null && typeof incomingPsd === "object") {
|
||||
@@ -240,7 +244,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
|
||||
const { id } = await params;
|
||||
|
||||
// Fetch connection before deleting to check provider type
|
||||
const connection = await getProviderConnectionById(id);
|
||||
const connection = (await getProviderConnectionById(id)) as Record<string, any> | null;
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
* Exports a legacy 9router compatible JSON backup.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
@@ -41,8 +41,8 @@ export async function GET(request: Request) {
|
||||
// Metadata to identify export version
|
||||
_meta: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: "omniroute-v3-legacy-export"
|
||||
}
|
||||
version: "omniroute-v3-legacy-export",
|
||||
},
|
||||
};
|
||||
|
||||
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { runJsonMigration, type LegacyJsonData } from "@/lib/db/jsonMigration";
|
||||
* 🔒 A pre-import backup is created automatically before any data is written.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
@@ -44,7 +44,9 @@ export async function POST(request: Request) {
|
||||
data = JSON.parse(rawText) as LegacyJsonData;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON: the file could not be parsed. Please upload a valid .json backup." },
|
||||
{
|
||||
error: "Invalid JSON: the file could not be parsed. Please upload a valid .json backup.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
44
src/app/api/settings/oneproxy/rotate/route.ts
Normal file
44
src/app/api/settings/oneproxy/rotate/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { rotateOneproxyProxy } from "@/lib/oneproxyRotator";
|
||||
import { oneproxyRotateSchema } from "@/shared/validation/oneproxySchemas";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
rawBody = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(oneproxyRotateSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: validation.error.message,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const proxy = await rotateOneproxyProxy({
|
||||
strategy: validation.data.strategy,
|
||||
});
|
||||
|
||||
if (!proxy) {
|
||||
return createErrorResponse({
|
||||
status: 404,
|
||||
message: "No active 1proxy proxies available",
|
||||
type: "not_found",
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json(proxy);
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to rotate 1proxy proxy");
|
||||
}
|
||||
}
|
||||
126
src/app/api/settings/oneproxy/route.ts
Normal file
126
src/app/api/settings/oneproxy/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import {
|
||||
listOneproxyProxies,
|
||||
getOneproxyStats,
|
||||
deleteOneproxyProxy,
|
||||
clearAllOneproxyProxies,
|
||||
} from "@/lib/localDb";
|
||||
import {
|
||||
syncOneproxyProxies,
|
||||
getOneproxySyncStatus,
|
||||
resetOneproxyCircuitBreaker,
|
||||
} from "@/lib/oneproxySync";
|
||||
import { oneproxyFilterSchema, oneproxySyncSchema } from "@/shared/validation/oneproxySchemas";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get("action");
|
||||
|
||||
if (action === "stats") {
|
||||
const stats = await getOneproxyStats();
|
||||
const status = getOneproxySyncStatus();
|
||||
return Response.json({ stats, status });
|
||||
}
|
||||
|
||||
if (action === "status") {
|
||||
return Response.json(getOneproxySyncStatus());
|
||||
}
|
||||
|
||||
const filterValidation = validateBody(oneproxyFilterSchema, {
|
||||
protocol: searchParams.get("protocol") || undefined,
|
||||
countryCode: searchParams.get("countryCode") || undefined,
|
||||
minQuality: searchParams.get("minQuality") || undefined,
|
||||
limit: searchParams.get("limit") || undefined,
|
||||
});
|
||||
|
||||
if (isValidationFailure(filterValidation)) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: filterValidation.error.message,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const proxies = await listOneproxyProxies({
|
||||
protocol: filterValidation.data.protocol,
|
||||
countryCode: filterValidation.data.countryCode,
|
||||
minQuality: filterValidation.data.minQuality,
|
||||
limit: filterValidation.data.limit,
|
||||
});
|
||||
|
||||
return Response.json({ items: proxies, total: proxies.length });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to load 1proxy proxies");
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "Invalid JSON body",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(oneproxySyncSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: validation.error.message,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await syncOneproxyProxies();
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to sync 1proxy proxies");
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
const clearAll = searchParams.get("clearAll") === "1";
|
||||
|
||||
if (clearAll) {
|
||||
const count = await clearAllOneproxyProxies();
|
||||
return Response.json({ success: true, deleted: count });
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "id or clearAll is required",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await deleteOneproxyProxy(id);
|
||||
if (!deleted) {
|
||||
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to delete 1proxy proxy");
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ async function getModelCatalogAuthRejection(
|
||||
settings: Record<string, any>,
|
||||
headers: Record<string, string>
|
||||
): Promise<Response | null> {
|
||||
if (settings.requireAuthForModels !== true || !(await isAuthRequired())) return null;
|
||||
if (settings.requireAuthForModels !== true || !(await isAuthRequired(request))) return null;
|
||||
|
||||
const bearer = extractBearer(request.headers);
|
||||
if (bearer) {
|
||||
|
||||
@@ -4669,6 +4669,7 @@
|
||||
"flowDiagramCli": "CLI Agent",
|
||||
"flowDiagramCliDesc": "Processes with own auth/model",
|
||||
"fingerprintSettingsHint": "CLI Fingerprint matching (disguise requests as specific CLI tools) can be configured in",
|
||||
"settingsRoutingLink": "Settings/Routing",
|
||||
"openSettings": "Settings"
|
||||
},
|
||||
"templateNames": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user