mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat: Implement historical model latency and success rate tracking for auto-combo routing and update Claude and Deepseek pricing and model registrations.
This commit is contained in:
30
CHANGELOG.md
30
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
|
||||
|
||||
23
README.md
23
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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user