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.
Files changed (135) hide show
  1. package/README.md +385 -0
  2. package/bin/synapse.js +242 -0
  3. package/docs/PLAN.md +1723 -0
  4. package/docs/PRD.md +1799 -0
  5. package/drizzle.config.ts +12 -0
  6. package/next.config.ts +8 -0
  7. package/package.json +82 -0
  8. package/postcss.config.mjs +7 -0
  9. package/public/file.svg +1 -0
  10. package/public/globe.svg +1 -0
  11. package/public/next.svg +1 -0
  12. package/public/vercel.svg +1 -0
  13. package/public/window.svg +1 -0
  14. package/src/app/api/analytics/cost/route.ts +13 -0
  15. package/src/app/api/analytics/usage/route.ts +16 -0
  16. package/src/app/api/auth/login/route.ts +42 -0
  17. package/src/app/api/cache/route.ts +19 -0
  18. package/src/app/api/dashboard/route.ts +35 -0
  19. package/src/app/api/distill/route.ts +10 -0
  20. package/src/app/api/events/route.ts +54 -0
  21. package/src/app/api/health/route.ts +10 -0
  22. package/src/app/api/intelligence/forensics/route.ts +23 -0
  23. package/src/app/api/intelligence/neural-router/route.ts +23 -0
  24. package/src/app/api/keys/route.ts +34 -0
  25. package/src/app/api/mcp/route.ts +49 -0
  26. package/src/app/api/memory/route.ts +10 -0
  27. package/src/app/api/models/benchmark/route.ts +13 -0
  28. package/src/app/api/models/route.ts +39 -0
  29. package/src/app/api/namespace/route.ts +25 -0
  30. package/src/app/api/plugins/route.ts +41 -0
  31. package/src/app/api/providers/accounts/route.ts +91 -0
  32. package/src/app/api/providers/fetch-models/route.ts +52 -0
  33. package/src/app/api/providers/health/route.ts +10 -0
  34. package/src/app/api/providers/route.ts +46 -0
  35. package/src/app/api/routes/pipeline/route.ts +20 -0
  36. package/src/app/api/settings/route.ts +33 -0
  37. package/src/app/api/skills/route.ts +39 -0
  38. package/src/app/api/v1/chat/completions/route.ts +156 -0
  39. package/src/app/api/v1/models/route.ts +44 -0
  40. package/src/app/dashboard/intelligence/loading.tsx +14 -0
  41. package/src/app/dashboard/intelligence/page.tsx +125 -0
  42. package/src/app/dashboard/layout.tsx +143 -0
  43. package/src/app/dashboard/loading.tsx +17 -0
  44. package/src/app/dashboard/memory/loading.tsx +15 -0
  45. package/src/app/dashboard/memory/page.tsx +71 -0
  46. package/src/app/dashboard/models/loading.tsx +13 -0
  47. package/src/app/dashboard/models/page.tsx +107 -0
  48. package/src/app/dashboard/page.tsx +183 -0
  49. package/src/app/dashboard/playground/loading.tsx +17 -0
  50. package/src/app/dashboard/playground/page.tsx +212 -0
  51. package/src/app/dashboard/providers/loading.tsx +15 -0
  52. package/src/app/dashboard/providers/page.tsx +248 -0
  53. package/src/app/dashboard/routes/loading.tsx +15 -0
  54. package/src/app/dashboard/routes/page.tsx +72 -0
  55. package/src/app/dashboard/settings/loading.tsx +20 -0
  56. package/src/app/dashboard/settings/page.tsx +208 -0
  57. package/src/app/dashboard/skills/loading.tsx +26 -0
  58. package/src/app/dashboard/skills/page.tsx +137 -0
  59. package/src/app/dashboard/vault/loading.tsx +18 -0
  60. package/src/app/dashboard/vault/page.tsx +139 -0
  61. package/src/app/favicon.ico +0 -0
  62. package/src/app/globals.css +59 -0
  63. package/src/app/layout.tsx +32 -0
  64. package/src/app/login/page.tsx +87 -0
  65. package/src/app/page.tsx +5 -0
  66. package/src/components/ui/badge.tsx +32 -0
  67. package/src/components/ui/button.tsx +38 -0
  68. package/src/components/ui/card.tsx +50 -0
  69. package/src/components/ui/error-boundary.tsx +47 -0
  70. package/src/components/ui/index.ts +11 -0
  71. package/src/components/ui/input.tsx +26 -0
  72. package/src/components/ui/select.tsx +24 -0
  73. package/src/components/ui/skeleton.tsx +53 -0
  74. package/src/components/ui/toast.tsx +51 -0
  75. package/src/instrumentation.ts +6 -0
  76. package/src/lib/__tests__/auth.test.ts +42 -0
  77. package/src/lib/__tests__/format.test.ts +94 -0
  78. package/src/lib/__tests__/namespace.test.ts +102 -0
  79. package/src/lib/__tests__/squeezer.test.ts +93 -0
  80. package/src/lib/__tests__/utils.test.ts +28 -0
  81. package/src/lib/analytics/index.ts +187 -0
  82. package/src/lib/auth/guard.tsx +71 -0
  83. package/src/lib/auth/index.ts +105 -0
  84. package/src/lib/auth/middleware.ts +64 -0
  85. package/src/lib/benchmark/index.ts +137 -0
  86. package/src/lib/bootstrap.ts +122 -0
  87. package/src/lib/cache/index.ts +1 -0
  88. package/src/lib/cache/semantic.ts +211 -0
  89. package/src/lib/config/defaults.ts +61 -0
  90. package/src/lib/config/index.ts +72 -0
  91. package/src/lib/config/schema.ts +63 -0
  92. package/src/lib/db/index.ts +22 -0
  93. package/src/lib/db/migrate.ts +327 -0
  94. package/src/lib/db/schema.ts +303 -0
  95. package/src/lib/distiller/index.ts +331 -0
  96. package/src/lib/fallback/index.ts +153 -0
  97. package/src/lib/forensics/index.ts +188 -0
  98. package/src/lib/format/anthropic.ts +139 -0
  99. package/src/lib/format/gemini.ts +130 -0
  100. package/src/lib/format/index.ts +3 -0
  101. package/src/lib/format/openai.ts +78 -0
  102. package/src/lib/health/index.ts +158 -0
  103. package/src/lib/mcp/builtin.ts +83 -0
  104. package/src/lib/mcp/index.ts +1 -0
  105. package/src/lib/mcp/registry.ts +49 -0
  106. package/src/lib/memory/index.ts +3 -0
  107. package/src/lib/memory/store.ts +215 -0
  108. package/src/lib/memory/types.ts +56 -0
  109. package/src/lib/namespace/index.ts +89 -0
  110. package/src/lib/neural/features.ts +74 -0
  111. package/src/lib/neural/index.ts +85 -0
  112. package/src/lib/neural/strategies.ts +124 -0
  113. package/src/lib/pipeline/index.ts +84 -0
  114. package/src/lib/pipeline/types.ts +77 -0
  115. package/src/lib/plugins/builtin.ts +79 -0
  116. package/src/lib/plugins/index.ts +65 -0
  117. package/src/lib/prediction/index.ts +113 -0
  118. package/src/lib/providers/api-key/anthropic.ts +96 -0
  119. package/src/lib/providers/api-key/deepseek.ts +108 -0
  120. package/src/lib/providers/api-key/gemini.ts +112 -0
  121. package/src/lib/providers/api-key/openai.ts +122 -0
  122. package/src/lib/providers/api-key/openrouter.ts +112 -0
  123. package/src/lib/providers/base-adapter.ts +122 -0
  124. package/src/lib/providers/registry.ts +46 -0
  125. package/src/lib/providers/types.ts +121 -0
  126. package/src/lib/router/index.ts +82 -0
  127. package/src/lib/skills/forge.ts +57 -0
  128. package/src/lib/skills/index.ts +3 -0
  129. package/src/lib/skills/registry.ts +195 -0
  130. package/src/lib/skills/types.ts +44 -0
  131. package/src/lib/squeezer/index.ts +158 -0
  132. package/src/lib/utils/cn.ts +6 -0
  133. package/src/lib/utils/logger.ts +16 -0
  134. package/src/middleware.ts +60 -0
  135. 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()