synapse-gateway 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +385 -0
- package/bin/synapse.js +242 -0
- package/docs/PLAN.md +1723 -0
- package/docs/PRD.md +1799 -0
- package/drizzle.config.ts +12 -0
- package/next.config.ts +8 -0
- package/package.json +82 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/analytics/cost/route.ts +13 -0
- package/src/app/api/analytics/usage/route.ts +16 -0
- package/src/app/api/auth/login/route.ts +42 -0
- package/src/app/api/cache/route.ts +19 -0
- package/src/app/api/dashboard/route.ts +35 -0
- package/src/app/api/distill/route.ts +10 -0
- package/src/app/api/events/route.ts +54 -0
- package/src/app/api/health/route.ts +10 -0
- package/src/app/api/intelligence/forensics/route.ts +23 -0
- package/src/app/api/intelligence/neural-router/route.ts +23 -0
- package/src/app/api/keys/route.ts +34 -0
- package/src/app/api/mcp/route.ts +49 -0
- package/src/app/api/memory/route.ts +10 -0
- package/src/app/api/models/benchmark/route.ts +13 -0
- package/src/app/api/models/route.ts +39 -0
- package/src/app/api/namespace/route.ts +25 -0
- package/src/app/api/plugins/route.ts +41 -0
- package/src/app/api/providers/accounts/route.ts +91 -0
- package/src/app/api/providers/fetch-models/route.ts +52 -0
- package/src/app/api/providers/health/route.ts +10 -0
- package/src/app/api/providers/route.ts +46 -0
- package/src/app/api/routes/pipeline/route.ts +20 -0
- package/src/app/api/settings/route.ts +33 -0
- package/src/app/api/skills/route.ts +39 -0
- package/src/app/api/v1/chat/completions/route.ts +156 -0
- package/src/app/api/v1/models/route.ts +44 -0
- package/src/app/dashboard/intelligence/loading.tsx +14 -0
- package/src/app/dashboard/intelligence/page.tsx +125 -0
- package/src/app/dashboard/layout.tsx +143 -0
- package/src/app/dashboard/loading.tsx +17 -0
- package/src/app/dashboard/memory/loading.tsx +15 -0
- package/src/app/dashboard/memory/page.tsx +71 -0
- package/src/app/dashboard/models/loading.tsx +13 -0
- package/src/app/dashboard/models/page.tsx +107 -0
- package/src/app/dashboard/page.tsx +183 -0
- package/src/app/dashboard/playground/loading.tsx +17 -0
- package/src/app/dashboard/playground/page.tsx +212 -0
- package/src/app/dashboard/providers/loading.tsx +15 -0
- package/src/app/dashboard/providers/page.tsx +248 -0
- package/src/app/dashboard/routes/loading.tsx +15 -0
- package/src/app/dashboard/routes/page.tsx +72 -0
- package/src/app/dashboard/settings/loading.tsx +20 -0
- package/src/app/dashboard/settings/page.tsx +208 -0
- package/src/app/dashboard/skills/loading.tsx +26 -0
- package/src/app/dashboard/skills/page.tsx +137 -0
- package/src/app/dashboard/vault/loading.tsx +18 -0
- package/src/app/dashboard/vault/page.tsx +139 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +59 -0
- package/src/app/layout.tsx +32 -0
- package/src/app/login/page.tsx +87 -0
- package/src/app/page.tsx +5 -0
- package/src/components/ui/badge.tsx +32 -0
- package/src/components/ui/button.tsx +38 -0
- package/src/components/ui/card.tsx +50 -0
- package/src/components/ui/error-boundary.tsx +47 -0
- package/src/components/ui/index.ts +11 -0
- package/src/components/ui/input.tsx +26 -0
- package/src/components/ui/select.tsx +24 -0
- package/src/components/ui/skeleton.tsx +53 -0
- package/src/components/ui/toast.tsx +51 -0
- package/src/instrumentation.ts +6 -0
- package/src/lib/__tests__/auth.test.ts +42 -0
- package/src/lib/__tests__/format.test.ts +94 -0
- package/src/lib/__tests__/namespace.test.ts +102 -0
- package/src/lib/__tests__/squeezer.test.ts +93 -0
- package/src/lib/__tests__/utils.test.ts +28 -0
- package/src/lib/analytics/index.ts +187 -0
- package/src/lib/auth/guard.tsx +71 -0
- package/src/lib/auth/index.ts +105 -0
- package/src/lib/auth/middleware.ts +64 -0
- package/src/lib/benchmark/index.ts +137 -0
- package/src/lib/bootstrap.ts +122 -0
- package/src/lib/cache/index.ts +1 -0
- package/src/lib/cache/semantic.ts +211 -0
- package/src/lib/config/defaults.ts +61 -0
- package/src/lib/config/index.ts +72 -0
- package/src/lib/config/schema.ts +63 -0
- package/src/lib/db/index.ts +22 -0
- package/src/lib/db/migrate.ts +327 -0
- package/src/lib/db/schema.ts +303 -0
- package/src/lib/distiller/index.ts +331 -0
- package/src/lib/fallback/index.ts +153 -0
- package/src/lib/forensics/index.ts +188 -0
- package/src/lib/format/anthropic.ts +139 -0
- package/src/lib/format/gemini.ts +130 -0
- package/src/lib/format/index.ts +3 -0
- package/src/lib/format/openai.ts +78 -0
- package/src/lib/health/index.ts +158 -0
- package/src/lib/mcp/builtin.ts +83 -0
- package/src/lib/mcp/index.ts +1 -0
- package/src/lib/mcp/registry.ts +49 -0
- package/src/lib/memory/index.ts +3 -0
- package/src/lib/memory/store.ts +215 -0
- package/src/lib/memory/types.ts +56 -0
- package/src/lib/namespace/index.ts +89 -0
- package/src/lib/neural/features.ts +74 -0
- package/src/lib/neural/index.ts +85 -0
- package/src/lib/neural/strategies.ts +124 -0
- package/src/lib/pipeline/index.ts +84 -0
- package/src/lib/pipeline/types.ts +77 -0
- package/src/lib/plugins/builtin.ts +79 -0
- package/src/lib/plugins/index.ts +65 -0
- package/src/lib/prediction/index.ts +113 -0
- package/src/lib/providers/api-key/anthropic.ts +96 -0
- package/src/lib/providers/api-key/deepseek.ts +108 -0
- package/src/lib/providers/api-key/gemini.ts +112 -0
- package/src/lib/providers/api-key/openai.ts +122 -0
- package/src/lib/providers/api-key/openrouter.ts +112 -0
- package/src/lib/providers/base-adapter.ts +122 -0
- package/src/lib/providers/registry.ts +46 -0
- package/src/lib/providers/types.ts +121 -0
- package/src/lib/router/index.ts +82 -0
- package/src/lib/skills/forge.ts +57 -0
- package/src/lib/skills/index.ts +3 -0
- package/src/lib/skills/registry.ts +195 -0
- package/src/lib/skills/types.ts +44 -0
- package/src/lib/squeezer/index.ts +158 -0
- package/src/lib/utils/cn.ts +6 -0
- package/src/lib/utils/logger.ts +16 -0
- package/src/middleware.ts +60 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { db } from '../db'
|
|
2
|
+
import { episodes, knowledge, proceduralRules, distillationLog } from '../db/schema'
|
|
3
|
+
import { desc, sql } from 'drizzle-orm'
|
|
4
|
+
import { logger } from '../utils/logger'
|
|
5
|
+
import { v4 as uuid } from 'uuid'
|
|
6
|
+
|
|
7
|
+
interface Pattern {
|
|
8
|
+
type: 'time_based' | 'model_preference' | 'cost_optimization' | 'quality_signal' | 'language_routing'
|
|
9
|
+
description: string
|
|
10
|
+
confidence: number
|
|
11
|
+
evidence: number
|
|
12
|
+
data: Record<string, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface DistillationResult {
|
|
16
|
+
id: string
|
|
17
|
+
patternsExtracted: number
|
|
18
|
+
rulesGenerated: number
|
|
19
|
+
knowledgeUpdated: number
|
|
20
|
+
episodesProcessed: number
|
|
21
|
+
durationMs: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class ExperienceDistiller {
|
|
25
|
+
private intervalHandle: ReturnType<typeof setInterval> | null = null
|
|
26
|
+
|
|
27
|
+
start(intervalMs = 6 * 60 * 60 * 1000) {
|
|
28
|
+
if (this.intervalHandle) return
|
|
29
|
+
|
|
30
|
+
logger.info({ intervalHours: intervalMs / 3600000 }, 'Experience Distiller started')
|
|
31
|
+
|
|
32
|
+
this.intervalHandle = setInterval(async () => {
|
|
33
|
+
try {
|
|
34
|
+
await this.run()
|
|
35
|
+
} catch (err) {
|
|
36
|
+
logger.error({ error: (err as Error).message }, 'Distillation run failed')
|
|
37
|
+
}
|
|
38
|
+
}, intervalMs)
|
|
39
|
+
|
|
40
|
+
setTimeout(() => this.run(), 5000)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
stop() {
|
|
44
|
+
if (this.intervalHandle) {
|
|
45
|
+
clearInterval(this.intervalHandle)
|
|
46
|
+
this.intervalHandle = null
|
|
47
|
+
logger.info('Experience Distiller stopped')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async run(): Promise<DistillationResult> {
|
|
52
|
+
const start = Date.now()
|
|
53
|
+
const runId = uuid()
|
|
54
|
+
logger.info({ runId }, 'Starting distillation run')
|
|
55
|
+
|
|
56
|
+
let episodesProcessed = 0
|
|
57
|
+
let patternsExtracted = 0
|
|
58
|
+
let rulesGenerated = 0
|
|
59
|
+
let knowledgeUpdated = 0
|
|
60
|
+
|
|
61
|
+
const recentEpisodes = await this.getRecentEpisodes()
|
|
62
|
+
episodesProcessed = recentEpisodes.length
|
|
63
|
+
|
|
64
|
+
if (episodesProcessed === 0) {
|
|
65
|
+
logger.info('No episodes to distill')
|
|
66
|
+
return this.saveRun(runId, 0, 0, 0, 0, Date.now() - start)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const patterns = this.extractPatterns(recentEpisodes)
|
|
70
|
+
patternsExtracted = patterns.length
|
|
71
|
+
|
|
72
|
+
const rules = this.generateRules(patterns)
|
|
73
|
+
rulesGenerated = rules.length
|
|
74
|
+
|
|
75
|
+
for (const rule of rules) {
|
|
76
|
+
try {
|
|
77
|
+
await db.insert(proceduralRules).values({
|
|
78
|
+
id: uuid(),
|
|
79
|
+
category: rule.category,
|
|
80
|
+
condition: rule.condition,
|
|
81
|
+
action: rule.action,
|
|
82
|
+
confidence: rule.confidence,
|
|
83
|
+
priority: rule.priority,
|
|
84
|
+
status: 'auto_generated',
|
|
85
|
+
})
|
|
86
|
+
} catch {
|
|
87
|
+
// ignore duplicate
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const facts = this.extractKnowledge(recentEpisodes)
|
|
92
|
+
knowledgeUpdated = facts.length
|
|
93
|
+
|
|
94
|
+
for (const fact of facts) {
|
|
95
|
+
try {
|
|
96
|
+
const existing = await db.select().from(knowledge)
|
|
97
|
+
.where(sql`${knowledge.category} = ${fact.category} AND ${knowledge.subject} = ${fact.subject} AND ${knowledge.key} = ${fact.key}`)
|
|
98
|
+
.limit(1)
|
|
99
|
+
|
|
100
|
+
if (existing.length > 0) {
|
|
101
|
+
await db.update(knowledge).set({
|
|
102
|
+
value: fact.value,
|
|
103
|
+
confidence: fact.confidence,
|
|
104
|
+
lastUpdated: new Date().toISOString(),
|
|
105
|
+
sampleCount: sql`${knowledge.sampleCount} + 1`,
|
|
106
|
+
}).where(eq(knowledge.id, existing[0].id))
|
|
107
|
+
} else {
|
|
108
|
+
await db.insert(knowledge).values({
|
|
109
|
+
id: uuid(),
|
|
110
|
+
category: fact.category,
|
|
111
|
+
subject: fact.subject,
|
|
112
|
+
key: fact.key,
|
|
113
|
+
value: fact.value,
|
|
114
|
+
confidence: fact.confidence,
|
|
115
|
+
source: 'distiller',
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// ignore
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const durationMs = Date.now() - start
|
|
124
|
+
logger.info({ runId, patternsExtracted, rulesGenerated, knowledgeUpdated, episodesProcessed, durationMs }, 'Distillation complete')
|
|
125
|
+
|
|
126
|
+
return this.saveRun(runId, patternsExtracted, rulesGenerated, knowledgeUpdated, episodesProcessed, durationMs)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async getRecentEpisodes() {
|
|
130
|
+
try {
|
|
131
|
+
const cutoff = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString()
|
|
132
|
+
return await db.select().from(episodes)
|
|
133
|
+
.where(sql`${episodes.createdAt} > ${cutoff}`)
|
|
134
|
+
.orderBy(desc(episodes.createdAt))
|
|
135
|
+
.limit(1000)
|
|
136
|
+
} catch {
|
|
137
|
+
return []
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private extractPatterns(episodes: Array<Record<string, unknown>>): Pattern[] {
|
|
142
|
+
const patterns: Pattern[] = []
|
|
143
|
+
|
|
144
|
+
const byModel = new Map<string, number>()
|
|
145
|
+
const byProvider = new Map<string, { count: number; totalLatency: number; totalQuality: number }>()
|
|
146
|
+
|
|
147
|
+
for (const ep of episodes) {
|
|
148
|
+
const model = String(ep.model || 'unknown')
|
|
149
|
+
byModel.set(model, (byModel.get(model) || 0) + 1)
|
|
150
|
+
|
|
151
|
+
const provider = String(ep.provider || 'unknown')
|
|
152
|
+
const existing = byProvider.get(provider) || { count: 0, totalLatency: 0, totalQuality: 0 }
|
|
153
|
+
existing.count++
|
|
154
|
+
existing.totalLatency += Number(ep.latencyMs || 0)
|
|
155
|
+
existing.totalQuality += Number(ep.qualityScore || 0)
|
|
156
|
+
byProvider.set(provider, existing)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const total = episodes.length
|
|
160
|
+
for (const [model, count] of byModel) {
|
|
161
|
+
const ratio = count / total
|
|
162
|
+
if (ratio > 0.4) {
|
|
163
|
+
patterns.push({
|
|
164
|
+
type: 'model_preference',
|
|
165
|
+
description: `${model} is used ${Math.round(ratio * 100)}% of the time`,
|
|
166
|
+
confidence: Math.min(ratio, 0.95),
|
|
167
|
+
evidence: count,
|
|
168
|
+
data: { model, ratio, count },
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const [provider, stats] of byProvider) {
|
|
174
|
+
if (stats.count < 5) continue
|
|
175
|
+
const avgLatency = stats.totalLatency / stats.count
|
|
176
|
+
const avgQuality = stats.totalQuality / stats.count
|
|
177
|
+
|
|
178
|
+
if (avgLatency < 500 && avgQuality > 4) {
|
|
179
|
+
patterns.push({
|
|
180
|
+
type: 'quality_signal',
|
|
181
|
+
description: `${provider} performs well: avg ${Math.round(avgLatency)}ms latency, ${avgQuality.toFixed(1)} quality`,
|
|
182
|
+
confidence: 0.85,
|
|
183
|
+
evidence: stats.count,
|
|
184
|
+
data: { provider, avgLatency, avgQuality },
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return patterns
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private generateRules(patterns: Pattern[]): Array<{
|
|
193
|
+
category: string
|
|
194
|
+
condition: string
|
|
195
|
+
action: string
|
|
196
|
+
priority: number
|
|
197
|
+
confidence: number
|
|
198
|
+
}> {
|
|
199
|
+
const rules: Array<{
|
|
200
|
+
category: string
|
|
201
|
+
condition: string
|
|
202
|
+
action: string
|
|
203
|
+
priority: number
|
|
204
|
+
confidence: number
|
|
205
|
+
}> = []
|
|
206
|
+
|
|
207
|
+
for (const p of patterns) {
|
|
208
|
+
switch (p.type) {
|
|
209
|
+
case 'model_preference': {
|
|
210
|
+
const model = (p.data as { model: string }).model
|
|
211
|
+
rules.push({
|
|
212
|
+
category: 'routing',
|
|
213
|
+
condition: `model:${model}`,
|
|
214
|
+
action: 'increase_priority',
|
|
215
|
+
priority: 8,
|
|
216
|
+
confidence: p.confidence,
|
|
217
|
+
})
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
case 'quality_signal': {
|
|
221
|
+
const provider = (p.data as { provider: string }).provider
|
|
222
|
+
rules.push({
|
|
223
|
+
category: 'routing',
|
|
224
|
+
condition: `provider:${provider}`,
|
|
225
|
+
action: 'prefer_for_quality',
|
|
226
|
+
priority: 7,
|
|
227
|
+
confidence: p.confidence,
|
|
228
|
+
})
|
|
229
|
+
break
|
|
230
|
+
}
|
|
231
|
+
case 'cost_optimization': {
|
|
232
|
+
rules.push({
|
|
233
|
+
category: 'cost',
|
|
234
|
+
condition: 'high_usage_period',
|
|
235
|
+
action: 'prefer_low_cost_provider',
|
|
236
|
+
priority: 5,
|
|
237
|
+
confidence: p.confidence * 0.8,
|
|
238
|
+
})
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return rules
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private extractKnowledge(episodes: Array<Record<string, unknown>>): Array<{
|
|
248
|
+
category: string
|
|
249
|
+
subject: string
|
|
250
|
+
key: string
|
|
251
|
+
value: string
|
|
252
|
+
confidence: number
|
|
253
|
+
}> {
|
|
254
|
+
const facts: Array<{
|
|
255
|
+
category: string
|
|
256
|
+
subject: string
|
|
257
|
+
key: string
|
|
258
|
+
value: string
|
|
259
|
+
confidence: number
|
|
260
|
+
}> = []
|
|
261
|
+
|
|
262
|
+
const modelStats = new Map<string, { count: number; avgLatency: number; avgCost: number }>()
|
|
263
|
+
|
|
264
|
+
for (const ep of episodes) {
|
|
265
|
+
const model = String(ep.model || 'unknown')
|
|
266
|
+
const existing = modelStats.get(model) || { count: 0, avgLatency: 0, avgCost: 0 }
|
|
267
|
+
existing.count++
|
|
268
|
+
existing.avgLatency = (existing.avgLatency * (existing.count - 1) + Number(ep.latencyMs || 0)) / existing.count
|
|
269
|
+
existing.avgCost = (existing.avgCost * (existing.count - 1) + Number(ep.cost || 0)) / existing.count
|
|
270
|
+
modelStats.set(model, existing)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const [model, stats] of modelStats) {
|
|
274
|
+
if (stats.count >= 5) {
|
|
275
|
+
facts.push({
|
|
276
|
+
category: 'performance',
|
|
277
|
+
subject: model,
|
|
278
|
+
key: 'avg_latency',
|
|
279
|
+
value: `${Math.round(stats.avgLatency)}ms over ${stats.count} requests`,
|
|
280
|
+
confidence: Math.min(stats.count / 100, 0.9),
|
|
281
|
+
})
|
|
282
|
+
facts.push({
|
|
283
|
+
category: 'cost',
|
|
284
|
+
subject: model,
|
|
285
|
+
key: 'avg_cost',
|
|
286
|
+
value: `$${stats.avgCost.toFixed(4)} per request over ${stats.count} requests`,
|
|
287
|
+
confidence: Math.min(stats.count / 100, 0.9),
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return facts
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async saveRun(
|
|
296
|
+
runId: string,
|
|
297
|
+
patternsExtracted: number,
|
|
298
|
+
rulesGenerated: number,
|
|
299
|
+
knowledgeUpdated: number,
|
|
300
|
+
episodesProcessed: number,
|
|
301
|
+
durationMs: number,
|
|
302
|
+
): Promise<DistillationResult> {
|
|
303
|
+
try {
|
|
304
|
+
await db.insert(distillationLog).values({
|
|
305
|
+
id: runId,
|
|
306
|
+
startedAt: new Date(Date.now() - durationMs).toISOString(),
|
|
307
|
+
completedAt: new Date().toISOString(),
|
|
308
|
+
episodesProcessed,
|
|
309
|
+
patternsFound: patternsExtracted,
|
|
310
|
+
rulesGenerated,
|
|
311
|
+
knowledgeEntriesAdded: knowledgeUpdated,
|
|
312
|
+
status: 'completed',
|
|
313
|
+
})
|
|
314
|
+
} catch {
|
|
315
|
+
// table might not exist
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
id: runId,
|
|
320
|
+
patternsExtracted,
|
|
321
|
+
rulesGenerated,
|
|
322
|
+
knowledgeUpdated,
|
|
323
|
+
episodesProcessed,
|
|
324
|
+
durationMs,
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
import { eq } from 'drizzle-orm'
|
|
330
|
+
|
|
331
|
+
export const experienceDistiller = new ExperienceDistiller()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { NormalizedRequest, NormalizedResponse, ProviderAccount, ProviderAdapter, HealthStatusType } from '../providers/types'
|
|
2
|
+
import { logger } from '../utils/logger'
|
|
3
|
+
|
|
4
|
+
export interface FallbackOptions {
|
|
5
|
+
maxRetries: number
|
|
6
|
+
retryDelayMs: number
|
|
7
|
+
timeoutMs: number
|
|
8
|
+
retryOnStatusCodes: number[]
|
|
9
|
+
retryOnErrors: string[]
|
|
10
|
+
healthThreshold: HealthStatusType
|
|
11
|
+
preferCostOverLatency: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_FALLBACK_OPTIONS: FallbackOptions = {
|
|
15
|
+
maxRetries: 3,
|
|
16
|
+
retryDelayMs: 1000,
|
|
17
|
+
timeoutMs: 30000,
|
|
18
|
+
retryOnStatusCodes: [429, 500, 502, 503, 504],
|
|
19
|
+
retryOnErrors: ['timeout', 'rate_limit', 'overloaded', 'context_length_exceeded'],
|
|
20
|
+
healthThreshold: 'healthy',
|
|
21
|
+
preferCostOverLatency: false,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface FallbackCandidate {
|
|
25
|
+
adapter: ProviderAdapter
|
|
26
|
+
account: ProviderAccount
|
|
27
|
+
priority: number
|
|
28
|
+
cost: number
|
|
29
|
+
latency: number
|
|
30
|
+
health: HealthStatusType
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class FallbackEngine {
|
|
34
|
+
private options: FallbackOptions
|
|
35
|
+
|
|
36
|
+
constructor(options?: Partial<FallbackOptions>) {
|
|
37
|
+
this.options = { ...DEFAULT_FALLBACK_OPTIONS, ...options }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async executeWithFallback(
|
|
41
|
+
req: NormalizedRequest,
|
|
42
|
+
candidates: FallbackCandidate[],
|
|
43
|
+
): Promise<{
|
|
44
|
+
response: NormalizedResponse
|
|
45
|
+
provider: string
|
|
46
|
+
model: string
|
|
47
|
+
fallbackUsed: boolean
|
|
48
|
+
fallbackChain: string[]
|
|
49
|
+
attempts: number
|
|
50
|
+
}> {
|
|
51
|
+
const sorted = this.rankCandidates(candidates)
|
|
52
|
+
const fallbackChain: string[] = []
|
|
53
|
+
|
|
54
|
+
for (let attempt = 0; attempt < sorted.length; attempt++) {
|
|
55
|
+
const candidate = sorted[attempt]
|
|
56
|
+
const { adapter, account } = candidate
|
|
57
|
+
|
|
58
|
+
if (!account.enabled) {
|
|
59
|
+
fallbackChain.push(`${adapter.info.name}: account disabled`)
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!this.isHealthyEnough(candidate.health)) {
|
|
64
|
+
fallbackChain.push(`${adapter.info.name}: unhealthy (${candidate.health})`)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await this.executeWithTimeout(adapter, req, account)
|
|
70
|
+
return {
|
|
71
|
+
response,
|
|
72
|
+
provider: adapter.info.name,
|
|
73
|
+
model: req.model,
|
|
74
|
+
fallbackUsed: attempt > 0,
|
|
75
|
+
fallbackChain,
|
|
76
|
+
attempts: attempt + 1,
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const errorMsg = (err as Error).message
|
|
80
|
+
fallbackChain.push(`${adapter.info.name}: ${errorMsg}`)
|
|
81
|
+
logger.warn(
|
|
82
|
+
{ provider: adapter.info.name, attempt: attempt + 1, error: errorMsg, model: req.model },
|
|
83
|
+
'Fallback: provider failed',
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if (this.isRetryableError(errorMsg) && attempt < this.options.maxRetries - 1) {
|
|
87
|
+
const delay = this.options.retryDelayMs * Math.pow(2, attempt)
|
|
88
|
+
await this.sleep(delay)
|
|
89
|
+
attempt--
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new FallbackExhaustedError(
|
|
95
|
+
`All ${sorted.length} providers failed for model "${req.model}"`,
|
|
96
|
+
fallbackChain,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private rankCandidates(candidates: FallbackCandidate[]): FallbackCandidate[] {
|
|
101
|
+
return [...candidates].sort((a, b) => {
|
|
102
|
+
const healthOrder: Record<HealthStatusType, number> = { healthy: 0, degraded: 1, down: 2, disabled: 3 }
|
|
103
|
+
const healthDiff = (healthOrder[a.health] ?? 99) - (healthOrder[b.health] ?? 99)
|
|
104
|
+
if (healthDiff !== 0) return healthDiff
|
|
105
|
+
|
|
106
|
+
const priorityDiff = a.priority - b.priority
|
|
107
|
+
if (priorityDiff !== 0) return priorityDiff
|
|
108
|
+
|
|
109
|
+
if (this.options.preferCostOverLatency) {
|
|
110
|
+
return a.cost - b.cost
|
|
111
|
+
}
|
|
112
|
+
return a.latency - b.latency
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private isHealthyEnough(health: HealthStatusType): boolean {
|
|
117
|
+
const order: Record<HealthStatusType, number> = { healthy: 0, degraded: 1, down: 2, disabled: 3 }
|
|
118
|
+
const threshold = order[this.options.healthThreshold] ?? 0
|
|
119
|
+
return (order[health] ?? 99) <= threshold
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private isRetryableError(errorMsg: string): boolean {
|
|
123
|
+
const lower = errorMsg.toLowerCase()
|
|
124
|
+
return this.options.retryOnErrors.some((e) => lower.includes(e))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async executeWithTimeout(
|
|
128
|
+
adapter: ProviderAdapter,
|
|
129
|
+
req: NormalizedRequest,
|
|
130
|
+
account: ProviderAccount,
|
|
131
|
+
): Promise<NormalizedResponse> {
|
|
132
|
+
return Promise.race([
|
|
133
|
+
adapter.chatCompletion(req, account),
|
|
134
|
+
new Promise<never>((_, reject) =>
|
|
135
|
+
setTimeout(() => reject(new Error(`Timeout after ${this.options.timeoutMs}ms`)), this.options.timeoutMs),
|
|
136
|
+
),
|
|
137
|
+
])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private sleep(ms: number): Promise<void> {
|
|
141
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class FallbackExhaustedError extends Error {
|
|
146
|
+
fallbackChain: string[]
|
|
147
|
+
|
|
148
|
+
constructor(message: string, fallbackChain: string[]) {
|
|
149
|
+
super(message)
|
|
150
|
+
this.name = 'FallbackExhaustedError'
|
|
151
|
+
this.fallbackChain = fallbackChain
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { db } from '../db'
|
|
2
|
+
import { requestLogs } from '../db/schema'
|
|
3
|
+
import { eq, desc, sql, and } from 'drizzle-orm'
|
|
4
|
+
import type { NormalizedRequest, NormalizedResponse } from '../providers/types'
|
|
5
|
+
import { logger } from '../utils/logger'
|
|
6
|
+
import { v4 as uuid } from 'uuid'
|
|
7
|
+
|
|
8
|
+
export interface ForensicReport {
|
|
9
|
+
requestId: string
|
|
10
|
+
model: string
|
|
11
|
+
provider: string
|
|
12
|
+
inputTokens: number
|
|
13
|
+
outputTokens: number
|
|
14
|
+
tokensSaved: number
|
|
15
|
+
cost: number
|
|
16
|
+
latencyMs: number
|
|
17
|
+
statusCode: number | null
|
|
18
|
+
fallbackUsed: boolean
|
|
19
|
+
cached: boolean
|
|
20
|
+
skillId: string | null
|
|
21
|
+
errorMessage: string | null
|
|
22
|
+
timeline: TimelineEvent[]
|
|
23
|
+
analysis: ForensicAnalysis
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TimelineEvent {
|
|
27
|
+
timestamp: string
|
|
28
|
+
event: string
|
|
29
|
+
durationMs: number
|
|
30
|
+
metadata?: Record<string, unknown>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ForensicAnalysis {
|
|
34
|
+
verdict: 'success' | 'degraded' | 'failure' | 'timeout' | 'rate_limited'
|
|
35
|
+
rootCause?: string
|
|
36
|
+
suggestions: string[]
|
|
37
|
+
cacheHit: boolean
|
|
38
|
+
fallbackTriggered: boolean
|
|
39
|
+
latencyBreakdown: {
|
|
40
|
+
total: number
|
|
41
|
+
network: number
|
|
42
|
+
provider: number
|
|
43
|
+
overhead: number
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class ForensicsEngine {
|
|
48
|
+
async analyzeRequest(requestId: string): Promise<ForensicReport | null> {
|
|
49
|
+
try {
|
|
50
|
+
const rows = await db.select().from(requestLogs)
|
|
51
|
+
.where(eq(requestLogs.requestId, requestId))
|
|
52
|
+
.limit(1)
|
|
53
|
+
|
|
54
|
+
if (rows.length === 0) return null
|
|
55
|
+
|
|
56
|
+
const row = rows[0]
|
|
57
|
+
const analysis = this.analyze(row)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
requestId: row.requestId,
|
|
61
|
+
model: row.model,
|
|
62
|
+
provider: row.providerId || 'unknown',
|
|
63
|
+
inputTokens: row.inputTokens || 0,
|
|
64
|
+
outputTokens: row.outputTokens || 0,
|
|
65
|
+
tokensSaved: row.tokensSaved || 0,
|
|
66
|
+
cost: Number(row.cost || 0),
|
|
67
|
+
latencyMs: row.latencyMs || 0,
|
|
68
|
+
statusCode: row.statusCode,
|
|
69
|
+
fallbackUsed: !!row.fallbackUsed,
|
|
70
|
+
cached: !!row.cached,
|
|
71
|
+
skillId: row.skillId || null,
|
|
72
|
+
errorMessage: row.errorMessage,
|
|
73
|
+
timeline: this.buildTimeline(row),
|
|
74
|
+
analysis,
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
logger.warn({ error: (err as Error).message }, 'Forensic analysis failed')
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async getRecentFailures(limit = 20) {
|
|
83
|
+
try {
|
|
84
|
+
return await db.select().from(requestLogs)
|
|
85
|
+
.where(sql`${requestLogs.statusCode} >= 400 OR ${requestLogs.errorMessage} IS NOT NULL`)
|
|
86
|
+
.orderBy(desc(requestLogs.createdAt))
|
|
87
|
+
.limit(limit)
|
|
88
|
+
} catch {
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getSlowRequests(thresholdMs = 3000, limit = 20) {
|
|
94
|
+
try {
|
|
95
|
+
return await db.select().from(requestLogs)
|
|
96
|
+
.where(sql`${requestLogs.latencyMs} > ${thresholdMs}`)
|
|
97
|
+
.orderBy(desc(requestLogs.latencyMs))
|
|
98
|
+
.limit(limit)
|
|
99
|
+
} catch {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private analyze(row: Record<string, unknown>): ForensicAnalysis {
|
|
105
|
+
const statusCode = row.status_code as number | null
|
|
106
|
+
const latencyMs = (row.latency_ms as number) || 0
|
|
107
|
+
const errorMessage = row.error_message as string | null
|
|
108
|
+
const cached = !!(row.cached as number)
|
|
109
|
+
const fallbackUsed = !!(row.fallback_used as number)
|
|
110
|
+
|
|
111
|
+
let verdict: ForensicAnalysis['verdict'] = 'success'
|
|
112
|
+
const suggestions: string[] = []
|
|
113
|
+
let rootCause: string | undefined
|
|
114
|
+
|
|
115
|
+
if (statusCode && statusCode >= 500) {
|
|
116
|
+
verdict = 'failure'
|
|
117
|
+
rootCause = 'provider_error'
|
|
118
|
+
suggestions.push('Check provider health status')
|
|
119
|
+
suggestions.push('Review provider error logs')
|
|
120
|
+
} else if (statusCode === 429) {
|
|
121
|
+
verdict = 'rate_limited'
|
|
122
|
+
rootCause = 'rate_limit'
|
|
123
|
+
suggestions.push('Reduce request rate')
|
|
124
|
+
suggestions.push('Add more provider accounts')
|
|
125
|
+
suggestions.push('Enable request queuing')
|
|
126
|
+
} else if (statusCode && statusCode >= 400) {
|
|
127
|
+
verdict = 'failure'
|
|
128
|
+
rootCause = 'client_error'
|
|
129
|
+
suggestions.push('Review request format')
|
|
130
|
+
} else if (latencyMs > 10000) {
|
|
131
|
+
verdict = 'timeout'
|
|
132
|
+
rootCause = 'slow_provider'
|
|
133
|
+
suggestions.push('Consider faster model')
|
|
134
|
+
suggestions.push('Enable request timeout')
|
|
135
|
+
} else if (latencyMs > 3000) {
|
|
136
|
+
verdict = 'degraded'
|
|
137
|
+
suggestions.push('Review provider latency trends')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (fallbackUsed && !rootCause) {
|
|
141
|
+
rootCause = 'primary_provider_failed'
|
|
142
|
+
suggestions.push('Check primary provider health')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
verdict,
|
|
147
|
+
rootCause,
|
|
148
|
+
suggestions,
|
|
149
|
+
cacheHit: cached,
|
|
150
|
+
fallbackTriggered: fallbackUsed,
|
|
151
|
+
latencyBreakdown: {
|
|
152
|
+
total: latencyMs,
|
|
153
|
+
network: Math.round(latencyMs * 0.3),
|
|
154
|
+
provider: Math.round(latencyMs * 0.6),
|
|
155
|
+
overhead: Math.round(latencyMs * 0.1),
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private buildTimeline(row: Record<string, unknown>): TimelineEvent[] {
|
|
161
|
+
const latencyMs = (row.latency_ms as number) || 0
|
|
162
|
+
const cached = !!(row.cached as number)
|
|
163
|
+
const fallbackUsed = !!(row.fallback_used as number)
|
|
164
|
+
const events: TimelineEvent[] = []
|
|
165
|
+
|
|
166
|
+
events.push({ timestamp: row.created_at as string, event: 'request_received', durationMs: 0 })
|
|
167
|
+
|
|
168
|
+
if (cached) {
|
|
169
|
+
events.push({ timestamp: row.created_at as string, event: 'cache_hit', durationMs: 2 })
|
|
170
|
+
} else {
|
|
171
|
+
events.push({ timestamp: row.created_at as string, event: 'cache_miss', durationMs: 1 })
|
|
172
|
+
events.push({ timestamp: row.created_at as string, event: 'route_selected', durationMs: 5, metadata: { provider: row.provider_id } })
|
|
173
|
+
|
|
174
|
+
if (fallbackUsed) {
|
|
175
|
+
events.push({ timestamp: row.created_at as string, event: 'primary_failed', durationMs: Math.round(latencyMs * 0.3) })
|
|
176
|
+
events.push({ timestamp: row.created_at as string, event: 'fallback_triggered', durationMs: Math.round(latencyMs * 0.7) })
|
|
177
|
+
} else {
|
|
178
|
+
events.push({ timestamp: row.created_at as string, event: 'provider_call', durationMs: latencyMs })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
events.push({ timestamp: row.created_at as string, event: 'response_received', durationMs: 0 })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return events
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const forensicsEngine = new ForensicsEngine()
|