Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 282c46fd46 | |||
| bc719875f6 | |||
| 78b634b204 | |||
| 39aae51ca2 | |||
| 937a8f3714 | |||
| c34973f40d | |||
| 3045aa3055 | |||
| 1e332babd6 | |||
| 2ddf63fde1 | |||
| de2ef14bc3 | |||
| 37123160e8 | |||
| 5a645973eb | |||
| 0279341221 | |||
| 7efbfad9bd | |||
| 3aca5fa691 | |||
| 1a7b7e8799 | |||
| 4c93f0618c | |||
| 16b72970d7 | |||
| fbceb0d301 | |||
| ed05599cdd | |||
| f79746f183 | |||
| 0ab89a2438 | |||
| ebecc7de39 | |||
| 62f3c7416e | |||
| 599668bba2 | |||
| 6ab6049931 | |||
| 94b290dbe2 | |||
| 983920b735 | |||
| 7aa4fcc39b | |||
| 5dff9e2af6 | |||
| 71d14209a4 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 1–2)
|
||||
|
||||
- **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 3–4)
|
||||
|
||||
- **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 01–06 (input validation, CSRF, rate limiting, auth hardening)
|
||||
- 🤖 **Advanced features** — Phases 07–09 (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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
[](https://hub.docker.com/r/diegosouzapw/omniroute)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
|
||||
[](https://omniroute.online)
|
||||
[](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
|
||||
|
||||
[](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>
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
[](https://hub.docker.com/r/diegosouzapw/omniroute)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
|
||||
[](https://omniroute.online)
|
||||
[](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
|
||||
|
||||
[](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>
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
[](https://hub.docker.com/r/diegosouzapw/omniroute)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
|
||||
[](https://omniroute.online)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Участники
|
||||
|
||||
[](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>
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
[](https://hub.docker.com/r/diegosouzapw/omniroute)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
|
||||
[](https://omniroute.online)
|
||||
[](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`
|
||||
|
||||
**专业建议:** 超快推理 — 最适合实时编程!
|
||||
|
||||
### OpenRouter(100+ 模型)
|
||||
|
||||
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.1(5 小时重置,$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>
|
||||
|
||||
### iFlow(8 个免费模型)
|
||||
|
||||
```bash
|
||||
仪表板 → 连接 iFlow
|
||||
→ iFlow OAuth 登录
|
||||
→ 无限使用
|
||||
|
||||
模型:
|
||||
if/kimi-k2-thinking
|
||||
if/qwen3-coder-plus
|
||||
if/glm-4.7
|
||||
if/minimax-m2
|
||||
if/deepseek-r1
|
||||
```
|
||||
|
||||
### Qwen(3 个免费模型)
|
||||
|
||||
```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 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)
|
||||
- **弹性**: 断路器、指数退避、反惊群、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)
|
||||
|
||||
---
|
||||
|
||||
## 👥 贡献者
|
||||
|
||||
[](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>
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🎨 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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 📊 Analytics
|
||||
|
||||
Comprehensive usage analytics with token consumption, cost estimates, activity heatmaps, weekly distribution charts, and per-provider breakdowns.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🏥 System Health
|
||||
|
||||
Real-time monitoring: uptime, memory, version, latency percentiles (p50/p95/p99), cache statistics, and provider circuit breaker states.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🔧 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).
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Settings
|
||||
|
||||
General settings, system storage, backup management (export/import database), appearance (dark/light mode), security, routing, resilience, and advanced configuration.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🔧 CLI Tools
|
||||
|
||||
One-click configuration for AI coding tools: Claude Code, Codex CLI, Gemini CLI, OpenClaw, Kilo Code, and Antigravity.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 📝 Request Logs
|
||||
|
||||
Real-time request logging with filtering by provider, model, account, and API key. Shows status codes, token usage, latency, and response details.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🌐 API Endpoint
|
||||
|
||||
Your unified API endpoint with capability breakdown: Chat Completions, Embeddings, Image Generation, Reranking, Audio Transcription, and registered API keys.
|
||||
|
||||

|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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) |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 173 KiB |
@@ -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/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}": [
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 610 KiB |
@@ -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 |
|
After Width: | Height: | Size: 27 KiB |
|
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>
|
||||
|
||||
@@ -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 "Run Eval" to execute all cases against your LLM endpoint
|
||||
Click "Run Eval" 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||