diff --git a/CHANGELOG.md b/CHANGELOG.md index c43e392488..1b46d4e569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## [Unreleased] +### ✨ 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 diff --git a/README.md b/README.md index a9ce1b8202..6e5db96813 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 6f4eacb389..9a15c0eb11 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -115,6 +115,7 @@ export const REGISTRY: Record = { }, 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" }, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index f6ee69fba7..265f9ff2ae 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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, diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index c6d878008d..369c006530 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -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> { + 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 = {}; + 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"; diff --git a/src/lib/usageDb.ts b/src/lib/usageDb.ts index 0934c705ae..2af941726c 100644 --- a/src/lib/usageDb.ts +++ b/src/lib/usageDb.ts @@ -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"; diff --git a/src/shared/constants/pricing.ts b/src/shared/constants/pricing.ts index 52d871d724..ce2838eef9 100644 --- a/src/shared/constants/pricing.ts +++ b/src/shared/constants/pricing.ts @@ -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": {