Compare commits

..

41 Commits

Author SHA1 Message Date
diegosouzapw 6b34a39f6e chore(release): bump version to v1.0.10 and update changelog 2026-02-21 12:35:37 -03:00
diegosouzapw f0513683e5 fix(qwen): extract email from id_token to allow multiple accounts 2026-02-21 12:22:56 -03:00
diegosouzapw b3e53ca250 chore(release): bump version to v1.0.9 and update changelog 2026-02-21 10:00:53 -03:00
diegosouzapw 7a3b21eff8 fix(settings): add blockedProviders and requireAuthForModels to Zod schema 2026-02-21 09:49:15 -03:00
diegosouzapw f8c8501036 chore(release): bump version to v1.0.8 2026-02-21 09:31:10 -03:00
Diego Rodrigues de Sa e Souza 4e1b5a5253 Merge pull request #102 from diegosouzapw/feat/issue-fixes-and-security
Release v1.0.8
2026-02-21 09:30:54 -03:00
diegosouzapw 1aa27ceefe chore(workflows): move agent workflows to global dir 2026-02-21 09:11:14 -03:00
diegosouzapw 340dcf9515 feat(core): api key protection, provider blocking, windows fix, ui and docs 2026-02-21 09:05:59 -03:00
Adarsh 1bd5d552c1 fix: extract email from id_token for Codex OAuth to allow multiple accounts (#97)
- Parse JWT id_token to extract email in mapTokens function
- Allows database to distinguish between different Codex accounts
- Fixes issue where adding 3rd+ Codex account would update existing connection instead of creating new one
2026-02-21 07:00:37 -03:00
Adarsh 862d94b859 Fix/auto generate jwt secret (#94)
* fix: auto-generate JWT_SECRET at startup if not provided
- Add ensureJwtSecret() in instrumentation.ts to generate random 64-char secret
- Mark JWT_SECRET as non-required in secretsValidator
- Remove fatal error log for missing JWT_SECRET in proxy.ts
Fixes login failure when running via CLI without JWT_SECRET env var.

* feat: improve auth flow and login page UX
- Add setupComplete to settings validation schema for proper persistence
- Support ?tab= query param in settings page for direct tab navigation
- Redesign login page with professional two-column layout
- Add context-aware states for onboarding/password setup flows
- Smooth animations and refined visual hierarchy

* fix: 404 page to use primary color theme (coral red) instead of purple
2026-02-21 06:55:40 -03:00
diegosouzapw be36d9c452 Merge fix/issues-89-90-91-v1.0.7: resolve #89 #90 #91, bump v1.0.7 2026-02-20 11:51:19 -03:00
diegosouzapw 5ce95dd829 fix: resolve issues #89 #90 #91 — stream default, custom models, OAuth redirect
- fix(chatCore): stream defaults to false per OpenAI spec (body.stream === true)
  closes #89
- fix(models): custom OpenAI-compatible providers now appear in /v1/models
  Added raw providerId check against activeAliases
  closes #90
- fix(OAuthModal): Google OAuth providers force localhost redirect URI
  Targeted amber warning + redirect_uri_mismatch error guidance for remote deployments
  closes #91
- docs: Add 'OAuth em Servidor Remoto' tutorial in README with 7-step Google Cloud guide
- docs: .env.example prominent warning block for Google OAuth remote setup
- chore: bump version 1.0.6 → 1.0.7
- docs: CHANGELOG entry for v1.0.7
2026-02-20 11:50:50 -03:00
diegosouzapw d0fa01cc0a fix: increase JWT session to 30 days + auto-refresh in middleware
JWT session tokens now last 30 days instead of 24 hours. The middleware
silently refreshes the token when it has less than 7 days remaining,
so active users never get locked out of the dashboard.

Auto-refresh fires on any /dashboard request and sets a fresh 30-day
cookie via Set-Cookie header — completely transparent to the user.
2026-02-20 07:02:18 -03:00
diegosouzapw c69ad118d1 release: v1.0.6 — provider/combo toggles, strict model filtering 2026-02-20 02:59:35 -03:00
diegosouzapw 0deeaf5ffb feat: combo enable/disable toggle + provider toggle always visible
1. Combos page: added Toggle to each ComboCard — disable/enable combos
   with optimistic UI update. Disabled combos show opacity-50.
2. /v1/models: disabled combos (isActive=false) are excluded from the
   model list.
3. Providers page: toggle is now always visible instead of hover-only.
2026-02-20 02:53:14 -03:00
diegosouzapw 87c7c92ddd fix: /v1/models only shows models from configured providers
Previously, if no connections existed in the database, ALL provider
models (378+) were shown as a fallback. Now only combos and custom
models appear when no provider connections are configured.

Models are strictly filtered by activeAliases — only providers with
at least one active (enabled) connection show their models. This
ensures /v1/models reflects exactly what the user has set up.
2026-02-20 02:44:38 -03:00
diegosouzapw f12b90f373 fix: /v1/models respects disabled providers — track total connections before filter
Root cause: after filtering disabled connections (isActive=false), the
connections array could be empty. The fallback 'connections.length > 0'
then treated it as 'no connections configured' and showed ALL models.

Fix: Track totalConnectionCount before filtering. Use it in all filter
conditions so disabled providers are properly excluded even when every
connection is toggled off.
2026-02-20 02:35:10 -03:00
diegosouzapw 4e37027e1f feat: add enable/disable toggle to provider cards on main page
Toggle appears on hover next to the chevron. Clicking it batch-toggles
all connections for that provider (enable/disable). Uses optimistic UI
update with parallel API calls. stopPropagation prevents navigation.
2026-02-20 02:22:47 -03:00
diegosouzapw 245345f37d fix: treat private/LAN IPs as localhost for OAuth redirect URI
Google OAuth rejects private IPs (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
with 'device_id and device_name are required' error. Now these are treated
the same as localhost, using http://localhost:{port}/callback as redirect URI.
2026-02-20 02:13:07 -03:00
diegosouzapw 99be29ce8e docs: update READMEs + CHANGELOG to v1.0.5 2026-02-20 01:50:06 -03:00
diegosouzapw 6aaaaccb12 fix: set DATA_DIR=/app/data in Dockerfile and docker-compose.yml
Ensures the Docker volume mount at /app/data matches the app data directory.
Previously DATA_DIR was not set, causing the app to default to ~/.omniroute/
which is not backed by the volume — leading to empty database on redeploys.
2026-02-20 01:47:22 -03:00
diegosouzapw 282c46fd46 chore: bump version to 1.0.5 2026-02-20 01:43:53 -03:00
Diego Rodrigues de Sa e Souza bc719875f6 fix: filter all model types in /v1/models by active providers (#88)
Previously only chat models were filtered by active provider connections.
Embedding, image, rerank, audio, and moderation models were always shown
regardless of whether the provider had any configured connections.

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

Closes #81

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

Phase 3 — UX & Polish:
- P-1: 500 error page (src/app/error.tsx)
- P-2: Audit log viewer (dashboard/audit-log/page.tsx)
- U-2: ARIA attributes on error pages
- U-5: Typed global-error.tsx params
- U-6: CSS variables for accent/semantic/traffic colors
- L-3: Output PII sanitization (src/lib/piiSanitizer.ts)
- L-4: Tool-calling allowlist/denylist (src/lib/toolPolicy.ts)
- L-9: Semantic cache invalidation API + auto-cleanup
- D-5: SECURITY.md updated to v1.0.x
- F-2: Deleted .env copy
2026-02-18 14:43:57 -03:00
diegosouzapw 3aca5fa691 feat: Implement initial database schema and centralize CORS configuration across API routes. 2026-02-18 13:44:08 -03:00
diegosouzapw 1a7b7e8799 feat: Implement graceful shutdown and body size guard middleware, and refine TypeScript typings across several routes and components. 2026-02-18 12:47:36 -03:00
diegosouzapw 4c93f0618c feat: Add dashboard loading and error UIs, introduce standardized API response utilities, enhance call log redaction and configurable retention, and document architectural decisions. 2026-02-18 11:34:05 -03:00
diegosouzapw 16b72970d7 feat: Implement API authentication middleware, mask sensitive API keys, integrate prompt injection guard, and enhance onboarding error handling. 2026-02-18 11:17:48 -03:00
Diego Rodrigues de Sa e Souza fbceb0d301 Merge pull request #77 from diegosouzapw/feature/api-compatibility-sanitization
feat(api-compat): response sanitization, role normalization & structured output for Gemini
2026-02-18 08:39:34 -03:00
diegosouzapw ed05599cdd feat(api-compat): response sanitization, role normalization, structured output for Gemini
- Add responseSanitizer.ts: strips non-standard fields (x_groq, usage_breakdown, service_tier),
  extracts <think> tags into reasoning_content, normalizes id/object/usage for strict OpenAI SDK v1.83+ compatibility
- Add roleNormalizer.ts: developer→system for non-OpenAI providers, system→user merging for GLM/ERNIE models
- Integrate sanitizer into chatCore.ts (non-streaming) and stream.ts (streaming passthrough)
- Integrate role normalizer into translator/index.ts pipeline
- Add response_format (json_schema/json_object) → Gemini responseMimeType/responseSchema conversion
- Update CHANGELOG.md with v1.1.0 entry
- Update README.md, ARCHITECTURE.md, TROUBLESHOOTING.md with new capabilities
2026-02-18 08:38:44 -03:00
151 changed files with 7324 additions and 799 deletions
-54
View File
@@ -1,54 +0,0 @@
---
description: Git workflow — NEVER commit directly to main. Always use feature branches.
---
# Git Workflow
## ⚠️ CRITICAL RULE: NEVER commit directly to `main`
## Steps
1. **Before starting any work**, create a feature branch from `main`:
```bash
git checkout main && git pull origin main
git checkout -b feature/<feature-name>
```
2. **During development**, commit to the feature branch:
```bash
git add -A && git commit -m "<type>(<scope>): <description>"
```
3. **Before pushing**, verify the build passes:
```bash
npm run build
```
4. **When the feature is complete and verified**, push the branch and STOP:
```bash
git push origin feature/<feature-name>
```
5. **DO NOT** create a PR, merge, or push to `main`. Let the user handle that.
## Branch naming convention
- `feature/<name>` — new features
- `fix/<name>` — bugfixes
- `refactor/<name>` — refactoring
- `docker/<name>` — Docker / infrastructure changes
- `style/<name>` — UI / CSS changes
## Commit types
- `feat` — new feature
- `fix` — bugfix
- `refactor` — code refactoring
- `style` — UI / CSS changes
- `docker` — Docker / infrastructure
- `docs` — documentation
- `chore` — maintenance
+41 -6
View File
@@ -44,6 +44,9 @@ REQUIRE_API_KEY=false
BASE_URL=http://localhost:20128
CLOUD_URL=
# Backward-compatible/public variables:
# NEXT_PUBLIC_BASE_URL is also used as the OAuth redirect_uri origin when running behind a
# reverse proxy (e.g., nginx). Set this to your public-facing URL so OAuth callbacks work.
# Example: NEXT_PUBLIC_BASE_URL=https://omniroute.example.com
NEXT_PUBLIC_BASE_URL=http://localhost:20128
NEXT_PUBLIC_CLOUD_URL=
@@ -79,17 +82,45 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Provider OAuth Credentials (optional — override hardcoded defaults)
# These can also be set via data/provider-credentials.json
# CLAUDE_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_SECRET=
# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity, Gemini CLI) — IMPORTANT FOR REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
# The built-in Google OAuth credentials ONLY work when OmniRoute runs on
# localhost (127.0.0.1 / local network). They are registered with
# redirect_uri = http://localhost:PORT/callback and Google will reject any
# other redirect URI with: redirect_uri_mismatch.
#
# If you are hosting OmniRoute on a remote server (VPS, Docker, cloud), you
# MUST register your own Google Cloud OAuth 2.0 credentials:
#
# 1. Go to https://console.cloud.google.com/apis/credentials
# 2. Create an OAuth 2.0 Client ID (type: "Web application")
# 3. Add your server URL as Authorized redirect URI:
# https://your-server.com/callback
# 4. Copy the Client ID and Client Secret below.
#
# See the full tutorial in README.md → "OAuth em Servidor Remoto" section.
#
# Antigravity (Google Gemini Code Assist):
# ANTIGRAVITY_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
ANTIGRAVITY_OAUTH_CLIENT_SECRET=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
# Gemini CLI (Google AI):
# GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
# GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
# GEMINI_CLI_OAUTH_CLIENT_ID=
# GEMINI_CLI_OAUTH_CLIENT_SECRET=
GEMINI_OAUTH_CLIENT_SECRET=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
GEMINI_CLI_OAUTH_CLIENT_SECRET=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
# ─────────────────────────────────────────────────────────────────────────────
# CLAUDE_OAUTH_CLIENT_ID=
# CODEX_OAUTH_CLIENT_ID=
# CODEX_OAUTH_CLIENT_SECRET=
# QWEN_OAUTH_CLIENT_ID=
# IFLOW_OAUTH_CLIENT_ID=
# IFLOW_OAUTH_CLIENT_SECRET=
# ANTIGRAVITY_OAUTH_CLIENT_ID=
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
IFLOW_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW
# API Key Providers (Phase 1 + Phase 4)
# Add via Dashboard → Providers → Add API Key, or set here
@@ -118,3 +149,7 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Logging
# LOG_LEVEL=info
# LOG_FORMAT=text
LOG_TO_FILE=true
# LOG_FILE_PATH=logs/application/app.log
# LOG_MAX_FILE_SIZE=50M
# LOG_RETENTION_DAYS=7
+38 -1
View File
@@ -36,7 +36,8 @@ jobs:
- name: Dependency audit
run: npm audit --audit-level=high --omit=dev
- name: Check for known vulnerabilities
run: npx is-my-node-vulnerable || true
run: npx is-my-node-vulnerable
continue-on-error: true
build:
name: Build
@@ -108,3 +109,39 @@ jobs:
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm run test:e2e
test-integration:
name: Integration Tests
runs-on: ubuntu-latest
needs: build
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
INITIAL_PASSWORD: ci-test-password-for-integration
DATA_DIR: /tmp/omniroute-ci
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:integration
continue-on-error: true
test-security:
name: Security Tests
runs-on: ubuntu-latest
needs: build
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:security
continue-on-error: true
+1
View File
@@ -94,3 +94,4 @@ security-analysis/
# Deploy workflow (contains sensitive VPS credentials)
.agent/workflows/deploy.md
clipr/
+293
View File
@@ -7,6 +7,292 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.0.10] — 2026-02-21
> ### 🐛 Bugfix — Multi-Account Support for Qwen
>
> Solves the issue where adding a second Qwen account would overwrite the first one.
### 🐛 Bug Fixes
- **OAuth Accounts** — Extracted user email from the `id_token` using JWT decoding for Qwen and similar providers, allowing multiple accounts of the same provider to be authenticated simultaneously instead of triggering the fallback overwrite logic ([#99](https://github.com/diegosouzapw/OmniRoute/issues/99))
---
## [1.0.9] — 2026-02-21
> ### 🐛 Hotfix — Settings Persistence
>
> Fixes blocked providers and API auth toggle not being saved after page reload.
### 🐛 Bug Fixes
- **Settings Persistence** — Added `requireAuthForModels` (boolean) and `blockedProviders` (string array) to the Zod validation schema, which was silently stripping these fields during PATCH requests, preventing them from being saved to the database
---
## [1.0.8] — 2026-02-21
> ### 🔒 API Security & Windows Support
>
> Adds API Endpoint Protection for `/models`, Windows server startup fixes, and UI improvements.
### ✨ New Features
- **API Endpoint Protection (`/models`)** — New Security Tab settings to optionally require an API key for the `/v1/models` endpoint (returns 404 when unauthorized) and to selectively block specific providers from appearing in the models list ([#100](https://github.com/diegosouzapw/OmniRoute/issues/100), [#96](https://github.com/diegosouzapw/OmniRoute/issues/96))
- **Interactive Provider UI** — Blocked Providers setting features an interactive chip selector with visual badges for all available AI providers
### 🐛 Bug Fixes
- **Windows Server Startup** — Fixed `ERR_INVALID_FILE_URL_PATH` crash on Windows by safely wrapping `import.meta.url` resolution with a fallback to `process.cwd()` for globally installed npm packages ([#98](https://github.com/diegosouzapw/OmniRoute/issues/98))
- **Combo buttons visibility** — Fixed layout overlap and tight spacing for the Quick Action buttons (Clone / Delete / Test) on the Combos page on narrower screens ([#95](https://github.com/diegosouzapw/OmniRoute/issues/95))
---
## [1.0.7] — 2026-02-20
> ### 🐛 Bugfix Release — OpenAI Compatibility, Custom Models & OAuth UX
>
> Fixes three community-reported issues: stream default now follows OpenAI spec, custom OpenAI-compatible providers appear in `/v1/models`, and Google OAuth shows a clear error + tutorial for remote deployments.
### 🐛 Bug Fixes
- **`stream` defaults to `false`** — Aligns with the OpenAI specification which explicitly states `stream` defaults to `false`. Previously OmniRoute defaulted to `true`, causing SSE data to be returned instead of a JSON object, breaking clients like Spacebot, OpenCode, and standard Python/Rust/Go OpenAI SDKs that don't explicitly set `stream: true` ([#89](https://github.com/diegosouzapw/OmniRoute/issues/89))
- **Custom AI providers now appear in `/v1/models`** — OpenAI-compatible custom providers (e.g. FriendLI) whose provider ID wasn't in the built-in alias map were silently excluded from the models list even when active. Fixed by also checking the raw provider ID from the database against active connections ([#90](https://github.com/diegosouzapw/OmniRoute/issues/90))
- **OAuth `redirect_uri_mismatch` — improved UX for remote deployments** — Google OAuth providers (Antigravity, Gemini CLI) now always use `localhost` as redirect URI matching the registered credentials. Remote-access users see a targeted amber warning with a link to the new setup guide. The token exchange error message explains the root cause and guides users to configure their own credentials ([#91](https://github.com/diegosouzapw/OmniRoute/issues/91))
### 📖 Documentation
- **OAuth em Servidor Remoto tutorial** — New README section with step-by-step guide to configure custom Google Cloud OAuth 2.0 credentials for remote/VPS/Docker deployments
- **`.env.example` Google OAuth block** — Added prominent warning block explaining remote credential requirements with direct links to Google Cloud Console
### 📁 Files Modified
| File | Change |
| -------------------------------------- | ------------------------------------------------------------------------------------------- |
| `open-sse/handlers/chatCore.ts` | `stream` defaults to `false` (was `true`) per OpenAI spec |
| `src/app/api/v1/models/route.ts` | Added raw `providerId` check for custom models active-provider filter |
| `src/shared/components/OAuthModal.tsx` | Force `localhost` redirect for Google OAuth; improved `redirect_uri_mismatch` error message |
| `.env.example` | Added ⚠️ Google OAuth remote credentials block with step-by-step instructions |
| `README.md` | New "🔐 OAuth em Servidor Remoto" tutorial section |
---
## [1.0.6] — 2026-02-20
> ### ✨ Provider & Combo Toggles — Strict Model Filtering
>
> `/v1/models` now shows only models from providers with active connections. Combos and providers can be toggled on/off directly from the dashboard.
### ✨ New Features
- **Provider toggle on Providers page** — Enable/disable all connections for a provider directly from the main Providers list. Toggle is always visible, no hover needed
- **Combo enable/disable toggle** — Each combo on the Combos page now has a toggle. Disabled combos are excluded from `/v1/models`
- **OAuth private IP support** — Expanded localhost detection to include private/LAN IPs (`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`) for correct OAuth redirect URIs
### 🐛 Bug Fixes
- **`/v1/models` strict filtering** — Models are now shown only from providers with active, enabled connections. Previously, if no connections existed or all were disabled, all 378+ models were shown as a fallback
- **Disabled provider models hidden** — Toggling off a provider immediately removes its models from `/v1/models`
---
## [1.0.5] — 2026-02-20
> ### 🐛 Hotfix — Model Filtering & Docker DATA_DIR
>
> Filters all model types in `/v1/models` by active providers and fixes Docker data directory mismatch.
### 🐛 Bug Fixes
- **`/v1/models` full filtering** — Embedding, image, rerank, audio, and moderation models are now filtered by active provider connections, matching chat model behavior. Providers like Together AI no longer appear without a configured API key (#88)
- **Docker `DATA_DIR`** — Added `ENV DATA_DIR=/app/data` to Dockerfile and `docker-compose.yml` ensuring the volume mount always matches the app data directory — prevents empty database on container recreation
---
## [1.0.4] — 2026-02-19
> ### 🔧 Provider Filtering, OAuth Proxy Fix & Documentation
>
> Dashboard model filtering by active providers, provider enable/disable visual indicators, OAuth login fix for nginx reverse proxy, and LLM onboarding documentation.
### ✨ Features
- **API Models filtering** — `GET /api/models` now returns only models from active providers; use `?all=true` for all models (#85)
- **Provider disabled indicator** — Provider cards show ⏸ "Disabled" badge with reduced opacity when all connections are inactive (#85)
- **`llm.txt`** — Comprehensive LLM onboarding file with project overview, architecture, flows, and conventions (#84)
- **WhatsApp Community** — Added WhatsApp group link to README badges and Support section
### 🐛 Bug Fixes
- **OAuth behind nginx** — Fixed OAuth login failing when behind a reverse proxy by using `window.location.origin` for redirect URI instead of hardcoded `localhost` (#86)
- **`NEXT_PUBLIC_BASE_URL` for OAuth** — Documented env var usage as redirect URI override for proxy deployments (#86)
### 📁 Files Added
| File | Purpose |
| --------- | -------------------------------------------------- |
| `llm.txt` | LLM and contributor onboarding (llms.txt standard) |
### 📁 Files Modified
| File | Change |
| -------------------------------------------------- | ---------------------------------------------------------------- |
| `src/app/api/models/route.ts` | Filter by active providers, `?all=true` param, `available` field |
| `src/app/(dashboard)/dashboard/providers/page.tsx` | `allDisabled` detection + ⏸ badge + opacity-50 on provider cards |
| `src/shared/components/OAuthModal.tsx` | Proxy-aware redirect URI using `window.location.origin` |
| `.env.example` | Documented `NEXT_PUBLIC_BASE_URL` for OAuth behind proxy |
---
## [1.0.3] — 2026-02-19
> ### 📊 Logs Dashboard & Real-Time Console Viewer
>
> Unified logs interface with real-time console log viewer, file-based logging via console interception, and server initialization improvements.
### ✨ Features
- **Logs Dashboard** — Consolidated 4-tab page at `/dashboard/logs` with Request Logs, Proxy Logs, Audit Logs, and Console tabs
- **Console Log Viewer** — Terminal-style real-time log viewer with color-coded log levels, auto-scroll, search/filtering, level filter, and 5-second polling
- **Console Interceptor** — Monkey-patches `console.log/info/warn/error/debug` at server start to capture all application output as JSON lines to `logs/application/app.log`
- **Log Rotation** — Size-based rotation and retention-based cleanup for log files
### 🔧 Improvements
- **Instrumentation consolidation** — Moved `initAuditLog()`, `cleanupExpiredLogs()`, and console interceptor initialization to Next.js `instrumentation.ts` (runs on both dev and prod server start)
- **Structured Logger file output** — `structuredLogger.ts` now also appends JSON log entries to the log file
- **Pino Logger fix** — Fixed broken mix of pino `transport` targets + manual `createWriteStream`; now uses `pino/file` transport targets exclusively with absolute paths
### 🗂️ Files Added
| File | Purpose |
| ---------------------------------------------------- | ----------------------------------------------------------------- |
| `src/app/(dashboard)/dashboard/logs/page.tsx` | Tabbed Logs Dashboard page |
| `src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx` | Audit log tab component extracted from standalone page |
| `src/shared/components/ConsoleLogViewer.tsx` | Terminal-style real-time log viewer |
| `src/app/api/logs/console/route.ts` | API endpoint to read log file (filters last 1h, level, component) |
| `src/lib/consoleInterceptor.ts` | Console method monkey-patching for file capture |
| `src/lib/logRotation.ts` | Log rotation by size and cleanup by retention days |
### 🗂️ Files Modified
| File | Change |
| --------------------------------------- | ------------------------------------------------------------------------------- |
| `src/shared/components/Sidebar.tsx` | Nav: "Request Logs" → "Logs" with `description` icon |
| `src/shared/components/Breadcrumbs.tsx` | Added breadcrumb labels for `logs`, `audit-log`, `console` |
| `src/instrumentation.ts` | Added console interceptor + audit log init + expired log cleanup |
| `src/server-init.ts` | Added console interceptor import (backup init) |
| `src/shared/utils/logger.ts` | Fixed pino file transport using `pino/file` targets |
| `src/shared/utils/structuredLogger.ts` | Added `appendFileSync` file writing + log file config |
| `.env.example` | Added `LOG_TO_FILE`, `LOG_FILE_PATH`, `LOG_MAX_FILE_SIZE`, `LOG_RETENTION_DAYS` |
### ⚙️ Configuration
New environment variables:
| Variable | Default | Description |
| -------------------- | -------------------------- | ----------------------------- |
| `LOG_TO_FILE` | `true` | Enable/disable file logging |
| `LOG_FILE_PATH` | `logs/application/app.log` | Log file path |
| `LOG_MAX_FILE_SIZE` | `50M` | Max file size before rotation |
| `LOG_RETENTION_DAYS` | `7` | Days to retain old log files |
---
## [1.0.2] — 2026-02-18
> ### 🔒 Security Hardening, Architecture Improvements & UX Polish
>
> Comprehensive audit-driven improvements across security, architecture, testing, and user experience.
### 🛡️ Security (Phase 0)
- **Auth guard** — API route protection via `withAuth` middleware for all dashboard routes
- **CSRF protection** — Token-based CSRF guard for all state-changing API routes
- **Request payload validation** — Zod schemas for provider, combo, key, and settings endpoints
- **Prompt injection guard** — Input sanitization against malicious prompt patterns
- **Body size guard** — Route-specific body size limits with dedicated audio upload threshold
- **Rate limiter** — Per-IP rate limiting with configurable windows and thresholds
### 🏗️ Architecture (Phase 12)
- **DI container** — Simple dependency injection container for service registration
- **Policy engine** — Consolidated `PolicyEngine` for routing, security, and rate limiting
- **SQLite migration** — Database migration system with versioned migration runner
- **Graceful shutdown** — Clean server shutdown with connection draining
- **TypeScript fixes** — Resolved all `tsc` errors; removed redundant `@ts-check` directives
- **Pipeline decomposition** — `handleSingleModelChat` decomposed into composable pipeline stages
- **Prompt template versioning** — Version-tracked prompt templates with rollback support
- **Eval scheduling** — Automated evaluation suite scheduling with cron-based runner
- **Plugin architecture** — Extensible plugin system for custom middleware and handlers
### 🧪 Testing & CI (Phase 2)
- **Coverage thresholds** — Jest coverage thresholds enforced in CI (368 tests passing)
- **Proxy pipeline integration tests** — End-to-end tests for the proxy request pipeline
- **CI audit workflow** — npm audit and security scanning in GitHub Actions
- **k6 load tests** — Performance testing with ramping VUs and custom metrics
### ✨ UX & Polish (Phase 34)
- **Session management** — Session info card with login time, age, user agent, and logout
- **Focus indicators** — Global `:focus-visible` styles and `--focus-ring` CSS utility
- **Audit log viewer** — Security event audit log with structured data display
- **Dashboard cleanup** — Removed unused files, fixed Quick Start links to Endpoint page
- **Documentation** — Troubleshooting guide, deployment improvements
---
## [1.1.0] — 2026-02-18
> ### 🔧 API Compatibility & SDK Hardening
>
> Response sanitization, role normalization, and structured output improvements for strict OpenAI SDK compatibility and cross-provider robustness.
### 🛡️ Response Sanitization (NEW)
- **Response sanitizer module** — New `responseSanitizer.ts` strips non-standard fields (`x_groq`, `usage_breakdown`, `service_tier`, etc.) from all OpenAI-format provider responses, fixing OpenAI Python SDK v1.83+ Pydantic validation failures that returned raw strings instead of parsed `ChatCompletion` objects
- **Streaming chunk sanitization** — Passthrough streaming mode now sanitizes each SSE chunk in real-time via `sanitizeStreamingChunk()`, ensuring strict `chat.completion.chunk` schema compliance
- **ID/Object/Usage normalization** — Ensures `id`, `object`, `created`, `model`, `choices`, and `usage` fields always exist with correct types
- **Usage field cleanup** — Strips non-standard usage sub-fields, keeps only `prompt_tokens`, `completion_tokens`, `total_tokens`, and OpenAI detail fields
### 🧠 Think Tag Extraction (NEW)
- **`<think>` tag extraction** — Automatically extracts `<think>...</think>` blocks from thinking model responses (DeepSeek R1, Kimi K2 Thinking, etc.) into OpenAI's standard `reasoning_content` field
- **Streaming think-tag stripping** — Real-time `<think>` extraction in passthrough SSE stream, preventing JSON parsing errors in downstream tools
- **Preserves native reasoning** — Providers that already send `reasoning_content` natively (e.g., OpenAI o1) are not overwritten
### 🔄 Role Normalization (NEW)
- **`developer``system` conversion** — OpenAI's new `developer` role is automatically converted to `system` for all non-OpenAI providers (Claude, Gemini, Kiro, etc.)
- **`system``user` merging** — For models that reject the `system` role (GLM, ERNIE), system messages are intelligently merged into the first user message with clear delimiters
- **Model-aware normalization** — Uses model name prefix matching (`glm-*`, `ernie-*`) for compatibility decisions, avoiding hardcoded provider-level flags
### 📐 Structured Output for Gemini (NEW)
- **`response_format` → Gemini conversion** — OpenAI's `json_schema` structured output is now translated to Gemini's `responseMimeType` + `responseSchema` in the translator pipeline
- **`json_object` support** — `response_format: { type: "json_object" }` maps to Gemini's `application/json` MIME type
- **Schema cleanup** — Automatically removes unsupported JSON Schema keywords (`$schema`, `additionalProperties`) for Gemini compatibility
### 📁 Files Added
| File | Purpose |
| ---------------------------------------- | ---------------------------------------------------------------------- |
| `open-sse/handlers/responseSanitizer.ts` | Response field stripping, think-tag extraction, ID/usage normalization |
| `open-sse/services/roleNormalizer.ts` | Developer→system, system→user role conversion pipeline |
### 📁 Files Modified
| File | Change |
| ------------------------------------------------- | ------------------------------------------------------------------------------- |
| `open-sse/handlers/chatCore.ts` | Integrated response sanitizer for non-streaming OpenAI responses |
| `open-sse/utils/stream.ts` | Integrated streaming chunk sanitizer + think-tag extraction in passthrough mode |
| `open-sse/translator/index.ts` | Integrated role normalizer into the request translation pipeline |
| `open-sse/translator/request/openai-to-gemini.ts` | Added `response_format``responseMimeType`/`responseSchema` conversion |
---
## [1.0.0] — 2026-02-18
> ### 🎉 First Major Release — OmniRoute 1.0
@@ -138,4 +424,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
[1.0.7]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.7
[1.0.6]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.6
[1.0.5]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.5
[1.0.4]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.4
[1.1.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.1.0
[1.0.3]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.3
[1.0.2]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.2
[1.0.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.0
+1 -1
View File
@@ -159,7 +159,7 @@ src/ # TypeScript (.ts / .tsx)
│ ├── cacheLayer.ts # LRU cache
│ ├── semanticCache.ts # Semantic response cache
│ ├── idempotencyLayer.ts # Request deduplication
│ └── localDb.ts # LowDB (JSON) storage
│ └── localDb.ts # Settings facade (LowDB for config, SQLite for domain data)
├── shared/
│ ├── components/ # React components (.tsx)
│ ├── middleware/ # Correlation IDs, etc.
+2 -1
View File
@@ -20,7 +20,8 @@ ENV NODE_ENV=production
ENV PORT=20128
ENV HOSTNAME=0.0.0.0
# Runtime writable location for localDb when DATA_DIR is configured to /app/data
# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/data
RUN mkdir -p /app/data
COPY --from=builder /app/public ./public
+9 -4
View File
@@ -100,6 +100,7 @@ _Verbinde jedes KI-gesteuerte IDE- oder CLI-Tool über OmniRoute — kostenloses
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 Website](https://omniroute.online) • [🚀 Schnellstart](#-schnellstart) • [💡 Funktionen](#-hauptfunktionen) • [📖 Doku](#-dokumentation) • [💰 Preise](#-preisübersicht)
@@ -242,7 +243,7 @@ docker compose --profile cli up -d
| Image | Tag | Größe | Beschreibung |
| ------------------------ | -------- | ------ | ------------------------ |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Letztes stabiles Release |
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Aktuelle Version |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Aktuelle Version |
---
@@ -892,7 +893,7 @@ Das vorgeladene „OmniRoute Golden Set" enthält 10 Testfälle:
**Verbindungstest zeigt „Invalid" für OpenAI-kompatible Anbieter**
- Viele Anbieter stellen den `/models` Endpoint nicht bereit
- OmniRoute v1.0.0+ enthält Fallback-Validierung via Chat Completions
- OmniRoute v1.0.6+ enthält Fallback-Validierung via Chat Completions
- Stelle sicher, dass die Base URL den `/v1` Suffix enthält
</details>
@@ -902,7 +903,7 @@ Das vorgeladene „OmniRoute Golden Set" enthält 10 Testfälle:
## 🛠️ Technologie-Stack
- **Runtime**: Node.js 20+
- **Sprache**: TypeScript 5.9 — **100% TypeScript** in `src/` und `open-sse/` (v1.0.0)
- **Sprache**: TypeScript 5.9 — **100% TypeScript** in `src/` und `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Datenbank**: LowDB (JSON) + SQLite (Domain-Status + Proxy-Logs)
- **Streaming**: Server-Sent Events (SSE)
@@ -932,9 +933,13 @@ Das vorgeladene „OmniRoute Golden Set" enthält 10 Testfälle:
## 📧 Support
> 💬 **Treten Sie unserer Community bei!** [WhatsApp-Gruppe](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Hilfe bekommen, Tipps teilen und auf dem Laufenden bleiben.
- **Website**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp**: [Community-Gruppe](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **WhatsApp**: [Community-Gruppe](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **Originalprojekt**: [9router von decolua](https://github.com/decolua/9router)
---
@@ -957,7 +962,7 @@ Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für detaillierte Richtlinien.
```bash
# Release erstellen — npm-Veröffentlichung erfolgt automatisch
gh release create v1.0.0 --title "v1.0.0" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+8 -4
View File
@@ -100,6 +100,7 @@ _Conecta cualquier IDE o herramienta CLI con IA a través de OmniRoute — gatew
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 Website](https://omniroute.online) • [🚀 Inicio Rápido](#-inicio-rápido) • [💡 Características](#-características-principales) • [📖 Docs](#-documentación) • [💰 Precios](#-precios-resumidos)
@@ -242,7 +243,7 @@ docker compose --profile cli up -d
| Imagen | Tag | Tamaño | Descripción |
| ------------------------ | -------- | ------ | ---------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Última versión estable |
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Versión actual |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Versión actual |
---
@@ -892,7 +893,7 @@ El "OmniRoute Golden Set" precargado contiene 10 casos de prueba que cubren:
**Prueba de conexión muestra "Invalid" para proveedores compatibles con OpenAI**
- Muchos proveedores no exponen el endpoint `/models`
- OmniRoute v1.0.0+ incluye validación vía chat completions como fallback
- OmniRoute v1.0.6+ incluye validación vía chat completions como fallback
- Asegúrate de que la URL base incluya el sufijo `/v1`
</details>
@@ -902,7 +903,7 @@ El "OmniRoute Golden Set" precargado contiene 10 casos de prueba que cubren:
## 🛠️ Stack Tecnológico
- **Runtime**: Node.js 20+
- **Lenguaje**: TypeScript 5.9 — **100% TypeScript** en `src/` y `open-sse/` (v1.0.0)
- **Lenguaje**: TypeScript 5.9 — **100% TypeScript** en `src/` y `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Base de Datos**: LowDB (JSON) + SQLite (estado del dominio + logs de proxy)
- **Streaming**: Server-Sent Events (SSE)
@@ -932,9 +933,12 @@ El "OmniRoute Golden Set" precargado contiene 10 casos de prueba que cubren:
## 📧 Soporte
> 💬 **¡Únete a la comunidad!** [Grupo WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Obtén ayuda, comparte consejos y mantente al día.
- **Website**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp**: [Grupo de la Comunidad](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **Proyecto Original**: [9router por decolua](https://github.com/decolua/9router)
---
@@ -957,7 +961,7 @@ Consulta [CONTRIBUTING.md](CONTRIBUTING.md) para directrices detalladas.
```bash
# Crea un release — la publicación en npm ocurre automáticamente
gh release create v1.0.0 --title "v1.0.0" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+8 -4
View File
@@ -100,6 +100,7 @@ _Connectez n'importe quel IDE ou outil CLI alimenté par l'IA via OmniRoute —
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 Site web](https://omniroute.online) • [🚀 Démarrage rapide](#-démarrage-rapide) • [💡 Fonctionnalités](#-fonctionnalités-principales) • [📖 Docs](#-documentation) • [💰 Tarifs](#-aperçu-des-tarifs)
@@ -242,7 +243,7 @@ docker compose --profile cli up -d
| Image | Tag | Taille | Description |
| ------------------------ | -------- | ------ | ----------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Dernière version stable |
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Version actuelle |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Version actuelle |
---
@@ -892,7 +893,7 @@ Le « OmniRoute Golden Set » préchargé contient 10 cas de test :
**Le test de connexion affiche « Invalid » pour les fournisseurs compatibles OpenAI**
- Beaucoup de fournisseurs n'exposent pas le point de terminaison `/models`
- OmniRoute v1.0.0+ inclut une validation de secours via chat completions
- OmniRoute v1.0.6+ inclut une validation de secours via chat completions
- Assurez-vous que l'URL de base inclut le suffixe `/v1`
</details>
@@ -902,7 +903,7 @@ Le « OmniRoute Golden Set » préchargé contient 10 cas de test :
## 🛠️ Stack technologique
- **Runtime** : Node.js 20+
- **Langage** : TypeScript 5.9 — **100% TypeScript** dans `src/` et `open-sse/` (v1.0.0)
- **Langage** : TypeScript 5.9 — **100% TypeScript** dans `src/` et `open-sse/` (v1.0.6)
- **Framework** : Next.js 16 + React 19 + Tailwind CSS 4
- **Base de données** : LowDB (JSON) + SQLite (état du domaine + logs proxy)
- **Streaming** : Server-Sent Events (SSE)
@@ -932,9 +933,12 @@ Le « OmniRoute Golden Set » préchargé contient 10 cas de test :
## 📧 Support
> 💬 **Rejoignez notre communauté !** [Groupe WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Obtenez de l'aide, partagez des astuces et restez informé.
- **Site web** : [omniroute.online](https://omniroute.online)
- **GitHub** : [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues** : [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp** : [Groupe communautaire](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **Projet original** : [9router par decolua](https://github.com/decolua/9router)
---
@@ -957,7 +961,7 @@ Consultez [CONTRIBUTING.md](CONTRIBUTING.md) pour les directives détaillées.
```bash
# Créer un release — la publication npm est automatique
gh release create v1.0.0 --title "v1.0.0" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+9 -4
View File
@@ -100,6 +100,7 @@ _Connetti qualsiasi IDE o strumento CLI con IA tramite OmniRoute — gateway API
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 Sito Web](https://omniroute.online) • [🚀 Avvio Rapido](#-avvio-rapido) • [💡 Funzionalità](#-funzionalità-principali) • [📖 Docs](#-documentazione) • [💰 Prezzi](#-panoramica-prezzi)
@@ -242,7 +243,7 @@ docker compose --profile cli up -d
| Immagine | Tag | Dimensione | Descrizione |
| ------------------------ | -------- | ---------- | ----------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Ultima versione stabile |
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Versione attuale |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Versione attuale |
---
@@ -892,7 +893,7 @@ Il "OmniRoute Golden Set" precaricato contiene 10 casi di test:
**Il test di connessione mostra "Invalid" per provider compatibili OpenAI**
- Molti provider non espongono l'endpoint `/models`
- OmniRoute v1.0.0+ include validazione fallback tramite chat completions
- OmniRoute v1.0.6+ include validazione fallback tramite chat completions
- Assicurati che la URL base includa il suffisso `/v1`
</details>
@@ -902,7 +903,7 @@ Il "OmniRoute Golden Set" precaricato contiene 10 casi di test:
## 🛠️ Stack Tecnologico
- **Runtime**: Node.js 20+
- **Linguaggio**: TypeScript 5.9 — **100% TypeScript** in `src/` e `open-sse/` (v1.0.0)
- **Linguaggio**: TypeScript 5.9 — **100% TypeScript** in `src/` e `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Database**: LowDB (JSON) + SQLite (stato dominio + log proxy)
- **Streaming**: Server-Sent Events (SSE)
@@ -932,9 +933,13 @@ Il "OmniRoute Golden Set" precaricato contiene 10 casi di test:
## 📧 Supporto
> 💬 **Unisciti alla nostra community!** [Gruppo WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Ottieni aiuto, condividi consigli e rimani aggiornato.
- **Sito Web**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp**: [Gruppo della comunità](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **WhatsApp**: [Gruppo della comunità](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **Progetto Originale**: [9router di decolua](https://github.com/decolua/9router)
---
@@ -957,7 +962,7 @@ Consulta [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida dettagliate.
```bash
# Crea un rilascio — la pubblicazione npm avviene automaticamente
gh release create v1.0.0 --title "v1.0.0" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+121 -17
View File
@@ -100,8 +100,9 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 Website](https://omniroute.online) • [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Docs](#-documentation) • [💰 Pricing](#-pricing-at-a-glance)
[🌐 Website](https://omniroute.online) • [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Docs](#-documentation) • [💰 Pricing](#-pricing-at-a-glance) • [💬 WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
🌐 **Available in:** [English](README.md) | [Português](README.pt-BR.md) | [Español](README.es.md) | [Русский](README.ru.md) | [中文](README.zh-CN.md) | [Deutsch](README.de.md) | [Français](README.fr.md) | [Italiano](README.it.md)
@@ -242,7 +243,7 @@ docker compose --profile cli up -d
| Image | Tag | Size | Description |
| ------------------------ | -------- | ------ | --------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release |
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Current version |
| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version |
---
@@ -340,7 +341,7 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
| ------------------------------- | ------------------------------------------------------------------------------ |
| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free |
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro seamless |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro seamless + response sanitization |
| 👥 **Multi-Account Support** | Multiple accounts per provider with intelligent selection |
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry |
| 🎨 **Custom Combos** | 6 strategies: fill-first, round-robin, p2c, random, least-used, cost-optimized |
@@ -372,20 +373,23 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
| 🔒 **TLS Fingerprint Spoofing** | Bypass TLS-based bot detection via wreq-js |
| 🌐 **IP Filtering** | Allowlist/blocklist for API access control |
| 📊 **Editable Rate Limits** | Configurable RPM, min gap, and max concurrent at system level |
| 🛡 **API Endpoint Protection** | Auth gating + provider blocking for the `/models` endpoint |
### 📊 Observability & Analytics
| Feature | What It Does |
| ---------------------------- | --------------------------------------------------------------- |
| 📝 **Request Logging** | Debug mode with full request/response logs |
| 💾 **SQLite Proxy Logs** | Persistent proxy logs survive server restarts |
| 📊 **Analytics Dashboard** | Recharts-powered: stat cards, model usage chart, provider table |
| 📈 **Progress Tracking** | Opt-in SSE progress events for streaming |
| 🧪 **LLM Evaluations** | Golden set testing with 4 match strategies |
| 🔍 **Request Telemetry** | p50/p95/p99 latency aggregation + X-Request-Id tracing |
| 📋 **Request Logs + Quotas** | Dedicated pages for log browsing and limits/quotas tracking |
| 🏥 **Health Dashboard** | System uptime, circuit breaker states, lockouts, cache stats |
| 💰 **Cost Tracking** | Budget management + per-model pricing configuration |
| Feature | What It Does |
| -------------------------- | ---------------------------------------------------------------------- |
| 📝 **Request Logging** | Debug mode with full request/response logs |
| 💾 **SQLite Proxy Logs** | Persistent proxy logs survive server restarts |
| 📊 **Analytics Dashboard** | Recharts-powered: stat cards, model usage chart, provider table |
| 📈 **Progress Tracking** | Opt-in SSE progress events for streaming |
| 🧪 **LLM Evaluations** | Golden set testing with 4 match strategies |
| 🔍 **Request Telemetry** | p50/p95/p99 latency aggregation + X-Request-Id tracing |
| 📋 **Logs Dashboard** | Unified 4-tab page: Request Logs, Proxy Logs, Audit Logs, Console |
| 🖥️ **Console Log Viewer** | Real-time terminal-style viewer with level filter, search, auto-scroll |
| 📑 **File-Based Logging** | Console interceptor captures all output to JSON log file with rotation |
| 🏥 **Health Dashboard** | System uptime, circuit breaker states, lockouts, cache stats |
| 💰 **Cost Tracking** | Budget management + per-model pricing configuration |
### ☁️ Deployment & Sync
@@ -429,6 +433,10 @@ Seamless translation between formats:
- **OpenAI****Claude****Gemini** ↔ **OpenAI Responses**
- Your CLI tool sends OpenAI format → OmniRoute translates → Provider receives native format
- Works with any tool that supports custom OpenAI endpoints
- **Response sanitization** — Strips non-standard fields for strict OpenAI SDK compatibility
- **Role normalization**`developer``system` for non-OpenAI; `system``user` for GLM/ERNIE models
- **Think tag extraction**`<think>` blocks → `reasoning_content` for thinking models
- **Structured output**`json_schema` → Gemini's `responseMimeType`/`responseSchema`
### 👥 Multi-Account Support
@@ -851,6 +859,99 @@ The pre-loaded "OmniRoute Golden Set" contains 10 test cases covering:
---
## 🔐 OAuth em Servidor Remoto (Remote OAuth Setup)
<a name="oauth-em-servidor-remoto"></a>
> **⚠️ IMPORTANTE para usuários com OmniRoute em VPS/Docker/servidor remoto**
### Por que o OAuth do Antigravity / Gemini CLI falha em servidores remotos?
Os provedores **Antigravity** e **Gemini CLI** usam **Google OAuth 2.0** para autenticação. O Google exige que a `redirect_uri` usada no fluxo OAuth seja **exatamente** uma das URIs pré-cadastradas no Google Cloud Console do aplicativo.
As credenciais OAuth embutidas no OmniRoute estão cadastradas **apenas para `localhost`**. Quando você acessa o OmniRoute em um servidor remoto (ex: `https://omniroute.meuservidor.com`), o Google rejeita a autenticação com:
```
Error 400: redirect_uri_mismatch
```
### Solução: Configure suas próprias credenciais OAuth
Você precisa criar um **OAuth 2.0 Client ID** no Google Cloud Console com a URI do seu servidor.
#### Passo a passo
**1. Acesse o Google Cloud Console**
Abra: [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)
**2. Crie um novo OAuth 2.0 Client ID**
- Clique em **"+ Create Credentials"** → **"OAuth client ID"**
- Tipo de aplicativo: **"Web application"**
- Nome: escolha qualquer nome (ex: `OmniRoute Remote`)
**3. Adicione as Authorized Redirect URIs**
No campo **"Authorized redirect URIs"**, adicione:
```
https://seu-servidor.com/callback
```
> Substitua `seu-servidor.com` pelo domínio ou IP do seu servidor (inclua a porta se necessário, ex: `http://45.33.32.156:20128/callback`).
**4. Salve e copie as credenciais**
Após criar, o Google mostrará o **Client ID** e o **Client Secret**.
**5. Configure as variáveis de ambiente**
No seu `.env` (ou nas variáveis de ambiente do Docker):
```bash
# Para Antigravity:
ANTIGRAVITY_OAUTH_CLIENT_ID=seu-client-id.apps.googleusercontent.com
ANTIGRAVITY_OAUTH_CLIENT_SECRET=GOCSPX-seu-secret
# Para Gemini CLI:
GEMINI_OAUTH_CLIENT_ID=seu-client-id.apps.googleusercontent.com
GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-seu-secret
GEMINI_CLI_OAUTH_CLIENT_SECRET=GOCSPX-seu-secret
```
**6. Reinicie o OmniRoute**
```bash
# Se usando npm:
npm run dev
# Se usando Docker:
docker restart omniroute
```
**7. Tente conectar novamente**
Dashboard → Providers → Antigravity (ou Gemini CLI) → OAuth
Agora o Google redirecionará corretamente para `https://seu-servidor.com/callback` e a autenticação funcionará.
---
### Workaround temporário (sem configurar credenciais próprias)
Se não quiser criar credenciais próprias agora, ainda é possível usar o fluxo **manual de URL**:
1. O OmniRoute abrirá a URL de autorização do Google
2. Após você autorizar, o Google tentará redirecionar para `localhost` (que falha no servidor remoto)
3. **Copie a URL completa** da barra de endereço do seu browser (mesmo que a página não carregue)
4. Cole essa URL no campo que aparece no modal de conexão do OmniRoute
5. Clique em **"Connect"**
> Este workaround funciona porque o código de autorização na URL é válido independente do redirect ter carregado ou não.
---
## 🐛 Troubleshooting
<details>
@@ -899,7 +1000,7 @@ The pre-loaded "OmniRoute Golden Set" contains 10 test cases covering:
**Connection test shows "Invalid" for OpenAI-compatible providers**
- Many providers don't expose a `/models` endpoint
- OmniRoute v1.0.0+ includes fallback validation via chat completions
- OmniRoute v1.0.6+ includes fallback validation via chat completions
- Ensure base URL includes `/v1` suffix
</details>
@@ -909,7 +1010,7 @@ The pre-loaded "OmniRoute Golden Set" contains 10 test cases covering:
## 🛠️ Tech Stack
- **Runtime**: Node.js 20+
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v1.0.0)
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs)
- **Streaming**: Server-Sent Events (SSE)
@@ -985,9 +1086,12 @@ OmniRoute has **210+ features planned** across multiple development phases. Here
## 📧 Support
> 💬 **Join our community!** [WhatsApp Group](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Get help, share tips, and stay updated.
- **Website**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp**: [Community Group](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **Original Project**: [9router by decolua](https://github.com/decolua/9router)
---
@@ -1010,7 +1114,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
```bash
# Create a release — npm publish happens automatically
gh release create v1.0.0 --title "v1.0.0" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+9 -4
View File
@@ -100,6 +100,7 @@ _Conecte qualquer IDE ou ferramenta CLI com IA através do OmniRoute — gateway
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 Website](https://omniroute.online) • [🚀 Início Rápido](#-início-rápido) • [💡 Funcionalidades](#-funcionalidades-principais) • [📖 Docs](#-documentação) • [💰 Preços](#-preços-resumidos)
@@ -242,7 +243,7 @@ docker compose --profile cli up -d
| Imagem | Tag | Tamanho | Descrição |
| ------------------------ | -------- | ------- | --------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Última versão estável |
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Versão atual |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Versão atual |
---
@@ -372,6 +373,7 @@ Acesso via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
| 🔒 **Spoofing de Fingerprint TLS** | Bypass de detecção de bot via TLS com wreq-js |
| 🌐 **Filtragem de IP** | Allowlist/blocklist para controle de acesso à API |
| 📊 **Rate Limits Editáveis** | RPM, gap mínimo e concorrência máxima configuráveis |
| 🛡 **Proteção de Endpoint API** | Gateway de Auth + bloqueio de provedores para o endpoint `/models` |
### 📊 Observabilidade e Analytics
@@ -899,7 +901,7 @@ O "OmniRoute Golden Set" pré-carregado contém 10 casos de teste cobrindo:
**Teste de conexão mostra "Invalid" para provedores compatíveis com OpenAI**
- Muitos provedores não expõem endpoint `/models`
- OmniRoute v1.0.0+ inclui validação via chat completions como fallback
- OmniRoute v1.0.6+ inclui validação via chat completions como fallback
- Certifique-se de que a base URL inclui sufixo `/v1`
</details>
@@ -909,7 +911,7 @@ O "OmniRoute Golden Set" pré-carregado contém 10 casos de teste cobrindo:
## 🛠️ Stack Tecnológico
- **Runtime**: Node.js 20+
- **Linguagem**: TypeScript 5.9 — **100% TypeScript** em `src/` e `open-sse/` (v1.0.0)
- **Linguagem**: TypeScript 5.9 — **100% TypeScript** em `src/` e `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Banco de Dados**: LowDB (JSON) + SQLite (estado do domínio + logs de proxy)
- **Streaming**: Server-Sent Events (SSE)
@@ -985,9 +987,12 @@ O OmniRoute tem **210+ funcionalidades planejadas** em múltiplas fases de desen
## 📧 Suporte
> 💬 **Participe da comunidade!** [Grupo WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Tire dúvidas, compartilhe dicas e fique atualizado.
- **Website**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp**: [Grupo da Comunidade](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **Projeto Original**: [9router por decolua](https://github.com/decolua/9router)
---
@@ -1010,7 +1015,7 @@ Veja [CONTRIBUTING.md](CONTRIBUTING.md) para diretrizes detalhadas.
```bash
# Crie um release — publicação no npm acontece automaticamente
gh release create v1.0.0 --title "v1.0.0" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+8 -4
View File
@@ -100,6 +100,7 @@ _Подключайте любую IDE или CLI-инструмент с AI ч
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 Сайт](https://omniroute.online) • [🚀 Быстрый старт](#-быстрый-старт) • [💡 Функции](#-основные-функции) • [📖 Документация](#-документация) • [💰 Цены](#-обзор-цен)
@@ -242,7 +243,7 @@ docker compose --profile cli up -d
| Образ | Тег | Размер | Описание |
| ------------------------ | -------- | ------ | -------------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Последний стабильный релиз |
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Текущая версия |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | Текущая версия |
---
@@ -892,7 +893,7 @@ OmniRoute включает встроенный фреймворк оценки
**Тест подключения показывает «Invalid» для OpenAI-совместимых провайдеров**
- Многие провайдеры не предоставляют endpoint `/models`
- OmniRoute v1.0.0+ включает fallback-валидацию через chat completions
- OmniRoute v1.0.6+ включает fallback-валидацию через chat completions
- Убедитесь что base URL содержит суффикс `/v1`
</details>
@@ -902,7 +903,7 @@ OmniRoute включает встроенный фреймворк оценки
## 🛠️ Технологический стек
- **Runtime**: Node.js 20+
- **Язык**: TypeScript 5.9 — **100% TypeScript** в `src/` и `open-sse/` (v1.0.0)
- **Язык**: TypeScript 5.9 — **100% TypeScript** в `src/` и `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **База данных**: LowDB (JSON) + SQLite (состояние домена + proxy-логи)
- **Стриминг**: Server-Sent Events (SSE)
@@ -932,9 +933,12 @@ OmniRoute включает встроенный фреймворк оценки
## 📧 Поддержка
> 💬 **Присоединяйтесь к сообществу!** [Группа WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Получайте помощь, делитесь советами и оставайтесь в курсе.
- **Сайт**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp**: [Группа сообщества](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **Оригинальный проект**: [9router от decolua](https://github.com/decolua/9router)
---
@@ -957,7 +961,7 @@ OmniRoute включает встроенный фреймворк оценки
```bash
# Создайте релиз — публикация в npm происходит автоматически
gh release create v1.0.0 --title "v1.0.0" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+8 -4
View File
@@ -100,6 +100,7 @@ _通过 OmniRoute 连接任何 AI 驱动的 IDE 或 CLI 工具 — 免费 API
[![Docker Hub](https://img.shields.io/docker/v/diegosouzapw/omniroute?label=Docker%20Hub&logo=docker&color=2496ED)](https://hub.docker.com/r/diegosouzapw/omniroute)
[![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online)
[![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[🌐 网站](https://omniroute.online) • [🚀 快速开始](#-快速开始) • [💡 功能特性](#-核心功能) • [📖 文档](#-文档) • [💰 定价](#-定价概览)
@@ -242,7 +243,7 @@ docker compose --profile cli up -d
| 镜像 | 标签 | 大小 | 描述 |
| ------------------------ | -------- | ------ | ---------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | 最新稳定版 |
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | 当前版本 |
| `diegosouzapw/omniroute` | `1.0.6` | ~250MB | 当前版本 |
---
@@ -892,7 +893,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质
**兼容 OpenAI 的提供商连接测试显示 "Invalid"**
- 许多提供商不暴露 `/models` 端点
- OmniRoute v1.0.0+ 包含通过 chat completions 的回退验证
- OmniRoute v1.0.6+ 包含通过 chat completions 的回退验证
- 确保 base URL 包含 `/v1` 后缀
</details>
@@ -902,7 +903,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质
## 🛠️ 技术栈
- **运行时**: Node.js 20+
- **语言**: TypeScript 5.9 — `src/``open-sse/`**100% TypeScript**v1.0.0
- **语言**: TypeScript 5.9 — `src/``open-sse/`**100% TypeScript**v1.0.6
- **框架**: Next.js 16 + React 19 + Tailwind CSS 4
- **数据库**: LowDB (JSON) + SQLite(领域状态 + 代理日志)
- **流式传输**: Server-Sent Events (SSE)
@@ -932,9 +933,12 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质
## 📧 支持
> 💬 **加入我们的社区!** [WhatsApp 群组](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — 获取帮助、分享技巧、了解最新动态。
- **网站**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **WhatsApp**: [社区群组](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
- **原始项目**: [decolua 的 9router](https://github.com/decolua/9router)
---
@@ -957,7 +961,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质
```bash
# 创建发布 — npm 发布自动完成
gh release create v1.0.0 --title "v1.0.0" --generate-notes
gh release create v1.0.6 --title "v1.0.6" --generate-notes
```
---
+3 -3
View File
@@ -20,9 +20,9 @@ If you discover a security vulnerability in OmniRoute, please report it responsi
| Version | Support Status |
| ------- | -------------- |
| 0.8.x | ✅ Active |
| 0.7.x | ✅ Security |
| < 0.7.0 | ❌ Unsupported |
| 1.0.x | ✅ Active |
| 0.8.x | ✅ Security |
| < 0.8.0 | ❌ Unsupported |
---
Submodule
+1
Submodule clipr/9router added at bc91be7305
+1
Submodule clipr/CLIProxyAPI added at 068630dbd0
+2
View File
@@ -18,6 +18,8 @@
x-common: &common
restart: unless-stopped
env_file: .env
environment:
- DATA_DIR=/app/data # Must match the volume mount below
volumes:
- omniroute-data:/app/data
healthcheck:
+15 -3
View File
@@ -1,6 +1,6 @@
# OmniRoute Architecture
_Last updated: 2026-02-17_
_Last updated: 2026-02-18_
## Executive Summary
@@ -17,6 +17,9 @@ Core capabilities:
- Embedding generation via `/v1/embeddings` (6 providers, 9 models)
- Image generation via `/v1/images/generations` (4 providers, 9 models)
- Think tag parsing (`<think>...</think>`) for reasoning models
- Response sanitization for strict OpenAI SDK compatibility
- Role normalization (developer→system, system→user) for cross-provider compatibility
- Structured output conversion (json_schema → Gemini responseSchema)
- Local persistence for providers, keys, aliases, combos, settings, pricing
- Usage/cost tracking and request logging
- Optional cloud sync for multi-device/state sync
@@ -180,6 +183,8 @@ Main flow modules:
- Embedding provider registry: `open-sse/config/embeddingRegistry.ts`
- Image generation handler: `open-sse/handlers/imageGeneration.ts`
- Image provider registry: `open-sse/config/imageRegistry.ts`
- Response sanitization: `open-sse/handlers/responseSanitizer.ts`
- Role normalization: `open-sse/services/roleNormalizer.ts`
Services (business logic):
@@ -647,6 +652,13 @@ Source Format → OpenAI (hub) → Target Format
Translations are selected dynamically based on source payload shape and provider target format.
Additional processing layers in the translation pipeline:
- **Response sanitization** — Strips non-standard fields from OpenAI-format responses (both streaming and non-streaming) to ensure strict SDK compliance
- **Role normalization** — Converts `developer``system` for non-OpenAI targets; merges `system``user` for models that reject the system role (GLM, ERNIE)
- **Think tag extraction** — Parses `<think>...</think>` blocks from content into `reasoning_content` field
- **Structured output** — Converts OpenAI `response_format.json_schema` to Gemini's `responseMimeType` + `responseSchema`
## Supported API Endpoints
| Endpoint | Format | Handler |
@@ -759,8 +771,8 @@ Environment variables actively used by code:
## Operational Verification Checklist
- Build from source: `cd /root/dev/omniroute && npm run build`
- Build Docker image: `cd /root/dev/omniroute && docker build -t omniroute .`
- Build from source: `npm run build`
- Build Docker image: `docker build -t omniroute .`
- Start service and verify:
- `GET /api/settings`
- `GET /api/v1/models`
+1 -1
View File
@@ -46,7 +46,7 @@ Four modes for debugging API translations: **Playground** (format converter), **
## ⚙️ Settings
General settings, system storage, backup management (export/import database), appearance (dark/light mode), security, routing, resilience, and advanced configuration.
General settings, system storage, backup management (export/import database), appearance (dark/light mode), security (includes API endpoint protection and custom provider blocking), routing, resilience, and advanced configuration.
![Settings Dashboard](screenshots/06-settings.png)
+4
View File
@@ -177,6 +177,10 @@ Use **Dashboard → Translator** to debug format translation issues:
- **Thinking tags not appearing** — Check if the target provider supports thinking and the thinking budget setting
- **Tool calls dropping** — Some format translations may strip unsupported fields; verify in Playground mode
- **System prompt missing** — Claude and Gemini handle system prompts differently; check translation output
- **SDK returns raw string instead of object** — Fixed in v1.1.0: response sanitizer now strips non-standard fields (`x_groq`, `usage_breakdown`, etc.) that cause OpenAI SDK Pydantic validation failures
- **GLM/ERNIE rejects `system` role** — Fixed in v1.1.0: role normalizer automatically merges system messages into user messages for incompatible models
- **`developer` role not recognized** — Fixed in v1.1.0: automatically converted to `system` for non-OpenAI providers
- **`json_schema` not working with Gemini** — Fixed in v1.1.0: `response_format` is now converted to Gemini's `responseMimeType` + `responseSchema`
---
+1 -1
View File
@@ -610,7 +610,7 @@ The settings page is organized into 5 tabs for easy navigation:
| Tab | Contents |
| -------------- | ---------------------------------------------------------------------------------------------- |
| **Security** | Login/Password settings and IP Access Control (allowlist/blocklist) |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking |
| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Provider profiles, editable rate limits, circuit breaker status, policies & locked identifiers |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
+37
View File
@@ -0,0 +1,37 @@
# ADR-001: Next.js as the Foundation for an AI Gateway
## Status: Accepted
## Context
OmniRoute is an AI routing gateway that translates, forwards, and manages requests across 20+ LLM providers. We needed a framework that could serve both the API proxy layer and a management dashboard from a single codebase.
**Alternatives considered:**
- **Express.js only** — Simpler proxy, but requires separate frontend tooling
- **Fastify** — Fast, but no built-in SSR/dashboard support
- **Next.js** — Unified full-stack framework with API routes, SSR, and static pages
## Decision
We chose Next.js because:
1. **Single deployment** — API routes (`/api/*`) and dashboard UI in one process
2. **Middleware layer** — Native request interception for auth guards and request tracing
3. **File-based routing** — Easy to map provider endpoints to handlers
4. **Built-in TypeScript** — Type safety across the entire codebase
## Consequences
**Positive:**
- One `npm run build` produces both API and UI
- Middleware provides centralized auth and request tracing
- Dashboard gets automatic code splitting and optimization
**Negative:**
- Next.js middleware has limitations (no heavy imports, edge runtime constraints)
- Serverless deployment model doesn't align with persistent WebSocket/SSE connections
- Build times are longer than Express-only setups
- The SSE proxy layer (`open-sse/`) operates outside Next.js conventions
+37
View File
@@ -0,0 +1,37 @@
# ADR-002: Hub-and-Spoke Translation with OpenAI as Intermediate Format
## Status: Accepted
## Context
OmniRoute routes requests across 20+ providers, each with its own API format (OpenAI, Anthropic Messages, Google Gemini, AWS Bedrock, etc.). Direct provider-to-provider translation would require O(n²) translators.
**Alternatives considered:**
- **Direct translation** — Each pair needs a dedicated translator (n² complexity)
- **Common intermediate format** — Translate to/from a canonical format (2n complexity)
- **Protocol buffers** — Strong typing but heavy overhead for a proxy
## Decision
We use the **OpenAI Chat Completions format** as the canonical intermediate representation. All incoming requests are normalized to OpenAI format, processed, then translated to the target provider's format.
```
Client → [any format] → OpenAI canonical → [target format] → Provider
Provider → [response] → OpenAI canonical → [original format] → Client
```
## Consequences
**Positive:**
- Only 2 translators per provider (inbound + outbound) instead of n² pairs
- OpenAI format is the de facto standard — most clients already use it
- Adding a new provider requires only implementing one translator pair
- Streaming (SSE) works consistently through the canonical format
**Negative:**
- Some provider-specific features may be lost in translation
- The double translation adds latency (typically < 5ms)
- OpenAI format changes require updating the canonical representation
+39
View File
@@ -0,0 +1,39 @@
# ADR-003: Dual Storage — SQLite Primary with JSON Migration Path
## Status: Accepted
## Context
OmniRoute originally used LowDB (JSON file) for all persistence. As the project grew, JSON-based storage became a bottleneck for concurrent access, querying, and data integrity.
**Alternatives considered:**
- **LowDB only** — Simple but no concurrent access, no ACID, no querying
- **SQLite only** — Fast, ACID-compliant, but breaks existing deployments
- **PostgreSQL** — Production-grade but requires external dependency
- **Dual storage with migration** — SQLite primary + automatic JSON migration
## Decision
We migrated to **SQLite as the primary store** with an automatic one-time migration from `db.json`:
1. On startup, if `db.json` exists and SQLite is empty, auto-migrate all data
2. All new reads/writes go through SQLite
3. The `db.json` file is preserved but no longer written to
Settings remain in a hybrid model where LowDB handles simple key-value configuration for backward compatibility.
## Consequences
**Positive:**
- ACID transactions for provider connections, API keys, and usage data
- Proper SQL queries for analytics and log filtering
- Concurrent read/write safety via WAL mode
- Zero-downtime migration from JSON — users upgrade transparently
**Negative:**
- Two storage engines to maintain (SQLite + LowDB for settings)
- Migration code must handle edge cases and partial data
- SQLite binary dependency needed in deployment environments
+15 -4
View File
@@ -1,9 +1,10 @@
import nextVitals from "eslint-config-next/core-web-vitals";
import tseslint from "typescript-eslint";
/** @type {import("eslint").Linter.Config[]} */
const eslintConfig = [
...nextVitals,
// FASE-02: Security rules
// FASE-02: Security rules (strict everywhere)
{
rules: {
"no-eval": "error",
@@ -11,18 +12,28 @@ const eslintConfig = [
"no-new-func": "error",
},
},
// Global ignores
// Relaxed rules for open-sse and tests (incremental adoption)
{
files: ["open-sse/**/*.ts", "tests/**/*.mjs", "tests/**/*.ts"],
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@next/next/no-assign-module-variable": "off",
"react-hooks/rules-of-hooks": "off",
},
},
// Global ignores (open-sse and tests REMOVED — now linted)
{
ignores: [
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"tests/**",
"scripts/**",
"bin/**",
"node_modules/**",
"open-sse/**",
],
},
];
+134
View File
@@ -0,0 +1,134 @@
# OmniRoute
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 36+ AI providers — all through a single OpenAI-compatible endpoint.
## Overview
OmniRoute solves the problem of managing multiple AI provider subscriptions, quotas, and rate limits. It sits between your AI-powered tools (IDE agents, CLI tools) and AI providers, routing requests intelligently through a 4-tier fallback system: Subscription → API Key → Cheap → Free.
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
## Tech Stack
- **Runtime:** Node.js >= 18
- **Framework:** Next.js 16 (App Router) with TypeScript
- **Database:** SQLite via better-sqlite3 (local, zero-config)
- **State management:** Zustand (client), lowdb (server JSON persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
- **Background jobs:** Custom token health check scheduler
- **Streaming:** Server-Sent Events (SSE) for real-time proxy responses
- **Proxy engine:** Custom pipeline with format translation, circuit breaker, rate limiting
- **Package:** Published on npm (`omniroute`) and Docker Hub (`diegosouzapw/omniroute`)
## Project Structure
```
/
├── src/ # Main application source
│ ├── app/ # Next.js App Router pages and API routes
│ │ ├── (dashboard)/ # Dashboard UI pages (providers, combos, analytics, logs, etc.)
│ │ ├── api/ # REST API endpoints
│ │ │ ├── v1/ # OpenAI-compatible API (chat, models, embeddings, images, audio)
│ │ │ ├── oauth/ # OAuth flows per provider (authorize, exchange, callback)
│ │ │ ├── providers/ # Provider CRUD and batch testing
│ │ │ ├── models/ # Dashboard model listing and aliases
│ │ │ ├── combos/ # Combo CRUD (multi-model fallback chains)
│ │ │ └── ... # Other endpoints (usage, logs, health, settings, etc.)
│ │ └── login/ # Login page
│ ├── domain/ # Domain types and business logic interfaces
│ ├── lib/ # Core libraries
│ │ ├── db/ # SQLite database layer (providers, combos, prompts, logs)
│ │ ├── oauth/ # OAuth providers, services, and utilities
│ │ │ ├── providers/ # Provider-specific OAuth configs (GitHub, Google, Claude, etc.)
│ │ │ ├── services/ # Provider-specific token exchange logic
│ │ │ └── utils/ # PKCE, callback server, token helpers
│ │ ├── cloudSync.ts # Cloud sync via Cloudflare Workers
│ │ ├── tokenHealthCheck.ts # Background OAuth token refresh scheduler
│ │ └── localDb.ts # Unified database access layer
│ ├── shared/ # Shared utilities, components, and constants
│ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, etc.)
│ │ ├── constants/ # Provider definitions, model lists, pricing
│ │ └── utils/ # Helpers (auth, CORS, error codes, machine ID)
│ ├── sse/ # SSE proxy pipeline
│ │ ├── services/ # Auth resolution, format translation, response handling
│ │ └── middleware/ # Rate limiting, circuit breaker, caching, idempotency
│ ├── store/ # Zustand client-side stores
│ ├── types/ # TypeScript type definitions
│ ├── proxy.ts # Main proxy request handler
│ └── server-init.ts # Server initialization (DB, health checks)
├── open-sse/ # Standalone SSE server (npm workspace)
│ ├── config/ # Model registries (embedding, image, audio, rerank, moderation)
│ ├── handlers/ # Request handlers per API type
│ └── translators/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses)
├── tests/ # Test suites
│ ├── unit/ # Unit tests (32+ test files)
│ └── integration/ # Integration tests
├── docs/ # Documentation and screenshots
├── bin/ # CLI entry points (omniroute, reset-password)
└── .env.example # Environment variable template
```
## Key Architectural Decisions
1. **OpenAI-compatible API surface:** All incoming requests follow the OpenAI API format (`/v1/chat/completions`, `/v1/models`, etc.). This makes OmniRoute a drop-in replacement for any tool that supports custom OpenAI endpoints.
2. **Provider abstraction via format translators:** Each AI provider (Claude, Gemini, etc.) has a translator in `open-sse/translators/` that converts between the OpenAI format and the provider's native format. This happens transparently.
3. **Connection-based provider model:** Providers are stored as "connections" in SQLite. Each connection has an `id`, `provider`, `authType` (oauth/apikey/free), `isActive` flag, and credentials. Multiple connections per provider are supported for multi-account rotation.
4. **Combo system for fallback:** Users create "combos" — ordered lists of `provider/model` pairs. The proxy tries each in order until one succeeds. Supports 6 strategies: fill-first, round-robin, power-of-two-choices, random, least-used, cost-optimized.
5. **SSE proxy pipeline (`src/sse/`):** The proxy pipeline is middleware-based: request → auth resolution → rate limiting → circuit breaker → format translation → upstream call → response translation → SSE streaming back to client.
6. **SQLite for persistence:** All state (providers, combos, logs, settings) is stored in a single SQLite database file at `data/omniroute.db`. This keeps the app self-contained and zero-config.
7. **OAuth with PKCE:** OAuth flows use PKCE for security. A local callback server (`src/lib/oauth/utils/server.ts`) handles the redirect. Token refresh is handled by a background job (`tokenHealthCheck.ts`).
## Main Flows
### Proxy Request Flow
1. Client sends OpenAI-format request to `/v1/chat/completions`
2. API key validation (`src/shared/utils/apiAuth.ts`)
3. Model resolution: direct model or combo lookup
4. For combos: iterate through models in fallback order
5. Auth resolution: get credentials for the target provider
6. Format translation: OpenAI → provider native format
7. Upstream request with circuit breaker and rate limiting
8. Response translation: provider → OpenAI format
9. SSE streaming back to client
### OAuth Flow
1. Dashboard initiates `/api/oauth/[provider]/authorize`
2. User completes OAuth login in browser
3. Callback hits `/api/oauth/[provider]/exchange`
4. Tokens stored as a provider connection in SQLite
5. Background job refreshes tokens before expiry
### Model Listing
- `/api/models` — Dashboard endpoint, lists all defined models with aliases
- `/v1/models` — OpenAI-compatible endpoint, lists only models from active providers
## Important Notes for LLMs
1. **Two model endpoints exist:** `/api/models` (dashboard, all models) and `/v1/models` (OpenAI-compatible, active only). Don't confuse them.
2. **Provider IDs vs aliases:** Providers have both an ID (`claude`, `github`) and a short alias (`cc`, `gh`). Models are referenced as `alias/model-name` (e.g., `cc/claude-opus-4-6`).
3. **The `open-sse/` directory is a separate npm workspace** with its own config, handlers, and translators. It handles the actual SSE streaming and format translation.
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
5. **Database migrations:** SQLite schema is managed inline in `src/lib/db/core.ts` and `src/lib/db/providers.ts`. No migration framework — schema changes are applied on startup.
6. **Tests use Node.js built-in test runner:** Run `npm test` or `node --test tests/unit/*.test.mjs`. Playwright is used for E2E tests.
7. **The proxy pipeline is in `src/sse/`**, not in `src/app/api/v1/`. The API routes in `src/app/api/v1/` delegate to the SSE server running on a separate Express instance.
## Links
- Repository: https://github.com/diegosouzapw/OmniRoute
- Website: https://omniroute.online
- npm: https://www.npmjs.com/package/omniroute
- Docker Hub: https://hub.docker.com/r/diegosouzapw/omniroute
- Documentation: See `/docs/` directory
+3 -3
View File
@@ -2,12 +2,12 @@
const nextConfig = {
turbopack: {},
output: "standalone",
serverExternalPackages: ["better-sqlite3"],
transpilePackages: ["@omniroute/open-sse"],
allowedDevOrigins: ["192.168.*"],
typescript: {
// Migration Phase: ignore TS errors during build.
// Remove after all 984 type errors are resolved.
ignoreBuildErrors: true,
// All TS errors resolved — strict checking enforced
ignoreBuildErrors: false,
},
images: {
unoptimized: true,
+16 -6
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
/**
* Audio Speech Handler (TTS)
*
@@ -40,7 +41,10 @@ async function handleHyperbolicSpeech(providerConfig, body, token) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -52,7 +56,7 @@ async function handleHyperbolicSpeech(providerConfig, body, token) {
status: 200,
headers: {
"Content-Type": "audio/mpeg",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -77,7 +81,10 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -86,7 +93,7 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
@@ -154,7 +161,10 @@ export async function handleAudioSpeech({ body, credentials }) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -164,7 +174,7 @@ export async function handleAudioSpeech({ body, credentials }) {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
+26 -9
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
/**
* Audio Transcription Handler
*
@@ -46,7 +47,10 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token)
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -54,7 +58,7 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token)
// Transform Deepgram response to OpenAI Whisper format
const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript || "";
return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": "*" } });
return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } });
}
/**
@@ -78,7 +82,10 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
const errText = await uploadRes.text();
return new Response(errText, {
status: uploadRes.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -102,7 +109,10 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
const errText = await submitRes.text();
return new Response(errText, {
status: submitRes.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -124,7 +134,7 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
if (result.status === "completed") {
return Response.json(
{ text: result.text || "" },
{ headers: { "Access-Control-Allow-Origin": "*" } }
{ headers: { "Access-Control-Allow-Origin": getCorsOrigin() } }
);
}
@@ -182,7 +192,11 @@ export async function handleAudioTranscription({ formData, credentials }) {
// Default: OpenAI/Groq-compatible multipart proxy
const upstreamForm = new FormData();
upstreamForm.append("file", /** @type {Blob} */ (file), /** @type {any} */ (file).name || "audio.wav");
upstreamForm.append(
"file",
/** @type {Blob} */ file,
/** @type {any} */ file.name || "audio.wav"
);
upstreamForm.append("model", modelId);
// Forward optional parameters
@@ -195,7 +209,7 @@ export async function handleAudioTranscription({ formData, credentials }) {
]) {
const val = formData.get(key);
if (val !== null && val !== undefined) {
upstreamForm.append(key, /** @type {string} */ (val));
upstreamForm.append(key, /** @type {string} */ val);
}
}
@@ -210,7 +224,10 @@ export async function handleAudioTranscription({ formData, credentials }) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -219,7 +236,7 @@ export async function handleAudioTranscription({ formData, credentials }) {
return new Response(data, {
status: 200,
headers: { "Content-Type": contentType, "Access-Control-Allow-Origin": "*" },
headers: { "Content-Type": contentType, "Access-Control-Allow-Origin": getCorsOrigin() },
});
} catch (err) {
return errorResponse(500, `Transcription request failed: ${err.message}`);
+17 -8
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
import { detectFormat, getTargetFormat } from "../services/provider.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
@@ -24,6 +25,7 @@ import { getExecutor } from "../executors/index.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
import { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } from "./sseParser.ts";
import { sanitizeOpenAIResponse } from "./responseSanitizer.ts";
import {
withRateLimit,
updateFromHeaders,
@@ -81,7 +83,7 @@ export async function handleChatCore({
status: cachedIdemp.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
"X-OmniRoute-Idempotent": "true",
},
}),
@@ -108,8 +110,8 @@ export async function handleChatCore({
const modelTargetFormat = getModelTargetFormat(alias, model);
const targetFormat = modelTargetFormat || getTargetFormat(provider);
// Default to streaming unless client explicitly sets stream: false
const stream = body.stream !== false;
// Default to false unless client explicitly sets stream: true (OpenAI spec compliant)
const stream = body.stream === true;
// ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ──
if (isCacheable(body, clientRawRequest?.headers)) {
@@ -122,7 +124,7 @@ export async function handleChatCore({
response: new Response(JSON.stringify(cached), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
"X-OmniRoute-Cache": "HIT",
},
}),
@@ -188,7 +190,7 @@ export async function handleChatCore({
status: statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}
),
@@ -471,10 +473,17 @@ export async function handleChatCore({
}
// Translate response to client's expected format (usually OpenAI)
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
let translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
: responseBody;
// Sanitize response for OpenAI SDK compatibility
// Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.)
// Extracts <think> tags into reasoning_content
if (sourceFormat === FORMATS.OPENAI) {
translatedResponse = sanitizeOpenAIResponse(translatedResponse);
}
// Add buffer and filter usage for client (to prevent CLI context errors)
if (translatedResponse?.usage) {
const buffered = addBufferToUsage(translatedResponse.usage);
@@ -506,7 +515,7 @@ export async function handleChatCore({
response: new Response(JSON.stringify(translatedResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
"X-OmniRoute-Cache": "MISS",
},
}),
@@ -524,7 +533,7 @@ export async function handleChatCore({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
};
// Create transform stream with logger for streaming response
+6 -2
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
/**
* Moderation Handler
*
@@ -55,13 +56,16 @@ export async function handleModeration({ body, credentials }) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
const data = await res.json();
return Response.json(data, {
headers: { "Access-Control-Allow-Origin": "*" },
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
});
} catch (err) {
return errorResponse(500, `Moderation request failed: ${err.message}`);
+2 -1
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
/**
* Rerank Handler
*
@@ -129,7 +130,7 @@ export async function handleRerank({
const result = transformResponseFromProvider(providerConfig, data);
return Response.json(result, {
headers: { "Access-Control-Allow-Origin": "*" },
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
});
} catch (err) {
return errorResponse(500, `Rerank request failed: ${err.message}`);
+269
View File
@@ -0,0 +1,269 @@
/**
* Response Sanitizer Normalizes LLM responses to strict OpenAI SDK format.
*
* Fixes Issues:
* 1. Strips non-standard fields (x_groq, usage_breakdown, service_tier) that
* break OpenAI Python SDK v1.83+ Pydantic validation (returns str instead of object)
* 2. Extracts <think> tags from thinking models into reasoning_content
* 3. Normalizes response id, object, and usage fields
* 4. Converts developer role system for non-OpenAI providers
*/
// ── Standard OpenAI ChatCompletion fields ──────────────────────────────────
const ALLOWED_TOP_LEVEL_FIELDS = new Set([
"id",
"object",
"created",
"model",
"choices",
"usage",
"system_fingerprint",
]);
const ALLOWED_USAGE_FIELDS = new Set([
"prompt_tokens",
"completion_tokens",
"total_tokens",
"prompt_tokens_details",
"completion_tokens_details",
]);
const ALLOWED_MESSAGE_FIELDS = new Set([
"role",
"content",
"tool_calls",
"function_call",
"refusal",
"reasoning_content",
]);
const ALLOWED_CHOICE_FIELDS = new Set(["index", "message", "delta", "finish_reason", "logprobs"]);
// ── Think tag regex ────────────────────────────────────────────────────────
// Matches <think>...</think> blocks (greedy, dotAll)
const THINK_TAG_REGEX = /<think>([\s\S]*?)<\/think>/gi;
/**
* Extract <think> blocks from text content and return separated parts.
* @returns {{ content: string, thinking: string | null }}
*/
export function extractThinkingFromContent(text: string): {
content: string;
thinking: string | null;
} {
if (!text || typeof text !== "string") {
return { content: text || "", thinking: null };
}
const thinkingParts: string[] = [];
let hasThinkTags = false;
const cleaned = text.replace(THINK_TAG_REGEX, (_, thinkContent) => {
hasThinkTags = true;
const trimmed = thinkContent.trim();
if (trimmed) {
thinkingParts.push(trimmed);
}
return "";
});
if (!hasThinkTags) {
return { content: text, thinking: null };
}
return {
content: cleaned.trim(),
thinking: thinkingParts.length > 0 ? thinkingParts.join("\n\n") : null,
};
}
/**
* Sanitize a non-streaming OpenAI ChatCompletion response.
* Strips non-standard fields and normalizes required fields.
*/
export function sanitizeOpenAIResponse(body: any): any {
if (!body || typeof body !== "object") return body;
// Build sanitized response with only allowed top-level fields
const sanitized: Record<string, any> = {};
// Ensure required fields exist
sanitized.id = normalizeResponseId(body.id);
sanitized.object = body.object || "chat.completion";
sanitized.created = body.created || Math.floor(Date.now() / 1000);
sanitized.model = body.model || "unknown";
// Sanitize choices
if (Array.isArray(body.choices)) {
sanitized.choices = body.choices.map((choice: any, idx: number) => sanitizeChoice(choice, idx));
} else {
sanitized.choices = [];
}
// Sanitize usage
if (body.usage && typeof body.usage === "object") {
sanitized.usage = sanitizeUsage(body.usage);
}
// Keep system_fingerprint if present (it's a valid OpenAI field)
if (body.system_fingerprint) {
sanitized.system_fingerprint = body.system_fingerprint;
}
return sanitized;
}
/**
* Sanitize a single choice object.
*/
function sanitizeChoice(choice: any, defaultIndex: number): any {
const sanitized: Record<string, any> = {
index: choice.index ?? defaultIndex,
finish_reason: choice.finish_reason || null,
};
// Sanitize message (non-streaming) or delta (streaming)
if (choice.message) {
sanitized.message = sanitizeMessage(choice.message);
}
if (choice.delta) {
sanitized.delta = sanitizeMessage(choice.delta);
}
// Keep logprobs if present
if (choice.logprobs !== undefined) {
sanitized.logprobs = choice.logprobs;
}
return sanitized;
}
/**
* Sanitize a message object, extracting <think> tags if present.
*/
function sanitizeMessage(msg: any): any {
if (!msg || typeof msg !== "object") return msg;
const sanitized: Record<string, any> = {};
// Copy only allowed fields
if (msg.role) sanitized.role = msg.role;
if (msg.refusal !== undefined) sanitized.refusal = msg.refusal;
// Handle content — extract <think> tags
if (typeof msg.content === "string") {
const { content, thinking } = extractThinkingFromContent(msg.content);
sanitized.content = content;
// Set reasoning_content from <think> tags (if not already set)
if (thinking && !msg.reasoning_content) {
sanitized.reasoning_content = thinking;
}
} else if (msg.content !== undefined) {
sanitized.content = msg.content;
}
// Preserve existing reasoning_content (from providers that natively support it)
if (msg.reasoning_content && !sanitized.reasoning_content) {
sanitized.reasoning_content = msg.reasoning_content;
}
// Preserve tool_calls
if (msg.tool_calls) {
sanitized.tool_calls = msg.tool_calls;
}
// Preserve function_call (legacy)
if (msg.function_call) {
sanitized.function_call = msg.function_call;
}
return sanitized;
}
/**
* Sanitize usage object keep only standard fields.
*/
function sanitizeUsage(usage: any): any {
if (!usage || typeof usage !== "object") return usage;
const sanitized: Record<string, any> = {};
for (const key of ALLOWED_USAGE_FIELDS) {
if (usage[key] !== undefined) {
sanitized[key] = usage[key];
}
}
// Ensure required fields
if (sanitized.prompt_tokens === undefined) sanitized.prompt_tokens = 0;
if (sanitized.completion_tokens === undefined) sanitized.completion_tokens = 0;
if (sanitized.total_tokens === undefined) {
sanitized.total_tokens = sanitized.prompt_tokens + sanitized.completion_tokens;
}
return sanitized;
}
/**
* Normalize response ID to use chatcmpl- prefix.
*/
function normalizeResponseId(id: any): string {
if (!id || typeof id !== "string") {
return `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 29)}`;
}
// Already correct format
if (id.startsWith("chatcmpl-")) return id;
// Keep custom IDs but don't break them
return id;
}
/**
* Sanitize a streaming SSE chunk for passthrough mode.
* Lighter than full sanitization only strips problematic extra fields.
*/
export function sanitizeStreamingChunk(parsed: any): any {
if (!parsed || typeof parsed !== "object") return parsed;
// Build sanitized chunk
const sanitized: Record<string, any> = {};
// Keep only standard fields
if (parsed.id !== undefined) sanitized.id = parsed.id;
sanitized.object = parsed.object || "chat.completion.chunk";
if (parsed.created !== undefined) sanitized.created = parsed.created;
if (parsed.model !== undefined) sanitized.model = parsed.model;
// Sanitize choices with delta
if (Array.isArray(parsed.choices)) {
sanitized.choices = parsed.choices.map((choice: any) => {
const c: Record<string, any> = {
index: choice.index ?? 0,
};
if (choice.delta !== undefined) {
c.delta = {};
const delta = choice.delta;
if (delta.role !== undefined) c.delta.role = delta.role;
if (delta.content !== undefined) c.delta.content = delta.content;
if (delta.reasoning_content !== undefined)
c.delta.reasoning_content = delta.reasoning_content;
if (delta.tool_calls !== undefined) c.delta.tool_calls = delta.tool_calls;
if (delta.function_call !== undefined) c.delta.function_call = delta.function_call;
}
if (choice.finish_reason !== undefined) c.finish_reason = choice.finish_reason;
if (choice.logprobs !== undefined) c.logprobs = choice.logprobs;
return c;
});
}
// Sanitize usage if present
if (parsed.usage && typeof parsed.usage === "object") {
sanitized.usage = sanitizeUsage(parsed.usage);
}
// Keep system_fingerprint if present
if (parsed.system_fingerprint) {
sanitized.system_fingerprint = parsed.system_fingerprint;
}
return sanitized;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
/**
* Responses API Handler for Workers
* Converts Chat Completions to Codex Responses API format
@@ -75,7 +76,7 @@ export async function handleResponsesCore({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};
+169
View File
@@ -0,0 +1,169 @@
/**
* Role Normalizer Converts message roles for provider compatibility.
*
* Fixes Issues:
* 1. GLM/ZhipuAI rejects `system` role merged into first `user` message
* 2. OpenAI `developer` role not understood by non-OpenAI providers normalized to `system`
* 3. Some providers don't support `system` role at all prepended to user message
*
* Provider capability matrix is defined here rather than in the registry to
* avoid breaking changes to the existing RegistryEntry interface.
*/
// ── Provider capabilities ──────────────────────────────────────────────────
/**
* Providers that do NOT support the `system` role in messages.
* For these, system messages are merged into the first user message.
*
* Note: This applies only to OpenAI-format passthrough providers.
* Claude and Gemini have their own system message handling in dedicated translators.
*/
const PROVIDERS_WITHOUT_SYSTEM_ROLE = new Set([
// Known to reject system role (from troubleshooting report)
// GLM uses Claude format, so this is handled through claude translator
// But if accessed through OpenAI-format providers like nvidia, it needs this:
]);
/**
* Models that are known to reject the `system` role regardless of provider.
* Uses prefix matching (e.g., "glm-" matches "glm-4.7", "glm-4.5", etc.)
*/
const MODELS_WITHOUT_SYSTEM_ROLE = [
"glm-", // ZhipuAI GLM models
"ernie-", // Baidu ERNIE models
];
/**
* Check if a provider+model combo supports the system role.
*/
function supportsSystemRole(provider: string, model: string): boolean {
if (PROVIDERS_WITHOUT_SYSTEM_ROLE.has(provider)) return false;
const modelLower = (model || "").toLowerCase();
for (const prefix of MODELS_WITHOUT_SYSTEM_ROLE) {
if (modelLower.startsWith(prefix)) return false;
}
return true;
}
/**
* Normalize the `developer` role to `system` for non-OpenAI providers.
* OpenAI introduced `developer` as a replacement for `system` in newer models,
* but most other providers still expect `system`.
*
* @param messages - Array of messages
* @param targetFormat - The target format (e.g., "openai", "claude", "gemini")
* @returns Modified messages array
*/
export function normalizeDeveloperRole(messages: any[], targetFormat: string): any[] {
if (!Array.isArray(messages)) return messages;
// For OpenAI format, keep developer role as-is (it's valid)
// For all other formats, convert developer → system
if (targetFormat === "openai") return messages;
return messages.map((msg) => {
if (msg.role === "developer") {
return { ...msg, role: "system" };
}
return msg;
});
}
/**
* Convert `system` messages to user messages for providers that don't support
* the system role. The system content is prepended to the first user message
* with a clear delimiter.
*
* @param messages - Array of messages
* @param provider - Provider name
* @param model - Model name
* @returns Modified messages array
*/
export function normalizeSystemRole(messages: any[], provider: string, model: string): any[] {
if (!Array.isArray(messages) || messages.length === 0) return messages;
if (supportsSystemRole(provider, model)) return messages;
// Extract system messages
const systemMessages = messages.filter((m) => m.role === "system" || m.role === "developer");
if (systemMessages.length === 0) return messages;
// Build system content string
const systemContent = systemMessages
.map((m) => {
if (typeof m.content === "string") return m.content;
if (Array.isArray(m.content)) {
return m.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("\n");
}
return "";
})
.filter(Boolean)
.join("\n\n");
if (!systemContent) {
return messages.filter((m) => m.role !== "system" && m.role !== "developer");
}
// Remove system messages and merge into first user message
const nonSystemMessages = messages.filter((m) => m.role !== "system" && m.role !== "developer");
// Find first user message and prepend system content
const firstUserIdx = nonSystemMessages.findIndex((m) => m.role === "user");
if (firstUserIdx >= 0) {
const userMsg = nonSystemMessages[firstUserIdx];
const userContent =
typeof userMsg.content === "string"
? userMsg.content
: Array.isArray(userMsg.content)
? userMsg.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("\n")
: "";
nonSystemMessages[firstUserIdx] = {
...userMsg,
content: `[System Instructions]\n${systemContent}\n\n[User Message]\n${userContent}`,
};
} else {
// No user message found — insert as a user message at the beginning
nonSystemMessages.unshift({
role: "user",
content: `[System Instructions]\n${systemContent}`,
});
}
return nonSystemMessages;
}
/**
* Full role normalization pipeline.
* Call this before sending the request to the provider.
*
* @param messages - Array of messages
* @param provider - Provider name/id
* @param model - Model name
* @param targetFormat - Target API format
* @returns Normalized messages array
*/
export function normalizeRoles(
messages: any[],
provider: string,
model: string,
targetFormat: string
): any[] {
if (!Array.isArray(messages)) return messages;
// Step 1: Normalize developer → system (for non-OpenAI formats)
let result = normalizeDeveloperRole(messages, targetFormat);
// Step 2: Normalize system → user (for providers that don't support system role)
result = normalizeSystemRole(result, provider, model);
return result;
}
+6
View File
@@ -4,6 +4,7 @@ import { prepareClaudeRequest } from "./helpers/claudeHelper.ts";
import { filterToOpenAIFormat } from "./helpers/openaiHelper.ts";
import { normalizeThinkingConfig } from "../services/provider.ts";
import { applyThinkingBudget } from "../services/thinkingBudget.ts";
import { normalizeRoles } from "../services/roleNormalizer.ts";
// Registry for translators.
// NOTE: translator modules import this file and call register() at module-load time.
@@ -132,6 +133,11 @@ export function translateRequest(
// Fix missing tool responses (insert empty tool_result if needed)
fixMissingToolResponses(result);
// Normalize roles: developer→system for non-OpenAI, system→user for incompatible models
if (result.messages && Array.isArray(result.messages)) {
result.messages = normalizeRoles(result.messages, provider || "", model || "", targetFormat);
}
// If same format, skip translation steps
if (sourceFormat !== targetFormat) {
// Check for direct translation path first (e.g., Claude → Gemini)
@@ -199,6 +199,24 @@ function openaiToGeminiBase(model, body, stream) {
}
}
// Convert response_format to Gemini's responseMimeType/responseSchema
if (body.response_format) {
if (body.response_format.type === "json_schema" && body.response_format.json_schema) {
result.generationConfig.responseMimeType = "application/json";
// Extract the schema (may be nested under .schema key)
const schema = body.response_format.json_schema.schema || body.response_format.json_schema;
if (schema && typeof schema === "object") {
// Remove unsupported keywords for Gemini (it uses a subset of JSON Schema)
const { $schema, additionalProperties, ...cleanSchema } = schema;
result.generationConfig.responseSchema = cleanSchema;
}
} else if (body.response_format.type === "json_object") {
result.generationConfig.responseMimeType = "application/json";
} else if (body.response_format.type === "text") {
result.generationConfig.responseMimeType = "text/plain";
}
}
return result;
}
+2 -4
View File
@@ -6,6 +6,7 @@
"allowJs": true,
"checkJs": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": false,
@@ -18,8 +19,5 @@
"@omniroute/open-sse/*": ["./open-sse/*"]
}
},
"include": [
"**/*.ts",
"**/*.js"
]
"include": ["**/*.ts", "**/*.js"]
}
+4 -3
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "./cors.ts";
import { detectFormat } from "../services/provider.ts";
import { translateResponse, initState } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
@@ -122,7 +123,7 @@ function createNonStreamingResponse(sourceFormat, model) {
response: new Response(JSON.stringify(openaiResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};
@@ -156,7 +157,7 @@ function createNonStreamingResponse(sourceFormat, model) {
response: new Response(JSON.stringify(finalResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};
@@ -204,7 +205,7 @@ function createStreamingResponse(sourceFormat, model) {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};
+22
View File
@@ -0,0 +1,22 @@
/**
* CORS configuration for open-sse handlers.
*
* Reads `CORS_ORIGIN` env var (default: "*") so that all handlers
* use the same configurable origin. Equivalent to src/shared/utils/cors.ts
* for the open-sse package boundary.
*/
const CORS_ORIGIN = process.env.CORS_ORIGIN || "*";
export const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, x-api-key, anthropic-version",
};
/**
* Returns just the origin header for merging into existing header objects.
*/
export function getCorsOrigin(): string {
return CORS_ORIGIN;
}
+7 -2
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "./cors.ts";
import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/constants.ts";
/**
@@ -33,7 +34,7 @@ export function errorResponse(statusCode, message) {
status: statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -131,7 +132,11 @@ export async function parseUpstreamError(response, provider = null) {
* @param {number|null} retryAfterMs - Optional retry-after time in milliseconds
* @returns {{ success: false, status: number, error: string, response: Response, retryAfterMs?: number }}
*/
export function createErrorResult(statusCode: number, message: string, retryAfterMs: number | null = null) {
export function createErrorResult(
statusCode: number,
message: string,
retryAfterMs: number | null = null
) {
const result: Record<string, any> = {
success: false,
status: statusCode,
+5 -1
View File
@@ -1,3 +1,4 @@
import { getCorsOrigin } from "./cors.ts";
// Transform OpenAI SSE stream to Ollama JSON lines format
export function transformToOllama(response, model) {
let buffer = "";
@@ -85,6 +86,9 @@ export function transformToOllama(response, model) {
});
return new Response(response.body.pipeThrough(transform), {
headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" },
headers: {
"Content-Type": "application/x-ndjson",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
+18 -1
View File
@@ -12,6 +12,10 @@ import {
} from "./usageTracking.ts";
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.ts";
import { STREAM_IDLE_TIMEOUT_MS, HTTP_STATUS } from "../config/constants.ts";
import {
sanitizeStreamingChunk,
extractThinkingFromContent,
} from "../handlers/responseSanitizer.ts";
export { COLORS, formatSSE };
@@ -130,7 +134,10 @@ export function createSSEStream(options: any = {}) {
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
try {
const parsed = JSON.parse(trimmed.slice(5).trim());
let parsed = JSON.parse(trimmed.slice(5).trim());
// Sanitize: strip non-standard fields for OpenAI SDK compatibility
parsed = sanitizeStreamingChunk(parsed);
const idFixed = fixInvalidId(parsed);
@@ -139,6 +146,16 @@ export function createSSEStream(options: any = {}) {
}
const delta = parsed.choices?.[0]?.delta;
// Extract <think> tags from streaming content
if (delta?.content && typeof delta.content === "string") {
const { content, thinking } = extractThinkingFromContent(delta.content);
delta.content = content;
if (thinking && !delta.reasoning_content) {
delta.reasoning_content = thinking;
}
}
const content = delta?.content || delta?.reasoning_content;
if (content && typeof content === "string") {
totalContentLength += content.length;
+185 -171
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "1.0.1",
"version": "1.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "1.0.1",
"version": "1.0.6",
"license": "MIT",
"workspaces": [
"open-sse"
@@ -58,7 +58,8 @@
"prettier": "^3.8.1",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0"
},
"engines": {
"node": ">=18.0.0"
@@ -2489,15 +2490,79 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz",
"integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==",
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
"integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.55.0",
"@typescript-eslint/types": "^8.55.0",
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/type-utils": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.56.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz",
"integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.56.0",
"@typescript-eslint/types": "^8.56.0",
"debug": "^4.4.3"
},
"engines": {
@@ -2512,14 +2577,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz",
"integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz",
"integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0"
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2530,9 +2595,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz",
"integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz",
"integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2546,10 +2611,35 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz",
"integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz",
"integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
"integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2561,16 +2651,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz",
"integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz",
"integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.55.0",
"@typescript-eslint/tsconfig-utils": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"@typescript-eslint/project-service": "8.56.0",
"@typescript-eslint/tsconfig-utils": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"debug": "^4.4.3",
"minimatch": "^9.0.5",
"semver": "^7.7.3",
@@ -2627,15 +2717,17 @@
"node": ">=10"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz",
"integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==",
"node_modules/@typescript-eslint/utils": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
"integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.55.0",
"eslint-visitor-keys": "^4.2.1"
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2643,6 +2735,41 @@
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz",
"integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.56.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
"integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
@@ -3713,7 +3840,7 @@
}
},
"node_modules/content-type": {
"version": "1.0.5",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
@@ -4725,16 +4852,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-config-next/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/eslint-config-next/node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -4753,133 +4870,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/eslint-config-next/node_modules/typescript-eslint": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz",
"integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.55.0",
"@typescript-eslint/parser": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/utils": "8.55.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
"integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/type-utils": "8.55.0",
"@typescript-eslint/utils": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.55.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz",
"integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/utils": "8.55.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz",
"integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@@ -9615,6 +9605,30 @@
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz",
"integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/utils": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "1.0.1",
"version": "1.0.10",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -53,7 +53,7 @@
"test:fixes": "node --test tests/unit/fixes-p1.test.mjs",
"test:security": "node --test tests/unit/security-fase01.test.mjs",
"test:e2e": "npx playwright test",
"test:coverage": "npx c8 --exclude=open-sse --check-coverage --lines 30 --functions 30 --branches 30 node --import tsx/esm --test tests/unit/*.test.mjs",
"test:coverage": "npx c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
"test:all": "npm run test:unit && npm run test:e2e",
"check": "npm run lint && npm run test",
"prepublishOnly": "npm run build:cli",
@@ -102,7 +102,8 @@
"prettier": "^3.8.1",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,mjs}": [
@@ -158,10 +158,10 @@ export default function HomePageClient({ machineId }) {
<span className="font-semibold">1. Create API key</span>
<p className="text-text-muted mt-0.5">
Go to{" "}
<Link href="/dashboard/settings" className="text-primary hover:underline">
Settings
<Link href="/dashboard/endpoint" className="text-primary hover:underline">
Endpoint
</Link>{" "}
API Keys. Generate one key per environment.
Registered Keys. Generate one key per environment.
</p>
</div>
</li>
@@ -0,0 +1,15 @@
"use client";
export default function AnalyticsLoading() {
return (
<div className="space-y-6 animate-pulse p-6">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-40" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-24 bg-gray-200 dark:bg-gray-700 rounded-lg" />
))}
</div>
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
);
}
@@ -0,0 +1,240 @@
"use client";
/**
* Audit Log Viewer P-2
*
* Dashboard page for viewing administrative audit log entries.
* Fetches from /api/compliance/audit-log with filter support.
*/
import { useState, useEffect, useCallback } from "react";
interface AuditEntry {
id: number;
timestamp: string;
action: string;
actor: string;
target: string | null;
details: any;
ip_address: string | null;
}
const PAGE_SIZE = 25;
export default function AuditLogPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionFilter, setActionFilter] = useState("");
const [actorFilter, setActorFilter] = useState("");
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const fetchEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (actionFilter) params.set("action", actionFilter);
if (actorFilter) params.set("actor", actorFilter);
params.set("limit", String(PAGE_SIZE + 1));
params.set("offset", String(offset));
const res = await fetch(`/api/compliance/audit-log?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: AuditEntry[] = await res.json();
setHasMore(data.length > PAGE_SIZE);
setEntries(data.slice(0, PAGE_SIZE));
} catch (err: any) {
setError(err.message || "Failed to fetch audit log");
} finally {
setLoading(false);
}
}, [actionFilter, actorFilter, offset]);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
const handleSearch = () => {
setOffset(0);
fetchEntries();
};
const formatTimestamp = (ts: string) => {
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
};
const actionBadgeColor = (action: string) => {
if (action.includes("delete") || action.includes("remove"))
return "bg-red-500/15 text-red-400 border-red-500/20";
if (action.includes("create") || action.includes("add"))
return "bg-green-500/15 text-green-400 border-green-500/20";
if (action.includes("update") || action.includes("change"))
return "bg-blue-500/15 text-blue-400 border-blue-500/20";
if (action.includes("login") || action.includes("auth"))
return "bg-purple-500/15 text-purple-400 border-purple-500/20";
return "bg-gray-500/15 text-gray-400 border-gray-500/20";
};
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-main)]">
Audit Log
</h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
Administrative actions and security events
</p>
</div>
<button
onClick={fetchEntries}
disabled={loading}
aria-label="Refresh audit log"
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
{/* Filters */}
<div
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
role="search"
aria-label="Filter audit log entries"
>
<input
type="text"
placeholder="Filter by action..."
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by action type"
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<input
type="text"
placeholder="Filter by actor..."
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by actor"
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<button
onClick={handleSearch}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Search
</button>
</div>
{/* Error */}
{error && (
<div
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
role="alert"
>
{error}
</div>
)}
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Timestamp
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Action
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Actor
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Target
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Details
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
IP
</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && !loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
No audit log entries found
</td>
</tr>
) : (
entries.map((entry) => (
<tr
key={entry.id}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-muted)] font-mono text-xs">
{formatTimestamp(entry.timestamp)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
>
{entry.action}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-main)]">
{entry.actor}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
{entry.target || "—"}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
{entry.details ? JSON.stringify(entry.details) : "—"}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
{entry.ip_address || "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--color-text-muted)]">
Showing {entries.length} entries (offset {offset})
</p>
<div className="flex gap-2">
<button
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Previous
</button>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Next
</button>
</div>
</div>
</div>
);
}
+72 -41
View File
@@ -6,6 +6,7 @@ import {
Button,
Modal,
Input,
Toggle,
CardSkeleton,
ModelSelectModal,
ProxyConfigModal,
@@ -170,6 +171,25 @@ export default function CombosPage() {
}
};
const handleToggleCombo = async (combo) => {
const newActive = combo.isActive === false ? true : false;
// Optimistic update
setCombos((prev) => prev.map((c) => (c.id === combo.id ? { ...c, isActive: newActive } : c)));
try {
await fetch(`/api/combos/${combo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: newActive }),
});
} catch (error) {
// Revert on error
setCombos((prev) =>
prev.map((c) => (c.id === combo.id ? { ...c, isActive: !newActive } : c))
);
notify.error("Failed to toggle combo");
}
};
if (loading) {
return (
<div className="flex flex-col gap-6">
@@ -219,6 +239,7 @@ export default function CombosPage() {
testing={testingCombo === combo.name}
onProxy={() => setProxyTargetCombo(combo)}
hasProxy={!!proxyConfig?.combos?.[combo.id]}
onToggle={() => handleToggleCombo(combo)}
/>
))}
</div>
@@ -287,12 +308,14 @@ function ComboCard({
testing,
onProxy,
hasProxy,
onToggle,
}) {
const strategy = combo.strategy || "priority";
const models = combo.models || [];
const isDisabled = combo.isActive === false;
return (
<Card padding="sm" className="group">
<Card padding="sm" className={`group ${isDisabled ? "opacity-50" : ""}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Icon */}
@@ -386,47 +409,55 @@ function ComboCard({
</div>
{/* Actions */}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={onTest}
disabled={testing}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-emerald-500 transition-colors"
title="Test combo"
>
<span
className={`material-symbols-outlined text-[16px] ${testing ? "animate-spin" : ""}`}
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<Toggle
size="sm"
checked={!isDisabled}
onChange={onToggle}
title={isDisabled ? "Enable combo" : "Disable combo"}
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onTest}
disabled={testing}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-emerald-500 transition-colors"
title="Test combo"
>
{testing ? "progress_activity" : "play_arrow"}
</span>
</button>
<button
onClick={onDuplicate}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Duplicate"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={onProxy}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Proxy configuration"
>
<span className="material-symbols-outlined text-[16px]">vpn_lock</span>
</button>
<button
onClick={onEdit}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Edit"
>
<span className="material-symbols-outlined text-[16px]">edit</span>
</button>
<button
onClick={onDelete}
className="p-1.5 hover:bg-red-500/10 rounded text-red-500 transition-colors"
title="Delete"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
<span
className={`material-symbols-outlined text-[16px] ${testing ? "animate-spin" : ""}`}
>
{testing ? "progress_activity" : "play_arrow"}
</span>
</button>
<button
onClick={onDuplicate}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Duplicate"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={onProxy}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Proxy configuration"
>
<span className="material-symbols-outlined text-[16px]">vpn_lock</span>
</button>
<button
onClick={onEdit}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Edit"
>
<span className="material-symbols-outlined text-[16px]">edit</span>
</button>
<button
onClick={onDelete}
className="p-1.5 hover:bg-red-500/10 rounded text-red-500 transition-colors"
title="Delete"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
</div>
</div>
</div>
</Card>
@@ -0,0 +1,232 @@
"use client";
/**
* Audit Log Tab Embedded version of the audit-log page for the Logs dashboard.
* Fetches from /api/compliance/audit-log with filter support.
*/
import { useState, useEffect, useCallback } from "react";
interface AuditEntry {
id: number;
timestamp: string;
action: string;
actor: string;
target: string | null;
details: any;
ip_address: string | null;
}
const PAGE_SIZE = 25;
export default function AuditLogTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionFilter, setActionFilter] = useState("");
const [actorFilter, setActorFilter] = useState("");
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const fetchEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (actionFilter) params.set("action", actionFilter);
if (actorFilter) params.set("actor", actorFilter);
params.set("limit", String(PAGE_SIZE + 1));
params.set("offset", String(offset));
const res = await fetch(`/api/compliance/audit-log?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: AuditEntry[] = await res.json();
setHasMore(data.length > PAGE_SIZE);
setEntries(data.slice(0, PAGE_SIZE));
} catch (err: any) {
setError(err.message || "Failed to fetch audit log");
} finally {
setLoading(false);
}
}, [actionFilter, actorFilter, offset]);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
const handleSearch = () => {
setOffset(0);
fetchEntries();
};
const formatTimestamp = (ts: string) => {
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
};
const actionBadgeColor = (action: string) => {
if (action.includes("delete") || action.includes("remove"))
return "bg-red-500/15 text-red-400 border-red-500/20";
if (action.includes("create") || action.includes("add"))
return "bg-green-500/15 text-green-400 border-green-500/20";
if (action.includes("update") || action.includes("change"))
return "bg-blue-500/15 text-blue-400 border-blue-500/20";
if (action.includes("login") || action.includes("auth"))
return "bg-purple-500/15 text-purple-400 border-purple-500/20";
return "bg-gray-500/15 text-gray-400 border-gray-500/20";
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-[var(--color-text-main)]">Audit Log</h2>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
Administrative actions and security events
</p>
</div>
<button
onClick={fetchEntries}
disabled={loading}
aria-label="Refresh audit log"
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
{/* Filters */}
<div
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
role="search"
aria-label="Filter audit log entries"
>
<input
type="text"
placeholder="Filter by action..."
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by action type"
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<input
type="text"
placeholder="Filter by actor..."
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by actor"
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<button
onClick={handleSearch}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Search
</button>
</div>
{/* Error */}
{error && (
<div
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
role="alert"
>
{error}
</div>
)}
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Timestamp
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Action
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Actor
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Target
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Details
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">IP</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && !loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
No audit log entries found
</td>
</tr>
) : (
entries.map((entry) => (
<tr
key={entry.id}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-muted)] font-mono text-xs">
{formatTimestamp(entry.timestamp)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
>
{entry.action}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
{entry.target || "—"}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
{entry.details ? JSON.stringify(entry.details) : "—"}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
{entry.ip_address || "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--color-text-muted)]">
Showing {entries.length} entries (offset {offset})
</p>
<div className="flex gap-2">
<button
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Previous
</button>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Next
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import { useState } from "react";
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer";
import AuditLogTab from "./AuditLogTab";
export default function LogsPage() {
const [activeTab, setActiveTab] = useState("request-logs");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "request-logs", label: "Request Logs" },
{ value: "proxy-logs", label: "Proxy Logs" },
{ value: "audit-logs", label: "Audit Logs" },
{ value: "console", label: "Console" },
]}
value={activeTab}
onChange={setActiveTab}
/>
{/* Content */}
{activeTab === "request-logs" && <RequestLoggerV2 />}
{activeTab === "proxy-logs" && <ProxyLogger />}
{activeTab === "audit-logs" && <AuditLogTab />}
{activeTab === "console" && <ConsoleLogViewer />}
</div>
);
}
@@ -71,27 +71,35 @@ export default function OnboardingWizard() {
if (step > 0) setStep(step - 1);
};
const [errorMessage, setErrorMessage] = useState("");
const handleSetPassword = async () => {
if (skipSecurity) {
handleNext();
return;
}
if (password !== confirmPassword) return;
setErrorMessage("");
try {
await fetch("/api/settings/require-login", {
const res = await fetch("/api/settings/require-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requireLogin: true, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorMessage(data.error || "Failed to set password. Try again.");
return;
}
handleNext();
} catch {
// Fallback: skip
handleNext();
setErrorMessage("Connection error. Please try again.");
}
};
const handleAddProvider = async () => {
if (!selectedProvider || !providerKey) return;
setErrorMessage("");
try {
const provider = COMMON_PROVIDERS.find((p) => p.id === selectedProvider);
const defaultUrls = {
@@ -102,7 +110,7 @@ export default function OnboardingWizard() {
groq: "https://api.groq.com/openai",
mistral: "https://api.mistral.ai",
};
await fetch("/api/providers", {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -113,9 +121,14 @@ export default function OnboardingWizard() {
isActive: true,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorMessage(data.error || "Failed to add provider. Try again.");
return;
}
handleNext();
} catch {
handleNext();
setErrorMessage("Connection error. Please try again.");
}
};
@@ -0,0 +1,28 @@
"use client";
export default function ProvidersError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-6">
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load providers
</h2>
<p className="text-gray-600 dark:text-gray-400 max-w-md">
{error.message || "An unexpected error occurred while loading provider data."}
</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
</div>
</div>
);
}
@@ -0,0 +1,14 @@
"use client";
export default function ProvidersLoading() {
return (
<div className="space-y-6 animate-pulse p-6">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
))}
</div>
</div>
);
}
+126 -26
View File
@@ -3,7 +3,16 @@
import { useState, useEffect } from "react";
import Image from "next/image";
import PropTypes from "prop-types";
import { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components";
import {
Card,
CardSkeleton,
Badge,
Button,
Input,
Modal,
Select,
Toggle,
} from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import {
FREE_PROVIDERS,
@@ -134,14 +143,41 @@ export default function ProvidersPage() {
const error = errorConns.length;
const total = providerConnections.length;
// Check if all connections are manually disabled
const allDisabled = total > 0 && providerConnections.every((c) => c.isActive === false);
// Get latest error info
const latestError = errorConns.sort(
(a: any, b: any) => (new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any)
(a: any, b: any) =>
(new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any)
)[0];
const errorCode = latestError ? getConnectionErrorTag(latestError) : null;
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
return { connected, error, total, errorCode, errorTime };
return { connected, error, total, errorCode, errorTime, allDisabled };
};
// Toggle all connections for a provider on/off
const handleToggleProvider = async (providerId: string, authType: string, newActive: boolean) => {
const providerConns = connections.filter(
(c) => c.provider === providerId && c.authType === authType
);
// Optimistically update UI
setConnections((prev) =>
prev.map((c) =>
c.provider === providerId && c.authType === authType ? { ...c, isActive: newActive } : c
)
);
// Fire API calls in parallel
await Promise.allSettled(
providerConns.map((c) =>
fetch(`/api/providers/${c.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: newActive }),
})
)
);
};
const handleBatchTest = async (mode, providerId = null) => {
@@ -233,6 +269,7 @@ export default function ProvidersPage() {
provider={info}
stats={getProviderStats(key, "oauth")}
authType="oauth"
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
/>
))}
</div>
@@ -269,6 +306,7 @@ export default function ProvidersPage() {
provider={info}
stats={getProviderStats(key, "oauth")}
authType="free"
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
/>
))}
</div>
@@ -306,6 +344,7 @@ export default function ProvidersPage() {
provider={info}
stats={getProviderStats(key, "apikey")}
authType="apikey"
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
/>
))}
</div>
@@ -369,6 +408,7 @@ export default function ProvidersPage() {
provider={info}
stats={getProviderStats(info.id, "apikey")}
authType="compatible"
onToggle={(active) => handleToggleProvider(info.id, "apikey", active)}
/>
))}
</div>
@@ -421,8 +461,8 @@ export default function ProvidersPage() {
);
}
function ProviderCard({ providerId, provider, stats, authType }) {
const { connected, error, errorCode, errorTime } = stats;
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const [imgError, setImgError] = useState(false);
const dotColors = {
@@ -437,7 +477,7 @@ function ProviderCard({ providerId, provider, stats, authType }) {
<Link href={`/dashboard/providers/${providerId}`} className="group">
<Card
padding="xs"
className="h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer"
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -470,14 +510,44 @@ function ProviderCard({ providerId, provider, stats, authType }) {
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{getStatusDisplay(connected, error, errorCode)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
{allDisabled ? (
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
Disabled
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</>
)}
</div>
</div>
</div>
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
chevron_right
</span>
<div className="flex items-center gap-2">
{stats.total > 0 && (
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle(!allDisabled ? false : true);
}}
className=""
>
<Toggle
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
/>
</div>
)}
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
chevron_right
</span>
</div>
</div>
</Card>
</Link>
@@ -502,8 +572,8 @@ ProviderCard.propTypes = {
};
// API Key providers - use image with textIcon fallback (same as OAuth providers)
function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
const { connected, error, errorCode, errorTime } = stats;
function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) {
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
const [imgError, setImgError] = useState(false);
@@ -531,7 +601,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
<Link href={`/dashboard/providers/${providerId}`} className="group">
<Card
padding="xs"
className="h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer"
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -564,24 +634,54 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{getStatusDisplay(connected, error, errorCode)}
{isCompatible && (
{allDisabled ? (
<Badge variant="default" size="sm">
{provider.apiType === "responses" ? "Responses" : "Chat"}
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
Disabled
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses" ? "Responses" : "Chat"}
</Badge>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
Messages
</Badge>
)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
Messages
</Badge>
)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</div>
</div>
</div>
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
chevron_right
</span>
<div className="flex items-center gap-2">
{stats.total > 0 && (
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle(!allDisabled ? false : true);
}}
className=""
>
<Toggle
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
/>
</div>
)}
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
chevron_right
</span>
</div>
</div>
</Card>
</Link>
@@ -2,10 +2,12 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import IPFilterSection from "./IPFilterSection";
import SessionInfoCard from "./SessionInfoCard";
export default function SecurityTab() {
const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false });
const [settings, setSettings] = useState<any>({ requireLogin: false, hasPassword: false });
const [loading, setLoading] = useState(true);
const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" });
const [passStatus, setPassStatus] = useState({ type: "", message: "" });
@@ -36,6 +38,29 @@ export default function SecurityTab() {
}
};
const updateSetting = async (key: string, value: any) => {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, [key]: value }));
}
} catch (err) {
console.error(`Failed to update ${key}:`, err);
}
};
const toggleBlockedProvider = (providerId: string) => {
const current: string[] = settings.blockedProviders || [];
const updated = current.includes(providerId)
? current.filter((p) => p !== providerId)
: [...current, providerId];
updateSetting("blockedProviders", updated);
};
const handlePasswordChange = async (e) => {
e.preventDefault();
if (passwords.new !== passwords.confirm) {
@@ -69,6 +94,8 @@ export default function SecurityTab() {
}
};
const blockedProviders: string[] = settings.blockedProviders || [];
return (
<div className="flex flex-col gap-6">
<Card>
@@ -145,6 +172,93 @@ export default function SecurityTab() {
)}
</div>
</Card>
{/* API Endpoint Protection */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
api
</span>
</div>
<h3 className="text-lg font-semibold">API Endpoint Protection</h3>
</div>
<div className="flex flex-col gap-4">
{/* Require auth for /models */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Require API key for /models</p>
<p className="text-sm text-text-muted">
When ON, the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
endpoint returns 404 for unauthenticated requests. Prevents model discovery by
unauthorized users.
</p>
</div>
<Toggle
checked={settings.requireAuthForModels === true}
onChange={() => updateSetting("requireAuthForModels", !settings.requireAuthForModels)}
disabled={loading}
/>
</div>
{/* Blocked Providers */}
<div className="pt-4 border-t border-border/50">
<div className="mb-3">
<p className="font-medium">Blocked Providers</p>
<p className="text-sm text-text-muted">
Hide specific providers from the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
response. Blocked providers will not appear in model listings.
</p>
</div>
<div className="flex flex-wrap gap-2">
{Object.values(AI_PROVIDERS).map((provider: any) => {
const isBlocked = blockedProviders.includes(provider.id);
return (
<button
key={provider.id}
onClick={() => toggleBlockedProvider(provider.id)}
disabled={loading}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all border ${
isBlocked
? "bg-red-500/10 border-red-500/30 text-red-600 dark:text-red-400"
: "bg-black/[0.02] dark:bg-white/[0.02] border-transparent text-text-muted hover:bg-black/[0.05] dark:hover:bg-white/[0.05]"
}`}
title={isBlocked ? `Unblock ${provider.name}` : `Block ${provider.name}`}
>
<span
className="material-symbols-outlined text-[14px]"
style={{ color: isBlocked ? undefined : provider.color }}
>
{isBlocked ? "block" : provider.icon}
</span>
{provider.name}
{isBlocked && (
<span className="material-symbols-outlined text-[12px] text-red-500">
close
</span>
)}
</button>
);
})}
</div>
{blockedProviders.length > 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">warning</span>
{blockedProviders.length} provider{blockedProviders.length !== 1 ? "s" : ""} blocked
from /models
</p>
)}
</div>
</div>
</Card>
<SessionInfoCard />
<IPFilterSection />
</div>
);
@@ -0,0 +1,152 @@
"use client";
/**
* Session Info Card P-3
*
* Displays current session details and provides session management
* controls (logout, clear sessions) within the Security settings tab.
*/
import { useState, useEffect } from "react";
import { Card, Button } from "@/shared/components";
interface SessionInfo {
authenticated: boolean;
loginTime: string | null;
sessionAge: string;
ipAddress: string;
userAgent: string;
}
export default function SessionInfoCard() {
const [session, setSession] = useState<SessionInfo | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function loadSession() {
// Build session info from client-side data
const loginTime = sessionStorage.getItem("omniroute_login_time");
const now = Date.now();
let sessionAge = "Unknown";
if (loginTime) {
const elapsed = now - parseInt(loginTime, 10);
const hours = Math.floor(elapsed / 3600000);
const minutes = Math.floor((elapsed % 3600000) / 60000);
sessionAge = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
let authenticated = false;
try {
const res = await fetch("/api/auth/status", {
method: "GET",
cache: "no-store",
});
if (res.ok) {
const data = await res.json();
authenticated = data.authenticated === true;
}
} catch {
// Keep unauthenticated fallback on network errors.
}
if (cancelled) return;
setSession({
authenticated,
loginTime: loginTime ? new Date(parseInt(loginTime, 10)).toLocaleString() : null,
sessionAge,
ipAddress: "—", // Server-side only
userAgent: navigator.userAgent.split(" ").slice(-2).join(" ") || "Unknown",
});
setLoading(false);
}
loadSession();
return () => {
cancelled = true;
};
}, []);
const handleLogout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
sessionStorage.removeItem("omniroute_login_time");
window.location.href = "/";
} catch {
window.location.href = "/";
}
};
const handleClearStorage = () => {
if (confirm("Clear all local data? This will reset your preferences.")) {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
}
};
if (loading) {
return (
<Card>
<div className="animate-pulse h-32 bg-black/5 dark:bg-white/5 rounded-lg" />
</Card>
);
}
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
person
</span>
</div>
<h3 className="text-lg font-semibold">Session</h3>
</div>
<div className="flex flex-col gap-3" role="list" aria-label="Session details">
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Status</span>
<span className="flex items-center gap-1.5">
<span
className={`w-2 h-2 rounded-full ${session?.authenticated ? "bg-green-500" : "bg-yellow-500"}`}
aria-hidden="true"
/>
{session?.authenticated ? "Authenticated" : "Guest"}
</span>
</div>
{session?.loginTime && (
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Login Time</span>
<span className="font-mono text-xs">{session.loginTime}</span>
</div>
)}
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Session Age</span>
<span className="font-mono text-xs">{session?.sessionAge}</span>
</div>
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Browser</span>
<span className="font-mono text-xs truncate max-w-[200px]">{session?.userAgent}</span>
</div>
</div>
<div className="flex gap-3 mt-4 pt-4 border-t border-border/50">
<Button variant="secondary" onClick={handleClearStorage}>
Clear Local Data
</Button>
{session?.authenticated && (
<Button variant="danger" onClick={handleLogout}>
Logout
</Button>
)}
</div>
</Card>
);
}
@@ -0,0 +1,28 @@
"use client";
export default function SettingsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-6">
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load settings
</h2>
<p className="text-gray-600 dark:text-gray-400 max-w-md">
{error.message || "An unexpected error occurred while loading settings."}
</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
"use client";
export default function SettingsLoading() {
return (
<div className="space-y-6 animate-pulse p-6">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-36" />
<div className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { cn } from "@/shared/utils/cn";
import { APP_CONFIG } from "@/shared/constants/config";
import SystemStorageTab from "./components/SystemStorageTab";
@@ -25,7 +26,10 @@ const tabs = [
];
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState("general");
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const [userSelectedTab, setUserSelectedTab] = useState(null);
const activeTab = userSelectedTab || tabs.find((t) => t.id === tabParam)?.id || "general";
return (
<div className="max-w-2xl mx-auto">
@@ -42,7 +46,7 @@ export default function SettingsPage() {
role="tab"
aria-selected={activeTab === tab.id}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onClick={() => setUserSelectedTab(tab.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all text-sm",
activeTab === tab.id
@@ -184,8 +184,10 @@ export default function EvalsTab() {
// Count total cases and unique models across all suites
const totalCases = suites.reduce((sum, s) => sum + (s.cases?.length || s.caseCount || 0), 0);
const uniqueModels = [
...new Set(suites.flatMap((s) => (s.cases || []).map((c) => c.model).filter(Boolean))),
const uniqueModels: string[] = [
...new Set(
suites.flatMap((s: any) => (s.cases || []).map((c: any) => c.model)).filter(Boolean)
),
];
if (loading) {
@@ -393,8 +395,8 @@ export default function EvalsTab() {
const caseCount = suite.cases?.length || suite.caseCount || 0;
// Count unique models in this suite
const suiteModels = [
...new Set((suite.cases || []).map((c) => c.model).filter(Boolean)),
const suiteModels: string[] = [
...new Set<string>((suite.cases || []).map((c: any) => c.model).filter(Boolean)),
];
return (
+22 -7
View File
@@ -5,12 +5,22 @@ import { SignJWT } from "jose";
import { cookies } from "next/headers";
import { loginSchema, validateBody } from "@/shared/validation/schemas";
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "omniroute-default-secret-change-me"
);
// SECURITY: No hardcoded fallback — JWT_SECRET must be configured.
if (!process.env.JWT_SECRET) {
console.error("[SECURITY] FATAL: JWT_SECRET is not set. Login authentication is disabled.");
}
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "");
export async function POST(request) {
try {
// Fail-fast if JWT_SECRET is not configured
if (!process.env.JWT_SECRET) {
return NextResponse.json(
{ error: "Server misconfigured: JWT_SECRET not set. Contact administrator." },
{ status: 500 }
);
}
const rawBody = await request.json();
// Zod validation
@@ -21,15 +31,20 @@ export async function POST(request) {
const { password } = validation.data;
const settings = await getSettings();
// Default password is '123456' if not set
const storedHash = settings.password;
let isValid = false;
if (storedHash) {
isValid = await bcrypt.compare(password, storedHash);
} else {
// Use env var or default
const initialPassword = process.env.INITIAL_PASSWORD || "123456";
// SECURITY: No default password — must be set via env or onboarding
if (!process.env.INITIAL_PASSWORD) {
return NextResponse.json(
{ error: "No password configured. Complete onboarding first.", needsSetup: true },
{ status: 403 }
);
}
const initialPassword = process.env.INITIAL_PASSWORD;
isValid = password === initialPassword;
}
@@ -42,7 +57,7 @@ export async function POST(request) {
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.setExpirationTime("30d")
.sign(SECRET);
const cookieStore = await cookies();
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
const SECRET = process.env.JWT_SECRET
? new TextEncoder().encode(process.env.JWT_SECRET)
: null;
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
if (!token || !SECRET) {
return NextResponse.json({ authenticated: false });
}
await jwtVerify(token, SECRET);
return NextResponse.json({ authenticated: true });
} catch {
return NextResponse.json({ authenticated: false });
}
}
+12 -4
View File
@@ -20,13 +20,21 @@ export async function POST(request) {
// Get active provider connections
const connections = await getProviderConnections({ isActive: true });
// Map connections
// Helper to mask sensitive values
function maskSecret(value: string | null | undefined): string | null {
if (!value) return null;
if (value.length <= 8) return "****";
return value.slice(0, 4) + "****" + value.slice(-4);
}
// Map connections — NEVER expose raw credentials
const mappedConnections = connections.map((conn) => ({
provider: conn.provider,
authType: conn.authType,
apiKey: conn.apiKey || null,
accessToken: conn.accessToken || null,
refreshToken: conn.refreshToken || null,
hasApiKey: !!conn.apiKey,
hasAccessToken: !!conn.accessToken,
hasRefreshToken: !!conn.refreshToken,
maskedApiKey: maskSecret(conn.apiKey),
projectId: conn.projectId || null,
expiresAt: conn.expiresAt,
priority: conn.priority,
+4 -2
View File
@@ -60,7 +60,7 @@ export async function GET() {
try {
const rows = db
.prepare(
"SELECT id, provider_id, display_name, auth_type, is_enabled, account_email, created_at FROM provider_connections"
"SELECT id, provider, name, auth_type, is_active, email, created_at FROM provider_connections"
)
.all();
providers.push(...rows);
@@ -73,7 +73,9 @@ export async function GET() {
const apiKeys: unknown[] = [];
try {
const rows = db
.prepare("SELECT id, name, prefix, created_at, is_active FROM api_keys")
.prepare(
"SELECT id, name, substr(key, 1, 8) as prefix, machine_id, created_at FROM api_keys"
)
.all();
apiKeys.push(...rows);
} catch {
+6 -1
View File
@@ -8,7 +8,12 @@ import { createKeySchema, validateBody } from "@/shared/validation/schemas";
export async function GET() {
try {
const keys = await getApiKeys();
return NextResponse.json({ keys });
// Mask key values — users should never see full keys after creation
const maskedKeys = keys.map((k) => ({
...k,
key: k.key ? k.key.slice(0, 8) + "****" + k.key.slice(-4) : null,
}));
return NextResponse.json({ keys: maskedKeys });
} catch (error) {
console.log("Error fetching keys:", error);
return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 });
+115
View File
@@ -0,0 +1,115 @@
/**
* Console Log API GET /api/logs/console
*
* Reads the application log file and returns entries from the last 1 hour.
* Supports filtering by level and limiting the number of entries.
*
* Query params:
* - level: minimum log level (debug|info|warn|error) default: all
* - limit: max entries to return default: 500
* - component: filter by component/module name
*/
import { NextRequest, NextResponse } from "next/server";
import { readFileSync, existsSync } from "fs";
import { join } from "path";
const LEVEL_ORDER: Record<string, number> = {
trace: 5,
debug: 10,
info: 20,
warn: 30,
error: 40,
fatal: 50,
};
// Map pino numeric levels to string levels
const NUMERIC_LEVEL_MAP: Record<number, string> = {
10: "trace",
20: "info",
30: "warn",
40: "error",
50: "fatal",
60: "fatal",
};
function getLogFilePath(): string {
return process.env.LOG_FILE_PATH || join(process.cwd(), "logs", "application", "app.log");
}
function parseLevel(raw: string | number): string {
if (typeof raw === "number") {
return NUMERIC_LEVEL_MAP[raw] || "info";
}
return String(raw).toLowerCase();
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const levelFilter = searchParams.get("level") || "all";
const limit = Math.min(parseInt(searchParams.get("limit") || "500", 10), 2000);
const componentFilter = searchParams.get("component") || "";
const logPath = getLogFilePath();
if (!existsSync(logPath)) {
return NextResponse.json([], { status: 200 });
}
const raw = readFileSync(logPath, "utf-8");
const lines = raw.trim().split("\n").filter(Boolean);
const oneHourAgo = Date.now() - 60 * 60 * 1000;
const minLevel = LEVEL_ORDER[levelFilter] || 0;
const entries: any[] = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Filter by time (last 1 hour)
const ts = entry.time || entry.timestamp;
if (ts) {
const entryTime = new Date(ts).getTime();
if (entryTime < oneHourAgo) continue;
}
// Normalize level
entry.level = parseLevel(entry.level);
// Filter by level
const entryLevelNum = LEVEL_ORDER[entry.level] || 0;
if (minLevel > 0 && entryLevelNum < minLevel) continue;
// Filter by component
if (componentFilter) {
const comp = entry.component || entry.module || "";
if (!comp.toLowerCase().includes(componentFilter.toLowerCase())) continue;
}
// Normalize timestamp field
if (entry.time && !entry.timestamp) {
entry.timestamp = entry.time;
}
entries.push(entry);
} catch {
// Skip unparseable lines
}
}
// Return last N entries (most recent)
const result = entries.slice(-limit);
return NextResponse.json(result, {
status: 200,
headers: {
"Cache-Control": "no-store, no-cache, must-revalidate",
},
});
} catch (err: any) {
return NextResponse.json({ error: err.message || "Failed to read logs" }, { status: 500 });
}
}
+22 -5
View File
@@ -1,20 +1,37 @@
import { NextResponse } from "next/server";
import { getModelAliases, setModelAlias } from "@/models";
import { getModelAliases, setModelAlias, getProviderConnections } from "@/models";
import { AI_MODELS } from "@/shared/constants/config";
// GET /api/models - Get models with aliases
export async function GET() {
// GET /api/models - Get models with aliases (only from active providers by default)
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const showAll = searchParams.get("all") === "true";
const modelAliases = await getModelAliases();
const models = AI_MODELS.map((m) => {
// Get active provider connections to filter available models
let activeProviders: Set<string> | null = null;
if (!showAll) {
try {
const connections = await getProviderConnections();
const active = connections.filter((c: any) => c.isActive !== false);
activeProviders = new Set(active.map((c: any) => c.provider));
} catch {
// If DB unavailable, show all models
}
}
const models = AI_MODELS.map((m: any) => {
const fullModel = `${m.provider}/${m.model}`;
const available = !activeProviders || activeProviders.has(m.provider);
return {
...m,
fullModel,
alias: modelAliases[fullModel] || m.model,
available,
};
});
}).filter((m: any) => showAll || m.available);
return NextResponse.json({ models });
} catch (error) {
+42 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { getSettings, updateSettings } from "@/lib/localDb";
import bcrypt from "bcryptjs";
export async function GET() {
try {
@@ -10,3 +11,43 @@ export async function GET() {
return NextResponse.json({ requireLogin: true }, { status: 200 });
}
}
/**
* POST /api/settings/require-login Set password and/or toggle requireLogin.
* Used by the onboarding wizard security step.
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { requireLogin, password } = body;
const updates: Record<string, any> = {};
if (typeof requireLogin === "boolean") {
updates.requireLogin = requireLogin;
}
if (password && typeof password === "string" && password.length >= 4) {
const hashedPassword = await bcrypt.hash(password, 12);
updates.password = hashedPassword;
} else if (password) {
return NextResponse.json(
{ error: "Password must be at least 4 characters" },
{ status: 400 }
);
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "No valid fields to update" }, { status: 400 });
}
await updateSettings(updates);
return NextResponse.json({ success: true });
} catch (error) {
console.error("[API] Error updating require-login settings:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to update settings" },
{ status: 500 }
);
}
}
+1 -6
View File
@@ -1,11 +1,6 @@
import { CORS_HEADERS } from "@/shared/utils/cors";
import { ollamaModels } from "@omniroute/open-sse/config/ollamaModels.ts";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function OPTIONS() {
return new Response(null, { headers: CORS_HEADERS });
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { transformToOllama } from "@omniroute/open-sse/utils/ollamaTransform.ts";
@@ -15,7 +16,7 @@ async function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
+2 -1
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { parseSpeechModel } from "@omniroute/open-sse/config/audioRegistry.ts";
@@ -10,7 +11,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
+2 -1
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleAudioTranscription } from "@omniroute/open-sse/handlers/audioTranscription.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { parseTranscriptionModel } from "@omniroute/open-sse/config/audioRegistry.ts";
@@ -10,7 +11,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
+30 -2
View File
@@ -1,9 +1,14 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { callCloudWithMachineId } from "@/shared/utils/cloud";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
let initPromise = null;
// Singleton injection guard instance
const injectionGuard = createInjectionGuard();
/**
* Initialize translators once (Promise-based singleton no race condition)
*/
@@ -22,7 +27,7 @@ function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -30,8 +35,31 @@ export async function OPTIONS() {
}
export async function POST(request) {
// Fallback to local handling
await ensureInitialized();
// Prompt injection guard — inspect body before forwarding
try {
const cloned = request.clone();
const body = await cloned.json().catch(() => null);
if (body) {
const { blocked, result } = injectionGuard(body);
if (blocked) {
return new Response(
JSON.stringify({
error: {
message: "Request blocked: potential prompt injection detected",
type: "injection_detected",
code: "SECURITY_001",
detections: result.detections.length,
},
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
}
} catch {
// Don't block on guard errors — fail open
}
return await handleChat(request);
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
@@ -15,7 +16,7 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
+5 -4
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { parseImageModel, getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
@@ -12,7 +13,7 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -92,15 +93,15 @@ export async function POST(request) {
const result = await handleImageGeneration({ body, credentials, log });
if (result.success) {
return new Response(JSON.stringify(result.data), {
return new Response(JSON.stringify((result as any).data), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const errorPayload = toJsonErrorPayload(result.error, "Image generation provider error");
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
return new Response(JSON.stringify(errorPayload), {
status: result.status,
status: (result as any).status,
headers: { "Content-Type": "application/json" },
});
}
@@ -1,8 +1,4 @@
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
import { CORS_HEADERS } from "@/shared/utils/cors";
/**
* Handle CORS preflight
+2 -1
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
@@ -20,7 +21,7 @@ async function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
+56 -21
View File
@@ -1,6 +1,8 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { getProviderConnections, getCombos, getAllCustomModels } from "@/lib/localDb";
import { getProviderConnections, getCombos, getAllCustomModels, getSettings } from "@/lib/localDb";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
@@ -82,7 +84,7 @@ function buildAliasMaps() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -93,19 +95,39 @@ export async function OPTIONS() {
* GET /v1/models - OpenAI compatible models list
* Returns models from all active providers, combos, embeddings, and image models in OpenAI format
*/
export async function GET() {
export async function GET(request: Request) {
try {
// Issue #100: Optionally require API key for /models (security hardening)
// When enabled, unauthenticated requests get 404 to hide endpoint existence
let settings: Record<string, any> = {};
try {
settings = await getSettings();
} catch {}
if (settings.requireAuthForModels === true) {
const apiKey = extractApiKey(request);
if (!apiKey || !(await isValidApiKey(apiKey))) {
return new Response("Not Found", { status: 404 });
}
}
const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();
// Issue #96: Allow blocking specific providers from the models list
const blockedProviders: Set<string> = new Set(
Array.isArray(settings.blockedProviders) ? settings.blockedProviders : []
);
// Get active provider connections
let connections = [];
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
try {
connections = await getProviderConnections();
totalConnectionCount = connections.length;
// Filter to only active connections
connections = connections.filter((c) => c.isActive !== false);
} catch (e) {
// If database not available, return all models
console.log("Could not fetch providers, returning all models");
// If database not available, show no provider models (safe default)
console.log("Could not fetch providers, showing only combos/custom models");
}
// Get combos
@@ -128,8 +150,9 @@ export async function GET() {
const models = [];
const timestamp = Math.floor(Date.now() / 1000);
// Add combos first (they appear at the top)
// Add combos first (they appear at the top) — only active ones
for (const combo of combos) {
if (combo.isActive === false) continue;
models.push({
id: combo.name,
object: "model",
@@ -146,12 +169,11 @@ export async function GET() {
const providerId = aliasToProviderId[alias] || alias;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
// If we have active providers, only include those; otherwise include all
if (
connections.length > 0 &&
!activeAliases.has(alias) &&
!activeAliases.has(canonicalProviderId)
) {
// Skip blocked providers (Issue #96)
if (blockedProviders.has(alias) || blockedProviders.has(canonicalProviderId)) continue;
// Only include models from providers with active connections
if (!activeAliases.has(alias) && !activeAliases.has(canonicalProviderId)) {
continue;
}
@@ -183,8 +205,16 @@ export async function GET() {
}
}
// Add embedding models
// Helper: check if a provider is active (by provider id or alias)
const isProviderActive = (provider: string) => {
if (activeAliases.size === 0) return false; // No active connections = show nothing
const alias = providerIdToAlias[provider] || provider;
return activeAliases.has(alias) || activeAliases.has(provider);
};
// Add embedding models (filtered by active providers)
for (const embModel of getAllEmbeddingModels()) {
if (!isProviderActive(embModel.provider)) continue;
models.push({
id: embModel.id,
object: "model",
@@ -195,8 +225,9 @@ export async function GET() {
});
}
// Add image models
// Add image models (filtered by active providers)
for (const imgModel of getAllImageModels()) {
if (!isProviderActive(imgModel.provider)) continue;
models.push({
id: imgModel.id,
object: "model",
@@ -207,8 +238,9 @@ export async function GET() {
});
}
// Add rerank models
// Add rerank models (filtered by active providers)
for (const rerankModel of getAllRerankModels()) {
if (!isProviderActive(rerankModel.provider)) continue;
models.push({
id: rerankModel.id,
object: "model",
@@ -218,8 +250,9 @@ export async function GET() {
});
}
// Add audio models (transcription + speech)
// Add audio models (filtered by active providers)
for (const audioModel of getAllAudioModels()) {
if (!isProviderActive(audioModel.provider)) continue;
models.push({
id: audioModel.id,
object: "model",
@@ -230,8 +263,9 @@ export async function GET() {
});
}
// Add moderation models
// Add moderation models (filtered by active providers)
for (const modModel of getAllModerationModels()) {
if (!isProviderActive(modModel.provider)) continue;
models.push({
id: modModel.id,
object: "model",
@@ -247,11 +281,12 @@ export async function GET() {
for (const [providerId, providerCustomModels] of Object.entries(customModelsMap)) {
const alias = providerIdToAlias[providerId] || providerId;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
// Only include if provider is active (or no connections configured)
// Only include if provider is active — check alias, canonical ID, or raw providerId
// (raw check needed for OpenAI-compatible providers whose ID isn't in the alias map)
if (
connections.length > 0 &&
!activeAliases.has(alias) &&
!activeAliases.has(canonicalProviderId)
!activeAliases.has(canonicalProviderId) &&
!activeAliases.has(providerId)
)
continue;
@@ -298,7 +333,7 @@ export async function GET() {
},
{
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
},
}
);
+2 -1
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleModeration } from "@omniroute/open-sse/handlers/moderations.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts";
@@ -10,7 +11,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
@@ -19,7 +20,7 @@ async function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
@@ -11,7 +12,7 @@ import * as log from "@/sse/utils/logger";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -12,7 +13,7 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -79,15 +80,15 @@ export async function POST(request, { params }) {
const result = await handleImageGeneration({ body, credentials, log });
if (result.success) {
return new Response(JSON.stringify(result.data), {
return new Response(JSON.stringify((result as any).data), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const errorPayload = toJsonErrorPayload(result.error, "Image generation provider error");
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
return new Response(JSON.stringify(errorPayload), {
status: result.status,
status: (result as any).status,
headers: { "Content-Type": "application/json" },
});
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleRerank } from "@omniroute/open-sse/handlers/rerank.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
@@ -10,7 +11,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
+2 -1
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
@@ -14,7 +15,7 @@ async function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
+1 -5
View File
@@ -1,8 +1,4 @@
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
import { CORS_HEADERS } from "@/shared/utils/cors";
/**
* Handle CORS preflight
+2 -1
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
@@ -20,7 +21,7 @@ async function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
+2 -1
View File
@@ -1,3 +1,4 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { PROVIDER_MODELS } from "@/shared/constants/models";
/**
@@ -6,7 +7,7 @@ import { PROVIDER_MODELS } from "@/shared/constants/models";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
+1 -1
View File
@@ -204,7 +204,7 @@ export default function DocsPage() {
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">2. Create API key</span>
<p className="text-text-muted mt-1">
Go to Settings API Keys. Generate one key per environment.
Go to Endpoint Registered Keys. Generate one key per environment.
</p>
</li>
<li className="rounded-lg border border-border p-3 bg-bg">
+64
View File
@@ -0,0 +1,64 @@
"use client";
/**
* Server Error Page P-1
*
* Per-page error boundary for unrecoverable errors within the
* dashboard layout. Falls back to global-error.tsx if this fails.
*/
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ error, reset }: ErrorProps) {
return (
<div
className="flex flex-col items-center justify-center min-h-[60vh] p-6 text-center"
role="alert"
aria-live="assertive"
>
<div className="text-[64px] mb-4" aria-hidden="true">
🔧
</div>
<h1 className="text-[28px] font-bold mb-2 text-[var(--color-text-main)]">
Internal Server Error
</h1>
<p className="text-[15px] text-[var(--color-text-muted)] max-w-[400px] leading-relaxed mb-2">
Something went wrong while processing your request. Our team has been
notified and is working on a fix.
</p>
{error?.digest && (
<p className="text-xs text-[var(--color-text-muted)] mb-6 font-mono">
Error ID: {error.digest}
</p>
)}
{process.env.NODE_ENV === "development" && error?.message && (
<pre
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6"
aria-label="Error details"
>
{error.message}
{error.stack && `\n\n${error.stack}`}
</pre>
)}
<div className="flex gap-3">
<button
onClick={reset}
aria-label="Retry loading the page"
className="px-6 py-2.5 rounded-lg text-white text-sm font-semibold cursor-pointer transition-all duration-200 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Try Again
</button>
<a
href="/dashboard"
className="px-6 py-2.5 rounded-lg text-[var(--color-text-main)] text-sm font-semibold cursor-pointer transition-all duration-200 border border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] no-underline focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
aria-label="Return to dashboard"
>
Go to Dashboard
</a>
</div>
</div>
);
}
+29 -19
View File
@@ -6,29 +6,39 @@
* Root-level error boundary for unrecoverable errors.
* This is the last resort catches errors that the per-page
* error.js boundaries don't handle.
* Styled with TailwindCSS 4 (Phase 7.3).
*/
export default function GlobalError({ error, reset }) {
interface GlobalErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function GlobalError({ error, reset }: GlobalErrorProps) {
return (
<html>
<html lang="en">
<body className="flex flex-col items-center justify-center min-h-screen p-6 bg-[#0a0a0f] text-[#e0e0e0] font-[system-ui,-apple-system,sans-serif] text-center m-0">
<div className="text-[64px] mb-4"></div>
<h1 className="text-[28px] font-bold mb-2">Something went wrong</h1>
<p className="text-[15px] text-[#888] max-w-[400px] leading-relaxed mb-6">
An unexpected error occurred. This has been logged and our team will investigate.
</p>
{process.env.NODE_ENV === "development" && error?.message && (
<pre className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6">
{error.message}
</pre>
)}
<button
onClick={reset}
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6]"
>
Try Again
</button>
<main role="alert" aria-live="assertive" className="flex flex-col items-center">
<div className="text-[64px] mb-4" aria-hidden="true"></div>
<h1 className="text-[28px] font-bold mb-2">Something went wrong</h1>
<p className="text-[15px] text-[#888] max-w-[400px] leading-relaxed mb-6">
An unexpected error occurred. This has been logged and our team will investigate.
</p>
{process.env.NODE_ENV === "development" && error?.message && (
<pre
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6"
aria-label="Error details"
>
{error.message}
</pre>
)}
<button
onClick={reset}
aria-label="Retry loading the page"
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6] focus:outline-2 focus:outline-offset-2 focus:outline-[#6366f1]"
>
Try Again
</button>
</main>
</body>
</html>
);
+52 -3
View File
@@ -9,6 +9,21 @@
--color-primary: #e54d5e;
--color-primary-hover: #c93d4e;
/* Accent - Indigo/Violet (buttons, links, gradients) */
--color-accent: #6366f1;
--color-accent-hover: #8b5cf6;
--color-accent-light: #a855f7;
/* Semantic */
--color-error: #ef4444;
--color-success: #22c55e;
--color-warning: #f59e0b;
/* Traffic lights */
--color-traffic-red: #ff5f56;
--color-traffic-yellow: #ffbd2e;
--color-traffic-green: #27c93f;
/* Light theme */
--color-bg: #f9f9fb;
--color-bg-alt: #f0f0f5;
@@ -45,6 +60,16 @@
--color-primary: var(--color-primary);
--color-primary-hover: var(--color-primary-hover);
/* Accent */
--color-accent: var(--color-accent);
--color-accent-hover: var(--color-accent-hover);
--color-accent-light: var(--color-accent-light);
/* Semantic */
--color-error: var(--color-error);
--color-success: var(--color-success);
--color-warning: var(--color-warning);
/* Auto-switch colors (use CSS variables from :root/.dark) */
--color-bg: var(--color-bg);
--color-surface: var(--color-surface);
@@ -77,6 +102,30 @@
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", system-ui, sans-serif;
}
/* ── Focus Indicators (U-3) ── */
:root {
--focus-ring: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-accent);
}
/* Visible focus ring on keyboard navigation only */
:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: 4px;
}
/* Utility class for custom focus ring */
.focus-ring:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* Remove focus ring from mouse clicks */
:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
/* Base styles */
body {
background-color: var(--color-bg);
@@ -259,13 +308,13 @@ button .material-symbols-outlined,
}
.traffic-light.red {
background: #ff5f56;
background: var(--color-traffic-red);
}
.traffic-light.yellow {
background: #ffbd2e;
background: var(--color-traffic-yellow);
}
.traffic-light.green {
background: #27c93f;
background: var(--color-traffic-green);
}
+190 -35
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Input } from "@/shared/components";
import { Button, Input } from "@/shared/components";
import { useRouter } from "next/navigation";
export default function LoginPage() {
@@ -9,9 +9,12 @@ export default function LoginPage() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [hasPassword, setHasPassword] = useState(null);
const [setupComplete, setSetupComplete] = useState(null);
const [mounted, setMounted] = useState(false);
const router = useRouter();
useEffect(() => {
setMounted(true);
async function checkAuth() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
@@ -31,13 +34,15 @@ export default function LoginPage() {
return;
}
setHasPassword(!!data.hasPassword);
setSetupComplete(!!data.setupComplete);
} else {
// Safe fallback on non-OK response to avoid infinite loading state.
setHasPassword(true);
setSetupComplete(true);
}
} catch (err) {
clearTimeout(timeoutId);
setHasPassword(true);
setSetupComplete(true);
}
}
checkAuth();
@@ -56,6 +61,7 @@ export default function LoginPage() {
});
if (res.ok) {
sessionStorage.setItem("omniroute_login_time", String(Date.now()));
router.push("/dashboard");
router.refresh();
} else {
@@ -69,58 +75,207 @@ export default function LoginPage() {
}
};
// Show loading state while checking password
if (hasPassword === null) {
if (hasPassword === null || setupComplete === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="text-text-muted mt-4">Loading...</p>
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="flex flex-col items-center gap-3">
<div className="relative">
<div className="w-10 h-10 border-2 border-primary/20 rounded-full"></div>
<div className="absolute inset-0 w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
</div>
<span className="text-sm text-text-muted">Loading...</span>
</div>
</div>
);
}
if (!hasPassword && !setupComplete) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-6">
<div
className={`w-full max-w-md transition-all duration-700 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/10 mb-6">
<span className="material-symbols-outlined text-primary text-[40px]">
rocket_launch
</span>
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight">Welcome</h1>
<p className="text-text-muted mt-2">
Let&apos;s get your OmniRoute instance configured
</p>
</div>
<div className="bg-surface border border-border rounded-2xl p-8 shadow-soft">
<div className="text-center">
<p className="text-text-muted leading-relaxed mb-6">
Run the onboarding wizard to set up your password and connect your first AI
provider.
</p>
<Button
variant="primary"
className="w-full h-11 text-sm font-medium"
onClick={() => router.push("/dashboard/onboarding")}
>
Start Onboarding
</Button>
</div>
</div>
<p className="text-center text-xs text-text-muted/60 mt-8">
OmniRoute Unified AI API Proxy
</p>
</div>
</div>
);
}
if (!hasPassword && setupComplete) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-6">
<div
className={`w-full max-w-md transition-all duration-700 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-amber-500/10 to-amber-500/5 border border-amber-500/10 mb-6">
<span className="material-symbols-outlined text-amber-500 text-[40px]">
shield_person
</span>
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight">
Secure Your Instance
</h1>
<p className="text-text-muted mt-2">Password protection is not enabled</p>
</div>
<div className="bg-surface border border-border rounded-2xl p-8 shadow-soft">
<div className="text-center">
<p className="text-text-muted leading-relaxed mb-6">
Set a password to protect your dashboard and secure your API endpoints from
unauthorized access.
</p>
<Button
variant="primary"
className="w-full h-11 text-sm font-medium"
onClick={() => router.push("/dashboard/settings?tab=security")}
>
Configure Password
</Button>
</div>
</div>
<p className="text-center text-xs text-text-muted/60 mt-8">
OmniRoute Unified AI API Proxy
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">OmniRoute</h1>
<p className="text-text-muted">Enter your password to access the dashboard</p>
</div>
<div className="min-h-screen flex bg-bg">
<div className="flex-1 flex items-center justify-center p-6">
<div
className={`w-full max-w-sm transition-all duration-700 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="mb-10">
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center">
<span className="material-symbols-outlined text-white text-[20px]">hub</span>
</div>
<span className="text-xl font-semibold text-text-main tracking-tight">OmniRoute</span>
</div>
<h1 className="text-2xl font-bold text-text-main tracking-tight">Sign in</h1>
<p className="text-text-muted mt-1.5">Enter your password to continue</p>
</div>
<Card>
<form onSubmit={handleLogin} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Password</label>
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">Password</label>
<Input
type="password"
placeholder="Enter password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11"
/>
{error && <p className="text-xs text-red-500">{error}</p>}
{error && (
<p className="text-sm text-red-500 flex items-center gap-1.5 pt-1">
<span className="material-symbols-outlined text-base">error</span>
{error}
</p>
)}
</div>
<Button type="submit" variant="primary" className="w-full" loading={loading}>
Login
<Button
type="submit"
variant="primary"
className="w-full h-11 text-sm font-medium"
loading={loading}
>
Continue
</Button>
{!hasPassword && (
<p className="text-xs text-center text-text-muted mt-2">
Default password is <code className="bg-sidebar px-1 rounded">123456</code>
</p>
)}
<p className="text-xs text-center mt-1">
<a href="/forgot-password" className="text-primary hover:underline">
Forgot password?
</a>
</p>
</form>
</Card>
<div className="mt-6 pt-6 border-t border-border">
<a
href="/forgot-password"
className="text-sm text-text-muted hover:text-primary transition-colors"
>
Forgot your password?
</a>
</div>
</div>
</div>
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary/5 via-primary/3 to-transparent items-center justify-center p-12">
<div
className={`max-w-md transition-all duration-700 delay-200 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold text-text-main mb-3">Unified AI API Proxy</h2>
<p className="text-text-muted leading-relaxed">
Route requests to multiple AI providers through a single endpoint. Load balancing,
failover, and usage tracking built in.
</p>
</div>
<div className="space-y-4">
{[
{
icon: "swap_horiz",
title: "Multi-Provider",
desc: "OpenAI, Anthropic, Google, and more",
},
{
icon: "speed",
title: "Load Balancing",
desc: "Distribute requests intelligently",
},
{ icon: "analytics", title: "Usage Tracking", desc: "Monitor costs and tokens" },
].map((item) => (
<div
key={item.icon}
className="flex items-start gap-4 p-4 rounded-xl bg-surface/50 border border-border"
>
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-primary text-[20px]">
{item.icon}
</span>
</div>
<div>
<h3 className="font-medium text-text-main">{item.title}</h3>
<p className="text-sm text-text-muted">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
+15 -12
View File
@@ -1,27 +1,30 @@
"use client";
/**
* Custom Not Found Page FASE-04 Error Handling
*
* Displayed when a user navigates to a non-existent route.
* Styled with TailwindCSS 4 (Phase 7.3).
*/
import Link from "next/link";
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-6 bg-[var(--bg-primary,#0a0a0f)] text-[var(--text-primary,#e0e0e0)] text-center">
<div className="text-[96px] font-extrabold leading-none mb-2 bg-gradient-to-br from-[#6366f1] via-[#8b5cf6] to-[#a855f7] bg-clip-text text-transparent">
<div
className="flex flex-col items-center justify-center min-h-screen p-6 bg-bg text-text-main text-center"
role="main"
aria-labelledby="not-found-title"
>
<div
className="text-[96px] font-extrabold leading-none mb-2 bg-gradient-to-br from-primary to-primary-hover bg-clip-text text-transparent"
aria-hidden="true"
>
404
</div>
<h1 className="text-2xl font-semibold mb-2">Page not found</h1>
<p className="text-[15px] text-[var(--text-secondary,#888)] max-w-[400px] leading-relaxed mb-8">
<h1 id="not-found-title" className="text-2xl font-semibold mb-2">
Page not found
</h1>
<p className="text-[15px] text-text-muted max-w-[400px] leading-relaxed mb-8">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Link
href="/dashboard"
className="px-8 py-3 rounded-[10px] text-white text-sm font-semibold no-underline transition-all duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6]"
className="px-8 py-3 rounded-xl text-white text-sm font-medium no-underline transition-all duration-200 shadow-warm hover:-translate-y-0.5 bg-gradient-to-br from-primary to-primary-hover hover:shadow-elevated focus:outline-2 focus:outline-offset-2 focus:outline-primary"
aria-label="Return to dashboard"
>
Go to Dashboard
</Link>
-1
View File
@@ -1,4 +1,3 @@
// @ts-check
/**
* Combo Resolver FASE-09 Domain Extraction (T-46)
*
-1
View File
@@ -9,7 +9,6 @@
* @module domain/costRules
*/
// @ts-check
import {
saveBudget,
-1
View File
@@ -10,7 +10,6 @@
* @module domain/fallbackPolicy
*/
// @ts-check
import {
saveFallbackChain,
-1
View File
@@ -1,4 +1,3 @@
// @ts-check
/**
* Lockout Policy FASE-09 Domain Extraction (T-46)
*

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