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,84 @@
1
+ import { db } from '../db'
2
+ import { pipelines } from '../db/schema'
3
+ import { eq } from 'drizzle-orm'
4
+ import { DEFAULT_PIPELINE } from './types'
5
+ import type { Pipeline, PipelineNode, PipelineConnection } from './types'
6
+ import { logger } from '../utils/logger'
7
+ import { v4 as uuid } from 'uuid'
8
+
9
+ class PipelineEngine {
10
+ async list(): Promise<Pipeline[]> {
11
+ try {
12
+ const rows = await db.select().from(pipelines)
13
+ return rows.map((r) => ({
14
+ id: r.id,
15
+ name: r.name,
16
+ description: r.description || '',
17
+ nodes: JSON.parse(r.nodes),
18
+ connections: JSON.parse(r.connections),
19
+ isDefault: !!r.isDefault,
20
+ }))
21
+ } catch {
22
+ return [DEFAULT_PIPELINE]
23
+ }
24
+ }
25
+
26
+ async getDefault(): Promise<Pipeline> {
27
+ try {
28
+ const rows = await db.select().from(pipelines).where(eq(pipelines.isDefault, true)).limit(1)
29
+ if (rows.length > 0) {
30
+ return {
31
+ id: rows[0].id,
32
+ name: rows[0].name,
33
+ description: rows[0].description || '',
34
+ nodes: JSON.parse(rows[0].nodes),
35
+ connections: JSON.parse(rows[0].connections),
36
+ isDefault: true,
37
+ }
38
+ }
39
+ } catch { /* ignore */ }
40
+ return DEFAULT_PIPELINE
41
+ }
42
+
43
+ async create(pipeline: Omit<Pipeline, 'id'>): Promise<string> {
44
+ const id = uuid()
45
+ try {
46
+ await db.insert(pipelines).values({
47
+ id,
48
+ name: pipeline.name,
49
+ description: pipeline.description,
50
+ nodes: JSON.stringify(pipeline.nodes),
51
+ connections: JSON.stringify(pipeline.connections),
52
+ isDefault: pipeline.isDefault ?? false,
53
+ })
54
+ } catch (err) {
55
+ logger.warn({ error: (err as Error).message }, 'Failed to create pipeline')
56
+ }
57
+ return id
58
+ }
59
+
60
+ async update(id: string, updates: Partial<Pipeline>): Promise<void> {
61
+ try {
62
+ const set: Record<string, unknown> = { updatedAt: new Date().toISOString() }
63
+ if (updates.name !== undefined) set.name = updates.name
64
+ if (updates.description !== undefined) set.description = updates.description
65
+ if (updates.nodes !== undefined) set.nodes = JSON.stringify(updates.nodes)
66
+ if (updates.connections !== undefined) set.connections = JSON.stringify(updates.connections)
67
+ if (updates.isDefault !== undefined) set.isDefault = updates.isDefault
68
+
69
+ await db.update(pipelines).set(set).where(eq(pipelines.id, id))
70
+ } catch (err) {
71
+ logger.warn({ error: (err as Error).message }, 'Failed to update pipeline')
72
+ }
73
+ }
74
+
75
+ async delete(id: string): Promise<void> {
76
+ try {
77
+ await db.delete(pipelines).where(eq(pipelines.id, id))
78
+ } catch (err) {
79
+ logger.warn({ error: (err as Error).message }, 'Failed to delete pipeline')
80
+ }
81
+ }
82
+ }
83
+
84
+ export const pipelineEngine = new PipelineEngine()
@@ -0,0 +1,77 @@
1
+ export type NodeType = 'input' | 'cache_check' | 'squeezer' | 'router' | 'provider_call' | 'fallback' | 'skill_apply' | 'cache_store' | 'benchmark' | 'plugin' | 'output' | 'condition'
2
+
3
+ export interface PipelineNode {
4
+ id: string
5
+ type: NodeType
6
+ label: string
7
+ config: Record<string, unknown>
8
+ position: { x: number; y: number }
9
+ }
10
+
11
+ export interface PipelineConnection {
12
+ id: string
13
+ source: string
14
+ target: string
15
+ condition?: string
16
+ }
17
+
18
+ export interface Pipeline {
19
+ id: string
20
+ name: string
21
+ description: string
22
+ nodes: PipelineNode[]
23
+ connections: PipelineConnection[]
24
+ isDefault: boolean
25
+ }
26
+
27
+ export interface PipelineContext {
28
+ request: import('../providers/types').NormalizedRequest
29
+ response?: import('../providers/types').NormalizedResponse
30
+ metadata: Record<string, unknown>
31
+ currentNode?: string
32
+ history: Array<{ node: string; result: string; durationMs: number }>
33
+ }
34
+
35
+ const DEFAULT_PIPELINE_NODES: PipelineNode[] = [
36
+ { id: 'input', type: 'input', label: 'Request Input', config: {}, position: { x: 0, y: 0 } },
37
+ { id: 'cache', type: 'cache_check', label: 'Cache Check', config: {}, position: { x: 200, y: 0 } },
38
+ { id: 'squeeze', type: 'squeezer', label: 'Token Squeezer', config: { level: 'balanced' }, position: { x: 400, y: 0 } },
39
+ { id: 'skill', type: 'skill_apply', label: 'Apply Skill', config: {}, position: { x: 600, y: -100 } },
40
+ { id: 'router', type: 'router', label: 'Neural Router', config: { strategy: 'hybrid' }, position: { x: 600, y: 100 } },
41
+ { id: 'provider', type: 'provider_call', label: 'Provider Call', config: {}, position: { x: 800, y: 0 } },
42
+ { id: 'fallback', type: 'fallback', label: 'Fallback Engine', config: {}, position: { x: 1000, y: 0 } },
43
+ { id: 'benchmark', type: 'benchmark', label: 'Benchmark', config: { enabled: false }, position: { x: 1200, y: -100 } },
44
+ { id: 'plugin', type: 'plugin', label: 'Post-Process', config: { plugins: [] }, position: { x: 1200, y: 100 } },
45
+ { id: 'cache_store', type: 'cache_store', label: 'Cache Store', config: {}, position: { x: 1400, y: 0 } },
46
+ { id: 'output', type: 'output', label: 'Response Output', config: {}, position: { x: 1600, y: 0 } },
47
+ ]
48
+
49
+ const DEFAULT_PIPELINE_CONNECTIONS: PipelineConnection[] = [
50
+ { id: 'c1', source: 'input', target: 'cache' },
51
+ { id: 'c2', source: 'cache', target: 'squeeze', condition: 'cache_miss' },
52
+ { id: 'c3', source: 'squeeze', target: 'skill' },
53
+ { id: 'c4', source: 'squeeze', target: 'router' },
54
+ { id: 'c5', source: 'router', target: 'provider' },
55
+ { id: 'c6', source: 'provider', target: 'fallback', condition: 'error' },
56
+ { id: 'c7', source: 'provider', target: 'benchmark', condition: 'success' },
57
+ { id: 'c8', source: 'provider', target: 'plugin', condition: 'success' },
58
+ { id: 'c9', source: 'fallback', target: 'plugin', condition: 'success' },
59
+ { id: 'c10', source: 'plugin', target: 'cache_store' },
60
+ { id: 'c11', source: 'cache_store', target: 'output' },
61
+ { id: 'c12', source: 'cache', target: 'output', condition: 'cache_hit' },
62
+ ]
63
+
64
+ export const DEFAULT_PIPELINE: Pipeline = {
65
+ id: 'default',
66
+ name: 'Default Pipeline',
67
+ description: 'Standard request processing pipeline with cache, squeeze, route, fallback',
68
+ nodes: DEFAULT_PIPELINE_NODES,
69
+ connections: DEFAULT_PIPELINE_CONNECTIONS,
70
+ isDefault: true,
71
+ }
72
+
73
+ export function getNodesAfter(pipeline: Pipeline, nodeId: string, condition?: string): PipelineConnection[] {
74
+ return pipeline.connections.filter(
75
+ (c) => c.source === nodeId && (!condition || c.condition === condition || !c.condition),
76
+ )
77
+ }
@@ -0,0 +1,79 @@
1
+ import { pluginManager } from './index'
2
+ import type { Plugin, PluginContext } from './index'
3
+
4
+ const piiPatterns = [
5
+ /\b\d{3}[-.]?\d{2}[-.]?\d{4}\b/g,
6
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
7
+ /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
8
+ /\b\d{3}[-.]?\d{3}[-.]?\d{3}[-.]?\d{4}\b/g,
9
+ /\b(?:4\d{3}|5[1-5]\d{2}|2\d{3}|3[47]\d{2})[-\s.]?\d{4}[-\s.]?\d{4}[-\s.]?\d{4}\b/g,
10
+ ]
11
+
12
+ const piiPlugin: Plugin = {
13
+ name: 'pii-redactor',
14
+ description: 'Redacts personally identifiable information (SSN, email, phone, credit card)',
15
+ phase: 'pre_request',
16
+ execute: async (ctx: PluginContext) => {
17
+ let content = ctx.content
18
+ let modified = false
19
+
20
+ for (const pattern of piiPatterns) {
21
+ if (pattern.test(content)) {
22
+ content = content.replace(pattern, '[REDACTED]')
23
+ modified = true
24
+ }
25
+ }
26
+
27
+ return { modified, content }
28
+ },
29
+ }
30
+
31
+ pluginManager.register(piiPlugin)
32
+
33
+ const profanityWords = ['damn', 'hell', 'crap']
34
+ const profanityPlugin: Plugin = {
35
+ name: 'profanity-filter',
36
+ description: 'Filters profanity from responses',
37
+ phase: 'post_response',
38
+ execute: async (ctx: PluginContext) => {
39
+ let content = ctx.content
40
+ let modified = false
41
+
42
+ for (const word of profanityWords) {
43
+ const regex = new RegExp(`\\b${word}\\b`, 'gi')
44
+ if (regex.test(content)) {
45
+ content = content.replace(regex, '*'.repeat(word.length))
46
+ modified = true
47
+ }
48
+ }
49
+
50
+ return { modified, content }
51
+ },
52
+ }
53
+
54
+ pluginManager.register(profanityPlugin)
55
+
56
+ const responseValidator: Plugin = {
57
+ name: 'response-validator',
58
+ description: 'Validates response is not empty and has reasonable length',
59
+ phase: 'post_response',
60
+ execute: async (ctx: PluginContext) => {
61
+ const issues: string[] = []
62
+
63
+ if (!ctx.content || ctx.content.trim().length === 0) {
64
+ issues.push('empty_response')
65
+ }
66
+
67
+ if (ctx.content.length > 100000) {
68
+ issues.push('response_too_long')
69
+ }
70
+
71
+ return {
72
+ modified: false,
73
+ content: ctx.content,
74
+ metadata: { validation: { valid: issues.length === 0, issues } },
75
+ }
76
+ },
77
+ }
78
+
79
+ pluginManager.register(responseValidator)
@@ -0,0 +1,65 @@
1
+ export interface Plugin {
2
+ name: string
3
+ description: string
4
+ phase: 'pre_request' | 'post_response' | 'both'
5
+ execute: (context: PluginContext) => Promise<PluginResult>
6
+ }
7
+
8
+ export interface PluginContext {
9
+ type: 'request' | 'response'
10
+ content: string
11
+ model: string
12
+ provider: string
13
+ metadata: Record<string, unknown>
14
+ }
15
+
16
+ export interface PluginResult {
17
+ modified: boolean
18
+ content: string
19
+ metadata?: Record<string, unknown>
20
+ }
21
+
22
+ class PluginManager {
23
+ private plugins = new Map<string, Plugin>()
24
+
25
+ register(plugin: Plugin) {
26
+ this.plugins.set(plugin.name, plugin)
27
+ }
28
+
29
+ unregister(name: string) {
30
+ this.plugins.delete(name)
31
+ }
32
+
33
+ async executePhase(phase: 'pre_request' | 'post_response', context: PluginContext): Promise<PluginContext> {
34
+ let current = context
35
+
36
+ for (const plugin of this.plugins.values()) {
37
+ if (plugin.phase !== phase && plugin.phase !== 'both') continue
38
+
39
+ try {
40
+ const result = await plugin.execute(current)
41
+ if (result.modified) {
42
+ current = {
43
+ ...current,
44
+ content: result.content,
45
+ metadata: { ...current.metadata, ...result.metadata },
46
+ }
47
+ }
48
+ } catch {
49
+ // plugin error doesn't stop pipeline
50
+ }
51
+ }
52
+
53
+ return current
54
+ }
55
+
56
+ list(): Array<{ name: string; description: string; phase: string }> {
57
+ return Array.from(this.plugins.values()).map((p) => ({
58
+ name: p.name,
59
+ description: p.description,
60
+ phase: p.phase,
61
+ }))
62
+ }
63
+ }
64
+
65
+ export const pluginManager = new PluginManager()
@@ -0,0 +1,113 @@
1
+ import { db } from '../db'
2
+ import { usageDaily, settings } from '../db/schema'
3
+ import { sql, and, gte, lte } from 'drizzle-orm'
4
+ import { logger } from '../utils/logger'
5
+
6
+ export interface CostForecast {
7
+ currentMonthSpend: number
8
+ projectedMonthSpend: number
9
+ monthlyBudget: number
10
+ budgetUsedPercent: number
11
+ daysRemaining: number
12
+ dailyBurnRate: number
13
+ daysUntilBudgetExhausted: number
14
+ recommendation: 'normal' | 'cost_saving' | 'budget_conserving' | 'strict_free'
15
+ }
16
+
17
+ export interface ModelCostComparison {
18
+ model: string
19
+ providerId: string
20
+ costPerRequest: number
21
+ avgLatency: number
22
+ qualityScore: number
23
+ valueScore: number
24
+ }
25
+
26
+ class PredictiveCostEngine {
27
+ async getForecast(): Promise<CostForecast> {
28
+ try {
29
+ const now = new Date()
30
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
31
+ const today = now.toISOString().split('T')[0]
32
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()
33
+ const daysElapsed = now.getDate()
34
+ const daysRemaining = daysInMonth - daysElapsed
35
+
36
+ const [monthStats] = await db.select({
37
+ spend: sql<number>`coalesce(sum(${usageDaily.totalCost}), 0)`,
38
+ requests: sql<number>`coalesce(sum(${usageDaily.totalRequests}), 0)`,
39
+ }).from(usageDaily).where(gte(usageDaily.date, monthStart))
40
+
41
+ const currentMonthSpend = Number(monthStats?.spend ?? 0)
42
+ const totalRequests = monthStats?.requests ?? 0
43
+ const dailyBurnRate = daysElapsed > 0 ? currentMonthSpend / daysElapsed : 0
44
+ const projectedMonthSpend = dailyBurnRate * daysInMonth
45
+
46
+ let monthlyBudget = 100
47
+ try {
48
+ const [budgetRow] = await db.select().from(settings).where(sql`${settings.key} = 'monthly_budget'`).limit(1)
49
+ if (budgetRow) monthlyBudget = Number(budgetRow.value)
50
+ } catch { /* ignore */ }
51
+
52
+ const budgetUsedPercent = monthlyBudget > 0 ? (currentMonthSpend / monthlyBudget) * 100 : 0
53
+ const daysUntilBudgetExhausted = dailyBurnRate > 0 ? Math.floor((monthlyBudget - currentMonthSpend) / dailyBurnRate) : Infinity
54
+
55
+ let recommendation: CostForecast['recommendation'] = 'normal'
56
+ if (budgetUsedPercent > 95) recommendation = 'strict_free'
57
+ else if (budgetUsedPercent > 80) recommendation = 'budget_conserving'
58
+ else if (budgetUsedPercent > 60) recommendation = 'cost_saving'
59
+
60
+ return {
61
+ currentMonthSpend,
62
+ projectedMonthSpend,
63
+ monthlyBudget,
64
+ budgetUsedPercent,
65
+ daysRemaining,
66
+ dailyBurnRate,
67
+ daysUntilBudgetExhausted: Math.max(daysUntilBudgetExhausted, 0),
68
+ recommendation,
69
+ }
70
+ } catch {
71
+ return {
72
+ currentMonthSpend: 0, projectedMonthSpend: 0, monthlyBudget: 100,
73
+ budgetUsedPercent: 0, daysRemaining: 30, dailyBurnRate: 0,
74
+ daysUntilBudgetExhausted: Infinity, recommendation: 'normal',
75
+ }
76
+ }
77
+ }
78
+
79
+ async getModelCostComparison(): Promise<ModelCostComparison[]> {
80
+ try {
81
+ const rows = await db.select({
82
+ model: usageDaily.model,
83
+ provider: usageDaily.providerId,
84
+ totalCost: sql<number>`coalesce(sum(${usageDaily.totalCost}), 0)`,
85
+ totalRequests: sql<number>`coalesce(sum(${usageDaily.totalRequests}), 0)`,
86
+ }).from(usageDaily)
87
+ .where(sql`${usageDaily.model} IS NOT NULL`)
88
+ .groupBy(usageDaily.model, usageDaily.providerId)
89
+
90
+ return rows.map((r) => ({
91
+ model: r.model || '',
92
+ providerId: r.provider || '',
93
+ costPerRequest: r.totalRequests > 0 ? Number(r.totalCost) / r.totalRequests : 0,
94
+ avgLatency: 0,
95
+ qualityScore: 0,
96
+ valueScore: r.totalRequests > 0 ? (1 / (Number(r.totalCost) / r.totalRequests + 0.001)) : 0,
97
+ }))
98
+ } catch {
99
+ return []
100
+ }
101
+ }
102
+
103
+ async setBudget(amount: number) {
104
+ try {
105
+ await db.insert(settings).values({ key: 'monthly_budget', value: String(amount) })
106
+ .onConflictDoUpdate({ target: settings.key, set: { value: String(amount) } })
107
+ } catch (err) {
108
+ logger.warn({ error: (err as Error).message }, 'Failed to set budget')
109
+ }
110
+ }
111
+ }
112
+
113
+ export const costEngine = new PredictiveCostEngine()
@@ -0,0 +1,96 @@
1
+ import { BaseAdapter } from '../base-adapter'
2
+ import type { ProviderInfo, ProviderAccount, NormalizedRequest, NormalizedResponse, Model, HealthStatusType } from '../types'
3
+
4
+ export class AnthropicAdapter extends BaseAdapter {
5
+ info: ProviderInfo
6
+
7
+ constructor(info?: Partial<ProviderInfo>) {
8
+ super()
9
+ this.info = {
10
+ id: info?.id || 'anthropic',
11
+ name: info?.name || 'Anthropic',
12
+ prefix: info?.prefix || 'an/',
13
+ authType: info?.authType || 'api_key',
14
+ baseUrl: info?.baseUrl || 'https://api.anthropic.com',
15
+ enabled: info?.enabled ?? true,
16
+ }
17
+ }
18
+
19
+ private staticModels: Model[] = [
20
+ { id: 'an/claude-opus-4-7', name: 'claude-opus-4-7', displayName: 'Claude Opus 4.7', providerId: 'anthropic', pricingTier: 'pay_per_use', costPer1mInput: 15, costPer1mOutput: 75, contextWindow: 200000, capabilities: ['streaming', 'tools', 'vision'], available: true },
21
+ { id: 'an/claude-sonnet-4-5', name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5', providerId: 'anthropic', pricingTier: 'pay_per_use', costPer1mInput: 3, costPer1mOutput: 15, contextWindow: 200000, capabilities: ['streaming', 'tools', 'vision'], available: true },
22
+ { id: 'an/claude-haiku-4-5', name: 'claude-haiku-4-5', displayName: 'Claude Haiku 4.5', providerId: 'anthropic', pricingTier: 'pay_per_use', costPer1mInput: 0.8, costPer1mOutput: 4, contextWindow: 200000, capabilities: ['streaming', 'tools', 'vision'], available: true },
23
+ ]
24
+
25
+ async fetchModels(): Promise<Model[]> {
26
+ return this.staticModels
27
+ }
28
+
29
+ async chatCompletion(req: NormalizedRequest, account: ProviderAccount): Promise<NormalizedResponse> {
30
+ const startTime = Date.now()
31
+ const systemMsg = req.messages.find((m) => m.role === 'system')
32
+ const nonSystemMsgs = req.messages.filter((m) => m.role !== 'system')
33
+
34
+ const claudeBody: Record<string, unknown> = {
35
+ model: req.model.replace('an/', ''),
36
+ messages: nonSystemMsgs.map((m) => ({ role: m.role, content: m.content })),
37
+ max_tokens: req.maxTokens || 4096,
38
+ stream: false,
39
+ }
40
+ if (systemMsg) claudeBody.system = systemMsg.content
41
+
42
+ const raw = await this.fetchJson<{
43
+ id?: string
44
+ model?: string
45
+ content?: Array<{ type: string; text?: string }>
46
+ stop_reason?: string
47
+ usage?: { input_tokens?: number; output_tokens?: number }
48
+ }>(`${this.info.baseUrl}/v1/messages`, {
49
+ method: 'POST',
50
+ headers: {
51
+ 'Content-Type': 'application/json',
52
+ 'x-api-key': account.authData.apiKey as string,
53
+ 'anthropic-version': '2023-06-01',
54
+ },
55
+ body: JSON.stringify(claudeBody),
56
+ })
57
+
58
+ const content = raw.content?.find((c) => c.type === 'text')?.text || ''
59
+
60
+ return {
61
+ id: raw.id || crypto.randomUUID(),
62
+ model: raw.model || req.model,
63
+ choices: [{
64
+ index: 0,
65
+ message: { role: 'assistant', content },
66
+ finishReason: raw.stop_reason || 'stop',
67
+ }],
68
+ usage: {
69
+ inputTokens: raw.usage?.input_tokens || 0,
70
+ outputTokens: raw.usage?.output_tokens || 0,
71
+ totalTokens: (raw.usage?.input_tokens || 0) + (raw.usage?.output_tokens || 0),
72
+ },
73
+ latencyMs: Date.now() - startTime,
74
+ provider: this.info.name,
75
+ }
76
+ }
77
+
78
+ async *chatCompletionStream(): AsyncIterable<import('../types').NormalizedChunk> {
79
+ yield { id: '', model: '', choices: [{ index: 0, delta: { content: 'Streaming not implemented for Anthropic adapter yet' }, finishReason: 'stop' }] }
80
+ }
81
+
82
+ async healthCheck(): Promise<{ status: HealthStatusType; latencyMs: number }> {
83
+ const start = Date.now()
84
+ try {
85
+ await fetch(`${this.info.baseUrl}/v1/messages`, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json', 'x-api-key': 'test', 'anthropic-version': '2023-06-01' },
88
+ body: JSON.stringify({ model: 'claude-haiku-4-5', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 }),
89
+ signal: AbortSignal.timeout(10000),
90
+ })
91
+ return { status: 'healthy', latencyMs: Date.now() - start }
92
+ } catch {
93
+ return { status: 'down', latencyMs: Date.now() - start }
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,108 @@
1
+ import { BaseAdapter } from '../base-adapter'
2
+ import type { ProviderInfo, ProviderAccount, NormalizedRequest, NormalizedResponse, NormalizedChunk, Model, HealthStatusType } from '../types'
3
+
4
+ export class DeepSeekAdapter extends BaseAdapter {
5
+ info: ProviderInfo
6
+
7
+ constructor(info?: Partial<ProviderInfo>) {
8
+ super()
9
+ this.info = {
10
+ id: info?.id || 'deepseek',
11
+ name: info?.name || 'DeepSeek',
12
+ prefix: info?.prefix || 'ds/',
13
+ authType: info?.authType || 'api_key',
14
+ baseUrl: info?.baseUrl || 'https://api.deepseek.com/v1',
15
+ enabled: info?.enabled ?? true,
16
+ }
17
+ }
18
+
19
+ async fetchModels(): Promise<Model[]> {
20
+ try {
21
+ const data = await this.fetchJson<{ data: Array<{ id: string }> }>(
22
+ `${this.info.baseUrl}/models`,
23
+ { headers: { 'Content-Type': 'application/json' } }
24
+ )
25
+ return data.data.map((m) => ({
26
+ id: `${this.info.prefix}${m.id}`,
27
+ name: m.id,
28
+ displayName: m.id,
29
+ providerId: this.info.id,
30
+ pricingTier: 'pay_per_use' as const,
31
+ available: true,
32
+ }))
33
+ } catch {
34
+ return [
35
+ { id: 'ds/deepseek-chat', name: 'deepseek-chat', displayName: 'DeepSeek Chat', providerId: this.info.id, pricingTier: 'pay_per_use', available: true },
36
+ { id: 'ds/deepseek-reasoner', name: 'deepseek-reasoner', displayName: 'DeepSeek Reasoner', providerId: this.info.id, pricingTier: 'pay_per_use', available: true },
37
+ ]
38
+ }
39
+ }
40
+
41
+ async chatCompletion(req: NormalizedRequest, account: ProviderAccount): Promise<NormalizedResponse> {
42
+ const startTime = Date.now()
43
+ const body = this.createOpenAIRequest(req)
44
+
45
+ const raw = await this.fetchJson<{
46
+ id?: string; model?: string
47
+ choices?: Array<{ index?: number; message?: { role?: string; content?: string; reasoning_content?: string }; finish_reason?: string }>
48
+ usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
49
+ }>(`${this.info.baseUrl}/chat/completions`, {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${account.authData.apiKey}` },
52
+ body: JSON.stringify(body),
53
+ })
54
+
55
+ const resp = this.parseOpenAIResponse(raw, this.info.name, startTime)
56
+ if (raw.choices?.[0]?.message?.reasoning_content) {
57
+ resp.choices[0].message.content = raw.choices[0].message.reasoning_content + '\n' + (raw.choices[0].message.content || '')
58
+ }
59
+ return resp
60
+ }
61
+
62
+ async *chatCompletionStream(req: NormalizedRequest, account: ProviderAccount): AsyncIterable<NormalizedChunk> {
63
+ const body = { ...this.createOpenAIRequest(req), stream: true }
64
+ const response = await this.fetchWithRetry(`${this.info.baseUrl}/chat/completions`, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${account.authData.apiKey}` },
67
+ body: JSON.stringify(body),
68
+ })
69
+ if (!response.body) return
70
+ const reader = response.body.getReader()
71
+ const decoder = new TextDecoder()
72
+ let buffer = ''
73
+ while (true) {
74
+ const { done, value } = await reader.read()
75
+ if (done) break
76
+ buffer += decoder.decode(value, { stream: true })
77
+ const lines = buffer.split('\n')
78
+ buffer = lines.pop() || ''
79
+ for (const line of lines) {
80
+ const trimmed = line.trim()
81
+ if (!trimmed.startsWith('data: ')) continue
82
+ const data = trimmed.slice(6)
83
+ if (data === '[DONE]') return
84
+ try {
85
+ const parsed = JSON.parse(data)
86
+ yield {
87
+ id: parsed.id || '', model: parsed.model || '',
88
+ choices: (parsed.choices || []).map((c: Record<string, unknown>) => ({
89
+ index: (c.index as number) || 0,
90
+ delta: { role: (c.delta as Record<string, unknown>)?.role as 'assistant' | undefined, content: (c.delta as Record<string, unknown>)?.content as string | undefined },
91
+ finishReason: (c.finish_reason as string) || null,
92
+ })),
93
+ }
94
+ } catch { continue }
95
+ }
96
+ }
97
+ }
98
+
99
+ async healthCheck(): Promise<{ status: HealthStatusType; latencyMs: number }> {
100
+ const start = Date.now()
101
+ try {
102
+ await fetch(`${this.info.baseUrl}/models`, { method: 'GET', signal: AbortSignal.timeout(10000) })
103
+ return { status: 'healthy', latencyMs: Date.now() - start }
104
+ } catch {
105
+ return { status: 'down', latencyMs: Date.now() - start }
106
+ }
107
+ }
108
+ }