Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| baae44f825 | |||
| 5fe53168b2 | |||
| ebdc1c214d | |||
| 467e998650 | |||
| 9a9f592f54 | |||
| c3fe96e221 | |||
| 1c6541f25d | |||
| 9f8dfa1398 | |||
| e4db9bff0e | |||
| 54aba4c087 | |||
| c2542b75f6 | |||
| 6572f2a1b6 | |||
| 2ad23f3b3c | |||
| fa9687ae0d | |||
| 0ed7738c7c | |||
| a43b1c9218 | |||
| 413a9f2a69 | |||
| 14714aa9f5 | |||
| ac4df197c3 | |||
| 06826d4c86 | |||
| 78b0782129 | |||
| 5c92f05079 | |||
| 16cdb345a0 | |||
| 0e238a61fb | |||
| 6964904bac | |||
| 597c590a1d | |||
| a94b6a8d8c | |||
| b08fb31a28 | |||
| 5bfd621438 | |||
| 034f1a7e1d | |||
| bc95095f82 | |||
| 7cf8ae8db6 | |||
| 05e93fa9b0 | |||
| 1acaf66dd0 | |||
| 1cee3ec8ff | |||
| 33c28f73db | |||
| 25f4f5987c | |||
| 4269c25a9f | |||
| 87b36dc197 | |||
| 6e29bd1197 | |||
| 5db6676319 | |||
| d08b3889d3 | |||
| 443e5b7b62 | |||
| b87e04db22 | |||
| 4341254b5d | |||
| 6718b23d57 | |||
| e3fc9387be | |||
| c094c9c678 | |||
| 388f06c74f | |||
| 91f6750d59 | |||
| 7e066df6cd | |||
| 33f8123835 | |||
| e87067f2fb | |||
| a9a85fdc1b | |||
| 31c09f08e1 | |||
| 3e89560a33 | |||
| 2dbb717377 | |||
| f44ec7e1f2 | |||
| ad003b6226 | |||
| c09978454b | |||
| daffd6c9ca | |||
| 492afc4ff1 | |||
| 967689d0a1 | |||
| f77dd89d53 | |||
| 0a5434cae5 | |||
| 2178e99da7 | |||
| 1cbbc33f20 |
+17
-5
@@ -1,14 +1,22 @@
|
||||
# OmniRoute environment contract
|
||||
# This file reflects actual runtime usage in the current codebase.
|
||||
|
||||
# Required
|
||||
JWT_SECRET=change-me-to-a-long-random-secret
|
||||
INITIAL_PASSWORD=123456
|
||||
# ═══════════════════════════════════════════════════
|
||||
# REQUIRED SECRETS — Generate strong values!
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Generate with: openssl rand -base64 48
|
||||
JWT_SECRET=
|
||||
# Generate with: openssl rand -hex 32
|
||||
API_KEY_SECRET=
|
||||
|
||||
# Initial admin password — CHANGE THIS before first use!
|
||||
INITIAL_PASSWORD=CHANGEME
|
||||
DATA_DIR=/var/lib/omniroute
|
||||
|
||||
# Storage (SQLite)
|
||||
STORAGE_DRIVER=sqlite
|
||||
STORAGE_ENCRYPTION_KEY=change-me-storage-encryption-key
|
||||
# Generate with: openssl rand -hex 32
|
||||
STORAGE_ENCRYPTION_KEY=
|
||||
STORAGE_ENCRYPTION_KEY_VERSION=v1
|
||||
LOG_RETENTION_DAYS=90
|
||||
SQLITE_MAX_SIZE_MB=2048
|
||||
@@ -20,12 +28,16 @@ NODE_ENV=production
|
||||
INSTANCE_NAME=omniroute
|
||||
|
||||
# Recommended security and ops variables
|
||||
API_KEY_SECRET=endpoint-proxy-api-key-secret
|
||||
MACHINE_ID_SALT=endpoint-proxy-salt
|
||||
ENABLE_REQUEST_LOGS=false
|
||||
AUTH_COOKIE_SECURE=false
|
||||
REQUIRE_API_KEY=false
|
||||
|
||||
# Input Sanitizer (FASE-01 — prompt injection & PII protection)
|
||||
# INPUT_SANITIZER_ENABLED=true
|
||||
# INPUT_SANITIZER_MODE=warn # warn | block | redact
|
||||
# PII_REDACTION_ENABLED=false
|
||||
|
||||
# Cloud sync variables
|
||||
# Must point to this running instance so internal sync jobs can call /api/sync/cloud.
|
||||
# Server-side preferred variables:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
security:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Dependency audit
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
- name: Check for known vulnerabilities
|
||||
run: npx is-my-node-vulnerable || true
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
test-unit:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:unit
|
||||
|
||||
test-coverage:
|
||||
name: Coverage
|
||||
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@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:coverage
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
echo "Coverage report generated. Check output for threshold compliance."
|
||||
|
||||
test-e2e:
|
||||
name: E2E 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@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npm run build
|
||||
- run: npm run test:e2e
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Publish to Docker Hub
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from release tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${VERSION#v}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing Docker image version: $VERSION"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: runner-base
|
||||
push: true
|
||||
tags: |
|
||||
diegosouzapw/omniroute:${{ steps.version.outputs.version }}
|
||||
diegosouzapw/omniroute:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Update Docker Hub description
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: diegosouzapw/omniroute
|
||||
short-description: "OmniRoute — Unified AI proxy. Route any LLM through one endpoint."
|
||||
readme-filepath: ./README.md
|
||||
@@ -47,6 +47,10 @@ next-env.d.ts
|
||||
data/
|
||||
logs/*
|
||||
|
||||
# analysis directories (generated, not tracked)
|
||||
.analysis/
|
||||
antigravity-manager-analysis/
|
||||
|
||||
# docs (allow specific tracked files)
|
||||
docs/*
|
||||
!docs/ARCHITECTURE.md
|
||||
@@ -56,6 +60,9 @@ docs/*
|
||||
!docs/TASK_NEBIUS_BACKEND_ENABLEMENT.md
|
||||
!docs/frontend-backend-provider-gap-report.md
|
||||
!docs/openapi.yaml
|
||||
!docs/PLANO-IMPLANTACAO.md
|
||||
!docs/TASKS.md
|
||||
!docs/FASE-*.md
|
||||
!docs/adr/
|
||||
!docs/cli-tools/
|
||||
!docs/planning/
|
||||
|
||||
+111
-191
@@ -2,213 +2,133 @@
|
||||
|
||||
All notable changes to OmniRoute are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
## [0.7.0] — 2026-02-16
|
||||
|
||||
### Added
|
||||
|
||||
- 🐳 **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
|
||||
|
||||
---
|
||||
|
||||
## [0.6.0] — 2026-02-16
|
||||
|
||||
### Added
|
||||
|
||||
- 💰 **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
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] — 2026-02-15
|
||||
|
||||
### Added
|
||||
|
||||
- 🧪 **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
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 Fix `TypeError` in `chat/completions` `ensureInitialized` call
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
Major feature release: advanced routing services, security hardening, cost analytics dashboard, and pricing management overhaul.
|
||||
|
||||
### Added
|
||||
|
||||
#### Open-SSE Services
|
||||
|
||||
- **Account Selector** — intelligent provider account selection with priority and load-balancing strategies (`accountSelector.js`)
|
||||
- **Context Manager** — request context tracking and lifecycle management (`contextManager.js`)
|
||||
- **IP Filter** — allowlist/blocklist IP filtering with CIDR support (`ipFilter.js`)
|
||||
- **Session Manager** — persistent session tracking across requests (`sessionManager.js`)
|
||||
- **Signature Cache** — request signature caching for deduplication (`signatureCache.js`)
|
||||
- **System Prompt** — global system prompt injection into all chat completions (`systemPrompt.js`)
|
||||
- **Thinking Budget** — token budget management for reasoning models (`thinkingBudget.js`)
|
||||
- **Wildcard Router** — pattern-based model routing with glob matching (`wildcardRouter.js`)
|
||||
- Enhanced **Rate Limit Manager** with sliding-window algorithm and per-key quotas
|
||||
|
||||
#### Dashboard Settings
|
||||
|
||||
- **IP Filter** settings tab — configure allowed/blocked IPs from the UI (`IPFilterSection.js`)
|
||||
- **System Prompt** settings tab — set global system prompt injection (`SystemPromptTab.js`)
|
||||
- **Thinking Budget** settings tab — configure reasoning token budgets (`ThinkingBudgetTab.js`)
|
||||
- **Pricing Tab** — full-page redesign with provider-centric organization, inline editing, search/filter, and save/reset per provider (`PricingTab.js`)
|
||||
- **Rate Limit Status** component on Usage page (`RateLimitStatus.js`)
|
||||
- **Sessions Tab** on Usage page — view and manage active sessions (`SessionsTab.js`)
|
||||
|
||||
#### Usage & Cost Analytics
|
||||
|
||||
- **Cost stat card** (amber accent) prominently displayed in analytics top row
|
||||
- **Provider Cost Donut** — new chart showing cost distribution across providers
|
||||
- **Daily Cost Trend** — cost line overlay (amber) on token trend chart with secondary Y-axis
|
||||
- **Model Table Cost column** — sortable cost column in model breakdown table
|
||||
- Cost-aware tooltip formatting throughout analytics charts
|
||||
|
||||
#### Pricing API
|
||||
|
||||
- `/api/pricing/models` endpoint — serves merged model catalog from 3 sources: registry, custom models (DB), and pricing-only models
|
||||
- Custom model badge in pricing page for user-imported models
|
||||
- `/api/rate-limits` endpoint for rate limit configuration
|
||||
- `/api/sessions` endpoint for session management
|
||||
- `/api/settings/ip-filter`, `/api/settings/system-prompt`, `/api/settings/thinking-budget` endpoints
|
||||
|
||||
#### Cloudflare Worker
|
||||
|
||||
- Cloud worker module for edge deployment (`cloud/`)
|
||||
|
||||
#### Tests
|
||||
|
||||
- Unit tests for account selector, context manager, IP filter, enhanced rate limiting, session manager, signature cache, system prompt, thinking budget, and wildcard router (9 new test files)
|
||||
|
||||
#### Documentation
|
||||
|
||||
- OpenAPI specification at `docs/openapi.yaml` covering all 89 API endpoints
|
||||
- Enhanced `restart.sh` with clean build, health check, graceful shutdown (Ctrl+C), and real-time log tailing
|
||||
- Updated architecture documentation and codebase docs with new services and API routes
|
||||
- Model selector with autocomplete in Chat Tester and Test Bench modes
|
||||
|
||||
### Fixed
|
||||
|
||||
- Server port collision (EADDRINUSE) during restart — now kills port before `next start`
|
||||
- Icon rendering corrected from `material-symbols-rounded` to `material-symbols-outlined`
|
||||
- Pricing page only showed hardcoded registry models — now includes custom/imported models
|
||||
|
||||
### Changed
|
||||
|
||||
- Usage analytics layout reorganized: donuts separated into logical groupings, bottom stats simplified from 6 to 4 cards
|
||||
- Daily trend chart upgraded from `BarChart` to `ComposedChart` with dual Y-axes
|
||||
- Routing tab updated with new service integrations
|
||||
- 🛣️ **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.0.1] — 2026-02-13
|
||||
|
||||
Initial public release of OmniRoute (rebranded from 9router).
|
||||
## [0.1.0] — 2026-02-14
|
||||
|
||||
### Added
|
||||
|
||||
- **28 AI Providers** — OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, xAI, Mistral, Perplexity, Together AI, Fireworks AI, Cerebras, Cohere, NVIDIA NIM, Nebius, GitHub Copilot, Cursor, Kiro, Kimi, MiniMax, iFlow, and more
|
||||
- **OpenAI-compatible proxy** at `/api/v1/chat/completions` with automatic format translation, load balancing, and failover
|
||||
- **Anthropic Messages API** at `/api/v1/messages` for Claude-native clients
|
||||
- **OpenAI Responses API** at `/api/v1/responses` for modern OpenAI workflows
|
||||
- **Embeddings API** at `/api/v1/embeddings` with 6 providers and 9 models
|
||||
- **Image Generation API** at `/api/v1/images/generations` with 4 providers and 9 models
|
||||
- **Format Translator** — automatic request/response conversion between OpenAI, Anthropic, Gemini, and OpenAI Responses formats
|
||||
- **Translator Playground** with 4 modes: Playground, Chat Tester, Test Bench, Live Monitor
|
||||
- **Combo Routing** — named route configurations with priority, weighted, and round-robin strategies
|
||||
- **API Key Management** — create/revoke keys with usage attribution
|
||||
- **Usage Dashboard** — analytics, call logs, request logger with API key filtering and cost tracking
|
||||
- **Provider Health Diagnostics** — structured status (runtime errors, auth failures, token refresh) with per-connection retest
|
||||
- **CLI Tools Integration** — runtime detection for Cline, Kiro, Droid, OpenClaw with backup/restore
|
||||
- **OAuth Flows** — for Cursor, Kiro, Kimi, and GitHub Copilot
|
||||
- **Docker Support** — multi-stage Dockerfile, docker-compose with 3 profiles (base, cli, host), production compose
|
||||
- **SOCKS5 Proxy** — outbound proxy support enabled by default (`ab8d752`)
|
||||
- **Unified Storage** — `DATA_DIR` / `XDG_CONFIG_HOME` resolution with auto-migration from `~/.omniroute`
|
||||
- **In-app Documentation** at `/docs` with quick start, endpoint reference, and client compatibility notes
|
||||
- **Dark Theme UI** — modern dashboard with glassmorphism, responsive layout
|
||||
- `<think>` tag parser for reasoning models (DeepSeek, Qwen)
|
||||
- Non-stream response translation for all formats
|
||||
- Secure cookie handling for LAN/reverse-proxy deployments
|
||||
|
||||
### Fixed
|
||||
|
||||
- OAuth re-authentication no longer creates duplicate connections (`773f117`, `510aedd`)
|
||||
- Connection test no longer corrupts valid OAuth tokens (`a2ba189`)
|
||||
- Cloud sync disabled to prevent 404 log spam (`71d132e`)
|
||||
- `.env.example` synced with current environment structure (`6bdc74b`)
|
||||
- Select dropdown dark theme inconsistency (`1bd734d`)
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `actions/github-script` bumped from 7 to 8 (`f6a994a`)
|
||||
- `eslint` bumped from 9.39.2 to 10.0.0 (`ecd4aea`)
|
||||
- 🎉 **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
|
||||
|
||||
---
|
||||
|
||||
## Pre-Release History (9router)
|
||||
|
||||
> The following entries document the legacy 9router project before it was
|
||||
> rebranded to OmniRoute. All changes below were included in the initial
|
||||
> `0.0.1` release.
|
||||
|
||||
### 0.2.75 — 2026-02-11
|
||||
|
||||
- API key attribution in usage/call logs with per-key analytics aggregates
|
||||
- Usage dashboard API key observability (distribution donut, filterable table)
|
||||
- In-app docs page (`/docs`) with quick start, endpoint reference, and client compatibility notes
|
||||
- Unified storage path policy (`DATA_DIR` → `XDG_CONFIG_HOME` → `~/.omniroute`)
|
||||
- Build-phase guard for `usageDb` (in-memory during `next build`)
|
||||
- LAN/reverse-proxy cookie security detection
|
||||
- Hardened Gemini 3 Flash normalization and non-stream SSE fallback parsing
|
||||
- CLI tool runtime and OAuth refresh reliability improvements
|
||||
- Provider health diagnostics with structured error types
|
||||
|
||||
### 0.2.74 — 2026-02-11
|
||||
|
||||
- Model resolution fallback fix for unprefixed models
|
||||
- GitHub Copilot dynamic endpoint selection (Codex → `/responses`)
|
||||
- Non-stream translation path for OpenAI Responses
|
||||
- Updated GitHub model catalog with compatibility aliases
|
||||
|
||||
### 0.2.73 — 2026-02-09
|
||||
|
||||
- Expanded provider registry from 18 → 28 providers (DeepSeek, Groq, xAI, Mistral, Perplexity, Together AI, Fireworks AI, Cerebras, Cohere, NVIDIA NIM)
|
||||
- `/v1/embeddings` endpoint with 6 providers and 9 models
|
||||
- `/v1/images/generations` endpoint with 4 providers and 9 models
|
||||
- `<think>` tag parser for reasoning models
|
||||
- Available Endpoints card on Endpoint page (127 chat, 9 embedding, 9 image models)
|
||||
|
||||
### 0.2.72 — 2026-02-08
|
||||
|
||||
- Split Kimi into dual providers: `kimi` (OpenAI-compatible) and `kimi-coding` (Moonshot API)
|
||||
- Hybrid CLI runtime support with Docker profiles (`runner-base`, `runner-cli`)
|
||||
- Hardened cloud sync/auth flow with SSE fallback
|
||||
|
||||
### 0.2.66 — 2026-02-06
|
||||
|
||||
- Cursor provider end-to-end support with OAuth import flow
|
||||
- `requireLogin` control and `hasPassword` state handling
|
||||
- Usage/quota UX improvements
|
||||
- Model support for custom providers
|
||||
- Codex updates (GPT-5.3, thinking levels), Claude Opus 4.6, MiniMax Coding
|
||||
- Auto-validation for provider API keys
|
||||
|
||||
### 0.2.56 — 2026-02-04
|
||||
|
||||
- Anthropic-compatible provider support
|
||||
- Provider icons across dashboard
|
||||
- Enhanced usage tracking pipeline
|
||||
|
||||
### 0.2.52 — 2026-02-02
|
||||
|
||||
- Codex Cursor compatibility and Next.js 16 proxy migration
|
||||
- OpenAI-compatible provider nodes (CRUD/validation/test)
|
||||
- Token expiration and key-validity checks
|
||||
- Non-streaming response translation for multiple formats
|
||||
- Kiro OAuth wiring and token refresh support
|
||||
|
||||
### 0.2.43 — 2026-01-27
|
||||
|
||||
- Fixed CLI tools model selection
|
||||
- Fixed Kiro translator request handling
|
||||
|
||||
### 0.2.36 — 2026-01-19
|
||||
|
||||
- Usage dashboard page
|
||||
- Outbound proxy support in Open SSE fetch pipeline
|
||||
- Fixed combo fallback behavior
|
||||
|
||||
### 0.2.31 — 2026-01-18
|
||||
|
||||
- Fixed Kiro token refresh and executor behavior
|
||||
- Fixed Kiro request translation handling
|
||||
|
||||
### 0.2.27 — 2026-01-15
|
||||
|
||||
- Added Kiro provider support with OAuth flow
|
||||
- Fixed Codex provider behavior
|
||||
|
||||
### 0.2.21 — 2026-01-12
|
||||
|
||||
- Initial README and project setup
|
||||
[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
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
# Contributing to OmniRoute
|
||||
|
||||
Thank you for your interest in contributing! This guide covers everything you need to get started.
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** 20+ (recommended: 22 LTS)
|
||||
- **npm** 10+
|
||||
- **Git**
|
||||
|
||||
### Clone & Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/diegosouzapw/OmniRoute.git
|
||||
cd OmniRoute
|
||||
npm install
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Create your .env from the template
|
||||
cp .env.example .env
|
||||
|
||||
# Generate required secrets
|
||||
echo "JWT_SECRET=$(openssl rand -base64 48)" >> .env
|
||||
echo "API_KEY_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
Key variables for development:
|
||||
|
||||
| Variable | Development Default | Description |
|
||||
| ---------------------- | ----------------------- | ------------------------- |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Base URL for frontend |
|
||||
| `JWT_SECRET` | (generate above) | JWT signing secret |
|
||||
| `INITIAL_PASSWORD` | `123456` | First login password |
|
||||
| `ENABLE_REQUEST_LOGS` | `false` | Enable debug request logs |
|
||||
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
# Development mode (hot reload)
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
npm run start
|
||||
|
||||
# Common port configuration
|
||||
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
||||
```
|
||||
|
||||
Default URLs:
|
||||
|
||||
- **Dashboard**: `http://localhost:3000/dashboard`
|
||||
- **API**: `http://localhost:3000/v1`
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow
|
||||
|
||||
> ⚠️ **NEVER commit directly to `main`.** Always use feature branches.
|
||||
|
||||
```bash
|
||||
git checkout -b feat/your-feature-name
|
||||
# ... make changes ...
|
||||
git commit -m "feat: describe your change"
|
||||
git push -u origin feat/your-feature-name
|
||||
# Open a Pull Request on GitHub
|
||||
```
|
||||
|
||||
### Branch Naming
|
||||
|
||||
| Prefix | Purpose |
|
||||
| ----------- | ------------------------- |
|
||||
| `feat/` | New features |
|
||||
| `fix/` | Bug fixes |
|
||||
| `refactor/` | Code restructuring |
|
||||
| `docs/` | Documentation changes |
|
||||
| `test/` | Test additions/fixes |
|
||||
| `chore/` | Tooling, CI, dependencies |
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat: add circuit breaker for provider calls
|
||||
fix: resolve JWT secret validation edge case
|
||||
docs: update SECURITY.md with PII protection
|
||||
test: add observability unit tests
|
||||
refactor(db): consolidate rate limit tables
|
||||
```
|
||||
|
||||
Scopes: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`.
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All unit tests
|
||||
npm test
|
||||
npm run test:unit
|
||||
|
||||
# Specific test suites
|
||||
npm run test:security # Security tests
|
||||
npm run test:fixes # Fix verification tests
|
||||
|
||||
# With coverage
|
||||
npm run test:coverage
|
||||
|
||||
# E2E tests (requires Playwright)
|
||||
npm run test:e2e
|
||||
|
||||
# Lint + format check
|
||||
npm run lint
|
||||
npm run check
|
||||
```
|
||||
|
||||
Current test status: **320+ unit tests** covering:
|
||||
|
||||
- Provider translators and format conversion
|
||||
- Rate limiting, circuit breaker, and resilience
|
||||
- Semantic cache, idempotency, progress tracking
|
||||
- Database operations and schema
|
||||
- OAuth flows and authentication
|
||||
- API endpoint validation
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
- **ESLint** — Run `npm run lint` before committing
|
||||
- **Prettier** — Auto-formatted via `lint-staged` on commit
|
||||
- **JSDoc** — Document public functions with `@param`, `@returns`, `@throws`
|
||||
- **No `eval()`** — ESLint enforces `no-eval`, `no-implied-eval`, `no-new-func`
|
||||
- **Zod validation** — Use Zod schemas for API input validation
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── (dashboard)/ # Dashboard pages
|
||||
│ ├── api/ # API routes
|
||||
│ └── login/ # Auth pages
|
||||
├── domain/ # Domain types and response helpers
|
||||
├── lib/ # Core business logic
|
||||
│ ├── db/ # SQLite database layer
|
||||
│ ├── oauth/ # OAuth services per provider
|
||||
│ ├── cacheLayer.js # LRU cache
|
||||
│ ├── semanticCache.js # Semantic response cache
|
||||
│ ├── idempotencyLayer.js # Request deduplication
|
||||
│ └── localDb.js # LowDB (JSON) storage
|
||||
├── shared/
|
||||
│ ├── components/ # React components
|
||||
│ ├── middleware/ # Correlation IDs, etc.
|
||||
│ ├── utils/ # Circuit breaker, sanitizer, etc.
|
||||
│ └── validation/ # Zod schemas
|
||||
└── sse/ # SSE chat handlers
|
||||
|
||||
open-sse/ # @omniroute/open-sse workspace
|
||||
├── handlers/ # chatCore.js — main request handler
|
||||
├── services/ # Rate limit, fallback
|
||||
├── translators/ # Format converters (OpenAI ↔ Claude ↔ Gemini)
|
||||
└── utils/ # Progress tracker, stream helpers
|
||||
|
||||
tests/
|
||||
├── unit/ # Node.js test runner
|
||||
└── e2e/ # Playwright tests
|
||||
|
||||
docs/ # Documentation
|
||||
├── USER_GUIDE.md # Provider setup, CLI integration
|
||||
├── API_REFERENCE.md # All endpoints
|
||||
├── TROUBLESHOOTING.md # Common issues
|
||||
├── ARCHITECTURE.md # System architecture
|
||||
└── adr/ # Architecture Decision Records
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Provider
|
||||
|
||||
### Step 1: OAuth Service (if using OAuth)
|
||||
|
||||
Create `src/lib/oauth/services/your-provider.js` extending `OAuthService`:
|
||||
|
||||
```javascript
|
||||
import { OAuthService } from "../OAuthService.js";
|
||||
|
||||
export class YourProviderService extends OAuthService {
|
||||
constructor() {
|
||||
super({
|
||||
name: "your-provider",
|
||||
authUrl: "https://provider.com/oauth/authorize",
|
||||
tokenUrl: "https://provider.com/oauth/token",
|
||||
clientId: "...",
|
||||
scopes: ["..."],
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Register Provider
|
||||
|
||||
Add to `src/lib/oauth/providers.js`:
|
||||
|
||||
```javascript
|
||||
import { YourProviderService } from "./services/your-provider.js";
|
||||
// Add to the providers map
|
||||
```
|
||||
|
||||
### Step 3: Add Constants
|
||||
|
||||
Add provider constants in `src/lib/providerConstants.js`:
|
||||
|
||||
- Provider prefix (e.g., `yp/`)
|
||||
- Default models
|
||||
- Pricing info
|
||||
|
||||
### Step 4: Add Translator (if non-OpenAI format)
|
||||
|
||||
Create translator in `open-sse/translators/` if the provider uses a custom API format.
|
||||
|
||||
### Step 5: Add Timeout
|
||||
|
||||
Add request timeout configuration in `src/shared/utils/requestTimeout.js`.
|
||||
|
||||
### Step 6: Add Tests
|
||||
|
||||
Write unit tests in `tests/unit/` covering at minimum:
|
||||
|
||||
- Provider registration
|
||||
- Request/response translation
|
||||
- Error handling
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
- [ ] Tests pass (`npm test`)
|
||||
- [ ] Linting passes (`npm run lint`)
|
||||
- [ ] Build succeeds (`npm run build`)
|
||||
- [ ] JSDoc added for new public functions
|
||||
- [ ] No hardcoded secrets or fallback values
|
||||
- [ ] CHANGELOG updated (if user-facing change)
|
||||
- [ ] Documentation updated (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## Releasing
|
||||
|
||||
When a new GitHub Release is created (e.g. `v0.4.0`), the package is **automatically published to npm** via GitHub Actions:
|
||||
|
||||
```bash
|
||||
gh release create v0.4.0 --title "v0.4.0" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Architecture**: See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
|
||||
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
|
||||
- **ADRs**: See `docs/adr/` for architectural decision records
|
||||
+58
-13
@@ -1,21 +1,66 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability in OmniRoute, please report it responsibly:
|
||||
|
||||
1. **DO NOT** open a public GitHub issue
|
||||
2. Email: **security@omniroute.dev** (or use GitHub Security Advisories)
|
||||
3. Include: description, reproduction steps, and potential impact
|
||||
|
||||
## Response Timeline
|
||||
|
||||
| Stage | Target |
|
||||
| ------------------- | --------------------------- |
|
||||
| Acknowledgment | 48 hours |
|
||||
| Triage & Assessment | 5 business days |
|
||||
| Patch Release | 14 business days (critical) |
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
| Version | Support Status |
|
||||
| ------- | -------------- |
|
||||
| 0.4.x | ✅ Active |
|
||||
| 0.3.x | ✅ Active |
|
||||
| < 0.3.0 | ❌ Unsupported |
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 5.1.x | :white_check_mark: |
|
||||
| 5.0.x | :x: |
|
||||
| 4.0.x | :white_check_mark: |
|
||||
| < 4.0 | :x: |
|
||||
## Security Best Practices
|
||||
|
||||
## Reporting a Vulnerability
|
||||
### Required Environment Variables
|
||||
|
||||
Use this section to tell people how to report a vulnerability.
|
||||
All secrets must be set before starting the server. The server will **fail fast** if they are missing or weak.
|
||||
|
||||
Tell them where to go, how often they can expect to get an update on a
|
||||
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||
declined, etc.
|
||||
```bash
|
||||
# Generate strong secrets:
|
||||
JWT_SECRET=$(openssl rand -base64 48)
|
||||
API_KEY_SECRET=$(openssl rand -hex 32)
|
||||
STORAGE_ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
```
|
||||
|
||||
### Input Protection
|
||||
|
||||
OmniRoute includes built-in protection against:
|
||||
|
||||
- **Prompt injection** — Detects system override, role hijack, delimiter injection, and DAN/jailbreak patterns
|
||||
- **PII leakage** — Optional detection and redaction of emails, CPF/CNPJ, credit cards, and phone numbers
|
||||
|
||||
Configure in `.env`:
|
||||
|
||||
```env
|
||||
INPUT_SANITIZER_ENABLED=true
|
||||
INPUT_SANITIZER_MODE=block # warn | block | redact
|
||||
PII_REDACTION_ENABLED=true
|
||||
```
|
||||
|
||||
### Docker Security
|
||||
|
||||
- Use non-root user in production
|
||||
- Mount secrets as read-only volumes
|
||||
- Never copy `.env` files into Docker images
|
||||
- Use `.dockerignore` to exclude sensitive files
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Run `npm audit` regularly
|
||||
- Keep dependencies updated
|
||||
- The project uses `husky` + `lint-staged` for pre-commit checks
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Password Reset CLI — T-38
|
||||
*
|
||||
* Usage:
|
||||
* node bin/reset-password.mjs
|
||||
* npx omniroute reset-password
|
||||
*
|
||||
* Resets the admin password for OmniRoute.
|
||||
* Prompts for a new password and updates the database directly.
|
||||
*
|
||||
* @module bin/reset-password
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Resolve data directory — same logic as the server
|
||||
const DATA_DIR = process.env.DATA_DIR || resolve(__dirname, "..", "data");
|
||||
const DB_PATH = resolve(DATA_DIR, "settings.db");
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
function ask(question) {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
return createHash("sha256").update(password).digest("hex");
|
||||
}
|
||||
|
||||
console.log("\n🔑 OmniRoute — Password Reset\n");
|
||||
|
||||
async function main() {
|
||||
// Check if database exists
|
||||
if (!existsSync(DB_PATH)) {
|
||||
console.error(`❌ Database not found at: ${DB_PATH}`);
|
||||
console.error(` Make sure OmniRoute has been started at least once.`);
|
||||
console.error(` Or set DATA_DIR env var to your data directory.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
console.error("❌ better-sqlite3 not installed. Run: npm install");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Check current settings
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'password'").get();
|
||||
|
||||
if (row) {
|
||||
console.log("ℹ️ A password is currently set.");
|
||||
} else {
|
||||
console.log("ℹ️ No password is currently set.");
|
||||
}
|
||||
|
||||
const password = await ask("Enter new password (min 4 chars): ");
|
||||
|
||||
if (!password || password.length < 4) {
|
||||
console.error("\n❌ Password must be at least 4 characters.\n");
|
||||
db.close();
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const confirm = await ask("Confirm new password: ");
|
||||
|
||||
if (password !== confirm) {
|
||||
console.error("\n❌ Passwords do not match.\n");
|
||||
db.close();
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hashed = hashPassword(password);
|
||||
|
||||
// Upsert the password
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO settings (key, value) VALUES ('password', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`);
|
||||
stmt.run(hashed);
|
||||
|
||||
// Also ensure requireLogin is true
|
||||
const loginStmt = db.prepare(`
|
||||
INSERT INTO settings (key, value) VALUES ('requireLogin', 'true')
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`);
|
||||
loginStmt.run();
|
||||
|
||||
db.close();
|
||||
rl.close();
|
||||
|
||||
console.log("\n✅ Password reset successfully!");
|
||||
console.log(" Restart OmniRoute for changes to take effect.\n");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\n❌ Error: ${err.message}\n`);
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
});
|
||||
+54
-2
@@ -1,6 +1,6 @@
|
||||
# OmniRoute Architecture
|
||||
|
||||
_Last updated: 2026-02-14_
|
||||
_Last updated: 2026-02-15_
|
||||
|
||||
## Executive Summary
|
||||
|
||||
@@ -24,8 +24,19 @@ Core capabilities:
|
||||
- Thinking budget management (passthrough/auto/custom/adaptive)
|
||||
- Global system prompt injection
|
||||
- Session tracking and fingerprinting
|
||||
- Per-account enhanced rate limiting
|
||||
- Per-account enhanced rate limiting with provider-specific profiles
|
||||
- Circuit breaker pattern for provider resilience
|
||||
- Anti-thundering herd protection with mutex locking
|
||||
- Signature-based request deduplication cache
|
||||
- Domain layer: model availability, cost rules, fallback policy, lockout policy
|
||||
- Domain state persistence (SQLite write-through cache for fallbacks, budgets, lockouts, circuit breakers)
|
||||
- Policy engine for centralized request evaluation (lockout → budget → fallback)
|
||||
- Request telemetry with p50/p95/p99 latency aggregation
|
||||
- Correlation ID (X-Request-Id) for end-to-end tracing
|
||||
- Compliance audit logging with opt-out per API key
|
||||
- Eval framework for LLM quality assurance
|
||||
- Resilience UI dashboard with real-time circuit breaker status
|
||||
- Modular OAuth providers (12 individual modules under `src/lib/oauth/providers/`)
|
||||
|
||||
Primary runtime model:
|
||||
|
||||
@@ -140,6 +151,16 @@ Management domains:
|
||||
- System prompt: `src/app/api/settings/system-prompt` (GET/PUT)
|
||||
- Sessions: `src/app/api/sessions` (GET)
|
||||
- Rate limits: `src/app/api/rate-limits` (GET)
|
||||
- Resilience: `src/app/api/resilience` (GET/PATCH) — provider profiles, circuit breaker, rate limit state
|
||||
- Resilience reset: `src/app/api/resilience/reset` (POST) — reset breakers + cooldowns
|
||||
- Cache stats: `src/app/api/cache/stats` (GET/DELETE)
|
||||
- Model availability: `src/app/api/models/availability` (GET/POST)
|
||||
- Telemetry: `src/app/api/telemetry/summary` (GET)
|
||||
- Budget: `src/app/api/usage/budget` (GET/POST)
|
||||
- Fallback chains: `src/app/api/fallback/chains` (GET/POST/DELETE)
|
||||
- Compliance audit: `src/app/api/compliance/audit-log` (GET)
|
||||
- Evals: `src/app/api/evals` (GET/POST), `src/app/api/evals/[suiteId]` (GET)
|
||||
- Policies: `src/app/api/policies` (GET/POST)
|
||||
|
||||
## 2) SSE + Translation Core
|
||||
|
||||
@@ -170,6 +191,30 @@ Services (business logic):
|
||||
- System prompt injection: `open-sse/services/systemPrompt.js`
|
||||
- Thinking budget management: `open-sse/services/thinkingBudget.js`
|
||||
- Wildcard model routing: `open-sse/services/wildcardRouter.js`
|
||||
- Rate limit management: `open-sse/services/rateLimitManager.js`
|
||||
- Circuit breaker: `open-sse/services/circuitBreaker.js`
|
||||
|
||||
Domain layer modules:
|
||||
|
||||
- Model availability: `src/lib/domain/modelAvailability.js`
|
||||
- Cost rules/budgets: `src/lib/domain/costRules.js`
|
||||
- Fallback policy: `src/lib/domain/fallbackPolicy.js`
|
||||
- Combo resolver: `src/lib/domain/comboResolver.js`
|
||||
- Lockout policy: `src/lib/domain/lockoutPolicy.js`
|
||||
- Policy engine: `src/domain/policyEngine.js` — centralized lockout → budget → fallback evaluation
|
||||
- Error codes catalog: `src/lib/domain/errorCodes.js`
|
||||
- Request ID: `src/lib/domain/requestId.js`
|
||||
- Fetch timeout: `src/lib/domain/fetchTimeout.js`
|
||||
- Request telemetry: `src/lib/domain/requestTelemetry.js`
|
||||
- Compliance/audit: `src/lib/domain/compliance/index.js`
|
||||
- Eval runner: `src/lib/domain/evalRunner.js`
|
||||
- Domain state persistence: `src/lib/db/domainState.js` — SQLite CRUD for fallback chains, budgets, cost history, lockout state, circuit breakers
|
||||
|
||||
OAuth provider modules (12 individual files under `src/lib/oauth/providers/`):
|
||||
|
||||
- Registry index: `src/lib/oauth/providers/index.js`
|
||||
- Individual providers: `claude.js`, `codex.js`, `gemini.js`, `antigravity.js`, `iflow.js`, `qwen.js`, `kimi-coding.js`, `github.js`, `kiro.js`, `cursor.js`, `kilocode.js`, `cline.js`
|
||||
- Thin wrapper: `src/lib/oauth/providers.js` — re-exports from individual modules
|
||||
|
||||
## 3) Persistence Layer
|
||||
|
||||
@@ -184,6 +229,13 @@ Usage DB:
|
||||
- `src/lib/usageDb.js`
|
||||
- files: `${DATA_DIR}/usage.json`, `${DATA_DIR}/log.txt`, `${DATA_DIR}/call_logs/`
|
||||
- follows same base directory policy as `localDb` (`DATA_DIR`, then `XDG_CONFIG_HOME/omniroute` when set)
|
||||
- decomposed into focused sub-modules: `migrations.js`, `usageHistory.js`, `costCalculator.js`, `usageStats.js`, `callLogs.js`
|
||||
|
||||
Domain State DB (SQLite):
|
||||
|
||||
- `src/lib/db/domainState.js` — CRUD operations for domain state
|
||||
- Tables (created in `src/lib/db/core.js`): `domain_fallback_chains`, `domain_budgets`, `domain_cost_history`, `domain_lockout_state`, `domain_circuit_breakers`
|
||||
- Write-through cache pattern: in-memory Maps are authoritative at runtime; mutations are written synchronously to SQLite; state is restored from DB on cold start
|
||||
|
||||
## 4) Auth + Security Surfaces
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Rate Limiting & Flow Control Overhaul — Tasks
|
||||
|
||||
> Referência: [Relatório de Análise](../walkthrough.md) · Fase docs em `/docs/phases/`
|
||||
|
||||
---
|
||||
|
||||
## Fase 1 — Error Classification & Provider Profiles
|
||||
|
||||
### 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)`
|
||||
|
||||
### Callers (propagar `provider`)
|
||||
|
||||
- [ ] `auth.js` → `markAccountUnavailable` — Passar `provider` para `checkFallbackError`
|
||||
- [ ] `combo.js` → `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`
|
||||
|
||||
---
|
||||
|
||||
## Fase 2 — Circuit Breaker no Combo Pipeline
|
||||
|
||||
### 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
|
||||
|
||||
### Testes
|
||||
|
||||
- [ ] Criar `combo-circuit-breaker.test.mjs` — Combo skip breaker OPEN, early exit, semáforo 502
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Anti-Thundering Herd & Auto Rate Limit
|
||||
|
||||
### 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
|
||||
|
||||
### Testes
|
||||
|
||||
- [ ] Criar `thundering-herd.test.mjs` — Mutex, auto-enable, limites não restritivos
|
||||
|
||||
---
|
||||
|
||||
## Fase 4 — Frontend Resilience UI
|
||||
|
||||
### Settings Page
|
||||
|
||||
- [ ] `settings/page.js` — Adicionar tab "Resilience" (icon: `health_and_safety`) entre Routing e Pricing
|
||||
|
||||
### Novos Componentes
|
||||
|
||||
- [ ] Criar `ResilienceTab.js` — Layout com 3 cards
|
||||
- [ ] Criar `ProviderProfilesCard.js` — Toggle OAuth/API Key, inputs para cooldowns
|
||||
- [ ] Criar `CircuitBreakerCard.js` — Status real-time per-provider, auto-refresh 5s, botão reset
|
||||
- [ ] Criar `RateLimitOverviewCard.js` — Tabela providers × accounts × cooldown
|
||||
|
||||
### API Routes
|
||||
|
||||
- [ ] Criar `api/resilience/route.js` — GET (estado completo) + PATCH (salvar perfis)
|
||||
- [ ] Criar `api/resilience/reset/route.js` — POST (resetar breakers + cooldowns)
|
||||
|
||||
### Migração
|
||||
|
||||
- [ ] Avaliar se `PoliciesPanel.js` pode ser removido ou simplificado após nova aba
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,31 @@
|
||||
# Architecture Decision Record Template
|
||||
|
||||
## ADR-XXX: [Title]
|
||||
|
||||
**Date:** YYYY-MM-DD
|
||||
**Status:** Accepted | Superseded | Deprecated
|
||||
**Deciders:** @team
|
||||
|
||||
## Context
|
||||
|
||||
What is the issue we're seeing that motivates this decision?
|
||||
|
||||
## Decision
|
||||
|
||||
What is the change that we're proposing and/or doing?
|
||||
|
||||
## Consequences
|
||||
|
||||
What becomes easier or more difficult because of this change?
|
||||
|
||||
### Positive
|
||||
|
||||
- ...
|
||||
|
||||
### Negative
|
||||
|
||||
- ...
|
||||
|
||||
### Neutral
|
||||
|
||||
- ...
|
||||
@@ -0,0 +1,44 @@
|
||||
# ADR-001: SQLite as Primary Data Store
|
||||
|
||||
**Date:** 2025-10-15
|
||||
**Status:** Accepted
|
||||
**Deciders:** @diegosouzapw
|
||||
|
||||
## Context
|
||||
|
||||
OmniRoute needs to persist usage data, call logs, API keys, and configuration. Options considered:
|
||||
|
||||
- PostgreSQL/MySQL — full RDBMS
|
||||
- SQLite — embedded, zero-config
|
||||
- JSON files (LowDB) — simple but fragile
|
||||
- Redis — in-memory, ephemeral
|
||||
|
||||
The project targets self-hosted, single-tenant deployments where operational simplicity is paramount.
|
||||
|
||||
## Decision
|
||||
|
||||
Use **SQLite** via `better-sqlite3` as the primary data store.
|
||||
|
||||
- All usage tracking, call logs, API keys, and settings stored in a single `.db` file
|
||||
- Synchronous reads (no async overhead for simple queries)
|
||||
- WAL mode for concurrent read/write performance
|
||||
- Automatic migration from legacy JSON format (`usageDb.json`) on first boot
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Zero infrastructure — no database server needed
|
||||
- Single-file backup (`cp data/omniroute.db backup/`)
|
||||
- Fast queries for dashboard stats (< 5ms typical)
|
||||
- Easy migration path from JSON format
|
||||
|
||||
### Negative
|
||||
|
||||
- Single-writer limitation (acceptable for single-tenant)
|
||||
- No built-in replication
|
||||
- Would need migration to PostgreSQL for multi-tenant cloud deployment
|
||||
|
||||
### Neutral
|
||||
|
||||
- File-based storage works well in Docker volumes
|
||||
@@ -0,0 +1,36 @@
|
||||
# ADR-002: Multi-Provider Fallback Strategy
|
||||
|
||||
**Date:** 2025-11-20
|
||||
**Status:** Accepted
|
||||
**Deciders:** @diegosouzapw
|
||||
|
||||
## Context
|
||||
|
||||
OmniRoute routes requests to multiple LLM providers (OpenAI, Anthropic, Google, etc.). Providers may become unavailable due to rate limiting, outages, or credential expiry. The system needs a strategy to handle these failures gracefully.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a **declarative fallback chain** with three layers:
|
||||
|
||||
1. **Credential Retry Loop** — Rotate through available credentials for the same provider before failing
|
||||
2. **Model Fallback Policy** — Configurable fallback chain per model (e.g., `gpt-4o → azure-gpt-4o → anthropic-claude`)
|
||||
3. **Circuit Breaker** — Trip open after consecutive failures to prevent cascading requests to broken providers
|
||||
|
||||
The fallback policy is defined in `src/domain/fallbackPolicy.js` and integrates with the circuit breaker in `src/shared/utils/circuitBreaker.js`.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Automatic failover with zero user intervention
|
||||
- Per-model granularity — different models can have different fallback strategies
|
||||
- Circuit breaker prevents wasting quota on broken providers
|
||||
|
||||
### Negative
|
||||
|
||||
- Fallback chain requires manual configuration per model
|
||||
- Response latency increases when primary fails (retry + fallback time)
|
||||
|
||||
### Neutral
|
||||
|
||||
- Lockout policy (n consecutive failures → temporary block) complements but is separate from fallback
|
||||
@@ -0,0 +1,47 @@
|
||||
# ADR-003: OAuth Strategy — Multi-Flow Support
|
||||
|
||||
**Date:** 2025-11-01
|
||||
**Status:** Accepted
|
||||
**Deciders:** @diegosouzapw
|
||||
|
||||
## Context
|
||||
|
||||
OmniRoute supports 12+ providers, each with different OAuth implementations:
|
||||
|
||||
- Authorization Code + PKCE (Claude, Codex, Gemini, Antigravity, iFlow)
|
||||
- Device Code Flow (Qwen, GitHub, Kiro, Kilocode, Kimi-Coding, Cline)
|
||||
- Token Import (Cursor — extracted from local SQLite)
|
||||
|
||||
A unified approach is needed to manage authentication across all providers.
|
||||
|
||||
## Decision
|
||||
|
||||
Use a **base class + strategy pattern**:
|
||||
|
||||
1. `OAuthService` base class (`src/lib/oauth/services/oauth.js`) — handles common authorization code flow with PKCE
|
||||
2. Provider-specific subclasses (e.g., `GitHubService`, `ClaudeService`) — override authentication methods
|
||||
3. Provider registry (`src/lib/oauth/providers.js`) — declarative config per provider with `flowType`, `buildAuthUrl`, `exchangeToken`, `mapTokens`
|
||||
4. Constants centralized in `src/lib/oauth/constants/oauth.js`
|
||||
|
||||
Each provider defines:
|
||||
|
||||
- `flowType`: `authorization_code_pkce` | `authorization_code` | `device_code` | `import_token`
|
||||
- Required hooks: `buildAuthUrl()`, `exchangeToken()`, `mapTokens()`
|
||||
- Optional hooks: `postExchange()` for provider-specific post-auth logic
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Adding new providers requires only a config entry + optional subclass
|
||||
- PKCE, state validation, and token exchange are shared (DRY)
|
||||
- Device code flow providers share polling logic
|
||||
|
||||
### Negative
|
||||
|
||||
- Some providers have unique quirks (Kiro uses AWS SSO OIDC with client registration)
|
||||
- Testing requires mocking external OAuth endpoints
|
||||
|
||||
### Neutral
|
||||
|
||||
- ~1050 lines in `providers.js` — could be further split per provider if needed
|
||||
@@ -0,0 +1,43 @@
|
||||
# ADR-004: JavaScript + JSDoc over TypeScript
|
||||
|
||||
**Date:** 2025-10-01
|
||||
**Status:** Accepted
|
||||
**Deciders:** @diegosouzapw
|
||||
|
||||
## Context
|
||||
|
||||
The project needs type safety and developer experience improvements. Options:
|
||||
|
||||
1. **Full TypeScript migration** — `.ts` files, `tsconfig.json`, build step
|
||||
2. **JavaScript + JSDoc + @ts-check** — type checking without compilation
|
||||
3. **No type checking** — status quo
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt **JavaScript with JSDoc annotations and `@ts-check`** instead of migrating to TypeScript.
|
||||
|
||||
- Add `// @ts-check` to critical module files
|
||||
- Use JSDoc `@param`, `@returns`, `@typedef` for type documentation
|
||||
- TypeScript compiler used only for checking (via IDE), not for building
|
||||
- Zod schemas for runtime validation at API boundaries
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- No build step — `node src/proxy.js` runs directly
|
||||
- Faster development iteration (no compile wait)
|
||||
- Gradual adoption — files can be annotated one at a time
|
||||
- IDE still provides autocomplete and type errors via JSDoc
|
||||
- Lower barrier for contributors
|
||||
|
||||
### Negative
|
||||
|
||||
- JSDoc type syntax is more verbose than TypeScript
|
||||
- Some advanced TypeScript features (generics, conditional types) are harder in JSDoc
|
||||
- No `.d.ts` generation for consumers
|
||||
|
||||
### Neutral
|
||||
|
||||
- Existing Zod schemas provide runtime validation regardless of type system choice
|
||||
- `@ts-check` can be added to any file without affecting others
|
||||
@@ -0,0 +1,39 @@
|
||||
# ADR-005: Single-Tenant Architecture
|
||||
|
||||
**Date:** 2025-10-01
|
||||
**Status:** Accepted
|
||||
**Deciders:** @diegosouzapw
|
||||
|
||||
## Context
|
||||
|
||||
OmniRoute needs to decide between single-tenant and multi-tenant architecture. The primary use case is individuals and small teams running their own proxy instance.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt a **single-tenant architecture** where each deployment serves one user/team.
|
||||
|
||||
- One SQLite database per instance
|
||||
- One set of API keys and credentials per instance
|
||||
- Password-based login (single admin user)
|
||||
- No user management, roles, or permissions beyond admin
|
||||
- Settings stored in a single `settings` table
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Dramatically simpler codebase (no tenant isolation, RBAC, or data partitioning)
|
||||
- SQLite is perfectly suited (no concurrent multi-tenant writes)
|
||||
- Easy deployment: one Docker container = one instance
|
||||
- Complete data isolation between users (separate deployments)
|
||||
|
||||
### Negative
|
||||
|
||||
- Not suitable for SaaS or shared hosting without running multiple instances
|
||||
- No built-in multi-user collaboration features
|
||||
- Scaling requires deploying separate instances
|
||||
|
||||
### Neutral
|
||||
|
||||
- Cloud worker mode exists as a separate deployment target with different constraints
|
||||
- Future multi-tenant support would require a PostgreSQL migration (see ADR-001)
|
||||
@@ -0,0 +1,48 @@
|
||||
# ADR-006: Translator Registry Pattern
|
||||
|
||||
**Date:** 2025-12-01
|
||||
**Status:** Accepted
|
||||
**Deciders:** @diegosouzapw
|
||||
|
||||
## Context
|
||||
|
||||
OmniRoute translates requests between different LLM API formats (OpenAI ↔ Anthropic ↔ Google ↔ etc.). Each provider has a unique request/response schema. The translator must:
|
||||
|
||||
- Convert incoming requests to the target provider's format
|
||||
- Convert streaming responses back to the client's expected format
|
||||
- Handle provider-specific features (tool calls, vision, system prompts)
|
||||
|
||||
## Decision
|
||||
|
||||
Use a **registry pattern** for translators:
|
||||
|
||||
1. Each provider pair has a translator module in `src/sse/translators/`
|
||||
2. Translators are registered by `(sourceFormat, targetFormat)` key
|
||||
3. The `translateRequest()` function auto-detects source format and applies the appropriate translator
|
||||
4. Translators handle both request translation and response stream mapping
|
||||
|
||||
Key translators:
|
||||
|
||||
- `openai → anthropic` (and reverse)
|
||||
- `openai → google` (and reverse)
|
||||
- `anthropic → google` (and reverse)
|
||||
- Identity translators for same-format routing
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Adding a new provider requires only a new translator module
|
||||
- Each translator is independently testable
|
||||
- Auto-detection reduces configuration burden on users
|
||||
- Supports chained translation (A → B → C) if needed
|
||||
|
||||
### Negative
|
||||
|
||||
- O(n²) translator combinations as providers grow (mitigated by identity translators)
|
||||
- Some edge cases in format conversion (e.g., tool call schemas differ significantly)
|
||||
|
||||
### Neutral
|
||||
|
||||
- The Translator Playground UI provides visual testing of translation chains
|
||||
- Performance overhead is minimal (JSON transformation, no network calls)
|
||||
+979
-5
File diff suppressed because it is too large
Load Diff
+24
-11
@@ -1,16 +1,29 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
const eslintConfig = [
|
||||
...nextVitals,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
// FASE-02: Security rules
|
||||
{
|
||||
rules: {
|
||||
"no-eval": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-new-func": "error",
|
||||
},
|
||||
},
|
||||
// Global ignores
|
||||
{
|
||||
ignores: [
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"tests/**",
|
||||
"scripts/**",
|
||||
"bin/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -120,20 +120,22 @@ export const BACKOFF_STEPS_MS = [60_000, 120_000, 300_000, 600_000, 1_200_000];
|
||||
|
||||
// Structured error classification for rate limiting decisions
|
||||
export const RateLimitReason = {
|
||||
QUOTA_EXHAUSTED: 'quota_exhausted', // Daily/monthly quota depleted
|
||||
RATE_LIMIT_EXCEEDED: 'rate_limit_exceeded', // RPM/RPD limits hit
|
||||
MODEL_CAPACITY: 'model_capacity', // Model overloaded (529, 503)
|
||||
SERVER_ERROR: 'server_error', // 5xx errors
|
||||
AUTH_ERROR: 'auth_error', // 401, 403
|
||||
UNKNOWN: 'unknown',
|
||||
QUOTA_EXHAUSTED: "quota_exhausted", // Daily/monthly quota depleted
|
||||
RATE_LIMIT_EXCEEDED: "rate_limit_exceeded", // RPM/RPD limits hit
|
||||
MODEL_CAPACITY: "model_capacity", // Model overloaded (529, 503)
|
||||
SERVER_ERROR: "server_error", // 5xx errors
|
||||
AUTH_ERROR: "auth_error", // 401, 403
|
||||
UNKNOWN: "unknown",
|
||||
};
|
||||
|
||||
// Error-based cooldown times (aligned with CLIProxyAPI)
|
||||
export const COOLDOWN_MS = {
|
||||
unauthorized: 2 * 60 * 1000, // 401 → 30 min
|
||||
paymentRequired: 2 * 60 * 1000, // 402/403 → 30 min
|
||||
unauthorized: 2 * 60 * 1000, // 401 → 2 min
|
||||
paymentRequired: 2 * 60 * 1000, // 402/403 → 2 min
|
||||
notFound: 2 * 60 * 1000, // 404 → 2 minutes
|
||||
transient: 30 * 1000, // 408/500/502/503/504 → 1 min
|
||||
transientInitial: 5 * 1000, // 408/500/502/503/504 first hit → 5s (backoff from here)
|
||||
transientMax: 60 * 1000, // 502/503/504 backoff ceiling → 60s
|
||||
transient: 5 * 1000, // Legacy alias → points to transientInitial
|
||||
requestNotAllowed: 5 * 1000, // "Request not allowed" → 5 sec
|
||||
// Legacy aliases for backward compatibility
|
||||
rateLimit: 2 * 60 * 1000,
|
||||
@@ -141,5 +143,33 @@ export const COOLDOWN_MS = {
|
||||
authExpired: 2 * 60 * 1000,
|
||||
};
|
||||
|
||||
// ─── Provider Resilience Profiles ───────────────────────────────────────────
|
||||
// Separate behavior for OAuth (low-limit, session-based) vs API Key (high-limit, metered)
|
||||
export const PROVIDER_PROFILES = {
|
||||
oauth: {
|
||||
transientCooldown: 5000, // 5s (session tokens — short recovery)
|
||||
rateLimitCooldown: 60000, // 60s default when no retry-after header
|
||||
maxBackoffLevel: 8, // Higher ceiling (sessions may stay bad longer)
|
||||
circuitBreakerThreshold: 3, // Opens fast (low limit providers)
|
||||
circuitBreakerReset: 60000, // 1min reset
|
||||
},
|
||||
apikey: {
|
||||
transientCooldown: 3000, // 3s (API providers recover faster)
|
||||
rateLimitCooldown: 0, // 0 = respect retry-after header from provider
|
||||
maxBackoffLevel: 5, // Lower ceiling (API quotas reset at known intervals)
|
||||
circuitBreakerThreshold: 5, // More tolerant (occasional 502 is normal)
|
||||
circuitBreakerReset: 30000, // 30s reset
|
||||
},
|
||||
};
|
||||
|
||||
// Default rate limit values for API Key providers (auto-enabled safety net)
|
||||
// These are intentionally HIGH — they won't restrict normal usage.
|
||||
// Real limits are learned from provider response headers.
|
||||
export const DEFAULT_API_LIMITS = {
|
||||
requestsPerMinute: 100, // 100 RPM (most APIs allow 60-600 RPM)
|
||||
minTimeBetweenRequests: 200, // 200ms minimum gap
|
||||
concurrentRequests: 10, // Max 10 parallel per provider
|
||||
};
|
||||
|
||||
// Skip patterns - requests containing these texts will bypass provider
|
||||
export const SKIP_PATTERNS = ["Please write a 5-10 word title for the following conversation:"];
|
||||
|
||||
@@ -849,3 +849,15 @@ export function getRegistryEntry(provider) {
|
||||
export function getRegisteredProviders() {
|
||||
return Object.keys(REGISTRY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider category: "oauth" or "apikey"
|
||||
* Used by the resilience layer to apply different cooldown/backoff profiles.
|
||||
* @param {string} provider - Provider ID or alias
|
||||
* @returns {"oauth"|"apikey"}
|
||||
*/
|
||||
export function getProviderCategory(provider) {
|
||||
const entry = getRegistryEntry(provider);
|
||||
if (!entry) return "apikey"; // Safe default for unknown providers
|
||||
return entry.authType === "apikey" ? "apikey" : "oauth";
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ import {
|
||||
updateFromHeaders,
|
||||
initializeRateLimits,
|
||||
} from "../services/rateLimitManager.js";
|
||||
import {
|
||||
generateSignature,
|
||||
getCachedResponse,
|
||||
setCachedResponse,
|
||||
isCacheable,
|
||||
} from "@/lib/semanticCache.js";
|
||||
import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idempotencyLayer.js";
|
||||
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.js";
|
||||
|
||||
/**
|
||||
* Core chat handler - shared between SSE and Worker
|
||||
@@ -61,6 +69,24 @@ export async function handleChatCore({
|
||||
const { provider, model } = modelInfo;
|
||||
const startTime = Date.now();
|
||||
|
||||
// ── Phase 9.2: Idempotency check ──
|
||||
const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers);
|
||||
const cachedIdemp = checkIdempotency(idempotencyKey);
|
||||
if (cachedIdemp) {
|
||||
log?.debug?.("IDEMPOTENCY", `Hit for key=${idempotencyKey?.slice(0, 12)}...`);
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(cachedIdemp.response), {
|
||||
status: cachedIdemp.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"X-OmniRoute-Idempotent": "true",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize rate limit settings from persisted DB (once, lazy)
|
||||
await initializeRateLimits();
|
||||
|
||||
@@ -84,6 +110,25 @@ export async function handleChatCore({
|
||||
// Default to streaming unless client explicitly sets stream: false
|
||||
const stream = body.stream !== false;
|
||||
|
||||
// ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ──
|
||||
if (isCacheable(body, clientRawRequest?.headers)) {
|
||||
const signature = generateSignature(model, body.messages, body.temperature, body.top_p);
|
||||
const cached = getCachedResponse(signature);
|
||||
if (cached) {
|
||||
log?.debug?.("CACHE", `Semantic cache HIT for ${model}`);
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(cached), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"X-OmniRoute-Cache": "HIT",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create request logger for this session: sourceFormat_targetFormat_model
|
||||
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model);
|
||||
|
||||
@@ -444,12 +489,24 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 9.1: Cache store (non-streaming, temp=0) ──
|
||||
if (isCacheable(body, clientRawRequest?.headers)) {
|
||||
const signature = generateSignature(model, body.messages, body.temperature, body.top_p);
|
||||
const tokensSaved = usage?.prompt_tokens + usage?.completion_tokens || 0;
|
||||
setCachedResponse(signature, model, translatedResponse, tokensSaved);
|
||||
log?.debug?.("CACHE", `Stored response for ${model} (${tokensSaved} tokens)`);
|
||||
}
|
||||
|
||||
// ── Phase 9.2: Save for idempotency ──
|
||||
saveIdempotency(idempotencyKey, translatedResponse, 200);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(translatedResponse), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"X-OmniRoute-Cache": "MISS",
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -546,12 +603,22 @@ export async function handleChatCore({
|
||||
);
|
||||
}
|
||||
|
||||
// Pipe response through transform with disconnect detection
|
||||
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController);
|
||||
// ── Phase 9.3: Progress tracking (opt-in) ──
|
||||
const progressEnabled = wantsProgress(clientRawRequest?.headers);
|
||||
let finalStream;
|
||||
if (progressEnabled) {
|
||||
const progressTransform = createProgressTransform({ signal: streamController.signal });
|
||||
// Chain: provider → transform → progress → client
|
||||
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController);
|
||||
finalStream = transformedBody.pipeThrough(progressTransform);
|
||||
responseHeaders["X-OmniRoute-Progress"] = "enabled";
|
||||
} else {
|
||||
finalStream = pipeWithDisconnect(providerResponse, transformStream, streamController);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(transformedBody, {
|
||||
response: new Response(finalStream, {
|
||||
headers: responseHeaders,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
import { COOLDOWN_MS, BACKOFF_CONFIG, BACKOFF_STEPS_MS, RateLimitReason, HTTP_STATUS } from "../config/constants.js";
|
||||
import {
|
||||
COOLDOWN_MS,
|
||||
BACKOFF_CONFIG,
|
||||
BACKOFF_STEPS_MS,
|
||||
RateLimitReason,
|
||||
HTTP_STATUS,
|
||||
PROVIDER_PROFILES,
|
||||
} from "../config/constants.js";
|
||||
import { getProviderCategory } from "../config/providerRegistry.js";
|
||||
|
||||
// ─── Provider Profile Helper ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the resilience profile for a provider (oauth or apikey).
|
||||
* @param {string} provider - Provider ID or alias
|
||||
* @returns {import('../config/constants.js').PROVIDER_PROFILES['oauth']}
|
||||
*/
|
||||
export function getProviderProfile(provider) {
|
||||
const category = getProviderCategory(provider);
|
||||
return PROVIDER_PROFILES[category] ?? PROVIDER_PROFILES.apikey;
|
||||
}
|
||||
|
||||
// ─── Per-Model Lockout Tracking ─────────────────────────────────────────────
|
||||
// In-memory map: "provider:connectionId:model" → { reason, until, lockedAt }
|
||||
@@ -71,7 +91,13 @@ export function getAllModelLockouts() {
|
||||
for (const [key, entry] of modelLockouts) {
|
||||
if (now <= entry.until) {
|
||||
const [provider, connectionId, model] = key.split(":");
|
||||
active.push({ provider, connectionId, model, reason: entry.reason, remainingMs: entry.until - now });
|
||||
active.push({
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
reason: entry.reason,
|
||||
remainingMs: entry.until - now,
|
||||
});
|
||||
}
|
||||
}
|
||||
return active;
|
||||
@@ -100,7 +126,10 @@ export function parseRetryAfterFromBody(responseBody) {
|
||||
const details = body.error?.details || body.details || [];
|
||||
for (const detail of Array.isArray(details) ? details : []) {
|
||||
if (detail.retryDelay) {
|
||||
return { retryAfterMs: parseDelayString(detail.retryDelay), reason: RateLimitReason.RATE_LIMIT_EXCEEDED };
|
||||
return {
|
||||
retryAfterMs: parseDelayString(detail.retryDelay),
|
||||
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +137,10 @@ export function parseRetryAfterFromBody(responseBody) {
|
||||
const msg = body.error?.message || body.message || "";
|
||||
const retryMatch = msg.match(/retry\s+after\s+(\d+)\s*s/i);
|
||||
if (retryMatch) {
|
||||
return { retryAfterMs: parseInt(retryMatch[1], 10) * 1000, reason: RateLimitReason.RATE_LIMIT_EXCEEDED };
|
||||
return {
|
||||
retryAfterMs: parseInt(retryMatch[1], 10) * 1000,
|
||||
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
|
||||
};
|
||||
}
|
||||
|
||||
// Anthropic: error type classification
|
||||
@@ -150,16 +182,32 @@ export function classifyErrorText(errorText) {
|
||||
if (!errorText) return RateLimitReason.UNKNOWN;
|
||||
const lower = String(errorText).toLowerCase();
|
||||
|
||||
if (lower.includes("quota exceeded") || lower.includes("quota depleted") || lower.includes("billing")) {
|
||||
if (
|
||||
lower.includes("quota exceeded") ||
|
||||
lower.includes("quota depleted") ||
|
||||
lower.includes("billing")
|
||||
) {
|
||||
return RateLimitReason.QUOTA_EXHAUSTED;
|
||||
}
|
||||
if (lower.includes("rate limit") || lower.includes("too many requests") || lower.includes("rate_limit")) {
|
||||
if (
|
||||
lower.includes("rate limit") ||
|
||||
lower.includes("too many requests") ||
|
||||
lower.includes("rate_limit")
|
||||
) {
|
||||
return RateLimitReason.RATE_LIMIT_EXCEEDED;
|
||||
}
|
||||
if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("resource exhausted")) {
|
||||
if (
|
||||
lower.includes("capacity") ||
|
||||
lower.includes("overloaded") ||
|
||||
lower.includes("resource exhausted")
|
||||
) {
|
||||
return RateLimitReason.MODEL_CAPACITY;
|
||||
}
|
||||
if (lower.includes("unauthorized") || lower.includes("invalid api key") || lower.includes("authentication")) {
|
||||
if (
|
||||
lower.includes("unauthorized") ||
|
||||
lower.includes("invalid api key") ||
|
||||
lower.includes("authentication")
|
||||
) {
|
||||
return RateLimitReason.AUTH_ERROR;
|
||||
}
|
||||
if (lower.includes("server error") || lower.includes("internal error")) {
|
||||
@@ -226,20 +274,35 @@ export function getQuotaCooldown(backoffLevel = 0) {
|
||||
* @param {string} errorText - Error message text
|
||||
* @param {number} backoffLevel - Current backoff level for exponential backoff
|
||||
* @param {string} [model] - Optional model name for model-level lockout
|
||||
* @param {string} [provider] - Provider ID for profile-aware cooldowns
|
||||
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number, reason?: string }}
|
||||
*/
|
||||
export function checkFallbackError(status, errorText, backoffLevel = 0, model = null) {
|
||||
export function checkFallbackError(
|
||||
status,
|
||||
errorText,
|
||||
backoffLevel = 0,
|
||||
model = null,
|
||||
provider = null
|
||||
) {
|
||||
// Check error message FIRST - specific patterns take priority over status codes
|
||||
if (errorText) {
|
||||
const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText);
|
||||
const lowerError = errorStr.toLowerCase();
|
||||
|
||||
if (lowerError.includes("no credentials")) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound, reason: RateLimitReason.AUTH_ERROR };
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: COOLDOWN_MS.notFound,
|
||||
reason: RateLimitReason.AUTH_ERROR,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerError.includes("request not allowed")) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.requestNotAllowed, reason: RateLimitReason.RATE_LIMIT_EXCEEDED };
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: COOLDOWN_MS.requestNotAllowed,
|
||||
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
|
||||
};
|
||||
}
|
||||
|
||||
// Rate limit keywords - exponential backoff
|
||||
@@ -262,15 +325,27 @@ export function checkFallbackError(status, errorText, backoffLevel = 0, model =
|
||||
}
|
||||
|
||||
if (status === HTTP_STATUS.UNAUTHORIZED) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.unauthorized, reason: RateLimitReason.AUTH_ERROR };
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: COOLDOWN_MS.unauthorized,
|
||||
reason: RateLimitReason.AUTH_ERROR,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.paymentRequired, reason: RateLimitReason.QUOTA_EXHAUSTED };
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: COOLDOWN_MS.paymentRequired,
|
||||
reason: RateLimitReason.QUOTA_EXHAUSTED,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === HTTP_STATUS.NOT_FOUND) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound, reason: RateLimitReason.UNKNOWN };
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: COOLDOWN_MS.notFound,
|
||||
reason: RateLimitReason.UNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
// 429 - Rate limit with exponential backoff
|
||||
@@ -284,7 +359,7 @@ export function checkFallbackError(status, errorText, backoffLevel = 0, model =
|
||||
};
|
||||
}
|
||||
|
||||
// Transient / server errors — DON'T increase backoff level
|
||||
// Transient / server errors — exponential backoff with provider profile
|
||||
const transientStatuses = [
|
||||
HTTP_STATUS.NOT_ACCEPTABLE,
|
||||
HTTP_STATUS.REQUEST_TIMEOUT,
|
||||
@@ -294,7 +369,17 @@ export function checkFallbackError(status, errorText, backoffLevel = 0, model =
|
||||
HTTP_STATUS.GATEWAY_TIMEOUT,
|
||||
];
|
||||
if (transientStatuses.includes(status)) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.transient, reason: RateLimitReason.SERVER_ERROR };
|
||||
const profile = provider ? getProviderProfile(provider) : null;
|
||||
const baseCooldown = profile?.transientCooldown ?? COOLDOWN_MS.transientInitial;
|
||||
const maxLevel = profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel;
|
||||
const cooldownMs = Math.min(baseCooldown * Math.pow(2, backoffLevel), COOLDOWN_MS.transientMax);
|
||||
const newLevel = Math.min(backoffLevel + 1, maxLevel);
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs,
|
||||
newBackoffLevel: newLevel,
|
||||
reason: RateLimitReason.SERVER_ERROR,
|
||||
};
|
||||
}
|
||||
|
||||
// 400 Bad Request - don't fallback (same request will fail on all accounts)
|
||||
@@ -303,7 +388,11 @@ export function checkFallbackError(status, errorText, backoffLevel = 0, model =
|
||||
}
|
||||
|
||||
// All other errors - fallback with transient cooldown
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.transient, reason: RateLimitReason.UNKNOWN };
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: COOLDOWN_MS.transient,
|
||||
reason: RateLimitReason.UNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Account State Management ───────────────────────────────────────────────
|
||||
@@ -389,11 +478,17 @@ export function resetAccountState(account) {
|
||||
/**
|
||||
* Apply error state to account
|
||||
*/
|
||||
export function applyErrorState(account, status, errorText) {
|
||||
export function applyErrorState(account, status, errorText, provider = null) {
|
||||
if (!account) return account;
|
||||
|
||||
const backoffLevel = account.backoffLevel || 0;
|
||||
const { cooldownMs, newBackoffLevel, reason } = checkFallbackError(status, errorText, backoffLevel);
|
||||
const { cooldownMs, newBackoffLevel, reason } = checkFallbackError(
|
||||
status,
|
||||
errorText,
|
||||
backoffLevel,
|
||||
null,
|
||||
provider
|
||||
);
|
||||
|
||||
return {
|
||||
...account,
|
||||
|
||||
+164
-10
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* Shared combo (model combo) handling with fallback support
|
||||
* Supports: priority (sequential), weighted (probabilistic), and round-robin (circular) strategies
|
||||
* Supports: priority, weighted, round-robin, random, least-used, and cost-optimized strategies
|
||||
*/
|
||||
|
||||
import { checkFallbackError, formatRetryAfter } from "./accountFallback.js";
|
||||
import { checkFallbackError, formatRetryAfter, getProviderProfile } from "./accountFallback.js";
|
||||
import { unavailableResponse } from "../utils/error.js";
|
||||
import { recordComboRequest } from "./comboMetrics.js";
|
||||
import { recordComboRequest, getComboMetrics } from "./comboMetrics.js";
|
||||
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.js";
|
||||
import * as semaphore from "./rateLimitSemaphore.js";
|
||||
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker.js";
|
||||
import { parseModel } from "./model.js";
|
||||
|
||||
// Status codes that should mark semaphore + record circuit breaker failures
|
||||
const TRANSIENT_FOR_BREAKER = [429, 502, 503, 504];
|
||||
|
||||
const MAX_COMBO_DEPTH = 3;
|
||||
|
||||
@@ -145,9 +150,69 @@ function orderModelsForWeightedFallback(models, selectedModel) {
|
||||
return [selected, ...rest].filter(Boolean).map((e) => e.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fisher-Yates shuffle (in-place)
|
||||
* @param {Array} arr
|
||||
* @returns {Array} The shuffled array
|
||||
*/
|
||||
function shuffleArray(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort models by pricing (cheapest first) for cost-optimized strategy
|
||||
* @param {Array<string>} models - Model strings in "provider/model" format
|
||||
* @returns {Promise<Array<string>>} Sorted model strings
|
||||
*/
|
||||
async function sortModelsByCost(models) {
|
||||
try {
|
||||
const { getPricingForModel } = await import("../../src/lib/localDb.js");
|
||||
const withCost = await Promise.all(
|
||||
models.map(async (modelStr) => {
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
||||
const model = parsed.model || modelStr;
|
||||
try {
|
||||
const pricing = await getPricingForModel(provider, model);
|
||||
return { modelStr, cost: pricing?.input ?? Infinity };
|
||||
} catch {
|
||||
return { modelStr, cost: Infinity };
|
||||
}
|
||||
})
|
||||
);
|
||||
withCost.sort((a, b) => a.cost - b.cost);
|
||||
return withCost.map((e) => e.modelStr);
|
||||
} catch {
|
||||
// If pricing lookup fails entirely, return original order
|
||||
return models;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort models by usage count (least-used first) for least-used strategy
|
||||
* @param {Array<string>} models - Model strings
|
||||
* @param {string} comboName - Combo name for metrics lookup
|
||||
* @returns {Array<string>} Sorted model strings
|
||||
*/
|
||||
function sortModelsByUsage(models, comboName) {
|
||||
const metrics = getComboMetrics(comboName);
|
||||
if (!metrics || !metrics.byModel) return models;
|
||||
|
||||
const withUsage = models.map((modelStr) => ({
|
||||
modelStr,
|
||||
requests: metrics.byModel[modelStr]?.requests ?? 0,
|
||||
}));
|
||||
withUsage.sort((a, b) => a.requests - b.requests);
|
||||
return withUsage.map((e) => e.modelStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle combo chat with fallback
|
||||
* Supports priority (sequential) and weighted (probabilistic) strategies
|
||||
* Supports all 6 strategies: priority, weighted, round-robin, random, least-used, cost-optimized
|
||||
* @param {Object} options
|
||||
* @param {Object} options.body - Request body
|
||||
* @param {Object} options.combo - Full combo object { name, models, strategy, config }
|
||||
@@ -210,7 +275,7 @@ export async function handleComboChat({
|
||||
);
|
||||
} else {
|
||||
orderedModels = flatModels;
|
||||
log.info("COMBO", `Priority with nested resolution: ${orderedModels.length} total models`);
|
||||
log.info("COMBO", `${strategy} with nested resolution: ${orderedModels.length} total models`);
|
||||
}
|
||||
} else if (strategy === "weighted") {
|
||||
const selected = selectWeightedModel(models);
|
||||
@@ -220,6 +285,18 @@ export async function handleComboChat({
|
||||
orderedModels = models.map((m) => normalizeModelEntry(m).model);
|
||||
}
|
||||
|
||||
// Apply strategy-specific ordering
|
||||
if (strategy === "random") {
|
||||
orderedModels = shuffleArray([...orderedModels]);
|
||||
log.info("COMBO", `Random shuffle: ${orderedModels.length} models`);
|
||||
} else if (strategy === "least-used") {
|
||||
orderedModels = sortModelsByUsage(orderedModels, combo.name);
|
||||
log.info("COMBO", `Least-used ordering: ${orderedModels[0]} has fewest requests`);
|
||||
} else if (strategy === "cost-optimized") {
|
||||
orderedModels = await sortModelsByCost(orderedModels);
|
||||
log.info("COMBO", `Cost-optimized ordering: cheapest first (${orderedModels[0]})`);
|
||||
}
|
||||
|
||||
let lastError = null;
|
||||
let earliestRetryAfter = null;
|
||||
let lastStatus = null;
|
||||
@@ -229,6 +306,20 @@ export async function handleComboChat({
|
||||
|
||||
for (let i = 0; i < orderedModels.length; i++) {
|
||||
const modelStr = orderedModels[i];
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
||||
const profile = getProviderProfile(provider);
|
||||
const breaker = getCircuitBreaker(`combo:${provider}`, {
|
||||
failureThreshold: profile.circuitBreakerThreshold,
|
||||
resetTimeout: profile.circuitBreakerReset,
|
||||
});
|
||||
|
||||
// Skip model if circuit breaker is OPEN
|
||||
if (!breaker.canExecute()) {
|
||||
log.info("COMBO", `Skipping ${modelStr}: circuit breaker OPEN for ${provider}`);
|
||||
if (i > 0) fallbackCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pre-check: skip models where all accounts are in cooldown
|
||||
if (isModelAvailable) {
|
||||
@@ -313,7 +404,18 @@ export async function handleComboChat({
|
||||
}
|
||||
}
|
||||
|
||||
const { shouldFallback } = checkFallbackError(result.status, errorText);
|
||||
const { shouldFallback, cooldownMs } = checkFallbackError(
|
||||
result.status,
|
||||
errorText,
|
||||
0,
|
||||
null,
|
||||
provider
|
||||
);
|
||||
|
||||
// Record failure in circuit breaker for transient errors
|
||||
if (TRANSIENT_FOR_BREAKER.includes(result.status)) {
|
||||
breaker._onFailure();
|
||||
}
|
||||
|
||||
if (!shouldFallback) {
|
||||
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
|
||||
@@ -335,10 +437,24 @@ export async function handleComboChat({
|
||||
}
|
||||
}
|
||||
|
||||
// Early exit: check if all models have breaker OPEN
|
||||
const allBreakersOpen = orderedModels.every((m) => {
|
||||
const p = parseModel(m).provider || parseModel(m).providerAlias || "unknown";
|
||||
return !getCircuitBreaker(`combo:${p}`).canExecute();
|
||||
});
|
||||
|
||||
// All models failed
|
||||
const latencyMs = Date.now() - startTime;
|
||||
recordComboRequest(combo.name, null, { success: false, latencyMs, fallbackCount, strategy });
|
||||
|
||||
if (allBreakersOpen) {
|
||||
log.warn("COMBO", "All models have circuit breaker OPEN — aborting");
|
||||
return unavailableResponse(
|
||||
503,
|
||||
"All providers temporarily unavailable (circuit breakers open)"
|
||||
);
|
||||
}
|
||||
|
||||
const status = lastStatus || 406;
|
||||
const msg = lastError || "All combo models unavailable";
|
||||
|
||||
@@ -412,6 +528,20 @@ async function handleRoundRobinCombo({
|
||||
for (let offset = 0; offset < modelCount; offset++) {
|
||||
const modelIndex = (startIndex + offset) % modelCount;
|
||||
const modelStr = orderedModels[modelIndex];
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
||||
const profile = getProviderProfile(provider);
|
||||
const breaker = getCircuitBreaker(`combo:${provider}`, {
|
||||
failureThreshold: profile.circuitBreakerThreshold,
|
||||
resetTimeout: profile.circuitBreakerReset,
|
||||
});
|
||||
|
||||
// Skip model if circuit breaker is OPEN
|
||||
if (!breaker.canExecute()) {
|
||||
log.info("COMBO-RR", `Skipping ${modelStr}: circuit breaker OPEN for ${provider}`);
|
||||
if (offset > 0) fallbackCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pre-check availability
|
||||
if (isModelAvailable) {
|
||||
@@ -510,12 +640,22 @@ async function handleRoundRobinCombo({
|
||||
}
|
||||
}
|
||||
|
||||
const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText);
|
||||
const { shouldFallback, cooldownMs } = checkFallbackError(
|
||||
result.status,
|
||||
errorText,
|
||||
0,
|
||||
null,
|
||||
provider
|
||||
);
|
||||
|
||||
// Rate-limited → mark in semaphore so queue pauses
|
||||
if (result.status === 429 && cooldownMs > 0) {
|
||||
// Transient errors → mark in semaphore AND record circuit breaker failure
|
||||
if (TRANSIENT_FOR_BREAKER.includes(result.status) && cooldownMs > 0) {
|
||||
semaphore.markRateLimited(modelStr, cooldownMs);
|
||||
log.warn("COMBO-RR", `${modelStr} rate-limited, cooldown ${cooldownMs}ms`);
|
||||
breaker._onFailure();
|
||||
log.warn(
|
||||
"COMBO-RR",
|
||||
`${modelStr} error ${result.status}, cooldown ${cooldownMs}ms (breaker: ${breaker.getStatus().failureCount}/${profile.circuitBreakerThreshold})`
|
||||
);
|
||||
}
|
||||
|
||||
if (!shouldFallback) {
|
||||
@@ -551,6 +691,20 @@ async function handleRoundRobinCombo({
|
||||
strategy: "round-robin",
|
||||
});
|
||||
|
||||
// Early exit: check if all models have breaker OPEN
|
||||
const allBreakersOpen = orderedModels.every((m) => {
|
||||
const p = parseModel(m).provider || parseModel(m).providerAlias || "unknown";
|
||||
return !getCircuitBreaker(`combo:${p}`).canExecute();
|
||||
});
|
||||
|
||||
if (allBreakersOpen) {
|
||||
log.warn("COMBO-RR", "All models have circuit breaker OPEN — aborting");
|
||||
return unavailableResponse(
|
||||
503,
|
||||
"All providers temporarily unavailable (circuit breakers open)"
|
||||
);
|
||||
}
|
||||
|
||||
const status = lastStatus || 406;
|
||||
const msg = lastError || "All round-robin combo models unavailable";
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
* Creates per-provider+connection limiters that auto-learn rate limits
|
||||
* from API response headers (x-ratelimit-*, retry-after, anthropic-ratelimit-*).
|
||||
*
|
||||
* Default: DISABLED. Must be enabled per provider connection via dashboard toggle.
|
||||
* Default: ENABLED for API key providers (safety net), DISABLED for OAuth.
|
||||
* Can be toggled per provider connection via dashboard.
|
||||
*/
|
||||
|
||||
import Bottleneck from "bottleneck";
|
||||
import { parseRetryAfterFromBody, lockModel } from "./accountFallback.js";
|
||||
import { getProviderCategory } from "../config/providerRegistry.js";
|
||||
import { DEFAULT_API_LIMITS } from "../config/constants.js";
|
||||
|
||||
// Store limiters keyed by "provider:connectionId" (and optionally ":model")
|
||||
const limiters = new Map();
|
||||
@@ -39,15 +42,45 @@ export async function initializeRateLimits() {
|
||||
try {
|
||||
const { getProviderConnections } = await import("@/lib/localDb.js");
|
||||
const connections = await getProviderConnections();
|
||||
let count = 0;
|
||||
let explicitCount = 0;
|
||||
let autoCount = 0;
|
||||
|
||||
for (const conn of connections) {
|
||||
if (conn.rateLimitProtection) {
|
||||
// Explicitly enabled by user
|
||||
enabledConnections.add(conn.id);
|
||||
count++;
|
||||
explicitCount++;
|
||||
} else if (
|
||||
conn.provider &&
|
||||
getProviderCategory(conn.provider) === "apikey" &&
|
||||
conn.isActive
|
||||
) {
|
||||
// Auto-enable for API key providers (safety net)
|
||||
enabledConnections.add(conn.id);
|
||||
autoCount++;
|
||||
|
||||
// Create a pre-configured limiter with conservative defaults
|
||||
const key = `${conn.provider}:${conn.id}`;
|
||||
if (!limiters.has(key)) {
|
||||
limiters.set(
|
||||
key,
|
||||
new Bottleneck({
|
||||
maxConcurrent: DEFAULT_API_LIMITS.concurrentRequests,
|
||||
minTime: DEFAULT_API_LIMITS.minTimeBetweenRequests,
|
||||
reservoir: DEFAULT_API_LIMITS.requestsPerMinute,
|
||||
reservoirRefreshAmount: DEFAULT_API_LIMITS.requestsPerMinute,
|
||||
reservoirRefreshInterval: 60 * 1000, // Refresh every minute
|
||||
id: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
console.log(`🛡️ [RATE-LIMIT] Loaded ${count} connection(s) with rate limit protection`);
|
||||
|
||||
if (explicitCount > 0 || autoCount > 0) {
|
||||
console.log(
|
||||
`🛡️ [RATE-LIMIT] Loaded ${explicitCount} explicit + ${autoCount} auto-enabled (API key) protection(s)`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[RATE-LIMIT] Failed to load settings:", err.message);
|
||||
@@ -361,4 +394,3 @@ export function updateFromResponseBody(provider, connectionId, responseBody, sta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -328,45 +328,76 @@ export async function refreshQwenToken(refreshToken, log) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Codex (OpenAI) OAuth tokens
|
||||
* Specialized refresh for Codex (OpenAI) OAuth tokens.
|
||||
* OpenAI uses rotating (one-time-use) refresh tokens.
|
||||
* Returns { error: 'refresh_token_reused' } when the token has already been consumed,
|
||||
* so callers can stop retrying and request re-authentication.
|
||||
*/
|
||||
export async function refreshCodexToken(refreshToken, log) {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.codex.clientId,
|
||||
scope: "openid profile email offline_access",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.codex.clientId,
|
||||
scope: "openid profile email offline_access",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
// Detect unrecoverable "refresh_token_reused" error from OpenAI
|
||||
// This means the token was already consumed and a new one was issued.
|
||||
// Retrying with the same token will never succeed.
|
||||
let errorCode = null;
|
||||
try {
|
||||
const parsed = JSON.parse(errorText);
|
||||
errorCode = parsed?.error?.code;
|
||||
} catch {
|
||||
// not JSON, ignore
|
||||
}
|
||||
|
||||
if (errorCode === "refresh_token_reused") {
|
||||
log?.error?.(
|
||||
"TOKEN_REFRESH",
|
||||
"Codex refresh token already used (rotating token consumed). Re-authentication required.",
|
||||
{
|
||||
status: response.status,
|
||||
}
|
||||
);
|
||||
return { error: "refresh_token_reused" };
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -666,6 +697,15 @@ export function supportsTokenRefresh(provider) {
|
||||
return !!(config?.refreshUrl || config?.tokenUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a refresh result indicates an unrecoverable error
|
||||
* (e.g. the refresh token was already consumed and cannot be reused).
|
||||
* Callers should stop retrying and request re-authentication.
|
||||
*/
|
||||
export function isUnrecoverableRefreshError(result) {
|
||||
return result && typeof result === "object" && result.error === "refresh_token_reused";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token for a specific provider (with deduplication).
|
||||
* If a refresh is already in-flight for the same provider+token,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Progress Tracker — Phase 9.3
|
||||
*
|
||||
* Emits SSE `event: progress` events during long streaming responses.
|
||||
* Opt-in via X-OmniRoute-Progress: true header.
|
||||
*
|
||||
* Progress events contain:
|
||||
* { tokens_generated, elapsed_ms }
|
||||
*
|
||||
* @module utils/progressTracker
|
||||
*/
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 2000;
|
||||
|
||||
/**
|
||||
* Create a progress emitter for a streaming response.
|
||||
* Returns a TransformStream that injects progress events periodically.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {number} [options.intervalMs=2000] - Interval between events
|
||||
* @param {AbortSignal} [options.signal] - Abort signal for cancellation
|
||||
* @returns {TransformStream}
|
||||
*/
|
||||
export function createProgressTransform({ intervalMs = DEFAULT_INTERVAL_MS, signal } = {}) {
|
||||
let tokenCount = 0;
|
||||
let startTime = Date.now();
|
||||
let intervalId;
|
||||
let writer;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
return new TransformStream({
|
||||
start(controller) {
|
||||
writer = controller;
|
||||
startTime = Date.now();
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
if (signal?.aborted) {
|
||||
clearInterval(intervalId);
|
||||
return;
|
||||
}
|
||||
const progressEvent = `event: progress\ndata: ${JSON.stringify({
|
||||
tokens_generated: tokenCount,
|
||||
elapsed_ms: Date.now() - startTime,
|
||||
})}\n\n`;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(progressEvent));
|
||||
} catch {
|
||||
// Stream closed
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
// Clean up on abort
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearInterval(intervalId);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
},
|
||||
|
||||
transform(chunk, controller) {
|
||||
// Count token events in the chunk
|
||||
const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
||||
// Count data lines (each is roughly one token event)
|
||||
const dataLines = text.split("\n").filter((l) => l.startsWith("data: "));
|
||||
tokenCount += dataLines.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
|
||||
flush() {
|
||||
clearInterval(intervalId);
|
||||
// Final progress event
|
||||
if (writer) {
|
||||
try {
|
||||
const finalEvent = `event: progress\ndata: ${JSON.stringify({
|
||||
tokens_generated: tokenCount,
|
||||
elapsed_ms: Date.now() - startTime,
|
||||
done: true,
|
||||
})}\n\n`;
|
||||
writer.enqueue(encoder.encode(finalEvent));
|
||||
} catch {
|
||||
// Stream already closed
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client opted into progress tracking.
|
||||
* @param {Headers|object} headers
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function wantsProgress(headers) {
|
||||
if (!headers) return false;
|
||||
const get = typeof headers.get === "function" ? (k) => headers.get(k) : (k) => headers[k];
|
||||
return get("x-omniroute-progress") === "true";
|
||||
}
|
||||
Generated
+291
-174
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.0",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"open-sse"
|
||||
@@ -18,7 +18,6 @@
|
||||
"bottleneck": "^2.19.5",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"jose": "^6.1.3",
|
||||
@@ -40,7 +39,8 @@
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"bin": {
|
||||
"omniroute": "bin/omniroute.mjs"
|
||||
"omniroute": "bin/omniroute.mjs",
|
||||
"omniroute-reset-password": "bin/reset-password.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^10",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
@@ -403,68 +403,105 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.1.tgz",
|
||||
"integrity": "sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==",
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
|
||||
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^3.0.1",
|
||||
"@eslint/object-schema": "^2.1.7",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^10.1.1"
|
||||
"minimatch": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz",
|
||||
"integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
|
||||
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.0"
|
||||
"@eslint/core": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz",
|
||||
"integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==",
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
|
||||
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
|
||||
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.4",
|
||||
"debug": "^4.3.2",
|
||||
"espree": "^10.0.1",
|
||||
"globals": "^14.0.0",
|
||||
"ignore": "^5.2.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"minimatch": "^3.1.2",
|
||||
"strip-json-comments": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.39.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
|
||||
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://eslint.org/donate"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.1.tgz",
|
||||
"integrity": "sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==",
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
|
||||
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz",
|
||||
"integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
|
||||
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.0",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
@@ -985,16 +1022,6 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1938,13 +1965,6 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2116,13 +2136,6 @@
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
@@ -2180,19 +2193,6 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
||||
@@ -2552,6 +2552,29 @@
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
@@ -2799,17 +2822,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
|
||||
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jackspeak": "^4.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
@@ -2914,16 +2931,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
||||
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -3077,6 +3092,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001769",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
|
||||
@@ -3097,6 +3122,23 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
@@ -3162,6 +3204,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
@@ -3908,30 +3970,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.0.tgz",
|
||||
"integrity": "sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==",
|
||||
"version": "9.39.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@eslint/config-array": "^0.23.0",
|
||||
"@eslint/config-helpers": "^0.5.2",
|
||||
"@eslint/core": "^1.1.0",
|
||||
"@eslint/plugin-kit": "^0.6.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.21.1",
|
||||
"@eslint/config-helpers": "^0.4.2",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@eslint/plugin-kit": "^0.4.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"debug": "^4.3.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^9.1.0",
|
||||
"eslint-visitor-keys": "^5.0.0",
|
||||
"espree": "^11.1.0",
|
||||
"esquery": "^1.7.0",
|
||||
"eslint-scope": "^8.4.0",
|
||||
"eslint-visitor-keys": "^4.2.1",
|
||||
"espree": "^10.4.0",
|
||||
"esquery": "^1.5.0",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-entry-cache": "^8.0.0",
|
||||
@@ -3941,7 +4006,8 @@
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"minimatch": "^10.1.1",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"minimatch": "^3.1.2",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
@@ -3949,7 +4015,7 @@
|
||||
"eslint": "bin/eslint.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://eslint.org/donate"
|
||||
@@ -3990,24 +4056,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/eslint-import-resolver-typescript": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz",
|
||||
@@ -4193,19 +4241,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -4402,50 +4437,48 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.0.tgz",
|
||||
"integrity": "sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==",
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
|
||||
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/esrecurse": "^4.3.1",
|
||||
"@types/estree": "^1.0.8",
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz",
|
||||
"integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==",
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
@@ -4791,12 +4824,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs": {
|
||||
"version": "0.0.1-security",
|
||||
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
|
||||
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
@@ -4977,6 +5004,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globalthis": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
@@ -5026,6 +5066,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
@@ -5254,6 +5304,23 @@
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parent-module": "^1.0.0",
|
||||
"resolve-from": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -5877,22 +5944,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
|
||||
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -5928,6 +5979,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -6362,6 +6426,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-symbols": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||
@@ -6561,19 +6632,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
|
||||
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
@@ -7083,6 +7151,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"callsites": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -7761,6 +7842,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@@ -8616,6 +8707,19 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
@@ -8639,6 +8743,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
|
||||
+9
-5
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "0.2.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"omniroute": "bin/omniroute.mjs"
|
||||
"omniroute": "bin/omniroute.mjs",
|
||||
"omniroute-reset-password": "bin/reset-password.mjs"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
@@ -46,10 +47,14 @@
|
||||
"build:cli": "node scripts/prepublish.mjs",
|
||||
"start": "next start --port 20128",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test tests/unit/*.test.mjs",
|
||||
"test:unit": "node --test tests/unit/*.test.mjs",
|
||||
"test:plan3": "node --test tests/unit/plan3-p0.test.mjs",
|
||||
"test:fixes": "node --test tests/unit/fixes-p1.test.mjs",
|
||||
"test": "npm run build",
|
||||
"test:security": "node --test tests/unit/security-fase01.test.mjs",
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:coverage": "npx c8 --check-coverage --lines 60 --functions 50 --branches 50 node --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",
|
||||
"prepare": "husky"
|
||||
@@ -61,7 +66,6 @@
|
||||
"bottleneck": "^2.19.5",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"jose": "^6.1.3",
|
||||
@@ -89,7 +93,7 @@
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^10",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
export default {
|
||||
const prettierConfig = {
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
tabWidth: 2,
|
||||
trailingComma: "es5",
|
||||
printWidth: 100,
|
||||
};
|
||||
|
||||
export default prettierConfig;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="180" height="180" rx="36" fill="url(#gradient)" />
|
||||
<!-- Central node -->
|
||||
<circle cx="90" cy="90" r="17" fill="white" />
|
||||
<!-- Outer nodes -->
|
||||
<circle cx="45" cy="45" r="11" fill="white" />
|
||||
<circle cx="135" cy="45" r="11" fill="white" />
|
||||
<circle cx="45" cy="135" r="11" fill="white" />
|
||||
<circle cx="135" cy="135" r="11" fill="white" />
|
||||
<circle cx="90" cy="28" r="8.5" fill="white" />
|
||||
<circle cx="90" cy="152" r="8.5" fill="white" />
|
||||
<!-- Connection lines -->
|
||||
<line x1="90" y1="73" x2="45" y2="45" stroke="white" stroke-width="6.5" stroke-linecap="round" />
|
||||
<line x1="90" y1="73" x2="135" y2="45" stroke="white" stroke-width="6.5" stroke-linecap="round" />
|
||||
<line x1="90" y1="107" x2="45" y2="135" stroke="white" stroke-width="6.5" stroke-linecap="round" />
|
||||
<line x1="90" y1="107" x2="135" y2="135" stroke="white" stroke-width="6.5"
|
||||
stroke-linecap="round" />
|
||||
<line x1="90" y1="73" x2="90" y2="28" stroke="white" stroke-width="6.5" stroke-linecap="round" />
|
||||
<line x1="90" y1="107" x2="90" y2="152" stroke="white" stroke-width="6.5" stroke-linecap="round" />
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0" y1="0" x2="180" y2="180" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E54D5E" />
|
||||
<stop offset="1" stop-color="#C93D4E" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
+18
-4
@@ -1,11 +1,25 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="6" fill="url(#gradient)"/>
|
||||
<text x="16" y="24" font-family="system-ui, -apple-system, sans-serif" font-size="20" font-weight="700" fill="white" text-anchor="middle">9</text>
|
||||
<!-- Central node -->
|
||||
<circle cx="16" cy="16" r="3" fill="white"/>
|
||||
<!-- Outer nodes -->
|
||||
<circle cx="8" cy="8" r="2" fill="white"/>
|
||||
<circle cx="24" cy="8" r="2" fill="white"/>
|
||||
<circle cx="8" cy="24" r="2" fill="white"/>
|
||||
<circle cx="24" cy="24" r="2" fill="white"/>
|
||||
<circle cx="16" cy="5" r="1.5" fill="white"/>
|
||||
<circle cx="16" cy="27" r="1.5" fill="white"/>
|
||||
<!-- Connection lines -->
|
||||
<line x1="16" y1="13" x2="8" y2="8" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<line x1="16" y1="13" x2="24" y2="8" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<line x1="16" y1="19" x2="8" y2="24" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<line x1="16" y1="19" x2="24" y2="24" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<line x1="16" y1="13" x2="16" y2="5" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<line x1="16" y1="19" x2="16" y2="27" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#f97815"/>
|
||||
<stop offset="1" stop-color="#c2590a"/>
|
||||
<stop stop-color="#E54D5E"/>
|
||||
<stop offset="1" stop-color="#C93D4E"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 533 B After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="192" height="192" rx="36" fill="url(#gradient)" />
|
||||
<!-- Central node -->
|
||||
<circle cx="96" cy="96" r="18" fill="white" />
|
||||
<!-- Outer nodes -->
|
||||
<circle cx="48" cy="48" r="12" fill="white" />
|
||||
<circle cx="144" cy="48" r="12" fill="white" />
|
||||
<circle cx="48" cy="144" r="12" fill="white" />
|
||||
<circle cx="144" cy="144" r="12" fill="white" />
|
||||
<circle cx="96" cy="30" r="9" fill="white" />
|
||||
<circle cx="96" cy="162" r="9" fill="white" />
|
||||
<!-- Connection lines -->
|
||||
<line x1="96" y1="78" x2="48" y2="48" stroke="white" stroke-width="7" stroke-linecap="round" />
|
||||
<line x1="96" y1="78" x2="144" y2="48" stroke="white" stroke-width="7" stroke-linecap="round" />
|
||||
<line x1="96" y1="114" x2="48" y2="144" stroke="white" stroke-width="7" stroke-linecap="round" />
|
||||
<line x1="96" y1="114" x2="144" y2="144" stroke="white" stroke-width="7" stroke-linecap="round" />
|
||||
<line x1="96" y1="78" x2="96" y2="30" stroke="white" stroke-width="7" stroke-linecap="round" />
|
||||
<line x1="96" y1="114" x2="96" y2="162" stroke="white" stroke-width="7" stroke-linecap="round" />
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0" y1="0" x2="192" y2="192" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E54D5E" />
|
||||
<stop offset="1" stop-color="#C93D4E" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,499 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardSkeleton, Button, Modal } from "@/shared/components";
|
||||
import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
export default function HomePageClient({ machineId }) {
|
||||
const [providerConnections, setProviderConnections] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [baseUrl, setBaseUrl] = useState("/v1");
|
||||
const [selectedProvider, setSelectedProvider] = useState(null);
|
||||
const [providerMetrics, setProviderMetrics] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setBaseUrl(`${window.location.origin}/v1`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [provRes, modelsRes, metricsRes] = await Promise.all([
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/models"),
|
||||
fetch("/api/provider-metrics"),
|
||||
]);
|
||||
if (provRes.ok) {
|
||||
const provData = await provRes.json();
|
||||
setProviderConnections(provData.connections || []);
|
||||
}
|
||||
if (modelsRes.ok) {
|
||||
const modelsData = await modelsRes.json();
|
||||
setModels(modelsData.models || []);
|
||||
}
|
||||
if (metricsRes.ok) {
|
||||
const metricsData = await metricsRes.json();
|
||||
setProviderMetrics(metricsData.metrics || {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Error fetching data:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const providerStats = useMemo(() => {
|
||||
return Object.entries(AI_PROVIDERS).map(([providerId, providerInfo]) => {
|
||||
const connections = providerConnections.filter((conn) => conn.provider === providerId);
|
||||
const connected = connections.filter(
|
||||
(conn) =>
|
||||
conn.isActive !== false &&
|
||||
(conn.testStatus === "active" ||
|
||||
conn.testStatus === "success" ||
|
||||
conn.testStatus === "unknown")
|
||||
).length;
|
||||
const errors = connections.filter(
|
||||
(conn) =>
|
||||
conn.isActive !== false &&
|
||||
(conn.testStatus === "error" ||
|
||||
conn.testStatus === "expired" ||
|
||||
conn.testStatus === "unavailable")
|
||||
).length;
|
||||
|
||||
const providerKeys = new Set([providerId, providerInfo.alias].filter(Boolean));
|
||||
const providerModels = models.filter((m) => providerKeys.has(m.provider));
|
||||
|
||||
// Determine auth type
|
||||
const authType = FREE_PROVIDERS[providerId]
|
||||
? "free"
|
||||
: OAUTH_PROVIDERS[providerId]
|
||||
? "oauth"
|
||||
: "apikey";
|
||||
|
||||
return {
|
||||
id: providerId,
|
||||
provider: providerInfo,
|
||||
total: connections.length,
|
||||
connected,
|
||||
errors,
|
||||
modelCount: providerModels.length,
|
||||
authType,
|
||||
};
|
||||
});
|
||||
}, [providerConnections, models]);
|
||||
|
||||
// Models for selected provider
|
||||
const selectedProviderModels = useMemo(() => {
|
||||
if (!selectedProvider) return [];
|
||||
const providerKeys = new Set(
|
||||
[selectedProvider.id, selectedProvider.provider?.alias].filter(Boolean)
|
||||
);
|
||||
return models.filter((m) => providerKeys.has(m.provider));
|
||||
}, [selectedProvider, models]);
|
||||
|
||||
const quickStartLinks = [
|
||||
{ label: "Documentation", href: "/docs", icon: "menu_book" },
|
||||
{ label: "Providers", href: "/dashboard/providers", icon: "dns" },
|
||||
{ label: "Combos", href: "/dashboard/combos", icon: "layers" },
|
||||
{ label: "Analytics", href: "/dashboard/analytics", icon: "analytics" },
|
||||
{ label: "Health Monitor", href: "/dashboard/health", icon: "health_and_safety" },
|
||||
{ label: "CLI Tools", href: "/dashboard/cli-tools", icon: "terminal" },
|
||||
{
|
||||
label: "Report issue",
|
||||
href: "https://github.com/diegosouzapw/OmniRoute/issues",
|
||||
external: true,
|
||||
icon: "bug_report",
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentEndpoint = baseUrl;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Quick Start */}
|
||||
<Card>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Quick Start</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
Get up and running in 4 steps. Connect providers, route models, monitor everything.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/docs"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">menu_book</span>
|
||||
Full Docs
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ol className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<li className="rounded-lg border border-border bg-bg-subtle p-4 flex gap-3">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 text-primary shrink-0">
|
||||
<span className="material-symbols-outlined text-[18px]">key</span>
|
||||
</div>
|
||||
<div>
|
||||
<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>{" "}
|
||||
→ API Keys. Generate one key per environment.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="rounded-lg border border-border bg-bg-subtle p-4 flex gap-3">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-green-500/10 text-green-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[18px]">dns</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">2. Connect providers</span>
|
||||
<p className="text-text-muted mt-0.5">
|
||||
Add accounts in{" "}
|
||||
<Link href="/dashboard/providers" className="text-primary hover:underline">
|
||||
Providers
|
||||
</Link>
|
||||
. Supports OAuth, API Key, and free tiers.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="rounded-lg border border-border bg-bg-subtle p-4 flex gap-3">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-blue-500/10 text-blue-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[18px]">link</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">3. Point your client</span>
|
||||
<p className="text-text-muted mt-0.5">
|
||||
Set base URL to{" "}
|
||||
<code className="px-1.5 py-0.5 rounded bg-surface text-xs font-mono">
|
||||
{currentEndpoint}
|
||||
</code>{" "}
|
||||
in your IDE or API client.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="rounded-lg border border-border bg-bg-subtle p-4 flex gap-3">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-amber-500/10 text-amber-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[18px]">analytics</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">4. Monitor & optimize</span>
|
||||
<p className="text-text-muted mt-0.5">
|
||||
Track tokens, cost and errors in{" "}
|
||||
<Link href="/dashboard/usage" className="text-primary hover:underline">
|
||||
Usage
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/dashboard/analytics" className="text-primary hover:underline">
|
||||
Analytics
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickStartLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
target={link.external ? "_blank" : undefined}
|
||||
rel={link.external ? "noopener noreferrer" : undefined}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{link.icon || (link.external ? "open_in_new" : "arrow_forward")}
|
||||
</span>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Providers Overview */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Providers Overview</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{providerStats.filter((item) => item.total > 0).length} configured of{" "}
|
||||
{providerStats.length} available providers
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden sm:flex items-center gap-3 text-[11px] text-text-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="size-2 rounded-full bg-green-500" /> Free
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="size-2 rounded-full bg-blue-500" /> OAuth
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="size-2 rounded-full bg-amber-500" /> API Key
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/providers"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">settings</span>
|
||||
Manage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{providerStats.map((item) => (
|
||||
<ProviderOverviewCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
metrics={providerMetrics[item.provider.alias] || providerMetrics[item.id]}
|
||||
onClick={() => setSelectedProvider(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Provider Models Modal */}
|
||||
{selectedProvider && (
|
||||
<ProviderModelsModal
|
||||
provider={selectedProvider}
|
||||
models={selectedProviderModels}
|
||||
onClose={() => setSelectedProvider(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
HomePageClient.propTypes = {
|
||||
machineId: PropTypes.string,
|
||||
};
|
||||
|
||||
function ProviderOverviewCard({ item, metrics, onClick }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const statusVariant =
|
||||
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
|
||||
|
||||
const authTypeConfig = {
|
||||
free: { color: "bg-green-500", label: "Free" },
|
||||
oauth: { color: "bg-blue-500", label: "OAuth" },
|
||||
apikey: { color: "bg-amber-500", label: "API Key" },
|
||||
};
|
||||
const authInfo = authTypeConfig[item.authType] || authTypeConfig.apikey;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="border border-border rounded-lg p-3 hover:bg-surface/40 transition-colors text-left cursor-pointer w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${item.provider.color || "#888"}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span
|
||||
className="text-[10px] font-bold"
|
||||
style={{ color: item.provider.color || "#888" }}
|
||||
>
|
||||
{item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={`/providers/${item.provider.id}.png`}
|
||||
alt={item.provider.name}
|
||||
width={26}
|
||||
height={26}
|
||||
className="object-contain rounded-lg"
|
||||
sizes="26px"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-semibold truncate">{item.provider.name}</p>
|
||||
<span
|
||||
className={`size-2 rounded-full ${authInfo.color} shrink-0`}
|
||||
title={authInfo.label}
|
||||
/>
|
||||
</div>
|
||||
<p className={`text-xs ${statusVariant}`}>
|
||||
{item.total === 0
|
||||
? "Not configured"
|
||||
: `${item.connected} active · ${item.errors} error`}
|
||||
</p>
|
||||
{metrics && metrics.totalRequests > 0 && (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] text-text-muted">
|
||||
<span className="text-emerald-500">{metrics.totalSuccesses}</span>/
|
||||
{metrics.totalRequests} reqs
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">{metrics.successRate}%</span>
|
||||
<span className="text-[10px] text-text-muted">~{metrics.avgLatencyMs}ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-xs font-medium text-text-main">{item.modelCount}</p>
|
||||
<p className="text-[10px] text-text-muted">models</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
ProviderOverviewCard.propTypes = {
|
||||
item: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
provider: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
color: PropTypes.string,
|
||||
textIcon: PropTypes.string,
|
||||
alias: PropTypes.string,
|
||||
}).isRequired,
|
||||
total: PropTypes.number.isRequired,
|
||||
connected: PropTypes.number.isRequired,
|
||||
errors: PropTypes.number.isRequired,
|
||||
modelCount: PropTypes.number.isRequired,
|
||||
authType: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
metrics: PropTypes.shape({
|
||||
totalRequests: PropTypes.number,
|
||||
totalSuccesses: PropTypes.number,
|
||||
successRate: PropTypes.number,
|
||||
avgLatencyMs: PropTypes.number,
|
||||
}),
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function ProviderModelsModal({ provider, models, onClose }) {
|
||||
const [copiedModel, setCopiedModel] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
const router = useRouter();
|
||||
|
||||
const navigateTo = (path) => {
|
||||
onClose();
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
const handleCopy = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedModel(text);
|
||||
notify.success(`Copied: ${text}`);
|
||||
setTimeout(() => setCopiedModel(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} title={`${provider.provider.name} — Models`} onClose={onClose}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Summary */}
|
||||
<div className="flex items-center gap-2 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-[16px]">token</span>
|
||||
{models.length} model{models.length !== 1 ? "s" : ""} available
|
||||
{provider.total > 0 && (
|
||||
<span className="ml-auto text-xs text-green-500">
|
||||
● {provider.connected} connection{provider.connected !== 1 ? "s" : ""} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{models.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
|
||||
search_off
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">No models available for this provider.</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Configure a connection first in{" "}
|
||||
<button
|
||||
onClick={() => navigateTo("/dashboard/providers")}
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
Providers
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-[400px] overflow-y-auto">
|
||||
{models.map((m) => (
|
||||
<div
|
||||
key={m.fullModel}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-lg hover:bg-surface/50 transition-colors group"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-sm text-text-main truncate">{m.fullModel}</p>
|
||||
{m.alias !== m.model && (
|
||||
<p className="text-[10px] text-text-muted">alias: {m.alias}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(m.fullModel)}
|
||||
className="shrink-0 ml-2 p-1.5 rounded-lg text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Copy model name"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copiedModel === m.fullModel ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2 border-t border-border">
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
size="sm"
|
||||
onClick={() => navigateTo(`/dashboard/providers/${provider.id}`)}
|
||||
className="flex-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">settings</span>
|
||||
Configure Provider
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
ProviderModelsModal.propTypes = {
|
||||
provider: PropTypes.object.isRequired,
|
||||
models: PropTypes.array.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components";
|
||||
import EvalsTab from "../usage/components/EvalsTab";
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "evals", label: "Evals" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{activeTab === "overview" && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<UsageAnalytics />
|
||||
</Suspense>
|
||||
)}
|
||||
{activeTab === "evals" && <EvalsTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,11 +28,13 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
const [modelMappings, setModelMappings] = useState({});
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [toolStatuses, setToolStatuses] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
loadCloudSettings();
|
||||
fetchApiKeys();
|
||||
fetchToolStatuses();
|
||||
}, []);
|
||||
|
||||
const loadCloudSettings = async () => {
|
||||
@@ -59,6 +61,18 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchToolStatuses = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/status");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setToolStatuses(data || {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching CLI tool statuses:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConnections = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
@@ -152,6 +166,7 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
onToggle: () => setExpandedTool(expandedTool === toolId ? null : toolId),
|
||||
baseUrl: getBaseUrl(),
|
||||
apiKeys,
|
||||
batchStatus: toolStatuses[toolId] || null,
|
||||
};
|
||||
|
||||
switch (toolId) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -17,6 +18,7 @@ export default function ClaudeToolCard({
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
cloudEnabled,
|
||||
batchStatus,
|
||||
}) {
|
||||
const [claudeStatus, setClaudeStatus] = useState(null);
|
||||
const [checkingClaude, setCheckingClaude] = useState(false);
|
||||
@@ -49,6 +51,9 @@ export default function ClaudeToolCard({
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
// Use batch status as fallback when card hasn't been expanded yet
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
@@ -269,21 +274,10 @@ export default function ClaudeToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{configStatus === "configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{configStatus === "not_configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
{configStatus === "other" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function CliStatusBadge({ effectiveConfigStatus, batchStatus }) {
|
||||
// Determine badge from effectiveConfigStatus or batchStatus
|
||||
const status = effectiveConfigStatus || batchStatus?.configStatus || null;
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const badges = {
|
||||
configured: {
|
||||
dotClass: "bg-green-500",
|
||||
badgeClass: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
text: "Configured",
|
||||
},
|
||||
not_configured: {
|
||||
dotClass: "bg-yellow-500",
|
||||
badgeClass: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
text: "Not configured",
|
||||
},
|
||||
not_installed: {
|
||||
dotClass: "bg-zinc-400 dark:bg-zinc-500",
|
||||
badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
|
||||
text: "Not installed",
|
||||
},
|
||||
other: {
|
||||
dotClass: "bg-blue-500",
|
||||
badgeClass: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
text: "Custom",
|
||||
},
|
||||
unknown: {
|
||||
dotClass: "bg-zinc-400 dark:bg-zinc-500",
|
||||
badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
|
||||
text: "Unknown",
|
||||
},
|
||||
};
|
||||
|
||||
const badge = badges[status] || badges.unknown;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -15,6 +16,7 @@ export default function ClineToolCard({
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
batchStatus,
|
||||
}) {
|
||||
const [clineStatus, setClineStatus] = useState(null);
|
||||
const [checkingCline, setCheckingCline] = useState(false);
|
||||
@@ -46,6 +48,9 @@ export default function ClineToolCard({
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
// Use batch status as fallback when card hasn't been expanded yet
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
@@ -84,7 +89,7 @@ export default function ClineToolCard({
|
||||
|
||||
const fetchBackups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?toolId=cline");
|
||||
const res = await fetch("/api/cli-tools/backups?tool=cline");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBackups(data.backups || []);
|
||||
@@ -100,7 +105,7 @@ export default function ClineToolCard({
|
||||
const res = await fetch("/api/cli-tools/backups", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ toolId: "cline", backupId }),
|
||||
body: JSON.stringify({ tool: "cline", backupId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Backup restored! Reloading status..." });
|
||||
@@ -202,28 +207,6 @@ export default function ClineToolCard({
|
||||
setShowManualConfigModal(false);
|
||||
};
|
||||
|
||||
const renderStatusBadge = () => {
|
||||
if (!cliReady) return null;
|
||||
const badges = {
|
||||
configured: {
|
||||
class: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
text: "Connected",
|
||||
},
|
||||
not_configured: {
|
||||
class: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
text: "Not configured",
|
||||
},
|
||||
other: { class: "bg-blue-500/10 text-blue-600 dark:text-blue-400", text: "Custom config" },
|
||||
};
|
||||
const badge = badges[configStatus];
|
||||
if (!badge) return null;
|
||||
return (
|
||||
<span className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${badge.class}`}>
|
||||
{badge.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
@@ -250,7 +233,10 @@ export default function ClineToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{renderStatusBadge()}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
export default function CodexToolCard({
|
||||
tool,
|
||||
@@ -12,6 +13,7 @@ export default function CodexToolCard({
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
batchStatus,
|
||||
}) {
|
||||
const [codexStatus, setCodexStatus] = useState(null);
|
||||
const [checkingCodex, setCheckingCodex] = useState(false);
|
||||
@@ -82,6 +84,9 @@ export default function CodexToolCard({
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
// Use batch status as fallback when card hasn't been expanded yet
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || `${baseUrl}/v1`;
|
||||
// Ensure URL ends with /v1
|
||||
@@ -329,21 +334,10 @@ wire_api = "responses"
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{configStatus === "configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{configStatus === "not_configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
{configStatus === "other" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function DefaultToolCard({
|
||||
apiKeys,
|
||||
activeProviders = [],
|
||||
cloudEnabled = false,
|
||||
batchStatus,
|
||||
}) {
|
||||
const [copiedField, setCopiedField] = useState(null);
|
||||
const [showModelModal, setShowModelModal] = useState(false);
|
||||
@@ -483,23 +484,48 @@ export default function DefaultToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{runtimeStatus && !runtimeStatus.error && (
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${
|
||||
runtimeStatus.reason === "not_required"
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
: runtimeStatus.installed && runtimeStatus.runnable
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{runtimeStatus.reason === "not_required"
|
||||
? "Guide"
|
||||
: runtimeStatus.installed && runtimeStatus.runnable
|
||||
? "Detected"
|
||||
: "Not ready"}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
// Use runtime status if available (after expanding), otherwise use batch status
|
||||
const rs = runtimeStatus;
|
||||
const bs = batchStatus;
|
||||
const isGuide = rs?.reason === "not_required" || tool.configType === "guide";
|
||||
const isDetected = rs ? rs.installed && rs.runnable : bs?.installed && bs?.runnable;
|
||||
const isInstalled = rs ? rs.installed : bs?.installed;
|
||||
|
||||
if (isGuide) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
<span className="size-1.5 rounded-full bg-blue-500" />
|
||||
Guide
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isDetected) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
<span className="size-1.5 rounded-full bg-green-500" />
|
||||
Detected
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isInstalled === false && (rs || bs)) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-zinc-500/10 text-zinc-500 dark:text-zinc-400">
|
||||
<span className="size-1.5 rounded-full bg-zinc-400 dark:bg-zinc-500" />
|
||||
Not installed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isInstalled && !isDetected && (rs || bs)) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
|
||||
<span className="size-1.5 rounded-full bg-yellow-500" />
|
||||
Not ready
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -15,6 +16,7 @@ export default function DroidToolCard({
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
batchStatus,
|
||||
}) {
|
||||
const [droidStatus, setDroidStatus] = useState(null);
|
||||
const [checkingDroid, setCheckingDroid] = useState(false);
|
||||
@@ -49,6 +51,9 @@ export default function DroidToolCard({
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
// Use batch status as fallback when card hasn't been expanded yet
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
@@ -262,21 +267,10 @@ export default function DroidToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{configStatus === "configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{configStatus === "not_configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
{configStatus === "other" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -15,6 +16,7 @@ export default function KiloToolCard({
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
batchStatus,
|
||||
}) {
|
||||
const [kiloStatus, setKiloStatus] = useState(null);
|
||||
const [checkingKilo, setCheckingKilo] = useState(false);
|
||||
@@ -42,6 +44,9 @@ export default function KiloToolCard({
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
// Use batch status as fallback when card hasn't been expanded yet
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
@@ -70,7 +75,7 @@ export default function KiloToolCard({
|
||||
|
||||
const fetchBackups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/backups?toolId=kilo");
|
||||
const res = await fetch("/api/cli-tools/backups?tool=kilo");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBackups(data.backups || []);
|
||||
@@ -86,7 +91,7 @@ export default function KiloToolCard({
|
||||
const res = await fetch("/api/cli-tools/backups", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ toolId: "kilo", backupId }),
|
||||
body: JSON.stringify({ tool: "kilo", backupId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Backup restored! Reloading status..." });
|
||||
@@ -188,27 +193,6 @@ export default function KiloToolCard({
|
||||
setShowManualConfigModal(false);
|
||||
};
|
||||
|
||||
const renderStatusBadge = () => {
|
||||
if (!cliReady) return null;
|
||||
const badges = {
|
||||
configured: {
|
||||
class: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
text: "Connected",
|
||||
},
|
||||
not_configured: {
|
||||
class: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
text: "Not configured",
|
||||
},
|
||||
};
|
||||
const badge = badges[configStatus];
|
||||
if (!badge) return null;
|
||||
return (
|
||||
<span className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${badge.class}`}>
|
||||
{badge.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
@@ -235,7 +219,10 @@ export default function KiloToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{renderStatusBadge()}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -15,6 +16,7 @@ export default function OpenClawToolCard({
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
batchStatus,
|
||||
}) {
|
||||
const [openclawStatus, setOpenclawStatus] = useState(null);
|
||||
const [checkingOpenclaw, setCheckingOpenclaw] = useState(false);
|
||||
@@ -48,6 +50,9 @@ export default function OpenClawToolCard({
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
// Use batch status as fallback when card hasn't been expanded yet
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
@@ -266,21 +271,10 @@ export default function OpenClawToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{configStatus === "configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{configStatus === "not_configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
{configStatus === "other" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
CardSkeleton,
|
||||
ModelSelectModal,
|
||||
ProxyConfigModal,
|
||||
EmptyState,
|
||||
} from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
// Validate combo name: letters, numbers, -, _, /, .
|
||||
const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/;
|
||||
@@ -40,6 +42,7 @@ export default function CombosPage() {
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const [testingCombo, setTestingCombo] = useState(null);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const notify = useNotificationStore();
|
||||
const [proxyTargetCombo, setProxyTargetCombo] = useState(null);
|
||||
const [proxyConfig, setProxyConfig] = useState(null);
|
||||
|
||||
@@ -87,12 +90,13 @@ export default function CombosPage() {
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
setShowCreateModal(false);
|
||||
notify.success("Combo created successfully");
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error?.message || err.error || "Failed to create combo");
|
||||
notify.error(err.error?.message || err.error || "Failed to create combo");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error creating combo:", error);
|
||||
notify.error("Error creating combo");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,12 +110,13 @@ export default function CombosPage() {
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
setEditingCombo(null);
|
||||
notify.success("Combo updated successfully");
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error?.message || err.error || "Failed to update combo");
|
||||
notify.error(err.error?.message || err.error || "Failed to update combo");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error updating combo:", error);
|
||||
notify.error("Error updating combo");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -121,9 +126,10 @@ export default function CombosPage() {
|
||||
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setCombos(combos.filter((c) => c.id !== id));
|
||||
notify.success("Combo deleted");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error deleting combo:", error);
|
||||
notify.error("Error deleting combo");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,6 +166,7 @@ export default function CombosPage() {
|
||||
setTestResults(data);
|
||||
} catch (error) {
|
||||
setTestResults({ error: "Test request failed" });
|
||||
notify.error("Test request failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -189,20 +196,13 @@ export default function CombosPage() {
|
||||
|
||||
{/* Combos List */}
|
||||
{combos.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">layers</span>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1">No combos yet</p>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Create model combos with weighted routing and fallback support
|
||||
</p>
|
||||
<Button icon="add" onClick={() => setShowCreateModal(true)}>
|
||||
Create Combo
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon="🧩"
|
||||
title="No combos yet"
|
||||
description="Create model combos with weighted routing and fallback support"
|
||||
actionLabel="Create Combo"
|
||||
onAction={() => setShowCreateModal(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{combos.map((combo) => (
|
||||
@@ -308,7 +308,13 @@ function ComboCard({
|
||||
? "bg-amber-500/15 text-amber-600 dark:text-amber-400"
|
||||
: strategy === "round-robin"
|
||||
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-blue-500/15 text-blue-600 dark:text-blue-400"
|
||||
: strategy === "random"
|
||||
? "bg-purple-500/15 text-purple-600 dark:text-purple-400"
|
||||
: strategy === "least-used"
|
||||
? "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400"
|
||||
: strategy === "cost-optimized"
|
||||
? "bg-teal-500/15 text-teal-600 dark:text-teal-400"
|
||||
: "bg-blue-500/15 text-blue-600 dark:text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{strategy}
|
||||
@@ -704,53 +710,43 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
{/* Strategy Toggle */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Routing Strategy</label>
|
||||
<div className="flex gap-1 p-0.5 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
<button
|
||||
onClick={() => setStrategy("priority")}
|
||||
className={`flex-1 py-1.5 px-3 rounded-md text-xs font-medium transition-all ${
|
||||
strategy === "priority"
|
||||
? "bg-white dark:bg-bg-main shadow-sm text-primary"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] align-middle mr-1">
|
||||
sort
|
||||
</span>
|
||||
Priority
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStrategy("weighted")}
|
||||
className={`flex-1 py-1.5 px-3 rounded-md text-xs font-medium transition-all ${
|
||||
strategy === "weighted"
|
||||
? "bg-white dark:bg-bg-main shadow-sm text-primary"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] align-middle mr-1">
|
||||
percent
|
||||
</span>
|
||||
Weighted
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStrategy("round-robin")}
|
||||
className={`flex-1 py-1.5 px-3 rounded-md text-xs font-medium transition-all ${
|
||||
strategy === "round-robin"
|
||||
? "bg-white dark:bg-bg-main shadow-sm text-primary"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] align-middle mr-1">
|
||||
autorenew
|
||||
</span>
|
||||
Round-Robin
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-1 p-0.5 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
{[
|
||||
{ value: "priority", label: "Priority", icon: "sort" },
|
||||
{ value: "weighted", label: "Weighted", icon: "percent" },
|
||||
{ value: "round-robin", label: "Round-Robin", icon: "autorenew" },
|
||||
{ value: "random", label: "Random", icon: "shuffle" },
|
||||
{ value: "least-used", label: "Least-Used", icon: "low_priority" },
|
||||
{ value: "cost-optimized", label: "Cost-Opt", icon: "savings" },
|
||||
].map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => setStrategy(s.value)}
|
||||
className={`py-1.5 px-2 rounded-md text-xs font-medium transition-all ${
|
||||
strategy === s.value
|
||||
? "bg-white dark:bg-bg-main shadow-sm text-primary"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] align-middle mr-0.5">
|
||||
{s.icon}
|
||||
</span>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted mt-0.5">
|
||||
{strategy === "priority"
|
||||
? "Sequential fallback: tries model 1 first, then 2, etc."
|
||||
: strategy === "weighted"
|
||||
? "Distributes traffic by weight percentage with fallback"
|
||||
: "Circular distribution: each request goes to the next model in rotation"}
|
||||
{
|
||||
{
|
||||
priority: "Sequential fallback: tries model 1 first, then 2, etc.",
|
||||
weighted: "Distributes traffic by weight percentage with fallback",
|
||||
"round-robin":
|
||||
"Circular distribution: each request goes to the next model in rotation",
|
||||
random: "Uniform random selection, then fallback to remaining models",
|
||||
"least-used": "Picks the model with fewest requests, balancing load over time",
|
||||
"cost-optimized": "Routes to the cheapest model first based on pricing",
|
||||
}[strategy]
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SegmentedControl } from "@/shared/components";
|
||||
import BudgetTab from "../usage/components/BudgetTab";
|
||||
import PricingTab from "../settings/components/PricingTab";
|
||||
|
||||
export default function CostsPage() {
|
||||
const [activeTab, setActiveTab] = useState("budget");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "budget", label: "Budget" },
|
||||
{ value: "pricing", label: "Pricing" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{activeTab === "budget" && <BudgetTab />}
|
||||
{activeTab === "pricing" && <PricingTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,8 @@ export default function APIPageClient({ machineId }) {
|
||||
const [showDisableModal, setShowDisableModal] = useState(false);
|
||||
const [cloudSyncing, setCloudSyncing] = useState(false);
|
||||
const [cloudStatus, setCloudStatus] = useState(null);
|
||||
const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | ""
|
||||
const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | "done" | ""
|
||||
const [modalSuccess, setModalSuccess] = useState(false); // show success state in modal before closing
|
||||
const [selectedProvider, setSelectedProvider] = useState(null); // for provider models popup
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
@@ -158,36 +159,72 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-dismiss cloudStatus after 5s
|
||||
useEffect(() => {
|
||||
if (cloudStatus) {
|
||||
const timer = setTimeout(() => setCloudStatus(null), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [cloudStatus]);
|
||||
|
||||
const dispatchCloudChange = () => {
|
||||
globalThis.dispatchEvent(new Event("cloud-status-changed"));
|
||||
};
|
||||
|
||||
const handleEnableCloud = async () => {
|
||||
setCloudSyncing(true);
|
||||
setModalSuccess(false);
|
||||
setSyncStep("syncing");
|
||||
try {
|
||||
const { ok, data } = await postCloudAction("enable");
|
||||
const { ok, status, data } = await postCloudAction("enable");
|
||||
if (ok) {
|
||||
setSyncStep("verifying");
|
||||
|
||||
// Brief delay so user sees the verifying step
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
|
||||
// Sync succeeded — mark as enabled regardless of verify result
|
||||
setCloudEnabled(true);
|
||||
setSyncStep("done");
|
||||
setModalSuccess(true);
|
||||
setCloudSyncing(false);
|
||||
dispatchCloudChange();
|
||||
|
||||
// Show success in modal for a moment, then close
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setShowCloudModal(false);
|
||||
setModalSuccess(false);
|
||||
|
||||
if (data.verified) {
|
||||
setCloudEnabled(true);
|
||||
setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" });
|
||||
setShowCloudModal(false);
|
||||
} else {
|
||||
setCloudEnabled(true);
|
||||
setCloudStatus({
|
||||
type: "warning",
|
||||
message: data.verifyError || "Connected but verification failed",
|
||||
message: data.verifyError
|
||||
? `Connected — verification pending: ${data.verifyError}`
|
||||
: "Connected — verification pending",
|
||||
});
|
||||
setShowCloudModal(false);
|
||||
}
|
||||
|
||||
// Refresh keys list if new key was created
|
||||
if (data.createdKey) {
|
||||
await fetchData();
|
||||
}
|
||||
// Reload settings to ensure fresh state
|
||||
await loadCloudSettings();
|
||||
} else {
|
||||
setCloudStatus({ type: "error", message: data.error || "Failed to enable cloud" });
|
||||
// Sync failed — provide a helpful error message
|
||||
let errorMessage = data.error || "Failed to enable cloud";
|
||||
if (status === 502 || status === 408) {
|
||||
errorMessage =
|
||||
"Could not reach cloud worker. Make sure the cloud service is running (npm run dev in /cloud).";
|
||||
}
|
||||
setCloudStatus({ type: "error", message: errorMessage });
|
||||
setShowCloudModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setCloudStatus({ type: "error", message: error.message });
|
||||
setCloudStatus({ type: "error", message: error.message || "Connection failed" });
|
||||
setShowCloudModal(false);
|
||||
} finally {
|
||||
setCloudSyncing(false);
|
||||
setSyncStep("");
|
||||
@@ -209,8 +246,10 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
if (ok) {
|
||||
setCloudEnabled(false);
|
||||
setCloudStatus({ type: "success", message: "Cloud disabled" });
|
||||
setCloudStatus({ type: "success", message: "Cloud disabled successfully" });
|
||||
setShowDisableModal(false);
|
||||
dispatchCloudChange();
|
||||
await loadCloudSettings();
|
||||
} else {
|
||||
setCloudStatus({ type: "error", message: data.error || "Failed to disable cloud" });
|
||||
}
|
||||
@@ -309,7 +348,11 @@ export default function APIPageClient({ machineId }) {
|
||||
{ label: "Documentation", href: "/docs" },
|
||||
{ label: "OpenAI API compatibility", href: "/docs#api-reference" },
|
||||
{ label: "Cherry/Codex compatibility", href: "/docs#client-compatibility" },
|
||||
{ label: "Report issue", href: "https://github.com/decolua/omniroute/issues", external: true },
|
||||
{
|
||||
label: "Report issue",
|
||||
href: "https://github.com/diegosouzapw/OmniRoute/issues",
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -352,6 +395,34 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cloud Status Toast */}
|
||||
{cloudStatus && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg mb-4 text-sm font-medium animate-in fade-in slide-in-from-top-2 duration-300 ${
|
||||
cloudStatus.type === "success"
|
||||
? "bg-green-500/10 border border-green-500/30 text-green-400"
|
||||
: cloudStatus.type === "warning"
|
||||
? "bg-amber-500/10 border border-amber-500/30 text-amber-400"
|
||||
: "bg-red-500/10 border border-red-500/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{cloudStatus.type === "success"
|
||||
? "check_circle"
|
||||
: cloudStatus.type === "warning"
|
||||
? "warning"
|
||||
: "error"}
|
||||
</span>
|
||||
<span className="flex-1">{cloudStatus.message}</span>
|
||||
<button
|
||||
onClick={() => setCloudStatus(null)}
|
||||
className="p-0.5 hover:bg-white/10 rounded transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Endpoint URL */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Input
|
||||
@@ -367,141 +438,94 @@ export default function APIPageClient({ machineId }) {
|
||||
{copied === "endpoint_url" ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Start */}
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Quick Start</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
First-time setup checklist for API clients and IDE tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<li className="rounded-lg border border-border bg-bg-subtle p-3">
|
||||
<span className="font-semibold">1. Create API key</span>
|
||||
<p className="text-text-muted mt-1">
|
||||
Generate one key per environment to isolate usage and revoke safely.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-lg border border-border bg-bg-subtle p-3">
|
||||
<span className="font-semibold">2. Connect provider account</span>
|
||||
<p className="text-text-muted mt-1">
|
||||
Configure providers in Dashboard and validate with Test Connection.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-lg border border-border bg-bg-subtle p-3">
|
||||
<span className="font-semibold">3. Use endpoint</span>
|
||||
<p className="text-text-muted mt-1">
|
||||
Point clients to <code>{currentEndpoint}</code> and send requests to{" "}
|
||||
<code>/chat/completions</code>.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-lg border border-border bg-bg-subtle p-3">
|
||||
<span className="font-semibold">4. Monitor usage</span>
|
||||
<p className="text-text-muted mt-1">
|
||||
Track requests, tokens, errors, and cost in Usage and Request Logger.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickStartLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
target={link.external ? "_blank" : undefined}
|
||||
rel={link.external ? "noopener noreferrer" : undefined}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{link.external ? "open_in_new" : "arrow_forward"}
|
||||
</span>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">vpn_key</span>
|
||||
{/* Registered Keys — collapsible section inside API Endpoint card */}
|
||||
<div className="border border-border rounded-lg overflow-hidden mt-4">
|
||||
<button
|
||||
onClick={() => setExpandedEndpoint(expandedEndpoint === "keys" ? null : "keys")}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-surface/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 shrink-0">
|
||||
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1">No API keys yet</p>
|
||||
<p className="text-sm text-text-muted mb-4">Create your first API key to get started</p>
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{keys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{key.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-xs text-text-muted font-mono">{key.key}</code>
|
||||
<button
|
||||
onClick={() => copy(key.key, key.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied === key.id ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Created {new Date(key.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteKey(key.id)}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">Registered Keys</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface text-text-muted font-medium">
|
||||
{keys.length} {keys.length === 1 ? "key" : "keys"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Manage API keys used to authenticate requests to this endpoint
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`material-symbols-outlined text-text-muted text-lg transition-transform ${expandedEndpoint === "keys" ? "rotate-180" : ""}`}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Providers Overview */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Providers Overview</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{providerStats.filter((item) => item.total > 0).length} configured of{" "}
|
||||
{providerStats.length} available providers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{expandedEndpoint === "keys" && (
|
||||
<div className="border-t border-border px-4 pb-4">
|
||||
<div className="flex items-center justify-between mt-3 mb-3">
|
||||
<p className="text-xs text-text-muted">
|
||||
Each key isolates usage tracking and can be revoked independently.
|
||||
</p>
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{providerStats.map((item) => (
|
||||
<ProviderOverviewCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => setSelectedProvider(item)}
|
||||
/>
|
||||
))}
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 text-primary mb-3">
|
||||
<span className="material-symbols-outlined text-[24px]">vpn_key</span>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1 text-sm">No API keys yet</p>
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
Create your first API key to get started
|
||||
</p>
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{keys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{key.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-xs text-text-muted font-mono">{key.key}</code>
|
||||
<button
|
||||
onClick={() => copy(key.key, key.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied === key.id ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Created {new Date(key.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteKey(key.id)}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -747,29 +771,51 @@ export default function APIPageClient({ machineId }) {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Sync Progress */}
|
||||
{cloudSyncing && (
|
||||
<div className="flex items-center gap-3 p-3 bg-primary/10 border border-primary/30 rounded-lg">
|
||||
<span className="material-symbols-outlined animate-spin text-primary">
|
||||
progress_activity
|
||||
</span>
|
||||
{/* Sync Progress / Success */}
|
||||
{(cloudSyncing || modalSuccess) && (
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-all duration-300 ${
|
||||
modalSuccess
|
||||
? "bg-green-500/10 border-green-500/30"
|
||||
: "bg-primary/10 border-primary/30"
|
||||
}`}
|
||||
>
|
||||
{modalSuccess ? (
|
||||
<span className="material-symbols-outlined text-green-500 text-xl">
|
||||
check_circle
|
||||
</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined animate-spin text-primary">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{syncStep === "syncing" && "Syncing data to cloud..."}
|
||||
{syncStep === "verifying" && "Verifying connection..."}
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
modalSuccess ? "text-green-500" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{modalSuccess && "Cloud Proxy connected!"}
|
||||
{!modalSuccess && syncStep === "syncing" && "Connecting to cloud..."}
|
||||
{!modalSuccess && syncStep === "verifying" && "Verifying connection..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleEnableCloud} fullWidth disabled={cloudSyncing}>
|
||||
<Button onClick={handleEnableCloud} fullWidth disabled={cloudSyncing || modalSuccess}>
|
||||
{cloudSyncing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">
|
||||
progress_activity
|
||||
</span>
|
||||
{syncStep === "syncing" ? "Syncing..." : "Verifying..."}
|
||||
{syncStep === "syncing" ? "Connecting..." : "Verifying..."}
|
||||
</span>
|
||||
) : modalSuccess ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-sm">check</span>
|
||||
Connected!
|
||||
</span>
|
||||
) : (
|
||||
"Enable Cloud"
|
||||
@@ -779,7 +825,7 @@ export default function APIPageClient({ machineId }) {
|
||||
onClick={() => setShowCloudModal(false)}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
disabled={cloudSyncing}
|
||||
disabled={cloudSyncing || modalSuccess}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Health Dashboard — Phase 8.3
|
||||
*
|
||||
* System health overview with cards for:
|
||||
* - System status (uptime, version, memory)
|
||||
* - Provider health (circuit breaker states)
|
||||
* - Rate limit status
|
||||
* - Active lockouts
|
||||
* - Signature cache stats
|
||||
* - Latency telemetry & prompt cache
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const CB_COLORS = {
|
||||
CLOSED: { bg: "bg-green-500/10", text: "text-green-500", label: "Healthy" },
|
||||
OPEN: { bg: "bg-red-500/10", text: "text-red-500", label: "Open" },
|
||||
HALF_OPEN: { bg: "bg-amber-500/10", text: "text-amber-500", label: "Half-Open" },
|
||||
};
|
||||
|
||||
export default function HealthPage() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
const [telemetry, setTelemetry] = useState(null);
|
||||
const [cache, setCache] = useState(null);
|
||||
const [signatureCache, setSignatureCache] = useState(null);
|
||||
|
||||
const fetchHealth = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring/health");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
setError(null);
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch telemetry, cache, and signature cache stats
|
||||
const fetchExtras = useCallback(async () => {
|
||||
const results = await Promise.allSettled([
|
||||
fetch("/api/telemetry/summary").then((r) => r.json()),
|
||||
fetch("/api/cache/stats").then((r) => r.json()),
|
||||
fetch("/api/rate-limits").then((r) => r.json()),
|
||||
]);
|
||||
if (results[0].status === "fulfilled") setTelemetry(results[0].value);
|
||||
if (results[1].status === "fulfilled") setCache(results[1].value);
|
||||
if (results[2].status === "fulfilled" && results[2].value.cacheStats) {
|
||||
setSignatureCache(results[2].value.cacheStats);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHealth();
|
||||
fetchExtras();
|
||||
const interval = setInterval(() => {
|
||||
fetchHealth();
|
||||
fetchExtras();
|
||||
}, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHealth, fetchExtras]);
|
||||
|
||||
const fmtMs = (ms) => (ms != null ? `${Math.round(ms)}ms` : "—");
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<p className="text-text-muted mt-4">Loading health data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-6 text-center">
|
||||
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2">error</span>
|
||||
<p className="text-red-400">Failed to load health data: {error}</p>
|
||||
<button
|
||||
onClick={fetchHealth}
|
||||
className="mt-4 px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { system, providerHealth, rateLimitStatus, lockouts } = data;
|
||||
const cbEntries = Object.entries(providerHealth || {});
|
||||
const lockoutEntries = Object.entries(lockouts || {});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-main">System Health</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Real-time monitoring of your OmniRoute instance
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastRefresh && (
|
||||
<span className="text-xs text-text-muted">
|
||||
Updated {lastRefresh.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
fetchHealth();
|
||||
fetchExtras();
|
||||
}}
|
||||
className="p-2 rounded-lg bg-surface hover:bg-surface/80 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Banner */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`rounded-xl p-4 flex items-center gap-3 ${
|
||||
data.status === "healthy"
|
||||
? "bg-green-500/10 border border-green-500/20"
|
||||
: "bg-red-500/10 border border-red-500/20"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[24px] ${
|
||||
data.status === "healthy" ? "text-green-500" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{data.status === "healthy" ? "check_circle" : "error"}
|
||||
</span>
|
||||
<span className={data.status === "healthy" ? "text-green-400" : "text-red-400"}>
|
||||
{data.status === "healthy" ? "All systems operational" : "System issues detected"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* System Info Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 text-primary">
|
||||
<span className="material-symbols-outlined text-[18px]">timer</span>
|
||||
</div>
|
||||
<span className="text-sm text-text-muted">Uptime</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-text-main">{formatUptime(system.uptime)}</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<span className="material-symbols-outlined text-[18px]">info</span>
|
||||
</div>
|
||||
<span className="text-sm text-text-muted">Version</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-text-main">v{system.version}</p>
|
||||
<p className="text-xs text-text-muted mt-1">Node {system.nodeVersion}</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-purple-500/10 text-purple-500">
|
||||
<span className="material-symbols-outlined text-[18px]">memory</span>
|
||||
</div>
|
||||
<span className="text-sm text-text-muted">Memory (RSS)</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-text-main">
|
||||
{formatBytes(system.memoryUsage?.rss || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Heap: {formatBytes(system.memoryUsage?.heapUsed || 0)} /{" "}
|
||||
{formatBytes(system.memoryUsage?.heapTotal || 0)}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<span className="material-symbols-outlined text-[18px]">dns</span>
|
||||
</div>
|
||||
<span className="text-sm text-text-muted">Providers</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-text-main">{cbEntries.length}</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{cbEntries.filter(([, v]) => v.state === "CLOSED").length} healthy
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Telemetry Cards — Latency & Prompt Cache */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Latency Card */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">speed</span>
|
||||
Latency
|
||||
</h3>
|
||||
{telemetry ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">p50</span>
|
||||
<span className="font-mono">{fmtMs(telemetry.p50)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">p95</span>
|
||||
<span className="font-mono">{fmtMs(telemetry.p95)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">p99</span>
|
||||
<span className="font-mono">{fmtMs(telemetry.p99)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-border pt-2 mt-2">
|
||||
<span className="text-text-muted">Total requests</span>
|
||||
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No data yet</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Prompt Cache Card */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">cached</span>
|
||||
Prompt Cache
|
||||
</h3>
|
||||
{cache ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Entries</span>
|
||||
<span className="font-mono">
|
||||
{cache.size}/{cache.maxSize}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Hit Rate</span>
|
||||
<span className="font-mono">{cache.hitRate?.toFixed(1) ?? 0}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Hits / Misses</span>
|
||||
<span className="font-mono">
|
||||
{cache.hits ?? 0} / {cache.misses ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No data yet</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Signature Cache Card */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">database</span>
|
||||
Signature Cache
|
||||
</h3>
|
||||
{signatureCache ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ label: "Defaults", value: signatureCache.defaultCount, color: "text-text-muted" },
|
||||
{
|
||||
label: "Tool",
|
||||
value: `${signatureCache.tool.entries}/${signatureCache.tool.patterns}`,
|
||||
color: "text-blue-400",
|
||||
},
|
||||
{
|
||||
label: "Family",
|
||||
value: `${signatureCache.family.entries}/${signatureCache.family.patterns}`,
|
||||
color: "text-purple-400",
|
||||
},
|
||||
{
|
||||
label: "Session",
|
||||
value: `${signatureCache.session.entries}/${signatureCache.session.patterns}`,
|
||||
color: "text-cyan-400",
|
||||
},
|
||||
].map(({ label, value, color }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="text-center p-2 rounded-lg bg-surface/30 border border-border/30"
|
||||
>
|
||||
<p className={`text-lg font-bold tabular-nums ${color}`}>{value}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No data yet</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Provider Health */}
|
||||
<Card className="p-5" role="region" aria-label="Provider health status">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">
|
||||
health_and_safety
|
||||
</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>
|
||||
{cbEntries.length === 0 ? (
|
||||
<p className="text-sm text-text-muted text-center py-4">
|
||||
No circuit breaker data available. Make some requests first.
|
||||
</p>
|
||||
) : (
|
||||
(() => {
|
||||
const unhealthy = cbEntries.filter(([, cb]) => cb.state !== "CLOSED");
|
||||
const healthy = cbEntries.filter(([, cb]) => cb.state === "CLOSED");
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Unhealthy providers first */}
|
||||
{unhealthy.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-red-400 uppercase tracking-wide">
|
||||
Issues Detected
|
||||
</p>
|
||||
{unhealthy.map(([provider, cb]) => {
|
||||
const style = CB_COLORS[cb.state] || CB_COLORS.OPEN;
|
||||
const providerInfo = AI_PROVIDERS[provider];
|
||||
const displayName = providerInfo?.name || provider;
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className={`rounded-lg p-3 ${style.bg} border border-white/5 flex items-center gap-3`}
|
||||
>
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center shrink-0 text-xs font-bold"
|
||||
style={{
|
||||
backgroundColor: `${providerInfo?.color || "#888"}15`,
|
||||
color: providerInfo?.color || "#888",
|
||||
}}
|
||||
>
|
||||
{providerInfo?.textIcon || provider.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text-main truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-semibold px-1.5 py-0.5 rounded ${style.bg} ${style.text}`}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{cb.failures} failure{cb.failures !== 1 ? "s" : ""}
|
||||
{cb.lastFailure && (
|
||||
<span className="ml-2">
|
||||
· Last: {new Date(cb.lastFailure).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Healthy providers in compact grid */}
|
||||
{healthy.length > 0 && (
|
||||
<div>
|
||||
{unhealthy.length > 0 && (
|
||||
<p className="text-xs font-medium text-green-400 uppercase tracking-wide mb-2">
|
||||
Operational
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||
{healthy.map(([provider]) => {
|
||||
const providerInfo = AI_PROVIDERS[provider];
|
||||
const displayName = providerInfo?.name || provider;
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="rounded-lg p-2.5 bg-green-500/5 border border-white/5 flex items-center gap-2"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-green-500 shrink-0" />
|
||||
<span
|
||||
className="text-xs font-medium text-text-main truncate"
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Rate Limit Status */}
|
||||
{rateLimitStatus &&
|
||||
Object.keys(rateLimitStatus).length > 0 &&
|
||||
(() => {
|
||||
// Parse rate limit keys ("provider:connectionId" or "provider:connectionId:model")
|
||||
const parseKey = (key) => {
|
||||
const parts = key.split(":");
|
||||
const providerId = parts[0];
|
||||
const connectionId = parts[1] || "";
|
||||
const model = parts.slice(2).join(":") || null;
|
||||
|
||||
// Resolve friendly name
|
||||
let displayName;
|
||||
let providerInfo = AI_PROVIDERS[providerId];
|
||||
|
||||
if (providerId.startsWith("openai-compatible-")) {
|
||||
const customName = providerId.replace("openai-compatible-", "");
|
||||
displayName = `OpenAI Compatible`;
|
||||
providerInfo = { color: "#10A37F", textIcon: "OC" };
|
||||
if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`;
|
||||
else if (customName) displayName += ` (${customName})`;
|
||||
} else if (providerId.startsWith("anthropic-compatible-")) {
|
||||
const customName = providerId.replace("anthropic-compatible-", "");
|
||||
displayName = `Anthropic Compatible`;
|
||||
providerInfo = { color: "#D97757", textIcon: "AC" };
|
||||
if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`;
|
||||
else if (customName) displayName += ` (${customName})`;
|
||||
} else {
|
||||
displayName = providerInfo?.name || providerId;
|
||||
}
|
||||
|
||||
return { providerId, displayName, providerInfo, connectionId, model };
|
||||
};
|
||||
|
||||
// Group entries by provider for a cleaner display
|
||||
const entries = Object.entries(rateLimitStatus).map(([key, status]) => ({
|
||||
key,
|
||||
...parseKey(key),
|
||||
status,
|
||||
}));
|
||||
|
||||
// Sort: active (queued/running > 0) first, then alphabetically
|
||||
entries.sort((a, b) => {
|
||||
const aActive = (a.status.queued || 0) + (a.status.running || 0);
|
||||
const bActive = (b.status.queued || 0) + (b.status.running || 0);
|
||||
if (aActive !== bActive) return bActive - aActive;
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-amber-500">
|
||||
speed
|
||||
</span>
|
||||
Rate Limit Status
|
||||
</h2>
|
||||
<span className="text-xs text-text-muted">
|
||||
{entries.length} active limiter{entries.length !== 1 ? "s" : ""}
|
||||
</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 }) => {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Active Lockouts */}
|
||||
{lockoutEntries.length > 0 && (
|
||||
<Card className="p-5">
|
||||
<h2 className="text-lg font-semibold text-text-main mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-red-500">lock</span>
|
||||
Active Lockouts
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{lockoutEntries.map(([key, lockout]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg p-3 bg-red-500/5 border border-red-500/10 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-text-main">{key}</span>
|
||||
{lockout.reason && (
|
||||
<span className="text-xs text-text-muted ml-2">({lockout.reason})</span>
|
||||
)}
|
||||
</div>
|
||||
{lockout.until && (
|
||||
<span className="text-xs text-red-400">
|
||||
Until {new Date(lockout.until).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import EndpointPageClient from "./endpoint/EndpointPageClient";
|
||||
import HomePageClient from "./HomePageClient";
|
||||
|
||||
// Must be dynamic — depends on DB state (setupComplete) that changes at runtime
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -12,5 +12,5 @@ export default async function DashboardPage() {
|
||||
redirect("/dashboard/onboarding");
|
||||
}
|
||||
const machineId = await getMachineId();
|
||||
return <EndpointPageClient machineId={machineId} />;
|
||||
return <HomePageClient machineId={machineId} />;
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ export default function ProviderDetailPage() {
|
||||
|
||||
const handleToggleRateLimit = async (connectionId, enabled) => {
|
||||
try {
|
||||
const res = await fetch("/api/rate-limit", {
|
||||
const res = await fetch("/api/rate-limits", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ connectionId, enabled }),
|
||||
@@ -370,8 +370,21 @@ export default function ProviderDetailPage() {
|
||||
if (!modelId) continue;
|
||||
const parts = modelId.split("/");
|
||||
const baseAlias = parts[parts.length - 1];
|
||||
if (modelAliases[baseAlias]) continue;
|
||||
await handleSetAlias(modelId, baseAlias, providerStorageAlias);
|
||||
// Save as imported (default) model in the DB
|
||||
await fetch("/api/provider-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: providerId,
|
||||
modelId,
|
||||
modelName: model.name || modelId,
|
||||
source: "imported",
|
||||
}),
|
||||
});
|
||||
// Also create an alias for routing
|
||||
if (!modelAliases[baseAlias]) {
|
||||
await handleSetAlias(modelId, baseAlias, providerStorageAlias);
|
||||
}
|
||||
importedCount += 1;
|
||||
}
|
||||
if (importedCount === 0) {
|
||||
@@ -405,14 +418,30 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
if (providerInfo.passthroughModels) {
|
||||
return (
|
||||
<PassthroughModelsSection
|
||||
providerAlias={providerAlias}
|
||||
modelAliases={modelAliases}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={handleSetAlias}
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="download"
|
||||
onClick={handleImportModels}
|
||||
disabled={!canImportModels || importingModels}
|
||||
>
|
||||
{importingModels ? "Importing..." : "Import from /models"}
|
||||
</Button>
|
||||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">Add a connection to enable importing.</span>
|
||||
)}
|
||||
</div>
|
||||
<PassthroughModelsSection
|
||||
providerAlias={providerAlias}
|
||||
modelAliases={modelAliases}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={handleSetAlias}
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -704,9 +733,7 @@ export default function ProviderDetailPage() {
|
||||
|
||||
{/* Models */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold mb-4">Available Models</h2>
|
||||
{renderModelsSection()}
|
||||
|
||||
{/* Custom Models — available for ALL providers */}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelAvailabilityBadge — compact inline status indicator
|
||||
*
|
||||
* Replaces the full ModelAvailabilityPanel card with a small badge
|
||||
* that shows green when all models are operational, or amber/red
|
||||
* when there are issues, with a hover popover for details.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
|
||||
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
|
||||
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
|
||||
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
|
||||
};
|
||||
|
||||
export default function ModelAvailabilityBadge() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [clearing, setClearing] = useState(null);
|
||||
const ref = useRef(null);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/availability");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
}
|
||||
} catch {
|
||||
// silent fail — will retry
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Close popover on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
if (expanded) document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [expanded]);
|
||||
|
||||
const handleClearCooldown = async (provider, model) => {
|
||||
setClearing(`${provider}:${model}`);
|
||||
try {
|
||||
const res = await fetch("/api/models/availability", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "clearCooldown", provider, model }),
|
||||
});
|
||||
if (res.ok) {
|
||||
notify.success(`Cooldown cleared for ${model}`);
|
||||
await fetchStatus();
|
||||
} else {
|
||||
notify.error("Failed to clear cooldown");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to clear cooldown");
|
||||
} finally {
|
||||
setClearing(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
const models = data?.models || [];
|
||||
const unavailableCount =
|
||||
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
|
||||
const isHealthy = unavailableCount === 0;
|
||||
|
||||
// Group unhealthy models by provider
|
||||
const byProvider = {};
|
||||
models.forEach((m) => {
|
||||
if (m.status === "available") return;
|
||||
const key = m.provider || "unknown";
|
||||
if (!byProvider[key]) byProvider[key] = [];
|
||||
byProvider[key].push(m);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
isHealthy
|
||||
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/15"
|
||||
: "bg-amber-500/10 border-amber-500/20 text-amber-500 hover:bg-amber-500/15"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{isHealthy ? "verified" : "warning"}
|
||||
</span>
|
||||
{isHealthy
|
||||
? "All models operational"
|
||||
: `${unavailableCount} model${unavailableCount !== 1 ? "s" : ""} with issues`}
|
||||
</button>
|
||||
|
||||
{/* Expanded popover */}
|
||||
{expanded && (
|
||||
<div className="absolute top-full right-0 mt-2 w-80 bg-surface border border-border rounded-xl shadow-2xl z-50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-bg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px]"
|
||||
style={{ color: isHealthy ? "#22c55e" : "#f59e0b" }}
|
||||
>
|
||||
{isHealthy ? "verified" : "warning"}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text-main">Model Status</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
className="p-1 rounded-lg hover:bg-surface text-text-muted hover:text-text-main transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 max-h-60 overflow-y-auto">
|
||||
{isHealthy ? (
|
||||
<p className="text-sm text-text-muted text-center py-2">
|
||||
All models are responding normally.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{Object.entries(byProvider).map(([provider, provModels]) => (
|
||||
<div key={provider}>
|
||||
<p className="text-xs font-semibold text-text-main mb-1.5 capitalize">
|
||||
{provider}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{provModels.map((m) => {
|
||||
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
|
||||
const isClearing = clearing === `${m.provider}:${m.model}`;
|
||||
return (
|
||||
<div
|
||||
key={`${m.provider}-${m.model}`}
|
||||
className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-surface/30"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] shrink-0"
|
||||
style={{ color: status.color }}
|
||||
>
|
||||
{status.icon}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-text-main truncate">
|
||||
{m.model}
|
||||
</span>
|
||||
</div>
|
||||
{m.status === "cooldown" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleClearCooldown(m.provider, m.model)}
|
||||
disabled={isClearing}
|
||||
className="text-[10px] px-1.5! py-0.5! ml-2"
|
||||
>
|
||||
{isClearing ? "..." : "Clear"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelAvailabilityPanel — Batch B
|
||||
*
|
||||
* Shows real-time model availability and cooldown status.
|
||||
* Fetched from /api/models/availability.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, EmptyState } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
|
||||
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
|
||||
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
|
||||
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
|
||||
};
|
||||
|
||||
export default function ModelAvailabilityPanel() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [clearing, setClearing] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/availability");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
}
|
||||
} catch {
|
||||
// silent fail — will retry
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStatus]);
|
||||
|
||||
const handleClearCooldown = async (provider, model) => {
|
||||
setClearing(`${provider}:${model}`);
|
||||
try {
|
||||
const res = await fetch("/api/models/availability", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "clearCooldown", provider, model }),
|
||||
});
|
||||
if (res.ok) {
|
||||
notify.success(`Cooldown cleared for ${model}`);
|
||||
await fetchStatus();
|
||||
} else {
|
||||
notify.error("Failed to clear cooldown");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to clear cooldown");
|
||||
} finally {
|
||||
setClearing(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<div className="flex items-center gap-2 text-text-muted animate-pulse">
|
||||
<span className="material-symbols-outlined text-[20px]">monitoring</span>
|
||||
Loading model availability...
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const models = data?.models || [];
|
||||
const unavailableCount =
|
||||
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
|
||||
|
||||
if (models.length === 0 || unavailableCount === 0) {
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
<span className="material-symbols-outlined text-[20px]">verified</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
|
||||
<p className="text-sm text-text-muted">All models operational</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Group by provider
|
||||
const byProvider = {};
|
||||
models.forEach((m) => {
|
||||
if (m.status === "available") return;
|
||||
const key = m.provider || "unknown";
|
||||
if (!byProvider[key]) byProvider[key] = [];
|
||||
byProvider[key].push(m);
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<span className="material-symbols-outlined text-[20px]">warning</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{unavailableCount} model{unavailableCount !== 1 ? "s" : ""} with issues
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={fetchStatus} className="text-text-muted">
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{Object.entries(byProvider).map(([provider, provModels]) => (
|
||||
<div key={provider} className="border border-border/30 rounded-lg p-3">
|
||||
<p className="text-sm font-medium text-text-main mb-2 capitalize">{provider}</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{provModels.map((m) => {
|
||||
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
|
||||
const isClearing = clearing === `${m.provider}:${m.model}`;
|
||||
return (
|
||||
<div
|
||||
key={`${m.provider}-${m.model}`}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px]"
|
||||
style={{ color: status.color }}
|
||||
>
|
||||
{status.icon}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-text-main">{m.model}</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${status.color}15`,
|
||||
color: status.color,
|
||||
}}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
{m.cooldownUntil && (
|
||||
<span className="text-xs text-text-muted">
|
||||
until {new Date(m.cooldownUntil).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.status === "cooldown" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleClearCooldown(m.provider, m.model)}
|
||||
disabled={isClearing}
|
||||
className="text-xs"
|
||||
>
|
||||
{isClearing ? "Clearing..." : "Clear"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "@/shared/constants/providers";
|
||||
import Link from "next/link";
|
||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
|
||||
|
||||
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
|
||||
function getStatusDisplay(connected, error, errorCode) {
|
||||
@@ -85,6 +87,7 @@ export default function ProvidersPage() {
|
||||
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
|
||||
const [testingMode, setTestingMode] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -153,8 +156,14 @@ export default function ProvidersPage() {
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestResults(data);
|
||||
if (data.summary) {
|
||||
const { passed, failed, total } = data.summary;
|
||||
if (failed === 0) notify.success(`All ${total} tests passed`);
|
||||
else notify.warning(`${passed}/${total} passed, ${failed} failed`);
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResults({ error: "Test request failed" });
|
||||
notify.error("Provider test failed");
|
||||
} finally {
|
||||
setTestingMode(null);
|
||||
}
|
||||
@@ -193,22 +202,28 @@ export default function ProvidersPage() {
|
||||
{/* OAuth Providers */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">OAuth Providers</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("oauth")}
|
||||
disabled={!!testingMode}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
testingMode === "oauth"
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all OAuth connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "oauth" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "oauth" ? "Testing..." : "Test All"}
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
OAuth Providers <span className="size-2.5 rounded-full bg-blue-500" title="OAuth" />
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelAvailabilityBadge />
|
||||
<button
|
||||
onClick={() => handleBatchTest("oauth")}
|
||||
disabled={!!testingMode}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
testingMode === "oauth"
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all OAuth connections"
|
||||
aria-label="Test all OAuth connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "oauth" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "oauth" ? "Testing..." : "Test All"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
|
||||
@@ -217,6 +232,7 @@ export default function ProvidersPage() {
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "oauth")}
|
||||
authType="oauth"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -225,7 +241,9 @@ export default function ProvidersPage() {
|
||||
{/* Free Providers */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Free Providers</h2>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
Free Providers <span className="size-2.5 rounded-full bg-green-500" title="Free" />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("free")}
|
||||
disabled={!!testingMode}
|
||||
@@ -235,6 +253,7 @@ export default function ProvidersPage() {
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all Free connections"
|
||||
aria-label="Test all Free provider connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "free" ? "sync" : "play_arrow"}
|
||||
@@ -249,6 +268,7 @@ export default function ProvidersPage() {
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "oauth")}
|
||||
authType="free"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -257,7 +277,10 @@ export default function ProvidersPage() {
|
||||
{/* API Key Providers — fixed list */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">API Key Providers</h2>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
API Key Providers{" "}
|
||||
<span className="size-2.5 rounded-full bg-amber-500" title="API Key" />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("apikey")}
|
||||
disabled={!!testingMode}
|
||||
@@ -267,6 +290,7 @@ export default function ProvidersPage() {
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all API Key connections"
|
||||
aria-label="Test all API Key connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "apikey" ? "sync" : "play_arrow"}
|
||||
@@ -281,6 +305,7 @@ export default function ProvidersPage() {
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
authType="apikey"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -289,7 +314,10 @@ export default function ProvidersPage() {
|
||||
{/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">API Key Compatible Providers</h2>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
API Key Compatible Providers{" "}
|
||||
<span className="size-2.5 rounded-full bg-orange-500" title="Compatible" />
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
|
||||
<button
|
||||
@@ -340,6 +368,7 @@ export default function ProvidersPage() {
|
||||
providerId={info.id}
|
||||
provider={info}
|
||||
stats={getProviderStats(info.id, "apikey")}
|
||||
authType="compatible"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -377,6 +406,7 @@ export default function ProvidersPage() {
|
||||
<button
|
||||
onClick={() => setTestResults(null)}
|
||||
className="p-1 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label="Close test results"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">close</span>
|
||||
</button>
|
||||
@@ -391,10 +421,18 @@ export default function ProvidersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({ providerId, provider, stats }) {
|
||||
function ProviderCard({ providerId, provider, stats, authType }) {
|
||||
const { connected, error, errorCode, errorTime } = stats;
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
oauth: "bg-blue-500",
|
||||
apikey: "bg-amber-500",
|
||||
compatible: "bg-orange-500",
|
||||
};
|
||||
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
||||
<Card
|
||||
@@ -424,7 +462,13 @@ function ProviderCard({ providerId, provider, stats }) {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<h3 className="font-semibold flex items-center gap-1.5">
|
||||
{provider.name}
|
||||
<span
|
||||
className={`size-2 rounded-full ${dotColors[authType] || dotColors.oauth} shrink-0`}
|
||||
title={dotLabels[authType] || "OAuth"}
|
||||
/>
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
{getStatusDisplay(connected, error, errorCode)}
|
||||
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
||||
@@ -454,15 +498,24 @@ ProviderCard.propTypes = {
|
||||
errorCode: PropTypes.string,
|
||||
errorTime: PropTypes.string,
|
||||
}).isRequired,
|
||||
authType: PropTypes.string,
|
||||
};
|
||||
|
||||
// API Key providers - use image with textIcon fallback (same as OAuth providers)
|
||||
function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||
function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
|
||||
const { connected, error, errorCode, errorTime } = stats;
|
||||
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
oauth: "bg-blue-500",
|
||||
apikey: "bg-amber-500",
|
||||
compatible: "bg-orange-500",
|
||||
};
|
||||
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
|
||||
|
||||
// Determine icon path: OpenAI Compatible providers use specialized icons
|
||||
const getIconPath = () => {
|
||||
if (isCompatible) {
|
||||
@@ -503,7 +556,13 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<h3 className="font-semibold flex items-center gap-1.5">
|
||||
{provider.name}
|
||||
<span
|
||||
className={`size-2 rounded-full ${dotColors[authType] || dotColors.apikey} shrink-0`}
|
||||
title={dotLabels[authType] || "API Key"}
|
||||
/>
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
{getStatusDisplay(connected, error, errorCode)}
|
||||
{isCompatible && (
|
||||
@@ -544,6 +603,7 @@ ApiKeyProviderCard.propTypes = {
|
||||
errorCode: PropTypes.string,
|
||||
errorTime: PropTypes.string,
|
||||
}).isRequired,
|
||||
authType: PropTypes.string,
|
||||
};
|
||||
|
||||
function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export default function CacheStatsCard() {
|
||||
const [cache, setCache] = useState(null);
|
||||
const [flushing, setFlushing] = useState(false);
|
||||
|
||||
const fetchStats = () => {
|
||||
fetch("/api/cache/stats")
|
||||
.then((r) => r.json())
|
||||
.then(setCache)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(fetchStats, []);
|
||||
|
||||
const handleFlush = async () => {
|
||||
setFlushing(true);
|
||||
try {
|
||||
await fetch("/api/cache/stats", { method: "DELETE" });
|
||||
fetchStats();
|
||||
} finally {
|
||||
setFlushing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px]">cached</span>
|
||||
Prompt Cache
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleFlush}
|
||||
disabled={flushing}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{flushing ? "Flushing…" : "Flush Cache"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cache ? (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted">Size</p>
|
||||
<p className="font-mono text-lg text-text-main">
|
||||
{cache.size}/{cache.maxSize}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">Hit Rate</p>
|
||||
<p className="font-mono text-lg text-text-main">{cache.hitRate?.toFixed(1) ?? 0}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">Hits</p>
|
||||
<p className="font-mono text-text-main">{cache.hits ?? 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">Evictions</p>
|
||||
<p className="font-mono text-text-main">{cache.evictions ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">Loading cache stats…</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -82,22 +82,30 @@ export default function ComboDefaultsTab() {
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Combo strategy"
|
||||
className="inline-flex p-0.5 rounded-md bg-black/5 dark:bg-white/5"
|
||||
className="grid grid-cols-3 gap-1 p-0.5 rounded-md bg-black/5 dark:bg-white/5"
|
||||
>
|
||||
{["priority", "weighted", "round-robin"].map((s) => (
|
||||
{[
|
||||
{ value: "priority", label: "Priority", icon: "sort" },
|
||||
{ value: "weighted", label: "Weighted", icon: "percent" },
|
||||
{ value: "round-robin", label: "Round-Robin", icon: "autorenew" },
|
||||
{ value: "random", label: "Random", icon: "shuffle" },
|
||||
{ value: "least-used", label: "Least-Used", icon: "low_priority" },
|
||||
{ value: "cost-optimized", label: "Cost-Opt", icon: "savings" },
|
||||
].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
key={s.value}
|
||||
role="tab"
|
||||
aria-selected={comboDefaults.strategy === s}
|
||||
onClick={() => setComboDefaults((prev) => ({ ...prev, strategy: s }))}
|
||||
aria-selected={comboDefaults.strategy === s.value}
|
||||
onClick={() => setComboDefaults((prev) => ({ ...prev, strategy: s.value }))}
|
||||
className={cn(
|
||||
"px-3 py-1 rounded text-xs font-medium transition-all capitalize",
|
||||
comboDefaults.strategy === s
|
||||
"px-2 py-1 rounded text-xs font-medium transition-all flex items-center justify-center gap-0.5",
|
||||
comboDefaults.strategy === s.value
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
{s === "round-robin" ? "Round-Robin" : s}
|
||||
<span className="material-symbols-outlined text-[14px]">{s.icon}</span>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, DataTable, FilterBar, ColumnToggle } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
const ALL_COLUMNS = [
|
||||
{ key: "timestamp", label: "Time" },
|
||||
{ key: "action", label: "Action" },
|
||||
{ key: "actor", label: "Actor" },
|
||||
{ key: "details", label: "Details" },
|
||||
];
|
||||
|
||||
export default function ComplianceTab() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filters, setFilters] = useState({});
|
||||
const [visibleCols, setVisibleCols] = useState({
|
||||
timestamp: true,
|
||||
action: true,
|
||||
actor: true,
|
||||
details: true,
|
||||
});
|
||||
const notify = useNotificationStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/compliance/audit-log?limit=100")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setLogs(Array.isArray(data) ? data : data.logs || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
notify.error("Failed to load audit log");
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const actionOptions = [...new Set(logs.map((l) => l.action).filter(Boolean))];
|
||||
const actorOptions = [...new Set(logs.map((l) => l.actor).filter(Boolean))];
|
||||
|
||||
const filtered = logs.filter((l) => {
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const matchesSearch =
|
||||
l.action?.toLowerCase().includes(q) ||
|
||||
l.actor?.toLowerCase().includes(q) ||
|
||||
(l.details && JSON.stringify(l.details).toLowerCase().includes(q));
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
if (filters.action && l.action !== filters.action) return false;
|
||||
if (filters.actor && l.actor !== filters.actor) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const columns = ALL_COLUMNS.filter((c) => visibleCols[c.key]);
|
||||
|
||||
const handleToggleCol = useCallback((key) => {
|
||||
setVisibleCols((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
const renderCell = useCallback((row, col) => {
|
||||
switch (col.key) {
|
||||
case "timestamp":
|
||||
return (
|
||||
<span className="font-mono text-xs text-text-muted whitespace-nowrap">
|
||||
{row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"}
|
||||
</span>
|
||||
);
|
||||
case "action":
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
|
||||
{row.action || "—"}
|
||||
</span>
|
||||
);
|
||||
case "actor":
|
||||
return <span className="text-text-main">{row.actor || "system"}</span>;
|
||||
case "details":
|
||||
return (
|
||||
<span className="text-text-muted text-xs max-w-xs truncate block">
|
||||
{row.details ? JSON.stringify(row.details) : "—"}
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return row[col.key] || "—";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px]">policy</span>
|
||||
Audit Log
|
||||
</h3>
|
||||
<ColumnToggle columns={ALL_COLUMNS} visible={visibleCols} onToggle={handleToggleCol} />
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search audit logs..."
|
||||
filters={[
|
||||
{ key: "action", label: "Action", options: actionOptions },
|
||||
{ key: "actor", label: "Actor", options: actorOptions },
|
||||
]}
|
||||
activeFilters={filters}
|
||||
onFilterChange={(key, val) => setFilters((prev) => ({ ...prev, [key]: val }))}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
renderCell={renderCell}
|
||||
loading={loading}
|
||||
maxHeight="400px"
|
||||
emptyIcon="📋"
|
||||
emptyMessage="No audit events found"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FallbackChainsEditor — Batch D
|
||||
*
|
||||
* Editor for model fallback chains. Each chain maps a model name
|
||||
* to a prioritized list of providers that can serve it.
|
||||
* API: /api/fallback/chains
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, Input, EmptyState } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
const CHAIN_COLORS = [
|
||||
"#6366f1",
|
||||
"#22c55e",
|
||||
"#f59e0b",
|
||||
"#ef4444",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#ec4899",
|
||||
"#14b8a6",
|
||||
];
|
||||
|
||||
export default function FallbackChainsEditor() {
|
||||
const [chains, setChains] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [newProviders, setNewProviders] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const fetchChains = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/fallback/chains");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setChains(data.chains || data || {});
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChains();
|
||||
}, [fetchChains]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newModel.trim() || !newProviders.trim()) {
|
||||
notify.warning("Please fill model name and providers");
|
||||
return;
|
||||
}
|
||||
|
||||
const providers = newProviders
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.map((provider, i) => ({ provider, priority: i + 1, enabled: true }));
|
||||
|
||||
if (providers.length === 0) {
|
||||
notify.warning("Add at least one provider");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/fallback/chains", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: newModel.trim(), chain: providers }),
|
||||
});
|
||||
if (res.ok) {
|
||||
notify.success(`Chain created for ${newModel.trim()}`);
|
||||
setNewModel("");
|
||||
setNewProviders("");
|
||||
setShowCreate(false);
|
||||
await fetchChains();
|
||||
} else {
|
||||
notify.error("Failed to create chain");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to create chain");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (model) => {
|
||||
if (!confirm(`Delete fallback chain for "${model}"?`)) return;
|
||||
try {
|
||||
const res = await fetch("/api/fallback/chains", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
if (res.ok) {
|
||||
notify.success(`Chain deleted for ${model}`);
|
||||
await fetchChains();
|
||||
} else {
|
||||
notify.error("Failed to delete chain");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to delete chain");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<div className="flex items-center gap-2 text-text-muted animate-pulse">
|
||||
<span className="material-symbols-outlined text-[20px]">timeline</span>
|
||||
Loading fallback chains...
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const chainEntries = Object.entries(chains);
|
||||
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<div className="flex items-center gap-3 mb-4 p-6 pb-0">
|
||||
<div className="p-2 rounded-lg bg-cyan-500/10 text-cyan-500">
|
||||
<span className="material-symbols-outlined text-[20px]">timeline</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Fallback Chains</h3>
|
||||
<p className="text-sm text-text-muted">Define provider fallback order per model</p>
|
||||
</div>
|
||||
<Button size="sm" variant="primary" onClick={() => setShowCreate(!showCreate)}>
|
||||
{showCreate ? "Cancel" : "+ Add Chain"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreate && (
|
||||
<div className="mx-6 p-4 rounded-lg border border-border/30 bg-surface/20 mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
|
||||
<Input
|
||||
label="Model Name"
|
||||
placeholder="claude-sonnet-4-20250514"
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Providers (comma-separated, in priority order)"
|
||||
placeholder="anthropic, openai, gemini"
|
||||
value={newProviders}
|
||||
onChange={(e) => setNewProviders(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={handleCreate} loading={saving}>
|
||||
Create Chain
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chains List */}
|
||||
<div className="px-6 pb-6">
|
||||
{chainEntries.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="timeline"
|
||||
title="No Fallback Chains"
|
||||
description="Create a chain to define provider fallback order for a model."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{chainEntries.map(([model, chain]) => (
|
||||
<div
|
||||
key={model}
|
||||
className="flex items-center justify-between px-4 py-3 rounded-lg border border-border/20 bg-surface/20 hover:bg-surface/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span className="font-mono text-sm text-text-main truncate max-w-[200px]">
|
||||
{model}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{(Array.isArray(chain) ? chain : []).map((entry, i) => (
|
||||
<span
|
||||
key={`${entry.provider}-${i}`}
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{
|
||||
backgroundColor: `${CHAIN_COLORS[i % CHAIN_COLORS.length]}20`,
|
||||
color: CHAIN_COLORS[i % CHAIN_COLORS.length],
|
||||
border: `1px solid ${CHAIN_COLORS[i % CHAIN_COLORS.length]}40`,
|
||||
}}
|
||||
>
|
||||
{i + 1}. {entry.provider}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(model)}
|
||||
className="text-text-muted hover:text-red-400 transition-colors ml-2"
|
||||
title="Delete chain"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PoliciesPanel — Batch E
|
||||
*
|
||||
* Shows circuit breaker states and locked identifiers.
|
||||
* Allows force-unlocking locked identifiers.
|
||||
* API: /api/policies
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, EmptyState } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
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" },
|
||||
};
|
||||
|
||||
export default function PoliciesPanel() {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<div className="flex items-center gap-2 text-text-muted animate-pulse">
|
||||
<span className="material-symbols-outlined text-[20px]">security</span>
|
||||
Loading policies...
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const circuitBreakers = data?.circuitBreakers || [];
|
||||
const lockedIds = data?.lockedIdentifiers || [];
|
||||
const hasIssues = circuitBreakers.some((cb) => cb.state !== "closed") || lockedIds.length > 0;
|
||||
|
||||
if (!hasIssues) {
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold text-text-main">Policies & Circuit Breakers</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
All systems operational — no lockouts or tripped breakers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-red-500/10 text-red-500">
|
||||
<span className="material-symbols-outlined text-[20px]">gpp_maybe</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">Policies & Circuit Breakers</h3>
|
||||
<p className="text-sm text-text-muted">Active issues detected</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ProxyConfigModal } from "@/shared/components";
|
||||
|
||||
export default function ProxyTab() {
|
||||
const [proxyModalOpen, setProxyModalOpen] = useState(false);
|
||||
const [globalProxy, setGlobalProxy] = useState(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const loadGlobalProxy = async () => {
|
||||
try {
|
||||
@@ -18,7 +19,21 @@ export default function ProxyTab() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadGlobalProxy();
|
||||
mountedRef.current = true;
|
||||
async function init() {
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxy?level=global");
|
||||
if (!mountedRef.current) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (mountedRef.current) setGlobalProxy(data.proxy || null);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
init();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
// ─── State colors and labels ──────────────────────────────────────────────
|
||||
const STATE_STYLES = {
|
||||
CLOSED: {
|
||||
bg: "bg-emerald-500/15",
|
||||
text: "text-emerald-400",
|
||||
border: "border-emerald-500/30",
|
||||
label: "CLOSED",
|
||||
icon: "check_circle",
|
||||
},
|
||||
OPEN: {
|
||||
bg: "bg-red-500/15",
|
||||
text: "text-red-400",
|
||||
border: "border-red-500/30",
|
||||
label: "OPEN",
|
||||
icon: "error",
|
||||
},
|
||||
HALF_OPEN: {
|
||||
bg: "bg-amber-500/15",
|
||||
text: "text-amber-400",
|
||||
border: "border-amber-500/30",
|
||||
label: "HALF-OPEN",
|
||||
icon: "warning",
|
||||
},
|
||||
};
|
||||
|
||||
function formatMs(ms) {
|
||||
if (!ms || ms <= 0) return "—";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
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);
|
||||
const [draft, setDraft] = useState(profiles);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(profiles);
|
||||
}, [profiles]);
|
||||
|
||||
const fields = [
|
||||
{ key: "transientCooldown", label: "Transient Cooldown", suffix: "ms" },
|
||||
{ key: "rateLimitCooldown", label: "Rate Limit Cooldown", suffix: "ms" },
|
||||
{ key: "maxBackoffLevel", label: "Max Backoff Level", suffix: "" },
|
||||
{ key: "circuitBreakerThreshold", label: "CB Threshold", suffix: " fails" },
|
||||
{ key: "circuitBreakerReset", label: "CB Reset Time", suffix: "ms" },
|
||||
];
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(draft);
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
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">
|
||||
tune
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">Provider Profiles</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">
|
||||
Separate resilience settings for OAuth (session-based) and API Key (metered) providers.
|
||||
OAuth providers have stricter thresholds due to lower rate limits.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{["oauth", "apikey"].map((type) => (
|
||||
<div key={type} className="rounded-lg bg-black/5 dark:bg-white/5 p-4">
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base" aria-hidden="true">
|
||||
{type === "oauth" ? "lock" : "key"}
|
||||
</span>
|
||||
{type === "oauth" ? "OAuth Providers" : "API Key Providers"}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{fields.map(({ key, label, suffix }) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-muted">{label}</span>
|
||||
{editMode ? (
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={draft?.[type]?.[key] ?? 0}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
[type]: { ...draft[type], [key]: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
className="w-24 px-2 py-1 text-xs rounded bg-white/10 border border-white/20 text-right"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-mono">
|
||||
{profiles?.[type]?.[key] ?? "—"}
|
||||
{suffix && profiles?.[type]?.[key] != null ? suffix : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Rate Limit Overview Card ────────────────────────────────────────────
|
||||
function RateLimitOverviewCard({ rateLimitStatus, defaults }) {
|
||||
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>
|
||||
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
API Key providers are automatically rate-limited with safe defaults. Limits are learned
|
||||
from response headers and adapt over time.
|
||||
</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">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rateLimitStatus && rateLimitStatus.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-text-muted">
|
||||
Active Limiters
|
||||
</h3>
|
||||
{rateLimitStatus.map((rl, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-black/5 dark:bg-white/5"
|
||||
>
|
||||
<span className="text-sm font-medium">{rl.provider || rl.key}</span>
|
||||
<div className="flex items-center gap-3 text-xs text-text-muted">
|
||||
{rl.reservoir != null && <span>Reservoir: {rl.reservoir}</span>}
|
||||
{rl.running != null && <span>Running: {rl.running}</span>}
|
||||
{rl.queued != null && <span>Queued: {rl.queued}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-muted">No active rate limiters yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Resilience Tab ─────────────────────────────────────────────────
|
||||
export default function ResilienceTab() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/resilience");
|
||||
if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// Auto-refresh every 10s
|
||||
const interval = setInterval(loadData, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
|
||||
const handleResetBreakers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/resilience/reset", { method: "POST" });
|
||||
if (!res.ok) throw new Error("Reset failed");
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfiles = async (profiles) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const res = await fetch("/api/resilience", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profiles }),
|
||||
});
|
||||
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">
|
||||
<span className="material-symbols-outlined animate-spin mr-2">hourglass_empty</span>
|
||||
Loading resilience status...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" icon="refresh" onClick={loadData} className="mt-3">
|
||||
Retry
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<CircuitBreakerCard
|
||||
breakers={data?.circuitBreakers || []}
|
||||
onReset={handleResetBreakers}
|
||||
loading={loading}
|
||||
/>
|
||||
<ProviderProfilesCard
|
||||
profiles={data?.profiles || {}}
|
||||
onSave={handleSaveProfiles}
|
||||
saving={saving}
|
||||
/>
|
||||
<RateLimitOverviewCard
|
||||
rateLimitStatus={data?.rateLimitStatus || []}
|
||||
defaults={data?.defaults || {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,15 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Input, Toggle, Button } from "@/shared/components";
|
||||
import FallbackChainsEditor from "./FallbackChainsEditor";
|
||||
|
||||
const STRATEGIES = [
|
||||
{ value: "fill-first", label: "Fill First", desc: "Use accounts in priority order", icon: "vertical_align_top" },
|
||||
{
|
||||
value: "fill-first",
|
||||
label: "Fill First",
|
||||
desc: "Use accounts in priority order",
|
||||
icon: "vertical_align_top",
|
||||
},
|
||||
{ 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" },
|
||||
];
|
||||
@@ -90,7 +96,9 @@ export default function RoutingTab() {
|
||||
{s.icon}
|
||||
</span>
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}>
|
||||
<p
|
||||
className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}
|
||||
>
|
||||
{s.label}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{s.desc}</p>
|
||||
@@ -120,7 +128,8 @@ export default function RoutingTab() {
|
||||
<p className="text-xs text-text-muted italic pt-3 border-t border-border/30 mt-3">
|
||||
{settings.fallbackStrategy === "round-robin" &&
|
||||
`Distributing requests across accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.`}
|
||||
{settings.fallbackStrategy === "fill-first" && "Using accounts in priority order (Fill First)."}
|
||||
{settings.fallbackStrategy === "fill-first" &&
|
||||
"Using accounts in priority order (Fill First)."}
|
||||
{settings.fallbackStrategy === "p2c" &&
|
||||
"Power of Two Choices: picks 2 random accounts and routes to the healthier one."}
|
||||
</p>
|
||||
@@ -136,7 +145,9 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Model Aliases</h3>
|
||||
<p className="text-sm text-text-muted">Wildcard patterns to remap model names • Use * and ?</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Wildcard patterns to remap model names • Use * and ?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +160,9 @@ export default function RoutingTab() {
|
||||
>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="font-mono text-purple-400">{a.pattern}</span>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">arrow_forward</span>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<span className="font-mono text-text-main">{a.target}</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -185,6 +198,9 @@ export default function RoutingTab() {
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Fallback Chains */}
|
||||
<FallbackChainsEditor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Input, Toggle } from "@/shared/components";
|
||||
import IPFilterSection from "./IPFilterSection";
|
||||
import PoliciesPanel from "./PoliciesPanel";
|
||||
|
||||
export default function SecurityTab() {
|
||||
const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false });
|
||||
@@ -146,6 +147,7 @@ export default function SecurityTab() {
|
||||
</div>
|
||||
</Card>
|
||||
<IPFilterSection />
|
||||
<PoliciesPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@ import ProxyTab from "./components/ProxyTab";
|
||||
import AppearanceTab from "./components/AppearanceTab";
|
||||
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
|
||||
import SystemPromptTab from "./components/SystemPromptTab";
|
||||
import PricingTab from "./components/PricingTab";
|
||||
import ComplianceTab from "./components/ComplianceTab";
|
||||
import CacheStatsCard from "./components/CacheStatsCard";
|
||||
import ResilienceTab from "./components/ResilienceTab";
|
||||
|
||||
const tabs = [
|
||||
{ id: "general", label: "General", icon: "settings" },
|
||||
{ id: "ai", label: "AI", icon: "smart_toy" },
|
||||
{ id: "security", label: "Security", icon: "shield" },
|
||||
{ id: "routing", label: "Routing", icon: "route" },
|
||||
{ id: "pricing", label: "Pricing", icon: "payments" },
|
||||
{ id: "resilience", label: "Resilience", icon: "electrical_services" },
|
||||
{ id: "advanced", label: "Advanced", icon: "tune" },
|
||||
{ id: "compliance", label: "Compliance", icon: "policy" },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -83,9 +86,16 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "pricing" && <PricingTab />}
|
||||
{activeTab === "resilience" && <ResilienceTab />}
|
||||
|
||||
{activeTab === "advanced" && <ProxyTab />}
|
||||
{activeTab === "advanced" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProxyTab />
|
||||
<CacheStatsCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "compliance" && <ComplianceTab />}
|
||||
</div>
|
||||
|
||||
{/* App Info */}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function TranslatorPageClient() {
|
||||
Translator Playground
|
||||
</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Debug, test, and visualize API format translations
|
||||
Debug, test, and visualize how OmniRoute translates API requests between providers
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedControl options={MODES} value={mode} onChange={setMode} size="md" />
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
import { useProviderOptions } from "../hooks/useProviderOptions";
|
||||
import { useAvailableModels } from "../hooks/useAvailableModels";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
@@ -16,20 +13,18 @@ const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
* Chat Tester Mode:
|
||||
* - Left: Chat interface (send messages as a specific client format)
|
||||
* - Right: Pipeline visualization showing each translation step
|
||||
*
|
||||
* How it works:
|
||||
* 1. You type a message and select a "Client Format" (how the request is structured)
|
||||
* 2. The message is built into a request body matching the client format
|
||||
* 3. OmniRoute detects the format, translates it through the pipeline, and sends to the provider
|
||||
* 4. Each pipeline step is shown on the right: Client → Detect → OpenAI → Provider → Response
|
||||
*/
|
||||
const DEFAULT_MODELS = {
|
||||
openai: "gpt-4o",
|
||||
claude: "claude-sonnet-4-20250514",
|
||||
gemini: "gemini-2.5-flash",
|
||||
"openai-responses": "gpt-4o",
|
||||
};
|
||||
|
||||
export default function ChatTesterMode() {
|
||||
const [provider, setProvider] = useState("openai");
|
||||
const [providerOptions, setProviderOptions] = useState([]);
|
||||
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
|
||||
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
|
||||
const [clientFormat, setClientFormat] = useState("openai");
|
||||
const [model, setModel] = useState(DEFAULT_MODELS.openai);
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [chatHistory, setChatHistory] = useState([]);
|
||||
@@ -37,79 +32,11 @@ export default function ChatTesterMode() {
|
||||
const [expandedStep, setExpandedStep] = useState(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
// Update default model when client format changes
|
||||
// Pick a smart default model when format changes or models finish loading
|
||||
useEffect(() => {
|
||||
setModel(DEFAULT_MODELS[clientFormat] || "gpt-4o");
|
||||
}, [clientFormat]);
|
||||
|
||||
// Load available models
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/models");
|
||||
const data = await res.json();
|
||||
const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b));
|
||||
setAvailableModels(models);
|
||||
} catch {
|
||||
setAvailableModels([]);
|
||||
}
|
||||
};
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
// Load providers
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const [connRes, nodesRes] = await Promise.all([
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/provider-nodes"),
|
||||
]);
|
||||
const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]);
|
||||
const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n]));
|
||||
const activeProviders = new Set(
|
||||
(connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider)
|
||||
);
|
||||
const options = [...activeProviders]
|
||||
.map((pid) => {
|
||||
const info = AI_PROVIDERS[pid];
|
||||
const node = nodeMap.get(pid);
|
||||
let label = info?.name || node?.name || pid;
|
||||
if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "OpenAI Compatible";
|
||||
if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "Anthropic Compatible";
|
||||
return { value: pid, label };
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const nextOptions =
|
||||
options.length > 0
|
||||
? options
|
||||
: Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name }));
|
||||
setProviderOptions(nextOptions);
|
||||
if (nextOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({
|
||||
value: id,
|
||||
label: info.name,
|
||||
}));
|
||||
setProviderOptions(fallbackOptions);
|
||||
if (fallbackOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
fallbackOptions.some((opt) => opt.value === current)
|
||||
? current
|
||||
: fallbackOptions[0].value
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchProviders();
|
||||
}, []);
|
||||
const picked = pickModelForFormat(clientFormat);
|
||||
if (picked) setModel(picked);
|
||||
}, [clientFormat, pickModelForFormat, setModel]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -170,6 +97,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 1,
|
||||
name: "Client Request",
|
||||
description: "The request body as your client would send it",
|
||||
format: clientFormat,
|
||||
content: JSON.stringify(clientRequest, null, 2),
|
||||
status: "done",
|
||||
@@ -187,6 +115,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 2,
|
||||
name: "Format Detected",
|
||||
description: "OmniRoute auto-detects the API format from the request structure",
|
||||
format: detectedFormat,
|
||||
content: JSON.stringify(
|
||||
{ detectedFormat, clientFormat, match: detectedFormat === clientFormat },
|
||||
@@ -212,6 +141,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 3,
|
||||
name: "OpenAI Intermediate",
|
||||
description: "All formats are first normalized to OpenAI format (the universal bridge)",
|
||||
format: "openai",
|
||||
content: JSON.stringify(toOpenaiData.result || toOpenaiData, null, 2),
|
||||
status: toOpenaiData.success ? "done" : "error",
|
||||
@@ -234,12 +164,13 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 4,
|
||||
name: "Provider Format",
|
||||
description: `OpenAI format is translated to the provider's native format`,
|
||||
format: targetFmt,
|
||||
content: JSON.stringify(providerTargetData.result || providerTargetData, null, 2),
|
||||
status: providerTargetData.success ? "done" : "error",
|
||||
});
|
||||
|
||||
// Step 5: Send to provider (use the OpenAI intermediate since the proxy handles translation)
|
||||
// Step 5: Send to provider
|
||||
const sendRes = await fetch("/api/translator/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -251,6 +182,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 5,
|
||||
name: "Provider Response",
|
||||
description: "The raw response from the provider API",
|
||||
format: targetFmt,
|
||||
content: JSON.stringify(errData, null, 2),
|
||||
status: "error",
|
||||
@@ -274,6 +206,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 5,
|
||||
name: "Provider Response",
|
||||
description: "The raw SSE stream from the provider API",
|
||||
format: targetFmt,
|
||||
content:
|
||||
fullResponse.slice(0, 5000) + (fullResponse.length > 5000 ? "\n... (truncated)" : ""),
|
||||
@@ -291,6 +224,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: steps.length + 1,
|
||||
name: "Error",
|
||||
description: "An unexpected error occurred",
|
||||
format: "error",
|
||||
content: JSON.stringify({ error: err.message }, null, 2),
|
||||
status: "error",
|
||||
@@ -305,229 +239,269 @@ export default function ChatTesterMode() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: Chat Interface */}
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Client Format
|
||||
</label>
|
||||
<Select
|
||||
value={clientFormat}
|
||||
onChange={(e) => setClientFormat(e.target.value)}
|
||||
options={FORMAT_OPTIONS.filter((o) =>
|
||||
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
options={providerOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
list="model-suggestions"
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
|
||||
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
<datalist id="model-suggestions">
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<Card className="min-h-[400px] flex flex-col">
|
||||
<div className="p-4 flex-1 overflow-y-auto max-h-[500px] space-y-3">
|
||||
{chatHistory.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted py-12">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">chat</span>
|
||||
<p className="text-sm">Send a message to see the translation pipeline</p>
|
||||
</div>
|
||||
)}
|
||||
{chatHistory.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary/10 text-text-main border border-primary/20"
|
||||
: "bg-bg-subtle text-text-main border border-border"
|
||||
}`}
|
||||
>
|
||||
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
|
||||
{msg.role === "user"
|
||||
? `You (${FORMAT_META[clientFormat]?.label})`
|
||||
: "Assistant"}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
disabled={sending}
|
||||
/>
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
loading={sending}
|
||||
disabled={!message.trim() || sending}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">Pipeline Debugger</p>
|
||||
<p>
|
||||
Send messages as a specific client format and see how each step of the translation
|
||||
pipeline works. The right panel shows the full flow:{" "}
|
||||
<strong className="text-text-main">
|
||||
Client Request → Format Detection → OpenAI Intermediate → Provider Format → Response
|
||||
</strong>
|
||||
. Click any step to inspect the data at that stage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Pipeline Visualization */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">
|
||||
account_tree
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Translation Pipeline</h3>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">Click on any step to inspect the data</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!pipeline ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: Chat Interface */}
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-8 flex flex-col items-center justify-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
|
||||
account_tree
|
||||
</span>
|
||||
<p className="text-sm">Send a message to see the pipeline</p>
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Client Format
|
||||
</label>
|
||||
<Select
|
||||
value={clientFormat}
|
||||
onChange={(e) => setClientFormat(e.target.value)}
|
||||
options={FORMAT_OPTIONS.filter((o) =>
|
||||
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
options={providerOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
list="model-suggestions"
|
||||
placeholder="Select or type a model name..."
|
||||
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
<datalist id="model-suggestions">
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pipeline.map((step, i) => {
|
||||
const meta = FORMAT_META[step.format] || {
|
||||
label: step.format,
|
||||
color: "gray",
|
||||
icon: "code",
|
||||
};
|
||||
const isExpanded = expandedStep === step.id;
|
||||
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{/* Connector line */}
|
||||
{i > 0 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={
|
||||
step.status === "error"
|
||||
? "border-red-500/30"
|
||||
: isExpanded
|
||||
? "border-primary/30"
|
||||
: ""
|
||||
}
|
||||
{/* Chat Messages */}
|
||||
<Card className="min-h-[400px] flex flex-col">
|
||||
<div className="p-4 flex-1 overflow-y-auto max-h-[500px] space-y-3">
|
||||
{chatHistory.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted py-12">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
|
||||
chat
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">
|
||||
Send a message to see the translation pipeline
|
||||
</p>
|
||||
<p className="text-xs text-center max-w-xs">
|
||||
Your message will be formatted as a{" "}
|
||||
<strong>{FORMAT_META[clientFormat]?.label}</strong> request, translated through
|
||||
the pipeline, and sent to the selected provider.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{chatHistory.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary/10 text-text-main border border-primary/20"
|
||||
: "bg-bg-subtle text-text-main border border-border"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
|
||||
className="w-full p-3 flex items-center gap-3 text-left"
|
||||
>
|
||||
{/* Step number */}
|
||||
<div
|
||||
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
|
||||
step.status === "error"
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: step.status === "done"
|
||||
? `bg-${meta.color}-500/10 text-${meta.color}-500`
|
||||
: "bg-bg-subtle text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{step.status === "error" ? "!" : step.id}
|
||||
</div>
|
||||
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
|
||||
{msg.role === "user"
|
||||
? `You (${FORMAT_META[clientFormat]?.label})`
|
||||
: "Assistant"}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Step info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-main">{step.name}</p>
|
||||
</div>
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
disabled={sending}
|
||||
/>
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
loading={sending}
|
||||
disabled={!message.trim() || sending}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Format badge */}
|
||||
<Badge variant={step.status === "error" ? "error" : "default"} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{/* Right: Pipeline Visualization */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">
|
||||
account_tree
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Translation Pipeline</h3>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Click on any step to inspect the data at that stage
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Expand icon */}
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{isExpanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
{!pipeline ? (
|
||||
<Card>
|
||||
<div className="p-8 flex flex-col items-center justify-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
|
||||
account_tree
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">Pipeline visualization</p>
|
||||
<p className="text-xs text-center max-w-xs">
|
||||
Send a message to see how your request flows through detection → translation →
|
||||
provider call.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pipeline.map((step, i) => {
|
||||
const meta = FORMAT_META[step.format] || {
|
||||
label: step.format,
|
||||
color: "gray",
|
||||
icon: "code",
|
||||
};
|
||||
const isExpanded = expandedStep === step.id;
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="250px"
|
||||
defaultLanguage="json"
|
||||
value={step.content}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 11,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{/* Connector line */}
|
||||
{i > 0 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={
|
||||
step.status === "error"
|
||||
? "border-red-500/30"
|
||||
: isExpanded
|
||||
? "border-primary/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
|
||||
className="w-full p-3 flex items-center gap-3 text-left"
|
||||
>
|
||||
{/* Step number */}
|
||||
<div
|
||||
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
|
||||
step.status === "error"
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: step.status === "done"
|
||||
? `bg-${meta.color}-500/10 text-${meta.color}-500`
|
||||
: "bg-bg-subtle text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{step.status === "error" ? "!" : step.id}
|
||||
</div>
|
||||
|
||||
{/* Step info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-main">{step.name}</p>
|
||||
{step.description && (
|
||||
<p className="text-[10px] text-text-muted truncate">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Format badge */}
|
||||
<Badge variant={step.status === "error" ? "error" : "default"} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
|
||||
{/* Expand icon */}
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{isExpanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="250px"
|
||||
defaultLanguage="json"
|
||||
value={step.content}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 11,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,6 +49,23 @@ export default function LiveMonitorMode() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">Real-Time Translation Activity</p>
|
||||
<p>
|
||||
Shows translation events as API calls flow through OmniRoute. Events come from the
|
||||
in-memory buffer (resets on restart). Use{" "}
|
||||
<strong className="text-text-main">Chat Tester</strong>,{" "}
|
||||
<strong className="text-text-main">Test Bench</strong>, or external API calls to
|
||||
generate events.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard icon="translate" label="Total Translations" value={events.length} color="blue" />
|
||||
@@ -99,11 +116,26 @@ export default function LiveMonitorMode() {
|
||||
monitoring
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">No translations yet</p>
|
||||
<p className="text-xs">
|
||||
Translations will appear here as requests flow through the proxy.
|
||||
<p className="text-xs text-center max-w-sm">
|
||||
Translation events appear here as requests flow through OmniRoute. Use any of these
|
||||
methods to generate events:
|
||||
</p>
|
||||
<p className="text-xs mt-2">
|
||||
Make API calls to your OmniRoute endpoints to see live translation data.
|
||||
<div className="flex flex-wrap gap-2 mt-3 text-xs">
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
Chat Tester tab
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
Test Bench tab
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
External API calls
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
IDE/CLI integrations
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] mt-3 text-text-muted/70">
|
||||
Note: Events are stored in-memory and reset when the server restarts.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -65,12 +65,6 @@ export default function PlaygroundMode() {
|
||||
step: "direct",
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
provider:
|
||||
targetFormat === "claude"
|
||||
? "anthropic"
|
||||
: targetFormat === "gemini"
|
||||
? "google"
|
||||
: "openai",
|
||||
body: parsed,
|
||||
}),
|
||||
});
|
||||
@@ -114,6 +108,20 @@ export default function PlaygroundMode() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">Format Converter</p>
|
||||
<p>
|
||||
Paste or type a JSON request body. The translator will auto-detect the source format and
|
||||
convert it to the target format. Use this to debug how OmniRoute translates requests
|
||||
between formats (OpenAI ↔ Claude ↔ Gemini ↔ Responses API).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Format Controls Bar */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col sm:flex-row items-center gap-4">
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { EXAMPLE_TEMPLATES, FORMAT_META } from "../exampleTemplates";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
import { useProviderOptions } from "../hooks/useProviderOptions";
|
||||
import { useAvailableModels } from "../hooks/useAvailableModels";
|
||||
|
||||
/**
|
||||
* Test Bench Mode:
|
||||
* Run translation + send scenarios between providers to validate compatibility.
|
||||
*
|
||||
* How it works:
|
||||
* Predefined scenarios (Simple Chat, Tool Calling, etc.) are loaded from example templates,
|
||||
* translated from the source format to the target provider, and sent to the provider API.
|
||||
* Results show pass/fail, latency, and chunk count, with a compatibility percentage.
|
||||
*/
|
||||
|
||||
const SCENARIOS = [
|
||||
@@ -23,92 +25,18 @@ const SCENARIOS = [
|
||||
{ id: "streaming", name: "Streaming", icon: "stream", templateId: "streaming" },
|
||||
];
|
||||
|
||||
const DEFAULT_MODELS = {
|
||||
openai: "gpt-4o",
|
||||
claude: "claude-sonnet-4-20250514",
|
||||
gemini: "gemini-2.5-flash",
|
||||
};
|
||||
|
||||
export default function TestBenchMode() {
|
||||
const [sourceFormat, setSourceFormat] = useState("claude");
|
||||
const [provider, setProvider] = useState("openai");
|
||||
const [providerOptions, setProviderOptions] = useState([]);
|
||||
const [model, setModel] = useState(DEFAULT_MODELS.claude);
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
|
||||
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
|
||||
const [results, setResults] = useState({});
|
||||
const [runningAll, setRunningAll] = useState(false);
|
||||
|
||||
// Update default model when source format changes
|
||||
// Pick a smart default model when source format changes or models finish loading
|
||||
useEffect(() => {
|
||||
setModel(DEFAULT_MODELS[sourceFormat] || "gpt-4o");
|
||||
}, [sourceFormat]);
|
||||
|
||||
// Load available models
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/models");
|
||||
const data = await res.json();
|
||||
const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b));
|
||||
setAvailableModels(models);
|
||||
} catch {
|
||||
setAvailableModels([]);
|
||||
}
|
||||
};
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const [connRes, nodesRes] = await Promise.all([
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/provider-nodes"),
|
||||
]);
|
||||
const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]);
|
||||
const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n]));
|
||||
const activeProviders = new Set(
|
||||
(connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider)
|
||||
);
|
||||
const options = [...activeProviders]
|
||||
.map((pid) => {
|
||||
const info = AI_PROVIDERS[pid];
|
||||
const node = nodeMap.get(pid);
|
||||
let label = info?.name || node?.name || pid;
|
||||
if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "OpenAI Compatible";
|
||||
if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "Anthropic Compatible";
|
||||
return { value: pid, label };
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
const nextOptions =
|
||||
options.length > 0
|
||||
? options
|
||||
: Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name }));
|
||||
setProviderOptions(nextOptions);
|
||||
if (nextOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({
|
||||
value: id,
|
||||
label: info.name,
|
||||
}));
|
||||
setProviderOptions(fallbackOptions);
|
||||
if (fallbackOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
fallbackOptions.some((opt) => opt.value === current)
|
||||
? current
|
||||
: fallbackOptions[0].value
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchProviders();
|
||||
}, []);
|
||||
const picked = pickModelForFormat(sourceFormat);
|
||||
if (picked) setModel(picked);
|
||||
}, [sourceFormat, pickModelForFormat, setModel]);
|
||||
|
||||
const runScenario = async (scenario) => {
|
||||
setResults((prev) => ({ ...prev, [scenario.id]: { status: "running" } }));
|
||||
@@ -213,6 +141,22 @@ export default function TestBenchMode() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">Compatibility Tester</p>
|
||||
<p>
|
||||
Run predefined scenarios (Simple Chat, Tool Calling, etc.) to verify translation and
|
||||
provider compatibility. Select a source format and target provider, then run all tests
|
||||
to see a compatibility percentage. Use this to find which features work across
|
||||
providers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
@@ -227,11 +171,9 @@ export default function TestBenchMode() {
|
||||
setSourceFormat(e.target.value);
|
||||
setResults({});
|
||||
}}
|
||||
options={[
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "claude", label: "Claude" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
]}
|
||||
options={FORMAT_OPTIONS.filter((o) =>
|
||||
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center px-2">
|
||||
@@ -271,7 +213,7 @@ export default function TestBenchMode() {
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
list="testbench-model-suggestions"
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
|
||||
placeholder="Select or type a model name..."
|
||||
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
<datalist id="testbench-model-suggestions">
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Prefix-based format→model matching, used to pick a smart default
|
||||
* model from the available models list when the user changes format.
|
||||
*/
|
||||
const FORMAT_MODEL_PREFIXES = {
|
||||
openai: ["gpt-", "o1-", "o3-", "o4-"],
|
||||
"openai-responses": ["gpt-", "o1-", "o3-", "o4-"],
|
||||
claude: ["claude-"],
|
||||
gemini: ["gemini-"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch available models and provide smart default selection.
|
||||
*
|
||||
* @returns {{
|
||||
* model: string,
|
||||
* setModel: Function,
|
||||
* availableModels: string[],
|
||||
* loading: boolean,
|
||||
* pickModelForFormat: (format: string) => string
|
||||
* }}
|
||||
*/
|
||||
export function useAvailableModels() {
|
||||
const [model, setModel] = useState("");
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/models");
|
||||
const data = await res.json();
|
||||
const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b));
|
||||
setAvailableModels(models);
|
||||
} catch {
|
||||
setAvailableModels([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Pick the best model for a given format from the available models.
|
||||
* Returns the first model matching the format prefixes, or the first available model.
|
||||
*/
|
||||
const pickModelForFormat = useCallback(
|
||||
(format) => {
|
||||
if (availableModels.length === 0) return "";
|
||||
const prefixes = FORMAT_MODEL_PREFIXES[format] || [];
|
||||
for (const prefix of prefixes) {
|
||||
const match = availableModels.find((m) => m.startsWith(prefix));
|
||||
if (match) return match;
|
||||
}
|
||||
return availableModels[0];
|
||||
},
|
||||
[availableModels]
|
||||
);
|
||||
|
||||
return { model, setModel, availableModels, loading, pickModelForFormat };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage provider options for the Translator tools.
|
||||
* Fetches active providers from the API and builds a sorted list of options.
|
||||
* Falls back to the static AI_PROVIDERS list if the API is unreachable.
|
||||
*
|
||||
* @param {string} [initialProvider="openai"] - Initial provider value
|
||||
* @returns {{ provider: string, setProvider: Function, providerOptions: Array<{value: string, label: string}>, loading: boolean }}
|
||||
*/
|
||||
export function useProviderOptions(initialProvider = "openai") {
|
||||
const [provider, setProvider] = useState(initialProvider);
|
||||
const [providerOptions, setProviderOptions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const [connRes, nodesRes] = await Promise.all([
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/provider-nodes"),
|
||||
]);
|
||||
const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]);
|
||||
const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n]));
|
||||
const activeProviders = new Set(
|
||||
(connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider)
|
||||
);
|
||||
const options = [...activeProviders]
|
||||
.map((pid) => {
|
||||
const info = AI_PROVIDERS[pid];
|
||||
const node = nodeMap.get(pid);
|
||||
let label = info?.name || node?.name || pid;
|
||||
if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "OpenAI Compatible";
|
||||
if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "Anthropic Compatible";
|
||||
return { value: pid, label };
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const nextOptions =
|
||||
options.length > 0
|
||||
? options
|
||||
: Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name }));
|
||||
setProviderOptions(nextOptions);
|
||||
if (nextOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({
|
||||
value: id,
|
||||
label: info.name,
|
||||
}));
|
||||
setProviderOptions(fallbackOptions);
|
||||
if (fallbackOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
fallbackOptions.some((opt) => opt.value === current)
|
||||
? current
|
||||
: fallbackOptions[0].value
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProviders();
|
||||
}, []);
|
||||
|
||||
return { provider, setProvider, providerOptions, loading };
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* BudgetTab — Batch C
|
||||
*
|
||||
* Budget management for API keys — set daily/monthly limits,
|
||||
* view current spend, and monitor warning thresholds.
|
||||
* API: /api/usage/budget
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, Input, EmptyState } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
function ProgressBar({ value, max, warningAt = 0.8 }) {
|
||||
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
|
||||
const ratio = max > 0 ? value / max : 0;
|
||||
const color = ratio >= 1 ? "#ef4444" : ratio >= warningAt ? "#f59e0b" : "#22c55e";
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-text-muted">${value.toFixed(2)}</span>
|
||||
<span className="text-text-muted">${max.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-surface/50 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BudgetTab() {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [selectedKey, setSelectedKey] = useState(null);
|
||||
const [budget, setBudget] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
dailyLimitUsd: "",
|
||||
monthlyLimitUsd: "",
|
||||
warningThreshold: "80",
|
||||
});
|
||||
const notify = useNotificationStore();
|
||||
|
||||
// Load API keys
|
||||
useEffect(() => {
|
||||
fetch("/api/keys")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const keyList = Array.isArray(data) ? data : data.keys || [];
|
||||
setKeys(keyList);
|
||||
if (keyList.length > 0) setSelectedKey(keyList[0].id);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Load budget for selected key
|
||||
const fetchBudget = useCallback(async () => {
|
||||
if (!selectedKey) return;
|
||||
try {
|
||||
const res = await fetch(`/api/usage/budget?apiKeyId=${selectedKey}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBudget(data);
|
||||
if (data.dailyLimitUsd)
|
||||
setForm((f) => ({ ...f, dailyLimitUsd: String(data.dailyLimitUsd) }));
|
||||
if (data.monthlyLimitUsd)
|
||||
setForm((f) => ({ ...f, monthlyLimitUsd: String(data.monthlyLimitUsd) }));
|
||||
if (data.warningThreshold)
|
||||
setForm((f) => ({ ...f, warningThreshold: String(data.warningThreshold) }));
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, [selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBudget();
|
||||
}, [fetchBudget]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/usage/budget", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
apiKeyId: selectedKey,
|
||||
dailyLimitUsd: form.dailyLimitUsd ? parseFloat(form.dailyLimitUsd) : null,
|
||||
monthlyLimitUsd: form.monthlyLimitUsd ? parseFloat(form.monthlyLimitUsd) : null,
|
||||
warningThreshold: parseInt(form.warningThreshold) || 80,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
notify.success("Budget limits saved");
|
||||
await fetchBudget();
|
||||
} else {
|
||||
notify.error("Failed to save budget");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to save budget");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-text-muted p-8 animate-pulse">
|
||||
<span className="material-symbols-outlined text-[20px]">account_balance_wallet</span>
|
||||
Loading budget data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="vpn_key"
|
||||
title="No API Keys"
|
||||
description="Add API keys first to set up budget limits."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dailyLimit = budget?.dailyLimitUsd || parseFloat(form.dailyLimitUsd) || 0;
|
||||
const monthlyLimit = budget?.monthlyLimitUsd || parseFloat(form.monthlyLimitUsd) || 0;
|
||||
const dailyCost = budget?.totalCostToday || 0;
|
||||
const monthlyCost = budget?.totalCostMonth || 0;
|
||||
const warnPct = (parseInt(form.warningThreshold) || 80) / 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Key Selector */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
<span className="material-symbols-outlined text-[20px]">account_balance_wallet</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Budget Management</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-sm text-text-muted mb-1 block">API Key</label>
|
||||
<select
|
||||
value={selectedKey || ""}
|
||||
onChange={(e) => setSelectedKey(e.target.value)}
|
||||
className="w-full md:w-auto px-3 py-2 rounded-lg border border-border/50 bg-surface/30 text-text-main text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{keys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name || k.id} {k.provider ? `(${k.provider})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Current Spend */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
|
||||
<p className="text-sm text-text-muted mb-2">Today's Spend</p>
|
||||
<p className="text-2xl font-bold text-text-main">${dailyCost.toFixed(2)}</p>
|
||||
{dailyLimit > 0 && (
|
||||
<ProgressBar value={dailyCost} max={dailyLimit} warningAt={warnPct} />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
|
||||
<p className="text-sm text-text-muted mb-2">This Month</p>
|
||||
<p className="text-2xl font-bold text-text-main">${monthlyCost.toFixed(2)}</p>
|
||||
{monthlyLimit > 0 && (
|
||||
<ProgressBar value={monthlyCost} max={monthlyLimit} warningAt={warnPct} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget Form */}
|
||||
<div className="border-t border-border/30 pt-4">
|
||||
<p className="text-sm font-medium mb-3">Set Limits</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<Input
|
||||
label="Daily Limit (USD)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="e.g. 5.00"
|
||||
value={form.dailyLimitUsd}
|
||||
onChange={(e) => setForm({ ...form, dailyLimitUsd: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Monthly Limit (USD)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="e.g. 50.00"
|
||||
value={form.monthlyLimitUsd}
|
||||
onChange={(e) => setForm({ ...form, monthlyLimitUsd: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Warning Threshold (%)"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
placeholder="80"
|
||||
value={form.warningThreshold}
|
||||
onChange={(e) => setForm({ ...form, warningThreshold: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleSave} loading={saving}>
|
||||
Save Limits
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Budget Check Status */}
|
||||
{budget?.budgetCheck && (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px]"
|
||||
style={{ color: budget.budgetCheck.allowed ? "#22c55e" : "#ef4444" }}
|
||||
>
|
||||
{budget.budgetCheck.allowed ? "check_circle" : "block"}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
{budget.budgetCheck.allowed
|
||||
? `Budget OK — $${(budget.budgetCheck.remaining || 0).toFixed(2)} remaining`
|
||||
: "Budget exceeded — requests may be blocked"}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export default function BudgetTelemetryCards() {
|
||||
const [telemetry, setTelemetry] = useState(null);
|
||||
const [cache, setCache] = useState(null);
|
||||
const [policies, setPolicies] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
fetch("/api/telemetry/summary").then((r) => r.json()),
|
||||
fetch("/api/cache/stats").then((r) => r.json()),
|
||||
fetch("/api/policies").then((r) => r.json()),
|
||||
]).then(([t, c, p]) => {
|
||||
if (t.status === "fulfilled") setTelemetry(t.value);
|
||||
if (c.status === "fulfilled") setCache(c.value);
|
||||
if (p.status === "fulfilled") setPolicies(p.value);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fmt = (ms) => (ms != null ? `${Math.round(ms)}ms` : "—");
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||
{/* Latency Card */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">speed</span>
|
||||
Latency
|
||||
</h3>
|
||||
{telemetry ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">p50</span>
|
||||
<span className="font-mono">{fmt(telemetry.p50)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">p95</span>
|
||||
<span className="font-mono">{fmt(telemetry.p95)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">p99</span>
|
||||
<span className="font-mono">{fmt(telemetry.p99)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-border pt-2 mt-2">
|
||||
<span className="text-text-muted">Total requests</span>
|
||||
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No data yet</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Cache Card */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">cached</span>
|
||||
Prompt Cache
|
||||
</h3>
|
||||
{cache ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Entries</span>
|
||||
<span className="font-mono">
|
||||
{cache.size}/{cache.maxSize}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Hit Rate</span>
|
||||
<span className="font-mono">{cache.hitRate?.toFixed(1) ?? 0}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Hits / Misses</span>
|
||||
<span className="font-mono">
|
||||
{cache.hits ?? 0} / {cache.misses ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No data yet</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* System Health Card */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">monitor_heart</span>
|
||||
System Health
|
||||
</h3>
|
||||
{policies ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Circuit Breakers</span>
|
||||
<span className="font-mono">{policies.circuitBreakers?.length ?? 0} active</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Locked IPs</span>
|
||||
<span className="font-mono">{policies.lockedIdentifiers?.length ?? 0}</span>
|
||||
</div>
|
||||
{policies.circuitBreakers?.some((cb) => cb.state === "OPEN") && (
|
||||
<div className="mt-2 px-2 py-1 rounded bg-red-500/10 text-red-400 text-xs">
|
||||
⚠ Open circuit breakers detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No data yet</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EvalsTab — Batch F
|
||||
*
|
||||
* Lists evaluation suites, runs evals against real LLM endpoints,
|
||||
* and shows results.
|
||||
* API: GET/POST /api/evals, GET /api/evals/[suiteId]
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, EmptyState, DataTable, FilterBar } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
export default function EvalsTab() {
|
||||
const [suites, setSuites] = useState([]);
|
||||
const [apiKey, setApiKey] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(null);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||
const [results, setResults] = useState({});
|
||||
const [search, setSearch] = useState("");
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const fetchSuites = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/evals");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSuites(Array.isArray(data) ? data : data.suites || []);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchApiKey = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/keys");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const firstKey = data?.keys?.[0]?.key || null;
|
||||
setApiKey(firstKey);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuites();
|
||||
fetchApiKey();
|
||||
}, [fetchSuites, fetchApiKey]);
|
||||
|
||||
/**
|
||||
* Call the proxy LLM endpoint for a single eval case.
|
||||
* Returns the assistant's response text.
|
||||
*/
|
||||
const callLLM = async (evalCase) => {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
|
||||
const res = await fetch("/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: evalCase.model || "gpt-4o",
|
||||
messages: evalCase.input?.messages || [],
|
||||
max_tokens: 512,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return `[ERROR: HTTP ${res.status}]`;
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.choices?.[0]?.message?.content || "[No content returned]";
|
||||
} catch (err) {
|
||||
return `[ERROR: ${err.message}]`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run all cases: call LLM for each, then submit outputs for evaluation.
|
||||
*/
|
||||
const handleRunEval = async (suite) => {
|
||||
const cases = suite.cases || [];
|
||||
if (cases.length === 0) {
|
||||
notify.warning("No test cases defined for this suite");
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(suite.id);
|
||||
setProgress({ current: 0, total: cases.length });
|
||||
|
||||
try {
|
||||
// Step 1: Call LLM for each case and collect outputs
|
||||
const outputs = {};
|
||||
for (let i = 0; i < cases.length; i++) {
|
||||
setProgress({ current: i + 1, total: cases.length });
|
||||
const response = await callLLM(cases[i]);
|
||||
outputs[cases[i].id] = response;
|
||||
}
|
||||
|
||||
// Step 2: Submit outputs for evaluation
|
||||
const res = await fetch("/api/evals", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
suiteId: suite.id,
|
||||
outputs,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setResults((prev) => ({ ...prev, [suite.id]: data }));
|
||||
|
||||
// Notify with results
|
||||
if (data.summary) {
|
||||
const { passed, failed, total } = data.summary;
|
||||
if (failed === 0) {
|
||||
notify.success(`All ${total} cases passed ✅`, `Eval: ${suite.name}`);
|
||||
} else {
|
||||
notify.warning(`${passed}/${total} passed, ${failed} failed`, `Eval: ${suite.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-expand to show results
|
||||
setExpanded(suite.id);
|
||||
} catch {
|
||||
notify.error("Eval run failed");
|
||||
} finally {
|
||||
setRunning(null);
|
||||
setProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = suites.filter((s) => {
|
||||
if (!search) return true;
|
||||
return (
|
||||
s.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.id?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-text-muted p-8 animate-pulse">
|
||||
<span className="material-symbols-outlined text-[20px]">science</span>
|
||||
Loading eval suites...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const RESULT_COLUMNS = [
|
||||
{ key: "caseName", label: "Case" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "durationMs", label: "Latency" },
|
||||
{ key: "details", label: "Details" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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">
|
||||
<span className="material-symbols-outlined text-[20px]">science</span>
|
||||
</div>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search suites..."
|
||||
filters={[]}
|
||||
activeFilters={{}}
|
||||
onFilterChange={() => {}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
{filtered.map((suite) => {
|
||||
const suiteResult = results[suite.id];
|
||||
const isRunning = running === suite.id;
|
||||
const isExpanded = expanded === suite.id;
|
||||
const caseCount = suite.cases?.length || suite.caseCount || 0;
|
||||
|
||||
return (
|
||||
<div key={suite.id} className="border border-border/30 rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-surface/30 transition-colors"
|
||||
onClick={() => setExpanded(isExpanded ? null : suite.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted">
|
||||
{isExpanded ? "expand_more" : "chevron_right"}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">{suite.name || suite.id}</p>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRunEval(suite);
|
||||
}}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{isRunning ? `Running ${progress.current}/${progress.total}...` : "Run Eval"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/20 p-4">
|
||||
{suiteResult?.results ? (
|
||||
<>
|
||||
{/* Summary bar */}
|
||||
{suiteResult.summary && (
|
||||
<div className="flex items-center gap-4 mb-4 p-3 rounded-lg bg-surface/30 border border-border/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-lg font-bold ${
|
||||
suiteResult.summary.passRate === 100
|
||||
? "text-emerald-400"
|
||||
: suiteResult.summary.passRate >= 80
|
||||
? "text-amber-400"
|
||||
: "text-red-400"
|
||||
}`}
|
||||
>
|
||||
{suiteResult.summary.passRate}%
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">pass rate</span>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{suiteResult.summary.passed} passed · {suiteResult.summary.failed}{" "}
|
||||
failed · {suiteResult.summary.total} total
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
columns={RESULT_COLUMNS}
|
||||
data={suiteResult.results.map((r, i) => ({
|
||||
...r,
|
||||
id: r.caseId || i,
|
||||
}))}
|
||||
renderCell={(row, col) => {
|
||||
if (col.key === "status") {
|
||||
return row.passed ? (
|
||||
<span className="text-emerald-400">✅ Passed</span>
|
||||
) : (
|
||||
<span className="text-red-400">❌ Failed</span>
|
||||
);
|
||||
}
|
||||
if (col.key === "durationMs") {
|
||||
return (
|
||||
<span className="text-text-muted text-xs font-mono">
|
||||
{row.durationMs != null ? `${row.durationMs}ms` : "—"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (col.key === "details") {
|
||||
const d = row.details || {};
|
||||
return (
|
||||
<span className="text-text-muted text-xs truncate max-w-[300px] block">
|
||||
{d.searchTerm
|
||||
? `Contains: "${d.searchTerm}"`
|
||||
: d.pattern
|
||||
? `Regex: ${d.pattern}`
|
||||
: d.expected
|
||||
? `Expected: "${String(d.expected).slice(0, 50)}"`
|
||||
: row.error || "—"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-sm text-text-main">{row[col.key] || "—"}</span>
|
||||
);
|
||||
}}
|
||||
maxHeight="400px"
|
||||
emptyMessage="No results yet"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* Show test cases before running eval */
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted">
|
||||
checklist
|
||||
</span>
|
||||
<span className="text-xs text-text-muted font-medium">
|
||||
Test Cases ({(suite.cases || []).length})
|
||||
</span>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: "name", label: "Case" },
|
||||
{ key: "model", label: "Model" },
|
||||
{ key: "strategy", label: "Strategy" },
|
||||
{ key: "expected", label: "Expected" },
|
||||
]}
|
||||
data={(suite.cases || []).map((c, i) => ({
|
||||
id: c.id || i,
|
||||
name: c.name,
|
||||
model: c.model || "—",
|
||||
strategy: c.expected?.strategy || "—",
|
||||
expected: c.expected?.value
|
||||
? String(c.expected.value).slice(0, 80)
|
||||
: "—",
|
||||
}))}
|
||||
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",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`text-xs font-mono ${colorMap[row.strategy] || "text-text-muted"}`}
|
||||
>
|
||||
{row.strategy}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (col.key === "expected") {
|
||||
return (
|
||||
<span className="text-text-muted text-xs font-mono truncate max-w-[300px] block">
|
||||
{row.expected}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-sm text-text-main">{row[col.key] || "—"}</span>
|
||||
);
|
||||
}}
|
||||
maxHeight="400px"
|
||||
emptyMessage="No test cases defined"
|
||||
/>
|
||||
<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
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -350,10 +350,10 @@ export default function ProviderLimits() {
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer"
|
||||
style={{
|
||||
border: active
|
||||
? "1px solid var(--primary, #f97815)"
|
||||
? "1px solid var(--primary, #E54D5E)"
|
||||
: "1px solid rgba(255,255,255,0.12)",
|
||||
background: active ? "rgba(249,120,21,0.14)" : "transparent",
|
||||
color: active ? "var(--primary, #f97815)" : "var(--text-muted)",
|
||||
color: active ? "var(--primary, #E54D5E)" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<span>{tier.label}</span>
|
||||
|
||||
@@ -11,7 +11,8 @@ export default function RateLimitStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/rate-limits");
|
||||
if (res.ok) setData(await res.json());
|
||||
} catch {} finally {
|
||||
} catch {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
@@ -65,11 +66,14 @@ export default function RateLimitStatus() {
|
||||
bg-orange-500/5 border border-orange-500/15"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-[16px] text-orange-400">lock</span>
|
||||
<span className="material-symbols-outlined text-[16px] text-orange-400">
|
||||
lock
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{lock.model}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Account: <span className="font-mono">{lock.accountId?.slice(0, 12) || "N/A"}</span>
|
||||
Account:{" "}
|
||||
<span className="font-mono">{lock.accountId?.slice(0, 12) || "N/A"}</span>
|
||||
{lock.reason && <> — {lock.reason}</>}
|
||||
</p>
|
||||
</div>
|
||||
@@ -82,33 +86,6 @@ export default function RateLimitStatus() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Signature Cache Stats */}
|
||||
{data.cacheStats && (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
database
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Signature Cache</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Defaults", value: data.cacheStats.defaultCount, color: "text-text-muted" },
|
||||
{ label: "Tool", value: `${data.cacheStats.tool.entries}/${data.cacheStats.tool.patterns}`, color: "text-blue-400" },
|
||||
{ label: "Family", value: `${data.cacheStats.family.entries}/${data.cacheStats.family.patterns}`, color: "text-purple-400" },
|
||||
{ label: "Session", value: `${data.cacheStats.session.entries}/${data.cacheStats.session.patterns}`, color: "text-cyan-400" },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} className="text-center p-3 rounded-lg bg-surface/30 border border-border/30">
|
||||
<p className={`text-lg font-bold tabular-nums ${color}`}>{value}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import {
|
||||
UsageAnalytics,
|
||||
RequestLoggerV2,
|
||||
ProxyLogger,
|
||||
CardSkeleton,
|
||||
SegmentedControl,
|
||||
} from "@/shared/components";
|
||||
import { RequestLoggerV2, ProxyLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
|
||||
import ProviderLimits from "./components/ProviderLimits";
|
||||
import SessionsTab from "./components/SessionsTab";
|
||||
import RateLimitStatus from "./components/RateLimitStatus";
|
||||
|
||||
export default function UsagePage() {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [activeTab, setActiveTab] = useState("logs");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "logs", label: "Logger" },
|
||||
{ value: "proxy-logs", label: "Proxy" },
|
||||
{ value: "limits", label: "Limits" },
|
||||
{ value: "sessions", label: "Sessions" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === "overview" && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<UsageAnalytics />
|
||||
</Suspense>
|
||||
)}
|
||||
{activeTab === "logs" && <RequestLoggerV2 />}
|
||||
{activeTab === "proxy-logs" && <ProxyLogger />}
|
||||
{activeTab === "limits" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ProviderLimits />
|
||||
</Suspense>
|
||||
<RateLimitStatus />
|
||||
<SessionsTab />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "sessions" && <SessionsTab />}
|
||||
{activeTab === "logs" && <RequestLoggerV2 />}
|
||||
{activeTab === "proxy-logs" && <ProxyLogger />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCacheStats, clearCache, cleanExpiredEntries } from "@/lib/semanticCache";
|
||||
import { getIdempotencyStats } from "@/lib/idempotencyLayer";
|
||||
|
||||
/**
|
||||
* GET /api/cache — Cache statistics
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const cacheStats = getCacheStats();
|
||||
const idempotencyStats = getIdempotencyStats();
|
||||
|
||||
return NextResponse.json({
|
||||
semanticCache: cacheStats,
|
||||
idempotency: idempotencyStats,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/cache — Clear all caches
|
||||
*/
|
||||
export async function DELETE() {
|
||||
try {
|
||||
clearCache();
|
||||
const cleaned = cleanExpiredEntries();
|
||||
return NextResponse.json({ ok: true, expiredRemoved: cleaned });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPromptCache } from "@/lib/cacheLayer";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cache = getPromptCache();
|
||||
const stats = cache.getStats();
|
||||
return NextResponse.json(stats);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const cache = getPromptCache();
|
||||
cache.clear();
|
||||
return NextResponse.json({ success: true, message: "Cache cleared" });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import { NextResponse } from "next/server";
|
||||
import { listBackups, restoreBackup, deleteBackup } from "@/shared/services/backupService";
|
||||
import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
|
||||
|
||||
const VALID_TOOLS = ["claude", "codex", "droid", "openclaw"];
|
||||
const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
|
||||
|
||||
// GET /api/cli-tools/backups?tool=claude — list backups
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tool = searchParams.get("tool");
|
||||
const tool = searchParams.get("tool") || searchParams.get("toolId");
|
||||
|
||||
if (tool && !VALID_TOOLS.includes(tool)) {
|
||||
return NextResponse.json({ error: `Invalid tool: ${tool}` }, { status: 400 });
|
||||
@@ -41,7 +41,9 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: writeGuard }, { status: 403 });
|
||||
}
|
||||
|
||||
const { tool, backupId } = await request.json();
|
||||
const body = await request.json();
|
||||
const tool = body.tool || body.toolId;
|
||||
const backupId = body.backupId;
|
||||
|
||||
if (!tool || !backupId) {
|
||||
return NextResponse.json({ error: "tool and backupId are required" }, { status: 400 });
|
||||
@@ -69,7 +71,9 @@ export async function POST(request) {
|
||||
// DELETE /api/cli-tools/backups { tool, backupId } — delete a backup
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
const { tool, backupId } = await request.json();
|
||||
const body = await request.json();
|
||||
const tool = body.tool || body.toolId;
|
||||
const backupId = body.backupId;
|
||||
|
||||
if (!tool || !backupId) {
|
||||
return NextResponse.json({ error: "tool and backupId are required" }, { status: 400 });
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCliRuntimeStatus, CLI_TOOL_IDS } from "@/shared/services/cliRuntime";
|
||||
|
||||
/**
|
||||
* GET /api/cli-tools/status
|
||||
* Returns runtime + config status for all CLI tools in one batch call.
|
||||
* Used by the CLI Tools page to show status badges in collapsed state.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const statuses = {};
|
||||
|
||||
await Promise.all(
|
||||
CLI_TOOL_IDS.map(async (toolId) => {
|
||||
try {
|
||||
const runtime = await getCliRuntimeStatus(toolId);
|
||||
statuses[toolId] = {
|
||||
installed: runtime.installed,
|
||||
runnable: runtime.runnable,
|
||||
command: runtime.command,
|
||||
commandPath: runtime.commandPath,
|
||||
reason: runtime.reason || null,
|
||||
};
|
||||
} catch (error) {
|
||||
statuses[toolId] = {
|
||||
installed: false,
|
||||
runnable: false,
|
||||
reason: error.message,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Now fetch configStatus for the 6 tools that have settings endpoints
|
||||
const settingsTools = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
|
||||
|
||||
await Promise.all(
|
||||
settingsTools.map(async (toolId) => {
|
||||
if (!statuses[toolId]?.installed || !statuses[toolId]?.runnable) {
|
||||
statuses[toolId].configStatus = "not_installed";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const settingsRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:20128"}/api/cli-tools/${toolId}-settings`
|
||||
);
|
||||
if (settingsRes.ok) {
|
||||
const data = await settingsRes.json();
|
||||
statuses[toolId].configStatus = data.hasOmniRoute ? "configured" : "not_configured";
|
||||
} else {
|
||||
statuses[toolId].configStatus = "unknown";
|
||||
}
|
||||
} catch {
|
||||
statuses[toolId].configStatus = "unknown";
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json(statuses);
|
||||
} catch (error) {
|
||||
console.log("Error fetching CLI tool statuses:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch statuses" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAuditLog, logAuditEvent } from "@/lib/compliance/index";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get("action") || undefined;
|
||||
const actor = searchParams.get("actor") || undefined;
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10);
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10);
|
||||
|
||||
const logs = getAuditLog({ action, actor, limit, offset });
|
||||
return NextResponse.json(logs);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSuite } from "@/lib/evals/evalRunner";
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { suiteId } = await params;
|
||||
const suite = getSuite(suiteId);
|
||||
if (!suite) {
|
||||
return NextResponse.json({ error: `Suite not found: ${suiteId}` }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(suite);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listSuites, runSuite } from "@/lib/evals/evalRunner";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const suites = listSuites();
|
||||
return NextResponse.json(suites);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { suiteId, outputs } = await request.json();
|
||||
if (!suiteId || !outputs) {
|
||||
return NextResponse.json(
|
||||
{ error: "suiteId and outputs (Record<caseId, actualOutput>) are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = runSuite(suiteId, outputs);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAllFallbackChains,
|
||||
registerFallback,
|
||||
removeFallback,
|
||||
} from "@/domain/fallbackPolicy";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const chains = getAllFallbackChains();
|
||||
return NextResponse.json(chains);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { model, chain } = await request.json();
|
||||
if (!model || !Array.isArray(chain)) {
|
||||
return NextResponse.json(
|
||||
{ error: "model (string) and chain (array of {provider, priority?, enabled?}) are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
registerFallback(model, chain);
|
||||
return NextResponse.json({ success: true, model });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
const { model } = await request.json();
|
||||
if (!model) {
|
||||
return NextResponse.json({ error: "model is required" }, { status: 400 });
|
||||
}
|
||||
const removed = removeFallback(model);
|
||||
return NextResponse.json({ success: true, removed });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAvailabilityReport,
|
||||
clearModelUnavailability,
|
||||
getUnavailableCount,
|
||||
} from "@/domain/modelAvailability";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const report = getAvailabilityReport();
|
||||
const count = getUnavailableCount();
|
||||
return NextResponse.json({ unavailableCount: count, models: report });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { provider, model } = await request.json();
|
||||
if (!provider || !model) {
|
||||
return NextResponse.json({ error: "provider and model are required" }, { status: 400 });
|
||||
}
|
||||
const removed = clearModelUnavailability(provider, model);
|
||||
return NextResponse.json({ success: true, removed });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export async function GET() {
|
||||
});
|
||||
}
|
||||
|
||||
// Custom models
|
||||
// Custom models (from DB)
|
||||
for (const [providerId, models] of Object.entries(customModelsMap)) {
|
||||
const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
|
||||
if (!catalog[alias]) {
|
||||
@@ -89,11 +89,13 @@ export async function GET() {
|
||||
const fullId = `${alias}/${model.id}`;
|
||||
// Skip duplicates
|
||||
if (catalog[alias].models.some((m) => m.id === fullId)) continue;
|
||||
// Imported models are treated as default (not custom)
|
||||
const isCustom = model.source !== "imported";
|
||||
catalog[alias].models.push({
|
||||
id: fullId,
|
||||
name: model.name || model.id,
|
||||
type: "chat",
|
||||
custom: true,
|
||||
custom: isCustom,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/health — System health overview
|
||||
*
|
||||
* Returns system info, provider health (circuit breakers),
|
||||
* rate limit status, and database stats.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const { getAllCircuitBreakerStatuses } =
|
||||
await import("@/../../src/shared/utils/circuitBreaker.js");
|
||||
const { getAllRateLimitStatus } =
|
||||
await import("@omniroute/open-sse/services/rateLimitManager.js");
|
||||
const { getAllModelLockouts } = await import("@omniroute/open-sse/services/accountFallback.js");
|
||||
|
||||
const settings = await getSettings();
|
||||
const circuitBreakers = getAllCircuitBreakerStatuses();
|
||||
const rateLimitStatus = getAllRateLimitStatus();
|
||||
const lockouts = getAllModelLockouts();
|
||||
|
||||
// System info
|
||||
const system = {
|
||||
version: APP_CONFIG.version,
|
||||
nodeVersion: process.version,
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: process.memoryUsage(),
|
||||
pid: process.pid,
|
||||
platform: process.platform,
|
||||
};
|
||||
|
||||
// Provider health summary (circuitBreakers is an Array of { name, state, ... })
|
||||
const providerHealth = {};
|
||||
for (const cb of circuitBreakers) {
|
||||
// Skip test circuit breakers (leftover from unit tests)
|
||||
if (cb.name.startsWith("test-") || cb.name.startsWith("test_")) continue;
|
||||
providerHealth[cb.name] = {
|
||||
state: cb.state,
|
||||
failures: cb.failureCount || 0,
|
||||
lastFailure: cb.lastFailureTime,
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
system,
|
||||
providerHealth,
|
||||
rateLimitStatus,
|
||||
lockouts,
|
||||
setupComplete: settings?.setupComplete || false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API] GET /api/monitoring/health error:", error);
|
||||
return NextResponse.json({ status: "error", error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAllCircuitBreakerStatuses,
|
||||
} from "@/shared/utils/circuitBreaker";
|
||||
import {
|
||||
getLockedIdentifiers,
|
||||
forceUnlock,
|
||||
} from "@/domain/lockoutPolicy";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const circuitBreakers = getAllCircuitBreakerStatuses();
|
||||
const lockedIdentifiers = getLockedIdentifiers();
|
||||
return NextResponse.json({ circuitBreakers, lockedIdentifiers });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { action, identifier } = await request.json();
|
||||
|
||||
if (action === "unlock" && identifier) {
|
||||
forceUnlock(identifier);
|
||||
return NextResponse.json({ success: true, action: "unlocked", identifier });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action. Supported: unlock" }, { status: 400 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDbInstance } from "@/lib/db/core.js";
|
||||
|
||||
/**
|
||||
* GET /api/providers/metrics — Aggregate per-provider stats from call_logs
|
||||
* Returns: { metrics: { [provider]: { totalRequests, totalSuccesses, successRate, avgLatencyMs } } }
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
provider,
|
||||
COUNT(*) as totalRequests,
|
||||
SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END) as totalSuccesses,
|
||||
ROUND(AVG(duration)) as avgLatencyMs
|
||||
FROM call_logs
|
||||
WHERE provider IS NOT NULL AND provider != '-'
|
||||
GROUP BY provider`
|
||||
)
|
||||
.all();
|
||||
|
||||
const metrics = {};
|
||||
for (const row of rows) {
|
||||
metrics[row.provider] = {
|
||||
totalRequests: row.totalRequests,
|
||||
totalSuccesses: row.totalSuccesses,
|
||||
successRate:
|
||||
row.totalRequests > 0 ? Math.round((row.totalSuccesses / row.totalRequests) * 100) : 0,
|
||||
avgLatencyMs: row.avgLatencyMs || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({ metrics });
|
||||
} catch (error) {
|
||||
console.error("[providers/metrics] Error:", error);
|
||||
return NextResponse.json({ metrics: {} });
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export async function GET(request) {
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { provider, modelId, modelName } = body;
|
||||
const { provider, modelId, modelName, source } = body;
|
||||
|
||||
if (!provider || !modelId) {
|
||||
return Response.json(
|
||||
@@ -41,7 +41,7 @@ export async function POST(request) {
|
||||
);
|
||||
}
|
||||
|
||||
const model = await addCustomModel(provider, modelId, modelName);
|
||||
const model = await addCustomModel(provider, modelId, modelName, source || "manual");
|
||||
return Response.json({ model });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
|
||||
@@ -5,6 +5,33 @@ import {
|
||||
isAnthropicCompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
|
||||
// Providers that return hardcoded models (no remote /models API)
|
||||
const STATIC_MODEL_PROVIDERS = {
|
||||
deepgram: () => [
|
||||
{ id: "nova-3", name: "Nova 3 (Transcription)" },
|
||||
{ id: "nova-2", name: "Nova 2 (Transcription)" },
|
||||
{ id: "whisper-large", name: "Whisper Large (Transcription)" },
|
||||
{ id: "aura-asteria-en", name: "Aura Asteria EN (TTS)" },
|
||||
{ id: "aura-luna-en", name: "Aura Luna EN (TTS)" },
|
||||
{ id: "aura-stella-en", name: "Aura Stella EN (TTS)" },
|
||||
],
|
||||
assemblyai: () => [
|
||||
{ id: "universal-3-pro", name: "Universal 3 Pro (Transcription)" },
|
||||
{ id: "universal-2", name: "Universal 2 (Transcription)" },
|
||||
],
|
||||
nanobanana: () => [
|
||||
{ id: "nanobanana-flash", name: "NanoBanana Flash (Gemini 2.5 Flash)" },
|
||||
{ id: "nanobanana-pro", name: "NanoBanana Pro (Gemini 3 Pro)" },
|
||||
],
|
||||
perplexity: () => [
|
||||
{ id: "sonar", name: "Sonar (Fast Search)" },
|
||||
{ id: "sonar-pro", name: "Sonar Pro (Advanced Search)" },
|
||||
{ id: "sonar-reasoning", name: "Sonar Reasoning (CoT + Search)" },
|
||||
{ id: "sonar-reasoning-pro", name: "Sonar Reasoning Pro (Advanced CoT + Search)" },
|
||||
{ id: "sonar-deep-research", name: "Sonar Deep Research (Expert Analysis)" },
|
||||
],
|
||||
};
|
||||
|
||||
// Provider models endpoints configuration
|
||||
const PROVIDER_MODELS_CONFIG = {
|
||||
claude: {
|
||||
@@ -122,14 +149,7 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
perplexity: {
|
||||
url: "https://api.perplexity.ai/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
|
||||
together: {
|
||||
url: "https://api.together.xyz/v1/models",
|
||||
method: "GET",
|
||||
@@ -272,6 +292,16 @@ export async function GET(request, { params }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Static model providers (no remote /models API)
|
||||
const staticModelsFn = STATIC_MODEL_PROVIDERS[connection.provider];
|
||||
if (staticModelsFn) {
|
||||
return NextResponse.json({
|
||||
provider: connection.provider,
|
||||
connectionId: connection.id,
|
||||
models: staticModelsFn(),
|
||||
});
|
||||
}
|
||||
|
||||
const config = PROVIDER_MODELS_CONFIG[connection.provider];
|
||||
if (!config) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -290,6 +290,17 @@ async function testOAuthConnection(connection) {
|
||||
|
||||
// Check if token exists
|
||||
if (!connection.accessToken) {
|
||||
// If the refresh token is also missing on a refreshable provider,
|
||||
// this means re-authentication is needed (e.g. after refresh_token_reused)
|
||||
if (config.refreshable && !connection.refreshToken) {
|
||||
const error = "Refresh token expired. Please re-authenticate this account.";
|
||||
return {
|
||||
valid: false,
|
||||
error,
|
||||
refreshed: false,
|
||||
diagnosis: makeDiagnosis("reauth_required", "oauth", error, "reauth_required"),
|
||||
};
|
||||
}
|
||||
const error = "No access token";
|
||||
return {
|
||||
valid: false,
|
||||
|
||||
@@ -1,63 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections, updateProviderConnection } from "@/lib/localDb";
|
||||
import {
|
||||
enableRateLimitProtection,
|
||||
disableRateLimitProtection,
|
||||
getRateLimitStatus,
|
||||
getAllRateLimitStatus,
|
||||
} from "@omniroute/open-sse/services/rateLimitManager.js";
|
||||
|
||||
/**
|
||||
* GET /api/rate-limit — Get rate limit status for all connections
|
||||
* @deprecated Use /api/rate-limits instead.
|
||||
* This route redirects to the consolidated rate-limits endpoint.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
const statuses = connections.map((conn) => ({
|
||||
connectionId: conn.id,
|
||||
provider: conn.provider,
|
||||
name: conn.name || conn.email || conn.id.slice(0, 8),
|
||||
rateLimitProtection: !!conn.rateLimitProtection,
|
||||
...getRateLimitStatus(conn.provider, conn.id),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
connections: statuses,
|
||||
overview: getAllRateLimitStatus(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/rate-limit GET:", error);
|
||||
return NextResponse.json({ error: "Failed to get rate limit status" }, { status: 500 });
|
||||
}
|
||||
export async function GET(request) {
|
||||
const url = new URL(request.url);
|
||||
url.pathname = "/api/rate-limits";
|
||||
return NextResponse.redirect(url, 308);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/rate-limit — Toggle rate limit protection for a connection
|
||||
* Body: { connectionId: string, enabled: boolean }
|
||||
*/
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { connectionId, enabled } = await request.json();
|
||||
|
||||
if (!connectionId) {
|
||||
return NextResponse.json({ error: "Missing connectionId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Update in-memory state
|
||||
if (enabled) {
|
||||
enableRateLimitProtection(connectionId);
|
||||
} else {
|
||||
disableRateLimitProtection(connectionId);
|
||||
}
|
||||
|
||||
// Persist to database
|
||||
await updateProviderConnection(connectionId, {
|
||||
rateLimitProtection: !!enabled,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, connectionId, enabled: !!enabled });
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/rate-limit POST:", error);
|
||||
return NextResponse.json({ error: "Failed to toggle rate limit" }, { status: 500 });
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
url.pathname = "/api/rate-limits";
|
||||
return NextResponse.redirect(url, 308);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAllModelLockouts,
|
||||
} from "@omniroute/open-sse/services/accountFallback.js";
|
||||
import { getAllModelLockouts } from "@omniroute/open-sse/services/accountFallback.js";
|
||||
import { getCacheStats } from "@omniroute/open-sse/services/signatureCache.js";
|
||||
import { getProviderConnections, updateProviderConnection } from "@/lib/localDb";
|
||||
import {
|
||||
enableRateLimitProtection,
|
||||
disableRateLimitProtection,
|
||||
getRateLimitStatus,
|
||||
getAllRateLimitStatus,
|
||||
} from "@omniroute/open-sse/services/rateLimitManager.js";
|
||||
|
||||
/**
|
||||
* GET /api/rate-limits — Consolidated rate-limit status
|
||||
*
|
||||
* Returns:
|
||||
* - Per-connection rate-limit status (protection toggle, current state)
|
||||
* - Global overview (all providers)
|
||||
* - Model lockouts
|
||||
* - Signature cache stats
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
const statuses = connections.map((conn) => ({
|
||||
connectionId: conn.id,
|
||||
provider: conn.provider,
|
||||
name: conn.name || conn.email || conn.id.slice(0, 8),
|
||||
rateLimitProtection: !!conn.rateLimitProtection,
|
||||
...getRateLimitStatus(conn.provider, conn.id),
|
||||
}));
|
||||
|
||||
const lockouts = getAllModelLockouts();
|
||||
const cacheStats = getCacheStats();
|
||||
return NextResponse.json({ lockouts, cacheStats });
|
||||
|
||||
return NextResponse.json({
|
||||
connections: statuses,
|
||||
overview: getAllRateLimitStatus(),
|
||||
lockouts,
|
||||
cacheStats,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
console.error("[API ERROR] /api/rate-limits GET:", error);
|
||||
return NextResponse.json({ error: "Failed to get rate limit status" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/rate-limits — Toggle rate limit protection for a connection
|
||||
* Body: { connectionId: string, enabled: boolean }
|
||||
*/
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { connectionId, enabled } = await request.json();
|
||||
|
||||
if (!connectionId) {
|
||||
return NextResponse.json({ error: "Missing connectionId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Update in-memory state
|
||||
if (enabled) {
|
||||
enableRateLimitProtection(connectionId);
|
||||
} else {
|
||||
disableRateLimitProtection(connectionId);
|
||||
}
|
||||
|
||||
// Persist to database
|
||||
await updateProviderConnection(connectionId, {
|
||||
rateLimitProtection: !!enabled,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, connectionId, enabled: !!enabled });
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/rate-limits POST:", error);
|
||||
return NextResponse.json({ error: "Failed to toggle rate limit" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* POST /api/resilience/reset — Reset all circuit breakers and clear cooldowns
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
// Reset all circuit breakers
|
||||
const { getAllCircuitBreakerStatuses, getCircuitBreaker } =
|
||||
await import("@/../../src/shared/utils/circuitBreaker.js");
|
||||
|
||||
const statuses = getAllCircuitBreakerStatuses();
|
||||
let resetCount = 0;
|
||||
|
||||
for (const { name } of statuses) {
|
||||
const breaker = getCircuitBreaker(name);
|
||||
breaker.reset();
|
||||
resetCount++;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
resetCount,
|
||||
message: `Reset ${resetCount} circuit breaker(s)`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[API] POST /api/resilience/reset error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || "Failed to reset resilience state" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
|
||||
/**
|
||||
* GET /api/resilience — Get current resilience configuration and status
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Dynamic imports for open-sse modules
|
||||
const { getAllCircuitBreakerStatuses } =
|
||||
await import("@/../../src/shared/utils/circuitBreaker.js");
|
||||
const { getAllRateLimitStatus } =
|
||||
await import("@omniroute/open-sse/services/rateLimitManager.js");
|
||||
const { PROVIDER_PROFILES, DEFAULT_API_LIMITS } =
|
||||
await import("@omniroute/open-sse/config/constants.js");
|
||||
|
||||
const settings = await getSettings();
|
||||
const circuitBreakers = getAllCircuitBreakerStatuses();
|
||||
const rateLimitStatus = getAllRateLimitStatus();
|
||||
|
||||
return NextResponse.json({
|
||||
profiles: settings.providerProfiles || PROVIDER_PROFILES,
|
||||
defaults: DEFAULT_API_LIMITS,
|
||||
circuitBreakers,
|
||||
rateLimitStatus,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[API] GET /api/resilience error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || "Failed to load resilience status" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/resilience — Update provider resilience profiles
|
||||
*/
|
||||
export async function PATCH(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { profiles } = body;
|
||||
|
||||
if (!profiles || typeof profiles !== "object") {
|
||||
return NextResponse.json({ error: "Invalid profiles payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate profile shape
|
||||
for (const [key, profile] of Object.entries(profiles)) {
|
||||
if (!["oauth", "apikey"].includes(key)) {
|
||||
return NextResponse.json({ error: `Invalid profile key: ${key}` }, { status: 400 });
|
||||
}
|
||||
const required = [
|
||||
"transientCooldown",
|
||||
"rateLimitCooldown",
|
||||
"maxBackoffLevel",
|
||||
"circuitBreakerThreshold",
|
||||
"circuitBreakerReset",
|
||||
];
|
||||
for (const field of required) {
|
||||
if (typeof profile[field] !== "number" || profile[field] < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid ${key}.${field}: must be a non-negative number` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await updateSettings({ providerProfiles: profiles });
|
||||
|
||||
return NextResponse.json({ ok: true, profiles });
|
||||
} catch (err) {
|
||||
console.error("[API] PATCH /api/resilience error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || "Failed to save resilience profiles" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+103
-32
@@ -6,6 +6,53 @@ import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
/**
|
||||
* GET /api/sync/cloud
|
||||
* Returns current cloud sync status for sidebar indicator
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const { isCloudEnabled } = await import("@/lib/db/settings.js");
|
||||
const enabled = await isCloudEnabled();
|
||||
|
||||
if (!enabled) {
|
||||
return NextResponse.json({ enabled: false });
|
||||
}
|
||||
|
||||
// Cloud is enabled — try to verify connection
|
||||
const machineId = await getConsistentMachineId();
|
||||
const keys = await getApiKeys();
|
||||
const apiKey = keys[0]?.key;
|
||||
|
||||
if (!apiKey || !CLOUD_URL) {
|
||||
return NextResponse.json({ enabled: true, connected: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const pingRes = await fetchWithTimeout(
|
||||
`${CLOUD_URL}/${machineId}/v1/verify`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
5000
|
||||
);
|
||||
return NextResponse.json({
|
||||
enabled: true,
|
||||
connected: pingRes.ok,
|
||||
lastSync: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ enabled: true, connected: false });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ enabled: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sync/cloud
|
||||
* Sync data with Cloud
|
||||
@@ -19,15 +66,22 @@ export async function POST(request) {
|
||||
const machineId = await getConsistentMachineId();
|
||||
|
||||
switch (action) {
|
||||
case "enable":
|
||||
await updateSettings({ cloudEnabled: true });
|
||||
// Auto create key if none exists
|
||||
case "enable": {
|
||||
// Auto create key if none exists (before sync, so it's included in sync data)
|
||||
const keys = await getApiKeys();
|
||||
let createdKey = null;
|
||||
if (keys.length === 0) {
|
||||
createdKey = await createApiKey("Default Key", machineId);
|
||||
}
|
||||
return syncAndVerify(machineId, createdKey?.key, keys);
|
||||
// Sync first — only enable if sync succeeds
|
||||
const enableResult = await syncAndVerify(machineId, createdKey?.key, keys);
|
||||
const enableBody = await enableResult.clone().json().catch(() => ({}));
|
||||
// Only persist cloudEnabled if sync succeeded (body.success exists)
|
||||
if (enableBody.success) {
|
||||
await updateSettings({ cloudEnabled: true });
|
||||
}
|
||||
return enableResult;
|
||||
}
|
||||
case "sync": {
|
||||
const syncResult = await syncToCloud(machineId);
|
||||
if (syncResult.error) {
|
||||
@@ -48,16 +102,19 @@ export async function POST(request) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync and verify connection with ping
|
||||
* Sync and verify connection with ping (retry on verify)
|
||||
*/
|
||||
async function syncAndVerify(machineId, createdKey, existingKeys) {
|
||||
// Step 1: Sync data to cloud
|
||||
const syncResult = await syncToCloud(machineId, createdKey);
|
||||
if (syncResult.error) {
|
||||
return NextResponse.json(syncResult, { status: 502 });
|
||||
return NextResponse.json(
|
||||
{ error: `Cloud sync failed: ${syncResult.error}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Verify connection by pinging the cloud
|
||||
// Step 2: Verify connection by pinging the cloud (with retry)
|
||||
const apiKey = createdKey || existingKeys[0]?.key;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({
|
||||
@@ -67,34 +124,48 @@ async function syncAndVerify(machineId, createdKey, existingKeys) {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const pingResponse = await fetchWithTimeout(`${CLOUD_URL}/${machineId}/v1/verify`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
// Retry verify up to 2 times with a delay (cloud may need a moment after sync)
|
||||
const MAX_VERIFY_ATTEMPTS = 2;
|
||||
const VERIFY_RETRY_DELAY_MS = 1500;
|
||||
let lastVerifyError = null;
|
||||
|
||||
if (pingResponse.ok) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: true,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: `Ping failed: ${pingResponse.status}`,
|
||||
});
|
||||
for (let attempt = 1; attempt <= MAX_VERIFY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const pingResponse = await fetchWithTimeout(
|
||||
`${CLOUD_URL}/${machineId}/v1/verify`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
5000
|
||||
);
|
||||
|
||||
if (pingResponse.ok) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: true,
|
||||
});
|
||||
}
|
||||
lastVerifyError = `Ping failed: ${pingResponse.status}`;
|
||||
} catch (error) {
|
||||
lastVerifyError = error?.name === "AbortError" ? "Verify timeout" : error.message;
|
||||
}
|
||||
|
||||
// Wait before retry (except on last attempt)
|
||||
if (attempt < MAX_VERIFY_ATTEMPTS) {
|
||||
await new Promise((r) => setTimeout(r, VERIFY_RETRY_DELAY_MS));
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Sync succeeded but verify failed — still return success with warning
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: lastVerifyError || "Verification failed after retries",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getTelemetrySummary } from "@/shared/utils/requestTelemetry";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const windowMs = parseInt(searchParams.get("windowMs") || "300000", 10);
|
||||
const summary = getTelemetrySummary(windowMs);
|
||||
return NextResponse.json(summary);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Token Health API Route — Batch G
|
||||
*
|
||||
* Exposes aggregate health status of OAuth tokens.
|
||||
* Used by TokenHealthBadge in the Header.
|
||||
*/
|
||||
|
||||
import { getProviderConnections } from "@/lib/localDb";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const connections = await getProviderConnections({ authType: "oauth" });
|
||||
const oauthConns = (connections || []).filter((c) => c.isActive && c.refreshToken);
|
||||
|
||||
const total = oauthConns.length;
|
||||
const healthy = oauthConns.filter((c) => c.testStatus === "active" || !c.lastError).length;
|
||||
const errored = oauthConns.filter(
|
||||
(c) => c.testStatus === "error" || c.lastErrorType === "token_refresh_failed"
|
||||
).length;
|
||||
const lastCheck = oauthConns.reduce((latest, c) => {
|
||||
if (!c.lastHealthCheckAt) return latest;
|
||||
return latest && latest > c.lastHealthCheckAt ? latest : c.lastHealthCheckAt;
|
||||
}, null);
|
||||
|
||||
return Response.json({
|
||||
total,
|
||||
healthy,
|
||||
errored,
|
||||
warning: total - healthy - errored,
|
||||
lastCheckAt: lastCheck,
|
||||
status: errored > 0 ? "error" : healthy < total ? "warning" : "healthy",
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json({ error: err.message, status: "unknown" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user