Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3dfd9ce57 | |||
| aa06d5d356 | |||
| 448c8a29e1 | |||
| 928b7120f4 | |||
| a3deacd718 | |||
| 78959fffbd | |||
| 1788616e52 | |||
| c61e6d0777 | |||
| a3bc7620b1 | |||
| 8064c588dc | |||
| 564e983c68 | |||
| e1da181740 | |||
| c63209200e | |||
| 737808cf53 | |||
| a197bb7736 | |||
| f9dd967bc5 |
@@ -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,54 @@
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -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.7.2
|
||||
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,
|
||||
|
||||
@@ -115,6 +115,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" },
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
@@ -641,6 +646,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,664 @@
|
||||
/**
|
||||
* 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();
|
||||
const container = searchType === "news" ? data.news : 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 { results, totalResults } = normalizeResponse(config.id, data, query, searchType);
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.6.10",
|
||||
"version": "2.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.6.10",
|
||||
"version": "2.7.0",
|
||||
"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.7.2",
|
||||
"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: 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)) {
|
||||
|
||||
@@ -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("/v1/search");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchProviders(data.data || []);
|
||||
}
|
||||
} 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">
|
||||
|
||||
@@ -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;
|
||||
@@ -1060,21 +1061,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" ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -818,7 +818,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",
|
||||
|
||||
@@ -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);
|
||||
@@ -440,6 +440,69 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search provider validators (factored) ──
|
||||
|
||||
async function validateSearchProvider(
|
||||
url: string,
|
||||
init: RequestInit
|
||||
): Promise<{ valid: boolean; error: string | null }> {
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
if (response.ok) return { valid: true, error: null };
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message || "Validation failed" };
|
||||
}
|
||||
}
|
||||
|
||||
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 +531,16 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
nanobanana: validateNanoBananaProvider,
|
||||
elevenlabs: validateElevenLabsProvider,
|
||||
inworld: validateInworldProvider,
|
||||
// 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>
|
||||
) : (
|
||||
|
||||
@@ -7,6 +7,20 @@ export const DEFAULT_PRICING = {
|
||||
|
||||
// Claude Code (cc)
|
||||
cc: {
|
||||
"claude-opus-4-6": {
|
||||
input: 5.0,
|
||||
output: 25.0,
|
||||
cached: 2.5,
|
||||
reasoning: 25.0,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cached: 1.5,
|
||||
reasoning: 15.0,
|
||||
cache_creation: 3.0,
|
||||
},
|
||||
"claude-opus-4-5-20251101": {
|
||||
input: 15.0,
|
||||
output: 75.0,
|
||||
@@ -210,25 +224,25 @@ export const DEFAULT_PRICING = {
|
||||
cache_creation: 0.75,
|
||||
},
|
||||
"deepseek-v3.2-chat": {
|
||||
input: 0.5,
|
||||
output: 2.0,
|
||||
cached: 0.25,
|
||||
reasoning: 3.0,
|
||||
cache_creation: 0.5,
|
||||
input: 0.28,
|
||||
output: 0.42,
|
||||
cached: 0.014,
|
||||
reasoning: 0.63,
|
||||
cache_creation: 0.28,
|
||||
},
|
||||
"deepseek-v3.2": {
|
||||
input: 0.5,
|
||||
output: 2.0,
|
||||
cached: 0.25,
|
||||
reasoning: 3.0,
|
||||
cache_creation: 0.5,
|
||||
input: 0.28,
|
||||
output: 0.42,
|
||||
cached: 0.014,
|
||||
reasoning: 0.63,
|
||||
cache_creation: 0.28,
|
||||
},
|
||||
"deepseek-v3.2-reasoner": {
|
||||
input: 0.75,
|
||||
output: 3.0,
|
||||
cached: 0.375,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.75,
|
||||
input: 0.55,
|
||||
output: 2.19,
|
||||
cached: 0.14,
|
||||
reasoning: 2.19,
|
||||
cache_creation: 0.55,
|
||||
},
|
||||
// Short-form aliases used by decolua/9router catalog (Mar 2026)
|
||||
"deepseek-3.1": {
|
||||
|
||||
@@ -390,8 +390,6 @@ export const APIKEY_PROVIDERS = {
|
||||
website: "https://cloud.google.com/vertex-ai",
|
||||
authHint: "Provide Service Account JSON or OAuth access_token",
|
||||
},
|
||||
// Z.AI (formerly ZhipuAI) — GLM-5 family with 128k output
|
||||
// Added 2026-03-17 based on ClawRouter feature analysis
|
||||
zai: {
|
||||
id: "zai",
|
||||
alias: "zai",
|
||||
@@ -402,6 +400,56 @@ export const APIKEY_PROVIDERS = {
|
||||
website: "https://open.bigmodel.cn",
|
||||
apiHint: "API key from https://open.bigmodel.cn/usercenter/apikeys",
|
||||
},
|
||||
"perplexity-search": {
|
||||
id: "perplexity-search",
|
||||
alias: "pplx-search",
|
||||
name: "Perplexity Search",
|
||||
icon: "search",
|
||||
color: "#20808D",
|
||||
textIcon: "PS",
|
||||
website: "https://docs.perplexity.ai/guides/search-quickstart",
|
||||
authHint: "Same API key as Perplexity (pplx-...)",
|
||||
},
|
||||
"serper-search": {
|
||||
id: "serper-search",
|
||||
alias: "serper-search",
|
||||
name: "Serper Search",
|
||||
icon: "search",
|
||||
color: "#4285F4",
|
||||
textIcon: "SP",
|
||||
website: "https://serper.dev",
|
||||
authHint: "API key from serper.dev dashboard",
|
||||
},
|
||||
"brave-search": {
|
||||
id: "brave-search",
|
||||
alias: "brave-search",
|
||||
name: "Brave Search",
|
||||
icon: "travel_explore",
|
||||
color: "#FB542B",
|
||||
textIcon: "BR",
|
||||
website: "https://brave.com/search/api",
|
||||
authHint: "Subscription token from Brave Search API dashboard",
|
||||
},
|
||||
"exa-search": {
|
||||
id: "exa-search",
|
||||
alias: "exa-search",
|
||||
name: "Exa Search",
|
||||
icon: "neurology",
|
||||
color: "#1E40AF",
|
||||
textIcon: "EX",
|
||||
website: "https://exa.ai",
|
||||
authHint: "API key from dashboard.exa.ai",
|
||||
},
|
||||
"tavily-search": {
|
||||
id: "tavily-search",
|
||||
alias: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
icon: "manage_search",
|
||||
color: "#5B4FDB",
|
||||
textIcon: "TV",
|
||||
website: "https://tavily.com",
|
||||
authHint: "API key from app.tavily.com (format: tvly-...)",
|
||||
},
|
||||
};
|
||||
|
||||
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
|
||||
@@ -1081,3 +1081,137 @@ export const guideSettingsSaveSchema = z.object({
|
||||
apiKey: z.string().optional(),
|
||||
model: z.string().trim().min(1, "Model is required"),
|
||||
});
|
||||
|
||||
// ── Search Schemas ─────────────────────────────────────────────────────
|
||||
// Unified search request/response schemas. Final contract — all fields optional
|
||||
// with defaults. New features add implementations, not new fields.
|
||||
// Multi-query deferred to POST /v1/search/batch (separate PRD).
|
||||
|
||||
export const v1SearchSchema = z
|
||||
.object({
|
||||
// Core
|
||||
query: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Query is required")
|
||||
.max(500, "Query must be 500 characters or fewer"),
|
||||
provider: z
|
||||
.enum(["serper-search", "brave-search", "perplexity-search", "exa-search", "tavily-search"])
|
||||
.optional(),
|
||||
max_results: z.coerce.number().int().min(1).max(100).default(5),
|
||||
search_type: z.enum(["web", "news"]).default("web"),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
|
||||
// Locale
|
||||
country: z.string().max(2).toUpperCase().optional(),
|
||||
language: z.string().min(2).max(5).optional(),
|
||||
time_range: z.enum(["any", "day", "week", "month", "year"]).optional(),
|
||||
|
||||
// Content control
|
||||
content: z
|
||||
.object({
|
||||
snippet: z.boolean().default(true),
|
||||
full_page: z.boolean().default(false),
|
||||
format: z.enum(["text", "markdown"]).default("text"),
|
||||
max_characters: z.coerce.number().int().min(100).max(100000).optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// Filters
|
||||
filters: z
|
||||
.object({
|
||||
include_domains: z.array(z.string().max(253)).max(20).optional(),
|
||||
exclude_domains: z.array(z.string().max(253)).max(20).optional(),
|
||||
safe_search: z.enum(["off", "moderate", "strict"]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// Answer synthesis (Phase 2 — returns null until implemented)
|
||||
synthesis: z
|
||||
.object({
|
||||
strategy: z.enum(["none", "auto", "provider", "internal"]).default("none"),
|
||||
model: z.string().optional(),
|
||||
max_tokens: z.coerce.number().int().min(1).max(4000).optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// Provider-specific passthrough
|
||||
provider_options: z.record(z.string(), z.unknown()).optional(),
|
||||
|
||||
// Strict mode — reject if provider doesn't support a requested filter
|
||||
strict_filters: z.boolean().default(false),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const searchResultSchema = z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
display_url: z.string().optional(),
|
||||
snippet: z.string(),
|
||||
position: z.number().int().positive(),
|
||||
score: z.number().min(0).max(1).nullable().optional(),
|
||||
published_at: z.string().nullable().optional(),
|
||||
favicon_url: z.string().nullable().optional(),
|
||||
content: z
|
||||
.object({
|
||||
format: z.enum(["text", "markdown"]).optional(),
|
||||
text: z.string().optional(),
|
||||
length: z.number().int().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
author: z.string().nullable().optional(),
|
||||
language: z.string().nullable().optional(),
|
||||
source_type: z
|
||||
.enum(["article", "blog", "forum", "video", "academic", "news", "other"])
|
||||
.nullable()
|
||||
.optional(),
|
||||
image_url: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
citation: z.object({
|
||||
provider: z.string(),
|
||||
retrieved_at: z.string(),
|
||||
rank: z.number().int().positive(),
|
||||
}),
|
||||
provider_raw: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
});
|
||||
|
||||
export const v1SearchResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
provider: z.string(),
|
||||
query: z.string(),
|
||||
results: z.array(searchResultSchema),
|
||||
cached: z.boolean(),
|
||||
answer: z
|
||||
.object({
|
||||
source: z.enum(["none", "provider", "internal"]).optional(),
|
||||
text: z.string().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
usage: z.object({
|
||||
queries_used: z.number().int().min(0),
|
||||
search_cost_usd: z.number().min(0),
|
||||
llm_tokens: z.number().int().min(0).optional(),
|
||||
}),
|
||||
metrics: z.object({
|
||||
response_time_ms: z.number().int().min(0),
|
||||
upstream_latency_ms: z.number().int().min(0).optional(),
|
||||
gateway_latency_ms: z.number().int().min(0).optional(),
|
||||
total_results_available: z.number().int().nullable(),
|
||||
}),
|
||||
errors: z
|
||||
.array(
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Search Registry + Cache Unit Tests
|
||||
// Tests for searchRegistry, searchCache, and response normalization
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const { SEARCH_PROVIDERS, getSearchProvider, getAllSearchProviders, selectProvider } =
|
||||
await import("../../open-sse/config/searchRegistry.ts");
|
||||
|
||||
const { computeCacheKey, getOrCoalesce, getCacheStats, SEARCH_CACHE_DEFAULT_TTL_MS } =
|
||||
await import("../../open-sse/services/searchCache.ts");
|
||||
|
||||
// ─── Registry Tests ──────────────────────────────────────────
|
||||
|
||||
test("SEARCH_PROVIDERS has all 5 providers", () => {
|
||||
assert.ok(SEARCH_PROVIDERS["serper-search"], "serper should exist");
|
||||
assert.ok(SEARCH_PROVIDERS["brave-search"], "brave should exist");
|
||||
assert.ok(SEARCH_PROVIDERS["perplexity-search"], "perplexity-search should exist");
|
||||
assert.ok(SEARCH_PROVIDERS["exa-search"], "exa should exist");
|
||||
assert.ok(SEARCH_PROVIDERS["tavily-search"], "tavily should exist");
|
||||
assert.equal(Object.keys(SEARCH_PROVIDERS).length, 5);
|
||||
});
|
||||
|
||||
test("serper-search config is correct", () => {
|
||||
const s = SEARCH_PROVIDERS["serper-search"];
|
||||
assert.equal(s.id, "serper-search");
|
||||
assert.equal(s.method, "POST");
|
||||
assert.equal(s.authHeader, "x-api-key");
|
||||
assert.equal(s.costPerQuery, 0.001);
|
||||
assert.equal(s.freeMonthlyQuota, 2500);
|
||||
assert.deepEqual(s.searchTypes, ["web", "news"]);
|
||||
});
|
||||
|
||||
test("brave-search config is correct", () => {
|
||||
const b = SEARCH_PROVIDERS["brave-search"];
|
||||
assert.equal(b.id, "brave-search");
|
||||
assert.equal(b.method, "GET");
|
||||
assert.equal(b.authHeader, "x-subscription-token");
|
||||
assert.equal(b.costPerQuery, 0.005);
|
||||
assert.equal(b.freeMonthlyQuota, 1000);
|
||||
});
|
||||
|
||||
test("perplexity-search config is correct", () => {
|
||||
const p = SEARCH_PROVIDERS["perplexity-search"];
|
||||
assert.equal(p.id, "perplexity-search");
|
||||
assert.equal(p.method, "POST");
|
||||
assert.equal(p.authHeader, "bearer");
|
||||
assert.equal(p.baseUrl, "https://api.perplexity.ai/search");
|
||||
assert.equal(p.costPerQuery, 0.005);
|
||||
assert.equal(p.freeMonthlyQuota, 0);
|
||||
assert.deepEqual(p.searchTypes, ["web"]);
|
||||
});
|
||||
|
||||
test("getSearchProvider returns config for valid ID", () => {
|
||||
const config = getSearchProvider("serper-search");
|
||||
assert.ok(config);
|
||||
assert.equal(config.id, "serper-search");
|
||||
});
|
||||
|
||||
test("getSearchProvider returns null for unknown ID", () => {
|
||||
assert.equal(getSearchProvider("unknown"), null);
|
||||
});
|
||||
|
||||
test("tavily config is correct", () => {
|
||||
const t = SEARCH_PROVIDERS["tavily-search"];
|
||||
assert.equal(t.id, "tavily-search");
|
||||
assert.equal(t.method, "POST");
|
||||
assert.equal(t.authHeader, "bearer");
|
||||
assert.equal(t.baseUrl, "https://api.tavily.com/search");
|
||||
assert.equal(t.costPerQuery, 0.008);
|
||||
assert.equal(t.freeMonthlyQuota, 1000);
|
||||
assert.deepEqual(t.searchTypes, ["web", "news"]);
|
||||
});
|
||||
|
||||
test("getAllSearchProviders returns flat list", () => {
|
||||
const all = getAllSearchProviders();
|
||||
assert.equal(all.length, 5);
|
||||
assert.ok(all.some((p) => p.id === "serper-search"));
|
||||
assert.ok(all.some((p) => p.id === "brave-search"));
|
||||
assert.ok(all.some((p) => p.id === "perplexity-search"));
|
||||
assert.ok(all.some((p) => p.id === "exa-search"));
|
||||
assert.ok(all.some((p) => p.id === "tavily-search"));
|
||||
// Each entry should have id, name, searchTypes
|
||||
for (const p of all) {
|
||||
assert.ok(p.id);
|
||||
assert.ok(p.name);
|
||||
assert.ok(Array.isArray(p.searchTypes));
|
||||
}
|
||||
});
|
||||
|
||||
test("selectProvider with explicit provider returns that provider", () => {
|
||||
const config = selectProvider("brave-search");
|
||||
assert.ok(config);
|
||||
assert.equal(config.id, "brave-search");
|
||||
});
|
||||
|
||||
test("selectProvider with unknown provider returns null", () => {
|
||||
assert.equal(selectProvider("unknown"), null);
|
||||
});
|
||||
|
||||
test("selectProvider without argument returns cheapest (serper)", () => {
|
||||
const config = selectProvider();
|
||||
assert.ok(config);
|
||||
assert.equal(config.id, "serper-search"); // $0.001 < $0.005
|
||||
});
|
||||
|
||||
// ─── Cache Key Tests ─────────────────────────────────────────
|
||||
|
||||
test("computeCacheKey is deterministic", () => {
|
||||
const k1 = computeCacheKey("hello world", "auto", "web", 5);
|
||||
const k2 = computeCacheKey("hello world", "auto", "web", 5);
|
||||
assert.equal(k1, k2);
|
||||
});
|
||||
|
||||
test("computeCacheKey normalizes query (case, whitespace)", () => {
|
||||
const k1 = computeCacheKey("Hello World", "auto", "web", 5);
|
||||
const k2 = computeCacheKey("hello world", "auto", "web", 5);
|
||||
assert.equal(k1, k2);
|
||||
});
|
||||
|
||||
test("computeCacheKey differs by provider", () => {
|
||||
const k1 = computeCacheKey("test", "serper", "web", 5);
|
||||
const k2 = computeCacheKey("test", "brave", "web", 5);
|
||||
assert.notEqual(k1, k2);
|
||||
});
|
||||
|
||||
test("computeCacheKey differs by search_type", () => {
|
||||
const k1 = computeCacheKey("test", "auto", "web", 5);
|
||||
const k2 = computeCacheKey("test", "auto", "news", 5);
|
||||
assert.notEqual(k1, k2);
|
||||
});
|
||||
|
||||
test("computeCacheKey differs by max_results", () => {
|
||||
const k1 = computeCacheKey("test", "auto", "web", 5);
|
||||
const k2 = computeCacheKey("test", "auto", "web", 10);
|
||||
assert.notEqual(k1, k2);
|
||||
});
|
||||
|
||||
// ─── Cache + Coalescing Tests ────────────────────────────────
|
||||
|
||||
test("getOrCoalesce caches and returns on second call", async () => {
|
||||
let callCount = 0;
|
||||
const key = "test-cache-hit-" + Date.now();
|
||||
|
||||
const r1 = await getOrCoalesce(key, 60_000, async () => {
|
||||
callCount++;
|
||||
return { value: 42 };
|
||||
});
|
||||
assert.equal(r1.cached, false);
|
||||
assert.deepEqual(r1.data, { value: 42 });
|
||||
|
||||
const r2 = await getOrCoalesce(key, 60_000, async () => {
|
||||
callCount++;
|
||||
return { value: 99 };
|
||||
});
|
||||
assert.equal(r2.cached, true);
|
||||
assert.deepEqual(r2.data, { value: 42 }); // original value, not 99
|
||||
assert.equal(callCount, 1); // fetchFn called only once
|
||||
});
|
||||
|
||||
test("getOrCoalesce coalesces concurrent requests", async () => {
|
||||
let callCount = 0;
|
||||
const key = "test-coalesce-" + Date.now();
|
||||
|
||||
const fetchFn = async () => {
|
||||
callCount++;
|
||||
await new Promise((r) => setTimeout(r, 50)); // simulate async
|
||||
return { value: "coalesced" };
|
||||
};
|
||||
|
||||
// Launch 3 concurrent requests with the same key
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
getOrCoalesce(key, 60_000, fetchFn),
|
||||
getOrCoalesce(key, 60_000, fetchFn),
|
||||
getOrCoalesce(key, 60_000, fetchFn),
|
||||
]);
|
||||
|
||||
assert.equal(callCount, 1); // Only one fetch executed
|
||||
assert.deepEqual(r1.data, { value: "coalesced" });
|
||||
assert.deepEqual(r2.data, { value: "coalesced" });
|
||||
assert.deepEqual(r3.data, { value: "coalesced" });
|
||||
});
|
||||
|
||||
test("getOrCoalesce respects TTL=0 (no caching)", async () => {
|
||||
let callCount = 0;
|
||||
const key = "test-no-cache-" + Date.now();
|
||||
|
||||
await getOrCoalesce(key, 0, async () => {
|
||||
callCount++;
|
||||
return { value: 1 };
|
||||
});
|
||||
await getOrCoalesce(key, 0, async () => {
|
||||
callCount++;
|
||||
return { value: 2 };
|
||||
});
|
||||
|
||||
assert.equal(callCount, 2); // Both calls executed
|
||||
});
|
||||
|
||||
test("getCacheStats returns valid stats", () => {
|
||||
const stats = getCacheStats();
|
||||
assert.equal(typeof stats.size, "number");
|
||||
assert.equal(typeof stats.hits, "number");
|
||||
assert.equal(typeof stats.misses, "number");
|
||||
});
|
||||
|
||||
test("SEARCH_CACHE_DEFAULT_TTL_MS is positive", () => {
|
||||
assert.ok(SEARCH_CACHE_DEFAULT_TTL_MS > 0);
|
||||
});
|
||||
|
||||
// ─── Validation Schema Tests ────────────────────────────────
|
||||
|
||||
test("v1SearchSchema validates correct input", async () => {
|
||||
const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
const result = v1SearchSchema.safeParse({
|
||||
query: "test query",
|
||||
provider: "serper-search",
|
||||
max_results: 10,
|
||||
search_type: "web",
|
||||
});
|
||||
assert.ok(result.success);
|
||||
assert.equal(result.data.query, "test query");
|
||||
assert.equal(result.data.provider, "serper-search");
|
||||
assert.equal(result.data.max_results, 10);
|
||||
});
|
||||
|
||||
test("v1SearchSchema rejects empty query", async () => {
|
||||
const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
const result = v1SearchSchema.safeParse({ query: "" });
|
||||
assert.ok(!result.success);
|
||||
});
|
||||
|
||||
test("v1SearchSchema rejects query over 500 chars", async () => {
|
||||
const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
const result = v1SearchSchema.safeParse({ query: "a".repeat(501) });
|
||||
assert.ok(!result.success);
|
||||
});
|
||||
|
||||
test("v1SearchSchema rejects invalid provider", async () => {
|
||||
const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
const result = v1SearchSchema.safeParse({ query: "test", provider: "google" });
|
||||
assert.ok(!result.success);
|
||||
});
|
||||
|
||||
test("v1SearchSchema accepts tavily provider", async () => {
|
||||
const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
const result = v1SearchSchema.safeParse({ query: "test", provider: "tavily-search" });
|
||||
assert.ok(result.success);
|
||||
assert.equal(result.data.provider, "tavily-search");
|
||||
});
|
||||
|
||||
test("v1SearchSchema applies defaults", async () => {
|
||||
const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
const result = v1SearchSchema.safeParse({ query: "test" });
|
||||
assert.ok(result.success);
|
||||
assert.equal(result.data.max_results, 5);
|
||||
assert.equal(result.data.search_type, "web");
|
||||
assert.equal(result.data.provider, undefined);
|
||||
});
|
||||
|
||||
test("v1SearchSchema allows unknown fields (forward compat)", async () => {
|
||||
const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
const result = v1SearchSchema.safeParse({
|
||||
query: "test",
|
||||
future_field: true,
|
||||
});
|
||||
assert.ok(result.success);
|
||||
});
|
||||