Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe5c20a04e | |||
| 246fd05fae | |||
| a09b298127 | |||
| f89f40778f | |||
| 3d0c8d8d45 | |||
| 0e5e8bf14e | |||
| ce34d329d3 | |||
| eaf4a5805c | |||
| 8420e565d4 | |||
| 1b68deb0f6 | |||
| d1497c9ac8 | |||
| 03d4cbf6d5 | |||
| 718be831af | |||
| 9d5ec523be | |||
| 81c43b45fb | |||
| 146a491769 | |||
| 4c53388579 | |||
| 3403ddcc6e | |||
| 684b81d835 | |||
| 4f32da57fd | |||
| 97265e48b3 | |||
| 64797158e2 | |||
| 8359293dcd | |||
| b2dc53d18b | |||
| edf8dd2a12 | |||
| 5a777bd598 | |||
| bd39e01ee1 | |||
| e3ed29aab6 | |||
| 896ce9c0e2 | |||
| 82934132e9 | |||
| a2012b70de | |||
| bcfeba8a57 | |||
| d3dfd9ce57 | |||
| aa06d5d356 | |||
| 448c8a29e1 | |||
| 928b7120f4 | |||
| a3deacd718 | |||
| 78959fffbd | |||
| 1788616e52 | |||
| c61e6d0777 | |||
| a3bc7620b1 | |||
| 8064c588dc | |||
| 564e983c68 | |||
| e1da181740 | |||
| c63209200e | |||
| 737808cf53 | |||
| a197bb7736 | |||
| f9dd967bc5 |
@@ -55,6 +55,8 @@ logs/*
|
||||
# analysis directories (generated, not tracked)
|
||||
.analysis/
|
||||
antigravity-manager-analysis/
|
||||
.sisyphus/
|
||||
.plans/
|
||||
|
||||
# docs (allow specific tracked files)
|
||||
docs/*
|
||||
|
||||
@@ -3,6 +3,11 @@ data/
|
||||
**/data/
|
||||
**/db.json
|
||||
|
||||
# VS Code extension test runtime (large binary, not needed in npm package)
|
||||
app/vscode-extension/
|
||||
**/data/
|
||||
**/db.json
|
||||
|
||||
# Source code (pre-built app/ is published instead)
|
||||
src/
|
||||
open-sse/
|
||||
|
||||
@@ -4,6 +4,175 @@
|
||||
|
||||
---
|
||||
|
||||
## [2.8.0] — 2026-03-19
|
||||
|
||||
> Sprint: Bailian Coding Plan provider with editable base URLs, plus community contributions for Alibaba Cloud and Kimi Coding.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **feat(providers)**: Added Bailian Coding Plan (`bailian-coding-plan`) — Alibaba Model Studio with Anthropic-compatible API. Static catalog of 8 models including Qwen3.5 Plus, Qwen3 Coder, MiniMax M2.5, GLM 5, and Kimi K2.5. Includes custom auth validation (400=valid, 401/403=invalid) (#467, @Mind-Dragon)
|
||||
- **feat(admin)**: Editable default URL in Provider Admin create/edit flows — users can configure custom base URLs per connection. Persisted in `providerSpecificData.baseUrl` with Zod schema validation rejecting non-http(s) schemes (#467)
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Added 30+ unit tests and 2 e2e scenarios for Bailian Coding Plan provider covering auth validation, schema hardening, route-level behavior, and cross-layer integration
|
||||
|
||||
---
|
||||
|
||||
## [2.7.10] — 2026-03-19
|
||||
|
||||
> Sprint: Two new community-contributed providers (Alibaba Cloud Coding, Kimi Coding API-key) and Docker pino fix.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **feat(providers)**: Added Alibaba Cloud Coding Plan support with two OpenAI-compatible endpoints — `alicode` (China) and `alicode-intl` (International), each with 8 models (#465, @dtk1985)
|
||||
- **feat(providers)**: Added dedicated `kimi-coding-apikey` provider path — API-key-based Kimi Coding access is no longer forced through OAuth-only `kimi-coding` route. Includes registry, constants, models API, config, and validation test (#463, @Mind-Dragon)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(docker)**: Added missing `split2` dependency to Docker image — `pino-abstract-transport` requires it at runtime but it was not being copied into the standalone container, causing `Cannot find module 'split2'` crashes (#459)
|
||||
|
||||
---
|
||||
|
||||
## [2.7.9] — 2026-03-18
|
||||
|
||||
> Sprint: Codex responses subpath passthrough natively supported, Windows MITM crash fixed, and Combos agent schemas adjusted.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **feat(codex)**: Native responses subpath passthrough for Codex — natively routes `POST /v1/responses/compact` to Codex upstream, maintaining Claude Code compatibility without stripping the `/compact` suffix (#457)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(combos)**: Zod schemas (`updateComboSchema` and `createComboSchema`) now include `system_message`, `tool_filter_regex`, and `context_cache_protection`. Fixes bug where agent-specific settings created via the dashboard were silently discarded by the backend validation layer (#458)
|
||||
- **fix(mitm)**: Kiro MITM profile crash on Windows fixed — `node-machine-id` failed due to missing `REG.exe` env, and the fallback threw a fatal `crypto is not defined` error. Fallback now safely and correctly imports crypto (#456)
|
||||
|
||||
---
|
||||
|
||||
## [2.7.8] — 2026-03-18
|
||||
|
||||
> Sprint: Budget save bug + combo agent features UI + omniModel tag security fix.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(budget)**: "Save Limits" no longer returns 422 — `warningThreshold` is now correctly sent as fraction (0–1) instead of percentage (0–100) (#451)
|
||||
- **fix(combos)**: `<omniModel>` internal cache tag is now stripped before forwarding requests to providers, preventing cache session breaks (#454)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **feat(combos)**: Agent Features section added to combo create/edit modal — expose `system_message` override, `tool_filter_regex`, and `context_cache_protection` directly from the dashboard (#454)
|
||||
|
||||
---
|
||||
|
||||
## [2.7.7] — 2026-03-18
|
||||
|
||||
> Sprint: Docker pino crash, Codex CLI responses worker fix, package-lock sync.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(docker)**: `pino-abstract-transport` and `pino-pretty` now explicitly copied in Docker runner stage — Next.js standalone trace misses these peer deps, causing `Cannot find module pino-abstract-transport` crash on startup (#449)
|
||||
- **fix(responses)**: Remove `initTranslators()` from `/v1/responses` route — was crashing Next.js worker with `the worker has exited` uncaughtException on Codex CLI requests (#450)
|
||||
|
||||
### 🔧 Maintenance
|
||||
|
||||
- **chore(deps)**: `package-lock.json` now committed on every version bump to ensure Docker `npm ci` uses exact dependency versions
|
||||
|
||||
---
|
||||
|
||||
## [2.7.5] — 2026-03-18
|
||||
|
||||
> Sprint: UX improvements and Windows CLI healthcheck fix.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(ux)**: Show default password hint on login page — new users now see `"Default password: 123456"` below the password input (#437)
|
||||
- **fix(cli)**: Claude CLI and other npm-installed tools now correctly detected as runnable on Windows — spawn uses `shell:true` to resolve `.cmd` wrappers via PATHEXT (#447)
|
||||
|
||||
---
|
||||
|
||||
## [2.7.4] — 2026-03-18
|
||||
|
||||
> Sprint: Search Tools dashboard, i18n fixes, Copilot limits, Serper validation fix.
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **feat(search)**: Add Search Playground (10th endpoint), Search Tools page with Compare Providers/Rerank Pipeline/Search History, local rerank routing, auth guards on search API (#443 by @Regis-RCR)
|
||||
- New route: `/dashboard/search-tools`
|
||||
- Sidebar entry under Debug section
|
||||
- `GET /api/search/providers` and `GET /api/search/stats` with auth guards
|
||||
- Local provider_nodes routing for `/v1/rerank`
|
||||
- 30+ i18n keys in search namespace
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(search)**: Fix Brave news normalizer (was returning 0 results), enforce max_results truncation post-normalization, fix Endpoints page fetch URL (#443 by @Regis-RCR)
|
||||
- **fix(analytics)**: Localize analytics day/date labels — replace hardcoded Portuguese strings with `Intl.DateTimeFormat(locale)` (#444 by @hijak)
|
||||
- **fix(copilot)**: Correct GitHub Copilot account type display, filter misleading unlimited quota rows from limits dashboard (#445 by @hijak)
|
||||
- **fix(providers)**: Stop rejecting valid Serper API keys — treat non-4xx responses as valid authentication (#446 by @hijak)
|
||||
|
||||
---
|
||||
|
||||
## [2.7.3] — 2026-03-18
|
||||
|
||||
> Sprint: Codex direct API quota fallback fix.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(codex)**: Block weekly-exhausted accounts in direct API fallback (#440)
|
||||
- `resolveQuotaWindow()` prefix matching: `"weekly"` now matches `"weekly (7d)"` cache keys
|
||||
- `applyCodexWindowPolicy()` enforces `useWeekly`/`use5h` toggles correctly
|
||||
- 4 new regression tests (766 total)
|
||||
|
||||
---
|
||||
|
||||
## [2.7.2] — 2026-03-18
|
||||
|
||||
> Sprint: Light mode UI contrast fixes.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(logs)**: Fix light mode contrast in request logs filter buttons and combo badge (#378)
|
||||
- Error/Success/Combo filter buttons now readable in light mode
|
||||
- Combo row badge uses stronger violet in light mode
|
||||
|
||||
---
|
||||
|
||||
## [2.7.1] — 2026-03-17
|
||||
|
||||
> Sprint: Unified web search routing (POST /v1/search) with 5 providers + Next.js 16.1.7 security fixes (6 CVEs).
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(search)**: Unified web search routing — `POST /v1/search` with 5 providers (Serper, Brave, Perplexity, Exa, Tavily)
|
||||
- Auto-failover across providers, 6,500+ free searches/month
|
||||
- In-memory cache with request coalescing (configurable TTL)
|
||||
- Dashboard: Search Analytics tab in `/dashboard/analytics` with provider breakdown, cache hit rate, cost tracking
|
||||
- New API: `GET /api/v1/search/analytics` for search request statistics
|
||||
- DB migration: `request_type` column on `call_logs` for non-chat request tracking
|
||||
- Zod validation (`v1SearchSchema`), auth-gated, cost recorded via `recordCost()`
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **deps**: Next.js 16.1.6 → 16.1.7 — fixes 6 CVEs:
|
||||
- **Critical**: CVE-2026-29057 (HTTP request smuggling via http-proxy)
|
||||
- **High**: CVE-2026-27977, CVE-2026-27978 (WebSocket + Server Actions)
|
||||
- **Medium**: CVE-2026-27979, CVE-2026-27980, CVE-2026-jcc7
|
||||
|
||||
### 📁 New Files
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------ |
|
||||
| `open-sse/handlers/search.ts` | Search handler with 5-provider routing |
|
||||
| `open-sse/config/searchRegistry.ts` | Provider registry (auth, cost, quota, TTL) |
|
||||
| `open-sse/services/searchCache.ts` | In-memory cache with request coalescing |
|
||||
| `src/app/api/v1/search/route.ts` | Next.js route (POST + GET) |
|
||||
| `src/app/api/v1/search/analytics/route.ts` | Search stats API |
|
||||
| `src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx` | Analytics dashboard tab |
|
||||
| `src/lib/db/migrations/007_search_request_type.sql` | DB migration |
|
||||
| `tests/unit/search-registry.test.mjs` | 277 lines of unit tests |
|
||||
|
||||
---
|
||||
|
||||
## [2.7.0] — 2026-03-17
|
||||
|
||||
> Sprint: ClawRouter-inspired features — toolCalling flag, multilingual intent detection, benchmark-driven fallback, request deduplication, pluggable RouterStrategy, Grok-4 Fast + GLM-5 + MiniMax M2.5 + Kimi K2.5 pricing.
|
||||
|
||||
@@ -32,6 +32,11 @@ COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
# Explicitly copy @swc/helpers — not always traced by standalone output but needed at runtime
|
||||
COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
|
||||
# Explicitly copy pino transport dependencies — pino spawns a worker that requires
|
||||
# pino-abstract-transport at runtime; Next.js standalone trace does not capture it (#449)
|
||||
COPY --from=builder /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
|
||||
COPY --from=builder /app/node_modules/pino-pretty ./node_modules/pino-pretty
|
||||
COPY --from=builder /app/node_modules/split2 ./node_modules/split2
|
||||
COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
|
||||
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs
|
||||
COPY --from=builder /app/scripts/bootstrap-env.mjs ./bootstrap-env.mjs
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
_Your universal API proxy — one endpoint, 44+ providers, zero downtime. Now with **MCP & A2A** agent orchestration._
|
||||
|
||||
**Chat Completions • Embeddings • Image Generation • Video • Music • Audio • Reranking • MCP Server • A2A Protocol • 100% TypeScript**
|
||||
**Chat Completions • Embeddings • Image Generation • Video • Music • Audio • Reranking • **Web Search** • MCP Server • A2A Protocol • 100% TypeScript**
|
||||
|
||||
---
|
||||
|
||||
@@ -1105,16 +1105,17 @@ OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
|
||||
|
||||
### 🎵 Multi-Modal APIs
|
||||
|
||||
| Feature | What It Does |
|
||||
| -------------------------- | ------------------------------------------------------------- |
|
||||
| 🖼️ **Image Generation** | `/v1/images/generations` with cloud and local backends |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` for search and RAG pipelines |
|
||||
| 🎤 **Audio Transcription** | `/v1/audio/transcriptions` (Whisper and additional providers) |
|
||||
| 🔊 **Text-to-Speech** | `/v1/audio/speech` (multiple engines/providers) |
|
||||
| 🎬 **Video Generation** | `/v1/videos/generations` (ComfyUI + SD WebUI workflows) |
|
||||
| 🎵 **Music Generation** | `/v1/music/generations` (ComfyUI workflows) |
|
||||
| 🛡️ **Moderations** | `/v1/moderations` safety checks |
|
||||
| 🔀 **Reranking** | `/v1/rerank` for relevance scoring |
|
||||
| Feature | What It Does |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| 🖼️ **Image Generation** | `/v1/images/generations` with cloud and local backends |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` for search and RAG pipelines |
|
||||
| 🎤 **Audio Transcription** | `/v1/audio/transcriptions` (Whisper and additional providers) |
|
||||
| 🔊 **Text-to-Speech** | `/v1/audio/speech` (multiple engines/providers) |
|
||||
| 🎬 **Video Generation** | `/v1/videos/generations` (ComfyUI + SD WebUI workflows) |
|
||||
| 🎵 **Music Generation** | `/v1/music/generations` (ComfyUI workflows) |
|
||||
| 🛡️ **Moderations** | `/v1/moderations` safety checks |
|
||||
| 🔀 **Reranking** | `/v1/rerank` for relevance scoring |
|
||||
| 🔍 **Web Search** 🆕 | `/v1/search` — 5 providers (Serper, Brave, Perplexity, Exa, Tavily), 6,500+ free/month, auto-failover, cache |
|
||||
|
||||
### 🛡️ Resilience, Security & Governance
|
||||
|
||||
|
||||
@@ -8,6 +8,16 @@ _وكيل API العالمي الخاص بك - نقطة نهاية واحدة،
|
||||
|
||||
---
|
||||
|
||||
### 🆕 الجديد في v2.7.0
|
||||
|
||||
- **RouterStrategy قابل للتوصيل** — استراتيجيات القواعد والتكلفة والكمون
|
||||
- **كشف النية متعدد اللغات** — تسجيل التوجيه بأكثر من 30 لغة
|
||||
- **إلغاء تكرار الطلبات** — تجنب مكالمات API المكررة عبر تجزئة المحتوى
|
||||
- **مزودون جدد:** Grok-4 Fast (xAI) وGLM-5 / Z.AI وMiniMax M2.5 وKimi K2.5
|
||||
- **أسعار محدثة:** Grok-4 Fast $0.20/$0.50/M، GLM-5 $0.50/M، MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
|
||||
@@ -8,6 +8,16 @@ _Вашият универсален API прокси — една крайна
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
|
||||
@@ -8,6 +8,16 @@ _Din universelle API-proxy — ét slutpunkt, 36+ udbydere, ingen nedetid. Nu me
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
|
||||
@@ -8,6 +8,16 @@ _Ihr universeller API-Proxy – ein Endpunkt, mehr als 36 Anbieter, keine Ausfal
|
||||
|
||||
---
|
||||
|
||||
### 🆕 Neu in v2.7.0
|
||||
|
||||
- **Erweiterbare RouterStrategy** — Regeln-, Kosten- und Latenzstrategien
|
||||
- **Mehrsprachige Absichtserkennung** — Routing-Scoring in 30+ Sprachen
|
||||
- **Anfrage-Deduplizierung** — doppelte API-Aufrufe per Content-Hash vermeiden
|
||||
- **Neue Anbieter:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Aktualisierte Preise:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
|
||||
@@ -11,6 +11,16 @@ _Tu proxy de API universal — un endpoint, 36+ proveedores, cero tiempo de inac
|
||||
|
||||
---
|
||||
|
||||
### 🆕 Novedades en v2.7.0
|
||||
|
||||
- **RouterStrategy enchufable** — estrategias de reglas, costo y latencia
|
||||
- **Detección de intención multilingüe** — puntuación de enrutamiento en 30+ idiomas
|
||||
- **Deduplicación de solicitudes** — evita llamadas duplicadas por hash de contenido
|
||||
- **Nuevos proveedores:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Precios actualizados:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Universaali API-välityspalvelin – yksi päätepiste, yli 36 palveluntarjoaja
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Votre proxy API universel — un endpoint, 36+ fournisseurs, zéro temps d'arr
|
||||
|
||||
---
|
||||
|
||||
### 🆕 Nouveautés dans v2.7.0
|
||||
|
||||
- **RouterStrategy extensible** — stratégies de règles, coût et latence
|
||||
- **Détection d'intention multilingue** — scoring de routage en 30+ langues
|
||||
- **Déduplication des requêtes** — évite les appels dupliqués via hash de contenu
|
||||
- **Nouveaux fournisseurs :** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Tarifs mis à jour :** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _שרת ה-API האוניברסלי שלך - נקודת קצה אחת, 36+ ספ
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Az univerzális API-proxy – egy végpont, 36+ szolgáltató, nulla állásid
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Proksi API universal Anda — satu titik akhir, 36+ penyedia, tanpa waktu henti
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -13,6 +13,16 @@ _आपका सार्वभौमिक एपीआई प्रॉक्
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Il tuo proxy API universale — un endpoint, 36+ provider, zero downtime._
|
||||
|
||||
---
|
||||
|
||||
### 🆕 Novità in v2.7.0
|
||||
|
||||
- **RouterStrategy estensibile** — strategie per regole, costo e latenza
|
||||
- **Rilevamento intento multilingue** — scoring di routing in 30+ lingue
|
||||
- **Deduplicazione richieste** — evita chiamate duplicate tramite hash del contenuto
|
||||
- **Nuovi provider:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Prezzi aggiornati:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _ユニバーサル API プロキシ — 1 つのエンドポイント、36 以
|
||||
|
||||
---
|
||||
|
||||
### 🆕 v2.7.0 の新機能
|
||||
|
||||
- **プラガブル RouterStrategy** — ルール・コスト・レイテンシ戦略をサポート
|
||||
- **多言語インテント検出** — 30以上の言語でルーティングスコアリング
|
||||
- **リクエスト重複排除** — コンテンツハッシュで重複 API 呼び出しを防止
|
||||
- **新しいプロバイダー:** Grok-4 Fast (xAI)、GLM-5 / Z.AI、MiniMax M2.5、Kimi K2.5
|
||||
- **価格更新:** Grok-4 Fast $0.20/$0.50/M、GLM-5 $0.50/M、MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _범용 API 프록시 — 하나의 엔드포인트, 36개 이상의 공급자,
|
||||
|
||||
---
|
||||
|
||||
### 🆕 v2.7.0 새로운 기능
|
||||
|
||||
- **플러그형 RouterStrategy** — 규칙, 비용, 지연 전략 지원
|
||||
- **다국어 의도 감지** — 30개 이상 언어로 라우팅 스코어링
|
||||
- **요청 중복 제거** — 콘텐츠 해시로 중복 API 호출 방지
|
||||
- **새 공급자:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **가격 업데이트:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Proksi API universal anda — satu titik akhir, 36+ pembekal, masa henti sifar.
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Uw universele API-proxy: één eindpunt, meer dan 36 providers, geen downtime._
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Din universelle API-proxy – ett endepunkt, 36+ leverandører, null nedetid._
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Iyong unibersal na API proxy — isang endpoint, 36+ provider, zero downtime._
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Twój uniwersalny serwer proxy API — jeden punkt końcowy, ponad 36 dostawcó
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Seu proxy de API universal — um endpoint, 36+ provedores, zero tempo de inati
|
||||
|
||||
---
|
||||
|
||||
### 🆕 Novidades na v2.7.0
|
||||
|
||||
- **RouterStrategy plugável** — estratégias de regras, custo e latência
|
||||
- **Detecção de intenção multilíngue** — scoring de roteamento em 30+ idiomas
|
||||
- **Deduplicação de requisições** — evita chamadas duplicadas por hash de conteúdo
|
||||
- **Novos provedores:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Preços atualizados:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Seu proxy de API universal — um endpoint, mais de 36 provedores, tempo de ina
|
||||
|
||||
---
|
||||
|
||||
### 🆕 Novidades na v2.7.0
|
||||
|
||||
- **RouterStrategy extensível** — estratégias de regras, custo e latência
|
||||
- **Deteção de intenção multilíngue** — scoring de encaminhamento em 30+ idiomas
|
||||
- **Deduplicação de pedidos** — evita chamadas duplicadas por hash de conteúdo
|
||||
- **Novos fornecedores:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Preços atualizados:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Proxy-ul dvs. universal API - un punct final, peste 36 de furnizori, zero timpi
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Ваш универсальный API-прокси — одна точка до
|
||||
|
||||
---
|
||||
|
||||
### 🆕 Новое в v2.7.0
|
||||
|
||||
- **Подключаемая RouterStrategy** — стратегии по правилам, стоимости и задержке
|
||||
- **Многоязычное распознавание намерений** — маршрутизация на 30+ языках
|
||||
- **Дедупликация запросов** — устранение дублей по хэшу содержимого
|
||||
- **Новые провайдеры:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Обновлённые цены:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Váš univerzálny proxy server API – jeden koncový bod, 36+ poskytovateľov
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Din universella API-proxy — en slutpunkt, 36+ leverantörer, noll driftstopp.
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _พร็อกซี API สากลของคุณ — จุดสิ้
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Ваш універсальний API-проксі — одна кінцева
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _Proxy API phổ quát của bạn — một điểm cuối, hơn 36 nhà cung c
|
||||
|
||||
---
|
||||
|
||||
### 🆕 What's New in v2.7.0
|
||||
|
||||
- **Pluggable RouterStrategy** — rules, cost, and latency routing strategies
|
||||
- **Multilingual intent detection** — routing scoring in 30+ languages
|
||||
- **Request deduplication** — prevent duplicate API calls via content hash
|
||||
- **New providers:** Grok-4 Fast (xAI), GLM-5 / Z.AI, MiniMax M2.5, Kimi K2.5
|
||||
- **Updated pricing:** Grok-4 Fast $0.20/$0.50/M, GLM-5 $0.50/M, MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -11,6 +11,16 @@ _您的通用 API 代理 — 一个端点,36+ 提供商,零停机时间。_
|
||||
|
||||
---
|
||||
|
||||
### 🆕 v2.7.0 新功能
|
||||
|
||||
- **可插拔 RouterStrategy** — 支持规则、成本和延迟策略
|
||||
- **多语言意图检测** — 支持 30+ 语言的路由评分
|
||||
- **请求去重** — 基于内容哈希避免重复 API 调用
|
||||
- **新增提供商:** Grok-4 Fast (xAI)、GLM-5 / Z.AI、MiniMax M2.5、Kimi K2.5
|
||||
- **价格更新:** Grok-4 Fast $0.20/$0.50/M,GLM-5 $0.50/M,MiniMax M2.5 $0.30/M
|
||||
|
||||
---
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.7.0
|
||||
version: 2.8.0
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -121,6 +121,10 @@ const nextConfig = {
|
||||
source: "/responses",
|
||||
destination: "/api/v1/responses",
|
||||
},
|
||||
{
|
||||
source: "/responses/:path*",
|
||||
destination: "/api/v1/responses/:path*",
|
||||
},
|
||||
{
|
||||
source: "/models",
|
||||
destination: "/api/v1/models",
|
||||
|
||||
@@ -78,6 +78,22 @@ interface LegacyProvider {
|
||||
clientVersion?: string;
|
||||
}
|
||||
|
||||
const KIMI_CODING_SHARED = {
|
||||
format: "claude",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.kimi.com/coding/v1/messages",
|
||||
authHeader: "x-api-key",
|
||||
headers: {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
models: [
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
|
||||
{ id: "kimi-latest", name: "Kimi Latest" },
|
||||
] as RegistryModel[],
|
||||
} as const;
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
@@ -115,6 +131,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
},
|
||||
models: [
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet" },
|
||||
{ id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus" },
|
||||
{ id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
|
||||
@@ -520,6 +537,32 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
"bailian-coding-plan": {
|
||||
id: "bailian-coding-plan",
|
||||
alias: "bcp",
|
||||
format: "claude",
|
||||
executor: "default",
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
|
||||
chatPath: "/messages",
|
||||
urlSuffix: "?beta=true",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
headers: {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
models: [
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
|
||||
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max (2026-01-23)" },
|
||||
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
],
|
||||
},
|
||||
|
||||
zai: {
|
||||
id: "zai",
|
||||
alias: "zai",
|
||||
@@ -558,16 +601,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
"kimi-coding": {
|
||||
id: "kimi-coding",
|
||||
alias: "kmc",
|
||||
format: "claude",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.kimi.com/coding/v1/messages",
|
||||
...KIMI_CODING_SHARED,
|
||||
urlSuffix: "?beta=true",
|
||||
authType: "oauth",
|
||||
authHeader: "x-api-key",
|
||||
headers: {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
oauth: {
|
||||
clientIdEnv: "KIMI_CODING_OAUTH_CLIENT_ID",
|
||||
clientIdDefault: "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||
@@ -575,11 +611,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
refreshUrl: "https://auth.kimi.com/api/oauth/token",
|
||||
authUrl: "https://auth.kimi.com/api/oauth/device_authorization",
|
||||
},
|
||||
models: [
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
|
||||
{ id: "kimi-latest", name: "Kimi Latest" },
|
||||
],
|
||||
},
|
||||
|
||||
"kimi-coding-apikey": {
|
||||
id: "kimi-coding-apikey",
|
||||
alias: "kmca",
|
||||
...KIMI_CODING_SHARED,
|
||||
authType: "apikey",
|
||||
},
|
||||
|
||||
kilocode: {
|
||||
@@ -698,6 +736,46 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
alicode: {
|
||||
id: "alicode",
|
||||
alias: "alicode",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max" },
|
||||
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
],
|
||||
},
|
||||
|
||||
"alicode-intl": {
|
||||
id: "alicode-intl",
|
||||
alias: "alicode-intl",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max" },
|
||||
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
],
|
||||
},
|
||||
|
||||
deepseek: {
|
||||
id: "deepseek",
|
||||
alias: "ds",
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Search Provider Registry
|
||||
*
|
||||
* Defines providers that support the /v1/search endpoint.
|
||||
* Unlike LLM/embedding providers, search providers don't have "models" —
|
||||
* a provider IS the model (Serper = Google SERP, Brave = Brave index).
|
||||
*
|
||||
* API keys are stored in the same provider credentials system,
|
||||
* keyed by provider ID (e.g. "serper-search", "brave-search").
|
||||
* perplexity-search reuses credentials from the "perplexity" chat provider.
|
||||
*/
|
||||
|
||||
export interface SearchProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
method: "GET" | "POST";
|
||||
authType: "apikey";
|
||||
authHeader: string;
|
||||
costPerQuery: number;
|
||||
freeMonthlyQuota: number;
|
||||
searchTypes: string[];
|
||||
defaultMaxResults: number;
|
||||
maxMaxResults: number;
|
||||
timeoutMs: number;
|
||||
cacheTTLMs: number;
|
||||
}
|
||||
|
||||
export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
|
||||
"serper-search": {
|
||||
id: "serper-search",
|
||||
name: "Serper Search",
|
||||
baseUrl: "https://google.serper.dev",
|
||||
method: "POST",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
costPerQuery: 0.001,
|
||||
freeMonthlyQuota: 2500,
|
||||
searchTypes: ["web", "news"],
|
||||
defaultMaxResults: 5,
|
||||
maxMaxResults: 100,
|
||||
timeoutMs: 10_000,
|
||||
cacheTTLMs: 5 * 60 * 1000,
|
||||
},
|
||||
|
||||
"brave-search": {
|
||||
id: "brave-search",
|
||||
name: "Brave Search",
|
||||
baseUrl: "https://api.search.brave.com/res/v1",
|
||||
method: "GET",
|
||||
authType: "apikey",
|
||||
authHeader: "x-subscription-token",
|
||||
costPerQuery: 0.005,
|
||||
freeMonthlyQuota: 1000,
|
||||
searchTypes: ["web", "news"],
|
||||
defaultMaxResults: 5,
|
||||
maxMaxResults: 20,
|
||||
timeoutMs: 10_000,
|
||||
cacheTTLMs: 5 * 60 * 1000,
|
||||
},
|
||||
|
||||
"perplexity-search": {
|
||||
id: "perplexity-search",
|
||||
name: "Perplexity Search",
|
||||
baseUrl: "https://api.perplexity.ai/search",
|
||||
method: "POST",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
costPerQuery: 0.005,
|
||||
freeMonthlyQuota: 0,
|
||||
searchTypes: ["web"],
|
||||
defaultMaxResults: 5,
|
||||
maxMaxResults: 20,
|
||||
timeoutMs: 10_000,
|
||||
cacheTTLMs: 5 * 60 * 1000,
|
||||
},
|
||||
|
||||
"exa-search": {
|
||||
id: "exa-search",
|
||||
name: "Exa Search",
|
||||
baseUrl: "https://api.exa.ai/search",
|
||||
method: "POST",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
costPerQuery: 0.007,
|
||||
freeMonthlyQuota: 1000,
|
||||
searchTypes: ["web", "news"],
|
||||
defaultMaxResults: 5,
|
||||
maxMaxResults: 100,
|
||||
timeoutMs: 10_000,
|
||||
cacheTTLMs: 5 * 60 * 1000,
|
||||
},
|
||||
|
||||
"tavily-search": {
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
baseUrl: "https://api.tavily.com/search",
|
||||
method: "POST",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
costPerQuery: 0.008,
|
||||
freeMonthlyQuota: 1000,
|
||||
searchTypes: ["web", "news"],
|
||||
defaultMaxResults: 5,
|
||||
maxMaxResults: 20,
|
||||
timeoutMs: 10_000,
|
||||
cacheTTLMs: 5 * 60 * 1000,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Credential fallback mapping — search providers that can reuse credentials
|
||||
* from a related provider (e.g., perplexity-search uses the same API key as perplexity chat).
|
||||
*/
|
||||
export const SEARCH_CREDENTIAL_FALLBACKS: Record<string, string> = {
|
||||
"perplexity-search": "perplexity",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get search provider config by ID
|
||||
*/
|
||||
export function getSearchProvider(providerId: string): SearchProviderConfig | null {
|
||||
return SEARCH_PROVIDERS[providerId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all search providers as a flat list
|
||||
*/
|
||||
export function getAllSearchProviders(): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
searchTypes: string[];
|
||||
}> {
|
||||
return Object.values(SEARCH_PROVIDERS).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
searchTypes: p.searchTypes,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the cheapest available provider.
|
||||
* If an explicit provider is given, validate and return it.
|
||||
* Otherwise, return the cheapest by costPerQuery.
|
||||
*/
|
||||
export function selectProvider(explicitProvider?: string): SearchProviderConfig | null {
|
||||
if (explicitProvider) {
|
||||
return SEARCH_PROVIDERS[explicitProvider] || null;
|
||||
}
|
||||
|
||||
const providers = Object.values(SEARCH_PROVIDERS);
|
||||
if (providers.length === 0) return null;
|
||||
|
||||
return providers.reduce((cheapest, p) => (p.costPerQuery < cheapest.costPerQuery ? p : cheapest));
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export type ProviderCredentials = {
|
||||
expiresAt?: string;
|
||||
connectionId?: string; // T07: used for API key rotation index
|
||||
providerSpecificData?: JsonRecord;
|
||||
requestEndpointPath?: string;
|
||||
};
|
||||
|
||||
export type ExecutorLog = {
|
||||
|
||||
@@ -9,6 +9,17 @@ type EffortLevel = (typeof EFFORT_ORDER)[number];
|
||||
const CODEX_FAST_WIRE_VALUE = "priority";
|
||||
let defaultFastServiceTierEnabled = false;
|
||||
|
||||
function getResponsesSubpath(endpointPath: unknown): string | null {
|
||||
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
|
||||
const match = normalizedEndpoint.match(/(?:^|\/)responses(?:(\/.*))?$/i);
|
||||
if (!match) return null;
|
||||
return match[1] || "";
|
||||
}
|
||||
|
||||
function isCompactResponsesEndpoint(endpointPath: unknown): boolean {
|
||||
return getResponsesSubpath(endpointPath)?.toLowerCase() === "/compact";
|
||||
}
|
||||
|
||||
function normalizeServiceTierValue(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
@@ -60,13 +71,31 @@ export class CodexExecutor extends BaseExecutor {
|
||||
super("codex", PROVIDERS.codex);
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
void model;
|
||||
void stream;
|
||||
void urlIndex;
|
||||
|
||||
const responsesSubpath = getResponsesSubpath(credentials?.requestEndpointPath);
|
||||
if (responsesSubpath !== null) {
|
||||
const baseUrl = String(this.config.baseUrl || "").replace(/\/$/, "");
|
||||
if (baseUrl.endsWith("/responses")) {
|
||||
return `${baseUrl}${responsesSubpath}`;
|
||||
}
|
||||
return `${baseUrl}/responses${responsesSubpath}`;
|
||||
}
|
||||
|
||||
return super.buildUrl(model, stream, urlIndex, credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex Responses endpoint is SSE-first.
|
||||
* Always request event-stream from upstream, even when client requested stream=false.
|
||||
* Includes chatgpt-account-id header for strict workspace binding.
|
||||
*/
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const headers = super.buildHeaders(credentials, true);
|
||||
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
|
||||
const headers = super.buildHeaders(credentials, isCompactRequest ? false : true);
|
||||
|
||||
// Add workspace binding header if workspaceId is persisted
|
||||
const workspaceId = credentials?.providerSpecificData?.workspaceId;
|
||||
@@ -107,9 +136,15 @@ export class CodexExecutor extends BaseExecutor {
|
||||
*/
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
|
||||
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
|
||||
|
||||
// Codex /responses rejects stream=false; we aggregate SSE back to JSON when needed.
|
||||
body.stream = true;
|
||||
// Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely.
|
||||
if (isCompactRequest) {
|
||||
delete body.stream;
|
||||
delete body.stream_options;
|
||||
} else {
|
||||
body.stream = true;
|
||||
}
|
||||
delete body._nativeCodexPassthrough;
|
||||
|
||||
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
|
||||
|
||||
@@ -54,6 +54,8 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
break;
|
||||
case "glm":
|
||||
case "kimi-coding":
|
||||
case "bailian-coding-plan":
|
||||
case "kimi-coding-apikey":
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
|
||||
|
||||
@@ -43,6 +43,11 @@ import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idem
|
||||
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts";
|
||||
import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts";
|
||||
import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts";
|
||||
import {
|
||||
shouldUseFallback,
|
||||
isFallbackDecision,
|
||||
EMERGENCY_FALLBACK_CONFIG,
|
||||
} from "../services/emergencyFallback.ts";
|
||||
|
||||
export function shouldUseNativeCodexPassthrough({
|
||||
provider,
|
||||
@@ -55,9 +60,8 @@ export function shouldUseNativeCodexPassthrough({
|
||||
}): boolean {
|
||||
if (provider !== "codex") return false;
|
||||
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false;
|
||||
return String(endpointPath || "")
|
||||
.toLowerCase()
|
||||
.endsWith("/responses");
|
||||
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
|
||||
return /(?:^|\/)responses(?:\/.*)?$/i.test(normalizedEndpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,8 +139,8 @@ export async function handleChatCore({
|
||||
}
|
||||
|
||||
const sourceFormat = detectFormat(body);
|
||||
const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase();
|
||||
const isResponsesEndpoint = endpointPath.endsWith("/responses");
|
||||
const endpointPath = String(clientRawRequest?.endpoint || "");
|
||||
const isResponsesEndpoint = /(?:^|\/)responses(?:\/.*)?$/i.test(endpointPath);
|
||||
const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({
|
||||
provider,
|
||||
sourceFormat,
|
||||
@@ -380,6 +384,8 @@ export async function handleChatCore({
|
||||
|
||||
// Get executor for this provider
|
||||
const executor = getExecutor(provider);
|
||||
const getExecutionCredentials = () =>
|
||||
nativeCodexPassthrough ? { ...credentials, requestEndpointPath: endpointPath } : credentials;
|
||||
|
||||
// Create stream controller for disconnect detection
|
||||
const streamController = createStreamController({ onDisconnect, log, provider, model });
|
||||
@@ -400,7 +406,7 @@ export async function handleChatCore({
|
||||
model: modelToCall,
|
||||
body: bodyToSend,
|
||||
stream,
|
||||
credentials,
|
||||
credentials: getExecutionCredentials(),
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
@@ -540,7 +546,7 @@ export async function handleChatCore({
|
||||
model,
|
||||
body: translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
credentials: getExecutionCredentials(),
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
@@ -641,6 +647,63 @@ export async function handleChatCore({
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
}
|
||||
// ── End T5 ───────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Emergency Fallback (ClawRouter Feature #09/017) ────────────────────
|
||||
// When a non-streaming request fails with a budget-related error (402 or
|
||||
// budget keywords), redirect to nvidia/gpt-oss-120b ($0.00/M) before
|
||||
// returning the error to the combo router. This gives one last free-tier
|
||||
// attempt so the user's session stays alive.
|
||||
const requestHasTools = Array.isArray(translatedBody.tools) && translatedBody.tools.length > 0;
|
||||
if (!stream) {
|
||||
const fbDecision = shouldUseFallback(
|
||||
statusCode,
|
||||
message,
|
||||
requestHasTools,
|
||||
EMERGENCY_FALLBACK_CONFIG
|
||||
);
|
||||
if (isFallbackDecision(fbDecision)) {
|
||||
log?.info?.("EMERGENCY_FALLBACK", fbDecision.reason);
|
||||
try {
|
||||
// Build a minimal fallback request using the original body but with
|
||||
// the NVIDIA free-tier model and max_tokens capped to avoid overuse.
|
||||
const fbExecutor = getExecutor(fbDecision.provider);
|
||||
const fbResult = await fbExecutor.execute({
|
||||
model: fbDecision.model,
|
||||
body: {
|
||||
...translatedBody,
|
||||
model: fbDecision.model,
|
||||
max_tokens: Math.min(
|
||||
typeof translatedBody.max_tokens === "number"
|
||||
? translatedBody.max_tokens
|
||||
: fbDecision.maxOutputTokens,
|
||||
fbDecision.maxOutputTokens
|
||||
),
|
||||
},
|
||||
stream: false,
|
||||
credentials: credentials,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
});
|
||||
if (fbResult.response.ok) {
|
||||
providerResponse = fbResult.response;
|
||||
log?.info?.(
|
||||
"EMERGENCY_FALLBACK",
|
||||
`Serving ${fbDecision.provider}/${fbDecision.model} as budget fallback for ${provider}/${model}`
|
||||
);
|
||||
// Fall through to non-streaming handler — providerResponse is now OK
|
||||
} else {
|
||||
log?.warn?.(
|
||||
"EMERGENCY_FALLBACK",
|
||||
`Emergency fallback also failed (${fbResult.response.status})`
|
||||
);
|
||||
}
|
||||
} catch (fbErr) {
|
||||
log?.warn?.("EMERGENCY_FALLBACK", `Emergency fallback error: ${fbErr?.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── End Emergency Fallback ────────────────────────────────────────────
|
||||
}
|
||||
|
||||
// Non-streaming response
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* Search Handler
|
||||
*
|
||||
* Handles POST /v1/search requests.
|
||||
* Routes to 5 search providers with automatic failover:
|
||||
* serper-search, brave-search, perplexity-search, exa-search, tavily-search
|
||||
*
|
||||
* Request format:
|
||||
* {
|
||||
* "query": "search query",
|
||||
* "provider": "serper-search" | "brave-search" | ... // optional, auto-selects cheapest
|
||||
* "max_results": 5,
|
||||
* "search_type": "web" | "news"
|
||||
* }
|
||||
*/
|
||||
|
||||
import { getSearchProvider, type SearchProviderConfig } from "../config/searchRegistry.ts";
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
display_url?: string;
|
||||
snippet: string;
|
||||
position: number;
|
||||
score: number | null;
|
||||
published_at: string | null;
|
||||
favicon_url: string | null;
|
||||
content: { format: string; text: string; length: number } | null;
|
||||
metadata: {
|
||||
author: string | null;
|
||||
language: string | null;
|
||||
source_type: string | null;
|
||||
image_url: string | null;
|
||||
} | null;
|
||||
citation: {
|
||||
provider: string;
|
||||
retrieved_at: string;
|
||||
rank: number;
|
||||
};
|
||||
provider_raw: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
provider: string;
|
||||
query: string;
|
||||
results: SearchResult[];
|
||||
answer: { source: string; text: string | null; model: string | null } | null;
|
||||
usage: { queries_used: number; search_cost_usd: number; llm_tokens?: number };
|
||||
metrics: {
|
||||
response_time_ms: number;
|
||||
upstream_latency_ms: number;
|
||||
gateway_latency_ms?: number;
|
||||
total_results_available: number | null;
|
||||
};
|
||||
errors: Array<{ provider: string; code: string; message: string }>;
|
||||
}
|
||||
|
||||
interface SearchHandlerResult {
|
||||
success: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
data?: SearchResponse;
|
||||
}
|
||||
|
||||
interface SearchHandlerOptions {
|
||||
query: string;
|
||||
provider: string;
|
||||
maxResults: number;
|
||||
searchType: string;
|
||||
country?: string;
|
||||
language?: string;
|
||||
timeRange?: string;
|
||||
offset?: number;
|
||||
domainFilter?: string[];
|
||||
contentOptions?: {
|
||||
snippet?: boolean;
|
||||
full_page?: boolean;
|
||||
format?: string;
|
||||
max_characters?: number;
|
||||
};
|
||||
strictFilters?: boolean;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
credentials: Record<string, any>;
|
||||
alternateProvider?: string;
|
||||
alternateCredentials?: Record<string, any> | null;
|
||||
log?: any;
|
||||
}
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
const GLOBAL_TIMEOUT_MS = 15_000;
|
||||
|
||||
// Non-retriable HTTP status codes — fail immediately, don't try alternate
|
||||
const NON_RETRIABLE = new Set([400, 401, 403, 404]);
|
||||
|
||||
// ── Input Sanitization ──────────────────────────────────────────────────
|
||||
|
||||
// Control characters that should never appear in search queries
|
||||
const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
|
||||
|
||||
function sanitizeQuery(query: string): { clean: string; error?: string } {
|
||||
if (CONTROL_CHAR_RE.test(query)) {
|
||||
return { clean: "", error: "Query contains invalid control characters" };
|
||||
}
|
||||
const clean = query.normalize("NFKC").trim().replace(/\s+/g, " ");
|
||||
if (clean.length === 0) {
|
||||
return { clean: "", error: "Query is empty after normalization" };
|
||||
}
|
||||
return { clean };
|
||||
}
|
||||
|
||||
// ── Response Normalizers ────────────────────────────────────────────────
|
||||
|
||||
function makeResult(
|
||||
providerId: string,
|
||||
item: {
|
||||
title?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
score?: number;
|
||||
published_at?: string;
|
||||
favicon_url?: string;
|
||||
author?: string;
|
||||
source_type?: string;
|
||||
image_url?: string;
|
||||
full_text?: string;
|
||||
text_format?: string;
|
||||
},
|
||||
idx: number,
|
||||
now: string
|
||||
): SearchResult {
|
||||
const url = item.url || "";
|
||||
return {
|
||||
title: item.title || "",
|
||||
url,
|
||||
display_url: url ? url.replace(/^https?:\/\/(www\.)?/, "").split("?")[0] : undefined,
|
||||
snippet: item.snippet || "",
|
||||
position: idx + 1,
|
||||
score: typeof item.score === "number" ? Math.min(1, Math.max(0, item.score)) : null,
|
||||
published_at: item.published_at || null,
|
||||
favicon_url: item.favicon_url || null,
|
||||
content: item.full_text
|
||||
? { format: item.text_format || "text", text: item.full_text, length: item.full_text.length }
|
||||
: null,
|
||||
metadata: {
|
||||
author: item.author || null,
|
||||
language: null,
|
||||
source_type: item.source_type || null,
|
||||
image_url: item.image_url || null,
|
||||
},
|
||||
citation: { provider: providerId, retrieved_at: now, rank: idx + 1 },
|
||||
provider_raw: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSerperResponse(
|
||||
data: any,
|
||||
_query: string,
|
||||
searchType: string
|
||||
): { results: SearchResult[]; totalResults: number | null } {
|
||||
const now = new Date().toISOString();
|
||||
const items = searchType === "news" ? data.news : data.organic;
|
||||
if (!Array.isArray(items)) return { results: [], totalResults: null };
|
||||
|
||||
const results = items.map((item: any, idx: number) =>
|
||||
makeResult(
|
||||
"serper-search",
|
||||
{
|
||||
title: item.title,
|
||||
url: item.link,
|
||||
snippet: item.snippet || item.description,
|
||||
published_at: item.date,
|
||||
},
|
||||
idx,
|
||||
now
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
results,
|
||||
totalResults:
|
||||
typeof data.searchParameters?.totalResults === "number"
|
||||
? data.searchParameters.totalResults
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBraveResponse(
|
||||
data: any,
|
||||
_query: string,
|
||||
searchType: string
|
||||
): { results: SearchResult[]; totalResults: number | null } {
|
||||
const now = new Date().toISOString();
|
||||
// Brave news endpoint returns { results: [...] } directly,
|
||||
// while web endpoint returns { web: { results: [...] } }
|
||||
const container = searchType === "news" ? data.news || data : data.web;
|
||||
const items = container?.results;
|
||||
if (!Array.isArray(items)) return { results: [], totalResults: null };
|
||||
|
||||
const results = items.map((item: any, idx: number) =>
|
||||
makeResult(
|
||||
"brave-search",
|
||||
{
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
snippet: item.description,
|
||||
published_at: item.page_age || item.age,
|
||||
favicon_url: item.meta_url?.favicon || item.favicon,
|
||||
},
|
||||
idx,
|
||||
now
|
||||
)
|
||||
);
|
||||
|
||||
return { results, totalResults: container?.totalCount ?? null };
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function parseDomainFilter(domainFilter?: string[]): {
|
||||
includes: string[];
|
||||
excludes: string[];
|
||||
} {
|
||||
if (!domainFilter?.length) return { includes: [], excludes: [] };
|
||||
const includes = domainFilter.filter((d) => !d.startsWith("-"));
|
||||
const excludes = domainFilter.filter((d) => d.startsWith("-")).map((d) => d.slice(1));
|
||||
return { includes, excludes };
|
||||
}
|
||||
|
||||
// ── Provider Request Builders ───────────────────────────────────────────
|
||||
|
||||
interface SearchRequestParams {
|
||||
query: string;
|
||||
searchType: string;
|
||||
maxResults: number;
|
||||
token: string;
|
||||
country?: string;
|
||||
language?: string;
|
||||
domainFilter?: string[];
|
||||
}
|
||||
|
||||
function buildSerperRequest(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
): { url: string; init: RequestInit } {
|
||||
const endpoint = params.searchType === "news" ? "/news" : "/search";
|
||||
const body: Record<string, unknown> = { q: params.query, num: params.maxResults };
|
||||
if (params.country) body.gl = params.country.toLowerCase();
|
||||
if (params.language) body.hl = params.language;
|
||||
return {
|
||||
url: `${config.baseUrl}${endpoint}`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-API-Key": params.token },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildBraveRequest(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
): { url: string; init: RequestInit } {
|
||||
const endpoint = params.searchType === "news" ? "/news/search" : "/web/search";
|
||||
const qp = new URLSearchParams({ q: params.query, count: String(params.maxResults) });
|
||||
if (params.country) qp.set("country", params.country);
|
||||
if (params.language) qp.set("search_lang", params.language);
|
||||
return {
|
||||
url: `${config.baseUrl}${endpoint}?${qp}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", "X-Subscription-Token": params.token },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPerplexityRequest(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
): { url: string; init: RequestInit } {
|
||||
const body: Record<string, unknown> = { query: params.query, max_results: params.maxResults };
|
||||
if (params.country) body.country = params.country;
|
||||
if (params.language) body.search_language_filter = [params.language];
|
||||
if (params.domainFilter?.length) body.search_domain_filter = params.domainFilter;
|
||||
return {
|
||||
url: config.baseUrl,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildExaRequest(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
): { url: string; init: RequestInit } {
|
||||
const { includes, excludes } = parseDomainFilter(params.domainFilter);
|
||||
const body: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
numResults: params.maxResults,
|
||||
type: "auto",
|
||||
text: true,
|
||||
highlights: true,
|
||||
};
|
||||
if (includes.length) body.includeDomains = includes;
|
||||
if (excludes.length) body.excludeDomains = excludes;
|
||||
if (params.searchType === "news") body.category = "news";
|
||||
return {
|
||||
url: config.baseUrl,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-api-key": params.token },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTavilyRequest(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
): { url: string; init: RequestInit } {
|
||||
const { includes, excludes } = parseDomainFilter(params.domainFilter);
|
||||
const body: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
max_results: params.maxResults,
|
||||
topic: params.searchType === "news" ? "news" : "general",
|
||||
};
|
||||
if (includes.length) body.include_domains = includes;
|
||||
if (excludes.length) body.exclude_domains = excludes;
|
||||
if (params.country) body.country = params.country;
|
||||
return {
|
||||
url: config.baseUrl,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequest(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
): { url: string; init: RequestInit } {
|
||||
if (config.id === "serper-search") return buildSerperRequest(config, params);
|
||||
if (config.id === "brave-search") return buildBraveRequest(config, params);
|
||||
if (config.id === "perplexity-search") return buildPerplexityRequest(config, params);
|
||||
if (config.id === "exa-search") return buildExaRequest(config, params);
|
||||
if (config.id === "tavily-search") return buildTavilyRequest(config, params);
|
||||
// Fallback for future providers: POST with bearer auth
|
||||
return {
|
||||
url: config.baseUrl,
|
||||
init: {
|
||||
method: config.method,
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` },
|
||||
body: JSON.stringify({
|
||||
query: params.query,
|
||||
max_results: params.maxResults,
|
||||
search_type: params.searchType,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePerplexityResponse(
|
||||
data: any,
|
||||
_query: string,
|
||||
_searchType: string
|
||||
): { results: SearchResult[]; totalResults: number | null } {
|
||||
const now = new Date().toISOString();
|
||||
const items = data.results;
|
||||
if (!Array.isArray(items)) return { results: [], totalResults: null };
|
||||
|
||||
const results = items.map((item: any, idx: number) =>
|
||||
makeResult(
|
||||
"perplexity-search",
|
||||
{
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
snippet: item.snippet,
|
||||
published_at: item.date || item.last_updated,
|
||||
},
|
||||
idx,
|
||||
now
|
||||
)
|
||||
);
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
function normalizeExaResponse(
|
||||
data: any,
|
||||
_query: string,
|
||||
_searchType: string
|
||||
): { results: SearchResult[]; totalResults: number | null } {
|
||||
const now = new Date().toISOString();
|
||||
const items = data.results;
|
||||
if (!Array.isArray(items)) return { results: [], totalResults: null };
|
||||
|
||||
const results = items.map((item: any, idx: number) =>
|
||||
makeResult(
|
||||
"exa-search",
|
||||
{
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
snippet: item.highlights?.[0] || item.text?.slice(0, 300) || "",
|
||||
score: item.score,
|
||||
published_at: item.publishedDate,
|
||||
favicon_url: item.favicon,
|
||||
author: item.author,
|
||||
image_url: item.image,
|
||||
full_text: item.text,
|
||||
text_format: "text",
|
||||
},
|
||||
idx,
|
||||
now
|
||||
)
|
||||
);
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
function normalizeTavilyResponse(
|
||||
data: any,
|
||||
_query: string,
|
||||
_searchType: string
|
||||
): { results: SearchResult[]; totalResults: number | null } {
|
||||
const now = new Date().toISOString();
|
||||
const items = data.results;
|
||||
if (!Array.isArray(items)) return { results: [], totalResults: null };
|
||||
|
||||
const results = items.map((item: any, idx: number) =>
|
||||
makeResult(
|
||||
"tavily-search",
|
||||
{
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
snippet: item.content || "",
|
||||
score: item.score,
|
||||
published_at: item.published_date,
|
||||
full_text: item.raw_content,
|
||||
text_format: "text",
|
||||
},
|
||||
idx,
|
||||
now
|
||||
)
|
||||
);
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
function normalizeResponse(
|
||||
providerId: string,
|
||||
data: any,
|
||||
query: string,
|
||||
searchType: string
|
||||
): { results: SearchResult[]; totalResults: number | null } {
|
||||
if (providerId === "serper-search") return normalizeSerperResponse(data, query, searchType);
|
||||
if (providerId === "brave-search") return normalizeBraveResponse(data, query, searchType);
|
||||
if (providerId === "perplexity-search")
|
||||
return normalizePerplexityResponse(data, query, searchType);
|
||||
if (providerId === "exa-search") return normalizeExaResponse(data, query, searchType);
|
||||
if (providerId === "tavily-search") return normalizeTavilyResponse(data, query, searchType);
|
||||
return { results: [], totalResults: null };
|
||||
}
|
||||
|
||||
// ── Main Handler ────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleSearch(options: SearchHandlerOptions): Promise<SearchHandlerResult> {
|
||||
const {
|
||||
query,
|
||||
provider: providerId,
|
||||
maxResults,
|
||||
searchType,
|
||||
country,
|
||||
language,
|
||||
domainFilter,
|
||||
credentials,
|
||||
alternateProvider,
|
||||
alternateCredentials,
|
||||
log,
|
||||
} = options;
|
||||
const startTime = Date.now();
|
||||
|
||||
// 1. Sanitize input
|
||||
const { clean: cleanQuery, error: sanitizeError } = sanitizeQuery(query);
|
||||
if (sanitizeError) {
|
||||
return { success: false, status: 400, error: sanitizeError };
|
||||
}
|
||||
|
||||
// 2. Use resolved provider from route (no re-resolution)
|
||||
const primaryConfig = getSearchProvider(providerId);
|
||||
if (!primaryConfig) {
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
error: `Unknown search provider: ${providerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Get alternate config for failover (pre-resolved by route)
|
||||
const alternateConfig = alternateProvider ? getSearchProvider(alternateProvider) : null;
|
||||
|
||||
const requestParams = {
|
||||
query: cleanQuery,
|
||||
searchType,
|
||||
maxResults,
|
||||
country,
|
||||
language,
|
||||
domainFilter,
|
||||
};
|
||||
|
||||
// 4. Try primary provider
|
||||
const result = await tryProvider(primaryConfig, requestParams, credentials, startTime, log);
|
||||
|
||||
if (result.success) return result;
|
||||
|
||||
// 5. Failover to alternate (only for retriable errors and auto-select mode)
|
||||
if (
|
||||
alternateConfig &&
|
||||
alternateCredentials &&
|
||||
!NON_RETRIABLE.has(result.status || 0) &&
|
||||
Date.now() - startTime < GLOBAL_TIMEOUT_MS
|
||||
) {
|
||||
if (log) {
|
||||
log.warn(
|
||||
"SEARCH",
|
||||
`${primaryConfig.id} failed (${result.status}), trying ${alternateConfig.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackResult = await tryProvider(
|
||||
alternateConfig,
|
||||
requestParams,
|
||||
alternateCredentials,
|
||||
startTime,
|
||||
log
|
||||
);
|
||||
|
||||
if (fallbackResult.success) return fallbackResult;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function tryProvider(
|
||||
config: SearchProviderConfig,
|
||||
params: Omit<SearchRequestParams, "token">,
|
||||
credentials: Record<string, any>,
|
||||
globalStartTime: number,
|
||||
log?: any
|
||||
): Promise<SearchHandlerResult> {
|
||||
const startTime = Date.now();
|
||||
const token = credentials.apiKey || credentials.accessToken;
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
error: `No credentials for search provider: ${config.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const { query, searchType, maxResults } = params;
|
||||
const { url, init } = buildRequest(config, { ...params, token });
|
||||
|
||||
// Timeout: min of provider timeout and remaining global timeout
|
||||
const remainingGlobal = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime);
|
||||
const timeout = Math.min(config.timeoutMs, Math.max(remainingGlobal, 1000));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
if (log) {
|
||||
log.info("SEARCH", `${config.id} | query: "${query.slice(0, 80)}" | type: ${searchType}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (log) {
|
||||
log.error("SEARCH", `${config.id} error ${response.status}: ${errorText.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
saveCallLog({
|
||||
method: config.method,
|
||||
path: "/v1/search",
|
||||
status: response.status,
|
||||
model: config.id,
|
||||
provider: config.id,
|
||||
duration: Date.now() - startTime,
|
||||
requestType: "search",
|
||||
error: errorText.slice(0, 500),
|
||||
requestBody: {
|
||||
query: query.slice(0, 200),
|
||||
search_type: searchType,
|
||||
max_results: maxResults,
|
||||
},
|
||||
}).catch(() => {
|
||||
/* non-critical — logging must not block search response */
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
error: `Search provider ${config.id} returned ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const normalized = normalizeResponse(config.id, data, query, searchType);
|
||||
// Enforce max_results — some providers return more than requested
|
||||
const results = normalized.results.slice(0, maxResults);
|
||||
const totalResults = normalized.totalResults;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
saveCallLog({
|
||||
method: config.method,
|
||||
path: "/v1/search",
|
||||
status: 200,
|
||||
model: config.id,
|
||||
provider: config.id,
|
||||
duration,
|
||||
requestType: "search",
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults },
|
||||
responseBody: { results_count: results.length, cached: false },
|
||||
}).catch(() => {
|
||||
/* non-critical — logging must not block search response */
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
provider: config.id,
|
||||
query,
|
||||
results,
|
||||
answer: null,
|
||||
usage: { queries_used: 1, search_cost_usd: config.costPerQuery },
|
||||
metrics: {
|
||||
response_time_ms: duration,
|
||||
upstream_latency_ms: duration,
|
||||
total_results_available: totalResults,
|
||||
},
|
||||
errors: [],
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
clearTimeout(timer);
|
||||
|
||||
const isTimeout = err.name === "AbortError";
|
||||
if (log) {
|
||||
log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${err.message}`);
|
||||
}
|
||||
|
||||
saveCallLog({
|
||||
method: config.method,
|
||||
path: "/v1/search",
|
||||
status: isTimeout ? 504 : 502,
|
||||
model: config.id,
|
||||
provider: config.id,
|
||||
duration: Date.now() - startTime,
|
||||
requestType: "search",
|
||||
error: err.message,
|
||||
requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults },
|
||||
}).catch(() => {
|
||||
/* non-critical — logging must not block search response */
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: isTimeout ? 504 : 502,
|
||||
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { getTaskFitness } from "./taskFitness";
|
||||
import { getModePack } from "./modePacks";
|
||||
import { getSelfHealingManager } from "./selfHealing";
|
||||
import { classifyPromptIntent } from "../intentClassifier";
|
||||
|
||||
export interface AutoComboConfig {
|
||||
id: string;
|
||||
@@ -30,6 +31,8 @@ export interface AutoComboConfig {
|
||||
modePack?: string;
|
||||
budgetCap?: number; // max cost per request in USD
|
||||
explorationRate: number; // 0.05 = 5% exploratory
|
||||
/** If set, RouterStrategy name to use for selection ('rules' | 'cost' | 'latency') */
|
||||
routerStrategy?: string;
|
||||
}
|
||||
|
||||
export interface SelectionResult {
|
||||
@@ -43,14 +46,44 @@ export interface SelectionResult {
|
||||
|
||||
/**
|
||||
* Select the best provider from an auto-combo pool.
|
||||
*
|
||||
* @param config - AutoCombo configuration
|
||||
* @param candidates - Provider candidates to score
|
||||
* @param taskType - Task type hint. When "default" or omitted, the engine will attempt
|
||||
* to infer the intent from `promptMessages` using multilingual classification.
|
||||
* @param promptMessages - Optional raw messages for intent classification
|
||||
*/
|
||||
export function selectProvider(
|
||||
config: AutoComboConfig,
|
||||
candidates: ProviderCandidate[],
|
||||
taskType: string = "default"
|
||||
taskType: string = "default",
|
||||
promptMessages?: Array<{ role: string; content: unknown }>
|
||||
): SelectionResult {
|
||||
const healer = getSelfHealingManager();
|
||||
|
||||
// ── Intent classification (ClawRouter Feature #10/11) ────────────────────
|
||||
// When taskType is generic ('default'), attempt to classify the prompt intent
|
||||
// using the multilingual intentClassifier for better task fitness scoring.
|
||||
let effectiveTaskType = taskType;
|
||||
if ((taskType === "default" || taskType === "") && promptMessages?.length) {
|
||||
// Extract text from last user message for classification
|
||||
const lastUserMsg = [...promptMessages].reverse().find((m) => m.role === "user");
|
||||
if (lastUserMsg) {
|
||||
const text =
|
||||
typeof lastUserMsg.content === "string"
|
||||
? lastUserMsg.content
|
||||
: Array.isArray(lastUserMsg.content)
|
||||
? (lastUserMsg.content as Array<{ type: string; text?: string }>)
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => b.text || "")
|
||||
.join(" ")
|
||||
: "";
|
||||
if (text.length > 10) {
|
||||
const intent = classifyPromptIntent(text);
|
||||
effectiveTaskType = intent; // 'code' | 'reasoning' | 'simple' | 'medium'
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolve weights from mode pack or config
|
||||
let weights = config.weights;
|
||||
if (config.modePack) {
|
||||
@@ -80,8 +113,8 @@ export function selectProvider(
|
||||
excluded.length = 0;
|
||||
}
|
||||
|
||||
// Score all providers
|
||||
const scored = scorePool(pool, taskType, weights, getTaskFitness);
|
||||
// Score all providers (using classified intent if available)
|
||||
const scored = scorePool(pool, effectiveTaskType, weights, getTaskFitness);
|
||||
|
||||
// Apply self-healing re-evaluation with actual scores
|
||||
const finalCandidates = scored.filter((s) => {
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
* - CostStrategy: always picks cheapest available model
|
||||
*/
|
||||
|
||||
import type { ProviderCandidate, ScoredProvider } from "./scoring.js";
|
||||
import { scorePool } from "./scoring.js";
|
||||
import { getTaskFitness } from "./taskFitness.js";
|
||||
import type { ProviderCandidate, ScoredProvider } from "./scoring.ts";
|
||||
import { scorePool } from "./scoring.ts";
|
||||
import { getTaskFitness } from "./taskFitness.ts";
|
||||
|
||||
export interface RoutingContext {
|
||||
taskType: string;
|
||||
|
||||
@@ -34,6 +34,7 @@ const DEFAULT_MODEL_P95_MS = {
|
||||
"claude-opus-4.6": 6000,
|
||||
"deepseek-chat": 2000,
|
||||
};
|
||||
const MIN_HISTORY_SAMPLES = 10;
|
||||
|
||||
// In-memory atomic counter per combo for round-robin distribution
|
||||
// Resets on server restart (by design — no stale state)
|
||||
@@ -320,12 +321,28 @@ function getBootstrapLatencyMs(modelId) {
|
||||
async function buildAutoCandidates(modelStrings, comboName) {
|
||||
const metrics = getComboMetrics(comboName);
|
||||
const { getPricingForModel } = await import("../../src/lib/localDb");
|
||||
let historicalLatencyStats = {};
|
||||
try {
|
||||
const { getModelLatencyStats } = await import("../../src/lib/usageDb");
|
||||
historicalLatencyStats = await getModelLatencyStats({
|
||||
windowHours: 24,
|
||||
minSamples: 3,
|
||||
maxRows: 10000,
|
||||
});
|
||||
} catch {
|
||||
// keep empty stats — auto-combo will use runtime + bootstrap signals
|
||||
}
|
||||
|
||||
const candidates = await Promise.all(
|
||||
modelStrings.map(async (modelStr) => {
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
||||
const model = parsed.model || modelStr;
|
||||
const historicalKey = `${provider}/${model}`;
|
||||
const historicalModelMetric = historicalLatencyStats[historicalKey] || null;
|
||||
const historicalTotal = Number(historicalModelMetric?.totalRequests);
|
||||
const hasHistoricalSignal =
|
||||
Number.isFinite(historicalTotal) && historicalTotal >= MIN_HISTORY_SAMPLES;
|
||||
|
||||
let costPer1MTokens = 1;
|
||||
try {
|
||||
@@ -341,12 +358,31 @@ async function buildAutoCandidates(modelStrings, comboName) {
|
||||
const modelMetric = metrics?.byModel?.[modelStr] || null;
|
||||
const avgLatency = Number(modelMetric?.avgLatencyMs);
|
||||
const successRate = Number(modelMetric?.successRate);
|
||||
const p95LatencyMs =
|
||||
Number.isFinite(avgLatency) && avgLatency > 0 ? avgLatency : getBootstrapLatencyMs(model);
|
||||
const errorRate =
|
||||
Number.isFinite(successRate) && successRate >= 0 && successRate <= 100
|
||||
const historicalP95Latency = Number(historicalModelMetric?.p95LatencyMs);
|
||||
const historicalStdDev = Number(historicalModelMetric?.latencyStdDev);
|
||||
const historicalSuccessRate = Number(historicalModelMetric?.successRate); // 0..1
|
||||
|
||||
const p95LatencyMs = hasHistoricalSignal
|
||||
? Number.isFinite(historicalP95Latency) && historicalP95Latency > 0
|
||||
? historicalP95Latency
|
||||
: getBootstrapLatencyMs(model)
|
||||
: Number.isFinite(avgLatency) && avgLatency > 0
|
||||
? avgLatency
|
||||
: getBootstrapLatencyMs(model);
|
||||
|
||||
const errorRate = hasHistoricalSignal
|
||||
? Number.isFinite(historicalSuccessRate) &&
|
||||
historicalSuccessRate >= 0 &&
|
||||
historicalSuccessRate <= 1
|
||||
? 1 - historicalSuccessRate
|
||||
: 0.05
|
||||
: Number.isFinite(successRate) && successRate >= 0 && successRate <= 100
|
||||
? 1 - successRate / 100
|
||||
: 0.05;
|
||||
const latencyStdDev =
|
||||
hasHistoricalSignal && Number.isFinite(historicalStdDev) && historicalStdDev > 0
|
||||
? Math.max(10, historicalStdDev)
|
||||
: Math.max(10, p95LatencyMs * 0.1);
|
||||
|
||||
const breakerStateRaw = getCircuitBreaker(`combo:${modelStr}`)?.getStatus?.()?.state;
|
||||
const circuitBreakerState =
|
||||
@@ -360,7 +396,7 @@ async function buildAutoCandidates(modelStrings, comboName) {
|
||||
circuitBreakerState,
|
||||
costPer1MTokens,
|
||||
p95LatencyMs,
|
||||
latencyStdDev: Math.max(10, p95LatencyMs * 0.1),
|
||||
latencyStdDev,
|
||||
errorRate,
|
||||
accountTier: "standard",
|
||||
quotaResetIntervalSecs: 86400,
|
||||
|
||||
@@ -123,6 +123,20 @@ export function applyToolFilter(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip all <omniModel> tags from message content before forwarding to the provider.
|
||||
* The tag is an internal OmniRoute marker; providers must never see it or their
|
||||
* cache will treat every tagged request as a new session (#454).
|
||||
*/
|
||||
export function stripModelTags(messages: Message[]): Message[] {
|
||||
return messages.map((msg) => {
|
||||
if (typeof msg.content === "string" && CACHE_TAG_PATTERN.test(msg.content)) {
|
||||
return { ...msg, content: msg.content.replace(CACHE_TAG_PATTERN, "").trimEnd() };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Main Middleware ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -158,6 +172,11 @@ export function applyComboAgentMiddleware(
|
||||
comboConfig.tool_filter_regex
|
||||
);
|
||||
|
||||
// 4. Strip internal <omniModel> tags before forwarding to provider (#454)
|
||||
// These tags are OmniRoute-internal markers and must never reach the provider
|
||||
// since providers would treat each tagged request as a new cache session.
|
||||
messages = stripModelTags(messages);
|
||||
|
||||
return {
|
||||
body: {
|
||||
...body,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Search Cache — in-memory TTL cache with request coalescing
|
||||
*
|
||||
* Bounded at MAX_CACHE_ENTRIES to prevent OOM.
|
||||
* Request coalescing deduplicates concurrent identical queries
|
||||
* to prevent cache stampede (critical for agentic tools).
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
|
||||
const MAX_CACHE_ENTRIES = 5000;
|
||||
const DEFAULT_TTL_MS = parseInt(process.env.SEARCH_CACHE_TTL_MS || String(5 * 60 * 1000), 10);
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry<unknown>>();
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
let hits = 0;
|
||||
let misses = 0;
|
||||
|
||||
/**
|
||||
* Normalize a query for cache key computation.
|
||||
* NFKC normalization, lowercase, trim, collapse whitespace.
|
||||
*/
|
||||
function normalizeQuery(query: string): string {
|
||||
return query.normalize("NFKC").toLowerCase().trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic cache key from search parameters.
|
||||
*/
|
||||
export function computeCacheKey(
|
||||
query: string,
|
||||
provider: string,
|
||||
searchType: string,
|
||||
maxResults: number,
|
||||
country?: string,
|
||||
language?: string,
|
||||
filters?: unknown
|
||||
): string {
|
||||
const normalized = normalizeQuery(query);
|
||||
const payload = JSON.stringify({
|
||||
q: normalized,
|
||||
p: provider,
|
||||
t: searchType,
|
||||
n: maxResults,
|
||||
c: country || null,
|
||||
l: language || null,
|
||||
f: filters || null,
|
||||
});
|
||||
return createHash("sha256").update(payload).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict expired entries and enforce size bound.
|
||||
* Called lazily on writes. O(n) worst case but amortized O(1).
|
||||
*/
|
||||
function evictIfNeeded(): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Remove expired entries first
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// FIFO eviction if still over limit
|
||||
while (cache.size >= MAX_CACHE_ENTRIES) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
cache.delete(firstKey);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or coalesce: return cached data, join an inflight request,
|
||||
* or execute the fetch function and cache the result.
|
||||
*
|
||||
* @param key - Cache key from computeCacheKey()
|
||||
* @param ttlMs - TTL in milliseconds (0 to bypass cache)
|
||||
* @param fetchFn - Function to execute on cache miss
|
||||
* @returns The cached or freshly fetched data
|
||||
*/
|
||||
export async function getOrCoalesce<T>(
|
||||
key: string,
|
||||
ttlMs: number,
|
||||
fetchFn: () => Promise<T>
|
||||
): Promise<{ data: T; cached: boolean }> {
|
||||
// 1. Check cache
|
||||
const cached = cache.get(key) as CacheEntry<T> | undefined;
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
hits++;
|
||||
return { data: cached.data, cached: true };
|
||||
}
|
||||
|
||||
// 2. Join inflight request if one exists (request coalescing)
|
||||
const existing = inflight.get(key) as Promise<T> | undefined;
|
||||
if (existing) {
|
||||
hits++;
|
||||
const data = await existing;
|
||||
return { data, cached: true };
|
||||
}
|
||||
|
||||
// 3. Cache miss — execute fetch
|
||||
misses++;
|
||||
const promise = fetchFn();
|
||||
inflight.set(key, promise);
|
||||
|
||||
try {
|
||||
const data = await promise;
|
||||
|
||||
// Store in cache
|
||||
if (ttlMs > 0) {
|
||||
evictIfNeeded();
|
||||
cache.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
return { data, cached: false };
|
||||
} finally {
|
||||
inflight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for monitoring.
|
||||
*/
|
||||
export function getCacheStats(): { size: number; hits: number; misses: number } {
|
||||
return { size: cache.size, hits, misses };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default TTL for search cache entries.
|
||||
*/
|
||||
export const SEARCH_CACHE_DEFAULT_TTL_MS = DEFAULT_TTL_MS;
|
||||
@@ -75,6 +75,30 @@ function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unk
|
||||
return obj[snakeKey] ?? obj[camelKey] ?? null;
|
||||
}
|
||||
|
||||
function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function toDisplayLabel(value: string): string {
|
||||
return value
|
||||
.replace(/^copilot[_\s-]*/i, "")
|
||||
.split(/[\s_-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
if (/^pro\+$/i.test(part)) return "Pro+";
|
||||
if (/^[a-z]{2,}$/.test(part)) return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
|
||||
return part;
|
||||
})
|
||||
.join(" ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota {
|
||||
if (!quota) return false;
|
||||
if (quota.unlimited && quota.total <= 0) return false;
|
||||
return quota.total > 0 || quota.remainingPercentage !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage data for a provider connection
|
||||
* @param {Object} connection - Provider connection with accessToken
|
||||
@@ -170,48 +194,65 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const dataRecord = toRecord(data);
|
||||
|
||||
// Handle different response formats (paid vs free)
|
||||
if (data.quota_snapshots) {
|
||||
if (dataRecord.quota_snapshots) {
|
||||
// Paid plan format
|
||||
const snapshots = data.quota_snapshots;
|
||||
const resetAt = parseResetTime(data.quota_reset_date);
|
||||
const snapshots = toRecord(dataRecord.quota_snapshots);
|
||||
const resetAt = parseResetTime(getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"));
|
||||
const premiumQuota = formatGitHubQuotaSnapshot(snapshots.premium_interactions, resetAt);
|
||||
const chatQuota = formatGitHubQuotaSnapshot(snapshots.chat, resetAt);
|
||||
const completionsQuota = formatGitHubQuotaSnapshot(snapshots.completions, resetAt);
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
if (shouldDisplayGitHubQuota(premiumQuota)) {
|
||||
quotas.premium_interactions = premiumQuota;
|
||||
}
|
||||
if (shouldDisplayGitHubQuota(chatQuota)) {
|
||||
quotas.chat = chatQuota;
|
||||
}
|
||||
if (shouldDisplayGitHubQuota(completionsQuota)) {
|
||||
quotas.completions = completionsQuota;
|
||||
}
|
||||
|
||||
return {
|
||||
plan: data.copilot_plan,
|
||||
resetDate: data.quota_reset_date,
|
||||
quotas: {
|
||||
chat: { ...formatGitHubQuotaSnapshot(snapshots.chat), resetAt },
|
||||
completions: { ...formatGitHubQuotaSnapshot(snapshots.completions), resetAt },
|
||||
premium_interactions: {
|
||||
...formatGitHubQuotaSnapshot(snapshots.premium_interactions),
|
||||
resetAt,
|
||||
},
|
||||
},
|
||||
plan: inferGitHubPlanName(dataRecord, premiumQuota),
|
||||
resetDate: getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"),
|
||||
quotas,
|
||||
};
|
||||
} else if (data.monthly_quotas || data.limited_user_quotas) {
|
||||
} else if (dataRecord.monthly_quotas || dataRecord.limited_user_quotas) {
|
||||
// Free/limited plan format
|
||||
const monthlyQuotas = data.monthly_quotas || {};
|
||||
const usedQuotas = data.limited_user_quotas || {};
|
||||
const resetAt = parseResetTime(data.limited_user_reset_date);
|
||||
const monthlyQuotas = toRecord(dataRecord.monthly_quotas);
|
||||
const usedQuotas = toRecord(dataRecord.limited_user_quotas);
|
||||
const resetDate = getFieldValue(dataRecord, "limited_user_reset_date", "limitedUserResetDate");
|
||||
const resetAt = parseResetTime(resetDate);
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
const addLimitedQuota = (name: string) => {
|
||||
const total = toNumber(getFieldValue(monthlyQuotas, name, name), 0);
|
||||
const used = Math.max(0, toNumber(getFieldValue(usedQuotas, name, name), 0));
|
||||
if (total <= 0) return null;
|
||||
const clampedUsed = Math.min(used, total);
|
||||
quotas[name] = {
|
||||
used: clampedUsed,
|
||||
total,
|
||||
remaining: Math.max(total - clampedUsed, 0),
|
||||
remainingPercentage: clampPercentage(((total - clampedUsed) / total) * 100),
|
||||
unlimited: false,
|
||||
resetAt,
|
||||
};
|
||||
return quotas[name];
|
||||
};
|
||||
|
||||
const premiumQuota = addLimitedQuota("premium_interactions");
|
||||
addLimitedQuota("chat");
|
||||
addLimitedQuota("completions");
|
||||
|
||||
return {
|
||||
plan: data.copilot_plan || data.access_type_sku,
|
||||
resetDate: data.limited_user_reset_date,
|
||||
quotas: {
|
||||
chat: {
|
||||
used: usedQuotas.chat || 0,
|
||||
total: monthlyQuotas.chat || 0,
|
||||
unlimited: false,
|
||||
resetAt,
|
||||
},
|
||||
completions: {
|
||||
used: usedQuotas.completions || 0,
|
||||
total: monthlyQuotas.completions || 0,
|
||||
unlimited: false,
|
||||
resetAt,
|
||||
},
|
||||
},
|
||||
plan: inferGitHubPlanName(dataRecord, premiumQuota),
|
||||
resetDate,
|
||||
quotas,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,17 +262,103 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatGitHubQuotaSnapshot(quota) {
|
||||
if (!quota) return { used: 0, total: 0, unlimited: true };
|
||||
function formatGitHubQuotaSnapshot(quota, resetAt: string | null = null): UsageQuota | null {
|
||||
const source = toRecord(quota);
|
||||
if (Object.keys(source).length === 0) return null;
|
||||
|
||||
const unlimited = source.unlimited === true;
|
||||
const entitlement = toNumber(source.entitlement, Number.NaN);
|
||||
const totalValue = toNumber(source.total, Number.NaN);
|
||||
const remainingValue = toNumber(source.remaining, Number.NaN);
|
||||
const usedValue = toNumber(source.used, Number.NaN);
|
||||
const percentRemainingValue = toNumber(
|
||||
getFieldValue(source, "percent_remaining", "percentRemaining"),
|
||||
Number.NaN
|
||||
);
|
||||
|
||||
let total = Number.isFinite(totalValue)
|
||||
? Math.max(0, totalValue)
|
||||
: Number.isFinite(entitlement)
|
||||
? Math.max(0, entitlement)
|
||||
: 0;
|
||||
let remaining = Number.isFinite(remainingValue) ? Math.max(0, remainingValue) : undefined;
|
||||
let used = Number.isFinite(usedValue) ? Math.max(0, usedValue) : undefined;
|
||||
let remainingPercentage = Number.isFinite(percentRemainingValue)
|
||||
? clampPercentage(percentRemainingValue)
|
||||
: undefined;
|
||||
|
||||
if (used === undefined && total > 0 && remaining !== undefined) {
|
||||
used = Math.max(total - remaining, 0);
|
||||
}
|
||||
|
||||
if (remaining === undefined && total > 0 && used !== undefined) {
|
||||
remaining = Math.max(total - used, 0);
|
||||
}
|
||||
|
||||
if (remainingPercentage === undefined && total > 0 && remaining !== undefined) {
|
||||
remainingPercentage = clampPercentage((remaining / total) * 100);
|
||||
}
|
||||
|
||||
if (total <= 0 && remainingPercentage !== undefined) {
|
||||
total = 100;
|
||||
used = 100 - remainingPercentage;
|
||||
remaining = remainingPercentage;
|
||||
}
|
||||
|
||||
return {
|
||||
used: quota.entitlement - quota.remaining,
|
||||
total: quota.entitlement,
|
||||
remaining: quota.remaining,
|
||||
unlimited: quota.unlimited || false,
|
||||
used: Math.max(0, used ?? 0),
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage,
|
||||
resetAt,
|
||||
unlimited,
|
||||
};
|
||||
}
|
||||
|
||||
function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): string {
|
||||
const rawPlan = getFieldValue(data, "copilot_plan", "copilotPlan");
|
||||
const rawSku = getFieldValue(data, "access_type_sku", "accessTypeSku");
|
||||
const planText = typeof rawPlan === "string" ? rawPlan.trim() : "";
|
||||
const skuText = typeof rawSku === "string" ? rawSku.trim() : "";
|
||||
const combined = `${skuText} ${planText}`.trim().toUpperCase();
|
||||
const monthlyQuotas = toRecord(getFieldValue(data, "monthly_quotas", "monthlyQuotas"));
|
||||
const premiumTotal =
|
||||
premiumQuota?.total ||
|
||||
toNumber(getFieldValue(monthlyQuotas, "premium_interactions", "premiumInteractions"), 0);
|
||||
const chatTotal = toNumber(getFieldValue(monthlyQuotas, "chat", "chat"), 0);
|
||||
|
||||
if (
|
||||
combined.includes("PRO+") ||
|
||||
combined.includes("PRO_PLUS") ||
|
||||
combined.includes("PROPLUS")
|
||||
) {
|
||||
return "Copilot Pro+";
|
||||
}
|
||||
if (combined.includes("ENTERPRISE")) return "Copilot Enterprise";
|
||||
if (combined.includes("BUSINESS")) return "Copilot Business";
|
||||
if (combined.includes("STUDENT")) return "Copilot Student";
|
||||
if (combined.includes("FREE")) return "Copilot Free";
|
||||
if (combined.includes("PRO")) return "Copilot Pro";
|
||||
|
||||
if (premiumTotal >= 1400) return "Copilot Pro+";
|
||||
if (premiumTotal >= 900) return "Copilot Enterprise";
|
||||
if (premiumTotal >= 250) {
|
||||
if (combined.includes("INDIVIDUAL")) return "Copilot Pro";
|
||||
return "Copilot Business";
|
||||
}
|
||||
if (premiumTotal > 0 || chatTotal === 50) return "Copilot Free";
|
||||
|
||||
if (skuText) {
|
||||
const label = toDisplayLabel(skuText);
|
||||
return label ? `Copilot ${label}` : "GitHub Copilot";
|
||||
}
|
||||
if (planText) {
|
||||
const label = toDisplayLabel(planText);
|
||||
return label ? `Copilot ${label}` : "GitHub Copilot";
|
||||
}
|
||||
return "GitHub Copilot";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini CLI Usage (Google Cloud)
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.6.10",
|
||||
"version": "2.7.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.6.10",
|
||||
"version": "2.7.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -1725,9 +1725,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
||||
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
|
||||
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -1741,9 +1741,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
|
||||
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
|
||||
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1757,9 +1757,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
|
||||
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
|
||||
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1773,9 +1773,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1789,9 +1789,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1805,9 +1805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1821,9 +1821,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1837,9 +1837,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1853,9 +1853,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -6817,7 +6817,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -8812,14 +8811,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
|
||||
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
|
||||
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.1.6",
|
||||
"@next/env": "16.1.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.6"
|
||||
@@ -8831,14 +8830,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.1.6",
|
||||
"@next/swc-darwin-x64": "16.1.6",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.6",
|
||||
"@next/swc-linux-arm64-musl": "16.1.6",
|
||||
"@next/swc-linux-x64-gnu": "16.1.6",
|
||||
"@next/swc-linux-x64-musl": "16.1.6",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.6",
|
||||
"@next/swc-win32-x64-msvc": "16.1.6",
|
||||
"@next/swc-darwin-arm64": "16.1.7",
|
||||
"@next/swc-darwin-x64": "16.1.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.7",
|
||||
"@next/swc-linux-arm64-musl": "16.1.7",
|
||||
"@next/swc-linux-x64-gnu": "16.1.7",
|
||||
"@next/swc-linux-x64-musl": "16.1.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.7",
|
||||
"@next/swc-win32-x64-msvc": "16.1.7",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="56" height="64" viewBox="0 0 56 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M53.292 15.321l1.5-3.676s-1.909-2.043-4.227-4.358c-2.317-2.315-7.225-.953-7.225-.953L37.751 0H18.12l-5.589 6.334s-4.908-1.362-7.225.953C2.988 9.602 1.08 11.645 1.08 11.645l1.5 3.676-1.91 5.447s5.614 21.236 6.272 23.83c1.295 5.106 2.181 7.08 5.862 9.668 3.68 2.587 10.36 7.08 11.45 7.762 1.091.68 2.455 1.84 3.682 1.84 1.227 0 2.59-1.16 3.68-1.84 1.091-.681 7.77-5.175 11.452-7.762 3.68-2.587 4.567-4.562 5.862-9.668.657-2.594 6.27-23.83 6.27-23.83l-1.908-5.447z" fill="url(#paint0_linear)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M34.888 11.508c.818 0 6.885-1.157 6.885-1.157s7.189 8.68 7.189 10.536c0 1.534-.619 2.134-1.347 2.842-.152.148-.31.3-.467.468l-5.39 5.717a9.42 9.42 0 01-.176.18c-.538.54-1.33 1.336-.772 2.658l.115.269c.613 1.432 1.37 3.2.407 4.99-1.025 1.906-2.78 3.178-3.905 2.967-1.124-.21-3.766-1.589-4.737-2.218-.971-.63-4.05-3.166-4.05-4.137 0-.809 2.214-2.155 3.29-2.81.214-.13.383-.232.48-.298.111-.075.297-.19.526-.332.981-.61 2.754-1.71 2.799-2.197.055-.602.034-.778-.758-2.264-.168-.316-.365-.654-.568-1.004-.754-1.295-1.598-2.745-1.41-3.784.21-1.173 2.05-1.845 3.608-2.415.194-.07.385-.14.567-.209l1.623-.609c1.556-.582 3.284-1.229 3.57-1.36.394-.181.292-.355-.903-.468a54.655 54.655 0 01-.58-.06c-1.48-.157-4.209-.446-5.535-.077-.261.073-.553.152-.86.235-1.49.403-3.317.897-3.493 1.182-.03.05-.06.093-.089.133-.168.238-.277.394-.091 1.406.055.302.169.895.31 1.629.41 2.148 1.053 5.498 1.134 6.25.011.106.024.207.036.305.103.84.171 1.399-.805 1.622l-.255.058c-1.102.252-2.717.623-3.3.623-.584 0-2.2-.37-3.302-.623l-.254-.058c-.976-.223-.907-.782-.804-1.622.012-.098.024-.2.035-.305.081-.753.725-4.112 1.137-6.259.14-.73.253-1.32.308-1.62.185-1.012.076-1.168-.092-1.406a3.743 3.743 0 01-.09-.133c-.174-.285-2-.779-3.491-1.182-.307-.083-.6-.162-.86-.235-1.327-.37-4.055-.08-5.535.077-.226.024-.422.045-.58.06-1.196.113-1.297.287-.903.468.285.131 2.013.778 3.568 1.36.597.223 1.17.437 1.624.609.183.069.373.138.568.21 1.558.57 3.398 1.241 3.608 2.414.187 1.039-.657 2.489-1.41 3.784-.204.35-.4.688-.569 1.004-.791 1.486-.812 1.662-.757 2.264.044.488 1.816 1.587 2.798 2.197.229.142.415.257.526.332.098.066.266.168.48.298 1.076.654 3.29 2 3.29 2.81 0 .97-3.078 3.507-4.05 4.137-.97.63-3.612 2.008-4.737 2.218-1.124.21-2.88-1.061-3.904-2.966-.963-1.791-.207-3.559.406-4.99l.115-.27c.559-1.322-.233-2.118-.772-2.658a9.377 9.377 0 01-.175-.18l-5.39-5.717c-.158-.167-.316-.32-.468-.468-.728-.707-1.346-1.308-1.346-2.842 0-1.855 7.189-10.536 7.189-10.536s6.066 1.157 6.884 1.157c.653 0 1.913-.433 3.227-.885.333-.114.669-.23 1-.34 1.635-.545 2.726-.549 2.726-.549s1.09.004 2.726.549c.33.11.667.226 1 .34 1.313.452 2.574.885 3.226.885zm-1.041 30.706c1.282.66 2.192 1.128 2.536 1.343.445.278.174.803-.232 1.09-.405.285-5.853 4.499-6.381 4.965l-.215.191c-.509.459-1.159 1.044-1.62 1.044-.46 0-1.11-.586-1.62-1.044l-.213-.191c-.53-.466-5.977-4.68-6.382-4.966-.405-.286-.677-.81-.232-1.09.344-.214 1.255-.683 2.539-1.344l1.22-.629c1.92-.992 4.315-1.837 4.689-1.837.373 0 2.767.844 4.689 1.837.436.226.845.437 1.222.63z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M43.34 6.334L37.751 0H18.12l-5.589 6.334s-4.908-1.362-7.225.953c0 0 6.544-.59 8.793 3.064 0 0 6.066 1.157 6.884 1.157.818 0 2.59-.68 4.226-1.225 1.636-.545 2.727-.549 2.727-.549s1.09.004 2.726.549 3.408 1.225 4.226 1.225c.818 0 6.885-1.157 6.885-1.157 2.249-3.654 8.792-3.064 8.792-3.064-2.317-2.315-7.225-.953-7.225-.953z" fill="url(#paint1_linear)"/><defs><linearGradient id="paint0_linear" x1=".671" y1="64.319" x2="55.2" y2="64.319" gradientUnits="userSpaceOnUse"><stop stop-color="#F50"/><stop offset=".41" stop-color="#F50"/><stop offset=".582" stop-color="#FF2000"/><stop offset="1" stop-color="#FF2000"/></linearGradient><linearGradient id="paint1_linear" x1="6.278" y1="11.466" x2="50.565" y2="11.466" gradientUnits="userSpaceOnUse"><stop stop-color="#FF452A"/><stop offset="1" stop-color="#FF2000"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
||||
<rect width="48" height="48" rx="8" fill="#1E40AF"/>
|
||||
<text x="24" y="32" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="22" font-weight="700" fill="white">exa</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 295 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
||||
<rect width="48" height="48" rx="8" fill="#1E40AF"/>
|
||||
<text x="24" y="32" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="22" font-weight="700" fill="white">exa</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -278,6 +278,19 @@ if (existsSync(swcHelpersSrc) && !existsSync(swcHelpersDst)) {
|
||||
console.log(" ✅ @swc/helpers included in standalone build.");
|
||||
}
|
||||
|
||||
// ── Step 10.6: Remove large binaries from standalone build ──
|
||||
// These directories contain platform-native binaries (.node, .asar) that
|
||||
// trigger Z_DATA_ERROR during npm pack. They are not needed in the npm package.
|
||||
const binaryDirsToRemove = ["vscode-extension", "electron"];
|
||||
for (const dir of binaryDirsToRemove) {
|
||||
const targetDir = join(APP_DIR, dir);
|
||||
if (existsSync(targetDir)) {
|
||||
console.log(` 🧹 Removing app/${dir}/ (not needed in npm package)...`);
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
console.log(` ✅ app/${dir}/ removed.`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Done ───────────────────────────────────────────────────
|
||||
const appPkg = join(APP_DIR, "package.json");
|
||||
if (existsSync(appPkg)) {
|
||||
|
||||
@@ -1181,6 +1181,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
const [config, setConfig] = useState(combo?.config || {});
|
||||
const [showStrategyNudge, setShowStrategyNudge] = useState(false);
|
||||
const strategyChangeMountedRef = useRef(false);
|
||||
// Agent features (#399 / #401 / #454)
|
||||
const [agentSystemMessage, setAgentSystemMessage] = useState<string>(combo?.system_message || "");
|
||||
const [agentToolFilter, setAgentToolFilter] = useState<string>(combo?.tool_filter_regex || "");
|
||||
const [agentContextCache, setAgentContextCache] = useState<boolean>(
|
||||
!!combo?.context_cache_protection
|
||||
);
|
||||
|
||||
// DnD state
|
||||
const hasPricingForModel = useCallback(
|
||||
@@ -1532,6 +1538,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
saveData.config = configToSave;
|
||||
}
|
||||
|
||||
// Agent features (#399 / #401 / #454)
|
||||
if (agentSystemMessage.trim()) saveData.system_message = agentSystemMessage.trim();
|
||||
else delete saveData.system_message;
|
||||
if (agentToolFilter.trim()) saveData.tool_filter_regex = agentToolFilter.trim();
|
||||
else delete saveData.tool_filter_regex;
|
||||
if (agentContextCache) saveData.context_cache_protection = true;
|
||||
else delete saveData.context_cache_protection;
|
||||
|
||||
await onSave(saveData);
|
||||
setSaving(false);
|
||||
};
|
||||
@@ -2052,6 +2066,72 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Features (#399 / #401 / #454) */}
|
||||
<div className="flex flex-col gap-2 p-3 bg-black/[0.02] dark:bg-white/[0.02] rounded-lg border border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="material-symbols-outlined text-[14px] text-primary">smart_toy</span>
|
||||
<p className="text-xs font-medium">Agent Features</p>
|
||||
<span className="text-[10px] text-text-muted">
|
||||
— optional, for agent/tool workflows
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* System Message Override */}
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-text-muted block mb-0.5">
|
||||
System Message Override
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={agentSystemMessage}
|
||||
onChange={(e) => setAgentSystemMessage(e.target.value)}
|
||||
placeholder="Override the system prompt for all requests routed through this combo…"
|
||||
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none resize-none"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-0.5">
|
||||
Replaces any system message sent by the client. Leave empty to pass through client
|
||||
system messages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tool Filter Regex */}
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-text-muted block mb-0.5">
|
||||
Tool Filter Regex
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentToolFilter}
|
||||
onChange={(e) => setAgentToolFilter(e.target.value)}
|
||||
placeholder="e.g. ^(bash|computer)$"
|
||||
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-0.5">
|
||||
Only tools whose name matches this regex are forwarded to the provider. Leave empty
|
||||
to forward all tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Context Cache Protection */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-text-muted block">
|
||||
Context Cache Protection
|
||||
</label>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
Pins the provider/model across turns to preserve cache sessions. Internal tags are
|
||||
stripped before forwarding to the provider.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agentContextCache}
|
||||
onChange={(e) => setAgentContextCache(e.target.checked)}
|
||||
className="accent-primary shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
|
||||
|
||||
@@ -33,11 +33,29 @@ export default function APIPageClient({ machineId }) {
|
||||
const [viewTab, setViewTab] = useState("api");
|
||||
const [mcpStatus, setMcpStatus] = useState<any>(null);
|
||||
const [a2aStatus, setA2aStatus] = useState<any>(null);
|
||||
const [searchProviders, setSearchProviders] = useState<any[]>([]);
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const fetchSearchProviders = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/search/providers");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchProviders(data.providers || []);
|
||||
}
|
||||
} catch {
|
||||
// Search endpoint may not be available
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([loadCloudSettings(), fetchModels(), fetchProtocolStatus()]).finally(() => {
|
||||
Promise.allSettled([
|
||||
loadCloudSettings(),
|
||||
fetchModels(),
|
||||
fetchProtocolStatus(),
|
||||
fetchSearchProviders(),
|
||||
]).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
@@ -575,6 +593,47 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search & Discovery */}
|
||||
{searchProviders.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-sm text-cyan-400">
|
||||
travel_explore
|
||||
</span>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("categorySearch") || "Search & Discovery"}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<EndpointSection
|
||||
icon="search"
|
||||
iconColor="text-cyan-500"
|
||||
iconBg="bg-cyan-500/10"
|
||||
title={t("webSearch") || "Web Search"}
|
||||
path="/v1/search"
|
||||
description={
|
||||
t("webSearchDesc") ||
|
||||
"Unified web search across multiple providers with automatic failover and caching"
|
||||
}
|
||||
models={searchProviders.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
owned_by: p.id,
|
||||
type: "search",
|
||||
}))}
|
||||
expanded={expandedEndpoint === "search"}
|
||||
onToggle={() =>
|
||||
setExpandedEndpoint(expandedEndpoint === "search" ? null : "search")
|
||||
}
|
||||
copy={copy}
|
||||
copied={copied}
|
||||
baseUrl={currentEndpoint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Utility & Management */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
|
||||
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
|
||||
interface SearchProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "active" | "no_credentials";
|
||||
cost_per_query: number;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
id: string;
|
||||
provider: string;
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
answer: string | null;
|
||||
cached: boolean;
|
||||
usage: {
|
||||
queries_used: number;
|
||||
search_cost_usd: number;
|
||||
};
|
||||
metrics: {
|
||||
response_time_ms: number;
|
||||
upstream_latency_ms: number;
|
||||
total_results_available: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
export default function SearchPlayground() {
|
||||
const t = useTranslations("search");
|
||||
const [providers, setProviders] = useState<SearchProvider[]>([]);
|
||||
const [selectedProvider, setSelectedProvider] = useState("");
|
||||
const [requestBody, setRequestBody] = useState(
|
||||
JSON.stringify(
|
||||
{
|
||||
query: "latest AI developments",
|
||||
max_results: 5,
|
||||
search_type: "web",
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
const [response, setResponse] = useState<SearchResponse | null>(null);
|
||||
const [rawResponse, setRawResponse] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [statusCode, setStatusCode] = useState(0);
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/search/providers")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const allProviders = data.providers || [];
|
||||
setProviders(allProviders);
|
||||
const firstActive = allProviders.find((p: SearchProvider) => p.status === "active");
|
||||
if (firstActive) setSelectedProvider(firstActive.id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setResponse(null);
|
||||
setRawResponse("");
|
||||
setStatusCode(0);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
let body: any;
|
||||
try {
|
||||
body = JSON.parse(requestBody);
|
||||
} catch {
|
||||
setError("Invalid JSON in request body");
|
||||
setLoading(false);
|
||||
clearTimeout(timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProvider) body.provider = selectedProvider;
|
||||
|
||||
const res = await fetch("/api/v1/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
setDuration(Date.now() - start);
|
||||
setStatusCode(res.status);
|
||||
|
||||
const data = await res.json();
|
||||
setRawResponse(JSON.stringify(data, null, 2));
|
||||
|
||||
if (res.ok) {
|
||||
setResponse(data);
|
||||
} else {
|
||||
setError(data.error?.message || data.error || `Error ${res.status}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setDuration(Date.now() - start);
|
||||
if (err.name === "AbortError") {
|
||||
setError("Request timed out (15s)");
|
||||
} else {
|
||||
setError(err.message || "Network error");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 0.9) return "text-success";
|
||||
if (score >= 0.7) return "text-warning";
|
||||
return "text-error";
|
||||
};
|
||||
|
||||
const getScoreBg = (score: number) => {
|
||||
if (score >= 0.9) return "bg-green-500/10";
|
||||
if (score >= 0.7) return "bg-yellow-500/10";
|
||||
return "bg-red-500/10";
|
||||
};
|
||||
|
||||
const noProviders = providers.filter((p) => p.status === "active").length === 0;
|
||||
|
||||
const editorTheme =
|
||||
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
|
||||
? "vs-dark"
|
||||
: "light";
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Request panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">upload</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Request</h3>
|
||||
<Badge variant="info" size="sm">
|
||||
POST /v1/search
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(requestBody)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setRequestBody(
|
||||
JSON.stringify(
|
||||
{
|
||||
query: "latest AI developments",
|
||||
max_results: 5,
|
||||
search_type: "web",
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Reset to default"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={requestBody}
|
||||
onChange={(value: string | undefined) => setRequestBody(value || "")}
|
||||
theme={editorTheme}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
formatOnPaste: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onChange={(e: any) => setSelectedProvider(e.target.value)}
|
||||
options={providers.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name}${p.status === "no_credentials" ? " (no key)" : ""}`,
|
||||
}))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Button icon="stop" variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon="search"
|
||||
onClick={handleSend}
|
||||
disabled={noProviders || !requestBody.trim()}
|
||||
>
|
||||
{t("webSearch")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{noProviders && <p className="text-xs text-text-muted">{t("noSearchProviders")}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Response panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
download
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Response</h3>
|
||||
{statusCode > 0 && (
|
||||
<>
|
||||
<Badge variant={statusCode < 400 ? "success" : "error"} size="sm">
|
||||
{statusCode}
|
||||
</Badge>
|
||||
<span className="text-xs text-text-muted">{duration}ms</span>
|
||||
</>
|
||||
)}
|
||||
{loading && (
|
||||
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{response && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className={`text-xs px-3 py-1 rounded-md ${
|
||||
!showJson
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted"
|
||||
}`}
|
||||
onClick={() => setShowJson(false)}
|
||||
>
|
||||
{t("formatted")}
|
||||
</button>
|
||||
<button
|
||||
className={`text-xs px-3 py-1 rounded-md ${
|
||||
showJson
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted"
|
||||
}`}
|
||||
onClick={() => setShowJson(true)}
|
||||
>
|
||||
{t("rawJson")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg overflow-hidden min-h-[400px]">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-[400px]">
|
||||
<span className="material-symbols-outlined text-[24px] text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="p-4">
|
||||
<div className="text-error text-sm">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{response && !showJson && !loading && (
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Meta bar */}
|
||||
<div className="flex justify-between items-center p-2 bg-bg-alt rounded-lg">
|
||||
<div className="flex items-center gap-3 text-xs text-text-muted">
|
||||
<span>
|
||||
{response.results.length} {t("searchResults").toLowerCase()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
{response.provider}
|
||||
</span>
|
||||
<span>${response.usage?.search_cost_usd?.toFixed(4)}</span>
|
||||
<span>{formatBytes(rawResponse.length)}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs flex items-center gap-1 ${
|
||||
response.cached ? "text-success" : "text-warning"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
response.cached ? "bg-success" : "bg-warning"
|
||||
}`}
|
||||
/>
|
||||
{response.cached ? t("cacheHit") : t("cacheMiss")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{response.results.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-l-[3px] border-l-primary p-3 bg-surface rounded-r-lg border border-border"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{i + 1}. {r.title}
|
||||
</span>
|
||||
{r.score != null && (
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded-md ml-2 whitespace-nowrap ${getScoreBg(r.score)} ${getScoreColor(r.score)}`}
|
||||
>
|
||||
{r.score.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={r.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent text-[11px] block mt-0.5"
|
||||
>
|
||||
{r.url}
|
||||
</a>
|
||||
<p className="text-xs text-text-muted mt-1 leading-relaxed">{r.snippet}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{response && showJson && !loading && (
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={rawResponse}
|
||||
theme={editorTheme}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && !response && (
|
||||
<div className="flex items-center justify-center h-[400px] text-text-muted text-sm">
|
||||
{t("emptyState")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
const SearchPlayground = dynamic(() => import("./SearchPlayground"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
@@ -27,6 +30,7 @@ const ENDPOINT_OPTIONS = [
|
||||
{ value: "video", label: "Video Generation" },
|
||||
{ value: "music", label: "Music Generation" },
|
||||
{ value: "rerank", label: "Rerank" },
|
||||
{ value: "search", label: "Web Search" },
|
||||
];
|
||||
|
||||
const DEFAULT_BODIES: Record<string, object> = {
|
||||
@@ -83,6 +87,11 @@ const DEFAULT_BODIES: Record<string, object> = {
|
||||
],
|
||||
top_n: 2,
|
||||
},
|
||||
search: {
|
||||
query: "latest AI developments",
|
||||
max_results: 5,
|
||||
search_type: "web",
|
||||
},
|
||||
};
|
||||
|
||||
const ENDPOINT_PATHS: Record<string, string> = {
|
||||
@@ -95,6 +104,7 @@ const ENDPOINT_PATHS: Record<string, string> = {
|
||||
video: "/v1/videos/generations",
|
||||
music: "/v1/music/generations",
|
||||
rerank: "/v1/rerank",
|
||||
search: "/v1/search",
|
||||
};
|
||||
|
||||
// Models known to support vision (image input)
|
||||
@@ -189,6 +199,7 @@ export default function PlaygroundPage() {
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [uploadedImages, setUploadedImages] = useState<string[]>([]); // base64 URIs for vision
|
||||
|
||||
const isSearchEndpoint = selectedEndpoint === "search";
|
||||
const isTranscriptionEndpoint = selectedEndpoint === "transcription";
|
||||
const isChatEndpoint = selectedEndpoint === "chat";
|
||||
const isImageEndpoint = selectedEndpoint === "images";
|
||||
@@ -419,33 +430,7 @@ export default function PlaygroundPage() {
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col sm:flex-row items-end gap-4">
|
||||
{/* Provider */}
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onChange={(e: any) => handleProviderChange(e.target.value)}
|
||||
options={providers}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
<Select
|
||||
value={selectedModel}
|
||||
onChange={(e: any) => handleModelChange(e.target.value)}
|
||||
options={filteredModels}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Endpoint */}
|
||||
{/* Endpoint — always first */}
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Endpoint
|
||||
@@ -458,274 +443,315 @@ export default function PlaygroundPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<div className="shrink-0">
|
||||
{loading ? (
|
||||
<Button icon="stop" variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!requestBody.trim() && !isTranscriptionEndpoint) ||
|
||||
(!selectedModel && !isTranscriptionEndpoint)
|
||||
}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Provider — hidden in search mode */}
|
||||
{!isSearchEndpoint && (
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onChange={(e: any) => handleProviderChange(e.target.value)}
|
||||
options={providers}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model — hidden in search mode */}
|
||||
{!isSearchEndpoint && (
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
<Select
|
||||
value={selectedModel}
|
||||
onChange={(e: any) => handleModelChange(e.target.value)}
|
||||
options={filteredModels}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Send Button — hidden in search mode (SearchPlayground has its own) */}
|
||||
{!isSearchEndpoint && (
|
||||
<div className="shrink-0">
|
||||
{loading ? (
|
||||
<Button icon="stop" variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!requestBody.trim() && !isTranscriptionEndpoint) ||
|
||||
(!selectedModel && !isTranscriptionEndpoint)
|
||||
}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* File Upload Zone — shown for transcription and vision models */}
|
||||
{(isTranscriptionEndpoint || supportsVision) && (
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
attach_file
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"}
|
||||
</h3>
|
||||
{isTranscriptionEndpoint && (
|
||||
<Badge variant="info" size="sm">
|
||||
multipart/form-data
|
||||
</Badge>
|
||||
)}
|
||||
{supportsVision && (
|
||||
<Badge variant="info" size="sm">
|
||||
up to 4 images
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isTranscriptionEndpoint && (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,video/*"
|
||||
onChange={handleAudioFileChange}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-xs text-text-muted mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px] text-green-500">
|
||||
check_circle
|
||||
</span>
|
||||
{uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB)
|
||||
</p>
|
||||
{/* Search mode — isolated sub-component */}
|
||||
{isSearchEndpoint ? (
|
||||
<SearchPlayground />
|
||||
) : (
|
||||
<>
|
||||
{/* File Upload Zone — shown for transcription and vision models */}
|
||||
{(isTranscriptionEndpoint || supportsVision) && (
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
attach_file
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"}
|
||||
</h3>
|
||||
{isTranscriptionEndpoint && (
|
||||
<Badge variant="info" size="sm">
|
||||
multipart/form-data
|
||||
</Badge>
|
||||
)}
|
||||
{supportsVision && (
|
||||
<Badge variant="info" size="sm">
|
||||
up to 4 images
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isTranscriptionEndpoint && (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,video/*"
|
||||
onChange={handleAudioFileChange}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-xs text-text-muted mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px] text-green-500">
|
||||
check_circle
|
||||
</span>
|
||||
{uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB)
|
||||
</p>
|
||||
)}
|
||||
{!uploadedFile && (
|
||||
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">info</span>
|
||||
Select an audio file to transcribe (mp3, wav, m4a, ogg, flac…)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!uploadedFile && (
|
||||
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">info</span>
|
||||
Select an audio file to transcribe (mp3, wav, m4a, ogg, flac…)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{supportsVision && (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageFileChange}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
|
||||
/>
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
{uploadedImages.map((src, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative group size-16 rounded overflow-hidden border border-border"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={`Attached ${i + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{supportsVision && (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageFileChange}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
|
||||
/>
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
{uploadedImages.map((src, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative group size-16 rounded overflow-hidden border border-border"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={`Attached ${i + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setUploadedImages((prev) => prev.filter((_, idx) => idx !== i))
|
||||
}
|
||||
className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() =>
|
||||
setUploadedImages((prev) => prev.filter((_, idx) => idx !== i))
|
||||
}
|
||||
className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
onClick={() => setUploadedImages([])}
|
||||
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Split Editor View */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Request Panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
upload
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Request</h3>
|
||||
<Badge variant="info" size="sm">
|
||||
POST {ENDPOINT_PATHS[selectedEndpoint]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setUploadedImages([])}
|
||||
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
|
||||
onClick={() => handleCopy(requestBody)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
Clear all
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
|
||||
if ("model" in template) (template as any).model = selectedModel;
|
||||
setRequestBody(JSON.stringify(template, null, 2));
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Reset to default"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Split Editor View */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Request Panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
upload
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Request</h3>
|
||||
<Badge variant="info" size="sm">
|
||||
POST {ENDPOINT_PATHS[selectedEndpoint]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleCopy(requestBody)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
|
||||
if ("model" in template) (template as any).model = selectedModel;
|
||||
setRequestBody(JSON.stringify(template, null, 2));
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Reset to default"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isTranscriptionEndpoint && (
|
||||
<p className="text-xs text-text-muted bg-amber-500/10 border border-amber-500/20 rounded px-2 py-1.5 flex items-start gap-1">
|
||||
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
|
||||
info
|
||||
</span>
|
||||
Transcription uses multipart/form-data. Upload the audio file above — JSON below
|
||||
controls extra params (model, language).
|
||||
</p>
|
||||
)}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={requestBody}
|
||||
onChange={(value: string | undefined) => setRequestBody(value || "")}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
formatOnPaste: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Response Panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
download
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Response</h3>
|
||||
{responseStatus !== null && (
|
||||
<Badge
|
||||
variant={responseStatus >= 200 && responseStatus < 300 ? "success" : "error"}
|
||||
size="sm"
|
||||
>
|
||||
{responseStatus}
|
||||
</Badge>
|
||||
)}
|
||||
{responseDuration !== null && (
|
||||
<span className="text-xs text-text-muted">{responseDuration}ms</span>
|
||||
)}
|
||||
{loading && (
|
||||
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleCopy(responseBody)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
{audioUrl ? (
|
||||
<div className="p-4 space-y-3">
|
||||
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
|
||||
<a
|
||||
href={audioUrl}
|
||||
download="speech.mp3"
|
||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">download</span>
|
||||
Download audio
|
||||
</a>
|
||||
</div>
|
||||
) : imageData ? (
|
||||
<ImageResultsInline data={imageData} />
|
||||
) : transcriptionText !== null ? (
|
||||
<div className="p-4 space-y-2">
|
||||
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
|
||||
Transcription
|
||||
{isTranscriptionEndpoint && (
|
||||
<p className="text-xs text-text-muted bg-amber-500/10 border border-amber-500/20 rounded px-2 py-1.5 flex items-start gap-1">
|
||||
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
|
||||
info
|
||||
</span>
|
||||
Transcription uses multipart/form-data. Upload the audio file above — JSON below
|
||||
controls extra params (model, language).
|
||||
</p>
|
||||
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
|
||||
{transcriptionText}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(transcriptionText)}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">content_copy</span>
|
||||
Copy text
|
||||
</button>
|
||||
)}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={requestBody}
|
||||
onChange={(value: string | undefined) => setRequestBody(value || "")}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
formatOnPaste: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={responseBody}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Response Panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
download
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Response</h3>
|
||||
{responseStatus !== null && (
|
||||
<Badge
|
||||
variant={
|
||||
responseStatus >= 200 && responseStatus < 300 ? "success" : "error"
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{responseStatus}
|
||||
</Badge>
|
||||
)}
|
||||
{responseDuration !== null && (
|
||||
<span className="text-xs text-text-muted">{responseDuration}ms</span>
|
||||
)}
|
||||
{loading && (
|
||||
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleCopy(responseBody)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
{audioUrl ? (
|
||||
<div className="p-4 space-y-3">
|
||||
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
|
||||
<a
|
||||
href={audioUrl}
|
||||
download="speech.mp3"
|
||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">download</span>
|
||||
Download audio
|
||||
</a>
|
||||
</div>
|
||||
) : imageData ? (
|
||||
<ImageResultsInline data={imageData} />
|
||||
) : transcriptionText !== null ? (
|
||||
<div className="p-4 space-y-2">
|
||||
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
|
||||
Transcription
|
||||
</p>
|
||||
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
|
||||
{transcriptionText}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(transcriptionText)}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">content_copy</span>
|
||||
Copy text
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={responseBody}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export default function ProviderDetailPage() {
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
|
||||
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
|
||||
const isSearchProvider = providerId.endsWith("-search");
|
||||
|
||||
const providerStorageAlias = isCompatible ? providerId : providerAlias;
|
||||
const providerDisplayAlias = isCompatible ? providerNode?.prefix || providerId : providerAlias;
|
||||
@@ -285,9 +286,13 @@ export default function ProviderDetailPage() {
|
||||
if (res.ok) {
|
||||
await fetchConnections();
|
||||
setShowEditModal(false);
|
||||
return null;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return data.error?.message || data.error || t("failedSaveConnection");
|
||||
} catch (error) {
|
||||
console.log("Error updating connection:", error);
|
||||
return t("failedSaveConnectionRetry");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1060,21 +1065,43 @@ export default function ProviderDetailPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Models */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">{t("availableModels")}</h2>
|
||||
{renderModelsSection()}
|
||||
{/* Models — hidden for search providers (they don't have models) */}
|
||||
{!isSearchProvider && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">{t("availableModels")}</h2>
|
||||
{renderModelsSection()}
|
||||
|
||||
{/* Custom Models — available for ALL providers */}
|
||||
{!isCompatible && (
|
||||
<CustomModelsSection
|
||||
providerId={providerId}
|
||||
providerAlias={providerDisplayAlias}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
{/* Custom Models — available for non-compatible, non-search providers */}
|
||||
{!isCompatible && (
|
||||
<CustomModelsSection
|
||||
providerId={providerId}
|
||||
providerAlias={providerDisplayAlias}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Search provider info */}
|
||||
{isSearchProvider && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">{t("searchProvider") || "Search Provider"}</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("searchProviderDesc") ||
|
||||
"This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."}
|
||||
</p>
|
||||
{providerId === "perplexity-search" && (
|
||||
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<span className="material-symbols-outlined text-sm text-blue-400">link</span>
|
||||
<p className="text-xs text-blue-300">
|
||||
Uses the same API key as <strong>Perplexity</strong> (chat provider). If you already
|
||||
have Perplexity configured, no additional setup is needed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{providerId === "kiro" ? (
|
||||
@@ -2595,10 +2622,14 @@ function AddApiKeyModal({
|
||||
onClose,
|
||||
}) {
|
||||
const t = useTranslations("providers");
|
||||
const isBailian = provider === "bailian-coding-plan";
|
||||
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
apiKey: "",
|
||||
priority: 1,
|
||||
baseUrl: isBailian ? defaultBailianUrl : "",
|
||||
});
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
@@ -2629,6 +2660,16 @@ function AddApiKeyModal({
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
let validatedBailianBaseUrl = null;
|
||||
if (isBailian) {
|
||||
const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBailianUrl);
|
||||
if (checked.error) {
|
||||
setSaveError(checked.error);
|
||||
return;
|
||||
}
|
||||
validatedBailianBaseUrl = checked.value;
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
try {
|
||||
setValidating(true);
|
||||
@@ -2652,12 +2693,22 @@ function AddApiKeyModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const error = await onSave({
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
apiKey: formData.apiKey,
|
||||
priority: formData.priority,
|
||||
testStatus: "active",
|
||||
});
|
||||
providerSpecificData: undefined,
|
||||
};
|
||||
|
||||
// Include baseUrl in providerSpecificData for bailian-coding-plan
|
||||
if (isBailian) {
|
||||
payload.providerSpecificData = {
|
||||
baseUrl: validatedBailianBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const error = await onSave(payload);
|
||||
if (error) {
|
||||
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
|
||||
}
|
||||
@@ -2728,6 +2779,15 @@ function AddApiKeyModal({
|
||||
setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
{isBailian && (
|
||||
<Input
|
||||
label="Base URL"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
placeholder={defaultBailianUrl}
|
||||
hint="Optional: Custom base URL for bailian-coding-plan provider"
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
@@ -2755,6 +2815,19 @@ AddApiKeyModal.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) {
|
||||
const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return { value: null, error: "Base URL must use http or https" };
|
||||
}
|
||||
return { value, error: null };
|
||||
} catch {
|
||||
return { value: null, error: "Base URL must be a valid URL" };
|
||||
}
|
||||
}
|
||||
|
||||
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
const t = useTranslations("providers");
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -2762,22 +2835,29 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
priority: 1,
|
||||
apiKey: "",
|
||||
healthCheckInterval: 60,
|
||||
baseUrl: "",
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
|
||||
const [newExtraKey, setNewExtraKey] = useState("");
|
||||
|
||||
const isBailian = connection?.provider === "bailian-coding-plan";
|
||||
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
const existingBaseUrl = connection.providerSpecificData?.baseUrl;
|
||||
setFormData({
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
apiKey: "",
|
||||
healthCheckInterval: connection.healthCheckInterval ?? 60,
|
||||
baseUrl: existingBaseUrl || (isBailian ? defaultBailianUrl : ""),
|
||||
});
|
||||
// Load existing extra keys from providerSpecificData
|
||||
const existing = connection.providerSpecificData?.extraApiKeys;
|
||||
@@ -2785,8 +2865,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
setNewExtraKey("");
|
||||
setTestResult(null);
|
||||
setValidationResult(null);
|
||||
setSaveError(null);
|
||||
}
|
||||
}, [connection]);
|
||||
}, [connection, isBailian]);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!connection?.provider) return;
|
||||
@@ -2832,12 +2913,24 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const updates: any = {
|
||||
name: formData.name,
|
||||
priority: formData.priority,
|
||||
healthCheckInterval: formData.healthCheckInterval,
|
||||
};
|
||||
|
||||
let validatedBailianBaseUrl = null;
|
||||
if (isBailian) {
|
||||
const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBailianUrl);
|
||||
if (checked.error) {
|
||||
setSaveError(checked.error);
|
||||
return;
|
||||
}
|
||||
validatedBailianBaseUrl = checked.value;
|
||||
}
|
||||
|
||||
if (!isOAuth && formData.apiKey) {
|
||||
updates.apiKey = formData.apiKey;
|
||||
let isValid = validationResult === "success";
|
||||
@@ -2869,14 +2962,21 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
updates.rateLimitedUntil = null;
|
||||
}
|
||||
}
|
||||
// Persist extra API keys in providerSpecificData
|
||||
// Persist extra API keys and baseUrl in providerSpecificData
|
||||
if (!isOAuth) {
|
||||
updates.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
|
||||
};
|
||||
// Update baseUrl for bailian-coding-plan
|
||||
if (isBailian) {
|
||||
updates.providerSpecificData.baseUrl = validatedBailianBaseUrl;
|
||||
}
|
||||
}
|
||||
const error = await onSave(updates);
|
||||
if (error) {
|
||||
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
|
||||
}
|
||||
await onSave(updates);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -2957,9 +3057,24 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
{validationResult === "success" ? t("valid") : t("invalid")}
|
||||
</Badge>
|
||||
)}
|
||||
{saveError && (
|
||||
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isBailian && (
|
||||
<Input
|
||||
label="Base URL"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
placeholder={defaultBailianUrl}
|
||||
hint="Custom base URL for bailian-coding-plan provider"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* T07: Extra API Keys for round-robin rotation */}
|
||||
{!isOAuth && (
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const SearchForm = dynamic(() => import("./components/SearchForm"), {
|
||||
ssr: false,
|
||||
});
|
||||
const SearchHistory = dynamic(() => import("./components/SearchHistory"), {
|
||||
ssr: false,
|
||||
});
|
||||
const ResultsPanel = dynamic(() => import("./components/ResultsPanel"), {
|
||||
ssr: false,
|
||||
});
|
||||
const ProviderComparison = dynamic(() => import("./components/ProviderComparison"), { ssr: false });
|
||||
const RerankPanel = dynamic(() => import("./components/RerankPanel"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
import type { SearchFormData } from "./components/SearchForm";
|
||||
import type { CompareResult } from "./components/ProviderComparison";
|
||||
|
||||
interface SearchProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "active" | "no_credentials";
|
||||
cost_per_query: number;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
id: string;
|
||||
provider: string;
|
||||
query: string;
|
||||
results: SearchResult[];
|
||||
cached: boolean;
|
||||
usage: {
|
||||
queries_used: number;
|
||||
search_cost_usd: number;
|
||||
};
|
||||
metrics: {
|
||||
response_time_ms: number;
|
||||
upstream_latency_ms: number;
|
||||
total_results_available: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function SearchToolsClient() {
|
||||
const t = useTranslations("search");
|
||||
const [providers, setProviders] = useState<SearchProvider[]>([]);
|
||||
const [response, setResponse] = useState<SearchResponse | null>(null);
|
||||
const [rawJson, setRawJson] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [statusCode, setStatusCode] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [lastQuery, setLastQuery] = useState<SearchFormData | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [showCompare, setShowCompare] = useState(false);
|
||||
const [compareLoading, setCompareLoading] = useState(false);
|
||||
const [compareResults, setCompareResults] = useState<CompareResult[]>([]);
|
||||
const [initialCompareResult, setInitialCompareResult] = useState<CompareResult | null>(null);
|
||||
const [showRerank, setShowRerank] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/search/providers")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setProviders(data.providers || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (formData: SearchFormData) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setResponse(null);
|
||||
setRawJson("");
|
||||
setStatusCode(0);
|
||||
setShowCompare(false);
|
||||
setShowRerank(false);
|
||||
setCompareResults([]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const body: any = { ...formData };
|
||||
if (!body.provider) delete body.provider;
|
||||
|
||||
const res = await fetch("/api/v1/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
setDuration(Date.now() - start);
|
||||
setStatusCode(res.status);
|
||||
|
||||
const data = await res.json();
|
||||
setRawJson(JSON.stringify(data, null, 2));
|
||||
setLastQuery(formData);
|
||||
|
||||
if (res.ok) {
|
||||
setResponse(data);
|
||||
} else {
|
||||
setError(data.error?.message || data.error || `Error ${res.status}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setDuration(Date.now() - start);
|
||||
if (err.name === "AbortError") {
|
||||
setError("Request timed out (15s)");
|
||||
} else {
|
||||
setError(err.message || "Network error");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompare = async () => {
|
||||
if (!response || !lastQuery) return;
|
||||
|
||||
const usedProvider = response.provider;
|
||||
const otherProviders = providers
|
||||
.filter((p) => p.status === "active" && p.id !== usedProvider)
|
||||
.map((p) => p.id);
|
||||
|
||||
if (otherProviders.length === 0) return;
|
||||
|
||||
const initial: CompareResult = {
|
||||
provider: usedProvider,
|
||||
latency: response.metrics.response_time_ms,
|
||||
cost: response.usage.search_cost_usd,
|
||||
resultCount: response.results.length,
|
||||
responseSize: rawJson.length,
|
||||
urls: response.results.map((r) => r.url),
|
||||
};
|
||||
setInitialCompareResult(initial);
|
||||
setShowCompare(true);
|
||||
setCompareLoading(true);
|
||||
|
||||
const promises = otherProviders.map(async (providerId) => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch("/api/v1/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...lastQuery, provider: providerId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
provider: providerId,
|
||||
latency: elapsed,
|
||||
cost: 0,
|
||||
resultCount: 0,
|
||||
responseSize: 0,
|
||||
urls: [],
|
||||
error: data.error?.message || `Error ${res.status}`,
|
||||
} as CompareResult;
|
||||
}
|
||||
|
||||
const respJson = JSON.stringify(data);
|
||||
return {
|
||||
provider: providerId,
|
||||
latency: data.metrics?.response_time_ms || elapsed,
|
||||
cost: data.usage?.search_cost_usd || 0,
|
||||
resultCount: data.results?.length || 0,
|
||||
responseSize: respJson.length,
|
||||
urls: (data.results || []).map((r: any) => r.url),
|
||||
} as CompareResult;
|
||||
} catch (err: any) {
|
||||
return {
|
||||
provider: providerId,
|
||||
latency: Date.now() - start,
|
||||
cost: 0,
|
||||
resultCount: 0,
|
||||
responseSize: 0,
|
||||
urls: [],
|
||||
error: err.message,
|
||||
} as CompareResult;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
setCompareResults(
|
||||
results.map((r) =>
|
||||
r.status === "fulfilled"
|
||||
? r.value
|
||||
: {
|
||||
provider: "unknown",
|
||||
latency: 0,
|
||||
cost: 0,
|
||||
resultCount: 0,
|
||||
responseSize: 0,
|
||||
urls: [],
|
||||
error: "Failed",
|
||||
}
|
||||
)
|
||||
);
|
||||
setCompareLoading(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
|
||||
const handleHistoryReplay = (entry: any) => {
|
||||
handleSearch({
|
||||
query: entry.query,
|
||||
provider: entry.provider || "",
|
||||
search_type: entry.filters?.search_type || "web",
|
||||
max_results: entry.filters?.max_results || 5,
|
||||
...entry.filters,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-120px)]">
|
||||
<div className="w-[340px] flex-shrink-0 bg-bg-alt border-r border-border overflow-y-auto flex flex-col">
|
||||
<SearchForm
|
||||
onSearch={handleSearch}
|
||||
loading={loading}
|
||||
onCancel={handleCancel}
|
||||
providers={providers}
|
||||
/>
|
||||
<SearchHistory onReplay={handleHistoryReplay} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ResultsPanel
|
||||
response={response}
|
||||
rawJson={rawJson}
|
||||
loading={loading}
|
||||
error={error}
|
||||
statusCode={statusCode}
|
||||
duration={duration}
|
||||
/>
|
||||
|
||||
{response && (
|
||||
<div className="px-4 py-2 flex gap-2">
|
||||
<button
|
||||
className="flex-1 bg-surface border border-border rounded-lg p-2 text-center hover:border-accent/30 transition-colors flex items-center justify-center gap-2"
|
||||
onClick={handleCompare}
|
||||
disabled={compareLoading}
|
||||
>
|
||||
<span className="text-accent text-sm">⇵</span>
|
||||
<span className="text-xs text-text-muted">{t("compareProviders")}</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 bg-surface border border-border rounded-lg p-2 text-center hover:border-primary/30 transition-colors flex items-center justify-center gap-2"
|
||||
onClick={() => setShowRerank(!showRerank)}
|
||||
>
|
||||
<span className="text-primary text-sm">⇅</span>
|
||||
<span className="text-xs text-text-muted">{t("rerankResults")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCompare && initialCompareResult && (
|
||||
<div className="px-4 pb-3">
|
||||
<ProviderComparison
|
||||
initialProvider={response!.provider}
|
||||
initialResult={initialCompareResult}
|
||||
otherResults={compareResults}
|
||||
loading={compareLoading}
|
||||
onClose={() => setShowCompare(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRerank && response && (
|
||||
<div className="px-4 pb-3">
|
||||
<RerankPanel
|
||||
query={response.query}
|
||||
results={response.results}
|
||||
onClose={() => setShowRerank(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface CompareResult {
|
||||
provider: string;
|
||||
latency: number;
|
||||
cost: number;
|
||||
resultCount: number;
|
||||
responseSize: number;
|
||||
urls: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ProviderComparisonProps {
|
||||
initialProvider: string;
|
||||
initialResult: CompareResult;
|
||||
otherResults: CompareResult[];
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
export default function ProviderComparison({
|
||||
initialProvider,
|
||||
initialResult,
|
||||
otherResults,
|
||||
loading,
|
||||
onClose,
|
||||
}: ProviderComparisonProps) {
|
||||
const t = useTranslations("search");
|
||||
|
||||
const allResults = [initialResult, ...otherResults];
|
||||
const initialUrls = new Set(initialResult.urls);
|
||||
|
||||
const valid = allResults.filter((r) => !r.error);
|
||||
const latencies = valid.map((r) => r.latency);
|
||||
const costs = valid.map((r) => r.cost);
|
||||
const sizes = valid.map((r) => r.responseSize);
|
||||
const bestLatency = Math.min(...latencies);
|
||||
const worstLatency = Math.max(...latencies);
|
||||
const bestCost = Math.min(...costs);
|
||||
const worstCost = Math.max(...costs);
|
||||
const bestSize = Math.min(...sizes);
|
||||
const worstSize = Math.max(...sizes);
|
||||
|
||||
const getLatencyColor = (val: number) => {
|
||||
if (val === bestLatency) return "text-success font-medium";
|
||||
if (val === worstLatency) return "text-warning";
|
||||
return "text-text-main";
|
||||
};
|
||||
|
||||
const getCostColor = (val: number) => {
|
||||
if (val === bestCost) return "text-success font-medium";
|
||||
if (val === worstCost) return "text-warning";
|
||||
return "text-text-main";
|
||||
};
|
||||
|
||||
const getSizeColor = (val: number) => {
|
||||
if (val === bestSize) return "text-success font-medium";
|
||||
if (val === worstSize) return "text-warning";
|
||||
return "text-text-main";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-accent/20 rounded-lg overflow-hidden">
|
||||
<div className="flex justify-between items-center px-4 py-2.5 bg-accent/5 border-b border-accent/15">
|
||||
<span className="text-xs font-semibold text-accent flex items-center gap-1.5">
|
||||
⇕ {t("compareProviders")}
|
||||
</span>
|
||||
<button onClick={onClose} className="text-text-muted text-xs hover:text-text-main">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 overflow-x-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="material-symbols-outlined text-[20px] text-accent animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
<span className="text-xs text-text-muted ml-2">{t("compareProviders")}...</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-2 text-text-muted font-semibold" />
|
||||
{allResults.map((r) => (
|
||||
<th
|
||||
key={r.provider}
|
||||
className={`text-center p-2 font-semibold ${
|
||||
r.provider === initialProvider ? "text-primary" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{r.provider.replace("-search", "")}
|
||||
{r.provider === initialProvider && " ✓"}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">{t("latency")}</td>
|
||||
{allResults.map((r) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : getLatencyColor(r.latency)}`}
|
||||
>
|
||||
{r.error ? "Error" : `${r.latency}ms`}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">{t("cost")}</td>
|
||||
{allResults.map((r) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : getCostColor(r.cost)}`}
|
||||
>
|
||||
{r.error ? "Error" : `$${r.cost.toFixed(4)}`}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">{t("results")}</td>
|
||||
{allResults.map((r) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : "text-text-main"}`}
|
||||
>
|
||||
{r.error ? "Error" : r.resultCount}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">Size</td>
|
||||
{allResults.map((r) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : getSizeColor(r.responseSize)}`}
|
||||
>
|
||||
{r.error ? "Error" : formatBytes(r.responseSize)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2 text-text-muted">{t("urlOverlap")}</td>
|
||||
{allResults.map((r) => (
|
||||
<td key={r.provider} className="text-center p-2 text-text-main">
|
||||
{r.provider === initialProvider
|
||||
? "—"
|
||||
: r.error
|
||||
? "Error"
|
||||
: `${r.urls.filter((u) => initialUrls.has(u)).length}/${r.resultCount}`}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Select } from "@/shared/components";
|
||||
|
||||
interface RerankResult {
|
||||
index: number;
|
||||
originalIndex: number;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
interface RerankPanelProps {
|
||||
query: string;
|
||||
results: { title: string; snippet: string; url: string }[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RerankPanel({ query, results, onClose }: RerankPanelProps) {
|
||||
const t = useTranslations("search");
|
||||
const [models, setModels] = useState<{ value: string; label: string }[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [reranked, setReranked] = useState<RerankResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/v1/models")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const rerankModels = (data?.data || [])
|
||||
.filter((m: any) => m.id.toLowerCase().includes("rerank"))
|
||||
.map((m: any) => ({ value: m.id, label: m.id }));
|
||||
setModels(rerankModels);
|
||||
if (rerankModels.length > 0) setSelectedModel(rerankModels[0].value);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleRerank = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/v1/rerank", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
query,
|
||||
documents: results.map((r) => r.snippet),
|
||||
top_n: results.length,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error?.message || data.error || `Error ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rerankedResults: RerankResult[] = (data.results || []).map(
|
||||
(r: any, newIndex: number) => {
|
||||
const origIndex = r.index;
|
||||
return {
|
||||
index: newIndex,
|
||||
originalIndex: origIndex,
|
||||
title: results[origIndex]?.title || "",
|
||||
snippet: results[origIndex]?.snippet || "",
|
||||
score: r.relevance_score,
|
||||
delta: origIndex - newIndex,
|
||||
};
|
||||
}
|
||||
);
|
||||
setReranked(rerankedResults);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Rerank failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDeltaDisplay = (delta: number) => {
|
||||
if (delta > 0) return <span className="text-success">↑{delta}</span>;
|
||||
if (delta < 0) return <span className="text-error">↓{Math.abs(delta)}</span>;
|
||||
return <span className="text-text-muted">=</span>;
|
||||
};
|
||||
|
||||
const noModels = models.length === 0;
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border">
|
||||
<span className="text-xs font-semibold text-text-main flex items-center gap-1.5">
|
||||
⇅ {t("rerankResults")}
|
||||
</span>
|
||||
<button onClick={onClose} className="text-text-muted text-xs hover:text-text-main">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{noModels ? (
|
||||
<p className="text-xs text-text-muted">{t("noRerankModels")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2 items-end mb-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
{t("rerankModel")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedModel}
|
||||
onChange={(e: any) => setSelectedModel(e.target.value)}
|
||||
options={models}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleRerank} disabled={loading || !selectedModel}>
|
||||
{loading ? "Reranking..." : t("rerank")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-error mb-2">{error}</p>}
|
||||
|
||||
{reranked.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{reranked.map((r) => (
|
||||
<div key={r.index} className="flex items-start gap-3 p-2 bg-bg-alt rounded-lg">
|
||||
<div className="flex flex-col items-center min-w-[32px]">
|
||||
<span className="text-xs font-medium text-text-main">#{r.index + 1}</span>
|
||||
<span className="text-[10px]">{getDeltaDisplay(r.delta)}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-text-main">{r.title}</div>
|
||||
<div className="text-[10px] text-text-muted mt-0.5 line-clamp-2">
|
||||
{r.snippet}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-accent whitespace-nowrap">
|
||||
{r.score.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Badge } from "@/shared/components";
|
||||
|
||||
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
|
||||
interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
id: string;
|
||||
provider: string;
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
answer: string | null;
|
||||
cached: boolean;
|
||||
usage: {
|
||||
queries_used: number;
|
||||
search_cost_usd: number;
|
||||
};
|
||||
metrics: {
|
||||
response_time_ms: number;
|
||||
upstream_latency_ms: number;
|
||||
total_results_available: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResultsPanelProps {
|
||||
response: SearchResponse | null;
|
||||
rawJson: string;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
statusCode: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
export default function ResultsPanel({
|
||||
response,
|
||||
rawJson,
|
||||
loading,
|
||||
error,
|
||||
statusCode,
|
||||
duration,
|
||||
}: ResultsPanelProps) {
|
||||
const t = useTranslations("search");
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 0.9) return "text-success";
|
||||
if (score >= 0.7) return "text-warning";
|
||||
return "text-error";
|
||||
};
|
||||
|
||||
const getScoreBg = (score: number) => {
|
||||
if (score >= 0.9) return "bg-green-500/10";
|
||||
if (score >= 0.7) return "bg-yellow-500/10";
|
||||
return "bg-red-500/10";
|
||||
};
|
||||
|
||||
const editorTheme =
|
||||
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
|
||||
? "vs-dark"
|
||||
: "light";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("searchResults")}
|
||||
</span>
|
||||
{statusCode > 0 && (
|
||||
<>
|
||||
<Badge variant={statusCode < 400 ? "success" : "error"} size="sm">
|
||||
{statusCode}
|
||||
</Badge>
|
||||
<span className="text-xs text-text-muted">{duration}ms</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{response && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className={`text-xs px-3 py-1 rounded-md ${
|
||||
!showJson
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted"
|
||||
}`}
|
||||
onClick={() => setShowJson(false)}
|
||||
>
|
||||
{t("formatted")}
|
||||
</button>
|
||||
<button
|
||||
className={`text-xs px-3 py-1 rounded-md ${
|
||||
showJson
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted"
|
||||
}`}
|
||||
onClick={() => setShowJson(true)}
|
||||
>
|
||||
{t("rawJson")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<span className="material-symbols-outlined text-[24px] text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="p-4">
|
||||
<div className="text-error text-sm">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{response && !showJson && !loading && (
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Meta bar */}
|
||||
<div className="flex justify-between items-center p-2 bg-bg-alt rounded-lg">
|
||||
<div className="flex items-center gap-3 text-xs text-text-muted">
|
||||
<span>
|
||||
{response.results.length} {t("results").toLowerCase()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
{response.provider}
|
||||
</span>
|
||||
<span>{response.metrics?.response_time_ms}ms</span>
|
||||
<span>${response.usage?.search_cost_usd?.toFixed(4)}</span>
|
||||
<span>{formatBytes(rawJson.length)}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs flex items-center gap-1 ${
|
||||
response.cached ? "text-success" : "text-warning"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
response.cached ? "bg-success" : "bg-warning"
|
||||
}`}
|
||||
/>
|
||||
{response.cached ? t("cacheHit") : t("cacheMiss")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
{response.results.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-l-[3px] border-l-primary p-3 bg-surface rounded-r-lg border border-border"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{i + 1}. {r.title}
|
||||
</span>
|
||||
{r.score != null && (
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded-md ml-2 whitespace-nowrap ${getScoreBg(r.score)} ${getScoreColor(r.score)}`}
|
||||
>
|
||||
{r.score.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={r.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent text-[11px] block mt-0.5"
|
||||
>
|
||||
{r.url}
|
||||
</a>
|
||||
<p className="text-xs text-text-muted mt-1 leading-relaxed">{r.snippet}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{response && showJson && !loading && (
|
||||
<div className="h-64">
|
||||
<Editor
|
||||
height="100%"
|
||||
language="json"
|
||||
value={rawJson}
|
||||
theme={editorTheme}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
automaticLayout: true,
|
||||
wordWrap: "on",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !response && (
|
||||
<div className="flex items-center justify-center py-20 text-text-muted text-sm">
|
||||
{t("emptyState")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Select } from "@/shared/components";
|
||||
|
||||
interface SearchProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "active" | "no_credentials";
|
||||
cost_per_query: number;
|
||||
}
|
||||
|
||||
export interface SearchFormData {
|
||||
query: string;
|
||||
provider: string;
|
||||
search_type: string;
|
||||
max_results: number;
|
||||
country?: string;
|
||||
language?: string;
|
||||
time_range?: string;
|
||||
include_domains?: string[];
|
||||
exclude_domains?: string[];
|
||||
safe_search?: string;
|
||||
}
|
||||
|
||||
interface SearchFormProps {
|
||||
onSearch: (data: SearchFormData) => void;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
providers: SearchProvider[];
|
||||
}
|
||||
|
||||
export default function SearchForm({ onSearch, loading, onCancel, providers }: SearchFormProps) {
|
||||
const t = useTranslations("search");
|
||||
const [query, setQuery] = useState("");
|
||||
const [provider, setProvider] = useState("auto");
|
||||
const [searchType, setSearchType] = useState("web");
|
||||
const [maxResults, setMaxResults] = useState(5);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [country, setCountry] = useState("");
|
||||
const [language, setLanguage] = useState("");
|
||||
const [timeRange, setTimeRange] = useState("");
|
||||
const [includeDomains, setIncludeDomains] = useState<string[]>([]);
|
||||
const [excludeDomains, setExcludeDomains] = useState<string[]>([]);
|
||||
const [safeSearch, setSafeSearch] = useState("moderate");
|
||||
const [domainInput, setDomainInput] = useState("");
|
||||
const [excludeDomainInput, setExcludeDomainInput] = useState("");
|
||||
|
||||
const activeProviders = providers.filter((p) => p.status === "active");
|
||||
const noProviders = activeProviders.length === 0;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: SearchFormData = {
|
||||
query,
|
||||
provider: provider === "auto" ? "" : provider,
|
||||
search_type: searchType,
|
||||
max_results: maxResults,
|
||||
};
|
||||
if (country) data.country = country;
|
||||
if (language) data.language = language;
|
||||
if (timeRange) data.time_range = timeRange;
|
||||
if (includeDomains.length > 0) data.include_domains = includeDomains;
|
||||
if (excludeDomains.length > 0) data.exclude_domains = excludeDomains;
|
||||
if (safeSearch !== "moderate") data.safe_search = safeSearch;
|
||||
onSearch(data);
|
||||
};
|
||||
|
||||
const addDomain = (type: "include" | "exclude") => {
|
||||
const input = type === "include" ? domainInput : excludeDomainInput;
|
||||
const setter = type === "include" ? setIncludeDomains : setExcludeDomains;
|
||||
const list = type === "include" ? includeDomains : excludeDomains;
|
||||
if (input.trim() && !list.includes(input.trim())) {
|
||||
setter([...list, input.trim()]);
|
||||
}
|
||||
type === "include" ? setDomainInput("") : setExcludeDomainInput("");
|
||||
};
|
||||
|
||||
const removeDomain = (domain: string, type: "include" | "exclude") => {
|
||||
const setter = type === "include" ? setIncludeDomains : setExcludeDomains;
|
||||
const list = type === "include" ? includeDomains : excludeDomains;
|
||||
setter(list.filter((d) => d !== domain));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Query */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<label className="block text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
{t("searchQuery")}
|
||||
</label>
|
||||
<textarea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Enter search query..."
|
||||
className="w-full bg-surface border border-border rounded-lg p-2.5 text-sm text-text-main resize-none h-16 focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!noProviders && query.trim()) handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider + Type + Max Results */}
|
||||
<div className="p-4 border-b border-border space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
{t("provider")}
|
||||
</label>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={(e: any) => setProvider(e.target.value)}
|
||||
options={[
|
||||
{ value: "auto", label: "auto (cheapest)" },
|
||||
...activeProviders.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
})),
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
{t("searchType")}
|
||||
</label>
|
||||
<Select
|
||||
value={searchType}
|
||||
onChange={(e: any) => setSearchType(e.target.value)}
|
||||
options={[
|
||||
{ value: "web", label: "web" },
|
||||
{ value: "news", label: "news" },
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
{t("maxResults")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxResults}
|
||||
onChange={(e) => setMaxResults(parseInt(e.target.value) || 5)}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-full bg-surface border border-border rounded-lg px-2.5 py-1.5 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters (collapsible) */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<button
|
||||
className="flex justify-between items-center w-full"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("filters")}
|
||||
</span>
|
||||
<span className="text-text-muted text-xs">{showFilters ? "▼" : "▶"}</span>
|
||||
</button>
|
||||
{showFilters && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] text-text-muted mb-1">{t("country")}</label>
|
||||
<input
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
placeholder="any"
|
||||
className="w-full bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] text-text-muted mb-1">{t("language")}</label>
|
||||
<input
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
placeholder="any"
|
||||
className="w-full bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-text-muted mb-1">{t("timeRange")}</label>
|
||||
<Select
|
||||
value={timeRange}
|
||||
onChange={(e: any) => setTimeRange(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: "any" },
|
||||
{ value: "day", label: "Past day" },
|
||||
{ value: "week", label: "Past week" },
|
||||
{ value: "month", label: "Past month" },
|
||||
{ value: "year", label: "Past year" },
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-text-muted mb-1">
|
||||
{t("includeDomains")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={domainInput}
|
||||
onChange={(e) => setDomainInput(e.target.value)}
|
||||
placeholder="example.com"
|
||||
className="flex-1 bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
|
||||
onKeyDown={(e) => e.key === "Enter" && addDomain("include")}
|
||||
/>
|
||||
<button onClick={() => addDomain("include")} className="text-primary text-lg px-1">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{includeDomains.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{includeDomains.map((d) => (
|
||||
<span
|
||||
key={d}
|
||||
className="text-[10px] bg-primary/10 text-primary px-2 py-0.5 rounded-full flex items-center gap-1"
|
||||
>
|
||||
{d}
|
||||
<button
|
||||
onClick={() => removeDomain(d, "include")}
|
||||
className="text-primary/60"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-text-muted mb-1">
|
||||
{t("excludeDomains")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={excludeDomainInput}
|
||||
onChange={(e) => setExcludeDomainInput(e.target.value)}
|
||||
placeholder="example.com"
|
||||
className="flex-1 bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
|
||||
onKeyDown={(e) => e.key === "Enter" && addDomain("exclude")}
|
||||
/>
|
||||
<button onClick={() => addDomain("exclude")} className="text-primary text-lg px-1">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{excludeDomains.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{excludeDomains.map((d) => (
|
||||
<span
|
||||
key={d}
|
||||
className="text-[10px] bg-error/10 text-error px-2 py-0.5 rounded-full flex items-center gap-1"
|
||||
>
|
||||
{d}
|
||||
<button onClick={() => removeDomain(d, "exclude")} className="text-error/60">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-text-muted mb-1">{t("safeSearch")}</label>
|
||||
<Select
|
||||
value={safeSearch}
|
||||
onChange={(e: any) => setSafeSearch(e.target.value)}
|
||||
options={[
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "moderate", label: "Moderate" },
|
||||
{ value: "strict", label: "Strict" },
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search button */}
|
||||
<div className="p-4 border-b border-border">
|
||||
{loading ? (
|
||||
<Button variant="danger" onClick={onCancel} className="w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={noProviders || !query.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
)}
|
||||
{noProviders && <p className="text-xs text-text-muted mt-2">{t("noSearchProviders")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface HistoryEntry {
|
||||
query: string;
|
||||
provider: string;
|
||||
timestamp: string;
|
||||
filters: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SearchHistoryProps {
|
||||
onReplay: (entry: HistoryEntry) => void;
|
||||
}
|
||||
|
||||
function timeAgo(timestamp: string): string {
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
const diff = Date.now() - new Date(timestamp).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return rtf.format(0, "minute");
|
||||
if (minutes < 60) return rtf.format(-minutes, "minute");
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return rtf.format(-hours, "hour");
|
||||
return rtf.format(-Math.floor(hours / 24), "day");
|
||||
} catch {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
export default function SearchHistory({ onReplay }: SearchHistoryProps) {
|
||||
const t = useTranslations("search");
|
||||
const [entries, setEntries] = useState<HistoryEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/search/stats")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setEntries(data.recent_searches || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4 flex-1">
|
||||
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("searchHistory")}
|
||||
</span>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{entries.map((entry, i) => (
|
||||
<button
|
||||
key={`${entry.timestamp}:${entry.provider}:${entry.query}`}
|
||||
onClick={() => onReplay(entry)}
|
||||
className="w-full text-left p-2 bg-surface border border-border rounded-lg hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<div className="text-xs text-text-main truncate">{entry.query}</div>
|
||||
<div className="flex justify-between mt-0.5">
|
||||
<span className="text-[10px] text-text-muted">{entry.provider}</span>
|
||||
<span className="text-[10px] text-text-muted">{timeAgo(entry.timestamp)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import SearchToolsClient from "./SearchToolsClient";
|
||||
|
||||
export default function SearchToolsPage() {
|
||||
return <SearchToolsClient />;
|
||||
}
|
||||
@@ -83,7 +83,11 @@ export default function BudgetTab() {
|
||||
if (data.monthlyLimitUsd)
|
||||
setForm((f) => ({ ...f, monthlyLimitUsd: String(data.monthlyLimitUsd) }));
|
||||
if (data.warningThreshold)
|
||||
setForm((f) => ({ ...f, warningThreshold: String(data.warningThreshold) }));
|
||||
// stored as fraction (0–1), display as percentage (0–100)
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
warningThreshold: String(Math.round(data.warningThreshold * 100)),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
@@ -104,7 +108,8 @@ export default function BudgetTab() {
|
||||
apiKeyId: selectedKey,
|
||||
dailyLimitUsd: form.dailyLimitUsd ? parseFloat(form.dailyLimitUsd) : null,
|
||||
monthlyLimitUsd: form.monthlyLimitUsd ? parseFloat(form.monthlyLimitUsd) : null,
|
||||
warningThreshold: parseInt(form.warningThreshold) || 80,
|
||||
// schema expects a fraction (0–1); UI shows percentage (0–100)
|
||||
warningThreshold: (parseInt(form.warningThreshold) || 80) / 100,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
|
||||
@@ -92,11 +92,15 @@ export function parseQuotaData(provider, data) {
|
||||
case "github":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
if (quota?.unlimited && (!quota?.total || quota.total <= 0)) {
|
||||
return;
|
||||
}
|
||||
normalizedQuotas.push({
|
||||
name,
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
remainingPercentage: safePercentage(quota.remainingPercentage),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -214,6 +218,14 @@ export function normalizePlanTier(plan) {
|
||||
|
||||
const upper = raw.toUpperCase();
|
||||
|
||||
if (
|
||||
upper.includes("PRO+") ||
|
||||
upper.includes("PRO PLUS") ||
|
||||
upper.includes("PROPLUS")
|
||||
) {
|
||||
return { key: "plus", label: "Pro+", variant: "secondary", rank: 4, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) {
|
||||
return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw };
|
||||
}
|
||||
@@ -227,6 +239,10 @@ export function normalizePlanTier(plan) {
|
||||
return { key: "business", label: "Business", variant: "warning", rank: 5, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("STUDENT")) {
|
||||
return { key: "pro", label: "Student", variant: "primary", rank: 3, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("ULTRA")) {
|
||||
return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw };
|
||||
}
|
||||
@@ -241,7 +257,6 @@ export function normalizePlanTier(plan) {
|
||||
|
||||
if (
|
||||
upper.includes("FREE") ||
|
||||
upper.includes("INDIVIDUAL") ||
|
||||
upper.includes("BASIC") ||
|
||||
upper.includes("TRIAL") ||
|
||||
upper.includes("LEGACY")
|
||||
|
||||
@@ -28,8 +28,16 @@ type ProviderModelsConfigEntry = {
|
||||
parseResponse: (data: any) => any;
|
||||
};
|
||||
|
||||
const KIMI_CODING_MODELS_CONFIG: ProviderModelsConfigEntry = {
|
||||
url: "https://api.kimi.com/coding/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "x-api-key",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
};
|
||||
|
||||
// Providers that return hardcoded models (no remote /models API)
|
||||
const STATIC_MODEL_PROVIDERS = {
|
||||
const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: string }>> = {
|
||||
deepgram: () => [
|
||||
{ id: "nova-3", name: "Nova 3 (Transcription)" },
|
||||
{ id: "nova-2", name: "Nova 2 (Transcription)" },
|
||||
@@ -53,8 +61,31 @@ const STATIC_MODEL_PROVIDERS = {
|
||||
{ id: "sonar-reasoning-pro", name: "Sonar Reasoning Pro (Advanced CoT + Search)" },
|
||||
{ id: "sonar-deep-research", name: "Sonar Deep Research (Expert Analysis)" },
|
||||
],
|
||||
"bailian-coding-plan": () => [
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
|
||||
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max (2026-01-23)" },
|
||||
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get static models for a provider (if available).
|
||||
* Exported for testing purposes.
|
||||
* @param provider - Provider ID
|
||||
* @returns Array of models or undefined if provider doesn't use static models
|
||||
*/
|
||||
export function getStaticModelsForProvider(
|
||||
provider: string
|
||||
): Array<{ id: string; name: string }> | undefined {
|
||||
const staticModelsFn = STATIC_MODEL_PROVIDERS[provider];
|
||||
return staticModelsFn ? staticModelsFn() : undefined;
|
||||
}
|
||||
|
||||
// Provider models endpoints configuration
|
||||
const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
|
||||
claude: {
|
||||
@@ -134,11 +165,10 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
|
||||
parseResponse: (data) => data.data || [],
|
||||
},
|
||||
"kimi-coding": {
|
||||
url: "https://api.kimi.com/coding/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "x-api-key",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
...KIMI_CODING_MODELS_CONFIG,
|
||||
},
|
||||
"kimi-coding-apikey": {
|
||||
...KIMI_CODING_MODELS_CONFIG,
|
||||
},
|
||||
anthropic: {
|
||||
url: "https://api.anthropic.com/v1/models",
|
||||
|
||||
@@ -46,8 +46,16 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } =
|
||||
validation.data;
|
||||
const {
|
||||
provider,
|
||||
apiKey,
|
||||
name,
|
||||
priority,
|
||||
globalPriority,
|
||||
defaultModel,
|
||||
testStatus,
|
||||
providerSpecificData: incomingPsd,
|
||||
} = validation.data;
|
||||
|
||||
// Business validation
|
||||
const isValidProvider =
|
||||
@@ -59,7 +67,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
let providerSpecificData: Record<string, any> | null = null;
|
||||
let providerSpecificData = incomingPsd || null;
|
||||
const allowMultipleCompatibleConnections =
|
||||
process.env.ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE === "true";
|
||||
|
||||
@@ -78,6 +86,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
providerSpecificData = {
|
||||
...(providerSpecificData || {}),
|
||||
prefix: node.prefix,
|
||||
apiType: node.apiType,
|
||||
baseUrl: node.baseUrl,
|
||||
@@ -100,6 +109,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
providerSpecificData = {
|
||||
...(providerSpecificData || {}),
|
||||
prefix: node.prefix,
|
||||
baseUrl: node.baseUrl,
|
||||
nodeName: node.name,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
SEARCH_PROVIDERS,
|
||||
SEARCH_CREDENTIAL_FALLBACKS,
|
||||
} from "@omniroute/open-sse/config/searchRegistry.ts";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const providers = Object.values(SEARCH_PROVIDERS).map((p) => {
|
||||
let status: "active" | "no_credentials" = "no_credentials";
|
||||
try {
|
||||
const cred = db
|
||||
.prepare(
|
||||
"SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1"
|
||||
)
|
||||
.get(p.id);
|
||||
// Use canonical fallback mapping (e.g. perplexity-search → perplexity)
|
||||
const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[p.id];
|
||||
const fallbackCred =
|
||||
!cred && fallbackId
|
||||
? db
|
||||
.prepare(
|
||||
"SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1"
|
||||
)
|
||||
.get(fallbackId)
|
||||
: null;
|
||||
if (cred || fallbackCred) status = "active";
|
||||
} catch {
|
||||
// DB error — report as no_credentials
|
||||
}
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
status,
|
||||
cost_per_query: p.costPerQuery,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ providers });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to list providers" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCacheStats } from "@omniroute/open-sse/services/searchCache.ts";
|
||||
import { SEARCH_PROVIDERS } from "@omniroute/open-sse/config/searchRegistry.ts";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const cache = getCacheStats();
|
||||
|
||||
// Provider aggregate stats — cost is per-query from registry
|
||||
const providerStats = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT provider, COUNT(*) as requests,
|
||||
CAST(AVG(duration) AS INTEGER) as avg_latency_ms
|
||||
FROM call_logs
|
||||
WHERE request_type = 'search'
|
||||
GROUP BY provider
|
||||
`
|
||||
)
|
||||
.all();
|
||||
|
||||
const providers: Record<
|
||||
string,
|
||||
{ requests: number; avg_latency_ms: number; total_cost: number }
|
||||
> = {};
|
||||
for (const row of providerStats as any[]) {
|
||||
const costPerQuery = SEARCH_PROVIDERS[row.provider]?.costPerQuery || 0;
|
||||
providers[row.provider] = {
|
||||
requests: row.requests,
|
||||
avg_latency_ms: row.avg_latency_ms,
|
||||
total_cost: parseFloat((row.requests * costPerQuery).toFixed(4)),
|
||||
};
|
||||
}
|
||||
|
||||
// Recent searches
|
||||
const recentRows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT request_body, provider, timestamp
|
||||
FROM call_logs
|
||||
WHERE request_type = 'search'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 10
|
||||
`
|
||||
)
|
||||
.all();
|
||||
|
||||
const recent_searches = (recentRows as any[]).map((row) => {
|
||||
let query = "";
|
||||
let filters = {};
|
||||
try {
|
||||
const body = JSON.parse(row.request_body);
|
||||
query = body.query || "";
|
||||
const { query: _q, provider: _p, ...rest } = body;
|
||||
filters = rest;
|
||||
} catch {
|
||||
// Unparseable request_body
|
||||
}
|
||||
return {
|
||||
query,
|
||||
provider: row.provider,
|
||||
timestamp: row.timestamp,
|
||||
filters,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ cache, providers, recent_searches });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to get stats" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import { parseRerankModel, getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { v1RerankSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getProviderNodes } from "@/lib/localDb";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -26,11 +27,29 @@ export async function OPTIONS() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dynamic rerank provider from a local provider_node.
|
||||
* Local OpenAI-compatible backends (oMLX, vLLM, etc.) expose /v1/rerank
|
||||
* under the same base URL as chat.
|
||||
*/
|
||||
function buildDynamicRerankProvider(node: any) {
|
||||
// Strip trailing /v1 if present — we'll add /rerank
|
||||
let base = node.baseUrl || "";
|
||||
if (base.endsWith("/v1")) base = base.slice(0, -3);
|
||||
return {
|
||||
id: node.prefix,
|
||||
baseUrl: `${base}/v1/rerank`,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
providerId: node.id, // full provider connection ID for credential lookup
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/rerank - Cohere-compatible rerank endpoint
|
||||
*
|
||||
* Reranks a list of documents against a query using the specified model.
|
||||
* Supports providers: Cohere, Together AI, NVIDIA, Fireworks AI.
|
||||
* Supports cloud providers (Cohere, Together, NVIDIA, Fireworks)
|
||||
* and local provider_nodes (oMLX, vLLM, etc.) via dynamic routing.
|
||||
*/
|
||||
export async function POST(request) {
|
||||
// Optional API key validation
|
||||
@@ -58,29 +77,113 @@ export async function POST(request) {
|
||||
const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
const { provider } = parseRerankModel(body.model);
|
||||
if (!provider) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`Invalid rerank model: ${body.model}. Use format: provider/model`
|
||||
);
|
||||
// Load local provider_nodes for rerank routing (localhost only)
|
||||
let localProviders: ReturnType<typeof buildDynamicRerankProvider>[] = [];
|
||||
try {
|
||||
const nodes = await getProviderNodes();
|
||||
localProviders = (Array.isArray(nodes) ? nodes : [])
|
||||
.filter((n: any) => {
|
||||
try {
|
||||
const hostname = new URL(n.baseUrl).hostname;
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "[::1]"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((n) => {
|
||||
try {
|
||||
return buildDynamicRerankProvider(n);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((p): p is NonNullable<typeof p> => p !== null);
|
||||
} catch {
|
||||
// Non-critical — continue with cloud providers only
|
||||
}
|
||||
|
||||
const credentials = await getProviderCredentials(provider);
|
||||
if (!credentials) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||
// Try cloud registry first
|
||||
const { provider, model: modelId } = parseRerankModel(body.model);
|
||||
|
||||
if (provider) {
|
||||
// Cloud provider matched
|
||||
const credentials = await getProviderCredentials(provider);
|
||||
if (!credentials) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||
}
|
||||
|
||||
const response = await handleRerank({
|
||||
model: body.model,
|
||||
query: body.query,
|
||||
documents: body.documents,
|
||||
top_n: body.top_n,
|
||||
return_documents: body.return_documents,
|
||||
credentials,
|
||||
});
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const response = await handleRerank({
|
||||
model: body.model,
|
||||
query: body.query,
|
||||
documents: body.documents,
|
||||
top_n: body.top_n,
|
||||
return_documents: body.return_documents,
|
||||
credentials,
|
||||
});
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
// Try local provider_nodes (model format: prefix/model-name)
|
||||
const parts = body.model.split("/");
|
||||
if (parts.length >= 2) {
|
||||
const prefix = parts[0];
|
||||
const localModel = parts.slice(1).join("/");
|
||||
const localProvider = localProviders.find((p) => p.id === prefix);
|
||||
|
||||
if (localProvider) {
|
||||
const credentials = await getProviderCredentials(localProvider.providerId);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`No credentials for local provider: ${prefix}`
|
||||
);
|
||||
}
|
||||
|
||||
const token = credentials?.apiKey || credentials?.accessToken;
|
||||
try {
|
||||
const res = await fetch(localProvider.baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: localModel,
|
||||
query: body.query,
|
||||
documents: body.documents,
|
||||
top_n: body.top_n || body.documents.length,
|
||||
return_documents: body.return_documents !== false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
return errorResponse(
|
||||
res.status,
|
||||
errData.message || errData.detail || `Provider returned HTTP ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return Response.json(data, {
|
||||
headers: { "Access-Control-Allow-Origin": CORS_ORIGIN },
|
||||
});
|
||||
} catch (err: any) {
|
||||
return errorResponse(500, `Rerank request failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return response;
|
||||
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`Invalid rerank model: ${body.model}. Use format: provider/model`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
async function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
await initTranslators();
|
||||
initialized = true;
|
||||
console.log("[SSE] Translators initialized for /v1/responses/*");
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/responses/:path* - OpenAI Responses subpaths
|
||||
* Reuses the shared chat handler so native Codex passthrough can keep
|
||||
* arbitrary Responses suffixes all the way to the upstream provider.
|
||||
*/
|
||||
export async function POST(request) {
|
||||
await ensureInitialized();
|
||||
return await handleChat(request);
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
async function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
await initTranslators();
|
||||
initialized = true;
|
||||
console.log("[SSE] Translators initialized for /v1/responses");
|
||||
}
|
||||
}
|
||||
// NOTE: We do NOT call initTranslators() here — the translator registry is
|
||||
// bootstrapped at module level inside open-sse/translator/index.ts when it
|
||||
// is first imported. Calling it again from a Next.js Route Handler caused a
|
||||
// "the worker has exited" uncaughtException crash on Codex CLI requests (#450)
|
||||
// because the dynamic import runs in a Next.js server worker context where
|
||||
// certain Node APIs used by the translator bootstrap are not available.
|
||||
// The translators are always initialized via the open-sse side (chatCore),
|
||||
// so /v1/responses just delegates to handleChat which handles everything.
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
@@ -24,9 +22,8 @@ export async function OPTIONS() {
|
||||
|
||||
/**
|
||||
* POST /v1/responses - OpenAI Responses API format
|
||||
* Now handled by translator pattern (openai-responses format auto-detected)
|
||||
* Handled by the unified chat handler (openai-responses format auto-detected).
|
||||
*/
|
||||
export async function POST(request) {
|
||||
await ensureInitialized();
|
||||
return await handleChat(request);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleSearch } from "@omniroute/open-sse/handlers/search.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getAllSearchProviders,
|
||||
getSearchProvider,
|
||||
selectProvider,
|
||||
SEARCH_PROVIDERS,
|
||||
SEARCH_CREDENTIAL_FALLBACKS,
|
||||
} from "@omniroute/open-sse/config/searchRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { v1SearchSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { recordCost } from "@/domain/costRules";
|
||||
import {
|
||||
computeCacheKey,
|
||||
getOrCoalesce,
|
||||
SEARCH_CACHE_DEFAULT_TTL_MS,
|
||||
} from "@omniroute/open-sse/services/searchCache.ts";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/search — list available search providers
|
||||
*/
|
||||
export async function GET() {
|
||||
const providers = getAllSearchProviders();
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const data = providers.map((p) => ({
|
||||
id: p.id,
|
||||
object: "search_provider",
|
||||
created: timestamp,
|
||||
name: p.name,
|
||||
search_types: p.searchTypes,
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({ object: "list", data }), {
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: resolve credentials with fallback (e.g., perplexity-search → perplexity)
|
||||
async function resolveSearchCredentials(providerId: string) {
|
||||
const creds = await getProviderCredentials(providerId).catch(() => null);
|
||||
if (creds) return creds;
|
||||
const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[providerId];
|
||||
if (fallbackId) return getProviderCredentials(fallbackId).catch(() => null);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper: build domain filter array from filters object
|
||||
function buildDomainFilter(filters?: {
|
||||
include_domains?: string[];
|
||||
exclude_domains?: string[];
|
||||
}): string[] | undefined {
|
||||
if (!filters) return undefined;
|
||||
const parts: string[] = [];
|
||||
if (filters.include_domains?.length) parts.push(...filters.include_domains);
|
||||
if (filters.exclude_domains?.length) parts.push(...filters.exclude_domains.map((d) => `-${d}`));
|
||||
return parts.length > 0 ? parts : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/search — execute a web search
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
log.warn("SEARCH", "Invalid JSON body");
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
const validation = validateBody(v1SearchSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
|
||||
}
|
||||
const body = validation.data;
|
||||
|
||||
// Optional API key validation
|
||||
if (process.env.REQUIRE_API_KEY === "true") {
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) {
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||
}
|
||||
const valid = await isValidApiKey(apiKey);
|
||||
if (!valid) {
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce API key policies — use "search" as model identifier for consistent policy config
|
||||
const policy = await enforceApiKeyPolicy(request, "search");
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
// Resolve provider and credentials
|
||||
let providerConfig = selectProvider(body.provider);
|
||||
if (!providerConfig) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
body.provider ? `Unknown search provider: ${body.provider}` : "No search providers available"
|
||||
);
|
||||
}
|
||||
|
||||
let credentials: Record<string, any> | null = null;
|
||||
let alternateProviderId: string | undefined;
|
||||
let alternateCredentials: Record<string, any> | null = null;
|
||||
|
||||
if (body.provider) {
|
||||
// Explicit provider — single credential lookup (with fallback)
|
||||
credentials = await resolveSearchCredentials(providerConfig.id);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`No credentials configured for search provider: ${providerConfig.id}. Add an API key for "${providerConfig.id}" in the dashboard.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Auto-select — try the resolved provider first, then iterate others by cost
|
||||
credentials = await resolveSearchCredentials(providerConfig.id);
|
||||
|
||||
if (!credentials) {
|
||||
// Sort by cost to find cheapest with credentials
|
||||
const sortedIds = Object.values(SEARCH_PROVIDERS)
|
||||
.sort((a, b) => a.costPerQuery - b.costPerQuery)
|
||||
.map((p) => p.id);
|
||||
|
||||
for (const pid of sortedIds) {
|
||||
if (pid === providerConfig.id) continue;
|
||||
const altConfig = getSearchProvider(pid);
|
||||
const altCreds = await resolveSearchCredentials(pid);
|
||||
if (altConfig && altCreds) {
|
||||
providerConfig = altConfig;
|
||||
credentials = altCreds;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`No credentials configured for any search provider. Add an API key for a search provider (${Object.keys(SEARCH_PROVIDERS).join(", ")}) in the dashboard.`
|
||||
);
|
||||
}
|
||||
|
||||
// Find alternate for failover — must bind credentials to the matched provider
|
||||
const otherIds = Object.values(SEARCH_PROVIDERS)
|
||||
.sort((a, b) => a.costPerQuery - b.costPerQuery)
|
||||
.map((p) => p.id)
|
||||
.filter((id) => id !== providerConfig.id);
|
||||
|
||||
for (const pid of otherIds) {
|
||||
const creds = await resolveSearchCredentials(pid);
|
||||
if (creds) {
|
||||
alternateProviderId = pid;
|
||||
alternateCredentials = creds;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp max_results to provider limit
|
||||
const clampedMaxResults = Math.min(body.max_results, providerConfig.maxMaxResults);
|
||||
|
||||
// Cache key — includes all fields that affect results
|
||||
const cacheKey = computeCacheKey(
|
||||
body.query,
|
||||
providerConfig.id,
|
||||
body.search_type,
|
||||
clampedMaxResults,
|
||||
body.country,
|
||||
body.language,
|
||||
{ filters: body.filters, offset: body.offset, time_range: body.time_range }
|
||||
);
|
||||
|
||||
const ttl = providerConfig.cacheTTLMs || SEARCH_CACHE_DEFAULT_TTL_MS;
|
||||
|
||||
try {
|
||||
const { data: searchResult, cached } = await getOrCoalesce(cacheKey, ttl, async () => {
|
||||
const result = await handleSearch({
|
||||
query: body.query,
|
||||
provider: providerConfig.id,
|
||||
maxResults: clampedMaxResults,
|
||||
searchType: body.search_type,
|
||||
country: body.country,
|
||||
language: body.language,
|
||||
timeRange: body.time_range,
|
||||
offset: body.offset,
|
||||
domainFilter: buildDomainFilter(body.filters),
|
||||
contentOptions: body.content,
|
||||
strictFilters: body.strict_filters,
|
||||
providerOptions: body.provider_options,
|
||||
credentials,
|
||||
alternateProvider: alternateProviderId,
|
||||
alternateCredentials,
|
||||
log,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new SearchError(result.error || "Search failed", result.status || 502);
|
||||
}
|
||||
|
||||
return result.data!;
|
||||
});
|
||||
|
||||
// Record cost for budget tracking (skip cache hits — no provider cost)
|
||||
if (!cached && policy.apiKeyInfo?.id && searchResult.usage?.search_cost_usd > 0) {
|
||||
try {
|
||||
recordCost(policy.apiKeyInfo.id, searchResult.usage.search_cost_usd);
|
||||
} catch (e: any) {
|
||||
log.warn("SEARCH", `Cost recording failed: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const response = {
|
||||
id: `search-${crypto.randomUUID()}`,
|
||||
...searchResult,
|
||||
cached,
|
||||
usage: cached ? { queries_used: 0, search_cost_usd: 0 } : searchResult.usage,
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err instanceof SearchError) {
|
||||
const errorPayload = toJsonErrorPayload(err.message, "Search provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: err.statusCode,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
|
||||
log.error("SEARCH", `Unexpected error: ${err.message}`);
|
||||
const errorPayload = toJsonErrorPayload(err.message, "Internal search error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class SearchError extends Error {
|
||||
statusCode: number;
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,7 @@ export default function LoginPage() {
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-text-muted/60 pt-0.5">{t("defaultPasswordHint")}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -101,6 +101,45 @@ function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function normalizeWindowKey(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveQuotaWindow(
|
||||
quotas: Record<string, QuotaInfo>,
|
||||
windowName: string
|
||||
): QuotaInfo | null {
|
||||
const direct = quotas[windowName];
|
||||
if (direct) return direct;
|
||||
|
||||
const normalizedTarget = normalizeWindowKey(windowName);
|
||||
if (!normalizedTarget) return null;
|
||||
|
||||
const prefixMatches: Array<{ key: string; quota: QuotaInfo }> = [];
|
||||
for (const [key, quota] of Object.entries(quotas)) {
|
||||
const normalizedKey = normalizeWindowKey(key);
|
||||
if (!normalizedKey) continue;
|
||||
if (normalizedKey === normalizedTarget) return quota;
|
||||
// Support canonical selection of generic windows from labeled windows,
|
||||
// e.g. "weekly" from "weekly (7d)" or "session" from "session (5h)".
|
||||
if (normalizedKey.startsWith(`${normalizedTarget} `)) {
|
||||
prefixMatches.push({ key, quota });
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic fallback: choose the lexicographically first matching key.
|
||||
if (prefixMatches.length > 0) {
|
||||
prefixMatches.sort((a, b) => a.key.localeCompare(b.key));
|
||||
return prefixMatches[0].quota;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function earliestResetAt(quotas: Record<string, QuotaInfo>): string | null {
|
||||
let earliest: string | null = null;
|
||||
let earliestMs = Infinity;
|
||||
@@ -201,7 +240,7 @@ export function getQuotaWindowStatus(
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const window = entry.quotas[windowName];
|
||||
const window = resolveQuotaWindow(entry.quotas, windowName);
|
||||
if (!window) return null;
|
||||
|
||||
const remainingPercentage = clampPercent(window.remainingPercentage);
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"settings": "Settings",
|
||||
"translator": "Translator",
|
||||
"playground": "Playground",
|
||||
"searchTools": "Search Tools",
|
||||
"agents": "Agents",
|
||||
"docs": "Docs",
|
||||
"issues": "Issues",
|
||||
@@ -328,6 +329,42 @@
|
||||
"videoDescription": "Create videos with AnimateDiff, Stable Video Diffusion via ComfyUI or SD WebUI.",
|
||||
"musicDescription": "Compose music using Stable Audio Open or MusicGen via ComfyUI."
|
||||
},
|
||||
"search": {
|
||||
"searchQuery": "Search Query",
|
||||
"searchResults": "Search Results",
|
||||
"cachedResult": "Cached",
|
||||
"searchCost": "Cost",
|
||||
"searchTools": "Search Tools",
|
||||
"searchToolsDesc": "Advanced search testing with provider comparison",
|
||||
"compareProviders": "Compare Providers",
|
||||
"rerankResults": "Rerank Results",
|
||||
"searchHistory": "Search History",
|
||||
"urlOverlap": "URL Overlap",
|
||||
"noSearchProviders": "No search providers configured. Add providers in Settings.",
|
||||
"noRerankModels": "No rerank model available",
|
||||
"webSearch": "Web Search",
|
||||
"provider": "Provider",
|
||||
"searchType": "Search Type",
|
||||
"maxResults": "Max Results",
|
||||
"filters": "Filters",
|
||||
"country": "Country",
|
||||
"language": "Language",
|
||||
"timeRange": "Time Range",
|
||||
"includeDomains": "Include Domains",
|
||||
"excludeDomains": "Exclude Domains",
|
||||
"safeSearch": "Safe Search",
|
||||
"formatted": "Formatted",
|
||||
"rawJson": "JSON",
|
||||
"cacheMiss": "cache miss",
|
||||
"cacheHit": "cache hit",
|
||||
"latency": "Latency",
|
||||
"cost": "Cost",
|
||||
"results": "Results",
|
||||
"rerank": "Rerank",
|
||||
"rerankModel": "Rerank Model",
|
||||
"positionDelta": "Position Change",
|
||||
"emptyState": "Send a search query to see results"
|
||||
},
|
||||
"cliTools": {
|
||||
"title": "CLI Tools",
|
||||
"noActiveProviders": "No active providers",
|
||||
@@ -818,7 +855,12 @@
|
||||
"settingsApi": "Settings API",
|
||||
"categoryCore": "Core APIs",
|
||||
"categoryMedia": "Media & Multi-Modal",
|
||||
"categorySearch": "Search & Discovery",
|
||||
"categoryUtility": "Utility & Management",
|
||||
"webSearch": "Web Search",
|
||||
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
|
||||
"searchProvider": "Search Provider",
|
||||
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
|
||||
"enableCloudTitle": "Enable Cloud Proxy",
|
||||
"whatYouGet": "What you will get",
|
||||
"cloudBenefitAccess": "Access your API from anywhere in the world",
|
||||
@@ -2251,7 +2293,8 @@
|
||||
"orRemovePasswordHashField": "or remove the passwordHash field",
|
||||
"restartServerWithNewPassword": "Restart the server - it will use the new password",
|
||||
"backToLogin": "Back to Login",
|
||||
"forgotPassword": "Forgot password?"
|
||||
"forgotPassword": "Forgot password?",
|
||||
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)"
|
||||
},
|
||||
"landing": {
|
||||
"brandName": "OmniRoute",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add request_type column to call_logs for non-chat request tracking (search, embed, rerank).
|
||||
-- Backward-compatible: DEFAULT NULL means existing rows are unaffected.
|
||||
ALTER TABLE call_logs ADD COLUMN request_type TEXT DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_call_logs_request_type ON call_logs(request_type);
|
||||
@@ -300,6 +300,52 @@ async function validateInworldProvider({ apiKey }: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateBailianCodingPlanProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
try {
|
||||
const rawBaseUrl =
|
||||
normalizeBaseUrl(providerSpecificData.baseUrl) ||
|
||||
"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
|
||||
const baseUrl = rawBaseUrl.endsWith("/messages")
|
||||
? rawBaseUrl.slice(0, -"/messages".length)
|
||||
: rawBaseUrl;
|
||||
// bailian-coding-plan uses DashScope Anthropic-compatible messages endpoint
|
||||
// It does NOT expose /v1/models — use messages probe directly
|
||||
const messagesUrl = `${baseUrl}/messages`;
|
||||
|
||||
const response = await fetch(messagesUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "qwen3-coder-plus",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
}),
|
||||
});
|
||||
|
||||
// 401/403 => invalid key
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
// Non-auth 4xx (e.g., 400 bad request) means auth passed but request was malformed
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message || "Validation failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
@@ -440,6 +486,75 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search provider validators (factored) ──
|
||||
|
||||
async function validateSearchProvider(
|
||||
url: string,
|
||||
init: RequestInit
|
||||
): Promise<{ valid: boolean; error: string | null; unsupported: false }> {
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
if (response.ok) return { valid: true, error: null, unsupported: false };
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key", unsupported: false };
|
||||
}
|
||||
// For provider setup we only need to confirm authentication passed.
|
||||
// Search providers may return non-auth statuses for exhausted credits,
|
||||
// rate limiting, or request-shape quirks while still accepting the key.
|
||||
if (response.status < 500) {
|
||||
return { valid: true, error: null, unsupported: false };
|
||||
}
|
||||
return { valid: false, error: `Validation failed: ${response.status}`, unsupported: false };
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message || "Validation failed", unsupported: false };
|
||||
}
|
||||
}
|
||||
|
||||
const SEARCH_VALIDATOR_CONFIGS: Record<
|
||||
string,
|
||||
(apiKey: string) => { url: string; init: RequestInit }
|
||||
> = {
|
||||
"serper-search": (apiKey) => ({
|
||||
url: "https://google.serper.dev/search",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-API-Key": apiKey },
|
||||
body: JSON.stringify({ q: "test", num: 1 }),
|
||||
},
|
||||
}),
|
||||
"brave-search": (apiKey) => ({
|
||||
url: "https://api.search.brave.com/res/v1/web/search?q=test&count=1",
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", "X-Subscription-Token": apiKey },
|
||||
},
|
||||
}),
|
||||
"perplexity-search": (apiKey) => ({
|
||||
url: "https://api.perplexity.ai/search",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({ query: "test", max_results: 1 }),
|
||||
},
|
||||
}),
|
||||
"exa-search": (apiKey) => ({
|
||||
url: "https://api.exa.ai/search",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
|
||||
body: JSON.stringify({ query: "test", numResults: 1 }),
|
||||
},
|
||||
}),
|
||||
"tavily-search": (apiKey) => ({
|
||||
url: "https://api.tavily.com/search",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({ query: "test", max_results: 1 }),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) {
|
||||
if (!provider || !apiKey) {
|
||||
return { valid: false, error: "Provider and API key required", unsupported: false };
|
||||
@@ -468,6 +583,17 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
nanobanana: validateNanoBananaProvider,
|
||||
elevenlabs: validateElevenLabsProvider,
|
||||
inworld: validateInworldProvider,
|
||||
"bailian-coding-plan": validateBailianCodingPlanProvider,
|
||||
// Search providers — use factored validator
|
||||
...Object.fromEntries(
|
||||
Object.entries(SEARCH_VALIDATOR_CONFIGS).map(([id, configFn]) => [
|
||||
id,
|
||||
({ apiKey }: any) => {
|
||||
const { url, init } = configFn(apiKey);
|
||||
return validateSearchProvider(url, init);
|
||||
},
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
if (SPECIALTY_VALIDATORS[provider]) {
|
||||
|
||||
@@ -186,6 +186,7 @@ export async function saveCallLog(entry: any) {
|
||||
duration: entry.duration || 0,
|
||||
tokensIn: entry.tokens?.prompt_tokens || 0,
|
||||
tokensOut: entry.tokens?.completion_tokens || 0,
|
||||
requestType: entry.requestType || null,
|
||||
sourceFormat: entry.sourceFormat || null,
|
||||
targetFormat: entry.targetFormat || null,
|
||||
apiKeyId,
|
||||
@@ -201,10 +202,10 @@ export async function saveCallLog(entry: any) {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO call_logs (id, timestamp, method, path, status, model, provider,
|
||||
account, connection_id, duration, tokens_in, tokens_out, source_format, target_format,
|
||||
account, connection_id, duration, tokens_in, tokens_out, request_type, source_format, target_format,
|
||||
api_key_id, api_key_name, combo_name, request_body, response_body, error)
|
||||
VALUES (@id, @timestamp, @method, @path, @status, @model, @provider,
|
||||
@account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat,
|
||||
@account, @connectionId, @duration, @tokensIn, @tokensOut, @requestType, @sourceFormat, @targetFormat,
|
||||
@apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error)
|
||||
`
|
||||
).run(logEntry);
|
||||
|
||||
@@ -29,6 +29,20 @@ function toNumber(value: unknown): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function percentile(sortedValues: number[], p: number): number {
|
||||
if (sortedValues.length === 0) return 0;
|
||||
if (sortedValues.length === 1) return sortedValues[0];
|
||||
const bounded = Math.max(0, Math.min(1, p));
|
||||
const idx = Math.round((sortedValues.length - 1) * bounded);
|
||||
return sortedValues[idx] ?? sortedValues[sortedValues.length - 1];
|
||||
}
|
||||
|
||||
function stdDev(values: number[], avg: number): number {
|
||||
if (values.length <= 1) return 0;
|
||||
const variance = values.reduce((acc, v) => acc + (v - avg) ** 2, 0) / values.length;
|
||||
return Math.sqrt(Math.max(0, variance));
|
||||
}
|
||||
|
||||
// ──────────────── Pending Requests (in-memory) ────────────────
|
||||
|
||||
const pendingRequests: {
|
||||
@@ -223,6 +237,141 @@ export async function getUsageHistory(filter: any = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export interface ModelLatencyStatsEntry {
|
||||
provider: string;
|
||||
model: string;
|
||||
key: string;
|
||||
totalRequests: number;
|
||||
successfulRequests: number;
|
||||
successRate: number; // 0..1
|
||||
avgLatencyMs: number;
|
||||
p50LatencyMs: number;
|
||||
p95LatencyMs: number;
|
||||
p99LatencyMs: number;
|
||||
latencyStdDev: number;
|
||||
windowHours: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate rolling latency stats per provider/model from usage_history.
|
||||
* Used by auto-combo routing to incorporate real-world latency and reliability.
|
||||
*/
|
||||
export async function getModelLatencyStats(
|
||||
options: { windowHours?: number; minSamples?: number; maxRows?: number } = {}
|
||||
): Promise<Record<string, ModelLatencyStatsEntry>> {
|
||||
const windowHours =
|
||||
Number.isFinite(Number(options.windowHours)) && Number(options.windowHours) > 0
|
||||
? Number(options.windowHours)
|
||||
: 24;
|
||||
const minSamples =
|
||||
Number.isFinite(Number(options.minSamples)) && Number(options.minSamples) > 0
|
||||
? Number(options.minSamples)
|
||||
: 1;
|
||||
const maxRows =
|
||||
Number.isFinite(Number(options.maxRows)) && Number(options.maxRows) > 0
|
||||
? Number(options.maxRows)
|
||||
: 10000;
|
||||
|
||||
const db = getDbInstance();
|
||||
const sinceIso = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
type LatencyRow = {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
success: number | null;
|
||||
latency_ms: number | null;
|
||||
};
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT provider, model, success, latency_ms
|
||||
FROM usage_history
|
||||
WHERE timestamp >= @sinceIso
|
||||
AND provider IS NOT NULL
|
||||
AND model IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT @maxRows
|
||||
`
|
||||
)
|
||||
.all({ sinceIso, maxRows }) as LatencyRow[];
|
||||
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
provider: string;
|
||||
model: string;
|
||||
totalRequests: number;
|
||||
successfulRequests: number;
|
||||
successfulLatencies: number[];
|
||||
allLatencies: number[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const provider = toStringOrNull(row.provider);
|
||||
const model = toStringOrNull(row.model);
|
||||
if (!provider || !model) continue;
|
||||
|
||||
const key = `${provider}/${model}`;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, {
|
||||
provider,
|
||||
model,
|
||||
totalRequests: 0,
|
||||
successfulRequests: 0,
|
||||
successfulLatencies: [],
|
||||
allLatencies: [],
|
||||
});
|
||||
}
|
||||
|
||||
const bucket = grouped.get(key);
|
||||
if (!bucket) continue;
|
||||
|
||||
bucket.totalRequests += 1;
|
||||
const isSuccess = toNumber(row.success) !== 0;
|
||||
if (isSuccess) bucket.successfulRequests += 1;
|
||||
|
||||
const latency = toNumber(row.latency_ms);
|
||||
if (latency > 0) {
|
||||
bucket.allLatencies.push(latency);
|
||||
if (isSuccess) bucket.successfulLatencies.push(latency);
|
||||
}
|
||||
}
|
||||
|
||||
const stats: Record<string, ModelLatencyStatsEntry> = {};
|
||||
for (const [key, bucket] of grouped.entries()) {
|
||||
const baseLatencies =
|
||||
bucket.successfulLatencies.length >= minSamples
|
||||
? bucket.successfulLatencies
|
||||
: bucket.allLatencies;
|
||||
|
||||
if (baseLatencies.length < minSamples) continue;
|
||||
|
||||
const sorted = [...baseLatencies].sort((a, b) => a - b);
|
||||
const avg = sorted.reduce((acc, n) => acc + n, 0) / sorted.length;
|
||||
const successRate =
|
||||
bucket.totalRequests > 0 ? bucket.successfulRequests / bucket.totalRequests : 0;
|
||||
|
||||
stats[key] = {
|
||||
provider: bucket.provider,
|
||||
model: bucket.model,
|
||||
key,
|
||||
totalRequests: bucket.totalRequests,
|
||||
successfulRequests: bucket.successfulRequests,
|
||||
successRate,
|
||||
avgLatencyMs: Math.round(avg),
|
||||
p50LatencyMs: Math.round(percentile(sorted, 0.5)),
|
||||
p95LatencyMs: Math.round(percentile(sorted, 0.95)),
|
||||
p99LatencyMs: Math.round(percentile(sorted, 0.99)),
|
||||
latencyStdDev: Math.round(stdDev(sorted, avg)),
|
||||
windowHours,
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ──────────────── Request Log (log.txt) ────────────────
|
||||
|
||||
import fs from "fs";
|
||||
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
getUsageDb,
|
||||
saveRequestUsage,
|
||||
getUsageHistory,
|
||||
getModelLatencyStats,
|
||||
appendRequestLog,
|
||||
getRecentLogs,
|
||||
} from "./usage/usageHistory";
|
||||
@@ -31,9 +32,4 @@ export { calculateCost } from "./usage/costCalculator";
|
||||
|
||||
export { getUsageStats } from "./usage/usageStats";
|
||||
|
||||
export {
|
||||
saveCallLog,
|
||||
rotateCallLogs,
|
||||
getCallLogs,
|
||||
getCallLogById,
|
||||
} from "./usage/callLogs";
|
||||
export { saveCallLog, rotateCallLogs, getCallLogs, getCallLogById } from "./usage/callLogs";
|
||||
|
||||
@@ -258,7 +258,7 @@ export default function RequestLoggerV2() {
|
||||
onClick={() => setRecording(!recording)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
|
||||
recording
|
||||
? "bg-red-500/10 border-red-500/30 text-red-400"
|
||||
? "bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400"
|
||||
: "bg-bg-subtle border-border text-text-muted"
|
||||
}`}
|
||||
>
|
||||
@@ -413,11 +413,11 @@ export default function RequestLoggerV2() {
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-all ${
|
||||
activeFilter === f.key
|
||||
? f.key === "error"
|
||||
? "bg-red-500/20 text-red-400 border-red-500/40"
|
||||
? "bg-red-500/20 text-red-700 dark:text-red-400 border-red-500/40"
|
||||
: f.key === "ok"
|
||||
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/40"
|
||||
? "bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 border-emerald-500/40"
|
||||
: f.key === "combo"
|
||||
? "bg-violet-500/20 text-violet-300 border-violet-500/40"
|
||||
? "bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/40"
|
||||
: "bg-primary text-white border-primary"
|
||||
: "bg-bg-subtle border-border text-text-muted hover:border-text-muted"
|
||||
}`}
|
||||
@@ -635,7 +635,7 @@ export default function RequestLoggerV2() {
|
||||
{visibleColumns.combo && (
|
||||
<td className="px-3 py-2">
|
||||
{log.comboName ? (
|
||||
<span className="inline-block px-2 py-0.5 rounded-full text-[9px] font-bold bg-violet-500/20 text-violet-700 dark:text-violet-300 border border-violet-500/30">
|
||||
<span className="inline-block px-2 py-0.5 rounded-full text-[9px] font-bold bg-violet-500/20 text-violet-800 dark:text-violet-300 border border-violet-500/40">
|
||||
{log.comboName}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -32,6 +32,7 @@ const debugItemDefs = [
|
||||
{ href: "/dashboard/translator", i18nKey: "translator", icon: "translate" },
|
||||
{ href: "/dashboard/playground", i18nKey: "playground", icon: "science" },
|
||||
{ href: "/dashboard/media", i18nKey: "media", icon: "auto_awesome" },
|
||||
{ href: "/dashboard/search-tools", i18nKey: "searchTools", icon: "manage_search" },
|
||||
];
|
||||
|
||||
const systemItemDefs = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import Card from "../Card";
|
||||
import { getModelColor } from "@/shared/constants/colors";
|
||||
import {
|
||||
@@ -25,6 +26,14 @@ import {
|
||||
Area,
|
||||
} from "recharts";
|
||||
|
||||
function createDateFormatter(locale: string, options: Intl.DateTimeFormatOptions) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, options);
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat(undefined, options);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom Tooltip for dark theme ──────────────────────────────────────────
|
||||
|
||||
function DarkTooltip({
|
||||
@@ -724,6 +733,15 @@ export function WeeklyPattern({ weeklyPattern }) {
|
||||
// ── MostActiveDay7d ────────────────────────────────────────────────────────
|
||||
|
||||
export function MostActiveDay7d({ activityMap }) {
|
||||
const locale = useLocale();
|
||||
const weekdayFormatter = useMemo(
|
||||
() => createDateFormatter(locale, { weekday: "long" }),
|
||||
[locale]
|
||||
);
|
||||
const dateFormatter = useMemo(
|
||||
() => createDateFormatter(locale, { month: "short", day: "numeric" }),
|
||||
[locale]
|
||||
);
|
||||
const data = useMemo(() => {
|
||||
if (!activityMap) return null;
|
||||
const today = new Date();
|
||||
@@ -743,27 +761,12 @@ export function MostActiveDay7d({ activityMap }) {
|
||||
if (!peakKey || peakVal === 0) return null;
|
||||
|
||||
const peakDate = new Date(peakKey + "T12:00:00");
|
||||
const weekdays = ["domingo", "segunda", "terça", "quarta", "quinta", "sexta", "sábado"];
|
||||
const months = [
|
||||
"jan",
|
||||
"fev",
|
||||
"mar",
|
||||
"abr",
|
||||
"mai",
|
||||
"jun",
|
||||
"jul",
|
||||
"ago",
|
||||
"set",
|
||||
"out",
|
||||
"nov",
|
||||
"dez",
|
||||
];
|
||||
return {
|
||||
weekday: weekdays[peakDate.getDay()],
|
||||
label: `${peakDate.getDate()} de ${months[peakDate.getMonth()]}`,
|
||||
weekday: weekdayFormatter.format(peakDate),
|
||||
label: dateFormatter.format(peakDate),
|
||||
tokens: peakVal,
|
||||
};
|
||||
}, [activityMap]);
|
||||
}, [activityMap, dateFormatter, weekdayFormatter]);
|
||||
|
||||
return (
|
||||
<Card className="p-4 flex flex-col justify-center" style={{ flex: 1, minHeight: 0 }}>
|
||||
@@ -784,7 +787,7 @@ export function MostActiveDay7d({ activityMap }) {
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
Sem dados nos últimos 7 dias
|
||||
No data in the last 7 days
|
||||
</span>
|
||||
)}
|
||||
</Card>
|
||||
@@ -794,6 +797,15 @@ export function MostActiveDay7d({ activityMap }) {
|
||||
// ── WeeklySquares7d ────────────────────────────────────────────────────────
|
||||
|
||||
export function WeeklySquares7d({ activityMap }) {
|
||||
const locale = useLocale();
|
||||
const weekdayFormatter = useMemo(
|
||||
() => createDateFormatter(locale, { weekday: "short" }),
|
||||
[locale]
|
||||
);
|
||||
const dateFormatter = useMemo(
|
||||
() => createDateFormatter(locale, { month: "short", day: "numeric" }),
|
||||
[locale]
|
||||
);
|
||||
const days = useMemo(() => {
|
||||
if (!activityMap) return [];
|
||||
const today = new Date();
|
||||
@@ -806,11 +818,15 @@ export function WeeklySquares7d({ activityMap }) {
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const val = activityMap[key] || 0;
|
||||
if (val > maxVal) maxVal = val;
|
||||
const shortDays = ["DOM", "SEG", "TER", "QUA", "QUI", "SEX", "SÁB"];
|
||||
result.push({ key, val, label: shortDays[d.getDay()] });
|
||||
result.push({
|
||||
key,
|
||||
val,
|
||||
label: weekdayFormatter.format(d),
|
||||
dateLabel: dateFormatter.format(d),
|
||||
});
|
||||
}
|
||||
return result.map((d) => ({ ...d, intensity: maxVal > 0 ? d.val / maxVal : 0 }));
|
||||
}, [activityMap]);
|
||||
}, [activityMap, dateFormatter, weekdayFormatter]);
|
||||
|
||||
function getSquareStyle(intensity) {
|
||||
if (intensity === 0) return { background: "rgba(255,255,255,0.04)" };
|
||||
@@ -829,11 +845,11 @@ export function WeeklySquares7d({ activityMap }) {
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, justifyContent: "center" }}>
|
||||
{days.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
key={d.key}
|
||||
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<div
|
||||
title={`${d.key}: ${fmtFull(d.val)} tokens`}
|
||||
title={`${d.dateLabel}: ${fmtFull(d.val)} tokens`}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
|
||||
@@ -33,8 +33,10 @@ export const API_ENDPOINTS = {
|
||||
export const PROVIDER_ENDPOINTS = {
|
||||
openrouter: "https://openrouter.ai/api/v1/chat/completions",
|
||||
glm: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
"bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
|
||||
kimi: "https://api.moonshot.ai/v1/chat/completions",
|
||||
"kimi-coding": "https://api.kimi.com/coding/v1/messages",
|
||||
"kimi-coding-apikey": "https://api.kimi.com/coding/v1/messages",
|
||||
minimax: "https://api.minimax.io/anthropic/v1/messages",
|
||||
"minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages",
|
||||
openai: "https://api.openai.com/v1/chat/completions",
|
||||
|
||||