Compare commits

..

31 Commits

Author SHA1 Message Date
diegosouzapw 282c46fd46 chore: bump version to 1.0.5 2026-02-20 01:43:53 -03:00
Diego Rodrigues de Sa e Souza bc719875f6 fix: filter all model types in /v1/models by active providers (#88)
Previously only chat models were filtered by active provider connections.
Embedding, image, rerank, audio, and moderation models were always shown
regardless of whether the provider had any configured connections.

This caused providers like Together AI to appear in /v1/models even when
no API key was configured.

Closes #81

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-02-20 01:43:33 -03:00
Diego Rodrigues de Sa e Souza 78b634b204 release: v1.0.4 — WhatsApp community, changelog, version bump, README translations (#87)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-02-19 22:43:27 -03:00
Diego Rodrigues de Sa e Souza 39aae51ca2 fix: OAuth login behind nginx — use actual origin for redirect URI (Closes #82) (#86)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-02-19 22:28:27 -03:00
Diego Rodrigues de Sa e Souza 937a8f3714 feat: filter api/models by active providers + disabled indicator on provider cards (Closes #81) (#85)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-02-19 22:22:16 -03:00
Diego Rodrigues de Sa e Souza c34973f40d docs: add llm.txt for LLM and contributor onboarding (Closes #83) (#84)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-02-19 22:16:15 -03:00
Diego Rodrigues de Sa e Souza 3045aa3055 Merge pull request #80 from diegosouzapw/feature/logs-dashboard
feat(logs): Logs Dashboard with real-time Console Viewer (v1.0.3)
2026-02-19 04:38:51 -03:00
diegosouzapw 1e332babd6 feat(logs): add Logs Dashboard with real-time Console Viewer
- Consolidated 4-tab Logs page: Request Logs, Proxy Logs, Audit Logs, Console
- Terminal-style Console Log Viewer with level filter, search, auto-scroll
- Console interceptor captures all console.log/warn/error to JSON log file
- Integrated in Next.js instrumentation.ts for both dev and prod
- Log rotation by size + retention-based cleanup
- Fixed pino logger file transport (pino/file targets only)
- Moved initAuditLog() to instrumentation.ts for proper initialization
- Bumped version to 1.0.3
- Updated CHANGELOG, all 8 README translations, and .env.example
2026-02-19 04:38:04 -03:00
Diego Rodrigues de Sa e Souza 2ddf63fde1 Merge pull request #79 from diegosouzapw/features-improvements
v1.0.2 — Security Hardening, Architecture & UX Polish
2026-02-18 17:09:56 -03:00
diegosouzapw de2ef14bc3 fix(ci): register @typescript-eslint plugin in ESLint flat config
- Install typescript-eslint as direct devDependency
- Import and register tseslint.plugin in eslint.config.mjs
- Fixes lint CI failure: plugin not found in ESLint 9 flat config
2026-02-18 16:59:31 -03:00
diegosouzapw 37123160e8 chore: bump version to 1.0.2
- package.json/package-lock.json: 1.0.1 → 1.0.2
- All 8 README files: 1.0.0 → 1.0.2 (Docker tags, tech notes, release commands)
- CHANGELOG.md: added v1.0.2 entry (security hardening, architecture, testing, UX)
2026-02-18 16:52:47 -03:00
diegosouzapw 5a645973eb fix(ui): correct Quick Start API key link to /dashboard/endpoint
- HomePageClient.tsx: 'Settings → API Keys' → 'Endpoint → Registered Keys'
- docs/page.tsx: same fix in Quick Start step 2
- Also includes user's manual fixes: bodySizeGuard, container imports, migrationRunner ESM compat
2026-02-18 16:40:33 -03:00
diegosouzapw 0279341221 feat(phase4): focus indicators, session management, k6 load tests, remove @ts-check
- U-3/U-4: Global focus-visible indicators + CSS focus-ring utility
- C-6: Removed @ts-check from 18 .ts files (redundant in TypeScript)
- T-5: k6 load test script (tests/load/proxy-load.js)
- P-3: SessionInfoCard component in Security settings tab
2026-02-18 14:51:15 -03:00
diegosouzapw 7efbfad9bd feat(phase2+3): DI container, plugins, PII sanitizer, tool policy, audit log, cache invalidation, ARIA, CSS vars
Phase 2 — Architecture & Quality:
- A-5: DI container (src/lib/container.ts)
- L-6: Prompt versioning (src/lib/db/prompts.ts)
- L-7: Eval scheduler (src/lib/evals/scheduler.ts)
- L-8: Plugin architecture (src/lib/plugins/index.ts)
- T-3: Integration tests (42 tests, 9 suites)

Phase 3 — UX & Polish:
- P-1: 500 error page (src/app/error.tsx)
- P-2: Audit log viewer (dashboard/audit-log/page.tsx)
- U-2: ARIA attributes on error pages
- U-5: Typed global-error.tsx params
- U-6: CSS variables for accent/semantic/traffic colors
- L-3: Output PII sanitization (src/lib/piiSanitizer.ts)
- L-4: Tool-calling allowlist/denylist (src/lib/toolPolicy.ts)
- L-9: Semantic cache invalidation API + auto-cleanup
- D-5: SECURITY.md updated to v1.0.x
- F-2: Deleted .env copy
2026-02-18 14:43:57 -03:00
diegosouzapw 3aca5fa691 feat: Implement initial database schema and centralize CORS configuration across API routes. 2026-02-18 13:44:08 -03:00
diegosouzapw 1a7b7e8799 feat: Implement graceful shutdown and body size guard middleware, and refine TypeScript typings across several routes and components. 2026-02-18 12:47:36 -03:00
diegosouzapw 4c93f0618c feat: Add dashboard loading and error UIs, introduce standardized API response utilities, enhance call log redaction and configurable retention, and document architectural decisions. 2026-02-18 11:34:05 -03:00
diegosouzapw 16b72970d7 feat: Implement API authentication middleware, mask sensitive API keys, integrate prompt injection guard, and enhance onboarding error handling. 2026-02-18 11:17:48 -03:00
Diego Rodrigues de Sa e Souza fbceb0d301 Merge pull request #77 from diegosouzapw/feature/api-compatibility-sanitization
feat(api-compat): response sanitization, role normalization & structured output for Gemini
2026-02-18 08:39:34 -03:00
diegosouzapw ed05599cdd feat(api-compat): response sanitization, role normalization, structured output for Gemini
- Add responseSanitizer.ts: strips non-standard fields (x_groq, usage_breakdown, service_tier),
  extracts <think> tags into reasoning_content, normalizes id/object/usage for strict OpenAI SDK v1.83+ compatibility
- Add roleNormalizer.ts: developer→system for non-OpenAI providers, system→user merging for GLM/ERNIE models
- Integrate sanitizer into chatCore.ts (non-streaming) and stream.ts (streaming passthrough)
- Integrate role normalizer into translator/index.ts pipeline
- Add response_format (json_schema/json_object) → Gemini responseMimeType/responseSchema conversion
- Update CHANGELOG.md with v1.1.0 entry
- Update README.md, ARCHITECTURE.md, TROUBLESHOOTING.md with new capabilities
2026-02-18 08:38:44 -03:00
Diego Rodrigues de Sa e Souza f79746f183 Merge pull request #76 from diegosouzapw/chore/bump-v1.0.1
chore: bump version to 1.0.1
2026-02-18 01:43:01 -03:00
diegosouzapw 0ab89a2438 chore: bump version to 1.0.1 2026-02-18 01:42:50 -03:00
Diego Rodrigues de Sa e Souza ebecc7de39 Merge pull request #75 from diegosouzapw/fix/codex-oauth-redirect-uri
fix(oauth): use registered redirect_uri for Codex on remote access
2026-02-18 01:42:25 -03:00
diegosouzapw 62f3c7416e fix(oauth): use registered redirect_uri for Codex on remote access
When accessing OmniRoute remotely (e.g. llms.omniroute.online), the
Codex OAuth redirect_uri was incorrectly set to http://localhost:443/callback.
OpenAI only accepts the registered http://localhost:1455/auth/callback.

This caused 'unknown_error' on auth.openai.com for remote users.
Other providers (Google-based: Antigravity, Gemini) are unaffected as
Google OAuth accepts any localhost port.
2026-02-18 01:42:01 -03:00
Diego Rodrigues de Sa e Souza 599668bba2 Merge pull request #74 from diegosouzapw/chore/changelog-v1.0.0
docs: consolidate CHANGELOG into single v1.0.0 release
2026-02-18 00:14:07 -03:00
diegosouzapw 6ab6049931 docs: consolidate CHANGELOG into single v1.0.0 release
Merged all previous entries (v0.1.0–v0.9.0) into a comprehensive
v1.0.0 entry organized by feature category (12 sections, 100+ items).
2026-02-18 00:13:56 -03:00
Diego Rodrigues de Sa e Souza 94b290dbe2 Merge pull request #73 from diegosouzapw/chore/update-readme-hero-image
chore: update hero image to MainOmniRoute.png
2026-02-18 00:11:00 -03:00
diegosouzapw 983920b735 chore: update hero image to MainOmniRoute.png in all READMEs
Replaced ./images/omniroute.png → ./docs/screenshots/MainOmniRoute.png
2026-02-18 00:10:39 -03:00
Diego Rodrigues de Sa e Souza 7aa4fcc39b Merge pull request #72 from diegosouzapw/chore/version-readmes-i18n
chore: update version to 1.0.0 in all i18n READMEs
2026-02-18 00:07:30 -03:00
diegosouzapw 5dff9e2af6 chore: update version to 1.0.0 in all i18n READMEs
Updated 0.9.0 → 1.0.0 in README.de.md, README.es.md, README.fr.md,
README.it.md, README.ru.md, README.zh-CN.md (4 refs each)
2026-02-18 00:07:07 -03:00
diegosouzapw 71d14209a4 feat: OmniRoute v1.0.0 — Intelligent AI Gateway & Universal LLM Proxy
OmniRoute is an intelligent API gateway that unifies 20+ AI providers behind a single
OpenAI-compatible endpoint. Features include intelligent routing with 6 strategies,
multi-format translation (OpenAI/Claude/Gemini/Responses API), circuit breakers,
semantic caching, combo fallback chains, real-time health monitoring, and a full
dashboard with provider management, analytics, and CLI tool integration.

Key highlights:
- 20+ providers (Claude Code, Codex, Gemini CLI, GitHub Copilot, iFlow, Qwen, Kiro, etc.)
- 6 routing strategies (Fill First, Round Robin, P2C, Random, Least Used, Cost Optimized)
- Export/Import database backup with full archive support
- Translator Playground with 4 modes (Playground, Chat Tester, Test Bench, Live Monitor)
- 100% TypeScript across src/ and open-sse/
- Docker support with multi-stage builds
- Comprehensive documentation and 9 dashboard screenshots
2026-02-18 00:02:15 -03:00
196 changed files with 17918 additions and 1298 deletions
+11 -4
View File
@@ -44,6 +44,9 @@ REQUIRE_API_KEY=false
BASE_URL=http://localhost:20128
CLOUD_URL=
# Backward-compatible/public variables:
# NEXT_PUBLIC_BASE_URL is also used as the OAuth redirect_uri origin when running behind a
# reverse proxy (e.g., nginx). Set this to your public-facing URL so OAuth callbacks work.
# Example: NEXT_PUBLIC_BASE_URL=https://omniroute.example.com
NEXT_PUBLIC_BASE_URL=http://localhost:20128
NEXT_PUBLIC_CLOUD_URL=
@@ -80,16 +83,16 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# These can also be set via data/provider-credentials.json
# CLAUDE_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_SECRET=
GEMINI_OAUTH_CLIENT_SECRET=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
# GEMINI_CLI_OAUTH_CLIENT_ID=
# GEMINI_CLI_OAUTH_CLIENT_SECRET=
GEMINI_CLI_OAUTH_CLIENT_SECRET=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
# CODEX_OAUTH_CLIENT_ID=
# CODEX_OAUTH_CLIENT_SECRET=
# QWEN_OAUTH_CLIENT_ID=
# IFLOW_OAUTH_CLIENT_ID=
# IFLOW_OAUTH_CLIENT_SECRET=
IFLOW_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW
# ANTIGRAVITY_OAUTH_CLIENT_ID=
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
ANTIGRAVITY_OAUTH_CLIENT_SECRET=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
# API Key Providers (Phase 1 + Phase 4)
# Add via Dashboard → Providers → Add API Key, or set here
@@ -118,3 +121,7 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Logging
# LOG_LEVEL=info
# LOG_FORMAT=text
LOG_TO_FILE=true
# LOG_FILE_PATH=logs/application/app.log
# LOG_MAX_FILE_SIZE=50M
# LOG_RETENTION_DAYS=7
+38 -1
View File
@@ -36,7 +36,8 @@ jobs:
- name: Dependency audit
run: npm audit --audit-level=high --omit=dev
- name: Check for known vulnerabilities
run: npx is-my-node-vulnerable || true
run: npx is-my-node-vulnerable
continue-on-error: true
build:
name: Build
@@ -108,3 +109,39 @@ jobs:
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm run test:e2e
test-integration:
name: Integration Tests
runs-on: ubuntu-latest
needs: build
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
INITIAL_PASSWORD: ci-test-password-for-integration
DATA_DIR: /tmp/omniroute-ci
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:integration
continue-on-error: true
test-security:
name: Security Tests
runs-on: ubuntu-latest
needs: build
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:security
continue-on-error: true
+7
View File
@@ -71,6 +71,9 @@ docs/*
!docs/planning/
!docs/improvement-plans/
!docs/api/
!docs/VM_DEPLOYMENT_GUIDE.md
!docs/FEATURES.md
!docs/screenshots/
# open-sse tests
open-sse/test/*
@@ -88,3 +91,7 @@ omnirouteSite/
# Security Analysis (standalone project with own git)
security-analysis/
# Deploy workflow (contains sensitive VPS credentials)
.agent/workflows/deploy.md
clipr/
+283 -157
View File
@@ -7,196 +7,322 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [0.8.5] — 2026-02-17
## [1.0.4] — 2026-02-19
### Added
> ### 🔧 Provider Filtering, OAuth Proxy Fix & Documentation
>
> Dashboard model filtering by active providers, provider enable/disable visual indicators, OAuth login fix for nginx reverse proxy, and LLM onboarding documentation.
- 🔒 **TLS fingerprint spoofing** — Implement browser-like TLS fingerprinting via `wreq-js` to bypass bot detection on providers that enforce TLS client fingerprint checks (`3dd0cc1`, PR #52)
- 💾 **SQLite proxy log persistence** — Proxy request/response logs now persist to SQLite database, surviving server restarts. Previously, logs were lost on restart (`f1664fe`, PR #53)
- 📋 **Unified test logging** — Shared `Logger` + Proxy logging infrastructure for all provider connection test flows. Consistent log formatting across batch and individual tests (`bce302e`, PR #55)
### ✨ Features
### Refactored
- **API Models filtering** — `GET /api/models` now returns only models from active providers; use `?all=true` for all models (#85)
- **Provider disabled indicator** — Provider cards show ⏸ "Disabled" badge with reduced opacity when all connections are inactive (#85)
- **`llm.txt`** — Comprehensive LLM onboarding file with project overview, architecture, flows, and conventions (#84)
- **WhatsApp Community** — Added WhatsApp group link to README badges and Support section
- 🔷 **Full TypeScript migration — `src/`** — Migrated the entire `src/` directory from JavaScript to TypeScript. All `.js`/`.jsx` files converted to `.ts`/`.tsx` with proper type annotations across API routes, lib modules, components, services, stores, domain layer, and shared utilities (`d0ca595`)
- **Wave 1**: Shared component interfaces + EventTarget fixes (`dfdd2a2`)
- **Wave 2**: Utils & services typed fields, Zustand stores, logger, sync scheduler (`89dd107`, `b2907cd`)
- **Wave 3a**: Lib layer, DB, compliance, domain layer typed (`9e13fe2`)
- **Wave 3b**: Usage, CLI runtime, SSE auth/logger typed (`a291abd`)
- **Wave 3c**: OAuth services + server utils typed (`d62cf8d`)
- **Wave 4a**: 7 API routes — providers, cli-tools, oauth (`7cdb923`)
- **Wave 4b**: 7 more API routes — providers, test, usage, nodes (`5592c2e`)
- **Wave 4c**: 8 files — components, SSE handlers, services (`d8ce9dc`)
- **Dashboard hardening**: Resolve all TypeScript errors across dashboard pages (`7a463a3`, PR #61)
- 🔷 **Full TypeScript migration — `open-sse/`** — Migrated all 94 `.js` files in the SSE routing engine to TypeScript (PR #62)
- **Phase 1**: Rename all 94 `.js``.ts` files (`256e443`)
- **Phase 6**: Reduce `@ts-ignore` from 231 → 186 with targeted fixes (`6a54b84`)
- **Phase 7**: Eliminate ALL `@ts-ignore` annotations (186 → 0) and ALL TypeScript errors (237 → 0) — zero `@ts-ignore`, zero errors (`7b37a3c`)
- Typing strategies: `Record<string, any>` for dynamic objects, optional function params, `as any` casts for custom Error/Array properties, `declare var EdgeRuntime` for edge compatibility, proper `fs`/`path` imports
### 🐛 Bug Fixes
### Fixed
- **OAuth behind nginx** — Fixed OAuth login failing when behind a reverse proxy by using `window.location.origin` for redirect URI instead of hardcoded `localhost` (#86)
- **`NEXT_PUBLIC_BASE_URL` for OAuth** — Documented env var usage as redirect URI override for proxy deployments (#86)
- 🐛 **Qwen token refresh** — Detect `invalid_request` as unrecoverable error and switch broken test endpoints to `checkExpiry` method instead of failing silently (`1e0ffbc`, PR #60)
- 🐛 **VPS batch test compatibility** — Eliminate HTTP self-calls in batch provider connection tests for VPS environments where localhost is unreachable (`a3bbbb5`, PR #54)
- 🐛 **E2E test assertions** — Correct API endpoints and response format assertions in end-to-end tests (`92b5e66`)
- 🐛 **CI coverage thresholds** — Lower coverage thresholds, use production server for E2E, block ESLint major upgrades from breaking CI (`3ca4b6b`, PR #51)
### 📁 Files Added
### Changed
| File | Purpose |
| --------- | -------------------------------------------------- |
| `llm.txt` | LLM and contributor onboarding (llms.txt standard) |
- 📖 **Documentation update** — Updated all documentation to reflect JS → TS migration, corrected file extensions and import paths (`7ff8aa2`)
- ⬆️ **CI/CD** — Bump `actions/checkout` v4 → v6, `actions/setup-node` v4 → v6, `peter-evans/dockerhub-description` v4 → v5
### 📁 Files Modified
### Dependencies
- ⬆️ `undici` 7.21.0 → 7.22.0 (production)
- ⬆️ `actions/checkout` 4 → 6
- ⬆️ `actions/setup-node` 4 → 6
- ⬆️ `peter-evans/dockerhub-description` 4 → 5
- 🚫 `eslint` 10.0.0 blocked — major version incompatible with `eslint-config-next`
| File | Change |
| -------------------------------------------------- | ---------------------------------------------------------------- |
| `src/app/api/models/route.ts` | Filter by active providers, `?all=true` param, `available` field |
| `src/app/(dashboard)/dashboard/providers/page.tsx` | `allDisabled` detection + ⏸ badge + opacity-50 on provider cards |
| `src/shared/components/OAuthModal.tsx` | Proxy-aware redirect URI using `window.location.origin` |
| `.env.example` | Documented `NEXT_PUBLIC_BASE_URL` for OAuth behind proxy |
---
## [0.8.0] — 2026-02-16
## [1.0.3] — 2026-02-19
### Added
> ### 📊 Logs Dashboard & Real-Time Console Viewer
>
> Unified logs interface with real-time console log viewer, file-based logging via console interception, and server initialization improvements.
- 🌐 **Official website** — [omniroute.online](https://omniroute.online) live with static site on Akamai VM + Cloudflare proxy
- 🛡️ **Comprehensive SECURITY.md** — Full codebase audit documenting 10+ security features (AES-256-GCM, prompt injection guard, PII redaction, circuit breaker, etc.)
- 📖 **Documentation tracking**`USER_GUIDE.md`, `API_REFERENCE.md`, `TROUBLESHOOTING.md` now tracked in git
- 🏷️ **Website badge** — Official website badge and links in README, npm, and Docker Hub
- 🔗 **36+ providers** — Updated provider count across documentation
### ✨ Features
### Changed
- **Logs Dashboard** — Consolidated 4-tab page at `/dashboard/logs` with Request Logs, Proxy Logs, Audit Logs, and Console tabs
- **Console Log Viewer** — Terminal-style real-time log viewer with color-coded log levels, auto-scroll, search/filtering, level filter, and 5-second polling
- **Console Interceptor** — Monkey-patches `console.log/info/warn/error/debug` at server start to capture all application output as JSON lines to `logs/application/app.log`
- **Log Rotation** — Size-based rotation and retention-based cleanup for log files
- 📦 **npm homepage** — Points to `omniroute.online` instead of GitHub
- 🐳 **Docker OCI labels** — Added `org.opencontainers.image.url` for Docker Hub
- 🔒 **Security policy**Updated supported versions, replaced email with GitHub Security Advisories
### 🔧 Improvements
- **Instrumentation consolidation** — Moved `initAuditLog()`, `cleanupExpiredLogs()`, and console interceptor initialization to Next.js `instrumentation.ts` (runs on both dev and prod server start)
- **Structured Logger file output** — `structuredLogger.ts` now also appends JSON log entries to the log file
- **Pino Logger fix** — Fixed broken mix of pino `transport` targets + manual `createWriteStream`; now uses `pino/file` transport targets exclusively with absolute paths
### 🗂️ Files Added
| File | Purpose |
| ---------------------------------------------------- | ----------------------------------------------------------------- |
| `src/app/(dashboard)/dashboard/logs/page.tsx` | Tabbed Logs Dashboard page |
| `src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx` | Audit log tab component extracted from standalone page |
| `src/shared/components/ConsoleLogViewer.tsx` | Terminal-style real-time log viewer |
| `src/app/api/logs/console/route.ts` | API endpoint to read log file (filters last 1h, level, component) |
| `src/lib/consoleInterceptor.ts` | Console method monkey-patching for file capture |
| `src/lib/logRotation.ts` | Log rotation by size and cleanup by retention days |
### 🗂️ Files Modified
| File | Change |
| --------------------------------------- | ------------------------------------------------------------------------------- |
| `src/shared/components/Sidebar.tsx` | Nav: "Request Logs" → "Logs" with `description` icon |
| `src/shared/components/Breadcrumbs.tsx` | Added breadcrumb labels for `logs`, `audit-log`, `console` |
| `src/instrumentation.ts` | Added console interceptor + audit log init + expired log cleanup |
| `src/server-init.ts` | Added console interceptor import (backup init) |
| `src/shared/utils/logger.ts` | Fixed pino file transport using `pino/file` targets |
| `src/shared/utils/structuredLogger.ts` | Added `appendFileSync` file writing + log file config |
| `.env.example` | Added `LOG_TO_FILE`, `LOG_FILE_PATH`, `LOG_MAX_FILE_SIZE`, `LOG_RETENTION_DAYS` |
### ⚙️ Configuration
New environment variables:
| Variable | Default | Description |
| -------------------- | -------------------------- | ----------------------------- |
| `LOG_TO_FILE` | `true` | Enable/disable file logging |
| `LOG_FILE_PATH` | `logs/application/app.log` | Log file path |
| `LOG_MAX_FILE_SIZE` | `50M` | Max file size before rotation |
| `LOG_RETENTION_DAYS` | `7` | Days to retain old log files |
---
## [0.7.0] — 2026-02-16
## [1.0.2] — 2026-02-18
### Added
> ### 🔒 Security Hardening, Architecture Improvements & UX Polish
>
> Comprehensive audit-driven improvements across security, architecture, testing, and user experience.
- 🐳 **Docker Hub public image**`diegosouzapw/omniroute` available on [Docker Hub](https://hub.docker.com/r/diegosouzapw/omniroute) with `latest` and versioned tags
- 🔄 **Docker CI/CD** — GitHub Actions workflow (`docker-publish.yml`) auto-builds and pushes Docker image on every release
- ☁️ **Akamai VM deployment**Nanode 1GB instance created for remote hosting
- 🎯 **Provider model filtering**Filter model suggestions by selected provider in Translator and Chat Tester
- 🔌 **CLI status badges**Extract `CliStatusBadge` component; status visible on collapsed tool cards
- ☁️ **Cloud connection UX**GET status endpoint, toast feedback, and sidebar indicator for cloud sync
- 🔐 **OAuth provider secrets**Default cloud URL and OAuth provider secrets set via environment variables
- **Edge compatibility**Replace `uuid` package with native `crypto.randomUUID()` for Cloudflare Workers compatibility
### 🛡️ Security (Phase 0)
- **Auth guard** — API route protection via `withAuth` middleware for all dashboard routes
- **CSRF protection** — Token-based CSRF guard for all state-changing API routes
- **Request payload validation** — Zod schemas for provider, combo, key, and settings endpoints
- **Prompt injection guard** — Input sanitization against malicious prompt patterns
- **Body size guard** — Route-specific body size limits with dedicated audio upload threshold
- **Rate limiter** — Per-IP rate limiting with configurable windows and thresholds
### 🏗️ Architecture (Phase 12)
- **DI container** — Simple dependency injection container for service registration
- **Policy engine** — Consolidated `PolicyEngine` for routing, security, and rate limiting
- **SQLite migration** — Database migration system with versioned migration runner
- **Graceful shutdown** — Clean server shutdown with connection draining
- **TypeScript fixes** — Resolved all `tsc` errors; removed redundant `@ts-check` directives
- **Pipeline decomposition** — `handleSingleModelChat` decomposed into composable pipeline stages
- **Prompt template versioning** — Version-tracked prompt templates with rollback support
- **Eval scheduling** — Automated evaluation suite scheduling with cron-based runner
- **Plugin architecture** — Extensible plugin system for custom middleware and handlers
### 🧪 Testing & CI (Phase 2)
- **Coverage thresholds** — Jest coverage thresholds enforced in CI (368 tests passing)
- **Proxy pipeline integration tests** — End-to-end tests for the proxy request pipeline
- **CI audit workflow** — npm audit and security scanning in GitHub Actions
- **k6 load tests** — Performance testing with ramping VUs and custom metrics
### ✨ UX & Polish (Phase 34)
- **Session management** — Session info card with login time, age, user agent, and logout
- **Focus indicators** — Global `:focus-visible` styles and `--focus-ring` CSS utility
- **Audit log viewer** — Security event audit log with structured data display
- **Dashboard cleanup** — Removed unused files, fixed Quick Start links to Endpoint page
- **Documentation** — Troubleshooting guide, deployment improvements
---
## [0.6.0] — 2026-02-16
## [1.1.0] — 2026-02-18
### Added
> ### 🔧 API Compatibility & SDK Hardening
>
> Response sanitization, role normalization, and structured output improvements for strict OpenAI SDK compatibility and cross-provider robustness.
- 💰 **Costs & Budget page** — Dedicated dashboard page for cost tracking and budget management
- 📊 **Provider metrics display** — Show per-provider usage metrics and statistics
- 📥 **Model import for passthrough providers** — Import models from API-compatible providers (Deepgram, AssemblyAI, NanoBanana)
- 🎨 **App icon redesign**New network node graph icon with updated color scheme
### 🛡️ Response Sanitization (NEW)
- **Response sanitizer module** — New `responseSanitizer.ts` strips non-standard fields (`x_groq`, `usage_breakdown`, `service_tier`, etc.) from all OpenAI-format provider responses, fixing OpenAI Python SDK v1.83+ Pydantic validation failures that returned raw strings instead of parsed `ChatCompletion` objects
- **Streaming chunk sanitization** — Passthrough streaming mode now sanitizes each SSE chunk in real-time via `sanitizeStreamingChunk()`, ensuring strict `chat.completion.chunk` schema compliance
- **ID/Object/Usage normalization** — Ensures `id`, `object`, `created`, `model`, `choices`, and `usage` fields always exist with correct types
- **Usage field cleanup** — Strips non-standard usage sub-fields, keeps only `prompt_tokens`, `completion_tokens`, `total_tokens`, and OpenAI detail fields
### 🧠 Think Tag Extraction (NEW)
- **`<think>` tag extraction** — Automatically extracts `<think>...</think>` blocks from thinking model responses (DeepSeek R1, Kimi K2 Thinking, etc.) into OpenAI's standard `reasoning_content` field
- **Streaming think-tag stripping** — Real-time `<think>` extraction in passthrough SSE stream, preventing JSON parsing errors in downstream tools
- **Preserves native reasoning** — Providers that already send `reasoning_content` natively (e.g., OpenAI o1) are not overwritten
### 🔄 Role Normalization (NEW)
- **`developer``system` conversion** — OpenAI's new `developer` role is automatically converted to `system` for all non-OpenAI providers (Claude, Gemini, Kiro, etc.)
- **`system``user` merging** — For models that reject the `system` role (GLM, ERNIE), system messages are intelligently merged into the first user message with clear delimiters
- **Model-aware normalization** — Uses model name prefix matching (`glm-*`, `ernie-*`) for compatibility decisions, avoiding hardcoded provider-level flags
### 📐 Structured Output for Gemini (NEW)
- **`response_format` → Gemini conversion** — OpenAI's `json_schema` structured output is now translated to Gemini's `responseMimeType` + `responseSchema` in the translator pipeline
- **`json_object` support** — `response_format: { type: "json_object" }` maps to Gemini's `application/json` MIME type
- **Schema cleanup** — Automatically removes unsupported JSON Schema keywords (`$schema`, `additionalProperties`) for Gemini compatibility
### 📁 Files Added
| File | Purpose |
| ---------------------------------------- | ---------------------------------------------------------------------- |
| `open-sse/handlers/responseSanitizer.ts` | Response field stripping, think-tag extraction, ID/usage normalization |
| `open-sse/services/roleNormalizer.ts` | Developer→system, system→user role conversion pipeline |
### 📁 Files Modified
| File | Change |
| ------------------------------------------------- | ------------------------------------------------------------------------------- |
| `open-sse/handlers/chatCore.ts` | Integrated response sanitizer for non-streaming OpenAI responses |
| `open-sse/utils/stream.ts` | Integrated streaming chunk sanitizer + think-tag extraction in passthrough mode |
| `open-sse/translator/index.ts` | Integrated role normalizer into the request translation pipeline |
| `open-sse/translator/request/openai-to-gemini.ts` | Added `response_format``responseMimeType`/`responseSchema` conversion |
---
## [0.5.0] — 2026-02-15
## [1.0.0] — 2026-02-18
### Added
> ### 🎉 First Major Release — OmniRoute 1.0
>
> OmniRoute is an intelligent API gateway that unifies 20+ AI providers behind a single OpenAI-compatible endpoint. This release represents the culmination of the entire development effort — from initial prototype to production-ready platform.
- 🧪 **LLM Evaluations (Evals)** — Golden set testing framework with 4 match strategies (`exact`, `contains`, `regex`, `custom`)
- 🎲 **Advanced combo strategies**`random`, `least-used`, and `cost-optimized` balancing strategies for combos
- 📊 **API key usage in Evals** — Evals tab uses API key auth for real LLM calls through the proxy
- 🏷️ **Model availability badge** — Visual indicator for model availability per provider
- 🎨 **Landing page retheme** — Updated landing page design with new aesthetic
- 🧩 **Shared UI component library** — Refactored dashboard with reusable component library
### 🧠 Core Routing & Intelligence
### Fixed
- **Smart 4-tier fallback** — Auto-routing: Subscription → Cheap → Free → Emergency
- **6 routing strategies** — Fill First, Round Robin, Power-of-Two-Choices, Random, Least Used, Cost Optimized
- **Semantic caching** — Auto-cache responses for deduplication with configurable TTL
- **Request idempotency** — Prevent duplicate processing of identical requests
- **Thinking budget validation** — Control reasoning token allocation per request
- **System prompt injection** — Configurable global system prompts for all requests
- 🐛 Fix `TypeError` in `chat/completions` `ensureInitialized` call
### 🔌 Providers & Models
- **20+ AI providers** — OpenAI, Claude (Anthropic), Gemini, GitHub Copilot, DeepSeek, Groq, xAI, Mistral, Qwen, iFlow, Kiro, OpenRouter, GLM, MiniMax, Kimi, NVIDIA NIM, and more
- **Multi-account support** — Multiple accounts per provider with automatic rotation
- **OAuth 2.0 (PKCE)** — Automatic token management and refresh for Claude Code, Codex, Gemini CLI, Copilot, Kiro
- **Auto token refresh** — Background refresh with expiry detection and unrecoverable error handling
- **Model import** — Import models from API-compatible passthrough providers
- **OpenAI-compatible validation** — Fallback validation via chat completions for providers without `/models` endpoint
- **TLS fingerprint spoofing** — Browser-like TLS fingerprinting via `wreq-js` to bypass bot detection
### 🔄 Format Translation
- **Multi-format translation** — Seamless OpenAI ↔ Claude ↔ Gemini ↔ OpenAI Responses API conversion
- **Translator Playground** — 4 interactive modes:
- **Playground** — Test format translations between any provider formats
- **Chat Tester** — Send real requests through the proxy with visual response rendering
- **Test Bench** — Automated batch testing across multiple providers
- **Live Monitor** — Real-time stream of active proxy requests and translations
### 🎯 Combos & Fallback Chains
- **Custom combos** — Create model combinations with multi-provider fallback chains
- **6 combo balancing strategies** — Fill First, Round Robin, Random, Least Used, P2C, Cost Optimized
- **Combo circuit breaker** — Auto-disable failing providers within a combo chain
### 🛡️ Resilience & Security
- **Circuit breakers** — Auto-recovery with configurable thresholds and cooldown periods
- **Exponential backoff** — Progressive retry delays for failed requests
- **Anti-thundering herd** — Mutex-based protection against concurrent retry storms
- **Rate limit detection** — Per-provider RPM, min gap, and max concurrent request tracking
- **Editable rate limits** — Configurable defaults via Settings → Resilience with persistence
- **Prompt injection guard** — Input sanitization for malicious prompt patterns
- **PII redaction** — Automatic detection and masking of personally identifiable information
- **AES-256-GCM encryption** — Credential encryption at rest
- **IP access control** — Whitelist/blacklist IP filtering
- **SOCKS5 proxy support** — Outbound proxy for upstream provider calls
### 📊 Observability & Analytics
- **Analytics dashboard** — Recharts-based SVG charts: stat cards, model usage bar chart, provider breakdown table with success rates and latency
- **Real-time health monitoring** — Provider health, rate limits, latency telemetry
- **Request logs** — Dedicated page with SQLite-persisted proxy request/response logs
- **Limits & Quotas** — Separate dashboard for quota monitoring with reset countdowns
- **Cost analytics** — Token cost tracking and budget management per provider
- **Request telemetry** — Correlation IDs, structured logging, request timing
### 💾 Database & Backup
- **Dual database** — LowDB (JSON) for config + SQLite for domain state and proxy logs
- **Export database** — `GET /api/db-backups/export` — Download SQLite database file
- **Export all** — `GET /api/db-backups/exportAll` — Full backup as `.tar.gz` archive (DB + settings + combos + providers + masked API keys)
- **Import database** — `POST /api/db-backups/import` — Upload and restore with validation, integrity check, and pre-import backup
- **Automatic backups** — Configurable backup schedule with retention
- **Storage health** — Dashboard widget with database size, path, and backup status
### 🖥️ Dashboard & UI
- **Full dashboard** — Provider management, analytics, health monitoring, settings, CLI tools
- **9 dashboard sections** — Providers, Combos, Analytics, Health, Translator, Settings, CLI Tools, Usage, Endpoint
- **Settings restructure** — 6 tabs: Security, Routing, Resilience, AI, System/Storage, Advanced
- **Shared UI component library** — Reusable components (Avatar, Badge, Button, Card, DataTable, Modal, etc.)
- **Dark/Light/System theme** — Persistent theme selection with system preference detection
- **Agent showcase grid** — Visual grid of 10 AI coding agents in README header
- **Provider logos** — Logo assets for all supported agents and providers
- **Red shield badges** — Styled badge icons across all documentation
### ☁️ Deployment & Infrastructure
- **Docker support** — Multi-stage Dockerfile with `base` and `cli` profiles
- **Docker Hub** — `diegosouzapw/omniroute` with `latest` and versioned tags
- **Docker CI/CD** — GitHub Actions auto-build and push on release
- **npm CLI package** — `npx omniroute` with auto-launch
- **npm CI/CD** — GitHub Actions auto-publish to npm on release
- **Akamai VM deployment** — Production deployment on Nanode 1GB with nginx reverse proxy
- **Cloud sync** — Sync configuration across devices via Cloudflare Worker
- **Edge compatibility** — Native `crypto.randomUUID()` for Cloudflare Workers
### 🧪 Testing & Quality
- **100% TypeScript** — Full migration of `src/` (200+ files) and `open-sse/` (94 files) — zero `@ts-ignore`, zero TypeScript errors
- **CI/CD pipeline** — GitHub Actions for lint, build, test, npm publish, Docker publish
- **Unit tests** — 20+ test suites covering domain logic, security, caching, routing
- **E2E tests** — Playwright specs for API, navigation, and responsive behavior
- **LLM evaluations** — Golden set testing framework with 4 match strategies (`exact`, `contains`, `regex`, `custom`)
- **Security tests** — CLI runtime, Docker hardening, cloud sync, and OpenAI compatibility
### 📖 Documentation
- **8 language READMEs** — English, Portuguese (pt-BR), Spanish, Russian, Chinese (zh-CN), German, French, Italian
- **VM Deployment Guide** — Complete guide (VM + Docker + nginx + Cloudflare + security)
- **Features Gallery** — 9 dashboard screenshots with descriptions
- **API Reference** — Full endpoint documentation including backup/export/import
- **User Guide** — Step-by-step setup, configuration, and usage instructions
- **Architecture docs** — System design, component decomposition, ADRs
- **OpenAPI specification** — Machine-readable API documentation
- **Troubleshooting guide** — Common issues and solutions
- **Security policy** — `SECURITY.md` with vulnerability reporting via GitHub Security Advisories
- **Roadmap** — 150+ planned features across 6 categories
### 🔌 API Endpoints
- `/v1/chat/completions` — OpenAI-compatible chat endpoint with format translation
- `/v1/embeddings` — Embedding generation
- `/v1/images/generations` — Image generation
- `/v1/models` — Model listing with provider filtering
- `/v1/rerank` — Re-ranking endpoint
- `/v1/audio/*` — Audio transcription and translation
- `/v1/moderations` — Content moderation
- `/api/db-backups/export` — Database export
- `/api/db-backups/exportAll` — Full archive export
- `/api/db-backups/import` — Database import with validation
- 30+ dashboard API routes for providers, combos, settings, analytics, health, CLI tools
---
## [0.4.0] — 2026-02-15
### Added
- 🧠 **LLM Gateway Intelligence** (Phase 9) — Smart routing, semantic caching, request idempotency, progress tracking
- 📄 **Missing flows & pages** (Phase 8) — Error pages, UX components, telemetry dashboards
- 🔧 **API & code quality** (Phase 7) — API restructuring, JSDoc documentation, code quality improvements
- 📚 **Documentation restructuring** (Phase 10) — Component decomposition, docs cleanup
-**26 action items** from critical analysis resolved
### Changed
- ♻️ **Architecture refactor** (Phase 5-6) — Domain persistence, policy engine, OAuth extraction, proxy decoupling
### Fixed
- 🐛 Fix CI build and lint failures
- 🐛 Fix ghost import in `chatHelpers.js` SSE handling
---
## [0.3.0] — 2026-02-15
### Added
-**Resilience system** — Exponential backoff, circuit breaker, anti-thundering herd mutex, Resilience UI settings page
- 🖥️ **100% frontend API coverage** — 7 implementation batches covering all backend routes
- 📊 **9 new API routes** — Budget, telemetry, compliance, tags, storage health, and more
- 🧪 **Eval framework & compliance** — ADRs, accessibility, CLI specs, Playwright test specs (46 tasks)
- 🏗️ **Pipeline integration** — 7 backend modules wired into request processing pipeline
- 🔐 **Security hardening** — Phases 0106 (input validation, CSRF, rate limiting, auth hardening)
- 🤖 **Advanced features** — Phases 0709 (domain extraction, error codes, request ID, fetch timeout)
- 🔄 **Unrecoverable token handling** — Detect and mark connections as expired on fatal refresh errors
### Changed
- ♻️ Decompose `usageDb`, `handleSingleModelChat`, and UI components for maintainability
- ⬇️ Downgrade ESLint v10 → v9 for `eslint-config-next` compatibility
---
## [0.2.0] — 2026-02-14
### Added
- 🛣️ **Advanced routing services** — Priority-based routing, global strategy configuration
- 💰 **Cost analytics dashboard** — Token cost tracking and analytics visualization
- 💎 **Pricing overhaul** — Comprehensive pricing data for all supported providers and models
- 📦 **npm badge & CLI options** — npm version badge in README, CLI options table, automated release docs
---
## [0.1.0] — 2026-02-14
### Added
- 🎉 **Initial OmniRoute release** — Rebranded from 9router with full feature set
- 🔄 **28 AI providers** — OpenAI, Claude, Gemini, Copilot, DeepSeek, Groq, xAI, Mistral, Qwen, iFlow, and more
- 🎯 **Smart fallback** — 3-tier auto-routing (Subscription → Cheap → Free)
- 🔀 **Format translation** — Seamless OpenAI ↔ Claude ↔ Gemini format conversion
- 👥 **Multi-account support** — Multiple accounts per provider with round-robin
- 🔐 **OAuth 2.0 (PKCE)** — Automatic token management and refresh
- 📊 **Usage tracking** — Real-time quota monitoring with reset countdown
- 🎨 **Custom combos** — Create model combinations with fallback chains
- ☁️ **Cloud sync** — Sync configuration across devices via Cloudflare Worker
- 📖 **OpenAPI specification** — Full API documentation
- 🛡️ **SOCKS5 proxy support** — Outbound proxy for upstream provider calls
- 🔌 **New endpoints**`/v1/rerank`, `/v1/audio/*`, `/v1/moderations`
- 📦 **npm CLI package**`npm install -g omniroute` with auto-launch
- 🐳 **Docker support** — Multi-stage Dockerfile with `base` and `cli` profiles
- 🔒 **Security policy**`SECURITY.md` with vulnerability reporting guidelines
- 🧪 **CI/CD pipeline** — GitHub Actions for lint, build, test, and npm publish
---
[0.8.5]: https://github.com/diegosouzapw/OmniRoute/compare/v0.8.0...v0.8.5
[0.8.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v0.1.0
[1.0.4]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.4
[1.1.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.1.0
[1.0.3]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.3
[1.0.2]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.2
[1.0.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.0
+1 -1
View File
@@ -159,7 +159,7 @@ src/ # TypeScript (.ts / .tsx)
│ ├── cacheLayer.ts # LRU cache
│ ├── semanticCache.ts # Semantic response cache
│ ├── idempotencyLayer.ts # Request deduplication
│ └── localDb.ts # LowDB (JSON) storage
│ └── localDb.ts # Settings facade (LowDB for config, SQLite for domain data)
├── shared/
│ ├── components/ # React components (.tsx)
│ ├── middleware/ # Correlation IDs, etc.
+1000
View File
File diff suppressed because it is too large Load Diff
+999
View File
@@ -0,0 +1,999 @@
<div align="center">
<img src="./docs/screenshots/MainOmniRoute.png" alt="OmniRoute Dashboard" width="800"/>
# 🚀 OmniRoute — El Gateway de IA Gratuito
### Nunca dejes de programar. Enrutamiento inteligente hacia **modelos de IA GRATUITOS y económicos** con fallback automático.
_Tu proxy de API universal — un endpoint, 36+ proveedores, cero tiempo de inactividad._
**Chat Completions • Embeddings • Generación de Imágenes • Audio • Reranking • 100% TypeScript**
---
### 🤖 Proveedor de IA Gratuito para tus agentes de programación favoritos
_Conecta cualquier IDE o herramienta CLI con IA a través de OmniRoute — gateway de API gratuito para programación ilimitada._
<table>
<tr>
<td align="center" width="110">
<a href="https://github.com/cline/cline">
<img src="./public/providers/openclaw.png" alt="OpenClaw" width="48"/><br/>
<b>OpenClaw</b>
</a><br/>
<sub>⭐ 205K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/HKUDS/nanobot">
<img src="./public/providers/nanobot.png" alt="NanoBot" width="48"/><br/>
<b>NanoBot</b>
</a><br/>
<sub>⭐ 20.9K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/sipeed/picoclaw">
<img src="./public/providers/picoclaw.jpg" alt="PicoClaw" width="48"/><br/>
<b>PicoClaw</b>
</a><br/>
<sub>⭐ 14.6K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/zeroclaw-labs/zeroclaw">
<img src="./public/providers/zeroclaw.png" alt="ZeroClaw" width="48"/><br/>
<b>ZeroClaw</b>
</a><br/>
<sub>⭐ 9.9K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/nearai/ironclaw">
<img src="./public/providers/ironclaw.png" alt="IronClaw" width="48"/><br/>
<b>IronClaw</b>
</a><br/>
<sub>⭐ 2.1K</sub>
</td>
</tr>
<tr>
<td align="center" width="110">
<a href="https://github.com/anomalyco/opencode">
<img src="./public/providers/opencode.svg" alt="OpenCode" width="48"/><br/>
<b>OpenCode</b>
</a><br/>
<sub>⭐ 106K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/openai/codex">
<img src="./public/providers/codex.png" alt="Codex CLI" width="48"/><br/>
<b>Codex CLI</b>
</a><br/>
<sub>⭐ 60.8K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/anthropics/claude-code">
<img src="./public/providers/claude.png" alt="Claude Code" width="48"/><br/>
<b>Claude Code</b>
</a><br/>
<sub>⭐ 67.3K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/google-gemini/gemini-cli">
<img src="./public/providers/gemini-cli.png" alt="Gemini CLI" width="48"/><br/>
<b>Gemini CLI</b>
</a><br/>
<sub>⭐ 94.7K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/Kilo-Org/kilocode">
<img src="./public/providers/kilocode.png" alt="Kilo Code" width="48"/><br/>
<b>Kilo Code</b>
</a><br/>
<sub>⭐ 15.5K</sub>
</td>
</tr>
</table>
<sub>📡 Todos los agentes se conectan vía <code>http://localhost:20128/v1</code> o <code>http://cloud.omniroute.online/v1</code> — una configuración, modelos y cuota ilimitados</sub>
---
[![npm version](https://img.shields.io/npm/v/omniroute?color=cb3837&logo=npm)](https://www.npmjs.com/package/omniroute)
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 Website](https://omniroute.online) • [🚀 Inicio Rápido](#-inicio-rápido) • [💡 Características](#-características-principales) • [📖 Docs](#-documentación) • [💰 Precios](#-precios-resumidos)
🌐 **Disponible en:** [English](README.md) | [Português](README.pt-BR.md) | [Español](README.es.md) | [Русский](README.ru.md) | [中文](README.zh-CN.md) | [Deutsch](README.de.md) | [Français](README.fr.md) | [Italiano](README.it.md)
</div>
---
## 🤔 ¿Por qué OmniRoute?
**Deja de desperdiciar dinero y chocar con límites:**
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> La cuota de suscripción expira sin usar cada mes
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> Los límites de tasa te detienen en medio de la programación
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> APIs caras ($20-50/mes por proveedor)
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> Cambiar manualmente entre proveedores
**OmniRoute resuelve esto:**
-**Maximiza suscripciones** - Rastrea cuotas, usa cada bit antes del reset
-**Fallback automático** - Suscripción → API Key → Barato → Gratuito, cero tiempo de inactividad
-**Multi-cuenta** - Round-robin entre cuentas por proveedor
-**Universal** - Funciona con Claude Code, Codex, Gemini CLI, Cursor, Cline, OpenClaw, cualquier herramienta CLI
---
## 🔄 Cómo Funciona
```
┌─────────────┐
│ Tu CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
│ Tool │
└──────┬──────┘
│ http://localhost:20128/v1
┌─────────────────────────────────────────┐
│ OmniRoute (Enrutador Inteligente) │
│ • Traducción de formato (OpenAI ↔ Claude) │
│ • Rastreo de cuota + Embeddings + Imágenes │
│ • Renovación automática de tokens │
└──────┬──────────────────────────────────┘
├─→ [Tier 1: SUSCRIPCIÓN] Claude Code, Codex, Gemini CLI
│ ↓ cuota agotada
├─→ [Tier 2: API KEY] DeepSeek, Groq, xAI, Mistral, NVIDIA NIM, etc.
│ ↓ límite de presupuesto
├─→ [Tier 3: BARATO] GLM ($0.6/1M), MiniMax ($0.2/1M)
│ ↓ límite de presupuesto
└─→ [Tier 4: GRATUITO] iFlow, Qwen, Kiro (ilimitado)
Resultado: Nunca dejes de programar, costo mínimo
```
---
## ⚡ Inicio Rápido
**1. Instala globalmente:**
```bash
npm install -g omniroute
omniroute
```
🎉 El Dashboard se abre en `http://localhost:20128`
| Comando | Descripción |
| ----------------------- | ---------------------------------------------- |
| `omniroute` | Iniciar servidor (puerto predeterminado 20128) |
| `omniroute --port 3000` | Usar puerto personalizado |
| `omniroute --no-open` | No abrir navegador automáticamente |
| `omniroute --help` | Mostrar ayuda |
**2. Conecta un proveedor GRATUITO:**
Dashboard → Proveedores → Conectar **Claude Code** o **Antigravity** → Login OAuth → ¡Listo!
**3. Usa en tu herramienta CLI:**
```
Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline Configuración:
Endpoint: http://localhost:20128/v1
API Key: [copiar del dashboard]
Model: if/kimi-k2-thinking
```
**¡Eso es todo!** Comienza a programar con modelos de IA GRATUITOS.
**Alternativa — ejecutar desde código fuente:**
```bash
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
---
## 🐳 Docker
OmniRoute está disponible como imagen Docker pública en [Docker Hub](https://hub.docker.com/r/diegosouzapw/omniroute).
**Ejecución rápida:**
```bash
docker run -d \
--name omniroute \
--restart unless-stopped \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
**Con archivo de entorno:**
```bash
# Copia y edita el .env primero
cp .env.example .env
docker run -d \
--name omniroute \
--restart unless-stopped \
--env-file .env \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
**Usando Docker Compose:**
```bash
# Perfil base (sin herramientas CLI)
docker compose --profile base up -d
# Perfil CLI (Claude Code, Codex, OpenClaw integrados)
docker compose --profile cli up -d
```
| Imagen | Tag | Tamaño | Descripción |
| ------------------------ | -------- | ------ | ---------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Última versión estable |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | Versión actual |
---
## 💰 Precios Resumidos
| Tier | Proveedor | Costo | Reset de Cuota | Mejor Para |
| ------------------ | ----------------- | ---------------------------- | ----------------- | ----------------------- |
| **💳 SUSCRIPCIÓN** | Claude Code (Pro) | $20/mes | 5h + semanal | Ya suscrito |
| | Codex (Plus/Pro) | $20-200/mes | 5h + semanal | Usuarios OpenAI |
| | Gemini CLI | **GRATUITO** | 180K/mes + 1K/día | ¡Todos! |
| | GitHub Copilot | $10-19/mes | Mensual | Usuarios GitHub |
| **🔑 API KEY** | NVIDIA NIM | **GRATUITO** (1000 créditos) | Único | Pruebas gratuitas |
| | DeepSeek | Por uso | Ninguno | Mejor precio/calidad |
| | Groq | Tier gratuito + pago | Limitado | Inferencia ultra-rápida |
| | xAI (Grok) | Por uso | Ninguno | Modelos Grok |
| | Mistral | Tier gratuito + pago | Limitado | IA Europea |
| | OpenRouter | Por uso | Ninguno | 100+ modelos |
| **💰 BARATO** | GLM-4.7 | $0.6/1M | Diario 10h | Respaldo económico |
| | MiniMax M2.1 | $0.2/1M | Rotativo 5h | Opción más barata |
| | Kimi K2 | $9/mes fijo | 10M tokens/mes | Costo predecible |
| **🆓 GRATUITO** | iFlow | $0 | Ilimitado | 8 modelos gratuitos |
| | Qwen | $0 | Ilimitado | 3 modelos gratuitos |
| | Kiro | $0 | Ilimitado | Claude gratuito |
**💡 Consejo Pro:** ¡Comienza con Gemini CLI (180K gratis/mes) + iFlow (ilimitado gratis) = $0 de costo!
---
## 🎯 Casos de Uso
### Caso 1: "Tengo suscripción Claude Pro"
**Problema:** La cuota expira sin usar, límites de tasa durante programación intensa
```
Combo: "maximize-claude"
1. cc/claude-opus-4-6 (usar suscripción al máximo)
2. glm/glm-4.7 (respaldo barato cuando la cuota se agota)
3. if/kimi-k2-thinking (fallback de emergencia gratuito)
Costo mensual: $20 (suscripción) + ~$5 (respaldo) = $25 total
vs. $20 + chocar con límites = frustración
```
### Caso 2: "Quiero costo cero"
**Problema:** No puede pagar suscripciones, necesita IA confiable para programar
```
Combo: "free-forever"
1. gc/gemini-3-flash (180K gratis/mes)
2. if/kimi-k2-thinking (ilimitado gratis)
3. qw/qwen3-coder-plus (ilimitado gratis)
Costo mensual: $0
Calidad: Modelos listos para producción
```
### Caso 3: "Necesito programar 24/7, sin interrupciones"
**Problema:** Plazos ajustados, no puede permitirse tiempo de inactividad
```
Combo: "always-on"
1. cc/claude-opus-4-6 (mejor calidad)
2. cx/gpt-5.2-codex (segunda suscripción)
3. glm/glm-4.7 (barato, reset diario)
4. minimax/MiniMax-M2.1 (más barato, reset 5h)
5. if/kimi-k2-thinking (gratuito ilimitado)
Resultado: 5 capas de fallback = cero tiempo de inactividad
```
### Caso 4: "Quiero IA GRATUITA en OpenClaw"
**Problema:** Necesita asistente de IA en apps de mensajería, completamente gratuito
```
Combo: "openclaw-free"
1. if/glm-4.7 (ilimitado gratis)
2. if/minimax-m2.1 (ilimitado gratis)
3. if/kimi-k2-thinking (ilimitado gratis)
Costo mensual: $0
Acceso vía: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
```
---
## 💡 Características Principales
### 🧠 Enrutamiento e Inteligencia
| Característica | Qué Hace |
| -------------------------------------- | ------------------------------------------------------------------------------- |
| 🎯 **Fallback Inteligente 4 Tiers** | Auto-enrutamiento: Suscripción → API Key → Barato → Gratuito |
| 📊 **Rastreo de Cuota en Tiempo Real** | Conteo de tokens en vivo + countdown de reset por proveedor |
| 🔄 **Traducción de Formato** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro transparente |
| 👥 **Soporte Multi-Cuenta** | Múltiples cuentas por proveedor con selección inteligente |
| 🔄 **Renovación Automática de Token** | Tokens OAuth se renuevan automáticamente con reintentos |
| 🎨 **Combos Personalizados** | 6 estrategias: fill-first, round-robin, p2c, random, least-used, cost-optimized |
| 🧩 **Modelos Personalizados** | Agrega cualquier ID de modelo a cualquier proveedor |
| 🌐 **Enrutador Wildcard** | Enruta patrones `provider/*` a cualquier proveedor dinámicamente |
| 🧠 **Presupuesto de Razonamiento** | Modos passthrough, auto, custom y adaptativo para modelos de razonamiento |
| 💬 **Inyección de System Prompt** | System prompt global aplicado en todas las solicitudes |
| 📄 **API Responses** | Soporte completo de la API Responses de OpenAI (`/v1/responses`) para Codex |
### 🎵 APIs Multi-Modal
| Característica | Qué Hace |
| ----------------------------- | ------------------------------------------------------ |
| 🖼️ **Generación de Imágenes** | `/v1/images/generations` — 4 proveedores, 9+ modelos |
| 📐 **Embeddings** | `/v1/embeddings` — 6 proveedores, 9+ modelos |
| 🎤 **Transcripción de Audio** | `/v1/audio/transcriptions` — Compatible con Whisper |
| 🔊 **Texto a Voz** | `/v1/audio/speech` — Síntesis de audio multi-proveedor |
| 🛡️ **Moderaciones** | `/v1/moderations` — Verificaciones de seguridad |
| 🔀 **Reranking** | `/v1/rerank` — Reranking de relevancia de documentos |
### 🛡️ Resiliencia y Seguridad
| Característica | Qué Hace |
| ---------------------------------- | ---------------------------------------------------------------- |
| 🔌 **Circuit Breaker** | Auto-apertura/cierre por proveedor con umbrales configurables |
| 🛡️ **Anti-Thundering Herd** | Mutex + semáforo rate-limit para proveedores con API key |
| 🧠 **Caché Semántico** | Caché de dos niveles (firma + semántico) reduce costo y latencia |
| ⚡ **Idempotencia de Solicitud** | Ventana de dedup de 5s para solicitudes duplicadas |
| 🔒 **Spoofing de Fingerprint TLS** | Bypass de detección de bot vía TLS con wreq-js |
| 🌐 **Filtrado de IP** | Allowlist/blocklist para control de acceso a la API |
| 📊 **Rate Limits Editables** | RPM, gap mínimo y concurrencia máxima configurables |
### 📊 Observabilidad y Analytics
| Característica | Qué Hace |
| ------------------------------ | --------------------------------------------------------------------- |
| 📝 **Logs de Solicitud** | Modo debug con logs completos de request/response |
| 💾 **Logs SQLite** | Logs de proxy persistentes sobreviven a reinicios |
| 📊 **Dashboard de Analytics** | Recharts: cards de estadísticas, gráfico de uso, tabla de proveedores |
| 📈 **Rastreo de Progreso** | Eventos de progreso SSE opt-in para streaming |
| 🧪 **Evaluaciones de LLM** | Pruebas con conjunto golden y 4 estrategias de match |
| 🔍 **Telemetría de Solicitud** | Agregación de latencia p50/p95/p99 + rastreo X-Request-Id |
| 📋 **Logs + Cuotas** | Páginas dedicadas para navegación de logs y rastreo de cuotas |
| 🏥 **Dashboard de Salud** | Uptime, estados de circuit breaker, lockouts, stats de caché |
| 💰 **Rastreo de Costos** | Gestión de presupuesto + configuración de precios por modelo |
### ☁️ Deploy y Sincronización
| Característica | Qué Hace |
| --------------------------------- | ------------------------------------------------------------------------------- |
| 💾 **Cloud Sync** | Sincroniza configuraciones entre dispositivos vía Cloudflare Workers |
| 🌐 **Deploy en Cualquier Lugar** | Localhost, VPS, Docker, Cloudflare Workers |
| 🔑 **Gestión de API Keys** | Genera, rota y define alcance de API keys por proveedor |
| 🧙 **Asistente de Configuración** | Setup guiado en 4 pasos para nuevos usuarios |
| 🔧 **Dashboard CLI Tools** | Configuración en un clic para Claude, Codex, Cline, OpenClaw, Kilo, Antigravity |
| 🔄 **Backups de DB** | Backup y restauración automáticos de todas las configuraciones |
<details>
<summary><b>📖 Detalles de Características</b></summary>
### 🎯 Fallback Inteligente 4 Tiers
Crea combos con fallback automático:
```
Combo: "my-coding-stack"
1. cc/claude-opus-4-6 (tu suscripción)
2. nvidia/llama-3.3-70b (API NVIDIA gratuita)
3. glm/glm-4.7 (respaldo barato, $0.6/1M)
4. if/kimi-k2-thinking (fallback gratuito)
→ Cambia automáticamente cuando la cuota se agota o ocurren errores
```
### 📊 Rastreo de Cuota en Tiempo Real
- Consumo de tokens por proveedor
- Countdown de reset (5 horas, diario, semanal)
- Estimación de costo para tiers pagos
- Reportes de gastos mensuales
### 🔄 Traducción de Formato
Traducción transparente entre formatos:
- **OpenAI** ↔ **Claude****Gemini****OpenAI Responses**
- Tu herramienta CLI envía formato OpenAI → OmniRoute traduce → El proveedor recibe formato nativo
- Funciona con cualquier herramienta que soporte endpoints OpenAI personalizados
### 👥 Soporte Multi-Cuenta
- Agrega múltiples cuentas por proveedor
- Round-robin automático o enrutamiento por prioridad
- Fallback a la siguiente cuenta cuando una alcanza la cuota
### 🔄 Renovación Automática de Token
- Los tokens OAuth se renuevan automáticamente antes de expirar
- Sin necesidad de re-autenticación manual
- Experiencia transparente en todos los proveedores
### 🎨 Combos Personalizados
- Crea combinaciones ilimitadas de modelos
- 6 estrategias: fill-first, round-robin, power-of-two-choices, random, least-used, cost-optimized
- Comparte combos entre dispositivos con Cloud Sync
### 🏥 Dashboard de Salud
- Estado del sistema (uptime, versión, uso de memoria)
- Estados de circuit breaker por proveedor (Closed/Open/Half-Open)
- Estado de rate limit y lockouts activos
- Estadísticas de caché de firma
- Telemetría de latencia (p50/p95/p99) + caché de prompt
- Reset de salud con un clic
### 🔧 Playground del Traductor
- Debug, prueba y visualiza traducciones de formato de API
- Envía solicitudes y ve cómo OmniRoute traduce entre formatos de proveedores
- Invaluable para troubleshooting de problemas de integración
### 💾 Cloud Sync
- Sincroniza proveedores, combos y configuraciones entre dispositivos
- Sincronización automática en segundo plano
- Almacenamiento cifrado seguro
</details>
---
## 📖 Guía de Configuración
<details>
<summary><b>💳 Proveedores por Suscripción</b></summary>
### Claude Code (Pro/Max)
```bash
Dashboard → Proveedores → Conectar Claude Code
→ Login OAuth → Renovación automática de token
→ Rastreo de cuota 5h + semanal
Modelos:
cc/claude-opus-4-6
cc/claude-sonnet-4-5-20250929
cc/claude-haiku-4-5-20251001
```
**Consejo Pro:** Usa Opus para tareas complejas, Sonnet para velocidad. ¡OmniRoute rastrea cuota por modelo!
### OpenAI Codex (Plus/Pro)
```bash
Dashboard → Proveedores → Conectar Codex
→ Login OAuth (puerto 1455)
→ Reset 5h + semanal
Modelos:
cx/gpt-5.2-codex
cx/gpt-5.1-codex-max
```
### Gemini CLI (¡GRATUITO 180K/mes!)
```bash
Dashboard → Proveedores → Conectar Gemini CLI
→ Google OAuth
→ 180K completions/mes + 1K/día
Modelos:
gc/gemini-3-flash-preview
gc/gemini-2.5-pro
```
**Mejor Valor:** ¡Tier gratuito enorme! Úsalo antes de los tiers pagos.
### GitHub Copilot
```bash
Dashboard → Proveedores → Conectar GitHub
→ OAuth vía GitHub
→ Reset mensual (1ro del mes)
Modelos:
gh/gpt-5
gh/claude-4.5-sonnet
gh/gemini-3-pro
```
</details>
<details>
<summary><b>🔑 Proveedores por API Key</b></summary>
### NVIDIA NIM (¡GRATUITO 1000 créditos!)
1. Regístrate: [build.nvidia.com](https://build.nvidia.com)
2. Obtén API key gratuita (1000 créditos de inferencia incluidos)
3. Dashboard → Agregar Proveedor → NVIDIA NIM:
- API Key: `nvapi-your-key`
**Modelos:** `nvidia/llama-3.3-70b-instruct`, `nvidia/mistral-7b-instruct`, y 50+ más
**Consejo Pro:** ¡API compatible con OpenAI — funciona perfectamente con la traducción de formato de OmniRoute!
### DeepSeek
1. Regístrate: [platform.deepseek.com](https://platform.deepseek.com)
2. Obtén API key
3. Dashboard → Agregar Proveedor → DeepSeek
**Modelos:** `deepseek/deepseek-chat`, `deepseek/deepseek-coder`
### Groq (¡Tier Gratuito Disponible!)
1. Regístrate: [console.groq.com](https://console.groq.com)
2. Obtén API key (tier gratuito incluido)
3. Dashboard → Agregar Proveedor → Groq
**Modelos:** `groq/llama-3.3-70b`, `groq/mixtral-8x7b`
**Consejo Pro:** ¡Inferencia ultra-rápida — mejor para programación en tiempo real!
### OpenRouter (100+ Modelos)
1. Regístrate: [openrouter.ai](https://openrouter.ai)
2. Obtén API key
3. Dashboard → Agregar Proveedor → OpenRouter
**Modelos:** Accede a 100+ modelos de todos los principales proveedores a través de una única API key.
</details>
<details>
<summary><b>💰 Proveedores Baratos (Respaldo)</b></summary>
### GLM-4.7 (Reset diario, $0.6/1M)
1. Regístrate: [Zhipu AI](https://open.bigmodel.cn/)
2. Obtén API key del Plan Coding
3. Dashboard → Agregar API Key:
- Proveedor: `glm`
- API Key: `your-key`
**Usa:** `glm/glm-4.7`
**Consejo Pro:** ¡El Plan Coding ofrece 3× cuota a 1/7 del costo! Reset diario 10:00 AM.
### MiniMax M2.1 (Reset 5h, $0.20/1M)
1. Regístrate: [MiniMax](https://www.minimax.io/)
2. Obtén API key
3. Dashboard → Agregar API Key
**Usa:** `minimax/MiniMax-M2.1`
**Consejo Pro:** ¡Opción más barata para contexto largo (1M tokens)!
### Kimi K2 ($9/mes fijo)
1. Suscríbete: [Moonshot AI](https://platform.moonshot.ai/)
2. Obtén API key
3. Dashboard → Agregar API Key
**Usa:** `kimi/kimi-latest`
**Consejo Pro:** ¡$9/mes fijo por 10M tokens = $0.90/1M de costo efectivo!
</details>
<details>
<summary><b>🆓 Proveedores GRATUITOS (Respaldo de Emergencia)</b></summary>
### iFlow (8 modelos GRATUITOS)
```bash
Dashboard → Conectar iFlow
→ Login OAuth iFlow
→ Uso ilimitado
Modelos:
if/kimi-k2-thinking
if/qwen3-coder-plus
if/glm-4.7
if/minimax-m2
if/deepseek-r1
```
### Qwen (3 modelos GRATUITOS)
```bash
Dashboard → Conectar Qwen
→ Autorización por código de dispositivo
→ Uso ilimitado
Modelos:
qw/qwen3-coder-plus
qw/qwen3-coder-flash
```
### Kiro (Claude GRATUITO)
```bash
Dashboard → Conectar Kiro
→ AWS Builder ID o Google/GitHub
→ Uso ilimitado
Modelos:
kr/claude-sonnet-4.5
kr/claude-haiku-4.5
```
</details>
<details>
<summary><b>🎨 Crear Combos</b></summary>
### Ejemplo 1: Maximizar Suscripción → Respaldo Barato
```
Dashboard → Combos → Crear Nuevo
Nombre: premium-coding
Modelos:
1. cc/claude-opus-4-6 (Suscripción primaria)
2. glm/glm-4.7 (Respaldo barato, $0.6/1M)
3. minimax/MiniMax-M2.1 (Fallback más barato, $0.20/1M)
Usa en CLI: premium-coding
```
### Ejemplo 2: Solo Gratuito (Costo Cero)
```
Nombre: free-combo
Modelos:
1. gc/gemini-3-flash-preview (180K gratis/mes)
2. if/kimi-k2-thinking (ilimitado)
3. qw/qwen3-coder-plus (ilimitado)
Costo: ¡$0 para siempre!
```
</details>
<details>
<summary><b>🔧 Integración CLI</b></summary>
### Cursor IDE
```
Configuración → Modelos → Avanzado:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [del dashboard OmniRoute]
Model: cc/claude-opus-4-6
```
### Claude Code
Usa la página **CLI Tools** en el dashboard para configuración en un clic, o edita `~/.claude/settings.json` manualmente.
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
export OPENAI_API_KEY="your-omniroute-api-key"
codex "your prompt"
```
### OpenClaw
**Opción 1 — Dashboard (recomendado):**
```
Dashboard → CLI Tools → OpenClaw → Seleccionar Modelo → Aplicar
```
**Opción 2 — Manual:** Edita `~/.openclaw/openclaw.json`:
```json
{
"models": {
"providers": {
"omniroute": {
"baseUrl": "http://127.0.0.1:20128/v1",
"apiKey": "sk_omniroute",
"api": "openai-completions"
}
}
}
}
```
> **Nota:** OpenClaw solo funciona con OmniRoute local. Usa `127.0.0.1` en lugar de `localhost` para evitar problemas de resolución IPv6.
### Cline / Continue / RooCode
```
Configuración → Configuración de API:
Proveedor: OpenAI Compatible
Base URL: http://localhost:20128/v1
API Key: [del dashboard OmniRoute]
Model: if/kimi-k2-thinking
```
</details>
---
## 📊 Modelos Disponibles
<details>
<summary><b>Ver todos los modelos disponibles</b></summary>
**Claude Code (`cc/`)** - Pro/Max:
- `cc/claude-opus-4-6`
- `cc/claude-sonnet-4-5-20250929`
- `cc/claude-haiku-4-5-20251001`
**Codex (`cx/`)** - Plus/Pro:
- `cx/gpt-5.2-codex`
- `cx/gpt-5.1-codex-max`
**Gemini CLI (`gc/`)** - GRATUITO:
- `gc/gemini-3-flash-preview`
- `gc/gemini-2.5-pro`
**GitHub Copilot (`gh/`)**:
- `gh/gpt-5`
- `gh/claude-4.5-sonnet`
**NVIDIA NIM (`nvidia/`)** - Créditos GRATUITOS:
- `nvidia/llama-3.3-70b-instruct`
- `nvidia/mistral-7b-instruct`
- 50+ más modelos en [build.nvidia.com](https://build.nvidia.com)
**GLM (`glm/`)** - $0.6/1M:
- `glm/glm-4.7`
**MiniMax (`minimax/`)** - $0.2/1M:
- `minimax/MiniMax-M2.1`
**iFlow (`if/`)** - GRATUITO:
- `if/kimi-k2-thinking`
- `if/qwen3-coder-plus`
- `if/deepseek-r1`
- `if/glm-4.7`
- `if/minimax-m2`
**Qwen (`qw/`)** - GRATUITO:
- `qw/qwen3-coder-plus`
- `qw/qwen3-coder-flash`
**Kiro (`kr/`)** - GRATUITO:
- `kr/claude-sonnet-4.5`
- `kr/claude-haiku-4.5`
**OpenRouter (`or/`)** - 100+ modelos:
- `or/anthropic/claude-4-sonnet`
- `or/google/gemini-2.5-pro`
- Cualquier modelo de [openrouter.ai/models](https://openrouter.ai/models)
</details>
---
## 🧪 Evaluaciones (Evals)
OmniRoute incluye un framework de evaluación integrado para probar la calidad de respuestas de LLM contra un conjunto golden. Accede vía **Analytics → Evals** en el dashboard.
### Conjunto Golden Integrado
El "OmniRoute Golden Set" precargado contiene 10 casos de prueba que cubren:
- Saludos, matemáticas, geografía, generación de código
- Conformidad de formato JSON, traducción, markdown
- Rechazo de seguridad (contenido dañino), conteo, lógica booleana
### Estrategias de Evaluación
| Estrategia | Descripción | Ejemplo |
| ---------- | ---------------------------------------------------- | -------------------------------- |
| `exact` | La salida debe coincidir exactamente | `"4"` |
| `contains` | La salida debe contener subcadena (case-insensitive) | `"Paris"` |
| `regex` | La salida debe coincidir con el patrón regex | `"1.*2.*3"` |
| `custom` | Función JS personalizada retorna true/false | `(output) => output.length > 10` |
---
## 🐛 Solución de Problemas
<details>
<summary><b>Haz clic para expandir la guía de solución de problemas</b></summary>
**"Language model did not provide messages"**
- Cuota del proveedor agotada → Verifica el rastreador de cuota en el dashboard
- Solución: Usa combo con fallback o cambia a tier más barato
**Rate limiting**
- Cuota de suscripción agotada → Fallback a GLM/MiniMax
- Agrega combo: `cc/claude-opus-4-6 → glm/glm-4.7 → if/kimi-k2-thinking`
**Token OAuth expirado**
- Renovado automáticamente por OmniRoute
- Si persiste: Dashboard → Proveedor → Reconectar
**Costos altos**
- Verifica estadísticas de uso en Dashboard → Costos
- Cambia modelo primario a GLM/MiniMax
- Usa tier gratuito (Gemini CLI, iFlow) para tareas no críticas
**Dashboard se abre en el puerto equivocado**
- Establece `PORT=20128` y `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
**Errores de cloud sync**
- Verifica que `BASE_URL` apunte a tu instancia en ejecución
- Verifica que `CLOUD_URL` apunte a tu endpoint cloud esperado
- Mantén los valores `NEXT_PUBLIC_*` alineados con los valores del servidor
**Primer login no funciona**
- Verifica `INITIAL_PASSWORD` en `.env`
- Si no está definido, la contraseña predeterminada es `123456`
**Sin logs de solicitud**
- Establece `ENABLE_REQUEST_LOGS=true` en `.env`
**Prueba de conexión muestra "Invalid" para proveedores compatibles con OpenAI**
- Muchos proveedores no exponen el endpoint `/models`
- OmniRoute v1.0.4+ incluye validación vía chat completions como fallback
- Asegúrate de que la URL base incluya el sufijo `/v1`
</details>
---
## 🛠️ Stack Tecnológico
- **Runtime**: Node.js 20+
- **Lenguaje**: TypeScript 5.9 — **100% TypeScript** en `src/` y `open-sse/` (v1.0.4)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Base de Datos**: LowDB (JSON) + SQLite (estado del dominio + logs de proxy)
- **Streaming**: Server-Sent Events (SSE)
- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys
- **Testing**: Node.js test runner (368+ tests unitarios)
- **CI/CD**: GitHub Actions (publicación automática npm + Docker Hub en release)
- **Website**: [omniroute.online](https://omniroute.online)
- **Paquete**: [npmjs.com/package/omniroute](https://www.npmjs.com/package/omniroute)
- **Docker**: [hub.docker.com/r/diegosouzapw/omniroute](https://hub.docker.com/r/diegosouzapw/omniroute)
- **Resiliencia**: Circuit breaker, backoff exponencial, anti-thundering herd, spoofing TLS
---
## 📖 Documentación
| Documento | Descripción |
| ------------------------------------------------ | -------------------------------------------------- |
| [Guía del Usuario](docs/USER_GUIDE.md) | Proveedores, combos, integración CLI, deploy |
| [Referencia de API](docs/API_REFERENCE.md) | Todos los endpoints con ejemplos |
| [Solución de Problemas](docs/TROUBLESHOOTING.md) | Problemas comunes y soluciones |
| [Arquitectura](docs/ARCHITECTURE.md) | Arquitectura del sistema e internos |
| [Contribuir](CONTRIBUTING.md) | Setup de desarrollo y directrices |
| [Spec OpenAPI](docs/openapi.yaml) | Especificación OpenAPI 3.0 |
| [Política de Seguridad](SECURITY.md) | Reportar vulnerabilidades y prácticas de seguridad |
---
## 📧 Soporte
> 💬 **¡Únete a la comunidad!** [Grupo WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Obtén ayuda, comparte consejos y mantente al día.
- **Website**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp**: [Grupo de la Comunidad](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **Proyecto Original**: [9router por decolua](https://github.com/decolua/9router)
---
## 👥 Contribuidores
[![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=100&columns=20&anon=1)](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
### Cómo Contribuir
1. Haz fork del repositorio
2. Crea tu rama de funcionalidad (`git checkout -b feature/amazing-feature`)
3. Haz commit de tus cambios (`git commit -m 'Add amazing feature'`)
4. Haz push a la rama (`git push origin feature/amazing-feature`)
5. Abre un Pull Request
Consulta [CONTRIBUTING.md](CONTRIBUTING.md) para directrices detalladas.
### Lanzar una Nueva Versión
```bash
# Crea un release — la publicación en npm ocurre automáticamente
gh release create v1.0.4 --title "v1.0.4" --generate-notes
```
---
## 📊 Historial de Stars
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
---
## 🙏 Agradecimientos
Agradecimiento especial a **[9router](https://github.com/decolua/9router)** por **[decolua](https://github.com/decolua)** — el proyecto original que inspiró este fork. OmniRoute se construye sobre esa increíble base con características adicionales, APIs multi-modal y una reescritura completa en TypeScript.
Agradecimiento especial a **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — la implementación original en Go que inspiró esta adaptación a JavaScript.
---
## 📄 Licencia
Licencia MIT - consulta [LICENSE](LICENSE) para detalles.
---
<div align="center">
<sub>Hecho con ❤️ para desarrolladores que programan 24/7</sub>
<br/>
<sub><a href="https://omniroute.online">omniroute.online</a></sub>
</div>
+999
View File
@@ -0,0 +1,999 @@
<div align="center">
<img src="./docs/screenshots/MainOmniRoute.png" alt="OmniRoute Dashboard" width="800"/>
# 🚀 OmniRoute — La Passerelle IA Gratuite
### N'arrêtez jamais de coder. Routage intelligent vers des **modèles IA GRATUITS et économiques** avec fallback automatique.
_Votre proxy API universel — un endpoint, 36+ fournisseurs, zéro temps d'arrêt._
**Chat Completions • Embeddings • Génération d'images • Audio • Reranking • 100% TypeScript**
---
### 🤖 Fournisseur IA gratuit pour vos agents de programmation préférés
_Connectez n'importe quel IDE ou outil CLI alimenté par l'IA via OmniRoute — passerelle API gratuite pour un codage illimité._
<table>
<tr>
<td align="center" width="110">
<a href="https://github.com/cline/cline">
<img src="./public/providers/openclaw.png" alt="OpenClaw" width="48"/><br/>
<b>OpenClaw</b>
</a><br/>
<sub>⭐ 205K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/HKUDS/nanobot">
<img src="./public/providers/nanobot.png" alt="NanoBot" width="48"/><br/>
<b>NanoBot</b>
</a><br/>
<sub>⭐ 20.9K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/sipeed/picoclaw">
<img src="./public/providers/picoclaw.jpg" alt="PicoClaw" width="48"/><br/>
<b>PicoClaw</b>
</a><br/>
<sub>⭐ 14.6K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/zeroclaw-labs/zeroclaw">
<img src="./public/providers/zeroclaw.png" alt="ZeroClaw" width="48"/><br/>
<b>ZeroClaw</b>
</a><br/>
<sub>⭐ 9.9K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/nearai/ironclaw">
<img src="./public/providers/ironclaw.png" alt="IronClaw" width="48"/><br/>
<b>IronClaw</b>
</a><br/>
<sub>⭐ 2.1K</sub>
</td>
</tr>
<tr>
<td align="center" width="110">
<a href="https://github.com/anomalyco/opencode">
<img src="./public/providers/opencode.svg" alt="OpenCode" width="48"/><br/>
<b>OpenCode</b>
</a><br/>
<sub>⭐ 106K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/openai/codex">
<img src="./public/providers/codex.png" alt="Codex CLI" width="48"/><br/>
<b>Codex CLI</b>
</a><br/>
<sub>⭐ 60.8K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/anthropics/claude-code">
<img src="./public/providers/claude.png" alt="Claude Code" width="48"/><br/>
<b>Claude Code</b>
</a><br/>
<sub>⭐ 67.3K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/google-gemini/gemini-cli">
<img src="./public/providers/gemini-cli.png" alt="Gemini CLI" width="48"/><br/>
<b>Gemini CLI</b>
</a><br/>
<sub>⭐ 94.7K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/Kilo-Org/kilocode">
<img src="./public/providers/kilocode.png" alt="Kilo Code" width="48"/><br/>
<b>Kilo Code</b>
</a><br/>
<sub>⭐ 15.5K</sub>
</td>
</tr>
</table>
<sub>📡 Tous les agents se connectent via <code>http://localhost:20128/v1</code> ou <code>http://cloud.omniroute.online/v1</code> — une configuration, modèles et quota illimités</sub>
---
[![npm version](https://img.shields.io/npm/v/omniroute?color=cb3837&logo=npm)](https://www.npmjs.com/package/omniroute)
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 Site web](https://omniroute.online) • [🚀 Démarrage rapide](#-démarrage-rapide) • [💡 Fonctionnalités](#-fonctionnalités-principales) • [📖 Docs](#-documentation) • [💰 Tarifs](#-aperçu-des-tarifs)
🌐 **Disponible en :** [English](README.md) | [Português](README.pt-BR.md) | [Español](README.es.md) | [Русский](README.ru.md) | [中文](README.zh-CN.md) | [Deutsch](README.de.md) | [Français](README.fr.md) | [Italiano](README.it.md)
</div>
---
## 🤔 Pourquoi OmniRoute ?
**Arrêtez de gaspiller de l'argent et de vous heurter aux limites :**
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> Le quota d'abonnement expire inutilisé chaque mois
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> Les limites de débit vous arrêtent en plein codage
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> APIs coûteuses (20-50 $/mois par fournisseur)
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> Changement manuel entre fournisseurs
**OmniRoute résout ces problèmes :**
-**Maximisez les abonnements** — Suivez les quotas, utilisez chaque bit avant la réinitialisation
-**Fallback automatique** — Abonnement → Clé API → Économique → Gratuit, zéro temps d'arrêt
-**Multi-comptes** — Round-robin entre les comptes par fournisseur
-**Universel** — Fonctionne avec Claude Code, Codex, Gemini CLI, Cursor, Cline, OpenClaw, tout outil CLI
---
## 🔄 Comment ça fonctionne
```
┌─────────────┐
│ Votre CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
│ Tool │
└──────┬──────┘
│ http://localhost:20128/v1
┌─────────────────────────────────────────┐
│ OmniRoute (Routeur intelligent) │
│ • Traduction de format (OpenAI ↔ Claude) │
│ • Suivi des quotas + Embeddings + Images │
│ • Renouvellement automatique des tokens │
└──────┬──────────────────────────────────┘
├─→ [Tier 1: ABONNEMENT] Claude Code, Codex, Gemini CLI
│ ↓ quota épuisé
├─→ [Tier 2: CLÉ API] DeepSeek, Groq, xAI, Mistral, NVIDIA NIM, etc.
│ ↓ limite de budget
├─→ [Tier 3: ÉCONOMIQUE] GLM ($0.6/1M), MiniMax ($0.2/1M)
│ ↓ limite de budget
└─→ [Tier 4: GRATUIT] iFlow, Qwen, Kiro (illimité)
Résultat : Ne jamais arrêter de coder, coût minimal
```
---
## ⚡ Démarrage rapide
**1. Installer globalement :**
```bash
npm install -g omniroute
omniroute
```
🎉 Le tableau de bord s'ouvre sur `http://localhost:20128`
| Commande | Description |
| ----------------------- | ------------------------------------------- |
| `omniroute` | Démarrer le serveur (port par défaut 20128) |
| `omniroute --port 3000` | Utiliser un port personnalisé |
| `omniroute --no-open` | Ne pas ouvrir le navigateur automatiquement |
| `omniroute --help` | Afficher l'aide |
**2. Connecter un fournisseur GRATUIT :**
Tableau de bord → Fournisseurs → Connecter **Claude Code** ou **Antigravity** → Connexion OAuth → Terminé !
**3. Utiliser dans votre outil CLI :**
```
Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline Paramètres :
Endpoint : http://localhost:20128/v1
API Key : [copier depuis le tableau de bord]
Model : if/kimi-k2-thinking
```
**C'est tout !** Commencez à coder avec des modèles IA GRATUITS.
**Alternative — exécuter depuis le code source :**
```bash
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
---
## 🐳 Docker
OmniRoute est disponible en tant qu'image Docker publique sur [Docker Hub](https://hub.docker.com/r/diegosouzapw/omniroute).
**Démarrage rapide :**
```bash
docker run -d \
--name omniroute \
--restart unless-stopped \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
**Avec fichier d'environnement :**
```bash
# Copier et modifier le .env d'abord
cp .env.example .env
docker run -d \
--name omniroute \
--restart unless-stopped \
--env-file .env \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
**Avec Docker Compose :**
```bash
# Profil de base (sans outils CLI)
docker compose --profile base up -d
# Profil CLI (Claude Code, Codex, OpenClaw intégrés)
docker compose --profile cli up -d
```
| Image | Tag | Taille | Description |
| ------------------------ | -------- | ------ | ----------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Dernière version stable |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | Version actuelle |
---
## 💰 Aperçu des tarifs
| Tier | Fournisseur | Coût | Réinitialisation | Idéal pour |
| ----------------- | ----------------- | -------------------------- | ------------------- | ----------------------------- |
| **💳 ABONNEMENT** | Claude Code (Pro) | 20 $/mois | 5h + hebdomadaire | Déjà abonné |
| | Codex (Plus/Pro) | 20-200 $/mois | 5h + hebdomadaire | Utilisateurs OpenAI |
| | Gemini CLI | **GRATUIT** | 180K/mois + 1K/jour | Tout le monde ! |
| | GitHub Copilot | 10-19 $/mois | Mensuel | Utilisateurs GitHub |
| **🔑 CLÉ API** | NVIDIA NIM | **GRATUIT** (1000 crédits) | Unique | Tests gratuits |
| | DeepSeek | À l'usage | Aucune | Meilleur rapport qualité-prix |
| | Groq | Niveau gratuit + payant | Limité | Inférence ultra-rapide |
| | xAI (Grok) | À l'usage | Aucune | Modèles Grok |
| | Mistral | Niveau gratuit + payant | Limité | IA européenne |
| | OpenRouter | À l'usage | Aucune | 100+ modèles |
| **💰 ÉCONOMIQUE** | GLM-4.7 | 0,6 $/1M | Quotidien 10h | Backup économique |
| | MiniMax M2.1 | 0,2 $/1M | Rotatif 5h | Option la moins chère |
| | Kimi K2 | 9 $/mois fixe | 10M tokens/mois | Coût prévisible |
| **🆓 GRATUIT** | iFlow | 0 $ | Illimité | 8 modèles gratuits |
| | Qwen | 0 $ | Illimité | 3 modèles gratuits |
| | Kiro | 0 $ | Illimité | Claude gratuit |
**💡 Conseil Pro :** Commencez avec Gemini CLI (180K gratuits/mois) + iFlow (illimité gratuit) = 0 $ de coût !
---
## 🎯 Cas d'utilisation
### Cas 1 : « J'ai un abonnement Claude Pro »
**Problème :** Le quota expire inutilisé, limites de débit pendant le codage intensif
```
Combo : "maximize-claude"
1. cc/claude-opus-4-6 (utiliser l'abonnement au maximum)
2. glm/glm-4.7 (backup économique quand le quota est épuisé)
3. if/kimi-k2-thinking (fallback d'urgence gratuit)
Coût mensuel : 20 $ (abonnement) + ~5 $ (backup) = 25 $ au total
vs. 20 $ + atteindre les limites = frustration
```
### Cas 2 : « Je veux zéro coût »
**Problème :** Impossible de payer des abonnements, besoin d'IA fiable pour coder
```
Combo : "free-forever"
1. gc/gemini-3-flash (180K gratuits/mois)
2. if/kimi-k2-thinking (illimité gratuit)
3. qw/qwen3-coder-plus (illimité gratuit)
Coût mensuel : 0 $
Qualité : Modèles prêts pour la production
```
### Cas 3 : « Je dois coder 24/7, sans interruption »
**Problème :** Délais serrés, ne peut pas se permettre de temps d'arrêt
```
Combo : "always-on"
1. cc/claude-opus-4-6 (meilleure qualité)
2. cx/gpt-5.2-codex (deuxième abonnement)
3. glm/glm-4.7 (économique, reset quotidien)
4. minimax/MiniMax-M2.1 (le moins cher, reset 5h)
5. if/kimi-k2-thinking (gratuit illimité)
Résultat : 5 niveaux de fallback = zéro temps d'arrêt
```
### Cas 4 : « Je veux l'IA GRATUITE dans OpenClaw »
**Problème :** Besoin d'assistant IA dans les apps de messagerie, entièrement gratuit
```
Combo : "openclaw-free"
1. if/glm-4.7 (illimité gratuit)
2. if/minimax-m2.1 (illimité gratuit)
3. if/kimi-k2-thinking (illimité gratuit)
Coût mensuel : 0 $
Accès via : WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
```
---
## 💡 Fonctionnalités principales
### 🧠 Routage & Intelligence
| Fonctionnalité | Ce qu'elle fait |
| ------------------------------------- | ------------------------------------------------------------------------------- |
| 🎯 **Fallback intelligent 4 niveaux** | Auto-routage : Abonnement → Clé API → Économique → Gratuit |
| 📊 **Suivi des quotas en temps réel** | Comptage de tokens en direct + compte à rebours de réinitialisation |
| 🔄 **Traduction de format** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro transparent |
| 👥 **Support multi-comptes** | Plusieurs comptes par fournisseur avec sélection intelligente |
| 🔄 **Renouvellement auto des tokens** | Les tokens OAuth se renouvellent automatiquement avec retry |
| 🎨 **Combos personnalisés** | 6 stratégies : fill-first, round-robin, p2c, random, least-used, cost-optimized |
| 🧩 **Modèles personnalisés** | Ajoutez n'importe quel ID de modèle à n'importe quel fournisseur |
| 🌐 **Routeur wildcard** | Routez les patterns `provider/*` vers n'importe quel fournisseur dynamiquement |
| 🧠 **Budget de raisonnement** | Modes passthrough, auto, custom et adaptive pour les modèles de raisonnement |
| 💬 **Injection System Prompt** | System prompt global appliqué à toutes les requêtes |
| 📄 **API Responses** | Support complet de l'API Responses d'OpenAI (`/v1/responses`) pour Codex |
### 🎵 APIs multi-modales
| Fonctionnalité | Ce qu'elle fait |
| -------------------------- | ------------------------------------------------------- |
| 🖼️ **Génération d'images** | `/v1/images/generations` — 4 fournisseurs, 9+ modèles |
| 📐 **Embeddings** | `/v1/embeddings` — 6 fournisseurs, 9+ modèles |
| 🎤 **Transcription audio** | `/v1/audio/transcriptions` — compatible Whisper |
| 🔊 **Texte vers parole** | `/v1/audio/speech` — synthèse audio multi-fournisseur |
| 🛡️ **Modérations** | `/v1/moderations` — vérifications de sécurité |
| 🔀 **Reranking** | `/v1/rerank` — reclassement de pertinence des documents |
### 🛡️ Résilience & Sécurité
| Fonctionnalité | Ce qu'elle fait |
| ------------------------------- | -------------------------------------------------------------------- |
| 🔌 **Circuit Breaker** | Ouverture/fermeture auto par fournisseur avec seuils configurables |
| 🛡️ **Anti-Thundering Herd** | Mutex + sémaphore de rate-limit pour les fournisseurs avec clé API |
| 🧠 **Cache sémantique** | Cache à deux niveaux (signature + sémantique) réduit coût et latence |
| ⚡ **Idempotence des requêtes** | Fenêtre de dédup 5s pour les requêtes dupliquées |
| 🔒 **Spoofing TLS Fingerprint** | Contournement de détection de bot via wreq-js |
| 🌐 **Filtrage IP** | Allowlist/blocklist pour le contrôle d'accès API |
| 📊 **Rate limits éditables** | RPM configurable, intervalle minimum, concurrence max |
### 📊 Observabilité & Analytique
| Fonctionnalité | Ce qu'elle fait |
| --------------------------------- | ------------------------------------------------------------------------- |
| 📝 **Logs de requêtes** | Mode debug avec logs complets requête/réponse |
| 💾 **Logs SQLite** | Logs proxy persistants survivant aux redémarrages |
| 📊 **Tableau de bord analytique** | Recharts : cartes de stats, graphique d'utilisation, tableau fournisseurs |
| 📈 **Suivi de progression** | Événements SSE de progression opt-in pour le streaming |
| 🧪 **Évaluations LLM** | Tests avec golden set et 4 stratégies de correspondance |
| 🔍 **Télémétrie des requêtes** | Agrégation de latence p50/p95/p99 + traçage X-Request-Id |
| 📋 **Logs + Quotas** | Pages dédiées pour navigation des logs et suivi des quotas |
| 🏥 **Tableau de bord santé** | Uptime, états circuit breaker, lockouts, stats cache |
| 💰 **Suivi des coûts** | Gestion de budget + configuration des prix par modèle |
### ☁️ Déploiement & Synchronisation
| Fonctionnalité | Ce qu'elle fait |
| --------------------------------- | ------------------------------------------------------------------------------- |
| 💾 **Cloud Sync** | Synchroniser les paramètres entre appareils via Cloudflare Workers |
| 🌐 **Déployer partout** | Localhost, VPS, Docker, Cloudflare Workers |
| 🔑 **Gestion des clés API** | Générer, faire tourner et limiter les clés API par fournisseur |
| 🧙 **Assistant de configuration** | Setup guidé en 4 étapes pour les nouveaux utilisateurs |
| 🔧 **Tableau de bord CLI Tools** | Configuration en un clic pour Claude, Codex, Cline, OpenClaw, Kilo, Antigravity |
| 🔄 **Sauvegardes DB** | Sauvegarde et restauration automatiques de tous les paramètres |
<details>
<summary><b>📖 Détails des fonctionnalités</b></summary>
### 🎯 Fallback intelligent 4 niveaux
Créez des combos avec fallback automatique :
```
Combo : "my-coding-stack"
1. cc/claude-opus-4-6 (votre abonnement)
2. nvidia/llama-3.3-70b (API NVIDIA gratuite)
3. glm/glm-4.7 (backup économique, $0.6/1M)
4. if/kimi-k2-thinking (fallback gratuit)
→ Bascule automatiquement lorsque le quota est épuisé ou en cas d'erreurs
```
### 📊 Suivi des quotas en temps réel
- Consommation de tokens par fournisseur
- Compte à rebours de réinitialisation (5 heures, quotidien, hebdomadaire)
- Estimation des coûts pour les niveaux payants
- Rapports de dépenses mensuels
### 🔄 Traduction de format
Traduction transparente entre les formats :
- **OpenAI** ↔ **Claude****Gemini****OpenAI Responses**
- Votre CLI envoie le format OpenAI → OmniRoute traduit → Le fournisseur reçoit le format natif
- Fonctionne avec tout outil supportant les endpoints OpenAI personnalisés
### 👥 Support multi-comptes
- Ajouter plusieurs comptes par fournisseur
- Round-robin automatique ou routage par priorité
- Basculement vers le compte suivant lorsqu'un quota est atteint
### 🔄 Renouvellement automatique des tokens
- Les tokens OAuth se renouvellent automatiquement avant expiration
- Pas de réauthentification manuelle nécessaire
- Expérience transparente sur tous les fournisseurs
### 🎨 Combos personnalisés
- Créer des combinaisons de modèles illimitées
- 6 stratégies : fill-first, round-robin, power-of-two-choices, random, least-used, cost-optimized
- Partager les combos entre appareils avec Cloud Sync
### 🏥 Tableau de bord santé
- Statut du système (uptime, version, utilisation mémoire)
- États des circuit breakers par fournisseur (Closed/Open/Half-Open)
- Statut des rate limits et lockouts actifs
- Statistiques du cache de signatures
- Télémétrie de latence (p50/p95/p99) + cache de prompt
- Réinitialisation de la santé en un clic
### 🔧 Playground du traducteur
- Déboguer, tester et visualiser les traductions de format d'API
- Envoyer des requêtes et voir comment OmniRoute traduit entre les formats des fournisseurs
- Inestimable pour résoudre les problèmes d'intégration
### 💾 Cloud Sync
- Synchroniser fournisseurs, combos et paramètres entre appareils
- Synchronisation en arrière-plan automatique
- Stockage chiffré sécurisé
</details>
---
## 📖 Guide de configuration
<details>
<summary><b>💳 Fournisseurs par abonnement</b></summary>
### Claude Code (Pro/Max)
```bash
Tableau de bord → Fournisseurs → Connecter Claude Code
→ Connexion OAuth → Renouvellement auto des tokens
→ Suivi de quota 5h + hebdomadaire
Modèles :
cc/claude-opus-4-6
cc/claude-sonnet-4-5-20250929
cc/claude-haiku-4-5-20251001
```
**Conseil Pro :** Utilisez Opus pour les tâches complexes, Sonnet pour la vitesse. OmniRoute suit les quotas par modèle !
### OpenAI Codex (Plus/Pro)
```bash
Tableau de bord → Fournisseurs → Connecter Codex
→ Connexion OAuth (port 1455)
→ Reset 5h + hebdomadaire
Modèles :
cx/gpt-5.2-codex
cx/gpt-5.1-codex-max
```
### Gemini CLI (GRATUIT 180K/mois !)
```bash
Tableau de bord → Fournisseurs → Connecter Gemini CLI
→ Google OAuth
→ 180K completions/mois + 1K/jour
Modèles :
gc/gemini-3-flash-preview
gc/gemini-2.5-pro
```
**Meilleure valeur :** Niveau gratuit énorme ! Utilisez avant les niveaux payants.
### GitHub Copilot
```bash
Tableau de bord → Fournisseurs → Connecter GitHub
→ OAuth via GitHub
→ Reset mensuel (1er du mois)
Modèles :
gh/gpt-5
gh/claude-4.5-sonnet
gh/gemini-3-pro
```
</details>
<details>
<summary><b>🔑 Fournisseurs par clé API</b></summary>
### NVIDIA NIM (GRATUIT 1000 crédits !)
1. S'inscrire : [build.nvidia.com](https://build.nvidia.com)
2. Obtenir une clé API gratuite (1000 crédits d'inférence inclus)
3. Tableau de bord → Ajouter fournisseur → NVIDIA NIM :
- API Key : `nvapi-your-key`
**Modèles :** `nvidia/llama-3.3-70b-instruct`, `nvidia/mistral-7b-instruct` et 50+ autres
**Conseil Pro :** API compatible OpenAI — fonctionne parfaitement avec la traduction de format d'OmniRoute !
### DeepSeek
1. S'inscrire : [platform.deepseek.com](https://platform.deepseek.com)
2. Obtenir une clé API
3. Tableau de bord → Ajouter fournisseur → DeepSeek
**Modèles :** `deepseek/deepseek-chat`, `deepseek/deepseek-coder`
### Groq (Niveau gratuit disponible !)
1. S'inscrire : [console.groq.com](https://console.groq.com)
2. Obtenir une clé API (niveau gratuit inclus)
3. Tableau de bord → Ajouter fournisseur → Groq
**Modèles :** `groq/llama-3.3-70b`, `groq/mixtral-8x7b`
**Conseil Pro :** Inférence ultra-rapide — idéal pour le codage en temps réel !
### OpenRouter (100+ modèles)
1. S'inscrire : [openrouter.ai](https://openrouter.ai)
2. Obtenir une clé API
3. Tableau de bord → Ajouter fournisseur → OpenRouter
**Modèles :** Accès à 100+ modèles de tous les grands fournisseurs via une seule clé API.
</details>
<details>
<summary><b>💰 Fournisseurs économiques (Backup)</b></summary>
### GLM-4.7 (Reset quotidien, $0.6/1M)
1. S'inscrire : [Zhipu AI](https://open.bigmodel.cn/)
2. Obtenir une clé API du Coding Plan
3. Tableau de bord → Ajouter clé API :
- Fournisseur : `glm`
- API Key : `your-key`
**Utilisez :** `glm/glm-4.7`
**Conseil Pro :** Le Coding Plan offre 3× le quota à 1/7 du coût ! Reset quotidien à 10h.
### MiniMax M2.1 (Reset 5h, $0.20/1M)
1. S'inscrire : [MiniMax](https://www.minimax.io/)
2. Obtenir une clé API
3. Tableau de bord → Ajouter clé API
**Utilisez :** `minimax/MiniMax-M2.1`
**Conseil Pro :** L'option la moins chère pour le contexte long (1M tokens) !
### Kimi K2 (9 $/mois fixe)
1. S'abonner : [Moonshot AI](https://platform.moonshot.ai/)
2. Obtenir une clé API
3. Tableau de bord → Ajouter clé API
**Utilisez :** `kimi/kimi-latest`
**Conseil Pro :** 9 $/mois fixe pour 10M tokens = 0,90 $/1M de coût effectif !
</details>
<details>
<summary><b>🆓 Fournisseurs GRATUITS (Backup d'urgence)</b></summary>
### iFlow (8 modèles GRATUITS)
```bash
Tableau de bord → Connecter iFlow
→ Connexion OAuth iFlow
→ Utilisation illimitée
Modèles :
if/kimi-k2-thinking
if/qwen3-coder-plus
if/glm-4.7
if/minimax-m2
if/deepseek-r1
```
### Qwen (3 modèles GRATUITS)
```bash
Tableau de bord → Connecter Qwen
→ Autorisation par code d'appareil
→ Utilisation illimitée
Modèles :
qw/qwen3-coder-plus
qw/qwen3-coder-flash
```
### Kiro (Claude GRATUIT)
```bash
Tableau de bord → Connecter Kiro
→ AWS Builder ID ou Google/GitHub
→ Utilisation illimitée
Modèles :
kr/claude-sonnet-4.5
kr/claude-haiku-4.5
```
</details>
<details>
<summary><b>🎨 Créer des combos</b></summary>
### Exemple 1 : Maximiser l'abonnement → Backup économique
```
Tableau de bord → Combos → Créer nouveau
Nom : premium-coding
Modèles :
1. cc/claude-opus-4-6 (Abonnement principal)
2. glm/glm-4.7 (Backup économique, $0.6/1M)
3. minimax/MiniMax-M2.1 (Fallback le moins cher, $0.20/1M)
Utilisez en CLI : premium-coding
```
### Exemple 2 : Gratuit uniquement (Zéro coût)
```
Nom : free-combo
Modèles :
1. gc/gemini-3-flash-preview (180K gratuits/mois)
2. if/kimi-k2-thinking (illimité)
3. qw/qwen3-coder-plus (illimité)
Coût : 0 $ pour toujours !
```
</details>
<details>
<summary><b>🔧 Intégration CLI</b></summary>
### Cursor IDE
```
Paramètres → Modèles → Avancé :
OpenAI API Base URL : http://localhost:20128/v1
OpenAI API Key : [du tableau de bord OmniRoute]
Model : cc/claude-opus-4-6
```
### Claude Code
Utilisez la page **CLI Tools** dans le tableau de bord pour la configuration en un clic, ou modifiez `~/.claude/settings.json` manuellement.
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
export OPENAI_API_KEY="your-omniroute-api-key"
codex "your prompt"
```
### OpenClaw
**Option 1 — Tableau de bord (recommandé) :**
```
Tableau de bord → CLI Tools → OpenClaw → Sélectionner modèle → Appliquer
```
**Option 2 — Manuel :** Modifier `~/.openclaw/openclaw.json` :
```json
{
"models": {
"providers": {
"omniroute": {
"baseUrl": "http://127.0.0.1:20128/v1",
"apiKey": "sk_omniroute",
"api": "openai-completions"
}
}
}
}
```
> **Note :** OpenClaw fonctionne uniquement avec OmniRoute local. Utilisez `127.0.0.1` au lieu de `localhost` pour éviter les problèmes de résolution IPv6.
### Cline / Continue / RooCode
```
Paramètres → Configuration API :
Fournisseur : OpenAI Compatible
Base URL : http://localhost:20128/v1
API Key : [du tableau de bord OmniRoute]
Model : if/kimi-k2-thinking
```
</details>
---
## 📊 Modèles disponibles
<details>
<summary><b>Voir tous les modèles disponibles</b></summary>
**Claude Code (`cc/`)** - Pro/Max :
- `cc/claude-opus-4-6`
- `cc/claude-sonnet-4-5-20250929`
- `cc/claude-haiku-4-5-20251001`
**Codex (`cx/`)** - Plus/Pro :
- `cx/gpt-5.2-codex`
- `cx/gpt-5.1-codex-max`
**Gemini CLI (`gc/`)** - GRATUIT :
- `gc/gemini-3-flash-preview`
- `gc/gemini-2.5-pro`
**GitHub Copilot (`gh/`)** :
- `gh/gpt-5`
- `gh/claude-4.5-sonnet`
**NVIDIA NIM (`nvidia/`)** - Crédits GRATUITS :
- `nvidia/llama-3.3-70b-instruct`
- `nvidia/mistral-7b-instruct`
- 50+ modèles sur [build.nvidia.com](https://build.nvidia.com)
**GLM (`glm/`)** - $0.6/1M :
- `glm/glm-4.7`
**MiniMax (`minimax/`)** - $0.2/1M :
- `minimax/MiniMax-M2.1`
**iFlow (`if/`)** - GRATUIT :
- `if/kimi-k2-thinking`
- `if/qwen3-coder-plus`
- `if/deepseek-r1`
- `if/glm-4.7`
- `if/minimax-m2`
**Qwen (`qw/`)** - GRATUIT :
- `qw/qwen3-coder-plus`
- `qw/qwen3-coder-flash`
**Kiro (`kr/`)** - GRATUIT :
- `kr/claude-sonnet-4.5`
- `kr/claude-haiku-4.5`
**OpenRouter (`or/`)** - 100+ modèles :
- `or/anthropic/claude-4-sonnet`
- `or/google/gemini-2.5-pro`
- Tout modèle de [openrouter.ai/models](https://openrouter.ai/models)
</details>
---
## 🧪 Évaluations (Evals)
OmniRoute inclut un framework d'évaluation intégré pour tester la qualité des réponses LLM contre un golden set. Accès via **Analytics → Evals** dans le tableau de bord.
### Golden Set intégré
Le « OmniRoute Golden Set » préchargé contient 10 cas de test :
- Salutations, mathématiques, géographie, génération de code
- Conformité format JSON, traduction, markdown
- Rejet de sécurité (contenu nocif), comptage, logique booléenne
### Stratégies d'évaluation
| Stratégie | Description | Exemple |
| ---------- | -------------------------------------------------------------- | -------------------------------- |
| `exact` | La sortie doit correspondre exactement | `"4"` |
| `contains` | La sortie doit contenir la sous-chaîne (insensible à la casse) | `"Paris"` |
| `regex` | La sortie doit correspondre au motif regex | `"1.*2.*3"` |
| `custom` | Fonction JS personnalisée retourne true/false | `(output) => output.length > 10` |
---
## 🐛 Dépannage
<details>
<summary><b>Cliquez pour développer le guide de dépannage</b></summary>
**« Language model did not provide messages »**
- Quota du fournisseur épuisé → Vérifiez le suivi de quota dans le tableau de bord
- Solution : Utilisez un combo avec fallback ou passez à un niveau moins cher
**Rate limiting**
- Quota d'abonnement épuisé → Fallback vers GLM/MiniMax
- Ajoutez un combo : `cc/claude-opus-4-6 → glm/glm-4.7 → if/kimi-k2-thinking`
**Token OAuth expiré**
- Renouvelé automatiquement par OmniRoute
- Si le problème persiste : Tableau de bord → Fournisseur → Reconnecter
**Coûts élevés**
- Vérifiez les statistiques d'utilisation dans Tableau de bord → Coûts
- Changez le modèle principal pour GLM/MiniMax
- Utilisez le niveau gratuit (Gemini CLI, iFlow) pour les tâches non critiques
**Le tableau de bord s'ouvre sur le mauvais port**
- Définissez `PORT=20128` et `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
**Erreurs de cloud sync**
- Vérifiez que `BASE_URL` pointe vers votre instance en cours d'exécution
- Vérifiez que `CLOUD_URL` pointe vers le point de terminaison cloud attendu
- Gardez les valeurs `NEXT_PUBLIC_*` alignées avec les valeurs du serveur
**Le premier login ne fonctionne pas**
- Vérifiez `INITIAL_PASSWORD` dans `.env`
- Si non défini, le mot de passe par défaut est `123456`
**Pas de logs de requêtes**
- Définissez `ENABLE_REQUEST_LOGS=true` dans `.env`
**Le test de connexion affiche « Invalid » pour les fournisseurs compatibles OpenAI**
- Beaucoup de fournisseurs n'exposent pas le point de terminaison `/models`
- OmniRoute v1.0.4+ inclut une validation de secours via chat completions
- Assurez-vous que l'URL de base inclut le suffixe `/v1`
</details>
---
## 🛠️ Stack technologique
- **Runtime** : Node.js 20+
- **Langage** : TypeScript 5.9 — **100% TypeScript** dans `src/` et `open-sse/` (v1.0.4)
- **Framework** : Next.js 16 + React 19 + Tailwind CSS 4
- **Base de données** : LowDB (JSON) + SQLite (état du domaine + logs proxy)
- **Streaming** : Server-Sent Events (SSE)
- **Auth** : OAuth 2.0 (PKCE) + JWT + API Keys
- **Tests** : Node.js test runner (368+ tests unitaires)
- **CI/CD** : GitHub Actions (publication automatique npm + Docker Hub lors du release)
- **Site web** : [omniroute.online](https://omniroute.online)
- **Package** : [npmjs.com/package/omniroute](https://www.npmjs.com/package/omniroute)
- **Docker** : [hub.docker.com/r/diegosouzapw/omniroute](https://hub.docker.com/r/diegosouzapw/omniroute)
- **Résilience** : Circuit breaker, backoff exponentiel, anti-thundering herd, spoofing TLS
---
## 📖 Documentation
| Document | Description |
| ------------------------------------------ | --------------------------------------------------- |
| [Guide utilisateur](docs/USER_GUIDE.md) | Fournisseurs, combos, intégration CLI, déploiement |
| [Référence API](docs/API_REFERENCE.md) | Tous les endpoints avec exemples |
| [Dépannage](docs/TROUBLESHOOTING.md) | Problèmes courants et solutions |
| [Architecture](docs/ARCHITECTURE.md) | Architecture système et détails internes |
| [Contribuer](CONTRIBUTING.md) | Configuration de développement et directives |
| [Spécification OpenAPI](docs/openapi.yaml) | Spécification OpenAPI 3.0 |
| [Politique de sécurité](SECURITY.md) | Signalement de vulnérabilités et pratiques sécurité |
---
## 📧 Support
> 💬 **Rejoignez notre communauté !** [Groupe WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Obtenez de l'aide, partagez des astuces et restez informé.
- **Site web** : [omniroute.online](https://omniroute.online)
- **GitHub** : [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues** : [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp** : [Groupe communautaire](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **Projet original** : [9router par decolua](https://github.com/decolua/9router)
---
## 👥 Contributeurs
[![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=100&columns=20&anon=1)](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
### Comment contribuer
1. Forkez le dépôt
2. Créez votre branche de fonctionnalité (`git checkout -b feature/amazing-feature`)
3. Committez vos changements (`git commit -m 'Add amazing feature'`)
4. Poussez vers la branche (`git push origin feature/amazing-feature`)
5. Ouvrez une Pull Request
Consultez [CONTRIBUTING.md](CONTRIBUTING.md) pour les directives détaillées.
### Publier une nouvelle version
```bash
# Créer un release — la publication npm est automatique
gh release create v1.0.4 --title "v1.0.4" --generate-notes
```
---
## 📊 Historique des Stars
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
---
## 🙏 Remerciements
Remerciements spéciaux à **[9router](https://github.com/decolua/9router)** par **[decolua](https://github.com/decolua)** — le projet original qui a inspiré ce fork. OmniRoute construit sur cette base incroyable avec des fonctionnalités supplémentaires, des APIs multi-modales et une réécriture complète en TypeScript.
Remerciements spéciaux à **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — l'implémentation originale en Go qui a inspiré ce portage en JavaScript.
---
## 📄 Licence
Licence MIT — voir [LICENSE](LICENSE) pour les détails.
---
<div align="center">
<sub>Fait avec ❤️ pour les développeurs qui codent 24/7</sub>
<br/>
<sub><a href="https://omniroute.online">omniroute.online</a></sub>
</div>
+1000
View File
File diff suppressed because it is too large Load Diff
+798 -111
View File
File diff suppressed because it is too large Load Diff
+1052
View File
File diff suppressed because it is too large Load Diff
+999
View File
@@ -0,0 +1,999 @@
<div align="center">
<img src="./docs/screenshots/MainOmniRoute.png" alt="OmniRoute Dashboard" width="800"/>
# 🚀 OmniRoute — Бесплатный AI Gateway
### Никогда не прекращайте программировать. Умная маршрутизация к **БЕСПЛАТНЫМ и дешёвым AI-моделям** с автоматическим fallback.
_Ваш универсальный API-прокси — одна точка доступа, 36+ провайдеров, нулевой простой._
**Chat Completions • Embeddings • Генерация изображений • Аудио • Reranking • 100% TypeScript**
---
### 🤖 Бесплатный AI-провайдер для ваших любимых агентов программирования
_Подключайте любую IDE или CLI-инструмент с AI через OmniRoute — бесплатный API gateway для неограниченного программирования._
<table>
<tr>
<td align="center" width="110">
<a href="https://github.com/cline/cline">
<img src="./public/providers/openclaw.png" alt="OpenClaw" width="48"/><br/>
<b>OpenClaw</b>
</a><br/>
<sub>⭐ 205K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/HKUDS/nanobot">
<img src="./public/providers/nanobot.png" alt="NanoBot" width="48"/><br/>
<b>NanoBot</b>
</a><br/>
<sub>⭐ 20.9K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/sipeed/picoclaw">
<img src="./public/providers/picoclaw.jpg" alt="PicoClaw" width="48"/><br/>
<b>PicoClaw</b>
</a><br/>
<sub>⭐ 14.6K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/zeroclaw-labs/zeroclaw">
<img src="./public/providers/zeroclaw.png" alt="ZeroClaw" width="48"/><br/>
<b>ZeroClaw</b>
</a><br/>
<sub>⭐ 9.9K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/nearai/ironclaw">
<img src="./public/providers/ironclaw.png" alt="IronClaw" width="48"/><br/>
<b>IronClaw</b>
</a><br/>
<sub>⭐ 2.1K</sub>
</td>
</tr>
<tr>
<td align="center" width="110">
<a href="https://github.com/anomalyco/opencode">
<img src="./public/providers/opencode.svg" alt="OpenCode" width="48"/><br/>
<b>OpenCode</b>
</a><br/>
<sub>⭐ 106K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/openai/codex">
<img src="./public/providers/codex.png" alt="Codex CLI" width="48"/><br/>
<b>Codex CLI</b>
</a><br/>
<sub>⭐ 60.8K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/anthropics/claude-code">
<img src="./public/providers/claude.png" alt="Claude Code" width="48"/><br/>
<b>Claude Code</b>
</a><br/>
<sub>⭐ 67.3K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/google-gemini/gemini-cli">
<img src="./public/providers/gemini-cli.png" alt="Gemini CLI" width="48"/><br/>
<b>Gemini CLI</b>
</a><br/>
<sub>⭐ 94.7K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/Kilo-Org/kilocode">
<img src="./public/providers/kilocode.png" alt="Kilo Code" width="48"/><br/>
<b>Kilo Code</b>
</a><br/>
<sub>⭐ 15.5K</sub>
</td>
</tr>
</table>
<sub>📡 Все агенты подключаются через <code>http://localhost:20128/v1</code> или <code>http://cloud.omniroute.online/v1</code> — одна конфигурация, неограниченные модели и квота</sub>
---
[![npm version](https://img.shields.io/npm/v/omniroute?color=cb3837&logo=npm)](https://www.npmjs.com/package/omniroute)
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 Сайт](https://omniroute.online) • [🚀 Быстрый старт](#-быстрый-старт) • [💡 Функции](#-основные-функции) • [📖 Документация](#-документация) • [💰 Цены](#-обзор-цен)
🌐 **Доступно на:** [English](README.md) | [Português](README.pt-BR.md) | [Español](README.es.md) | [Русский](README.ru.md) | [中文](README.zh-CN.md) | [Deutsch](README.de.md) | [Français](README.fr.md) | [Italiano](README.it.md)
</div>
---
## 🤔 Почему OmniRoute?
**Перестаньте тратить деньги и упираться в лимиты:**
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> Квота подписки истекает неиспользованной каждый месяц
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> Лимиты скорости останавливают вас посреди программирования
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> Дорогие API ($20-50/месяц за провайдера)
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> Ручное переключение между провайдерами
**OmniRoute решает это:**
-**Максимизируйте подписки** — Отслеживайте квоты, используйте всё до сброса
-**Автоматический fallback** — Подписка → API Key → Дешёвый → Бесплатный, нулевой простой
-**Мульти-аккаунт** — Round-robin между аккаунтами каждого провайдера
-**Универсальный** — Работает с Claude Code, Codex, Gemini CLI, Cursor, Cline, OpenClaw, любым CLI-инструментом
---
## 🔄 Как это работает
```
┌─────────────┐
│ Ваш CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
│ Tool │
└──────┬──────┘
│ http://localhost:20128/v1
┌─────────────────────────────────────────┐
│ OmniRoute (Умный маршрутизатор) │
│ • Трансляция формата (OpenAI ↔ Claude) │
│ • Отслеживание квот + Embeddings + Изображения │
│ • Автообновление токенов │
└──────┬──────────────────────────────────┘
├─→ [Tier 1: ПОДПИСКА] Claude Code, Codex, Gemini CLI
│ ↓ квота исчерпана
├─→ [Tier 2: API KEY] DeepSeek, Groq, xAI, Mistral, NVIDIA NIM и др.
│ ↓ лимит бюджета
├─→ [Tier 3: ДЕШЁВЫЙ] GLM ($0.6/1M), MiniMax ($0.2/1M)
│ ↓ лимит бюджета
└─→ [Tier 4: БЕСПЛАТНЫЙ] iFlow, Qwen, Kiro (неограниченно)
Результат: Никогда не прекращайте программировать, минимальные затраты
```
---
## ⚡ Быстрый старт
**1. Установите глобально:**
```bash
npm install -g omniroute
omniroute
```
🎉 Dashboard открывается на `http://localhost:20128`
| Команда | Описание |
| ----------------------- | ------------------------------------------ |
| `omniroute` | Запустить сервер (порт по умолчанию 20128) |
| `omniroute --port 3000` | Использовать другой порт |
| `omniroute --no-open` | Не открывать браузер автоматически |
| `omniroute --help` | Показать справку |
**2. Подключите БЕСПЛАТНОГО провайдера:**
Dashboard → Провайдеры → Подключить **Claude Code** или **Antigravity** → OAuth вход → Готово!
**3. Используйте в CLI-инструменте:**
```
Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline Настройки:
Endpoint: http://localhost:20128/v1
API Key: [скопируйте из dashboard]
Model: if/kimi-k2-thinking
```
**Готово!** Начните программировать с БЕСПЛАТНЫМИ AI-моделями.
**Альтернатива — запуск из исходного кода:**
```bash
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
---
## 🐳 Docker
OmniRoute доступен как публичный Docker-образ на [Docker Hub](https://hub.docker.com/r/diegosouzapw/omniroute).
**Быстрый запуск:**
```bash
docker run -d \
--name omniroute \
--restart unless-stopped \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
**С файлом окружения:**
```bash
# Скопируйте и отредактируйте .env
cp .env.example .env
docker run -d \
--name omniroute \
--restart unless-stopped \
--env-file .env \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
**Используя Docker Compose:**
```bash
# Базовый профиль (без CLI-инструментов)
docker compose --profile base up -d
# CLI-профиль (Claude Code, Codex, OpenClaw встроены)
docker compose --profile cli up -d
```
| Образ | Тег | Размер | Описание |
| ------------------------ | -------- | ------ | -------------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Последний стабильный релиз |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | Текущая версия |
---
## 💰 Обзор цен
| Tier | Провайдер | Стоимость | Сброс квоты | Лучше всего для |
| ----------------- | ----------------- | ----------------------------- | ------------------ | -------------------------------- |
| **💳 ПОДПИСКА** | Claude Code (Pro) | $20/мес | 5ч + еженедельно | Уже подписан |
| | Codex (Plus/Pro) | $20-200/мес | 5ч + еженедельно | Пользователи OpenAI |
| | Gemini CLI | **БЕСПЛАТНО** | 180K/мес + 1K/день | Все! |
| | GitHub Copilot | $10-19/мес | Ежемесячно | Пользователи GitHub |
| **🔑 API KEY** | NVIDIA NIM | **БЕСПЛАТНО** (1000 кредитов) | Одноразово | Бесплатное тестирование |
| | DeepSeek | По использованию | Нет | Лучшее соотношение цена/качество |
| | Groq | Беспл. уровень + платный | Ограничено | Сверхбыстрый вывод |
| | xAI (Grok) | По использованию | Нет | Модели Grok |
| | Mistral | Беспл. уровень + платный | Ограничено | Европейский AI |
| | OpenRouter | По использованию | Нет | 100+ моделей |
| **💰 ДЕШЁВЫЙ** | GLM-4.7 | $0.6/1M | Ежедневно 10ч | Бюджетный бэкап |
| | MiniMax M2.1 | $0.2/1M | 5ч ротация | Самый дешёвый вариант |
| | Kimi K2 | $9/мес фикс | 10M токенов/мес | Предсказуемая цена |
| **🆓 БЕСПЛАТНЫЙ** | iFlow | $0 | Неограниченно | 8 бесплатных моделей |
| | Qwen | $0 | Неограниченно | 3 бесплатные модели |
| | Kiro | $0 | Неограниченно | Claude бесплатно |
**💡 Совет:** Начните с Gemini CLI (180K бесплатно/мес) + iFlow (неограниченно бесплатно) = $0!
---
## 🎯 Сценарии использования
### Сценарий 1: «У меня подписка Claude Pro»
**Проблема:** Квота истекает неиспользованной, лимиты скорости во время интенсивного программирования
```
Combo: "maximize-claude"
1. cc/claude-opus-4-6 (используйте подписку полностью)
2. glm/glm-4.7 (дешёвый бэкап при исчерпании квоты)
3. if/kimi-k2-thinking (бесплатный аварийный fallback)
Месячная стоимость: $20 (подписка) + ~$5 (бэкап) = $25 итого
vs. $20 + упирание в лимиты = разочарование
```
### Сценарий 2: «Хочу нулевую стоимость»
**Проблема:** Не может позволить подписки, нужен надёжный AI для программирования
```
Combo: "free-forever"
1. gc/gemini-3-flash (180K бесплатно/мес)
2. if/kimi-k2-thinking (неограниченно бесплатно)
3. qw/qwen3-coder-plus (неограниченно бесплатно)
Месячная стоимость: $0
Качество: Модели готовые к продакшену
```
### Сценарий 3: «Мне нужно программировать 24/7, без перерывов»
**Проблема:** Дедлайны, не может позволить простой
```
Combo: "always-on"
1. cc/claude-opus-4-6 (лучшее качество)
2. cx/gpt-5.2-codex (вторая подписка)
3. glm/glm-4.7 (дешёвый, ежедневный сброс)
4. minimax/MiniMax-M2.1 (самый дешёвый, сброс 5ч)
5. if/kimi-k2-thinking (бесплатно неограниченно)
Результат: 5 уровней fallback = нулевой простой
```
### Сценарий 4: «Хочу БЕСПЛАТНЫЙ AI в OpenClaw»
**Проблема:** Нужен AI-ассистент в мессенджерах, полностью бесплатно
```
Combo: "openclaw-free"
1. if/glm-4.7 (неограниченно бесплатно)
2. if/minimax-m2.1 (неограниченно бесплатно)
3. if/kimi-k2-thinking (неограниченно бесплатно)
Месячная стоимость: $0
Доступ через: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
```
---
## 💡 Основные функции
### 🧠 Маршрутизация и интеллект
| Функция | Что делает |
| ------------------------------------------- | ----------------------------------------------------------------------------- |
| 🎯 **Умный 4-уровневый Fallback** | Авто-маршрутизация: Подписка → API Key → Дешёвый → Бесплатный |
| 📊 **Отслеживание квот в реальном времени** | Счётчик токенов в реальном времени + обратный отсчёт до сброса |
| 🔄 **Трансляция формата** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro бесшовно |
| 👥 **Мульти-аккаунт** | Несколько аккаунтов на провайдера с интеллектуальным выбором |
| 🔄 **Автообновление токенов** | OAuth-токены обновляются автоматически с повторами |
| 🎨 **Пользовательские комбо** | 6 стратегий: fill-first, round-robin, p2c, random, least-used, cost-optimized |
| 🧩 **Пользовательские модели** | Добавьте любой ID модели к любому провайдеру |
| 🌐 **Wildcard-маршрутизатор** | Маршрутизируйте паттерны `provider/*` к любому провайдеру динамически |
| 🧠 **Бюджет рассуждений** | Режимы passthrough, auto, custom и adaptive для моделей рассуждений |
| 💬 **Инъекция System Prompt** | Глобальный system prompt для всех запросов |
| 📄 **API Responses** | Полная поддержка OpenAI Responses API (`/v1/responses`) для Codex |
### 🎵 Мультимодальные API
| Функция | Что делает |
| ---------------------------- | --------------------------------------------------- |
| 🖼️ **Генерация изображений** | `/v1/images/generations` — 4 провайдера, 9+ моделей |
| 📐 **Embeddings** | `/v1/embeddings` — 6 провайдеров, 9+ моделей |
| 🎤 **Транскрипция аудио** | `/v1/audio/transcriptions` — Совместимо с Whisper |
| 🔊 **Текст в речь** | `/v1/audio/speech` — Мульти-провайдерный синтез |
| 🛡️ **Модерация** | `/v1/moderations` — Проверки безопасности контента |
| 🔀 **Reranking** | `/v1/rerank` — Переранжирование релевантности |
### 🛡️ Устойчивость и безопасность
| Функция | Что делает |
| -------------------------------- | -------------------------------------------------------------- |
| 🔌 **Circuit Breaker** | Авто-открытие/закрытие по провайдеру с настраиваемыми порогами |
| 🛡️ **Anti-Thundering Herd** | Mutex + семафор для API key провайдеров |
| 🧠 **Семантический кеш** | Двухуровневый кеш (сигнатура + семантика) снижает стоимость |
| ⚡ **Идемпотентность запросов** | 5с окно дедупликации для дублирующихся запросов |
| 🔒 **Спуфинг TLS Fingerprint** | Обход обнаружения ботов через wreq-js |
| 🌐 **Фильтрация IP** | Allowlist/blocklist для контроля доступа к API |
| 📊 **Настраиваемые Rate Limits** | Настраиваемые RPM, минимальный интервал, макс. конкуррентность |
### 📊 Наблюдаемость и аналитика
| Функция | Что делает |
| ----------------------------- | ------------------------------------------------------------------------ |
| 📝 **Логи запросов** | Режим debug с полными логами запросов/ответов |
| 💾 **Логи SQLite** | Постоянные proxy-логи переживают перезапуски |
| 📊 **Dashboard аналитики** | Recharts: карточки статистики, график использования, таблица провайдеров |
| 📈 **Отслеживание прогресса** | Opt-in SSE-события прогресса для стриминга |
| 🧪 **Оценки LLM** | Тестирование с golden set и 4 стратегиями сравнения |
| 🔍 **Телеметрия запросов** | Агрегация латентности p50/p95/p99 + трекинг X-Request-Id |
| 📋 **Логи + Квоты** | Отдельные страницы для просмотра логов и отслеживания квот |
| 🏥 **Dashboard здоровья** | Uptime, состояния circuit breaker, блокировки, статистика кеша |
| 💰 **Отслеживание стоимости** | Управление бюджетом + настройка цен по моделям |
### ☁️ Деплой и синхронизация
| Функция | Что делает |
| -------------------------- | --------------------------------------------------------------------------- |
| 💾 **Cloud Sync** | Синхронизация настроек между устройствами через Cloudflare Workers |
| 🌐 **Деплой куда угодно** | Localhost, VPS, Docker, Cloudflare Workers |
| 🔑 **Управление API Keys** | Генерация, ротация и настройка scope API keys по провайдерам |
| 🧙 **Мастер настройки** | 4-шаговая настройка для новых пользователей |
| 🔧 **Dashboard CLI Tools** | Настройка в один клик для Claude, Codex, Cline, OpenClaw, Kilo, Antigravity |
| 🔄 **Бэкапы БД** | Автоматическое резервное копирование и восстановление всех настроек |
<details>
<summary><b>📖 Подробности функций</b></summary>
### 🎯 Умный 4-уровневый Fallback
Создавайте комбо с автоматическим fallback:
```
Combo: "my-coding-stack"
1. cc/claude-opus-4-6 (ваша подписка)
2. nvidia/llama-3.3-70b (бесплатный NVIDIA API)
3. glm/glm-4.7 (дешёвый бэкап, $0.6/1M)
4. if/kimi-k2-thinking (бесплатный fallback)
→ Автоматически переключается при исчерпании квоты или ошибках
```
### 📊 Отслеживание квот в реальном времени
- Потребление токенов по провайдерам
- Обратный отсчёт до сброса (5 часов, ежедневно, еженедельно)
- Оценка стоимости для платных уровней
- Ежемесячные отчёты о расходах
### 🔄 Трансляция формата
Бесшовная трансляция между форматами:
- **OpenAI** ↔ **Claude****Gemini****OpenAI Responses**
- Ваш CLI отправляет формат OpenAI → OmniRoute транслирует → Провайдер получает нативный формат
- Работает с любым инструментом, поддерживающим пользовательские OpenAI endpoints
### 👥 Мульти-аккаунт
- Добавляйте несколько аккаунтов на провайдера
- Автоматический round-robin или маршрутизация по приоритету
- Fallback на следующий аккаунт при исчерпании квоты
### 🔄 Автообновление токенов
- OAuth-токены обновляются автоматически до истечения
- Без необходимости ручной повторной аутентификации
- Бесшовный опыт по всем провайдерам
### 🎨 Пользовательские комбо
- Создавайте неограниченные комбинации моделей
- 6 стратегий: fill-first, round-robin, power-of-two-choices, random, least-used, cost-optimized
- Делитесь комбо между устройствами с Cloud Sync
### 🏥 Dashboard здоровья
- Статус системы (uptime, версия, использование памяти)
- Состояния circuit breaker по провайдерам (Closed/Open/Half-Open)
- Статус rate limit и активные блокировки
- Статистика кеша сигнатур
- Телеметрия латентности (p50/p95/p99) + кеш промптов
- Сброс состояния здоровья одним кликом
### 🔧 Playground транслятора
- Отладка, тестирование и визуализация трансляции форматов API
- Отправляйте запросы и смотрите, как OmniRoute транслирует между форматами провайдеров
- Бесценно для устранения проблем интеграции
### 💾 Cloud Sync
- Синхронизация провайдеров, комбо и настроек между устройствами
- Автоматическая фоновая синхронизация
- Безопасное шифрованное хранилище
</details>
---
## 📖 Руководство по настройке
<details>
<summary><b>💳 Провайдеры по подписке</b></summary>
### Claude Code (Pro/Max)
```bash
Dashboard → Провайдеры → Подключить Claude Code
→ OAuth вход → Автообновление токенов
→ Отслеживание квоты 5ч + еженедельно
Модели:
cc/claude-opus-4-6
cc/claude-sonnet-4-5-20250929
cc/claude-haiku-4-5-20251001
```
**Совет:** Используйте Opus для сложных задач, Sonnet для скорости. OmniRoute отслеживает квоту по моделям!
### OpenAI Codex (Plus/Pro)
```bash
Dashboard → Провайдеры → Подключить Codex
→ OAuth вход (порт 1455)
→ Сброс 5ч + еженедельно
Модели:
cx/gpt-5.2-codex
cx/gpt-5.1-codex-max
```
### Gemini CLI (БЕСПЛАТНО 180K/мес!)
```bash
Dashboard → Провайдеры → Подключить Gemini CLI
→ Google OAuth
→ 180K completions/мес + 1K/день
Модели:
gc/gemini-3-flash-preview
gc/gemini-2.5-pro
```
**Лучшая ценность:** Огромный бесплатный уровень! Используйте перед платными.
### GitHub Copilot
```bash
Dashboard → Провайдеры → Подключить GitHub
→ OAuth через GitHub
→ Ежемесячный сброс (1-е число)
Модели:
gh/gpt-5
gh/claude-4.5-sonnet
gh/gemini-3-pro
```
</details>
<details>
<summary><b>🔑 Провайдеры по API Key</b></summary>
### NVIDIA NIM (БЕСПЛАТНО 1000 кредитов!)
1. Регистрация: [build.nvidia.com](https://build.nvidia.com)
2. Получите бесплатный API key (1000 кредитов включены)
3. Dashboard → Добавить провайдера → NVIDIA NIM:
- API Key: `nvapi-your-key`
**Модели:** `nvidia/llama-3.3-70b-instruct`, `nvidia/mistral-7b-instruct` и 50+ других
**Совет:** OpenAI-совместимый API — работает идеально с трансляцией форматов OmniRoute!
### DeepSeek
1. Регистрация: [platform.deepseek.com](https://platform.deepseek.com)
2. Получите API key
3. Dashboard → Добавить провайдера → DeepSeek
**Модели:** `deepseek/deepseek-chat`, `deepseek/deepseek-coder`
### Groq (Бесплатный уровень доступен!)
1. Регистрация: [console.groq.com](https://console.groq.com)
2. Получите API key (бесплатный уровень включён)
3. Dashboard → Добавить провайдера → Groq
**Модели:** `groq/llama-3.3-70b`, `groq/mixtral-8x7b`
**Совет:** Сверхбыстрый вывод — лучший для программирования в реальном времени!
### OpenRouter (100+ моделей)
1. Регистрация: [openrouter.ai](https://openrouter.ai)
2. Получите API key
3. Dashboard → Добавить провайдера → OpenRouter
**Модели:** Доступ к 100+ моделям от всех основных провайдеров через один API key.
</details>
<details>
<summary><b>💰 Дешёвые провайдеры (Бэкап)</b></summary>
### GLM-4.7 (Ежедневный сброс, $0.6/1M)
1. Регистрация: [Zhipu AI](https://open.bigmodel.cn/)
2. Получите API key из Coding Plan
3. Dashboard → Добавить API Key:
- Провайдер: `glm`
- API Key: `your-key`
**Используйте:** `glm/glm-4.7`
**Совет:** Coding Plan предлагает 3× квоту по цене 1/7! Ежедневный сброс в 10:00.
### MiniMax M2.1 (Сброс 5ч, $0.20/1M)
1. Регистрация: [MiniMax](https://www.minimax.io/)
2. Получите API key
3. Dashboard → Добавить API Key
**Используйте:** `minimax/MiniMax-M2.1`
**Совет:** Самый дешёвый вариант для длинного контекста (1M токенов)!
### Kimi K2 ($9/мес фикс)
1. Подпишитесь: [Moonshot AI](https://platform.moonshot.ai/)
2. Получите API key
3. Dashboard → Добавить API Key
**Используйте:** `kimi/kimi-latest`
**Совет:** Фикс $9/мес за 10M токенов = $0.90/1M эффективная стоимость!
</details>
<details>
<summary><b>🆓 БЕСПЛАТНЫЕ провайдеры (Аварийный бэкап)</b></summary>
### iFlow (8 БЕСПЛАТНЫХ моделей)
```bash
Dashboard → Подключить iFlow
→ OAuth вход iFlow
→ Неограниченное использование
Модели:
if/kimi-k2-thinking
if/qwen3-coder-plus
if/glm-4.7
if/minimax-m2
if/deepseek-r1
```
### Qwen (3 БЕСПЛАТНЫЕ модели)
```bash
Dashboard → Подключить Qwen
→ Авторизация по коду устройства
→ Неограниченное использование
Модели:
qw/qwen3-coder-plus
qw/qwen3-coder-flash
```
### Kiro (Claude БЕСПЛАТНО)
```bash
Dashboard → Подключить Kiro
→ AWS Builder ID или Google/GitHub
→ Неограниченное использование
Модели:
kr/claude-sonnet-4.5
kr/claude-haiku-4.5
```
</details>
<details>
<summary><b>🎨 Создание комбо</b></summary>
### Пример 1: Максимизация подписки → Дешёвый бэкап
```
Dashboard → Комбо → Создать новое
Название: premium-coding
Модели:
1. cc/claude-opus-4-6 (Основная подписка)
2. glm/glm-4.7 (Дешёвый бэкап, $0.6/1M)
3. minimax/MiniMax-M2.1 (Самый дешёвый fallback, $0.20/1M)
Используйте в CLI: premium-coding
```
### Пример 2: Только бесплатные (Нулевая стоимость)
```
Название: free-combo
Модели:
1. gc/gemini-3-flash-preview (180K бесплатно/мес)
2. if/kimi-k2-thinking (неограниченно)
3. qw/qwen3-coder-plus (неограниченно)
Стоимость: $0 навсегда!
```
</details>
<details>
<summary><b>🔧 Интеграция с CLI</b></summary>
### Cursor IDE
```
Настройки → Модели → Расширенные:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [из dashboard OmniRoute]
Model: cc/claude-opus-4-6
```
### Claude Code
Используйте страницу **CLI Tools** в dashboard для настройки в один клик, или редактируйте `~/.claude/settings.json` вручную.
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
export OPENAI_API_KEY="your-omniroute-api-key"
codex "your prompt"
```
### OpenClaw
**Вариант 1 — Dashboard (рекомендуется):**
```
Dashboard → CLI Tools → OpenClaw → Выбрать модель → Применить
```
**Вариант 2 — Вручную:** Редактируйте `~/.openclaw/openclaw.json`:
```json
{
"models": {
"providers": {
"omniroute": {
"baseUrl": "http://127.0.0.1:20128/v1",
"apiKey": "sk_omniroute",
"api": "openai-completions"
}
}
}
}
```
> **Примечание:** OpenClaw работает только с локальным OmniRoute. Используйте `127.0.0.1` вместо `localhost` для избежания проблем с IPv6.
### Cline / Continue / RooCode
```
Настройки → Конфигурация API:
Провайдер: OpenAI Compatible
Base URL: http://localhost:20128/v1
API Key: [из dashboard OmniRoute]
Model: if/kimi-k2-thinking
```
</details>
---
## 📊 Доступные модели
<details>
<summary><b>Посмотреть все доступные модели</b></summary>
**Claude Code (`cc/`)** - Pro/Max:
- `cc/claude-opus-4-6`
- `cc/claude-sonnet-4-5-20250929`
- `cc/claude-haiku-4-5-20251001`
**Codex (`cx/`)** - Plus/Pro:
- `cx/gpt-5.2-codex`
- `cx/gpt-5.1-codex-max`
**Gemini CLI (`gc/`)** - БЕСПЛАТНО:
- `gc/gemini-3-flash-preview`
- `gc/gemini-2.5-pro`
**GitHub Copilot (`gh/`)**:
- `gh/gpt-5`
- `gh/claude-4.5-sonnet`
**NVIDIA NIM (`nvidia/`)** - БЕСПЛАТНЫЕ кредиты:
- `nvidia/llama-3.3-70b-instruct`
- `nvidia/mistral-7b-instruct`
- 50+ моделей на [build.nvidia.com](https://build.nvidia.com)
**GLM (`glm/`)** - $0.6/1M:
- `glm/glm-4.7`
**MiniMax (`minimax/`)** - $0.2/1M:
- `minimax/MiniMax-M2.1`
**iFlow (`if/`)** - БЕСПЛАТНО:
- `if/kimi-k2-thinking`
- `if/qwen3-coder-plus`
- `if/deepseek-r1`
- `if/glm-4.7`
- `if/minimax-m2`
**Qwen (`qw/`)** - БЕСПЛАТНО:
- `qw/qwen3-coder-plus`
- `qw/qwen3-coder-flash`
**Kiro (`kr/`)** - БЕСПЛАТНО:
- `kr/claude-sonnet-4.5`
- `kr/claude-haiku-4.5`
**OpenRouter (`or/`)** - 100+ моделей:
- `or/anthropic/claude-4-sonnet`
- `or/google/gemini-2.5-pro`
- Любая модель с [openrouter.ai/models](https://openrouter.ai/models)
</details>
---
## 🧪 Оценки (Evals)
OmniRoute включает встроенный фреймворк оценки для тестирования качества ответов LLM по golden set. Доступ через **Analytics → Evals** в dashboard.
### Встроенный Golden Set
Предзагруженный «OmniRoute Golden Set» содержит 10 тестов:
- Приветствия, математика, география, генерация кода
- Соответствие формату JSON, перевод, markdown
- Отказ от небезопасного контента, подсчёт, булева логика
### Стратегии оценки
| Стратегия | Описание | Пример |
| ---------- | ----------------------------------------------------- | -------------------------------- |
| `exact` | Вывод должен совпадать точно | `"4"` |
| `contains` | Вывод должен содержать подстроку (без учёта регистра) | `"Paris"` |
| `regex` | Вывод должен соответствовать regex-паттерну | `"1.*2.*3"` |
| `custom` | Пользовательская JS-функция возвращает true/false | `(output) => output.length > 10` |
---
## 🐛 Устранение неполадок
<details>
<summary><b>Нажмите для раскрытия руководства</b></summary>
**«Language model did not provide messages»**
- Квота провайдера исчерпана → Проверьте трекер квот в dashboard
- Решение: Используйте комбо с fallback или переключитесь на более дешёвый уровень
**Rate limiting**
- Квота подписки исчерпана → Fallback на GLM/MiniMax
- Добавьте комбо: `cc/claude-opus-4-6 → glm/glm-4.7 → if/kimi-k2-thinking`
**OAuth-токен истёк**
- Обновляется автоматически OmniRoute
- Если проблема сохраняется: Dashboard → Провайдер → Переподключить
**Высокие расходы**
- Проверьте статистику в Dashboard → Расходы
- Переключите основную модель на GLM/MiniMax
- Используйте бесплатный уровень (Gemini CLI, iFlow) для некритичных задач
**Dashboard открывается на неправильном порту**
- Установите `PORT=20128` и `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
**Ошибки cloud sync**
- Проверьте что `BASE_URL` указывает на ваш запущенный экземпляр
- Проверьте что `CLOUD_URL` указывает на правильный облачный endpoint
- Держите значения `NEXT_PUBLIC_*` синхронизированными с серверными значениями
**Первый вход не работает**
- Проверьте `INITIAL_PASSWORD` в `.env`
- Если не задан, пароль по умолчанию `123456`
**Нет логов запросов**
- Установите `ENABLE_REQUEST_LOGS=true` в `.env`
**Тест подключения показывает «Invalid» для OpenAI-совместимых провайдеров**
- Многие провайдеры не предоставляют endpoint `/models`
- OmniRoute v1.0.4+ включает fallback-валидацию через chat completions
- Убедитесь что base URL содержит суффикс `/v1`
</details>
---
## 🛠️ Технологический стек
- **Runtime**: Node.js 20+
- **Язык**: TypeScript 5.9 — **100% TypeScript** в `src/` и `open-sse/` (v1.0.4)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **База данных**: LowDB (JSON) + SQLite (состояние домена + proxy-логи)
- **Стриминг**: Server-Sent Events (SSE)
- **Аутентификация**: OAuth 2.0 (PKCE) + JWT + API Keys
- **Тестирование**: Node.js test runner (368+ юнит-тестов)
- **CI/CD**: GitHub Actions (авто-публикация npm + Docker Hub при релизе)
- **Сайт**: [omniroute.online](https://omniroute.online)
- **Пакет**: [npmjs.com/package/omniroute](https://www.npmjs.com/package/omniroute)
- **Docker**: [hub.docker.com/r/diegosouzapw/omniroute](https://hub.docker.com/r/diegosouzapw/omniroute)
- **Устойчивость**: Circuit breaker, экспоненциальный backoff, anti-thundering herd, TLS-спуфинг
---
## 📖 Документация
| Документ | Описание |
| ----------------------------------------------- | ------------------------------------------------ |
| [Руководство пользователя](docs/USER_GUIDE.md) | Провайдеры, комбо, интеграция CLI, деплой |
| [Справка API](docs/API_REFERENCE.md) | Все endpoints с примерами |
| [Устранение неполадок](docs/TROUBLESHOOTING.md) | Частые проблемы и решения |
| [Архитектура](docs/ARCHITECTURE.md) | Архитектура системы и внутреннее устройство |
| [Как внести вклад](CONTRIBUTING.md) | Настройка разработки и руководящие принципы |
| [Спецификация OpenAPI](docs/openapi.yaml) | Спецификация OpenAPI 3.0 |
| [Политика безопасности](SECURITY.md) | Сообщение об уязвимостях и практики безопасности |
---
## 📧 Поддержка
> 💬 **Присоединяйтесь к сообществу!** [Группа WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Получайте помощь, делитесь советами и оставайтесь в курсе.
- **Сайт**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp**: [Группа сообщества](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **Оригинальный проект**: [9router от decolua](https://github.com/decolua/9router)
---
## 👥 Участники
[![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=100&columns=20&anon=1)](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
### Как внести вклад
1. Сделайте fork репозитория
2. Создайте ветку функции (`git checkout -b feature/amazing-feature`)
3. Зафиксируйте изменения (`git commit -m 'Add amazing feature'`)
4. Отправьте в ветку (`git push origin feature/amazing-feature`)
5. Откройте Pull Request
См. [CONTRIBUTING.md](CONTRIBUTING.md) для подробных рекомендаций.
### Выпуск новой версии
```bash
# Создайте релиз — публикация в npm происходит автоматически
gh release create v1.0.4 --title "v1.0.4" --generate-notes
```
---
## 📊 История звёзд
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
---
## 🙏 Благодарности
Особая благодарность **[9router](https://github.com/decolua/9router)** от **[decolua](https://github.com/decolua)** — оригинальному проекту, вдохновившему этот форк. OmniRoute строится на этом невероятном фундаменте с дополнительными функциями, мультимодальными API и полной переписью на TypeScript.
Особая благодарность **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — оригинальной реализации на Go, вдохновившей этот порт на JavaScript.
---
## 📄 Лицензия
Лицензия MIT — см. [LICENSE](LICENSE) для подробностей.
---
<div align="center">
<sub>Сделано с ❤️ для разработчиков, которые программируют 24/7</sub>
<br/>
<sub><a href="https://omniroute.online">omniroute.online</a></sub>
</div>
+999
View File
@@ -0,0 +1,999 @@
<div align="center">
<img src="./docs/screenshots/MainOmniRoute.png" alt="OmniRoute Dashboard" width="800"/>
# 🚀 OmniRoute — 免费 AI 网关
### 永不停止编程。智能路由至**免费和低成本 AI 模型**,自动故障转移。
_您的通用 API 代理 — 一个端点,36+ 提供商,零停机时间。_
**Chat Completions • Embeddings • 图像生成 • 音频 • Reranking • 100% TypeScript**
---
### 🤖 为您最爱的编程代理提供免费 AI
_通过 OmniRoute 连接任何 AI 驱动的 IDE 或 CLI 工具 — 免费 API 网关,无限编程。_
<table>
<tr>
<td align="center" width="110">
<a href="https://github.com/cline/cline">
<img src="./public/providers/openclaw.png" alt="OpenClaw" width="48"/><br/>
<b>OpenClaw</b>
</a><br/>
<sub>⭐ 205K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/HKUDS/nanobot">
<img src="./public/providers/nanobot.png" alt="NanoBot" width="48"/><br/>
<b>NanoBot</b>
</a><br/>
<sub>⭐ 20.9K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/sipeed/picoclaw">
<img src="./public/providers/picoclaw.jpg" alt="PicoClaw" width="48"/><br/>
<b>PicoClaw</b>
</a><br/>
<sub>⭐ 14.6K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/zeroclaw-labs/zeroclaw">
<img src="./public/providers/zeroclaw.png" alt="ZeroClaw" width="48"/><br/>
<b>ZeroClaw</b>
</a><br/>
<sub>⭐ 9.9K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/nearai/ironclaw">
<img src="./public/providers/ironclaw.png" alt="IronClaw" width="48"/><br/>
<b>IronClaw</b>
</a><br/>
<sub>⭐ 2.1K</sub>
</td>
</tr>
<tr>
<td align="center" width="110">
<a href="https://github.com/anomalyco/opencode">
<img src="./public/providers/opencode.svg" alt="OpenCode" width="48"/><br/>
<b>OpenCode</b>
</a><br/>
<sub>⭐ 106K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/openai/codex">
<img src="./public/providers/codex.png" alt="Codex CLI" width="48"/><br/>
<b>Codex CLI</b>
</a><br/>
<sub>⭐ 60.8K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/anthropics/claude-code">
<img src="./public/providers/claude.png" alt="Claude Code" width="48"/><br/>
<b>Claude Code</b>
</a><br/>
<sub>⭐ 67.3K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/google-gemini/gemini-cli">
<img src="./public/providers/gemini-cli.png" alt="Gemini CLI" width="48"/><br/>
<b>Gemini CLI</b>
</a><br/>
<sub>⭐ 94.7K</sub>
</td>
<td align="center" width="110">
<a href="https://github.com/Kilo-Org/kilocode">
<img src="./public/providers/kilocode.png" alt="Kilo Code" width="48"/><br/>
<b>Kilo Code</b>
</a><br/>
<sub>⭐ 15.5K</sub>
</td>
</tr>
</table>
<sub>📡 所有代理通过 <code>http://localhost:20128/v1</code> 或 <code>http://cloud.omniroute.online/v1</code> 连接 — 一个配置,无限模型和配额</sub>
---
[![npm version](https://img.shields.io/npm/v/omniroute?color=cb3837&logo=npm)](https://www.npmjs.com/package/omniroute)
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 网站](https://omniroute.online) • [🚀 快速开始](#-快速开始) • [💡 功能特性](#-核心功能) • [📖 文档](#-文档) • [💰 定价](#-定价概览)
🌐 **多语言版本:** [English](README.md) | [Português](README.pt-BR.md) | [Español](README.es.md) | [Русский](README.ru.md) | [中文](README.zh-CN.md) | [Deutsch](README.de.md) | [Français](README.fr.md) | [Italiano](README.it.md)
</div>
---
## 🤔 为什么选择 OmniRoute
**停止浪费金钱和遭遇限制:**
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> 订阅配额每月未使用就过期
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> 速率限制在编程中途停止你
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> 昂贵的 API(每个提供商 $20-50/月)
- <img src="https://img.shields.io/badge/✗-e74c3c?style=flat-square" height="16"/> 手动在提供商间切换
**OmniRoute 解决这些问题:**
-**最大化订阅** — 追踪配额,在重置前用完每一点
-**自动故障转移** — 订阅 → API Key → 低价 → 免费,零停机
-**多账号** — 每个提供商的账号轮询
-**通用** — 适用于 Claude Code、Codex、Gemini CLI、Cursor、Cline、OpenClaw、任何 CLI 工具
---
## 🔄 工作原理
```
┌─────────────┐
│ 您的 CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
│ 工具 │
└──────┬──────┘
│ http://localhost:20128/v1
┌─────────────────────────────────────────┐
│ OmniRoute(智能路由器) │
│ • 格式转换(OpenAI ↔ Claude
│ • 配额追踪 + Embeddings + 图像 │
│ • 自动令牌刷新 │
└──────┬──────────────────────────────────┘
├─→ [第1层: 订阅] Claude Code, Codex, Gemini CLI
│ ↓ 配额用完
├─→ [第2层: API KEY] DeepSeek, Groq, xAI, Mistral, NVIDIA NIM 等
│ ↓ 预算限制
├─→ [第3层: 低价] GLM ($0.6/1M), MiniMax ($0.2/1M)
│ ↓ 预算限制
└─→ [第4层: 免费] iFlow, Qwen, Kiro(无限制)
结果:永不停止编程,成本最低
```
---
## ⚡ 快速开始
**1. 全局安装:**
```bash
npm install -g omniroute
omniroute
```
🎉 仪表板在 `http://localhost:20128` 打开
| 命令 | 描述 |
| ----------------------- | ---------------------------- |
| `omniroute` | 启动服务器(默认端口 20128) |
| `omniroute --port 3000` | 使用自定义端口 |
| `omniroute --no-open` | 不自动打开浏览器 |
| `omniroute --help` | 显示帮助 |
**2. 连接免费提供商:**
仪表板 → 提供商 → 连接 **Claude Code****Antigravity** → OAuth 登录 → 完成!
**3. 在 CLI 工具中使用:**
```
Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline 设置:
Endpoint: http://localhost:20128/v1
API Key: [从仪表板复制]
Model: if/kimi-k2-thinking
```
**完成!** 开始使用免费 AI 模型编程。
**替代方案 — 从源代码运行:**
```bash
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
---
## 🐳 Docker
OmniRoute 作为公共 Docker 镜像在 [Docker Hub](https://hub.docker.com/r/diegosouzapw/omniroute) 上可用。
**快速运行:**
```bash
docker run -d \
--name omniroute \
--restart unless-stopped \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
**使用环境文件:**
```bash
# 先复制并编辑 .env
cp .env.example .env
docker run -d \
--name omniroute \
--restart unless-stopped \
--env-file .env \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
**使用 Docker Compose**
```bash
# 基础配置(无 CLI 工具)
docker compose --profile base up -d
# CLI 配置(内置 Claude Code、Codex、OpenClaw
docker compose --profile cli up -d
```
| 镜像 | 标签 | 大小 | 描述 |
| ------------------------ | -------- | ------ | ---------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | 最新稳定版 |
| `diegosouzapw/omniroute` | `1.0.4` | ~250MB | 当前版本 |
---
## 💰 定价概览
| 层级 | 提供商 | 费用 | 配额重置 | 最适合 |
| -------------- | ----------------- | --------------------- | --------------- | ------------ |
| **💳 订阅** | Claude Code (Pro) | $20/月 | 5小时 + 每周 | 已订阅用户 |
| | Codex (Plus/Pro) | $20-200/月 | 5小时 + 每周 | OpenAI 用户 |
| | Gemini CLI | **免费** | 180K/月 + 1K/天 | 所有人! |
| | GitHub Copilot | $10-19/月 | 每月 | GitHub 用户 |
| **🔑 API KEY** | NVIDIA NIM | **免费**(1000 积分) | 一次性 | 免费测试 |
| | DeepSeek | 按使用量 | 无 | 最佳性价比 |
| | Groq | 免费层 + 付费 | 限速 | 超快推理 |
| | xAI (Grok) | 按使用量 | 无 | Grok 模型 |
| | Mistral | 免费层 + 付费 | 限速 | 欧洲 AI |
| | OpenRouter | 按使用量 | 无 | 100+ 模型 |
| **💰 低价** | GLM-4.7 | $0.6/1M | 每日 10时 | 经济备用 |
| | MiniMax M2.1 | $0.2/1M | 5小时滚动 | 最便宜选项 |
| | Kimi K2 | $9/月固定 | 每月 10M Token | 可预测成本 |
| **🆓 免费** | iFlow | $0 | 无限制 | 8 个免费模型 |
| | Qwen | $0 | 无限制 | 3 个免费模型 |
| | Kiro | $0 | 无限制 | 免费 Claude |
**💡 专业建议:** 从 Gemini CLI(每月 180K 免费)+ iFlow(无限免费)开始 = $0 成本!
---
## 🎯 使用场景
### 场景 1"我有 Claude Pro 订阅"
**问题:** 配额未使用就过期,编程高峰期遇到速率限制
```
Combo: "maximize-claude"
1. cc/claude-opus-4-6 (充分使用订阅)
2. glm/glm-4.7 (配额用完时的便宜备用)
3. if/kimi-k2-thinking (免费应急后备)
每月成本:$20(订阅)+ ~$5(备用)= $25 总计
对比:$20 + 遇到限制 = 受挫
```
### 场景 2"我想要零成本"
**问题:** 无法承担订阅费用,需要可靠的 AI 编程
```
Combo: "free-forever"
1. gc/gemini-3-flash (每月 180K 免费)
2. if/kimi-k2-thinking (无限免费)
3. qw/qwen3-coder-plus (无限免费)
每月成本:$0
质量:生产级模型
```
### 场景 3"我需要 24/7 编程,不中断"
**问题:** 截止日期紧迫,不能有停机时间
```
Combo: "always-on"
1. cc/claude-opus-4-6 (最佳质量)
2. cx/gpt-5.2-codex (第二个订阅)
3. glm/glm-4.7 (便宜,每日重置)
4. minimax/MiniMax-M2.1 (最便宜,5小时重置)
5. if/kimi-k2-thinking (免费无限制)
结果:5 层故障转移 = 零停机
```
### 场景 4"我想在 OpenClaw 中使用免费 AI"
**问题:** 需要在消息应用中使用 AI 助手,完全免费
```
Combo: "openclaw-free"
1. if/glm-4.7 (无限免费)
2. if/minimax-m2.1 (无限免费)
3. if/kimi-k2-thinking (无限免费)
每月成本:$0
访问方式:WhatsApp、Telegram、Slack、Discord、iMessage、Signal...
```
---
## 💡 核心功能
### 🧠 路由与智能
| 功能 | 功能描述 |
| ------------------------- | -------------------------------------------------------------------------- |
| 🎯 **智能 4 层故障转移** | 自动路由:订阅 → API Key → 低价 → 免费 |
| 📊 **实时配额追踪** | 实时 Token 计数 + 每个提供商的重置倒计时 |
| 🔄 **格式转换** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro 无缝切换 |
| 👥 **多账号支持** | 每个提供商多个账号,智能选择 |
| 🔄 **自动令牌刷新** | OAuth 令牌自动刷新并重试 |
| 🎨 **自定义组合** | 6 种策略:fill-first、round-robin、p2c、random、least-used、cost-optimized |
| 🧩 **自定义模型** | 为任何提供商添加任何模型 ID |
| 🌐 **通配符路由** | 动态路由 `provider/*` 模式到任何提供商 |
| 🧠 **推理预算** | passthrough、auto、custom 和 adaptive 模式用于推理模型 |
| 💬 **System Prompt 注入** | 全局 System Prompt 应用于所有请求 |
| 📄 **Responses API** | 完整支持 OpenAI Responses API (`/v1/responses`) 用于 Codex |
### 🎵 多模态 API
| 功能 | 功能描述 |
| ----------------- | ---------------------------------------------- |
| 🖼️ **图像生成** | `/v1/images/generations` — 4 个提供商,9+ 模型 |
| 📐 **Embeddings** | `/v1/embeddings` — 6 个提供商,9+ 模型 |
| 🎤 **音频转录** | `/v1/audio/transcriptions` — Whisper 兼容 |
| 🔊 **文字转语音** | `/v1/audio/speech` — 多提供商音频合成 |
| 🛡️ **内容审核** | `/v1/moderations` — 内容安全检查 |
| 🔀 **重排序** | `/v1/rerank` — 文档相关性重排序 |
### 🛡️ 弹性与安全
| 功能 | 功能描述 |
| --------------------- | -------------------------------------- |
| 🔌 **断路器** | 每个提供商自动打开/关闭,可配置阈值 |
| 🛡️ **反惊群** | Mutex + 信号量限速用于 API Key 提供商 |
| 🧠 **语义缓存** | 两层缓存(签名 + 语义)降低成本和延迟 |
| ⚡ **请求幂等性** | 5 秒去重窗口防止重复请求 |
| 🔒 **TLS 指纹伪装** | 通过 wreq-js 绕过基于 TLS 的机器人检测 |
| 🌐 **IP 过滤** | 白名单/黑名单用于 API 访问控制 |
| 📊 **可编辑速率限制** | 可配置的 RPM、最小间隔和最大并发 |
### 📊 可观察性与分析
| 功能 | 功能描述 |
| ------------------ | ------------------------------------------ |
| 📝 **请求日志** | 调试模式,完整的请求/响应日志 |
| 💾 **SQLite 日志** | 持久化代理日志,服务器重启后仍然保留 |
| 📊 **分析仪表板** | Recharts:统计卡片、使用量图表、提供商表格 |
| 📈 **进度追踪** | 流式传输的 SSE 进度事件(可选) |
| 🧪 **LLM 评估** | 黄金集测试,4 种匹配策略 |
| 🔍 **请求遥测** | p50/p95/p99 延迟聚合 + X-Request-Id 追踪 |
| 📋 **日志 + 配额** | 专用页面用于日志浏览和配额追踪 |
| 🏥 **健康仪表板** | 运行时间、断路器状态、锁定、缓存统计 |
| 💰 **成本追踪** | 预算管理 + 每模型定价配置 |
### ☁️ 部署与同步
| 功能 | 功能描述 |
| --------------------- | ---------------------------------------------------------- |
| 💾 **Cloud Sync** | 通过 Cloudflare Workers 在设备间同步配置 |
| 🌐 **随处部署** | Localhost、VPS、Docker、Cloudflare Workers |
| 🔑 **API Key 管理** | 按提供商生成、轮换和设定 API Key 范围 |
| 🧙 **配置向导** | 4 步引导式设置,面向新用户 |
| 🔧 **CLI 工具仪表板** | 一键配置 Claude、Codex、Cline、OpenClaw、Kilo、Antigravity |
| 🔄 **数据库备份** | 自动备份和恢复所有设置 |
<details>
<summary><b>📖 功能详情</b></summary>
### 🎯 智能 4 层故障转移
创建带自动故障转移的组合:
```
Combo: "my-coding-stack"
1. cc/claude-opus-4-6 (您的订阅)
2. nvidia/llama-3.3-70b (免费 NVIDIA API
3. glm/glm-4.7 (便宜备用,$0.6/1M
4. if/kimi-k2-thinking (免费后备)
→ 配额用完或出错时自动切换
```
### 📊 实时配额追踪
- 每个提供商的 Token 消耗
- 重置倒计时(5 小时、每日、每周)
- 付费层级的成本估算
- 月度支出报告
### 🔄 格式转换
格式间的无缝转换:
- **OpenAI** ↔ **Claude****Gemini****OpenAI Responses**
- 您的 CLI 发送 OpenAI 格式 → OmniRoute 转换 → 提供商接收原生格式
- 适用于任何支持自定义 OpenAI 端点的工具
### 👥 多账号支持
- 每个提供商添加多个账号
- 自动轮询或基于优先级的路由
- 当一个账号达到配额时自动切换到下一个
### 🔄 自动令牌刷新
- OAuth 令牌在过期前自动刷新
- 无需手动重新认证
- 所有提供商的无缝体验
### 🎨 自定义组合
- 创建无限模型组合
- 6 种策略:fill-first、round-robin、power-of-two-choices、random、least-used、cost-optimized
- 通过 Cloud Sync 在设备间共享组合
### 🏥 健康仪表板
- 系统状态(运行时间、版本、内存使用)
- 每个提供商的断路器状态(Closed/Open/Half-Open
- 速率限制状态和活动锁定
- 签名缓存统计
- 延迟遥测(p50/p95/p99+ 提示缓存
- 一键重置健康状态
### 🔧 翻译器 Playground
- 调试、测试和可视化 API 格式转换
- 发送请求并查看 OmniRoute 如何在提供商格式间转换
- 对排查集成问题非常有价值
### 💾 Cloud Sync
- 在设备间同步提供商、组合和设置
- 自动后台同步
- 安全加密存储
</details>
---
## 📖 设置指南
<details>
<summary><b>💳 订阅提供商</b></summary>
### Claude Code (Pro/Max)
```bash
仪表板 → 提供商 → 连接 Claude Code
→ OAuth 登录 → 自动令牌刷新
5 小时 + 每周配额追踪
模型:
cc/claude-opus-4-6
cc/claude-sonnet-4-5-20250929
cc/claude-haiku-4-5-20251001
```
**专业建议:** 复杂任务用 Opus,追求速度用 Sonnet。OmniRoute 按模型追踪配额!
### OpenAI Codex (Plus/Pro)
```bash
仪表板 → 提供商 → 连接 Codex
→ OAuth 登录(端口 1455
5 小时 + 每周重置
模型:
cx/gpt-5.2-codex
cx/gpt-5.1-codex-max
```
### Gemini CLI(免费 180K/月!)
```bash
仪表板 → 提供商 → 连接 Gemini CLI
→ Google OAuth
→ 每月 180K completions + 每天 1K
模型:
gc/gemini-3-flash-preview
gc/gemini-2.5-pro
```
**最佳价值:** 巨大的免费额度!在付费层级之前使用。
### GitHub Copilot
```bash
仪表板 → 提供商 → 连接 GitHub
→ 通过 GitHub OAuth
→ 每月重置(每月 1 日)
模型:
gh/gpt-5
gh/claude-4.5-sonnet
gh/gemini-3-pro
```
</details>
<details>
<summary><b>🔑 API Key 提供商</b></summary>
### NVIDIA NIM(免费 1000 积分!)
1. 注册:[build.nvidia.com](https://build.nvidia.com)
2. 获取免费 API key(包含 1000 推理积分)
3. 仪表板 → 添加提供商 → NVIDIA NIM
- API Key`nvapi-your-key`
**模型:** `nvidia/llama-3.3-70b-instruct``nvidia/mistral-7b-instruct` 及 50+ 更多
**专业建议:** OpenAI 兼容的 API — 与 OmniRoute 的格式转换完美配合!
### DeepSeek
1. 注册:[platform.deepseek.com](https://platform.deepseek.com)
2. 获取 API key
3. 仪表板 → 添加提供商 → DeepSeek
**模型:** `deepseek/deepseek-chat``deepseek/deepseek-coder`
### Groq(免费层可用!)
1. 注册:[console.groq.com](https://console.groq.com)
2. 获取 API key(包含免费层)
3. 仪表板 → 添加提供商 → Groq
**模型:** `groq/llama-3.3-70b``groq/mixtral-8x7b`
**专业建议:** 超快推理 — 最适合实时编程!
### OpenRouter100+ 模型)
1. 注册:[openrouter.ai](https://openrouter.ai)
2. 获取 API key
3. 仪表板 → 添加提供商 → OpenRouter
**模型:** 通过一个 API key 访问所有主要提供商的 100+ 模型。
</details>
<details>
<summary><b>💰 低价提供商(备用)</b></summary>
### GLM-4.7(每日重置,$0.6/1M
1. 注册:[Zhipu AI](https://open.bigmodel.cn/)
2. 从 Coding Plan 获取 API key
3. 仪表板 → 添加 API Key
- 提供商:`glm`
- API Key`your-key`
**使用:** `glm/glm-4.7`
**专业建议:** Coding Plan 以 1/7 的价格提供 3 倍配额!每日 10:00 AM 重置。
### MiniMax M2.15 小时重置,$0.20/1M
1. 注册:[MiniMax](https://www.minimax.io/)
2. 获取 API key
3. 仪表板 → 添加 API Key
**使用:** `minimax/MiniMax-M2.1`
**专业建议:** 长上下文(1M Token)最便宜的选项!
### Kimi K2$9/月固定)
1. 订阅:[Moonshot AI](https://platform.moonshot.ai/)
2. 获取 API key
3. 仪表板 → 添加 API Key
**使用:** `kimi/kimi-latest`
**专业建议:** 固定 $9/月 10M Token = $0.90/1M 有效成本!
</details>
<details>
<summary><b>🆓 免费提供商(应急备用)</b></summary>
### iFlow8 个免费模型)
```bash
仪表板 → 连接 iFlow
→ iFlow OAuth 登录
→ 无限使用
模型:
if/kimi-k2-thinking
if/qwen3-coder-plus
if/glm-4.7
if/minimax-m2
if/deepseek-r1
```
### Qwen3 个免费模型)
```bash
仪表板 → 连接 Qwen
→ 设备码授权
→ 无限使用
模型:
qw/qwen3-coder-plus
qw/qwen3-coder-flash
```
### Kiro(免费 Claude
```bash
仪表板 → 连接 Kiro
→ AWS Builder ID 或 Google/GitHub
→ 无限使用
模型:
kr/claude-sonnet-4.5
kr/claude-haiku-4.5
```
</details>
<details>
<summary><b>🎨 创建组合</b></summary>
### 示例 1:最大化订阅 → 便宜备用
```
仪表板 → 组合 → 创建新的
名称:premium-coding
模型:
1. cc/claude-opus-4-6(订阅主力)
2. glm/glm-4.7(便宜备用,$0.6/1M
3. minimax/MiniMax-M2.1(最便宜的后备,$0.20/1M
在 CLI 中使用:premium-coding
```
### 示例 2:仅免费(零成本)
```
名称:free-combo
模型:
1. gc/gemini-3-flash-preview(每月 180K 免费)
2. if/kimi-k2-thinking(无限制)
3. qw/qwen3-coder-plus(无限制)
成本:永远 $0
```
</details>
<details>
<summary><b>🔧 CLI 集成</b></summary>
### Cursor IDE
```
设置 → 模型 → 高级:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [从 OmniRoute 仪表板获取]
Model: cc/claude-opus-4-6
```
### Claude Code
使用仪表板中的 **CLI Tools** 页面一键配置,或手动编辑 `~/.claude/settings.json`
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
export OPENAI_API_KEY="your-omniroute-api-key"
codex "your prompt"
```
### OpenClaw
**选项 1 — 仪表板(推荐):**
```
仪表板 → CLI Tools → OpenClaw → 选择模型 → 应用
```
**选项 2 — 手动:** 编辑 `~/.openclaw/openclaw.json`
```json
{
"models": {
"providers": {
"omniroute": {
"baseUrl": "http://127.0.0.1:20128/v1",
"apiKey": "sk_omniroute",
"api": "openai-completions"
}
}
}
}
```
> **注意:** OpenClaw 仅支持本地 OmniRoute。使用 `127.0.0.1` 而非 `localhost` 以避免 IPv6 解析问题。
### Cline / Continue / RooCode
```
设置 → API 配置:
提供商:OpenAI Compatible
Base URL: http://localhost:20128/v1
API Key: [从 OmniRoute 仪表板获取]
Model: if/kimi-k2-thinking
```
</details>
---
## 📊 可用模型
<details>
<summary><b>查看所有可用模型</b></summary>
**Claude Code (`cc/`)** - Pro/Max:
- `cc/claude-opus-4-6`
- `cc/claude-sonnet-4-5-20250929`
- `cc/claude-haiku-4-5-20251001`
**Codex (`cx/`)** - Plus/Pro:
- `cx/gpt-5.2-codex`
- `cx/gpt-5.1-codex-max`
**Gemini CLI (`gc/`)** - 免费:
- `gc/gemini-3-flash-preview`
- `gc/gemini-2.5-pro`
**GitHub Copilot (`gh/`)**:
- `gh/gpt-5`
- `gh/claude-4.5-sonnet`
**NVIDIA NIM (`nvidia/`)** - 免费积分:
- `nvidia/llama-3.3-70b-instruct`
- `nvidia/mistral-7b-instruct`
- 50+ 更多模型在 [build.nvidia.com](https://build.nvidia.com)
**GLM (`glm/`)** - $0.6/1M:
- `glm/glm-4.7`
**MiniMax (`minimax/`)** - $0.2/1M:
- `minimax/MiniMax-M2.1`
**iFlow (`if/`)** - 免费:
- `if/kimi-k2-thinking`
- `if/qwen3-coder-plus`
- `if/deepseek-r1`
- `if/glm-4.7`
- `if/minimax-m2`
**Qwen (`qw/`)** - 免费:
- `qw/qwen3-coder-plus`
- `qw/qwen3-coder-flash`
**Kiro (`kr/`)** - 免费:
- `kr/claude-sonnet-4.5`
- `kr/claude-haiku-4.5`
**OpenRouter (`or/`)** - 100+ 模型:
- `or/anthropic/claude-4-sonnet`
- `or/google/gemini-2.5-pro`
- [openrouter.ai/models](https://openrouter.ai/models) 上的任何模型
</details>
---
## 🧪 评估 (Evals)
OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质量。通过仪表板中的 **Analytics → Evals** 访问。
### 内置黄金集
预加载的「OmniRoute Golden Set」包含 10 个测试用例:
- 问候、数学、地理、代码生成
- JSON 格式合规性、翻译、markdown
- 安全拒绝(有害内容)、计数、布尔逻辑
### 评估策略
| 策略 | 描述 | 示例 |
| ---------- | -------------------------------- | -------------------------------- |
| `exact` | 输出必须完全匹配 | `"4"` |
| `contains` | 输出必须包含子串(不区分大小写) | `"Paris"` |
| `regex` | 输出必须匹配正则表达式模式 | `"1.*2.*3"` |
| `custom` | 自定义 JS 函数返回 true/false | `(output) => output.length > 10` |
---
## 🐛 故障排除
<details>
<summary><b>点击展开故障排除指南</b></summary>
**"Language model did not provide messages"**
- 提供商配额已耗尽 → 检查仪表板配额追踪器
- 解决方案:使用组合故障转移或切换到更便宜的层级
**速率限制**
- 订阅配额耗尽 → 回退到 GLM/MiniMax
- 添加组合:`cc/claude-opus-4-6 → glm/glm-4.7 → if/kimi-k2-thinking`
**OAuth 令牌过期**
- OmniRoute 自动刷新
- 如果问题持续:仪表板 → 提供商 → 重新连接
**高成本**
- 在仪表板 → 成本中检查使用统计
- 将主要模型切换为 GLM/MiniMax
- 对非关键任务使用免费层(Gemini CLI、iFlow
**仪表板在错误端口打开**
- 设置 `PORT=20128``NEXT_PUBLIC_BASE_URL=http://localhost:20128`
**Cloud sync 错误**
- 验证 `BASE_URL` 指向您正在运行的实例
- 验证 `CLOUD_URL` 指向预期的云端点
- 保持 `NEXT_PUBLIC_*` 值与服务器端值一致
**首次登录不工作**
- 检查 `.env` 中的 `INITIAL_PASSWORD`
- 如未设置,默认密码为 `123456`
**没有请求日志**
-`.env` 中设置 `ENABLE_REQUEST_LOGS=true`
**兼容 OpenAI 的提供商连接测试显示 "Invalid"**
- 许多提供商不暴露 `/models` 端点
- OmniRoute v1.0.4+ 包含通过 chat completions 的回退验证
- 确保 base URL 包含 `/v1` 后缀
</details>
---
## 🛠️ 技术栈
- **运行时**: Node.js 20+
- **语言**: TypeScript 5.9 — `src/``open-sse/`**100% TypeScript**v1.0.4
- **框架**: Next.js 16 + React 19 + Tailwind CSS 4
- **数据库**: LowDB (JSON) + SQLite(领域状态 + 代理日志)
- **流式传输**: Server-Sent Events (SSE)
- **认证**: OAuth 2.0 (PKCE) + JWT + API Keys
- **测试**: Node.js test runner368+ 单元测试)
- **CI/CD**: GitHub Actions(发布时自动 npm 发布 + Docker Hub
- **网站**: [omniroute.online](https://omniroute.online)
- **包**: [npmjs.com/package/omniroute](https://www.npmjs.com/package/omniroute)
- **Docker**: [hub.docker.com/r/diegosouzapw/omniroute](https://hub.docker.com/r/diegosouzapw/omniroute)
- **弹性**: 断路器、指数退避、反惊群、TLS 伪装
---
## 📖 文档
| 文档 | 描述 |
| ----------------------------------- | ---------------------------- |
| [用户指南](docs/USER_GUIDE.md) | 提供商、组合、CLI 集成、部署 |
| [API 参考](docs/API_REFERENCE.md) | 所有端点及示例 |
| [故障排除](docs/TROUBLESHOOTING.md) | 常见问题和解决方案 |
| [架构](docs/ARCHITECTURE.md) | 系统架构和内部机制 |
| [贡献指南](CONTRIBUTING.md) | 开发设置和指南 |
| [OpenAPI 规范](docs/openapi.yaml) | OpenAPI 3.0 规范 |
| [安全策略](SECURITY.md) | 漏洞报告和安全实践 |
---
## 📧 支持
> 💬 **加入我们的社区!** [WhatsApp 群组](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — 获取帮助、分享技巧、了解最新动态。
- **网站**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp**: [社区群组](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **原始项目**: [decolua 的 9router](https://github.com/decolua/9router)
---
## 👥 贡献者
[![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=100&columns=20&anon=1)](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
### 如何贡献
1. Fork 仓库
2. 创建功能分支(`git checkout -b feature/amazing-feature`
3. 提交更改(`git commit -m 'Add amazing feature'`
4. 推送到分支(`git push origin feature/amazing-feature`
5. 打开 Pull Request
详细指南请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
### 发布新版本
```bash
# 创建发布 — npm 发布自动完成
gh release create v1.0.4 --title "v1.0.4" --generate-notes
```
---
## 📊 Star 历史
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
---
## 🙏 致谢
特别感谢 **[decolua](https://github.com/decolua)** 的 **[9router](https://github.com/decolua/9router)** — 启发了本 fork 的原始项目。OmniRoute 在这个令人难以置信的基础上添加了额外功能、多模态 API 和完整的 TypeScript 重写。
特别感谢 **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — 启发了本 JavaScript 移植的原始 Go 实现。
---
## 📄 许可证
MIT 许可证 — 详见 [LICENSE](LICENSE)。
---
<div align="center">
<sub>用 ❤️ 为 24/7 编程的开发者打造</sub>
<br/>
<sub><a href="https://omniroute.online">omniroute.online</a></sub>
</div>
+3 -3
View File
@@ -20,9 +20,9 @@ If you discover a security vulnerability in OmniRoute, please report it responsi
| Version | Support Status |
| ------- | -------------- |
| 0.8.x | ✅ Active |
| 0.7.x | ✅ Security |
| < 0.7.0 | ❌ Unsupported |
| 1.0.x | ✅ Active |
| 0.8.x | ✅ Security |
| < 0.8.0 | ❌ Unsupported |
---
Submodule
+1
Submodule clipr/9router added at bc91be7305
+1
Submodule clipr/CLIProxyAPI added at 068630dbd0
+11
View File
@@ -227,6 +227,17 @@ Response example:
| `/api/monitoring/health` | GET | Health check |
| `/api/cache` | GET/DELETE | Cache stats / clear |
### Backup & Export/Import
| Endpoint | Method | Description |
| --------------------------- | ------ | --------------------------------------- |
| `/api/db-backups` | GET | List available backups |
| `/api/db-backups` | PUT | Create a manual backup |
| `/api/db-backups` | POST | Restore from a specific backup |
| `/api/db-backups/export` | GET | Download database as .sqlite file |
| `/api/db-backups/import` | POST | Upload .sqlite file to replace database |
| `/api/db-backups/exportAll` | GET | Download full backup as .tar.gz archive |
### Cloud Sync
| Endpoint | Method | Description |
+20 -4
View File
@@ -1,6 +1,6 @@
# OmniRoute Architecture
_Last updated: 2026-02-17_
_Last updated: 2026-02-18_
## Executive Summary
@@ -17,6 +17,9 @@ Core capabilities:
- Embedding generation via `/v1/embeddings` (6 providers, 9 models)
- Image generation via `/v1/images/generations` (4 providers, 9 models)
- Think tag parsing (`<think>...</think>`) for reasoning models
- Response sanitization for strict OpenAI SDK compatibility
- Role normalization (developer→system, system→user) for cross-provider compatibility
- Structured output conversion (json_schema → Gemini responseSchema)
- Local persistence for providers, keys, aliases, combos, settings, pricing
- Usage/cost tracking and request logging
- Optional cloud sync for multi-device/state sync
@@ -180,6 +183,8 @@ Main flow modules:
- Embedding provider registry: `open-sse/config/embeddingRegistry.ts`
- Image generation handler: `open-sse/handlers/imageGeneration.ts`
- Image provider registry: `open-sse/config/imageRegistry.ts`
- Response sanitization: `open-sse/handlers/responseSanitizer.ts`
- Role normalization: `open-sse/services/roleNormalizer.ts`
Services (business logic):
@@ -408,6 +413,9 @@ erDiagram
number stickyRoundRobinLimit
boolean requireLogin
string password_hash
string fallbackStrategy
json rateLimitDefaults
json providerProfiles
}
PROVIDER_CONNECTION {
@@ -644,6 +652,13 @@ Source Format → OpenAI (hub) → Target Format
Translations are selected dynamically based on source payload shape and provider target format.
Additional processing layers in the translation pipeline:
- **Response sanitization** — Strips non-standard fields from OpenAI-format responses (both streaming and non-streaming) to ensure strict SDK compliance
- **Role normalization** — Converts `developer``system` for non-OpenAI targets; merges `system``user` for models that reject the system role (GLM, ERNIE)
- **Think tag extraction** — Parses `<think>...</think>` blocks from content into `reasoning_content` field
- **Structured output** — Converts OpenAI `response_format.json_schema` to Gemini's `responseMimeType` + `responseSchema`
## Supported API Endpoints
| Endpoint | Format | Handler |
@@ -750,13 +765,14 @@ Environment variables actively used by code:
3. Request logger writes full headers/body when enabled; treat log directory as sensitive.
4. Cloud behavior depends on correct `NEXT_PUBLIC_BASE_URL` and cloud endpoint reachability.
5. The `open-sse/` directory is published as the `@omniroute/open-sse` **npm workspace package**. Source code imports it via `@omniroute/open-sse/...` (resolved by Next.js `transpilePackages`). File paths in this document still use the directory name `open-sse/` for consistency.
6. Charts in the dashboard use **Recharts** (SVG-based) for accessible, interactive visualizations (bar charts, donut charts).
6. Charts in the dashboard use **Recharts** (SVG-based) for accessible, interactive analytics visualizations (model usage bar charts, provider breakdown tables with success rates).
7. E2E tests use **Playwright** (`tests/e2e/`), run via `npm run test:e2e`. Unit tests use **Node.js test runner** (`tests/unit/`), run via `npm run test:plan3`. Source code under `src/` is **TypeScript** (`.ts`/`.tsx`); the `open-sse/` workspace remains JavaScript (`.js`).
8. Settings page is organized into 5 tabs: Security, Routing (6 global strategies: fill-first, round-robin, p2c, random, least-used, cost-optimized), Resilience (editable rate limits, circuit breaker, policies), AI (thinking budget, system prompt, prompt cache), Advanced (proxy).
## Operational Verification Checklist
- Build from source: `cd /root/dev/omniroute && npm run build`
- Build Docker image: `cd /root/dev/omniroute && docker build -t omniroute .`
- Build from source: `npm run build`
- Build Docker image: `docker build -t omniroute .`
- Start service and verify:
- `GET /api/settings`
- `GET /api/v1/models`
+75
View File
@@ -0,0 +1,75 @@
# OmniRoute — Dashboard Features Gallery
Visual guide to every section of the OmniRoute dashboard.
---
## 🔌 Providers
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (iFlow, Qwen, Kiro).
![Providers Dashboard](screenshots/01-providers.png)
---
## 🎨 Combos
Create model routing combos with 6 strategies: fill-first, round-robin, power-of-two-choices, random, least-used, and cost-optimized. Each combo chains multiple models with automatic fallback.
![Combos Dashboard](screenshots/02-combos.png)
---
## 📊 Analytics
Comprehensive usage analytics with token consumption, cost estimates, activity heatmaps, weekly distribution charts, and per-provider breakdowns.
![Analytics Dashboard](screenshots/03-analytics.png)
---
## 🏥 System Health
Real-time monitoring: uptime, memory, version, latency percentiles (p50/p95/p99), cache statistics, and provider circuit breaker states.
![Health Dashboard](screenshots/04-health.png)
---
## 🔧 Translator Playground
Four modes for debugging API translations: **Playground** (format converter), **Chat Tester** (live requests), **Test Bench** (batch tests), and **Live Monitor** (real-time stream).
![Translator Playground](screenshots/05-translator.png)
---
## ⚙️ Settings
General settings, system storage, backup management (export/import database), appearance (dark/light mode), security, routing, resilience, and advanced configuration.
![Settings Dashboard](screenshots/06-settings.png)
---
## 🔧 CLI Tools
One-click configuration for AI coding tools: Claude Code, Codex CLI, Gemini CLI, OpenClaw, Kilo Code, and Antigravity.
![CLI Tools Dashboard](screenshots/07-cli-tools.png)
---
## 📝 Request Logs
Real-time request logging with filtering by provider, model, account, and API key. Shows status codes, token usage, latency, and response details.
![Usage Logs](screenshots/08-usage.png)
---
## 🌐 API Endpoint
Your unified API endpoint with capability breakdown: Chat Completions, Embeddings, Image Generation, Reranking, Audio Transcription, and registered API keys.
![Endpoint Dashboard](screenshots/09-endpoint.png)
+56 -35
View File
@@ -8,24 +8,24 @@
### Backend Core
- [ ] `constants.js` — Substituir `COOLDOWN_MS.transient` por `transientInitial` (5s) + `transientMax` (60s)
- [ ] `constants.js` — Adicionar `PROVIDER_PROFILES` (oauth / apikey) com cooldowns diferenciados
- [ ] `constants.js` — Adicionar `DEFAULT_API_LIMITS` (100 RPM, 200ms minTime)
- [ ] `providerRegistry.js` — Criar helper `getProviderCategory(providerId)``"oauth"` | `"apikey"`
- [ ] `accountFallback.js` — Aceitar `provider` como parâmetro em `checkFallbackError`
- [ ] `accountFallback.js` — Implementar backoff exponencial para 502/503/504 transientes
- [ ] `accountFallback.js` — Calcular cooldown baseado no perfil do provedor
- [ ] `accountFallback.js` — Adicionar helper `getProviderProfile(provider)`
- [x] `constants.ts` — Substituir `COOLDOWN_MS.transient` por `transientInitial` (5s) + `transientMax` (60s)
- [x] `constants.ts` — Adicionar `PROVIDER_PROFILES` (oauth / apikey) com cooldowns diferenciados
- [x] `constants.ts` — Adicionar `DEFAULT_API_LIMITS` (100 RPM, 200ms minTime)
- [x] `providerRegistry.ts` — Criar helper `getProviderCategory(providerId)``"oauth"` | `"apikey"`
- [x] `accountFallback.ts` — Aceitar `provider` como parâmetro em `checkFallbackError`
- [x] `accountFallback.ts` — Implementar backoff exponencial para 502/503/504 transientes
- [x] `accountFallback.ts` — Calcular cooldown baseado no perfil do provedor
- [x] `accountFallback.ts` — Adicionar helper `getProviderProfile(provider)`
### Callers (propagar `provider`)
- [ ] `auth.js``markAccountUnavailable` — Passar `provider` para `checkFallbackError`
- [ ] `combo.js``handleComboChat` / `handleRoundRobinCombo` — Passar `provider` nos erros
- [x] `auth.ts``markAccountUnavailable` — Passar `provider` para `checkFallbackError`
- [x] `combo.ts``handleComboChat` / `handleRoundRobinCombo` — Passar `provider` nos erros
### Testes
- [ ] Atualizar `rate-limit-enhanced.test.mjs` — Teste "transient errors don't increase backoff" → `newBackoffLevel = 1`
- [ ] Criar `error-classification.test.mjs` — Cooldown exponencial 502, perfis OAuth/API, helper `getProviderCategory`
- [x] Atualizar `rate-limit-enhanced.test.mjs` — Teste "transient errors don't increase backoff" → `newBackoffLevel = 1`
- [x] Criar `error-classification.test.mjs` — Cooldown exponencial 502, perfis OAuth/API, helper `getProviderCategory`
---
@@ -33,15 +33,15 @@
### Backend
- [ ] `combo.js` — Importar `getCircuitBreaker` e `CircuitBreakerOpenError`
- [ ] `combo.js``handleComboChat` — Verificar `breaker.canExecute()` antes de cada modelo
- [ ] `combo.js``handleRoundRobinCombo` — Integrar breaker per-model
- [ ] `combo.js` — Marcar `semaphore.markRateLimited` para 502/503/504 (não só 429)
- [ ] `combo.js` — Implementar early exit quando todos os modelos têm breaker OPEN
- [x] `combo.ts` — Importar `getCircuitBreaker` e `CircuitBreakerOpenError`
- [x] `combo.ts``handleComboChat` — Verificar `breaker.canExecute()` antes de cada modelo
- [x] `combo.ts``handleRoundRobinCombo` — Integrar breaker per-model
- [x] `combo.ts` — Marcar `semaphore.markRateLimited` para 502/503/504 (não só 429)
- [x] `combo.ts` — Implementar early exit quando todos os modelos têm breaker OPEN
### Testes
- [ ] Criar `combo-circuit-breaker.test.mjs` — Combo skip breaker OPEN, early exit, semáforo 502
- [x] Criar `combo-circuit-breaker.test.mjs` — Combo skip breaker OPEN, early exit, semáforo 502
---
@@ -49,13 +49,13 @@
### Backend
- [ ] `rateLimitManager.js` — Auto-enable para `apikey` providers com limites elevados
- [ ] `rateLimitManager.js` — Criar limiter com defaults (100 RPM) quando não configurado
- [ ] `auth.js` — Adicionar mutex na `markAccountUnavailable` para evitar marcação paralela
- [x] `rateLimitManager.ts` — Auto-enable para `apikey` providers com limites elevados
- [x] `rateLimitManager.ts` — Criar limiter com defaults (100 RPM) quando não configurado
- [x] `auth.ts` — Adicionar mutex na `markAccountUnavailable` para evitar marcação paralela
### Testes
- [ ] Criar `thundering-herd.test.mjs` — Mutex, auto-enable, limites não restritivos
- [x] Criar `thundering-herd.test.mjs` — Mutex, auto-enable, limites não restritivos
---
@@ -63,30 +63,51 @@
### Settings Page
- [ ] `settings/page.tsx` — Adicionar tab "Resilience" (icon: `health_and_safety`) entre Routing e Pricing
- [x] `settings/page.tsx` — Adicionar tab "Resilience" (icon: `health_and_safety`) entre Routing e Pricing
### Novos Componentes
- [ ] Criar `ResilienceTab.tsx` — Layout com 3 cards
- [ ] Criar `ProviderProfilesCard.tsx` — Toggle OAuth/API Key, inputs para cooldowns
- [ ] Criar `CircuitBreakerCard.tsx` — Status real-time per-provider, auto-refresh 5s, botão reset
- [ ] Criar `RateLimitOverviewCard.tsx` — Tabela providers × accounts × cooldown
- [x] Criar `ResilienceTab.tsx` — Layout com 4 cards (Provider Profiles → Rate Limiting → Circuit Breakers → Policies)
- [x] Criar `ProviderProfilesCard.tsx` — Toggle OAuth/API Key, inputs para cooldowns
- [x] Criar `CircuitBreakerCard.tsx` — Status real-time per-provider, auto-refresh 5s, botão reset
- [x] Criar `RateLimitOverviewCard.tsx` — Tabela providers × accounts × cooldown**agora editável com RPM, Min Gap, Max Concurrent**
### API Routes
- [ ] Criar `api/resilience/route.ts` — GET (estado completo) + PATCH (salvar perfis)
- [ ] Criar `api/resilience/reset/route.ts` — POST (resetar breakers + cooldowns)
- [x] Criar `api/resilience/route.ts` — GET (estado completo + defaults mesclados) + PATCH (salvar perfis + defaults)
- [x] Criar `api/resilience/reset/route.ts` — POST (resetar breakers + cooldowns)
### Migração
- [ ] Avaliar se `PoliciesPanel.tsx` pode ser removido ou simplificado após nova aba
- [x] `PoliciesPanel.tsx` movido de Security para Resilience tab
---
## Fase 5 — Settings Page Restructure (v0.9.0)
### Tab Reorganization
- [x] **Security** — Simplificado para Login/Password + IP Access Control
- [x] **Routing** — Expandido para 6 estratégias globais com descrições
- [x] **Resilience** — Reordenado: Provider Profiles → Rate Limiting (editável) → Circuit Breakers → Policies
- [x] **AI** — Thinking Budget + System Prompt + Prompt Cache (movido do Advanced)
- [x] **Advanced** — Simplificado para apenas Global Proxy
### Backend Routing Strategies
- [x] `auth.ts` — Implementar `random` (Fisher-Yates shuffle)
- [x] `auth.ts` — Implementar `least-used` (sorted by lastUsedAt)
- [x] `auth.ts` — Implementar `cost-optimized` (sorted by priority)
- [x] `auth.ts` — Corrigir `p2c` (power-of-two-choices com health scoring)
- [x] `settings.ts` — Expandir tipo `fallbackStrategy` para 6 valores
---
## Verificação Final
- [ ] Rodar todos os testes unitários: `node --test tests/unit/*.test.mjs`
- [ ] Build do Next.js: `npm run build`
- [ ] Verificar aba Resilience no browser
- [ ] Testar persistência dos perfis (salvar → reload)
- [ ] Testar Reset All Breakers
- [x] Rodar todos os testes unitários: `node --test tests/unit/*.test.mjs`
- [x] Build do Next.js: `npm run build`
- [x] Verificar aba Resilience no browser
- [x] Testar persistência dos perfis (salvar → reload)
- [x] Testar Reset All Breakers
- [x] Verificar todas as 5 tabs reestruturadas
+4
View File
@@ -177,6 +177,10 @@ Use **Dashboard → Translator** to debug format translation issues:
- **Thinking tags not appearing** — Check if the target provider supports thinking and the thinking budget setting
- **Tool calls dropping** — Some format translations may strip unsupported fields; verify in Playground mode
- **System prompt missing** — Claude and Gemini handle system prompts differently; check translation output
- **SDK returns raw string instead of object** — Fixed in v1.1.0: response sanitizer now strips non-standard fields (`x_groq`, `usage_breakdown`, etc.) that cause OpenAI SDK Pydantic validation failures
- **GLM/ERNIE rejects `system` role** — Fixed in v1.1.0: role normalizer automatically merges system messages into user messages for incompatible models
- **`developer` role not recognized** — Fixed in v1.1.0: automatically converted to `system` for non-OpenAI providers
- **`json_schema` not working with Gemini** — Fixed in v1.1.0: `response_format` is now converted to Gemini's `responseMimeType` + `responseSchema`
---
+65 -8
View File
@@ -513,6 +513,9 @@ Configure via **Dashboard → Settings → Routing**.
| **Fill First** | Uses accounts in priority order — primary account handles all requests until unavailable |
| **Round Robin** | Cycles through all accounts with a configurable sticky limit (default: 3 calls per account) |
| **P2C (Power of Two Choices)** | Picks 2 random accounts and routes to the healthier one — balances load with awareness of health |
| **Random** | Randomly selects an account for each request using Fisher-Yates shuffle |
| **Least Used** | Routes to the account with the oldest `lastUsedAt` timestamp, distributing traffic evenly |
| **Cost Optimized** | Routes to the account with the lowest priority value, optimizing for lowest-cost providers |
#### Wildcard Model Aliases
@@ -542,25 +545,79 @@ Chain: production-fallback
Configure via **Dashboard → Settings → Resilience**.
OmniRoute implements provider-level resilience with three components:
OmniRoute implements provider-level resilience with four components:
1. **Circuit Breaker** — Tracks failures per provider and automatically opens the circuit when a threshold is reached:
- **CLOSED** (Healthy) — Requests flow normally
- **OPEN** — Provider is temporarily blocked after repeated failures
- **HALF_OPEN** — Testing if provider has recovered
2. **Provider Profiles** — Per-provider configuration for:
1. **Provider Profiles** — Per-provider configuration for:
- Failure threshold (how many failures before opening)
- Cooldown duration
- Rate limit detection sensitivity
- Exponential backoff parameters
3. **Rate Limit Auto-Detection** — Monitors `429` and `Retry-After` headers to proactively avoid hitting provider rate limits.
2. **Editable Rate Limits** — System-level defaults configurable in the dashboard:
- **Requests Per Minute (RPM)** — Maximum requests per minute per account
- **Min Time Between Requests** — Minimum gap in milliseconds between requests
- **Max Concurrent Requests** — Maximum simultaneous requests per account
- Click **Edit** to modify, then **Save** or **Cancel**. Values persist via the resilience API.
3. **Circuit Breaker** — Tracks failures per provider and automatically opens the circuit when a threshold is reached:
- **CLOSED** (Healthy) — Requests flow normally
- **OPEN** — Provider is temporarily blocked after repeated failures
- **HALF_OPEN** — Testing if provider has recovered
4. **Policies & Locked Identifiers** — Shows circuit breaker status and locked identifiers with force-unlock capability.
5. **Rate Limit Auto-Detection** — Monitors `429` and `Retry-After` headers to proactively avoid hitting provider rate limits.
**Pro Tip:** Use **Reset All** button to clear all circuit breakers and cooldowns when a provider recovers from an outage.
---
### Database Export / Import
Manage database backups in **Dashboard → Settings → System & Storage**.
| Action | Description |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| **Export Database** | Downloads the current SQLite database as a `.sqlite` file |
| **Export All (.tar.gz)** | Downloads a full backup archive including: database, settings, combos, provider connections (no credentials), API key metadata |
| **Import Database** | Upload a `.sqlite` file to replace the current database. A pre-import backup is automatically created |
```bash
# API: Export database
curl -o backup.sqlite http://localhost:20128/api/db-backups/export
# API: Export all (full archive)
curl -o backup.tar.gz http://localhost:20128/api/db-backups/exportAll
# API: Import database
curl -X POST http://localhost:20128/api/db-backups/import \
-F "file=@backup.sqlite"
```
**Import Validation:** The imported file is validated for integrity (SQLite pragma check), required tables (`provider_connections`, `provider_nodes`, `combos`, `api_keys`), and size (max 100MB).
**Use Cases:**
- Migrate OmniRoute between machines
- Create external backups for disaster recovery
- Share configurations between team members (export all → share archive)
---
### Settings Dashboard
The settings page is organized into 5 tabs for easy navigation:
| Tab | Contents |
| -------------- | ---------------------------------------------------------------------------------------------- |
| **Security** | Login/Password settings and IP Access Control (allowlist/blocklist) |
| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Provider profiles, editable rate limits, circuit breaker status, policies & locked identifiers |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5) |
---
### Costs & Budget Management
Access via **Dashboard → Costs**.
+399
View File
@@ -0,0 +1,399 @@
# OmniRoute — Guia de Deploy em VM com Cloudflare
Guia completo para instalar e configurar o OmniRoute em uma VM (VPS) com domínio gerenciado via Cloudflare.
---
## Pré-Requisitos
| Item | Mínimo | Recomendado |
| ----------- | ------------------------ | ---------------- |
| **CPU** | 1 vCPU | 2 vCPU |
| **RAM** | 1 GB | 2 GB |
| **Disco** | 10 GB SSD | 25 GB SSD |
| **SO** | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
| **Domínio** | Registrado no Cloudflare | — |
| **Docker** | Docker Engine 24+ | Docker 27+ |
**Providers testados**: Akamai (Linode), DigitalOcean, Vultr, Hetzner, AWS Lightsail.
---
## 1. Configurar a VM
### 1.1 Criar a instância
No seu provider de VPS preferido:
- Escolha Ubuntu 24.04 LTS
- Selecione o plano mínimo (1 vCPU / 1 GB RAM)
- Defina uma senha forte para root ou configure SSH key
- Anote o **IP público** (ex: `203.0.113.10`)
### 1.2 Conectar via SSH
```bash
ssh root@203.0.113.10
```
### 1.3 Atualizar o sistema
```bash
apt update && apt upgrade -y
```
### 1.4 Instalar Docker
```bash
# Instalar dependências
apt install -y ca-certificates curl gnupg
# Adicionar repositório oficial do Docker
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
```
### 1.5 Instalar nginx
```bash
apt install -y nginx
```
### 1.6 Configurar Firewall (UFW)
```bash
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP (redirect)
ufw allow 443/tcp # HTTPS
ufw enable
```
> **Dica**: Para segurança máxima, restrinja as portas 80 e 443 apenas para IPs da Cloudflare. Veja a seção [Segurança Avançada](#segurança-avançada).
---
## 2. Instalar o OmniRoute
### 2.1 Criar diretório de configuração
```bash
mkdir -p /opt/omniroute
```
### 2.2 Criar arquivo de variáveis de ambiente
```bash
cat > /opt/omniroute/.env << 'EOF'
# === Segurança ===
JWT_SECRET=ALTERE-PARA-CHAVE-SECRETA-UNICA-64-CHARS
INITIAL_PASSWORD=SuaSenhaSegura123!
API_KEY_SECRET=ALTERE-PARA-OUTRA-CHAVE-SECRETA
STORAGE_ENCRYPTION_KEY=ALTERE-PARA-TERCEIRA-CHAVE-SECRETA
STORAGE_ENCRYPTION_KEY_VERSION=v1
MACHINE_ID_SALT=ALTERE-PARA-SALT-UNICO
# === App ===
PORT=20128
NODE_ENV=production
HOSTNAME=0.0.0.0
DATA_DIR=/app/data
STORAGE_DRIVER=sqlite
ENABLE_REQUEST_LOGS=true
AUTH_COOKIE_SECURE=false
REQUIRE_API_KEY=false
# === Domain (altere para seu domínio) ===
BASE_URL=https://llms.seudominio.com
NEXT_PUBLIC_BASE_URL=https://llms.seudominio.com
# === Cloud Sync (opcional) ===
# CLOUD_URL=https://cloud.omniroute.online
# NEXT_PUBLIC_CLOUD_URL=https://cloud.omniroute.online
EOF
```
> ⚠️ **IMPORTANTE**: Gere chaves secretas únicas! Use `openssl rand -hex 32` para cada chave.
### 2.3 Iniciar o container
```bash
docker pull diegosouzapw/omniroute:latest
docker run -d \
--name omniroute \
--restart unless-stopped \
--env-file /opt/omniroute/.env \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
### 2.4 Verificar se está rodando
```bash
docker ps | grep omniroute
docker logs omniroute --tail 20
```
Deve exibir: `[DB] SQLite database ready` e `listening on port 20128`.
---
## 3. Configurar nginx (Reverse Proxy)
### 3.1 Gerar certificado SSL (Cloudflare Origin)
No painel da Cloudflare:
1. Vá em **SSL/TLS → Origin Server**
2. Clique **Create Certificate**
3. Deixe os padrões (15 anos, \*.seudominio.com)
4. Copie o **Origin Certificate** e a **Private Key**
```bash
mkdir -p /etc/nginx/ssl
# Colar o certificado
nano /etc/nginx/ssl/origin.crt
# Colar a chave privada
nano /etc/nginx/ssl/origin.key
chmod 600 /etc/nginx/ssl/origin.key
```
### 3.2 Configuração do nginx
```bash
cat > /etc/nginx/sites-available/omniroute << 'NGINX'
# Default server — bloqueia acesso direto por IP
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_certificate /etc/nginx/ssl/origin.crt;
ssl_certificate_key /etc/nginx/ssl/origin.key;
server_name _;
return 444;
}
# OmniRoute — HTTPS
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name llms.seudominio.com; # Altere para seu domínio
ssl_certificate /etc/nginx/ssl/origin.crt;
ssl_certificate_key /etc/nginx/ssl/origin.key;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:20128;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# SSE (Server-Sent Events) — streaming AI responses
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name llms.seudominio.com;
return 301 https://$server_name$request_uri;
}
NGINX
```
### 3.3 Ativar e testar
```bash
# Remover config padrão
rm -f /etc/nginx/sites-enabled/default
# Ativar OmniRoute
ln -sf /etc/nginx/sites-available/omniroute /etc/nginx/sites-enabled/omniroute
# Testar e recarregar
nginx -t && systemctl reload nginx
```
---
## 4. Configurar Cloudflare DNS
### 4.1 Adicionar registro DNS
No painel da Cloudflare → DNS:
| Type | Name | Content | Proxy |
| ---- | ------ | ------------------------- | ---------- |
| A | `llms` | `203.0.113.10` (IP da VM) | ✅ Proxied |
### 4.2 Configurar SSL
Em **SSL/TLS → Overview**:
- Modo: **Full (Strict)**
Em **SSL/TLS → Edge Certificates**:
- Always Use HTTPS: ✅ On
- Minimum TLS Version: TLS 1.2
- Automatic HTTPS Rewrites: ✅ On
### 4.3 Testar
```bash
curl -sI https://llms.seudominio.com/health
# Deve retornar HTTP/2 200
```
---
## 5. Operações e Manutenção
### Atualizar para nova versão
```bash
docker pull diegosouzapw/omniroute:latest
docker stop omniroute && docker rm omniroute
docker run -d --name omniroute --restart unless-stopped \
--env-file /opt/omniroute/.env \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
### Ver logs
```bash
docker logs -f omniroute # Stream em tempo real
docker logs omniroute --tail 50 # Últimas 50 linhas
```
### Backup manual do banco
```bash
# Copiar dados do volume para o host
docker cp omniroute:/app/data ./backup-$(date +%F)
# Ou comprimir todo o volume
docker run --rm -v omniroute-data:/data -v $(pwd):/backup \
alpine tar czf /backup/omniroute-data-$(date +%F).tar.gz /data
```
### Restaurar de backup
```bash
docker stop omniroute
docker run --rm -v omniroute-data:/data -v $(pwd):/backup \
alpine sh -c "rm -rf /data/* && tar xzf /backup/omniroute-data-YYYY-MM-DD.tar.gz -C /"
docker start omniroute
```
---
## 6. Segurança Avançada
### Restringir nginx para Cloudflare IPs
```bash
cat > /etc/nginx/cloudflare-ips.conf << 'CF'
# Cloudflare IPv4 ranges — atualizar periodicamente
# https://www.cloudflare.com/ips-v4/
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
real_ip_header CF-Connecting-IP;
CF
```
Adicionar no `nginx.conf` dentro do bloco `http {}`:
```nginx
include /etc/nginx/cloudflare-ips.conf;
```
### Install fail2ban
```bash
apt install -y fail2ban
systemctl enable fail2ban
systemctl start fail2ban
# Verificar status
fail2ban-client status sshd
```
### Bloquear acesso direto na porta do Docker
```bash
# Impedir acesso externo direto à porta 20128
iptables -I DOCKER-USER -p tcp --dport 20128 -j DROP
iptables -I DOCKER-USER -i lo -p tcp --dport 20128 -j ACCEPT
# Persistir as regras
apt install -y iptables-persistent
netfilter-persistent save
```
---
## 7. Deploy do Cloud Worker (Opcional)
Para acesso remoto via Cloudflare Workers (sem expor a VM diretamente):
```bash
# No repositório local
cd omnirouteCloud
npm install
npx wrangler login
npx wrangler deploy
```
Ver documentação completa em [omnirouteCloud/README.md](../omnirouteCloud/README.md).
---
## Resumo de Portas
| Porta | Serviço | Acesso |
| ----- | ----------- | ----------------------------- |
| 22 | SSH | Público (com fail2ban) |
| 80 | nginx HTTP | Redirect → HTTPS |
| 443 | nginx HTTPS | Via Cloudflare Proxy |
| 20128 | OmniRoute | Somente localhost (via nginx) |
+37
View File
@@ -0,0 +1,37 @@
# ADR-001: Next.js as the Foundation for an AI Gateway
## Status: Accepted
## Context
OmniRoute is an AI routing gateway that translates, forwards, and manages requests across 20+ LLM providers. We needed a framework that could serve both the API proxy layer and a management dashboard from a single codebase.
**Alternatives considered:**
- **Express.js only** — Simpler proxy, but requires separate frontend tooling
- **Fastify** — Fast, but no built-in SSR/dashboard support
- **Next.js** — Unified full-stack framework with API routes, SSR, and static pages
## Decision
We chose Next.js because:
1. **Single deployment** — API routes (`/api/*`) and dashboard UI in one process
2. **Middleware layer** — Native request interception for auth guards and request tracing
3. **File-based routing** — Easy to map provider endpoints to handlers
4. **Built-in TypeScript** — Type safety across the entire codebase
## Consequences
**Positive:**
- One `npm run build` produces both API and UI
- Middleware provides centralized auth and request tracing
- Dashboard gets automatic code splitting and optimization
**Negative:**
- Next.js middleware has limitations (no heavy imports, edge runtime constraints)
- Serverless deployment model doesn't align with persistent WebSocket/SSE connections
- Build times are longer than Express-only setups
- The SSE proxy layer (`open-sse/`) operates outside Next.js conventions
+37
View File
@@ -0,0 +1,37 @@
# ADR-002: Hub-and-Spoke Translation with OpenAI as Intermediate Format
## Status: Accepted
## Context
OmniRoute routes requests across 20+ providers, each with its own API format (OpenAI, Anthropic Messages, Google Gemini, AWS Bedrock, etc.). Direct provider-to-provider translation would require O(n²) translators.
**Alternatives considered:**
- **Direct translation** — Each pair needs a dedicated translator (n² complexity)
- **Common intermediate format** — Translate to/from a canonical format (2n complexity)
- **Protocol buffers** — Strong typing but heavy overhead for a proxy
## Decision
We use the **OpenAI Chat Completions format** as the canonical intermediate representation. All incoming requests are normalized to OpenAI format, processed, then translated to the target provider's format.
```
Client → [any format] → OpenAI canonical → [target format] → Provider
Provider → [response] → OpenAI canonical → [original format] → Client
```
## Consequences
**Positive:**
- Only 2 translators per provider (inbound + outbound) instead of n² pairs
- OpenAI format is the de facto standard — most clients already use it
- Adding a new provider requires only implementing one translator pair
- Streaming (SSE) works consistently through the canonical format
**Negative:**
- Some provider-specific features may be lost in translation
- The double translation adds latency (typically < 5ms)
- OpenAI format changes require updating the canonical representation
+39
View File
@@ -0,0 +1,39 @@
# ADR-003: Dual Storage — SQLite Primary with JSON Migration Path
## Status: Accepted
## Context
OmniRoute originally used LowDB (JSON file) for all persistence. As the project grew, JSON-based storage became a bottleneck for concurrent access, querying, and data integrity.
**Alternatives considered:**
- **LowDB only** — Simple but no concurrent access, no ACID, no querying
- **SQLite only** — Fast, ACID-compliant, but breaks existing deployments
- **PostgreSQL** — Production-grade but requires external dependency
- **Dual storage with migration** — SQLite primary + automatic JSON migration
## Decision
We migrated to **SQLite as the primary store** with an automatic one-time migration from `db.json`:
1. On startup, if `db.json` exists and SQLite is empty, auto-migrate all data
2. All new reads/writes go through SQLite
3. The `db.json` file is preserved but no longer written to
Settings remain in a hybrid model where LowDB handles simple key-value configuration for backward compatibility.
## Consequences
**Positive:**
- ACID transactions for provider connections, API keys, and usage data
- Proper SQL queries for analytics and log filtering
- Concurrent read/write safety via WAL mode
- Zero-downtime migration from JSON — users upgrade transparently
**Negative:**
- Two storage engines to maintain (SQLite + LowDB for settings)
- Migration code must handle edge cases and partial data
- SQLite binary dependency needed in deployment environments
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

+15 -4
View File
@@ -1,9 +1,10 @@
import nextVitals from "eslint-config-next/core-web-vitals";
import tseslint from "typescript-eslint";
/** @type {import("eslint").Linter.Config[]} */
const eslintConfig = [
...nextVitals,
// FASE-02: Security rules
// FASE-02: Security rules (strict everywhere)
{
rules: {
"no-eval": "error",
@@ -11,18 +12,28 @@ const eslintConfig = [
"no-new-func": "error",
},
},
// Global ignores
// Relaxed rules for open-sse and tests (incremental adoption)
{
files: ["open-sse/**/*.ts", "tests/**/*.mjs", "tests/**/*.ts"],
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@next/next/no-assign-module-variable": "off",
"react-hooks/rules-of-hooks": "off",
},
},
// Global ignores (open-sse and tests REMOVED — now linted)
{
ignores: [
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"tests/**",
"scripts/**",
"bin/**",
"node_modules/**",
"open-sse/**",
],
},
];
+134
View File
@@ -0,0 +1,134 @@
# OmniRoute
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 36+ AI providers — all through a single OpenAI-compatible endpoint.
## Overview
OmniRoute solves the problem of managing multiple AI provider subscriptions, quotas, and rate limits. It sits between your AI-powered tools (IDE agents, CLI tools) and AI providers, routing requests intelligently through a 4-tier fallback system: Subscription → API Key → Cheap → Free.
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
## Tech Stack
- **Runtime:** Node.js >= 18
- **Framework:** Next.js 16 (App Router) with TypeScript
- **Database:** SQLite via better-sqlite3 (local, zero-config)
- **State management:** Zustand (client), lowdb (server JSON persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
- **Background jobs:** Custom token health check scheduler
- **Streaming:** Server-Sent Events (SSE) for real-time proxy responses
- **Proxy engine:** Custom pipeline with format translation, circuit breaker, rate limiting
- **Package:** Published on npm (`omniroute`) and Docker Hub (`diegosouzapw/omniroute`)
## Project Structure
```
/
├── src/ # Main application source
│ ├── app/ # Next.js App Router pages and API routes
│ │ ├── (dashboard)/ # Dashboard UI pages (providers, combos, analytics, logs, etc.)
│ │ ├── api/ # REST API endpoints
│ │ │ ├── v1/ # OpenAI-compatible API (chat, models, embeddings, images, audio)
│ │ │ ├── oauth/ # OAuth flows per provider (authorize, exchange, callback)
│ │ │ ├── providers/ # Provider CRUD and batch testing
│ │ │ ├── models/ # Dashboard model listing and aliases
│ │ │ ├── combos/ # Combo CRUD (multi-model fallback chains)
│ │ │ └── ... # Other endpoints (usage, logs, health, settings, etc.)
│ │ └── login/ # Login page
│ ├── domain/ # Domain types and business logic interfaces
│ ├── lib/ # Core libraries
│ │ ├── db/ # SQLite database layer (providers, combos, prompts, logs)
│ │ ├── oauth/ # OAuth providers, services, and utilities
│ │ │ ├── providers/ # Provider-specific OAuth configs (GitHub, Google, Claude, etc.)
│ │ │ ├── services/ # Provider-specific token exchange logic
│ │ │ └── utils/ # PKCE, callback server, token helpers
│ │ ├── cloudSync.ts # Cloud sync via Cloudflare Workers
│ │ ├── tokenHealthCheck.ts # Background OAuth token refresh scheduler
│ │ └── localDb.ts # Unified database access layer
│ ├── shared/ # Shared utilities, components, and constants
│ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, etc.)
│ │ ├── constants/ # Provider definitions, model lists, pricing
│ │ └── utils/ # Helpers (auth, CORS, error codes, machine ID)
│ ├── sse/ # SSE proxy pipeline
│ │ ├── services/ # Auth resolution, format translation, response handling
│ │ └── middleware/ # Rate limiting, circuit breaker, caching, idempotency
│ ├── store/ # Zustand client-side stores
│ ├── types/ # TypeScript type definitions
│ ├── proxy.ts # Main proxy request handler
│ └── server-init.ts # Server initialization (DB, health checks)
├── open-sse/ # Standalone SSE server (npm workspace)
│ ├── config/ # Model registries (embedding, image, audio, rerank, moderation)
│ ├── handlers/ # Request handlers per API type
│ └── translators/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses)
├── tests/ # Test suites
│ ├── unit/ # Unit tests (32+ test files)
│ └── integration/ # Integration tests
├── docs/ # Documentation and screenshots
├── bin/ # CLI entry points (omniroute, reset-password)
└── .env.example # Environment variable template
```
## Key Architectural Decisions
1. **OpenAI-compatible API surface:** All incoming requests follow the OpenAI API format (`/v1/chat/completions`, `/v1/models`, etc.). This makes OmniRoute a drop-in replacement for any tool that supports custom OpenAI endpoints.
2. **Provider abstraction via format translators:** Each AI provider (Claude, Gemini, etc.) has a translator in `open-sse/translators/` that converts between the OpenAI format and the provider's native format. This happens transparently.
3. **Connection-based provider model:** Providers are stored as "connections" in SQLite. Each connection has an `id`, `provider`, `authType` (oauth/apikey/free), `isActive` flag, and credentials. Multiple connections per provider are supported for multi-account rotation.
4. **Combo system for fallback:** Users create "combos" — ordered lists of `provider/model` pairs. The proxy tries each in order until one succeeds. Supports 6 strategies: fill-first, round-robin, power-of-two-choices, random, least-used, cost-optimized.
5. **SSE proxy pipeline (`src/sse/`):** The proxy pipeline is middleware-based: request → auth resolution → rate limiting → circuit breaker → format translation → upstream call → response translation → SSE streaming back to client.
6. **SQLite for persistence:** All state (providers, combos, logs, settings) is stored in a single SQLite database file at `data/omniroute.db`. This keeps the app self-contained and zero-config.
7. **OAuth with PKCE:** OAuth flows use PKCE for security. A local callback server (`src/lib/oauth/utils/server.ts`) handles the redirect. Token refresh is handled by a background job (`tokenHealthCheck.ts`).
## Main Flows
### Proxy Request Flow
1. Client sends OpenAI-format request to `/v1/chat/completions`
2. API key validation (`src/shared/utils/apiAuth.ts`)
3. Model resolution: direct model or combo lookup
4. For combos: iterate through models in fallback order
5. Auth resolution: get credentials for the target provider
6. Format translation: OpenAI → provider native format
7. Upstream request with circuit breaker and rate limiting
8. Response translation: provider → OpenAI format
9. SSE streaming back to client
### OAuth Flow
1. Dashboard initiates `/api/oauth/[provider]/authorize`
2. User completes OAuth login in browser
3. Callback hits `/api/oauth/[provider]/exchange`
4. Tokens stored as a provider connection in SQLite
5. Background job refreshes tokens before expiry
### Model Listing
- `/api/models` — Dashboard endpoint, lists all defined models with aliases
- `/v1/models` — OpenAI-compatible endpoint, lists only models from active providers
## Important Notes for LLMs
1. **Two model endpoints exist:** `/api/models` (dashboard, all models) and `/v1/models` (OpenAI-compatible, active only). Don't confuse them.
2. **Provider IDs vs aliases:** Providers have both an ID (`claude`, `github`) and a short alias (`cc`, `gh`). Models are referenced as `alias/model-name` (e.g., `cc/claude-opus-4-6`).
3. **The `open-sse/` directory is a separate npm workspace** with its own config, handlers, and translators. It handles the actual SSE streaming and format translation.
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
5. **Database migrations:** SQLite schema is managed inline in `src/lib/db/core.ts` and `src/lib/db/providers.ts`. No migration framework — schema changes are applied on startup.
6. **Tests use Node.js built-in test runner:** Run `npm test` or `node --test tests/unit/*.test.mjs`. Playwright is used for E2E tests.
7. **The proxy pipeline is in `src/sse/`**, not in `src/app/api/v1/`. The API routes in `src/app/api/v1/` delegate to the SSE server running on a separate Express instance.
## Links
- Repository: https://github.com/diegosouzapw/OmniRoute
- Website: https://omniroute.online
- npm: https://www.npmjs.com/package/omniroute
- Docker Hub: https://hub.docker.com/r/diegosouzapw/omniroute
- Documentation: See `/docs/` directory
+3 -3
View File
@@ -2,12 +2,12 @@
const nextConfig = {
turbopack: {},
output: "standalone",
serverExternalPackages: ["better-sqlite3"],
transpilePackages: ["@omniroute/open-sse"],
allowedDevOrigins: ["192.168.*"],
typescript: {
// Migration Phase: ignore TS errors during build.
// Remove after all 984 type errors are resolved.
ignoreBuildErrors: true,
// All TS errors resolved — strict checking enforced
ignoreBuildErrors: false,
},
images: {
unoptimized: true,
+16 -6
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
/**
* Audio Speech Handler (TTS)
*
@@ -40,7 +41,10 @@ async function handleHyperbolicSpeech(providerConfig, body, token) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -52,7 +56,7 @@ async function handleHyperbolicSpeech(providerConfig, body, token) {
status: 200,
headers: {
"Content-Type": "audio/mpeg",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -77,7 +81,10 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -86,7 +93,7 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
@@ -154,7 +161,10 @@ export async function handleAudioSpeech({ body, credentials }) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -164,7 +174,7 @@ export async function handleAudioSpeech({ body, credentials }) {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
+26 -9
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
/**
* Audio Transcription Handler
*
@@ -46,7 +47,10 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token)
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -54,7 +58,7 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token)
// Transform Deepgram response to OpenAI Whisper format
const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript || "";
return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": "*" } });
return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } });
}
/**
@@ -78,7 +82,10 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
const errText = await uploadRes.text();
return new Response(errText, {
status: uploadRes.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -102,7 +109,10 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
const errText = await submitRes.text();
return new Response(errText, {
status: submitRes.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -124,7 +134,7 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
if (result.status === "completed") {
return Response.json(
{ text: result.text || "" },
{ headers: { "Access-Control-Allow-Origin": "*" } }
{ headers: { "Access-Control-Allow-Origin": getCorsOrigin() } }
);
}
@@ -182,7 +192,11 @@ export async function handleAudioTranscription({ formData, credentials }) {
// Default: OpenAI/Groq-compatible multipart proxy
const upstreamForm = new FormData();
upstreamForm.append("file", /** @type {Blob} */ (file), /** @type {any} */ (file).name || "audio.wav");
upstreamForm.append(
"file",
/** @type {Blob} */ file,
/** @type {any} */ file.name || "audio.wav"
);
upstreamForm.append("model", modelId);
// Forward optional parameters
@@ -195,7 +209,7 @@ export async function handleAudioTranscription({ formData, credentials }) {
]) {
const val = formData.get(key);
if (val !== null && val !== undefined) {
upstreamForm.append(key, /** @type {string} */ (val));
upstreamForm.append(key, /** @type {string} */ val);
}
}
@@ -210,7 +224,10 @@ export async function handleAudioTranscription({ formData, credentials }) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -219,7 +236,7 @@ export async function handleAudioTranscription({ formData, credentials }) {
return new Response(data, {
status: 200,
headers: { "Content-Type": contentType, "Access-Control-Allow-Origin": "*" },
headers: { "Content-Type": contentType, "Access-Control-Allow-Origin": getCorsOrigin() },
});
} catch (err) {
return errorResponse(500, `Transcription request failed: ${err.message}`);
+15 -6
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
import { detectFormat, getTargetFormat } from "../services/provider.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
@@ -24,6 +25,7 @@ import { getExecutor } from "../executors/index.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
import { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } from "./sseParser.ts";
import { sanitizeOpenAIResponse } from "./responseSanitizer.ts";
import {
withRateLimit,
updateFromHeaders,
@@ -81,7 +83,7 @@ export async function handleChatCore({
status: cachedIdemp.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
"X-OmniRoute-Idempotent": "true",
},
}),
@@ -122,7 +124,7 @@ export async function handleChatCore({
response: new Response(JSON.stringify(cached), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
"X-OmniRoute-Cache": "HIT",
},
}),
@@ -188,7 +190,7 @@ export async function handleChatCore({
status: statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}
),
@@ -471,10 +473,17 @@ export async function handleChatCore({
}
// Translate response to client's expected format (usually OpenAI)
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
let translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
: responseBody;
// Sanitize response for OpenAI SDK compatibility
// Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.)
// Extracts <think> tags into reasoning_content
if (sourceFormat === FORMATS.OPENAI) {
translatedResponse = sanitizeOpenAIResponse(translatedResponse);
}
// Add buffer and filter usage for client (to prevent CLI context errors)
if (translatedResponse?.usage) {
const buffered = addBufferToUsage(translatedResponse.usage);
@@ -506,7 +515,7 @@ export async function handleChatCore({
response: new Response(JSON.stringify(translatedResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
"X-OmniRoute-Cache": "MISS",
},
}),
@@ -524,7 +533,7 @@ export async function handleChatCore({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
};
// Create transform stream with logger for streaming response
+6 -2
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
/**
* Moderation Handler
*
@@ -55,13 +56,16 @@ export async function handleModeration({ body, credentials }) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
const data = await res.json();
return Response.json(data, {
headers: { "Access-Control-Allow-Origin": "*" },
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
});
} catch (err) {
return errorResponse(500, `Moderation request failed: ${err.message}`);
+2 -1
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
/**
* Rerank Handler
*
@@ -129,7 +130,7 @@ export async function handleRerank({
const result = transformResponseFromProvider(providerConfig, data);
return Response.json(result, {
headers: { "Access-Control-Allow-Origin": "*" },
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
});
} catch (err) {
return errorResponse(500, `Rerank request failed: ${err.message}`);
+269
View File
@@ -0,0 +1,269 @@
/**
* Response Sanitizer Normalizes LLM responses to strict OpenAI SDK format.
*
* Fixes Issues:
* 1. Strips non-standard fields (x_groq, usage_breakdown, service_tier) that
* break OpenAI Python SDK v1.83+ Pydantic validation (returns str instead of object)
* 2. Extracts <think> tags from thinking models into reasoning_content
* 3. Normalizes response id, object, and usage fields
* 4. Converts developer role system for non-OpenAI providers
*/
// ── Standard OpenAI ChatCompletion fields ──────────────────────────────────
const ALLOWED_TOP_LEVEL_FIELDS = new Set([
"id",
"object",
"created",
"model",
"choices",
"usage",
"system_fingerprint",
]);
const ALLOWED_USAGE_FIELDS = new Set([
"prompt_tokens",
"completion_tokens",
"total_tokens",
"prompt_tokens_details",
"completion_tokens_details",
]);
const ALLOWED_MESSAGE_FIELDS = new Set([
"role",
"content",
"tool_calls",
"function_call",
"refusal",
"reasoning_content",
]);
const ALLOWED_CHOICE_FIELDS = new Set(["index", "message", "delta", "finish_reason", "logprobs"]);
// ── Think tag regex ────────────────────────────────────────────────────────
// Matches <think>...</think> blocks (greedy, dotAll)
const THINK_TAG_REGEX = /<think>([\s\S]*?)<\/think>/gi;
/**
* Extract <think> blocks from text content and return separated parts.
* @returns {{ content: string, thinking: string | null }}
*/
export function extractThinkingFromContent(text: string): {
content: string;
thinking: string | null;
} {
if (!text || typeof text !== "string") {
return { content: text || "", thinking: null };
}
const thinkingParts: string[] = [];
let hasThinkTags = false;
const cleaned = text.replace(THINK_TAG_REGEX, (_, thinkContent) => {
hasThinkTags = true;
const trimmed = thinkContent.trim();
if (trimmed) {
thinkingParts.push(trimmed);
}
return "";
});
if (!hasThinkTags) {
return { content: text, thinking: null };
}
return {
content: cleaned.trim(),
thinking: thinkingParts.length > 0 ? thinkingParts.join("\n\n") : null,
};
}
/**
* Sanitize a non-streaming OpenAI ChatCompletion response.
* Strips non-standard fields and normalizes required fields.
*/
export function sanitizeOpenAIResponse(body: any): any {
if (!body || typeof body !== "object") return body;
// Build sanitized response with only allowed top-level fields
const sanitized: Record<string, any> = {};
// Ensure required fields exist
sanitized.id = normalizeResponseId(body.id);
sanitized.object = body.object || "chat.completion";
sanitized.created = body.created || Math.floor(Date.now() / 1000);
sanitized.model = body.model || "unknown";
// Sanitize choices
if (Array.isArray(body.choices)) {
sanitized.choices = body.choices.map((choice: any, idx: number) => sanitizeChoice(choice, idx));
} else {
sanitized.choices = [];
}
// Sanitize usage
if (body.usage && typeof body.usage === "object") {
sanitized.usage = sanitizeUsage(body.usage);
}
// Keep system_fingerprint if present (it's a valid OpenAI field)
if (body.system_fingerprint) {
sanitized.system_fingerprint = body.system_fingerprint;
}
return sanitized;
}
/**
* Sanitize a single choice object.
*/
function sanitizeChoice(choice: any, defaultIndex: number): any {
const sanitized: Record<string, any> = {
index: choice.index ?? defaultIndex,
finish_reason: choice.finish_reason || null,
};
// Sanitize message (non-streaming) or delta (streaming)
if (choice.message) {
sanitized.message = sanitizeMessage(choice.message);
}
if (choice.delta) {
sanitized.delta = sanitizeMessage(choice.delta);
}
// Keep logprobs if present
if (choice.logprobs !== undefined) {
sanitized.logprobs = choice.logprobs;
}
return sanitized;
}
/**
* Sanitize a message object, extracting <think> tags if present.
*/
function sanitizeMessage(msg: any): any {
if (!msg || typeof msg !== "object") return msg;
const sanitized: Record<string, any> = {};
// Copy only allowed fields
if (msg.role) sanitized.role = msg.role;
if (msg.refusal !== undefined) sanitized.refusal = msg.refusal;
// Handle content — extract <think> tags
if (typeof msg.content === "string") {
const { content, thinking } = extractThinkingFromContent(msg.content);
sanitized.content = content;
// Set reasoning_content from <think> tags (if not already set)
if (thinking && !msg.reasoning_content) {
sanitized.reasoning_content = thinking;
}
} else if (msg.content !== undefined) {
sanitized.content = msg.content;
}
// Preserve existing reasoning_content (from providers that natively support it)
if (msg.reasoning_content && !sanitized.reasoning_content) {
sanitized.reasoning_content = msg.reasoning_content;
}
// Preserve tool_calls
if (msg.tool_calls) {
sanitized.tool_calls = msg.tool_calls;
}
// Preserve function_call (legacy)
if (msg.function_call) {
sanitized.function_call = msg.function_call;
}
return sanitized;
}
/**
* Sanitize usage object keep only standard fields.
*/
function sanitizeUsage(usage: any): any {
if (!usage || typeof usage !== "object") return usage;
const sanitized: Record<string, any> = {};
for (const key of ALLOWED_USAGE_FIELDS) {
if (usage[key] !== undefined) {
sanitized[key] = usage[key];
}
}
// Ensure required fields
if (sanitized.prompt_tokens === undefined) sanitized.prompt_tokens = 0;
if (sanitized.completion_tokens === undefined) sanitized.completion_tokens = 0;
if (sanitized.total_tokens === undefined) {
sanitized.total_tokens = sanitized.prompt_tokens + sanitized.completion_tokens;
}
return sanitized;
}
/**
* Normalize response ID to use chatcmpl- prefix.
*/
function normalizeResponseId(id: any): string {
if (!id || typeof id !== "string") {
return `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 29)}`;
}
// Already correct format
if (id.startsWith("chatcmpl-")) return id;
// Keep custom IDs but don't break them
return id;
}
/**
* Sanitize a streaming SSE chunk for passthrough mode.
* Lighter than full sanitization only strips problematic extra fields.
*/
export function sanitizeStreamingChunk(parsed: any): any {
if (!parsed || typeof parsed !== "object") return parsed;
// Build sanitized chunk
const sanitized: Record<string, any> = {};
// Keep only standard fields
if (parsed.id !== undefined) sanitized.id = parsed.id;
sanitized.object = parsed.object || "chat.completion.chunk";
if (parsed.created !== undefined) sanitized.created = parsed.created;
if (parsed.model !== undefined) sanitized.model = parsed.model;
// Sanitize choices with delta
if (Array.isArray(parsed.choices)) {
sanitized.choices = parsed.choices.map((choice: any) => {
const c: Record<string, any> = {
index: choice.index ?? 0,
};
if (choice.delta !== undefined) {
c.delta = {};
const delta = choice.delta;
if (delta.role !== undefined) c.delta.role = delta.role;
if (delta.content !== undefined) c.delta.content = delta.content;
if (delta.reasoning_content !== undefined)
c.delta.reasoning_content = delta.reasoning_content;
if (delta.tool_calls !== undefined) c.delta.tool_calls = delta.tool_calls;
if (delta.function_call !== undefined) c.delta.function_call = delta.function_call;
}
if (choice.finish_reason !== undefined) c.finish_reason = choice.finish_reason;
if (choice.logprobs !== undefined) c.logprobs = choice.logprobs;
return c;
});
}
// Sanitize usage if present
if (parsed.usage && typeof parsed.usage === "object") {
sanitized.usage = sanitizeUsage(parsed.usage);
}
// Keep system_fingerprint if present
if (parsed.system_fingerprint) {
sanitized.system_fingerprint = parsed.system_fingerprint;
}
return sanitized;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
/**
* Responses API Handler for Workers
* Converts Chat Completions to Codex Responses API format
@@ -75,7 +76,7 @@ export async function handleResponsesCore({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};
+169
View File
@@ -0,0 +1,169 @@
/**
* Role Normalizer Converts message roles for provider compatibility.
*
* Fixes Issues:
* 1. GLM/ZhipuAI rejects `system` role merged into first `user` message
* 2. OpenAI `developer` role not understood by non-OpenAI providers normalized to `system`
* 3. Some providers don't support `system` role at all prepended to user message
*
* Provider capability matrix is defined here rather than in the registry to
* avoid breaking changes to the existing RegistryEntry interface.
*/
// ── Provider capabilities ──────────────────────────────────────────────────
/**
* Providers that do NOT support the `system` role in messages.
* For these, system messages are merged into the first user message.
*
* Note: This applies only to OpenAI-format passthrough providers.
* Claude and Gemini have their own system message handling in dedicated translators.
*/
const PROVIDERS_WITHOUT_SYSTEM_ROLE = new Set([
// Known to reject system role (from troubleshooting report)
// GLM uses Claude format, so this is handled through claude translator
// But if accessed through OpenAI-format providers like nvidia, it needs this:
]);
/**
* Models that are known to reject the `system` role regardless of provider.
* Uses prefix matching (e.g., "glm-" matches "glm-4.7", "glm-4.5", etc.)
*/
const MODELS_WITHOUT_SYSTEM_ROLE = [
"glm-", // ZhipuAI GLM models
"ernie-", // Baidu ERNIE models
];
/**
* Check if a provider+model combo supports the system role.
*/
function supportsSystemRole(provider: string, model: string): boolean {
if (PROVIDERS_WITHOUT_SYSTEM_ROLE.has(provider)) return false;
const modelLower = (model || "").toLowerCase();
for (const prefix of MODELS_WITHOUT_SYSTEM_ROLE) {
if (modelLower.startsWith(prefix)) return false;
}
return true;
}
/**
* Normalize the `developer` role to `system` for non-OpenAI providers.
* OpenAI introduced `developer` as a replacement for `system` in newer models,
* but most other providers still expect `system`.
*
* @param messages - Array of messages
* @param targetFormat - The target format (e.g., "openai", "claude", "gemini")
* @returns Modified messages array
*/
export function normalizeDeveloperRole(messages: any[], targetFormat: string): any[] {
if (!Array.isArray(messages)) return messages;
// For OpenAI format, keep developer role as-is (it's valid)
// For all other formats, convert developer → system
if (targetFormat === "openai") return messages;
return messages.map((msg) => {
if (msg.role === "developer") {
return { ...msg, role: "system" };
}
return msg;
});
}
/**
* Convert `system` messages to user messages for providers that don't support
* the system role. The system content is prepended to the first user message
* with a clear delimiter.
*
* @param messages - Array of messages
* @param provider - Provider name
* @param model - Model name
* @returns Modified messages array
*/
export function normalizeSystemRole(messages: any[], provider: string, model: string): any[] {
if (!Array.isArray(messages) || messages.length === 0) return messages;
if (supportsSystemRole(provider, model)) return messages;
// Extract system messages
const systemMessages = messages.filter((m) => m.role === "system" || m.role === "developer");
if (systemMessages.length === 0) return messages;
// Build system content string
const systemContent = systemMessages
.map((m) => {
if (typeof m.content === "string") return m.content;
if (Array.isArray(m.content)) {
return m.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("\n");
}
return "";
})
.filter(Boolean)
.join("\n\n");
if (!systemContent) {
return messages.filter((m) => m.role !== "system" && m.role !== "developer");
}
// Remove system messages and merge into first user message
const nonSystemMessages = messages.filter((m) => m.role !== "system" && m.role !== "developer");
// Find first user message and prepend system content
const firstUserIdx = nonSystemMessages.findIndex((m) => m.role === "user");
if (firstUserIdx >= 0) {
const userMsg = nonSystemMessages[firstUserIdx];
const userContent =
typeof userMsg.content === "string"
? userMsg.content
: Array.isArray(userMsg.content)
? userMsg.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("\n")
: "";
nonSystemMessages[firstUserIdx] = {
...userMsg,
content: `[System Instructions]\n${systemContent}\n\n[User Message]\n${userContent}`,
};
} else {
// No user message found — insert as a user message at the beginning
nonSystemMessages.unshift({
role: "user",
content: `[System Instructions]\n${systemContent}`,
});
}
return nonSystemMessages;
}
/**
* Full role normalization pipeline.
* Call this before sending the request to the provider.
*
* @param messages - Array of messages
* @param provider - Provider name/id
* @param model - Model name
* @param targetFormat - Target API format
* @returns Normalized messages array
*/
export function normalizeRoles(
messages: any[],
provider: string,
model: string,
targetFormat: string
): any[] {
if (!Array.isArray(messages)) return messages;
// Step 1: Normalize developer → system (for non-OpenAI formats)
let result = normalizeDeveloperRole(messages, targetFormat);
// Step 2: Normalize system → user (for providers that don't support system role)
result = normalizeSystemRole(result, provider, model);
return result;
}
+6
View File
@@ -4,6 +4,7 @@ import { prepareClaudeRequest } from "./helpers/claudeHelper.ts";
import { filterToOpenAIFormat } from "./helpers/openaiHelper.ts";
import { normalizeThinkingConfig } from "../services/provider.ts";
import { applyThinkingBudget } from "../services/thinkingBudget.ts";
import { normalizeRoles } from "../services/roleNormalizer.ts";
// Registry for translators.
// NOTE: translator modules import this file and call register() at module-load time.
@@ -132,6 +133,11 @@ export function translateRequest(
// Fix missing tool responses (insert empty tool_result if needed)
fixMissingToolResponses(result);
// Normalize roles: developer→system for non-OpenAI, system→user for incompatible models
if (result.messages && Array.isArray(result.messages)) {
result.messages = normalizeRoles(result.messages, provider || "", model || "", targetFormat);
}
// If same format, skip translation steps
if (sourceFormat !== targetFormat) {
// Check for direct translation path first (e.g., Claude → Gemini)
@@ -199,6 +199,24 @@ function openaiToGeminiBase(model, body, stream) {
}
}
// Convert response_format to Gemini's responseMimeType/responseSchema
if (body.response_format) {
if (body.response_format.type === "json_schema" && body.response_format.json_schema) {
result.generationConfig.responseMimeType = "application/json";
// Extract the schema (may be nested under .schema key)
const schema = body.response_format.json_schema.schema || body.response_format.json_schema;
if (schema && typeof schema === "object") {
// Remove unsupported keywords for Gemini (it uses a subset of JSON Schema)
const { $schema, additionalProperties, ...cleanSchema } = schema;
result.generationConfig.responseSchema = cleanSchema;
}
} else if (body.response_format.type === "json_object") {
result.generationConfig.responseMimeType = "application/json";
} else if (body.response_format.type === "text") {
result.generationConfig.responseMimeType = "text/plain";
}
}
return result;
}
+2 -4
View File
@@ -6,6 +6,7 @@
"allowJs": true,
"checkJs": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": false,
@@ -18,8 +19,5 @@
"@omniroute/open-sse/*": ["./open-sse/*"]
}
},
"include": [
"**/*.ts",
"**/*.js"
]
"include": ["**/*.ts", "**/*.js"]
}
+4 -3
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "./cors.ts";
import { detectFormat } from "../services/provider.ts";
import { translateResponse, initState } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
@@ -122,7 +123,7 @@ function createNonStreamingResponse(sourceFormat, model) {
response: new Response(JSON.stringify(openaiResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};
@@ -156,7 +157,7 @@ function createNonStreamingResponse(sourceFormat, model) {
response: new Response(JSON.stringify(finalResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};
@@ -204,7 +205,7 @@ function createStreamingResponse(sourceFormat, model) {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};
+22
View File
@@ -0,0 +1,22 @@
/**
* CORS configuration for open-sse handlers.
*
* Reads `CORS_ORIGIN` env var (default: "*") so that all handlers
* use the same configurable origin. Equivalent to src/shared/utils/cors.ts
* for the open-sse package boundary.
*/
const CORS_ORIGIN = process.env.CORS_ORIGIN || "*";
export const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, x-api-key, anthropic-version",
};
/**
* Returns just the origin header for merging into existing header objects.
*/
export function getCorsOrigin(): string {
return CORS_ORIGIN;
}
+7 -2
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "./cors.ts";
import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/constants.ts";
/**
@@ -33,7 +34,7 @@ export function errorResponse(statusCode, message) {
status: statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -131,7 +132,11 @@ export async function parseUpstreamError(response, provider = null) {
* @param {number|null} retryAfterMs - Optional retry-after time in milliseconds
* @returns {{ success: false, status: number, error: string, response: Response, retryAfterMs?: number }}
*/
export function createErrorResult(statusCode: number, message: string, retryAfterMs: number | null = null) {
export function createErrorResult(
statusCode: number,
message: string,
retryAfterMs: number | null = null
) {
const result: Record<string, any> = {
success: false,
status: statusCode,
+5 -1
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "./cors.ts";
// Transform OpenAI SSE stream to Ollama JSON lines format
export function transformToOllama(response, model) {
let buffer = "";
@@ -85,6 +86,9 @@ export function transformToOllama(response, model) {
});
return new Response(response.body.pipeThrough(transform), {
headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/x-ndjson",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
+18 -1
View File
@@ -12,6 +12,10 @@ import {
} from "./usageTracking.ts";
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.ts";
import { STREAM_IDLE_TIMEOUT_MS, HTTP_STATUS } from "../config/constants.ts";
import {
sanitizeStreamingChunk,
extractThinkingFromContent,
} from "../handlers/responseSanitizer.ts";
export { COLORS, formatSSE };
@@ -130,7 +134,10 @@ export function createSSEStream(options: any = {}) {
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
try {
const parsed = JSON.parse(trimmed.slice(5).trim());
let parsed = JSON.parse(trimmed.slice(5).trim());
// Sanitize: strip non-standard fields for OpenAI SDK compatibility
parsed = sanitizeStreamingChunk(parsed);
const idFixed = fixInvalidId(parsed);
@@ -139,6 +146,16 @@ export function createSSEStream(options: any = {}) {
}
const delta = parsed.choices?.[0]?.delta;
// Extract <think> tags from streaming content
if (delta?.content && typeof delta.content === "string") {
const { content, thinking } = extractThinkingFromContent(delta.content);
delta.content = content;
if (thinking && !delta.reasoning_content) {
delta.reasoning_content = thinking;
}
}
const content = delta?.content || delta?.reasoning_content;
if (content && typeof content === "string") {
totalContentLength += content.length;
+184 -170
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "0.8.5",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "0.8.5",
"version": "1.0.5",
"license": "MIT",
"workspaces": [
"open-sse"
@@ -58,7 +58,8 @@
"prettier": "^3.8.1",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0"
},
"engines": {
"node": ">=18.0.0"
@@ -2489,15 +2490,79 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz",
"integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==",
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
"integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.55.0",
"@typescript-eslint/types": "^8.55.0",
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/type-utils": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.56.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz",
"integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.56.0",
"@typescript-eslint/types": "^8.56.0",
"debug": "^4.4.3"
},
"engines": {
@@ -2512,14 +2577,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz",
"integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz",
"integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0"
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2530,9 +2595,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz",
"integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz",
"integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2546,10 +2611,35 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz",
"integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz",
"integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
"integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2561,16 +2651,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz",
"integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz",
"integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.55.0",
"@typescript-eslint/tsconfig-utils": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"@typescript-eslint/project-service": "8.56.0",
"@typescript-eslint/tsconfig-utils": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"debug": "^4.4.3",
"minimatch": "^9.0.5",
"semver": "^7.7.3",
@@ -2627,15 +2717,17 @@
"node": ">=10"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz",
"integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==",
"node_modules/@typescript-eslint/utils": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
"integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.55.0",
"eslint-visitor-keys": "^4.2.1"
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2643,6 +2735,41 @@
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz",
"integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.56.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
"integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
@@ -4725,16 +4852,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-config-next/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/eslint-config-next/node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -4753,133 +4870,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/eslint-config-next/node_modules/typescript-eslint": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz",
"integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.55.0",
"@typescript-eslint/parser": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/utils": "8.55.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
"integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/type-utils": "8.55.0",
"@typescript-eslint/utils": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.55.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz",
"integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/utils": "8.55.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz",
"integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@@ -9615,6 +9605,30 @@
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz",
"integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/utils": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "0.8.5",
"version": "1.0.5",
"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": {
@@ -53,7 +53,7 @@
"test:fixes": "node --test tests/unit/fixes-p1.test.mjs",
"test:security": "node --test tests/unit/security-fase01.test.mjs",
"test:e2e": "npx playwright test",
"test:coverage": "npx c8 --check-coverage --lines 50 --functions 40 --branches 40 node --test tests/unit/*.test.mjs",
"test:coverage": "npx c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
"test:all": "npm run test:unit && npm run test:e2e",
"check": "npm run lint && npm run test",
"prepublishOnly": "npm run build:cli",
@@ -102,7 +102,8 @@
"prettier": "^3.8.1",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,mjs}": [
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -158,10 +158,10 @@ export default function HomePageClient({ machineId }) {
<span className="font-semibold">1. Create API key</span>
<p className="text-text-muted mt-0.5">
Go to{" "}
<Link href="/dashboard/settings" className="text-primary hover:underline">
Settings
<Link href="/dashboard/endpoint" className="text-primary hover:underline">
Endpoint
</Link>{" "}
API Keys. Generate one key per environment.
Registered Keys. Generate one key per environment.
</p>
</div>
</li>
@@ -204,7 +204,7 @@ export default function HomePageClient({ machineId }) {
<p className="text-text-muted mt-0.5">
Track tokens, cost and errors in{" "}
<Link href="/dashboard/usage" className="text-primary hover:underline">
Usage
Request Logs
</Link>{" "}
and{" "}
<Link href="/dashboard/analytics" className="text-primary hover:underline">
@@ -0,0 +1,15 @@
"use client";
export default function AnalyticsLoading() {
return (
<div className="space-y-6 animate-pulse p-6">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-40" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-24 bg-gray-200 dark:bg-gray-700 rounded-lg" />
))}
</div>
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
);
}
@@ -7,8 +7,24 @@ import EvalsTab from "../usage/components/EvalsTab";
export default function AnalyticsPage() {
const [activeTab, setActiveTab] = useState("overview");
const tabDescriptions = {
overview:
"Monitor your API usage patterns, token consumption, costs, and activity trends across all providers and models.",
evals:
"Run evaluation suites to test and validate your LLM endpoints. Compare model quality, detect regressions, and benchmark latency.",
};
return (
<div className="flex flex-col gap-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[28px]">analytics</span>
Analytics
</h1>
<p className="text-sm text-text-muted mt-1">{tabDescriptions[activeTab]}</p>
</div>
<SegmentedControl
options={[
{ value: "overview", label: "Overview" },
@@ -0,0 +1,240 @@
"use client";
/**
* Audit Log Viewer P-2
*
* Dashboard page for viewing administrative audit log entries.
* Fetches from /api/compliance/audit-log with filter support.
*/
import { useState, useEffect, useCallback } from "react";
interface AuditEntry {
id: number;
timestamp: string;
action: string;
actor: string;
target: string | null;
details: any;
ip_address: string | null;
}
const PAGE_SIZE = 25;
export default function AuditLogPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionFilter, setActionFilter] = useState("");
const [actorFilter, setActorFilter] = useState("");
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const fetchEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (actionFilter) params.set("action", actionFilter);
if (actorFilter) params.set("actor", actorFilter);
params.set("limit", String(PAGE_SIZE + 1));
params.set("offset", String(offset));
const res = await fetch(`/api/compliance/audit-log?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: AuditEntry[] = await res.json();
setHasMore(data.length > PAGE_SIZE);
setEntries(data.slice(0, PAGE_SIZE));
} catch (err: any) {
setError(err.message || "Failed to fetch audit log");
} finally {
setLoading(false);
}
}, [actionFilter, actorFilter, offset]);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
const handleSearch = () => {
setOffset(0);
fetchEntries();
};
const formatTimestamp = (ts: string) => {
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
};
const actionBadgeColor = (action: string) => {
if (action.includes("delete") || action.includes("remove"))
return "bg-red-500/15 text-red-400 border-red-500/20";
if (action.includes("create") || action.includes("add"))
return "bg-green-500/15 text-green-400 border-green-500/20";
if (action.includes("update") || action.includes("change"))
return "bg-blue-500/15 text-blue-400 border-blue-500/20";
if (action.includes("login") || action.includes("auth"))
return "bg-purple-500/15 text-purple-400 border-purple-500/20";
return "bg-gray-500/15 text-gray-400 border-gray-500/20";
};
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-main)]">
Audit Log
</h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
Administrative actions and security events
</p>
</div>
<button
onClick={fetchEntries}
disabled={loading}
aria-label="Refresh audit log"
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
{/* Filters */}
<div
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
role="search"
aria-label="Filter audit log entries"
>
<input
type="text"
placeholder="Filter by action..."
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by action type"
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<input
type="text"
placeholder="Filter by actor..."
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by actor"
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<button
onClick={handleSearch}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Search
</button>
</div>
{/* Error */}
{error && (
<div
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
role="alert"
>
{error}
</div>
)}
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Timestamp
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Action
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Actor
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Target
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Details
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
IP
</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && !loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
No audit log entries found
</td>
</tr>
) : (
entries.map((entry) => (
<tr
key={entry.id}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-muted)] font-mono text-xs">
{formatTimestamp(entry.timestamp)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
>
{entry.action}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-main)]">
{entry.actor}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
{entry.target || "—"}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
{entry.details ? JSON.stringify(entry.details) : "—"}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
{entry.ip_address || "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--color-text-muted)]">
Showing {entries.length} entries (offset {offset})
</p>
<div className="flex gap-2">
<button
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Previous
</button>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Next
</button>
</div>
</div>
</div>
);
}
@@ -29,6 +29,7 @@ export default function CLIToolsPageClient({ machineId }) {
const [cloudEnabled, setCloudEnabled] = useState(false);
const [apiKeys, setApiKeys] = useState([]);
const [toolStatuses, setToolStatuses] = useState({});
const [statusesLoaded, setStatusesLoaded] = useState(false);
useEffect(() => {
fetchConnections();
@@ -70,6 +71,8 @@ export default function CLIToolsPageClient({ machineId }) {
}
} catch (error) {
console.log("Error fetching CLI tool statuses:", error);
} finally {
setStatusesLoaded(true);
}
};
@@ -144,7 +147,7 @@ export default function CLIToolsPageClient({ machineId }) {
return "http://localhost:20128";
};
if (loading) {
if (loading || !statusesLoaded) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
@@ -167,6 +170,7 @@ export default function CLIToolsPageClient({ machineId }) {
baseUrl: getBaseUrl(),
apiKeys,
batchStatus: toolStatuses[toolId] || null,
lastConfiguredAt: toolStatuses[toolId]?.lastConfiguredAt || null,
};
switch (toolId) {
@@ -19,6 +19,7 @@ export default function ClaudeToolCard({
apiKeys,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [claudeStatus, setClaudeStatus] = useState(null);
const [checkingClaude, setCheckingClaude] = useState(false);
@@ -277,6 +278,7 @@ export default function ClaudeToolCard({
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
@@ -4,13 +4,41 @@
* Shared status badge for CLI tool cards.
* Shows the effective config/installation status using batch data,
* so badges are visible even when cards are collapsed.
* Optionally shows last-configured relative timestamp.
*/
export default function CliStatusBadge({ effectiveConfigStatus, batchStatus }) {
function formatRelativeTime(isoDate: string): string {
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffMs = now - then;
if (diffMs < 0) return "just now";
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
export default function CliStatusBadge({
effectiveConfigStatus,
batchStatus,
lastConfiguredAt = null,
}) {
// Determine badge from effectiveConfigStatus or batchStatus
const status = effectiveConfigStatus || batchStatus?.configStatus || null;
if (!status) return null;
const badges = {
configured: {
dotClass: "bg-green-500",
@@ -39,14 +67,32 @@ export default function CliStatusBadge({ effectiveConfigStatus, batchStatus }) {
},
};
const badge = badges[status] || badges.unknown;
const badge = status ? badges[status] || badges.unknown : null;
return (
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full ${badge.badgeClass}`}
>
<span className={`size-1.5 rounded-full ${badge.dotClass}`} />
{badge.text}
</span>
<>
{badge && (
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full ${badge.badgeClass}`}
>
<span className={`size-1.5 rounded-full ${badge.dotClass}`} />
{badge.text}
</span>
)}
{lastConfiguredAt ? (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-text-muted"
title={`Last saved: ${new Date(lastConfiguredAt).toLocaleString()}`}
>
<span className="material-symbols-outlined text-[12px]">schedule</span>
{formatRelativeTime(lastConfiguredAt)}
</span>
) : status && status !== "not_installed" ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-text-muted">
<span className="material-symbols-outlined text-[12px]">schedule</span>
Never
</span>
) : null}
</>
);
}
@@ -17,6 +17,7 @@ export default function ClineToolCard({
activeProviders,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [clineStatus, setClineStatus] = useState(null);
const [checkingCline, setCheckingCline] = useState(false);
@@ -236,6 +237,7 @@ export default function ClineToolCard({
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
@@ -463,11 +465,14 @@ export default function ClineToolCard({
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Cline Manual Configuration"
{...{onApply: handleManualConfig, currentConfig: {
model: selectedModel,
apiKey: selectedApiKey,
baseUrl: customBaseUrl || baseUrl,
}} as any}
{...({
onApply: handleManualConfig,
currentConfig: {
model: selectedModel,
apiKey: selectedApiKey,
baseUrl: customBaseUrl || baseUrl,
},
} as any)}
/>
)}
</Card>
@@ -14,6 +14,7 @@ export default function CodexToolCard({
activeProviders,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [codexStatus, setCodexStatus] = useState(null);
const [checkingCodex, setCheckingCodex] = useState(false);
@@ -337,6 +338,7 @@ wire_api = "responses"
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
@@ -17,6 +17,7 @@ export default function DroidToolCard({
activeProviders,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [droidStatus, setDroidStatus] = useState(null);
const [checkingDroid, setCheckingDroid] = useState(false);
@@ -270,6 +271,7 @@ export default function DroidToolCard({
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
@@ -17,6 +17,7 @@ export default function KiloToolCard({
activeProviders,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [kiloStatus, setKiloStatus] = useState(null);
const [checkingKilo, setCheckingKilo] = useState(false);
@@ -222,6 +223,7 @@ export default function KiloToolCard({
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
@@ -448,11 +450,14 @@ export default function KiloToolCard({
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Kilo Code Manual Configuration"
{...{onApply: handleManualConfig, currentConfig: {
model: selectedModel,
apiKey: selectedApiKey,
baseUrl: customBaseUrl || baseUrl,
}} as any}
{...({
onApply: handleManualConfig,
currentConfig: {
model: selectedModel,
apiKey: selectedApiKey,
baseUrl: customBaseUrl || baseUrl,
},
} as any)}
/>
)}
</Card>
@@ -17,6 +17,7 @@ export default function OpenClawToolCard({
activeProviders,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [openclawStatus, setOpenclawStatus] = useState(null);
const [checkingOpenclaw, setCheckingOpenclaw] = useState(false);
@@ -274,6 +275,7 @@ export default function OpenClawToolCard({
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
+129 -73
View File
@@ -44,6 +44,7 @@ export default function HealthPage() {
const [telemetry, setTelemetry] = useState(null);
const [cache, setCache] = useState(null);
const [signatureCache, setSignatureCache] = useState(null);
const [resetting, setResetting] = useState(false);
const fetchHealth = useCallback(async () => {
try {
@@ -82,6 +83,27 @@ export default function HealthPage() {
return () => clearInterval(interval);
}, [fetchHealth, fetchExtras]);
const handleResetHealth = async () => {
if (
!confirm(
"Reset all circuit breakers to healthy state? This will clear all failure counts and restore all providers to operational status."
)
)
return;
setResetting(true);
try {
const res = await fetch("/api/monitoring/health", { method: "DELETE" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Refresh health data immediately
await fetchHealth();
await fetchExtras();
} catch (err) {
console.error("Failed to reset health:", err);
} finally {
setResetting(false);
}
};
const fmtMs = (ms) => (ms != null ? `${Math.round(ms)}ms` : "—");
if (!data && !error) {
@@ -332,19 +354,47 @@ export default function HealthPage() {
</span>
Provider Health
</h2>
{cbEntries.length > 0 && (
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> Healthy
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-amber-500" /> Recovering
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-red-500" /> Down
</span>
</div>
)}
<div className="flex items-center gap-3">
{cbEntries.some(([, cb]: [string, any]) => cb.state !== "CLOSED") && (
<button
onClick={handleResetHealth}
disabled={resetting}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
resetting
? "bg-surface/50 text-text-muted cursor-wait"
: "bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300 border border-red-500/20"
}`}
title="Reset all circuit breakers to healthy state"
>
{resetting ? (
<>
<span className="material-symbols-outlined text-[14px] animate-spin">
progress_activity
</span>
Resetting...
</>
) : (
<>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
Reset All
</>
)}
</button>
)}
{cbEntries.length > 0 && (
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> Healthy
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-amber-500" /> Recovering
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-red-500" /> Down
</span>
</div>
)}
</div>
</div>
{cbEntries.length === 0 ? (
<p className="text-sm text-text-muted text-center py-4">
@@ -505,69 +555,75 @@ export default function HealthPage() {
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{entries.map(({ key, displayName, providerInfo, connectionId, model, status }: any) => {
const isActive = (status.queued || 0) + (status.running || 0) > 0;
const isQueued = (status.queued || 0) > 0;
return (
<div
key={key}
className={`rounded-lg p-3 border transition-colors ${
isQueued
? "bg-amber-500/5 border-amber-500/20"
: isActive
? "bg-blue-500/5 border-blue-500/15"
: "bg-surface/30 border-white/5"
}`}
title={key}
>
<div className="flex items-center gap-2.5 mb-2">
<div
className="size-7 rounded-md flex items-center justify-center shrink-0 text-[10px] font-bold"
style={{
backgroundColor: `${providerInfo?.color || "#888"}15`,
color: providerInfo?.color || "#888",
}}
>
{providerInfo?.textIcon || displayName.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-main truncate">
{displayName}
</p>
{connectionId && (
<p className="text-[10px] text-text-muted font-mono truncate">
{connectionId.length > 12
? connectionId.slice(0, 8) + "…"
: connectionId}
{model && <span className="ml-1 text-text-muted/60">· {model}</span>}
{entries.map(
({ key, displayName, providerInfo, connectionId, model, status }: any) => {
const isActive = (status.queued || 0) + (status.running || 0) > 0;
const isQueued = (status.queued || 0) > 0;
return (
<div
key={key}
className={`rounded-lg p-3 border transition-colors ${
isQueued
? "bg-amber-500/5 border-amber-500/20"
: isActive
? "bg-blue-500/5 border-blue-500/15"
: "bg-surface/30 border-white/5"
}`}
title={key}
>
<div className="flex items-center gap-2.5 mb-2">
<div
className="size-7 rounded-md flex items-center justify-center shrink-0 text-[10px] font-bold"
style={{
backgroundColor: `${providerInfo?.color || "#888"}15`,
color: providerInfo?.color || "#888",
}}
>
{providerInfo?.textIcon || displayName.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-main truncate">
{displayName}
</p>
)}
{connectionId && (
<p className="text-[10px] text-text-muted font-mono truncate">
{connectionId.length > 12
? connectionId.slice(0, 8) + "…"
: connectionId}
{model && (
<span className="ml-1 text-text-muted/60">· {model}</span>
)}
</p>
)}
</div>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
isQueued
? "bg-amber-500/15 text-amber-400"
: isActive
? "bg-blue-500/15 text-blue-400"
: "bg-green-500/10 text-green-400"
}`}
>
{isQueued ? "Queued" : isActive ? "Active" : "OK"}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">schedule</span>
{status.queued || 0} queued
</span>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">
play_arrow
</span>
{status.running || 0} running
</span>
</div>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
isQueued
? "bg-amber-500/15 text-amber-400"
: isActive
? "bg-blue-500/15 text-blue-400"
: "bg-green-500/10 text-green-400"
}`}
>
{isQueued ? "Queued" : isActive ? "Active" : "OK"}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">schedule</span>
{status.queued || 0} queued
</span>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">play_arrow</span>
{status.running || 0} running
</span>
</div>
</div>
);
})}
);
}
)}
</div>
</Card>
);
@@ -0,0 +1,19 @@
"use client";
import { Suspense } from "react";
import { CardSkeleton } from "@/shared/components";
import ProviderLimits from "../usage/components/ProviderLimits";
import RateLimitStatus from "../usage/components/RateLimitStatus";
import SessionsTab from "../usage/components/SessionsTab";
export default function LimitsPage() {
return (
<div className="flex flex-col gap-6">
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits />
</Suspense>
<RateLimitStatus />
<SessionsTab />
</div>
);
}
@@ -0,0 +1,232 @@
"use client";
/**
* Audit Log Tab Embedded version of the audit-log page for the Logs dashboard.
* Fetches from /api/compliance/audit-log with filter support.
*/
import { useState, useEffect, useCallback } from "react";
interface AuditEntry {
id: number;
timestamp: string;
action: string;
actor: string;
target: string | null;
details: any;
ip_address: string | null;
}
const PAGE_SIZE = 25;
export default function AuditLogTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionFilter, setActionFilter] = useState("");
const [actorFilter, setActorFilter] = useState("");
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const fetchEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (actionFilter) params.set("action", actionFilter);
if (actorFilter) params.set("actor", actorFilter);
params.set("limit", String(PAGE_SIZE + 1));
params.set("offset", String(offset));
const res = await fetch(`/api/compliance/audit-log?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: AuditEntry[] = await res.json();
setHasMore(data.length > PAGE_SIZE);
setEntries(data.slice(0, PAGE_SIZE));
} catch (err: any) {
setError(err.message || "Failed to fetch audit log");
} finally {
setLoading(false);
}
}, [actionFilter, actorFilter, offset]);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
const handleSearch = () => {
setOffset(0);
fetchEntries();
};
const formatTimestamp = (ts: string) => {
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
};
const actionBadgeColor = (action: string) => {
if (action.includes("delete") || action.includes("remove"))
return "bg-red-500/15 text-red-400 border-red-500/20";
if (action.includes("create") || action.includes("add"))
return "bg-green-500/15 text-green-400 border-green-500/20";
if (action.includes("update") || action.includes("change"))
return "bg-blue-500/15 text-blue-400 border-blue-500/20";
if (action.includes("login") || action.includes("auth"))
return "bg-purple-500/15 text-purple-400 border-purple-500/20";
return "bg-gray-500/15 text-gray-400 border-gray-500/20";
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-[var(--color-text-main)]">Audit Log</h2>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
Administrative actions and security events
</p>
</div>
<button
onClick={fetchEntries}
disabled={loading}
aria-label="Refresh audit log"
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
{/* Filters */}
<div
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
role="search"
aria-label="Filter audit log entries"
>
<input
type="text"
placeholder="Filter by action..."
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by action type"
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<input
type="text"
placeholder="Filter by actor..."
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by actor"
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<button
onClick={handleSearch}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Search
</button>
</div>
{/* Error */}
{error && (
<div
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
role="alert"
>
{error}
</div>
)}
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Timestamp
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Action
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Actor
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Target
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Details
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">IP</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && !loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
No audit log entries found
</td>
</tr>
) : (
entries.map((entry) => (
<tr
key={entry.id}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-muted)] font-mono text-xs">
{formatTimestamp(entry.timestamp)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
>
{entry.action}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
{entry.target || "—"}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
{entry.details ? JSON.stringify(entry.details) : "—"}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
{entry.ip_address || "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--color-text-muted)]">
Showing {entries.length} entries (offset {offset})
</p>
<div className="flex gap-2">
<button
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Previous
</button>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Next
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import { useState } from "react";
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer";
import AuditLogTab from "./AuditLogTab";
export default function LogsPage() {
const [activeTab, setActiveTab] = useState("request-logs");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "request-logs", label: "Request Logs" },
{ value: "proxy-logs", label: "Proxy Logs" },
{ value: "audit-logs", label: "Audit Logs" },
{ value: "console", label: "Console" },
]}
value={activeTab}
onChange={setActiveTab}
/>
{/* Content */}
{activeTab === "request-logs" && <RequestLoggerV2 />}
{activeTab === "proxy-logs" && <ProxyLogger />}
{activeTab === "audit-logs" && <AuditLogTab />}
{activeTab === "console" && <ConsoleLogViewer />}
</div>
);
}
@@ -71,27 +71,35 @@ export default function OnboardingWizard() {
if (step > 0) setStep(step - 1);
};
const [errorMessage, setErrorMessage] = useState("");
const handleSetPassword = async () => {
if (skipSecurity) {
handleNext();
return;
}
if (password !== confirmPassword) return;
setErrorMessage("");
try {
await fetch("/api/settings/require-login", {
const res = await fetch("/api/settings/require-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requireLogin: true, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorMessage(data.error || "Failed to set password. Try again.");
return;
}
handleNext();
} catch {
// Fallback: skip
handleNext();
setErrorMessage("Connection error. Please try again.");
}
};
const handleAddProvider = async () => {
if (!selectedProvider || !providerKey) return;
setErrorMessage("");
try {
const provider = COMMON_PROVIDERS.find((p) => p.id === selectedProvider);
const defaultUrls = {
@@ -102,7 +110,7 @@ export default function OnboardingWizard() {
groq: "https://api.groq.com/openai",
mistral: "https://api.mistral.ai",
};
await fetch("/api/providers", {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -113,9 +121,14 @@ export default function OnboardingWizard() {
isActive: true,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorMessage(data.error || "Failed to add provider. Try again.");
return;
}
handleNext();
} catch {
handleNext();
setErrorMessage("Connection error. Please try again.");
}
};
@@ -51,6 +51,16 @@ export default function ProviderDetailPage() {
const [proxyTarget, setProxyTarget] = useState(null);
const [proxyConfig, setProxyConfig] = useState(null);
const [importingModels, setImportingModels] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [importProgress, setImportProgress] = useState({
current: 0,
total: 0,
phase: "idle" as "idle" | "fetching" | "importing" | "done" | "error",
status: "",
logs: [] as string[],
error: "",
importedCount: 0,
});
const providerInfo = providerNode
? {
@@ -66,7 +76,9 @@ export default function ProviderDetailPage() {
baseUrl: providerNode.baseUrl,
type: providerNode.type,
}
: (FREE_PROVIDERS as any)[providerId] || (OAUTH_PROVIDERS as any)[providerId] || (APIKEY_PROVIDERS as any)[providerId];
: (FREE_PROVIDERS as any)[providerId] ||
(OAUTH_PROVIDERS as any)[providerId] ||
(APIKEY_PROVIDERS as any)[providerId];
const isOAuth = !!(FREE_PROVIDERS as any)[providerId] || !!(OAUTH_PROVIDERS as any)[providerId];
const models = getModelsByProviderId(providerId);
const providerAlias = getProviderAlias(providerId);
@@ -237,9 +249,14 @@ export default function ProviderDetailPage() {
if (res.ok) {
await fetchConnections();
setShowAddApiKeyModal(false);
return null;
}
const data = await res.json().catch(() => ({}));
const errorMsg = data.error?.message || data.error || "Failed to save connection";
return errorMsg;
} catch (error) {
console.log("Error saving connection:", error);
return "Failed to save connection. Please try again.";
}
};
@@ -352,24 +369,63 @@ export default function ProviderDetailPage() {
if (!activeConnection) return;
setImportingModels(true);
setShowImportModal(true);
setImportProgress({
current: 0,
total: 0,
phase: "fetching",
status: "Fetching available models...",
logs: [],
error: "",
importedCount: 0,
});
try {
const res = await fetch(`/api/providers/${activeConnection.id}/models`);
const data = await res.json();
if (!res.ok) {
alert(data.error || "Failed to import models");
setImportProgress((prev) => ({
...prev,
phase: "error",
status: "Failed to fetch models",
error: data.error || "Failed to import models",
}));
return;
}
const fetchedModels = data.models || [];
if (fetchedModels.length === 0) {
alert("No models returned from /models.");
setImportProgress((prev) => ({
...prev,
phase: "done",
status: "No models found",
logs: ["No models returned from /models endpoint."],
}));
return;
}
setImportProgress((prev) => ({
...prev,
phase: "importing",
total: fetchedModels.length,
status: `Importing 0 of ${fetchedModels.length} models...`,
logs: [`Found ${fetchedModels.length} models. Starting import...`],
}));
let importedCount = 0;
for (const model of fetchedModels) {
for (let i = 0; i < fetchedModels.length; i++) {
const model = fetchedModels[i];
const modelId = model.id || model.name || model.model;
if (!modelId) continue;
const parts = modelId.split("/");
const baseAlias = parts[parts.length - 1];
setImportProgress((prev) => ({
...prev,
current: i + 1,
status: `Importing ${i + 1} of ${fetchedModels.length} models...`,
logs: [...prev.logs, `Importing ${modelId}...`],
}));
// Save as imported (default) model in the DB
await fetch("/api/provider-models", {
method: "POST",
@@ -387,17 +443,132 @@ export default function ProviderDetailPage() {
}
importedCount += 1;
}
if (importedCount === 0) {
alert("No new models were added (all already exist).");
}
await fetchAliases();
setImportProgress((prev) => ({
...prev,
phase: "done",
current: fetchedModels.length,
status:
importedCount > 0
? `Successfully imported ${importedCount} model${importedCount === 1 ? "" : "s"}!`
: "No new models were added (all already exist).",
logs: [
...prev.logs,
importedCount > 0
? `✓ Done! ${importedCount} model${importedCount === 1 ? "" : "s"} imported.`
: "No new models were added.",
],
importedCount,
}));
// Auto-reload after success
if (importedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);
}
} catch (error) {
console.log("Error importing models:", error);
setImportProgress((prev) => ({
...prev,
phase: "error",
status: "Import failed",
error: error instanceof Error ? error.message : "An unexpected error occurred",
}));
} finally {
setImportingModels(false);
}
};
// Shared import handler for CompatibleModelsSection
const handleCompatibleImportWithProgress = async (
fetchModels: () => Promise<{ models: any[] }>,
processModel: (model: any) => Promise<boolean>
) => {
setShowImportModal(true);
setImportProgress({
current: 0,
total: 0,
phase: "fetching",
status: "Fetching available models...",
logs: [],
error: "",
importedCount: 0,
});
try {
const data = await fetchModels();
const models = data.models || [];
if (models.length === 0) {
setImportProgress((prev) => ({
...prev,
phase: "done",
status: "No models found",
logs: ["No models returned from /models endpoint."],
}));
return;
}
setImportProgress((prev) => ({
...prev,
phase: "importing",
total: models.length,
status: `Importing 0 of ${models.length} models...`,
logs: [`Found ${models.length} models. Starting import...`],
}));
let importedCount = 0;
for (let i = 0; i < models.length; i++) {
const model = models[i];
const modelId = model.id || model.name || model.model;
if (!modelId) continue;
setImportProgress((prev) => ({
...prev,
current: i + 1,
status: `Importing ${i + 1} of ${models.length} models...`,
logs: [...prev.logs, `Importing ${modelId}...`],
}));
const added = await processModel(model);
if (added) importedCount += 1;
}
setImportProgress((prev) => ({
...prev,
phase: "done",
current: models.length,
status:
importedCount > 0
? `Successfully imported ${importedCount} model${importedCount === 1 ? "" : "s"}!`
: "No new models were added.",
logs: [
...prev.logs,
importedCount > 0
? `✓ Done! ${importedCount} model${importedCount === 1 ? "" : "s"} imported.`
: "No new models were added.",
],
importedCount,
}));
if (importedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);
}
} catch (error) {
console.log("Error importing models:", error);
setImportProgress((prev) => ({
...prev,
phase: "error",
status: "Import failed",
error: error instanceof Error ? error.message : "An unexpected error occurred",
}));
}
};
const canImportModels = connections.some((conn) => conn.isActive !== false);
const renderModelsSection = () => {
@@ -413,6 +584,7 @@ export default function ProviderDetailPage() {
onDeleteAlias={handleDeleteAlias}
connections={connections}
isAnthropic={isAnthropicCompatible}
onImportWithProgress={handleCompatibleImportWithProgress}
/>
);
}
@@ -813,6 +985,115 @@ export default function ProviderDetailPage() {
levelLabel={proxyTarget.label}
/>
)}
{/* Import Progress Modal */}
<Modal
isOpen={showImportModal}
onClose={() => {
if (importProgress.phase === "done" || importProgress.phase === "error") {
setShowImportModal(false);
}
}}
title="Importing Models"
size="md"
closeOnOverlay={false}
showCloseButton={importProgress.phase === "done" || importProgress.phase === "error"}
>
<div className="flex flex-col gap-4">
{/* Status text */}
<div className="flex items-center gap-3">
{importProgress.phase === "fetching" && (
<span className="material-symbols-outlined text-primary animate-spin">
progress_activity
</span>
)}
{importProgress.phase === "importing" && (
<span className="material-symbols-outlined text-primary animate-spin">
progress_activity
</span>
)}
{importProgress.phase === "done" && (
<span className="material-symbols-outlined text-green-500">check_circle</span>
)}
{importProgress.phase === "error" && (
<span className="material-symbols-outlined text-red-500">error</span>
)}
<span className="text-sm font-medium text-text-main">{importProgress.status}</span>
</div>
{/* Progress bar */}
{(importProgress.phase === "importing" || importProgress.phase === "done") &&
importProgress.total > 0 && (
<div className="w-full">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-muted">
{importProgress.current} / {importProgress.total}
</span>
<span className="text-xs text-text-muted">
{Math.round((importProgress.current / importProgress.total) * 100)}%
</span>
</div>
<div className="w-full h-2.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300 ease-out"
style={{
width: `${(importProgress.current / importProgress.total) * 100}%`,
background:
importProgress.phase === "done"
? "linear-gradient(90deg, #22c55e, #16a34a)"
: "linear-gradient(90deg, var(--color-primary), var(--color-primary-hover, var(--color-primary)))",
}}
/>
</div>
</div>
)}
{/* Fetching indeterminate bar */}
{importProgress.phase === "fetching" && (
<div className="w-full h-2.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full rounded-full animate-pulse"
style={{
width: "60%",
background:
"linear-gradient(90deg, var(--color-primary), var(--color-primary-hover, var(--color-primary)))",
}}
/>
</div>
)}
{/* Error message */}
{importProgress.phase === "error" && importProgress.error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<p className="text-sm text-red-400">{importProgress.error}</p>
</div>
)}
{/* Log list */}
{importProgress.logs.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-lg bg-black/5 dark:bg-white/5 p-3 border border-black/5 dark:border-white/5">
<div className="flex flex-col gap-1">
{importProgress.logs.map((log, i) => (
<p
key={i}
className={`text-xs font-mono ${
log.startsWith("✓") ? "text-green-500 font-semibold" : "text-text-muted"
}`}
>
{log}
</p>
))}
</div>
</div>
)}
{/* Auto-reload notice */}
{importProgress.phase === "done" && importProgress.importedCount > 0 && (
<p className="text-xs text-text-muted text-center animate-pulse">
Page will refresh automatically...
</p>
)}
</div>
</Modal>
</div>
);
}
@@ -1175,6 +1456,7 @@ function CompatibleModelsSection({
onDeleteAlias,
connections,
isAnthropic,
onImportWithProgress,
}) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
@@ -1232,29 +1514,24 @@ function CompatibleModelsSection({
setImporting(true);
try {
const res = await fetch(`/api/providers/${activeConnection.id}/models`);
const data = await res.json();
if (!res.ok) {
alert(data.error || "Failed to import models");
return;
}
const models = data.models || [];
if (models.length === 0) {
alert("No models returned from /models.");
return;
}
let importedCount = 0;
for (const model of models) {
const modelId = model.id || model.name || model.model;
if (!modelId) continue;
const resolvedAlias = resolveAlias(modelId);
if (!resolvedAlias) continue;
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
importedCount += 1;
}
if (importedCount === 0) {
alert("No new models were added.");
}
await onImportWithProgress(
// fetchModels callback
async () => {
const res = await fetch(`/api/providers/${activeConnection.id}/models`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to import models");
return data;
},
// processModel callback
async (model: any) => {
const modelId = model.id || model.name || model.model;
if (!modelId) return false;
const resolvedAlias = resolveAlias(modelId);
if (!resolvedAlias) return false;
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
return true;
}
);
} catch (error) {
console.log("Error importing models:", error);
} finally {
@@ -1340,6 +1617,7 @@ CompatibleModelsSection.propTypes = {
})
).isRequired,
isAnthropic: PropTypes.bool,
onImportWithProgress: PropTypes.func.isRequired,
};
function CooldownTimer({ until }) {
@@ -1757,9 +2035,11 @@ function AddApiKeyModal({
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const handleValidate = async () => {
setValidating(true);
setSaveError(null);
try {
const res = await fetch("/api/providers/validate", {
method: "POST",
@@ -1779,6 +2059,7 @@ function AddApiKeyModal({
if (!provider || !formData.apiKey) return;
setSaving(true);
setSaveError(null);
try {
let isValid = false;
try {
@@ -1798,12 +2079,20 @@ function AddApiKeyModal({
setValidating(false);
}
await onSave({
if (!isValid) {
setSaveError("API key validation failed. Please check your key and try again.");
return;
}
const error = await onSave({
name: formData.name,
apiKey: formData.apiKey,
priority: formData.priority,
testStatus: isValid ? "active" : "unknown",
testStatus: "active",
});
if (error) {
setSaveError(typeof error === "string" ? error : "Failed to save connection");
}
} finally {
setSaving(false);
}
@@ -1843,6 +2132,11 @@ function AddApiKeyModal({
{validationResult === "success" ? "Valid" : "Invalid"}
</Badge>
)}
{saveError && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{saveError}
</div>
)}
{isCompatible && (
<p className="text-xs text-text-muted">
{isAnthropic
@@ -0,0 +1,28 @@
"use client";
export default function ProvidersError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-6">
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load providers
</h2>
<p className="text-gray-600 dark:text-gray-400 max-w-md">
{error.message || "An unexpected error occurred while loading provider data."}
</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
</div>
</div>
);
}
@@ -0,0 +1,14 @@
"use client";
export default function ProvidersLoading() {
return (
<div className="space-y-6 animate-pulse p-6">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
))}
</div>
</div>
);
}
@@ -134,14 +134,18 @@ export default function ProvidersPage() {
const error = errorConns.length;
const total = providerConnections.length;
// Check if all connections are manually disabled
const allDisabled = total > 0 && providerConnections.every((c) => c.isActive === false);
// Get latest error info
const latestError = errorConns.sort(
(a: any, b: any) => (new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any)
(a: any, b: any) =>
(new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any)
)[0];
const errorCode = latestError ? getConnectionErrorTag(latestError) : null;
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
return { connected, error, total, errorCode, errorTime };
return { connected, error, total, errorCode, errorTime, allDisabled };
};
const handleBatchTest = async (mode, providerId = null) => {
@@ -422,7 +426,7 @@ export default function ProvidersPage() {
}
function ProviderCard({ providerId, provider, stats, authType }) {
const { connected, error, errorCode, errorTime } = stats;
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const [imgError, setImgError] = useState(false);
const dotColors = {
@@ -437,7 +441,7 @@ function ProviderCard({ providerId, provider, stats, authType }) {
<Link href={`/dashboard/providers/${providerId}`} className="group">
<Card
padding="xs"
className="h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer"
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -470,8 +474,19 @@ function ProviderCard({ providerId, provider, stats, authType }) {
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{getStatusDisplay(connected, error, errorCode)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
{allDisabled ? (
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
Disabled
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</>
)}
</div>
</div>
</div>
@@ -503,7 +518,7 @@ ProviderCard.propTypes = {
// API Key providers - use image with textIcon fallback (same as OAuth providers)
function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
const { connected, error, errorCode, errorTime } = stats;
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
const [imgError, setImgError] = useState(false);
@@ -531,7 +546,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
<Link href={`/dashboard/providers/${providerId}`} className="group">
<Card
padding="xs"
className="h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer"
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -564,18 +579,29 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{getStatusDisplay(connected, error, errorCode)}
{isCompatible && (
{allDisabled ? (
<Badge variant="default" size="sm">
{provider.apiType === "responses" ? "Responses" : "Chat"}
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
Disabled
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses" ? "Responses" : "Chat"}
</Badge>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
Messages
</Badge>
)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
Messages
</Badge>
)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</div>
</div>
</div>
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
// ─── State colors and labels ──────────────────────────────────────────────
const STATE_STYLES = {
@@ -28,6 +29,12 @@ const STATE_STYLES = {
},
};
const CB_STATUS = {
closed: { icon: "check_circle", color: "#22c55e", label: "Closed" },
"half-open": { icon: "pending", color: "#f59e0b", label: "Half-Open" },
open: { icon: "error", color: "#ef4444", label: "Open" },
};
function formatMs(ms) {
if (!ms || ms <= 0) return "—";
if (ms < 1000) return `${ms}ms`;
@@ -35,86 +42,6 @@ function formatMs(ms) {
return `${(ms / 60000).toFixed(1)}m`;
}
// ─── Circuit Breaker Card ────────────────────────────────────────────────
function CircuitBreakerCard({ breakers, onReset, loading }) {
const activeBreakers = breakers.filter((b) => b.state !== "CLOSED");
const totalBreakers = breakers.length;
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
electrical_services
</span>
<h2 className="text-lg font-bold">Circuit Breakers</h2>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{activeBreakers.length > 0
? `${activeBreakers.length} tripped`
: `${totalBreakers} healthy`}
</span>
{activeBreakers.length > 0 && (
<Button
size="sm"
variant="danger"
icon="restart_alt"
onClick={onReset}
disabled={loading}
>
Reset All
</Button>
)}
</div>
</div>
{breakers.length === 0 ? (
<p className="text-sm text-text-muted">
No circuit breakers active yet. They are created automatically when requests flow
through the combo pipeline.
</p>
) : (
<div className="space-y-2">
{breakers.map((b) => {
const style = STATE_STYLES[b.state] || STATE_STYLES.CLOSED;
return (
<div
key={b.name}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-black/5 dark:bg-white/5"
>
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-base ${style.text}`}
aria-hidden="true"
>
{style.icon}
</span>
<span className="text-sm font-medium">{b.name.replace("combo:", "")}</span>
</div>
<div className="flex items-center gap-3">
{b.failureCount > 0 && (
<span className="text-xs text-text-muted">
{b.failureCount} failure{b.failureCount !== 1 ? "s" : ""}
</span>
)}
<span
className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${style.bg} ${style.text} border ${style.border}`}
>
{style.label}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</Card>
);
}
// ─── Provider Profiles Card ──────────────────────────────────────────────
function ProviderProfilesCard({ profiles, onSave, saving }) {
const [editMode, setEditMode] = useState(false);
@@ -217,16 +144,53 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
);
}
// ─── Rate Limit Overview Card ────────────────────────────────────────────
function RateLimitOverviewCard({ rateLimitStatus, defaults }) {
// ─── Editable Rate Limit Card ────────────────────────────────────────────
function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState(defaults || {});
// Sync draft when defaults change from parent (standard prop-to-state sync)
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (defaults) setDraft(defaults);
}, [defaults]);
/* eslint-enable react-hooks/set-state-in-effect */
const handleSave = () => {
onSaveDefaults(draft);
setEditMode(false);
};
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
speed
</span>
<h2 className="text-lg font-bold">Rate Limiting</h2>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
speed
</span>
<h2 className="text-lg font-bold">Rate Limiting</h2>
</div>
{editMode ? (
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditMode(false)}>
Cancel
</Button>
<Button
size="sm"
variant="primary"
icon="save"
onClick={handleSave}
disabled={saving}
>
Save
</Button>
</div>
) : (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setEditMode(true)}>
Edit
</Button>
)}
</div>
<p className="text-sm text-text-muted mb-4">
@@ -235,22 +199,34 @@ function RateLimitOverviewCard({ rateLimitStatus, defaults }) {
</p>
<div className="rounded-lg bg-black/5 dark:bg-white/5 p-4 mb-4">
<h3 className="text-xs font-bold uppercase tracking-wider mb-2 text-text-muted">
<h3 className="text-xs font-bold uppercase tracking-wider mb-3 text-text-muted">
Default Safety Net
</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-lg font-bold">{defaults?.requestsPerMinute ?? ""}</div>
<div className="text-xs text-text-muted">RPM</div>
</div>
<div>
<div className="text-lg font-bold">{formatMs(defaults?.minTimeBetweenRequests)}</div>
<div className="text-xs text-text-muted">Min Gap</div>
</div>
<div>
<div className="text-lg font-bold">{defaults?.concurrentRequests ?? ""}</div>
<div className="text-xs text-text-muted">Max Concurrent</div>
</div>
{[
{ key: "requestsPerMinute", label: "RPM", suffix: "" },
{ key: "minTimeBetweenRequests", label: "Min Gap", suffix: "ms", format: formatMs },
{ key: "concurrentRequests", label: "Max Concurrent", suffix: "" },
].map(({ key, label, format }) => (
<div key={key}>
{editMode ? (
<input
type="number"
min="1"
value={draft[key] ?? 0}
onChange={(e) =>
setDraft((prev) => ({ ...prev, [key]: parseInt(e.target.value) || 0 }))
}
className="w-full px-2 py-1 text-lg font-bold rounded bg-white/10 border border-white/20"
/>
) : (
<div className="text-lg font-bold">
{format ? format(defaults?.[key]) : (defaults?.[key] ?? "—")}
</div>
)}
<div className="text-xs text-text-muted">{label}</div>
</div>
))}
</div>
</div>
@@ -281,6 +257,270 @@ function RateLimitOverviewCard({ rateLimitStatus, defaults }) {
);
}
// ─── Circuit Breaker Card ────────────────────────────────────────────────
function CircuitBreakerCard({ breakers, onReset, loading }) {
const activeBreakers = breakers.filter((b) => b.state !== "CLOSED");
const totalBreakers = breakers.length;
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
electrical_services
</span>
<h2 className="text-lg font-bold">Circuit Breakers</h2>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{activeBreakers.length > 0
? `${activeBreakers.length} tripped`
: `${totalBreakers} healthy`}
</span>
{activeBreakers.length > 0 && (
<Button
size="sm"
variant="danger"
icon="restart_alt"
onClick={onReset}
disabled={loading}
>
Reset All
</Button>
)}
</div>
</div>
{breakers.length === 0 ? (
<p className="text-sm text-text-muted">
No circuit breakers active yet. They are created automatically when requests flow
through the combo pipeline.
</p>
) : (
<div className="space-y-2">
{breakers.map((b) => {
const style = STATE_STYLES[b.state] || STATE_STYLES.CLOSED;
return (
<div
key={b.name}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-black/5 dark:bg-white/5"
>
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-base ${style.text}`}
aria-hidden="true"
>
{style.icon}
</span>
<span className="text-sm font-medium">{b.name.replace("combo:", "")}</span>
</div>
<div className="flex items-center gap-3">
{b.failureCount > 0 && (
<span className="text-xs text-text-muted">
{b.failureCount} failure{b.failureCount !== 1 ? "s" : ""}
</span>
)}
<span
className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${style.bg} ${style.text} border ${style.border}`}
>
{style.label}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</Card>
);
}
// ─── Policies Panel (from Security tab) ──────────────────────────────────
function PoliciesCard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [unlocking, setUnlocking] = useState(null);
const notify = useNotificationStore();
const fetchPolicies = useCallback(async () => {
try {
const res = await fetch("/api/policies");
if (res.ok) {
const json = await res.json();
setData(json);
}
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPolicies();
const interval = setInterval(fetchPolicies, 15000);
return () => clearInterval(interval);
}, [fetchPolicies]);
const handleUnlock = async (identifier) => {
setUnlocking(identifier);
try {
const res = await fetch("/api/policies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "unlock", identifier }),
});
if (res.ok) {
notify.success(`Unlocked: ${identifier}`);
await fetchPolicies();
} else {
notify.error("Failed to unlock");
}
} catch {
notify.error("Failed to unlock");
} finally {
setUnlocking(null);
}
};
const circuitBreakers = data?.circuitBreakers || [];
const lockedIds = data?.lockedIdentifiers || [];
const hasIssues = circuitBreakers.some((cb) => cb.state !== "closed") || lockedIds.length > 0;
if (loading) {
return (
<Card className="p-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">policy</span>
Loading policies...
</div>
</Card>
);
}
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
policy
</span>
<h2 className="text-lg font-bold">Policies & Locked Identifiers</h2>
</div>
{hasIssues && (
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
<span className="material-symbols-outlined text-[16px]">refresh</span>
</Button>
)}
</div>
{!hasIssues ? (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">verified_user</span>
</div>
<div>
<p className="text-sm text-text-muted">
All systems operational no lockouts or tripped breakers
</p>
</div>
</div>
) : (
<>
{/* Circuit Breakers */}
{circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium text-text-muted mb-2">Circuit Breakers</p>
<div className="flex flex-col gap-1.5">
{circuitBreakers
.filter((cb) => cb.state !== "closed")
.map((cb, i) => {
const status = CB_STATUS[cb.state] || CB_STATUS.open;
return (
<div
key={cb.name || i}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
>
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[16px]"
style={{ color: status.color }}
>
{status.icon}
</span>
<span className="text-sm text-text-main font-medium">
{cb.name || cb.provider || "Unknown"}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: `${status.color}15`,
color: status.color,
}}
>
{status.label}
</span>
{cb.failures > 0 && (
<span className="text-xs text-text-muted">
{cb.failures} failures
</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Locked Identifiers */}
{lockedIds.length > 0 && (
<div>
<p className="text-sm font-medium text-text-muted mb-2">Locked Identifiers</p>
<div className="flex flex-col gap-1.5">
{lockedIds.map((id, i) => {
const identifier = typeof id === "string" ? id : id.identifier || id.id;
return (
<div
key={identifier || i}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-red-400">
lock
</span>
<span className="font-mono text-sm text-text-main">{identifier}</span>
{typeof id === "object" && id.lockedAt && (
<span className="text-xs text-text-muted">
since {new Date(id.lockedAt).toLocaleString()}
</span>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleUnlock(identifier)}
disabled={unlocking === identifier}
className="text-xs"
>
{unlocking === identifier ? "Unlocking..." : "Force Unlock"}
</Button>
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
</Card>
);
}
// ─── Main Resilience Tab ─────────────────────────────────────────────────
export default function ResilienceTab() {
const [data, setData] = useState(null);
@@ -340,6 +580,23 @@ export default function ResilienceTab() {
}
};
const handleSaveDefaults = async (defaults) => {
try {
setSaving(true);
const res = await fetch("/api/resilience", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaults }),
});
if (!res.ok) throw new Error("Save failed");
await loadData();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (loading && !data) {
return (
<div className="flex items-center justify-center py-12 text-text-muted">
@@ -365,20 +622,27 @@ export default function ResilienceTab() {
return (
<div className="flex flex-col gap-6">
<CircuitBreakerCard
breakers={data?.circuitBreakers || []}
onReset={handleResetBreakers}
loading={loading}
/>
{/* 1. Provider Profiles (resilience settings by auth type) */}
<ProviderProfilesCard
profiles={data?.profiles || {}}
onSave={handleSaveProfiles}
saving={saving}
/>
<RateLimitOverviewCard
{/* 2. Rate Limiting (editable defaults + active limiters) */}
<RateLimitCard
rateLimitStatus={data?.rateLimitStatus || []}
defaults={data?.defaults || {}}
onSaveDefaults={handleSaveDefaults}
saving={saving}
/>
{/* 3. Circuit Breakers (combo pipeline) */}
<CircuitBreakerCard
breakers={data?.circuitBreakers || []}
onReset={handleResetBreakers}
loading={loading}
/>
{/* 4. Policies & Locked Identifiers (from previous Security tab) */}
<PoliciesCard />
</div>
);
}
@@ -13,6 +13,19 @@ const STRATEGIES = [
},
{ value: "round-robin", label: "Round Robin", desc: "Cycle through all accounts", icon: "loop" },
{ value: "p2c", label: "P2C", desc: "Pick 2 random, use the healthier one", icon: "balance" },
{ value: "random", label: "Random", desc: "Random account each request", icon: "shuffle" },
{
value: "least-used",
label: "Least Used",
desc: "Pick least recently used account",
icon: "low_priority",
},
{
value: "cost-optimized",
label: "Cost Opt",
desc: "Prefer cheapest available account",
icon: "savings",
},
];
export default function RoutingTab() {
@@ -76,7 +89,7 @@ export default function RoutingTab() {
<h3 className="text-lg font-semibold">Routing Strategy</h3>
</div>
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="grid grid-cols-3 gap-2 mb-4" style={{ gridAutoRows: "1fr" }}>
{STRATEGIES.map((s) => (
<button
key={s.value}
@@ -132,6 +145,12 @@ export default function RoutingTab() {
"Using accounts in priority order (Fill First)."}
{settings.fallbackStrategy === "p2c" &&
"Power of Two Choices: picks 2 random accounts and routes to the healthier one."}
{settings.fallbackStrategy === "random" &&
"Randomly selects an available account for each request."}
{settings.fallbackStrategy === "least-used" &&
"Picks the account that was used least recently."}
{settings.fallbackStrategy === "cost-optimized" &&
"Prefers accounts with the lowest cost (priority-based, extensible with actual cost data)."}
</p>
</Card>
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import IPFilterSection from "./IPFilterSection";
import PoliciesPanel from "./PoliciesPanel";
import SessionInfoCard from "./SessionInfoCard";
export default function SecurityTab() {
const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false });
@@ -146,8 +146,8 @@ export default function SecurityTab() {
)}
</div>
</Card>
<SessionInfoCard />
<IPFilterSection />
<PoliciesPanel />
</div>
);
}
@@ -0,0 +1,152 @@
"use client";
/**
* Session Info Card P-3
*
* Displays current session details and provides session management
* controls (logout, clear sessions) within the Security settings tab.
*/
import { useState, useEffect } from "react";
import { Card, Button } from "@/shared/components";
interface SessionInfo {
authenticated: boolean;
loginTime: string | null;
sessionAge: string;
ipAddress: string;
userAgent: string;
}
export default function SessionInfoCard() {
const [session, setSession] = useState<SessionInfo | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function loadSession() {
// Build session info from client-side data
const loginTime = sessionStorage.getItem("omniroute_login_time");
const now = Date.now();
let sessionAge = "Unknown";
if (loginTime) {
const elapsed = now - parseInt(loginTime, 10);
const hours = Math.floor(elapsed / 3600000);
const minutes = Math.floor((elapsed % 3600000) / 60000);
sessionAge = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
let authenticated = false;
try {
const res = await fetch("/api/auth/status", {
method: "GET",
cache: "no-store",
});
if (res.ok) {
const data = await res.json();
authenticated = data.authenticated === true;
}
} catch {
// Keep unauthenticated fallback on network errors.
}
if (cancelled) return;
setSession({
authenticated,
loginTime: loginTime ? new Date(parseInt(loginTime, 10)).toLocaleString() : null,
sessionAge,
ipAddress: "—", // Server-side only
userAgent: navigator.userAgent.split(" ").slice(-2).join(" ") || "Unknown",
});
setLoading(false);
}
loadSession();
return () => {
cancelled = true;
};
}, []);
const handleLogout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
sessionStorage.removeItem("omniroute_login_time");
window.location.href = "/";
} catch {
window.location.href = "/";
}
};
const handleClearStorage = () => {
if (confirm("Clear all local data? This will reset your preferences.")) {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
}
};
if (loading) {
return (
<Card>
<div className="animate-pulse h-32 bg-black/5 dark:bg-white/5 rounded-lg" />
</Card>
);
}
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
person
</span>
</div>
<h3 className="text-lg font-semibold">Session</h3>
</div>
<div className="flex flex-col gap-3" role="list" aria-label="Session details">
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Status</span>
<span className="flex items-center gap-1.5">
<span
className={`w-2 h-2 rounded-full ${session?.authenticated ? "bg-green-500" : "bg-yellow-500"}`}
aria-hidden="true"
/>
{session?.authenticated ? "Authenticated" : "Guest"}
</span>
</div>
{session?.loginTime && (
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Login Time</span>
<span className="font-mono text-xs">{session.loginTime}</span>
</div>
)}
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Session Age</span>
<span className="font-mono text-xs">{session?.sessionAge}</span>
</div>
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Browser</span>
<span className="font-mono text-xs truncate max-w-[200px]">{session?.userAgent}</span>
</div>
</div>
<div className="flex gap-3 mt-4 pt-4 border-t border-border/50">
<Button variant="secondary" onClick={handleClearStorage}>
Clear Local Data
</Button>
{session?.authenticated && (
<Button variant="danger" onClick={handleLogout}>
Logout
</Button>
)}
</div>
</Card>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Card, Button, Badge } from "@/shared/components";
export default function SystemStorageTab() {
@@ -12,6 +12,12 @@ export default function SystemStorageTab() {
const [confirmRestoreId, setConfirmRestoreId] = useState(null);
const [manualBackupLoading, setManualBackupLoading] = useState(false);
const [manualBackupStatus, setManualBackupStatus] = useState({ type: "", message: "" });
const [exportLoading, setExportLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
const [importStatus, setImportStatus] = useState({ type: "", message: "" });
const [confirmImport, setConfirmImport] = useState(false);
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [storageHealth, setStorageHealth] = useState({
driver: "sqlite",
dbPath: "~/.omniroute/storage.sqlite",
@@ -103,6 +109,91 @@ export default function SystemStorageTab() {
loadStorageHealth();
}, []);
const handleExport = async () => {
setExportLoading(true);
try {
const res = await fetch("/api/db-backups/export");
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Export failed");
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const disposition = res.headers.get("Content-Disposition") || "";
const filenameMatch = disposition.match(/filename="(.+)"/);
const filename = filenameMatch
? filenameMatch[1]
: `omniroute-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`;
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed:", err);
setImportStatus({ type: "error", message: `Export failed: ${(err as Error).message}` });
} finally {
setExportLoading(false);
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith(".sqlite")) {
setImportStatus({
type: "error",
message: "Invalid file type. Only .sqlite files are accepted.",
});
return;
}
setPendingImportFile(file);
setConfirmImport(true);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleImportConfirm = async () => {
if (!pendingImportFile) return;
setImportLoading(true);
setImportStatus({ type: "", message: "" });
setConfirmImport(false);
try {
const formData = new FormData();
formData.append("file", pendingImportFile);
const res = await fetch("/api/db-backups/import", {
method: "POST",
body: formData,
});
const data = await res.json();
if (res.ok) {
setImportStatus({
type: "success",
message: `Database imported! ${data.connectionCount} connections, ${data.nodeCount} nodes, ${data.comboCount} combos, ${data.apiKeyCount} API keys.`,
});
await loadStorageHealth();
if (backupsExpanded) await loadBackups();
} else {
setImportStatus({ type: "error", message: data.error || "Import failed" });
}
} catch {
setImportStatus({ type: "error", message: "An error occurred during import" });
} finally {
setImportLoading(false);
setPendingImportFile(null);
}
};
const handleImportCancel = () => {
setConfirmImport(false);
setPendingImportFile(null);
};
const formatBytes = (bytes) => {
if (!bytes || bytes === 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
@@ -155,7 +246,118 @@ export default function SystemStorageTab() {
</div>
</div>
{/* Last backup + Backup Now */}
{/* Export / Import */}
<div className="flex items-center gap-2 mb-4">
<Button variant="outline" size="sm" onClick={handleExport} loading={exportLoading}>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
download
</span>
Export Database
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
setExportLoading(true);
try {
const res = await fetch("/api/db-backups/exportAll");
if (!res.ok) throw new Error("Export failed");
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") || "";
const filenameMatch = cd.match(/filename="?([^"]+)"?/);
const filename = filenameMatch?.[1] || `omniroute-full-backup.tar.gz`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
setImportStatus({
type: "error",
message: `Full export failed: ${(err as Error).message}`,
});
} finally {
setExportLoading(false);
}
}}
loading={exportLoading}
>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
folder_zip
</span>
Export All (.tar.gz)
</Button>
<Button variant="outline" size="sm" onClick={handleImportClick} loading={importLoading}>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
upload
</span>
Import Database
</Button>
<input
ref={fileInputRef}
type="file"
accept=".sqlite"
className="hidden"
onChange={handleFileSelected}
/>
</div>
{/* Import confirmation dialog */}
{confirmImport && pendingImportFile && (
<div className="p-4 rounded-lg mb-4 bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-3">
<span
className="material-symbols-outlined text-[20px] text-amber-500 mt-0.5"
aria-hidden="true"
>
warning
</span>
<div className="flex-1">
<p className="text-sm font-medium text-amber-500 mb-1">Confirm Database Import</p>
<p className="text-xs text-text-muted mb-2">
This will replace <strong>all current data</strong> with the content from{" "}
<span className="font-mono">{pendingImportFile.name}</span>. A backup will be
created automatically before the import.
</p>
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={handleImportConfirm}
className="!bg-amber-500 hover:!bg-amber-600"
>
Yes, Import
</Button>
<Button variant="outline" size="sm" onClick={handleImportCancel}>
Cancel
</Button>
</div>
</div>
</div>
</div>
)}
{/* Import status */}
{importStatus.message && (
<div
className={`p-3 rounded-lg mb-4 text-sm ${
importStatus.type === "success"
? "bg-green-500/10 text-green-500 border border-green-500/20"
: "bg-red-500/10 text-red-500 border border-red-500/20"
}`}
role="alert"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{importStatus.type === "success" ? "check_circle" : "error"}
</span>
{importStatus.message}
</div>
</div>
)}
<div className="flex items-center justify-between p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-amber-500" aria-hidden="true">
@@ -0,0 +1,28 @@
"use client";
export default function SettingsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-6">
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load settings
</h2>
<p className="text-gray-600 dark:text-gray-400 max-w-md">
{error.message || "An unexpected error occurred while loading settings."}
</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
"use client";
export default function SettingsLoading() {
return (
<div className="space-y-6 animate-pulse p-6">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-36" />
<div className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
</div>
);
}
@@ -11,7 +11,7 @@ import ProxyTab from "./components/ProxyTab";
import AppearanceTab from "./components/AppearanceTab";
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
import SystemPromptTab from "./components/SystemPromptTab";
import ComplianceTab from "./components/ComplianceTab";
import CacheStatsCard from "./components/CacheStatsCard";
import ResilienceTab from "./components/ResilienceTab";
@@ -22,7 +22,6 @@ const tabs = [
{ id: "routing", label: "Routing", icon: "route" },
{ id: "resilience", label: "Resilience", icon: "electrical_services" },
{ id: "advanced", label: "Advanced", icon: "tune" },
{ id: "compliance", label: "Compliance", icon: "policy" },
];
export default function SettingsPage() {
@@ -74,6 +73,7 @@ export default function SettingsPage() {
<div className="flex flex-col gap-6">
<ThinkingBudgetTab />
<SystemPromptTab />
<CacheStatsCard />
</div>
)}
@@ -88,14 +88,7 @@ export default function SettingsPage() {
{activeTab === "resilience" && <ResilienceTab />}
{activeTab === "advanced" && (
<div className="flex flex-col gap-6">
<ProxyTab />
<CacheStatsCard />
</div>
)}
{activeTab === "compliance" && <ComplianceTab />}
{activeTab === "advanced" && <ProxyTab />}
</div>
{/* App Info */}
@@ -14,6 +14,17 @@ const MODES = [
{ value: "live-monitor", label: "Live Monitor", icon: "monitoring" },
];
const MODE_DESCRIPTIONS: Record<string, string> = {
playground:
"Paste any API request body and see how OmniRoute translates it between provider formats (OpenAI ↔ Claude ↔ Gemini ↔ Responses API)",
"chat-tester":
"Send real chat requests through OmniRoute and see the full round-trip: your input, the translated request, the provider response, and the translated output",
"test-bench":
"Define multiple test cases with different inputs and expected outputs, run them all at once, and compare results across providers and models",
"live-monitor":
"Watch incoming requests in real-time as they flow through OmniRoute — see format translations happening live and identify issues instantly",
};
export default function TranslatorPageClient() {
const [mode, setMode] = useState("playground");
@@ -27,7 +38,8 @@ export default function TranslatorPageClient() {
Translator Playground
</h1>
<p className="text-sm text-text-muted mt-1">
Debug, test, and visualize how OmniRoute translates API requests between providers
{MODE_DESCRIPTIONS[mode] ||
"Debug, test, and visualize how OmniRoute translates API requests between providers"}
</p>
</div>
<SegmentedControl options={MODES} value={mode} onChange={setMode} size="md" />
@@ -12,6 +12,42 @@ import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState, DataTable, FilterBar } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
// ── Strategy config for visual legend ──────────────────────────────────
const STRATEGIES = [
{
name: "contains",
label: "Contains",
icon: "search",
color: "text-sky-400",
bg: "bg-sky-500/10",
description: "Checks if the response contains a specific text (case-insensitive)",
},
{
name: "exact",
label: "Exact Match",
icon: "check_circle",
color: "text-emerald-400",
bg: "bg-emerald-500/10",
description: "Response must be an exact character-for-character match",
},
{
name: "regex",
label: "Regex Pattern",
icon: "code",
color: "text-amber-400",
bg: "bg-amber-500/10",
description: "Matches response against a regular expression pattern",
},
{
name: "custom",
label: "Custom Function",
icon: "tune",
color: "text-violet-400",
bg: "bg-violet-500/10",
description: "Uses a custom function for advanced evaluation logic",
},
];
export default function EvalsTab() {
const [suites, setSuites] = useState([]);
const [apiKey, setApiKey] = useState(null);
@@ -21,6 +57,7 @@ export default function EvalsTab() {
const [results, setResults] = useState({});
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState(null);
const [showHowItWorks, setShowHowItWorks] = useState(false);
const notify = useNotificationStore();
const fetchSuites = useCallback(async () => {
@@ -145,6 +182,14 @@ export default function EvalsTab() {
);
});
// Count total cases and unique models across all suites
const totalCases = suites.reduce((sum, s) => sum + (s.cases?.length || s.caseCount || 0), 0);
const uniqueModels: string[] = [
...new Set(
suites.flatMap((s: any) => (s.cases || []).map((c: any) => c.model)).filter(Boolean)
),
];
if (loading) {
return (
<div className="flex items-center gap-2 text-text-muted p-8 animate-pulse">
@@ -156,11 +201,15 @@ export default function EvalsTab() {
if (suites.length === 0) {
return (
<EmptyState
icon="science"
title="No Eval Suites"
description="Eval suites can be defined via the API to test model outputs against expected results."
/>
<div className="flex flex-col gap-6">
{/* Hero Section — always visible */}
<HeroSection />
<EmptyState
icon="science"
title="No Eval Suites Found"
description="Eval suites can be defined via the API or in code. They test model outputs against expected results using strategies like contains, regex, exact match, and custom functions."
/>
</div>
);
}
@@ -173,6 +222,147 @@ export default function EvalsTab() {
return (
<div className="flex flex-col gap-6">
{/* Hero Section */}
<HeroSection />
{/* Stats Bar */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold tracking-wide">
Suites
</span>
<div className="text-2xl font-bold mt-1 text-violet-400">{suites.length}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold tracking-wide">
Test Cases
</span>
<div className="text-2xl font-bold mt-1 text-sky-400">{totalCases}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold tracking-wide">
Models
</span>
<div className="text-2xl font-bold mt-1 text-emerald-400">{uniqueModels.length}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold tracking-wide">
Coverage
</span>
<div className="text-2xl font-bold mt-1 text-amber-400">
{STRATEGIES.length} strategies
</div>
</Card>
</div>
{/* How It Works — Collapsible */}
<Card className="p-0 overflow-hidden">
<button
onClick={() => setShowHowItWorks(!showHowItWorks)}
className="w-full flex items-center justify-between px-6 py-4 hover:bg-surface/30 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[20px]">help</span>
</div>
<div>
<h3 className="text-sm font-semibold text-text-main">How It Works</h3>
<p className="text-xs text-text-muted">
Learn how evaluations validate your LLM responses
</p>
</div>
</div>
<span
className={`material-symbols-outlined text-text-muted transition-transform duration-200 ${
showHowItWorks ? "rotate-180" : ""
}`}
>
expand_more
</span>
</button>
{showHowItWorks && (
<div className="px-6 pb-6 border-t border-border/10">
{/* 3-step process */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div className="flex flex-col items-center text-center p-4 rounded-lg bg-violet-500/5 border border-violet-500/10">
<div className="w-10 h-10 rounded-full bg-violet-500/20 flex items-center justify-center mb-3">
<span className="text-lg font-bold text-violet-400">1</span>
</div>
<h4 className="text-sm font-semibold text-text-main mb-1">Define</h4>
<p className="text-xs text-text-muted">
Create test cases with input prompts and expected output criteria using strategies
like contains, regex, or exact match.
</p>
</div>
<div className="flex flex-col items-center text-center p-4 rounded-lg bg-sky-500/5 border border-sky-500/10">
<div className="w-10 h-10 rounded-full bg-sky-500/20 flex items-center justify-center mb-3">
<span className="text-lg font-bold text-sky-400">2</span>
</div>
<h4 className="text-sm font-semibold text-text-main mb-1">Run</h4>
<p className="text-xs text-text-muted">
Execute test cases against your LLM endpoints through OmniRoute. Each case is sent
as a real API request.
</p>
</div>
<div className="flex flex-col items-center text-center p-4 rounded-lg bg-emerald-500/5 border border-emerald-500/10">
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center mb-3">
<span className="text-lg font-bold text-emerald-400">3</span>
</div>
<h4 className="text-sm font-semibold text-text-main mb-1">Evaluate</h4>
<p className="text-xs text-text-muted">
Responses are compared against expected criteria. See pass/fail for each case with
latency metrics and detailed feedback.
</p>
</div>
</div>
{/* Evaluation Strategies */}
<div className="mt-6">
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-3">
Evaluation Strategies
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{STRATEGIES.map((s) => (
<div
key={s.name}
className={`flex items-center gap-3 p-3 rounded-lg ${s.bg} border border-transparent`}
>
<span className={`material-symbols-outlined text-[18px] ${s.color}`}>
{s.icon}
</span>
<div>
<span className={`text-xs font-mono font-semibold ${s.color}`}>{s.name}</span>
<p className="text-xs text-text-muted mt-0.5">{s.description}</p>
</div>
</div>
))}
</div>
</div>
{/* Model Coverage */}
{uniqueModels.length > 0 && (
<div className="mt-6">
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-3">
Models Under Test
</h4>
<div className="flex flex-wrap gap-2">
{uniqueModels.map((m) => (
<span
key={m}
className="px-3 py-1.5 rounded-full text-xs font-mono font-medium bg-primary/10 text-primary border border-primary/20"
>
{m}
</span>
))}
</div>
</div>
)}
</div>
)}
</Card>
{/* Suite List */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500">
@@ -181,7 +371,7 @@ export default function EvalsTab() {
<div>
<h3 className="text-lg font-semibold">Evaluation Suites</h3>
<p className="text-xs text-text-muted">
Run test cases against your LLM endpoints to validate response quality
Click a suite to view test cases, then run to evaluate your LLM endpoints
</p>
</div>
</div>
@@ -204,6 +394,11 @@ export default function EvalsTab() {
const isExpanded = expanded === suite.id;
const caseCount = suite.cases?.length || suite.caseCount || 0;
// Count unique models in this suite
const suiteModels: string[] = [
...new Set<string>((suite.cases || []).map((c: any) => c.model).filter(Boolean)),
];
return (
<div key={suite.id} className="border border-border/30 rounded-lg overflow-hidden">
<div
@@ -215,24 +410,57 @@ export default function EvalsTab() {
{isExpanded ? "expand_more" : "chevron_right"}
</span>
<div>
<p className="text-sm font-medium text-text-main">{suite.name || suite.id}</p>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-main">
{suite.name || suite.id}
</p>
{suiteResult?.summary && (
<span
className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
suiteResult.summary.passRate === 100
? "bg-emerald-500/10 text-emerald-400"
: suiteResult.summary.passRate >= 80
? "bg-amber-500/10 text-amber-400"
: "bg-red-500/10 text-red-400"
}`}
>
{suiteResult.summary.passRate}% pass
</span>
)}
</div>
<p className="text-xs text-text-muted">
{caseCount} case{caseCount !== 1 ? "s" : ""}
{suite.description && <span className="ml-1"> {suite.description}</span>}
{suiteResult?.summary && (
<span className="ml-2">
Last run: {suiteResult.summary.passed || 0} {" "}
{suiteResult.summary.failed || 0} ({suiteResult.summary.passRate}%)
</span>
)}
</p>
{suiteModels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{suiteModels.map((m) => (
<span
key={m}
className="px-1.5 py-0.5 rounded text-[10px] font-mono text-text-muted bg-black/5 dark:bg-white/5"
>
{m}
</span>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
{isRunning && progress.total > 0 && (
<span className="text-xs text-text-muted font-mono tabular-nums">
{progress.current}/{progress.total}
</span>
<div className="flex items-center gap-2">
<div className="w-24 h-1.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-violet-500 rounded-full transition-all duration-300"
style={{
width: `${(progress.current / progress.total) * 100}%`,
}}
/>
</div>
<span className="text-xs text-text-muted font-mono tabular-nums">
{progress.current}/{progress.total}
</span>
</div>
)}
<Button
size="sm"
@@ -274,6 +502,21 @@ export default function EvalsTab() {
{suiteResult.summary.passed} passed · {suiteResult.summary.failed}{" "}
failed · {suiteResult.summary.total} total
</div>
{/* Visual pass/fail bar */}
<div className="flex-1 h-2 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${suiteResult.summary.passRate}%`,
background:
suiteResult.summary.passRate === 100
? "linear-gradient(90deg, #22c55e, #16a34a)"
: suiteResult.summary.passRate >= 80
? "linear-gradient(90deg, #f59e0b, #d97706)"
: "linear-gradient(90deg, #ef4444, #dc2626)",
}}
/>
</div>
</div>
)}
<DataTable
@@ -301,18 +544,22 @@ export default function EvalsTab() {
const d = row.details || {};
return (
<span className="text-text-muted text-xs truncate max-w-[300px] block">
{String((d as any).searchTerm
? `Contains: "${(d as any).searchTerm}"`
: (d as any).pattern
? `Regex: ${(d as any).pattern}`
: (d as any).expected
? `Expected: "${String((d as any).expected).slice(0, 50)}"`
: row.error || "—")}
{String(
(d as any).searchTerm
? `Contains: "${(d as any).searchTerm}"`
: (d as any).pattern
? `Regex: ${(d as any).pattern}`
: (d as any).expected
? `Expected: "${String((d as any).expected).slice(0, 50)}"`
: row.error || "—"
)}
</span>
);
}
return (
<span className="text-sm text-text-main">{(row as any)[col.key] || "—"}</span>
<span className="text-sm text-text-main">
{(row as any)[col.key] || "—"}
</span>
);
}}
maxHeight="400px"
@@ -348,20 +595,24 @@ export default function EvalsTab() {
}))}
renderCell={(row, col) => {
if (col.key === "strategy") {
const colorMap = {
contains: "text-sky-400",
exact: "text-emerald-400",
regex: "text-amber-400",
custom: "text-violet-400",
};
const strat = STRATEGIES.find(
(s) => s.name === (row as any).strategy
);
return (
<span
className={`text-xs font-mono ${(colorMap as any)[(row as any).strategy] || "text-text-muted"}`}
className={`text-xs font-mono font-semibold ${strat?.color || "text-text-muted"}`}
>
{(row as any).strategy}
</span>
);
}
if (col.key === "model") {
return (
<span className="text-xs font-mono text-primary/80">
{(row as any).model}
</span>
);
}
if (col.key === "expected") {
return (
<span className="text-text-muted text-xs font-mono truncate max-w-[300px] block">
@@ -370,7 +621,9 @@ export default function EvalsTab() {
);
}
return (
<span className="text-sm text-text-main">{(row as any)[col.key] || "—"}</span>
<span className="text-sm text-text-main">
{(row as any)[col.key] || "—"}
</span>
);
}}
maxHeight="400px"
@@ -378,7 +631,8 @@ export default function EvalsTab() {
/>
<p className="text-xs text-text-muted mt-3 flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px]">info</span>
Click &quot;Run Eval&quot; to execute all cases against your LLM endpoint
Click &quot;Run Eval&quot; to execute all cases against your LLM endpoint.
Each test sends a real request through OmniRoute.
</p>
</>
)}
@@ -392,3 +646,55 @@ export default function EvalsTab() {
</div>
);
}
// ── Hero Section Component ─────────────────────────────────────────────
function HeroSection() {
return (
<Card className="p-0 overflow-hidden">
<div
className="p-6"
style={{
background:
"linear-gradient(135deg, rgba(139, 92, 246, 0.05) 0%, rgba(59, 130, 246, 0.05) 50%, rgba(16, 185, 129, 0.05) 100%)",
}}
>
<div className="flex items-start gap-4">
<div className="p-3 rounded-xl bg-violet-500/10 text-violet-500">
<span className="material-symbols-outlined text-[28px]">science</span>
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-main mb-1">Model Evaluations</h2>
<p className="text-sm text-text-muted leading-relaxed max-w-2xl">
Test and validate your LLM endpoints by running predefined evaluation suites. Each
suite contains test cases that send real prompts through OmniRoute and compare
responses against expected criteria helping you detect regressions, compare models,
and ensure response quality across providers.
</p>
<div className="flex flex-wrap items-center gap-4 mt-4">
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="material-symbols-outlined text-[16px] text-emerald-400">
verified
</span>
Quality Validation
</div>
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="material-symbols-outlined text-[16px] text-sky-400">compare</span>
Model Comparison
</div>
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="material-symbols-outlined text-[16px] text-amber-400">
bug_report
</span>
Regression Detection
</div>
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="material-symbols-outlined text-[16px] text-violet-400">speed</span>
Latency Benchmarks
</div>
</div>
</div>
</div>
</div>
</Card>
);
}
+2 -15
View File
@@ -1,10 +1,7 @@
"use client";
import { useState, Suspense } from "react";
import { RequestLoggerV2, ProxyLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
import ProviderLimits from "./components/ProviderLimits";
import SessionsTab from "./components/SessionsTab";
import RateLimitStatus from "./components/RateLimitStatus";
import { useState } from "react";
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
export default function UsagePage() {
const [activeTab, setActiveTab] = useState("logs");
@@ -15,22 +12,12 @@ export default function UsagePage() {
options={[
{ value: "logs", label: "Logger" },
{ value: "proxy-logs", label: "Proxy" },
{ value: "limits", label: "Limits" },
]}
value={activeTab}
onChange={setActiveTab}
/>
{/* Content */}
{activeTab === "limits" && (
<div className="flex flex-col gap-6">
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits />
</Suspense>
<RateLimitStatus />
<SessionsTab />
</div>
)}
{activeTab === "logs" && <RequestLoggerV2 />}
{activeTab === "proxy-logs" && <ProxyLogger />}
</div>
+21 -6
View File
@@ -5,12 +5,22 @@ import { SignJWT } from "jose";
import { cookies } from "next/headers";
import { loginSchema, validateBody } from "@/shared/validation/schemas";
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "omniroute-default-secret-change-me"
);
// SECURITY: No hardcoded fallback — JWT_SECRET must be configured.
if (!process.env.JWT_SECRET) {
console.error("[SECURITY] FATAL: JWT_SECRET is not set. Login authentication is disabled.");
}
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "");
export async function POST(request) {
try {
// Fail-fast if JWT_SECRET is not configured
if (!process.env.JWT_SECRET) {
return NextResponse.json(
{ error: "Server misconfigured: JWT_SECRET not set. Contact administrator." },
{ status: 500 }
);
}
const rawBody = await request.json();
// Zod validation
@@ -21,15 +31,20 @@ export async function POST(request) {
const { password } = validation.data;
const settings = await getSettings();
// Default password is '123456' if not set
const storedHash = settings.password;
let isValid = false;
if (storedHash) {
isValid = await bcrypt.compare(password, storedHash);
} else {
// Use env var or default
const initialPassword = process.env.INITIAL_PASSWORD || "123456";
// SECURITY: No default password — must be set via env or onboarding
if (!process.env.INITIAL_PASSWORD) {
return NextResponse.json(
{ error: "No password configured. Complete onboarding first.", needsSetup: true },
{ status: 403 }
);
}
const initialPassword = process.env.INITIAL_PASSWORD;
isValid = password === initialPassword;
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
const SECRET = process.env.JWT_SECRET
? new TextEncoder().encode(process.env.JWT_SECRET)
: null;
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
if (!token || !SECRET) {
return NextResponse.json({ authenticated: false });
}
await jwtVerify(token, SECRET);
return NextResponse.json({ authenticated: true });
} catch {
return NextResponse.json({ authenticated: false });
}
}
@@ -9,6 +9,7 @@ import {
getCliRuntimeStatus,
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
// Get claude settings path based on OS
const getClaudeSettingsPath = () => getCliPrimaryConfigPath("claude");
@@ -121,6 +122,13 @@ export async function POST(request: Request) {
// Write new settings
await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2));
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("claude");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Settings updated successfully",
@@ -184,6 +192,13 @@ export async function DELETE() {
// Write updated settings
await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2));
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("claude");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Settings reset successfully",
@@ -6,6 +6,7 @@ import path from "path";
import os from "os";
import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data");
const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json");
@@ -151,6 +152,13 @@ export async function POST(request: Request) {
await fs.writeFile(SECRETS_PATH, JSON.stringify(secrets, null, 2));
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("cline");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Cline settings applied successfully!",
@@ -210,6 +218,13 @@ export async function DELETE() {
delete secrets.openAiApiKey;
await fs.writeFile(SECRETS_PATH, JSON.stringify(secrets, null, 2));
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("cline");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "OmniRoute settings removed from Cline",
@@ -9,6 +9,7 @@ import {
getCliRuntimeStatus,
} from "@/shared/services/cliRuntime";
import { createMultiBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
const getCodexConfigPath = () => getCliConfigPaths("codex").config;
const getCodexAuthPath = () => getCliConfigPaths("codex").auth;
@@ -201,6 +202,13 @@ export async function POST(request: Request) {
authData.OPENAI_API_KEY = apiKey;
await fs.writeFile(authPath, JSON.stringify(authData, null, 2));
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("codex");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Codex settings applied successfully!",
@@ -270,6 +278,13 @@ export async function DELETE() {
/* No auth file */
}
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("codex");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "OmniRoute settings removed successfully",
@@ -9,6 +9,7 @@ import {
getCliRuntimeStatus,
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid");
const getDroidDir = () => path.dirname(getDroidSettingsPath());
@@ -132,6 +133,13 @@ export async function POST(request: Request) {
// Write settings
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("droid");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Factory Droid settings applied successfully!",
@@ -184,6 +192,13 @@ export async function DELETE() {
// Write updated settings
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("droid");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "OmniRoute settings removed successfully",

Some files were not shown because too many files have changed in this diff Show More