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,102 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { namespaceResolver, BUILTIN_ALIASES, TASK_TYPE_MODELS } from '../namespace'
3
+ import type { TaskType } from '../namespace'
4
+
5
+ describe('Namespace', () => {
6
+ describe('BUILTIN_ALIASES', () => {
7
+ it('has 8 built-in aliases', () => {
8
+ expect(BUILTIN_ALIASES).toHaveLength(8)
9
+ })
10
+
11
+ it('each alias has required fields', () => {
12
+ for (const a of BUILTIN_ALIASES) {
13
+ expect(a.alias).toBeTruthy()
14
+ expect(a.resolvesTo).toBeTruthy()
15
+ expect(a.description).toBeTruthy()
16
+ expect(a.taskTypes.length).toBeGreaterThan(0)
17
+ }
18
+ })
19
+ })
20
+
21
+ describe('resolve', () => {
22
+ it('resolves "best" to claude-sonnet-4', () => {
23
+ const result = namespaceResolver.resolve('best')
24
+ expect(result).toContain('an/claude-sonnet-4')
25
+ })
26
+
27
+ it('resolves "fast" to gpt-4o-mini', () => {
28
+ const result = namespaceResolver.resolve('fast')
29
+ expect(result).toContain('oa/gpt-4o-mini')
30
+ })
31
+
32
+ it('resolves "cheap" to deepseek-chat', () => {
33
+ const result = namespaceResolver.resolve('cheap')
34
+ expect(result).toContain('ds/deepseek-chat')
35
+ })
36
+
37
+ it('resolves "code" to a code model', () => {
38
+ const result = namespaceResolver.resolve('code')
39
+ expect(result.length).toBeGreaterThan(0)
40
+ })
41
+
42
+ it('passes through full model IDs', () => {
43
+ const result = namespaceResolver.resolve('oa/gpt-4o')
44
+ expect(result).toEqual(['oa/gpt-4o'])
45
+ })
46
+
47
+ it('is case-insensitive', () => {
48
+ const result = namespaceResolver.resolve('BEST')
49
+ expect(result).toContain('an/claude-sonnet-4')
50
+ })
51
+
52
+ it('returns single model for unknown short names', () => {
53
+ const result = namespaceResolver.resolve('unknown-model')
54
+ expect(result).toEqual(['unknown-model'])
55
+ })
56
+ })
57
+
58
+ describe('detectTaskType', () => {
59
+ it('detects code tasks', () => {
60
+ expect(namespaceResolver.detectTaskType('write a function that sorts an array')).toBe('code')
61
+ expect(namespaceResolver.detectTaskType('async function hello() {}')).toBe('code')
62
+ expect(namespaceResolver.detectTaskType('fix the bug in my code')).toBe('debug')
63
+ })
64
+
65
+ it('detects reasoning tasks', () => {
66
+ expect(namespaceResolver.detectTaskType('reason about this problem')).toBe('reason')
67
+ expect(namespaceResolver.detectTaskType('prove this theorem')).toBe('reason')
68
+ })
69
+
70
+ it('detects review tasks', () => {
71
+ expect(namespaceResolver.detectTaskType('review this pull request')).toBe('review')
72
+ expect(namespaceResolver.detectTaskType('critique my approach')).toBe('review')
73
+ })
74
+
75
+ it('defaults to chat', () => {
76
+ expect(namespaceResolver.detectTaskType('hello there')).toBe('chat')
77
+ expect(namespaceResolver.detectTaskType('how are you')).toBe('chat')
78
+ })
79
+ })
80
+
81
+ describe('resolveForTask', () => {
82
+ it('returns models for a task type', () => {
83
+ const models = namespaceResolver.resolveForTask('code')
84
+ expect(models.length).toBeGreaterThan(0)
85
+ })
86
+
87
+ it('defaults to chat for unknown task', () => {
88
+ const models = namespaceResolver.resolveForTask('chat' as TaskType)
89
+ expect(models).toEqual(TASK_TYPE_MODELS.chat)
90
+ })
91
+ })
92
+
93
+ describe('TASK_TYPE_MODELS', () => {
94
+ it('has models for every task type', () => {
95
+ const taskTypes: TaskType[] = ['code', 'chat', 'reason', 'review', 'debug', 'doc', 'translate', 'fast', 'creative', 'math']
96
+ for (const tt of taskTypes) {
97
+ expect(TASK_TYPE_MODELS[tt]).toBeDefined()
98
+ expect(TASK_TYPE_MODELS[tt].length).toBeGreaterThan(0)
99
+ }
100
+ })
101
+ })
102
+ })
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { squeeze, selectCompressionLevel } from '../squeezer'
3
+ import type { SqueezeContext, CompressionLevel } from '../squeezer'
4
+
5
+ function makeCtx(content: string, overrides?: Partial<SqueezeContext>): SqueezeContext {
6
+ return {
7
+ content,
8
+ contentType: 'mixed',
9
+ contextWindow: 128000,
10
+ usedTokens: 0,
11
+ ...overrides,
12
+ }
13
+ }
14
+
15
+ describe('Token Squeezer', () => {
16
+ describe('squeeze', () => {
17
+ it('returns zero savings for "none" level', () => {
18
+ const result = squeeze(makeCtx('hello world'), 'none')
19
+ expect(result.savingsPercent).toBe(0)
20
+ expect(result.level).toBe('none')
21
+ expect(result.filters).toEqual([])
22
+ })
23
+
24
+ it('applies whitespace collapsing for "light" level', () => {
25
+ const content = 'hello\n\n\n\nworld'
26
+ const result = squeeze(makeCtx(content), 'light')
27
+ expect(result.filters).toContain('collapse_whitespace')
28
+ expect(result.squeezedTokens).toBeLessThanOrEqual(result.originalTokens)
29
+ })
30
+
31
+ it('removes comments for code content at "balanced" level', () => {
32
+ const content = '// this is a comment\nfunction hello() {\n return 42;\n}\n/* block comment */'
33
+ const result = squeeze(makeCtx(content, { contentType: 'code' }), 'balanced')
34
+ expect(result.filters).toContain('collapse_whitespace')
35
+ expect(result.filters).toContain('remove_comments')
36
+ expect(result.filters).toContain('remove_console_logs')
37
+ })
38
+
39
+ it('removes console.log for code at "balanced" level', () => {
40
+ const content = 'console.log("debug");\nconsole.error("err");\nconst x = 1;'
41
+ const result = squeeze(makeCtx(content, { contentType: 'code' }), 'balanced')
42
+ expect(result.filters).toContain('remove_console_logs')
43
+ })
44
+
45
+ it('applies aggressive compression with truncation', () => {
46
+ const longContent = Array.from({ length: 1000 }, (_, i) => `// line ${i}\nconsole.log("${i}");\nimport type { T${i} } from "mod";\nconst x${i} = ${i};\n`).join('\n')
47
+ const result = squeeze(makeCtx(longContent, { contentType: 'code', usedTokens: 120000, targetTokens: 1000 }), 'aggressive')
48
+ expect(result.filters).toContain('collapse_whitespace')
49
+ expect(result.filters).toContain('remove_comments')
50
+ expect(result.filters).toContain('remove_console_logs')
51
+ expect(result.savingsPercent).toBeGreaterThan(0)
52
+ })
53
+
54
+ it('handles empty content', () => {
55
+ const result = squeeze(makeCtx(''), 'balanced')
56
+ expect(result.originalTokens).toBe(0)
57
+ expect(result.squeezedTokens).toBe(0)
58
+ })
59
+
60
+ it('calculates savings percent correctly', () => {
61
+ const content = '// comment\n\n\n\nconst x = 1;'
62
+ const result = squeeze(makeCtx(content, { contentType: 'code' }), 'balanced')
63
+ expect(result.savingsPercent).toBeGreaterThanOrEqual(0)
64
+ expect(result.savingsPercent).toBeLessThanOrEqual(100)
65
+ })
66
+ })
67
+
68
+ describe('selectCompressionLevel', () => {
69
+ it('returns "none" for low fill ratio', () => {
70
+ expect(selectCompressionLevel(0.3)).toBe('none')
71
+ })
72
+
73
+ it('returns "light" for moderate fill ratio', () => {
74
+ expect(selectCompressionLevel(0.6)).toBe('light')
75
+ })
76
+
77
+ it('returns "balanced" for high fill ratio', () => {
78
+ expect(selectCompressionLevel(0.75)).toBe('balanced')
79
+ })
80
+
81
+ it('returns "aggressive" for very high fill ratio', () => {
82
+ expect(selectCompressionLevel(0.9)).toBe('aggressive')
83
+ })
84
+
85
+ it('handles edge cases', () => {
86
+ expect(selectCompressionLevel(0)).toBe('none')
87
+ expect(selectCompressionLevel(0.5)).toBe('light')
88
+ expect(selectCompressionLevel(0.7)).toBe('balanced')
89
+ expect(selectCompressionLevel(0.85)).toBe('aggressive')
90
+ expect(selectCompressionLevel(1)).toBe('aggressive')
91
+ })
92
+ })
93
+ })
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { cn } from '../utils/cn'
3
+
4
+ describe('cn utility', () => {
5
+ it('merges class names', () => {
6
+ expect(cn('foo', 'bar')).toBe('foo bar')
7
+ })
8
+
9
+ it('handles conditional classes', () => {
10
+ expect(cn('base', false && 'hidden', 'visible')).toBe('base visible')
11
+ })
12
+
13
+ it('handles undefined and null', () => {
14
+ expect(cn('base', undefined, null, 'end')).toBe('base end')
15
+ })
16
+
17
+ it('merges tailwind conflicts', () => {
18
+ expect(cn('px-2', 'px-4')).toBe('px-4')
19
+ })
20
+
21
+ it('handles empty input', () => {
22
+ expect(cn()).toBe('')
23
+ })
24
+
25
+ it('handles arrays', () => {
26
+ expect(cn(['foo', 'bar'], 'baz')).toBe('foo bar baz')
27
+ })
28
+ })
@@ -0,0 +1,187 @@
1
+ import { db } from '../db'
2
+ import { requestLogs, usageDaily, providerHealth } from '../db/schema'
3
+ import { sql, desc, and, gte } from 'drizzle-orm'
4
+ import { logger } from '../utils/logger'
5
+
6
+ export interface UsageStats {
7
+ totalRequests: number
8
+ totalInputTokens: number
9
+ totalOutputTokens: number
10
+ totalTokensSaved: number
11
+ totalCost: number
12
+ totalErrors: number
13
+ totalFallbacks: number
14
+ totalCacheHits: number
15
+ avgLatencyMs: number
16
+ cacheHitRate: number
17
+ errorRate: number
18
+ fallbackRate: number
19
+ }
20
+
21
+ export interface TimeSeriesPoint {
22
+ date: string
23
+ requests: number
24
+ tokens: number
25
+ cost: number
26
+ }
27
+
28
+ class AnalyticsAggregator {
29
+ async getUsageStats(days = 7): Promise<UsageStats> {
30
+ try {
31
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
32
+
33
+ const [stats] = await db.select({
34
+ totalRequests: sql<number>`coalesce(sum(${usageDaily.totalRequests}), 0)`,
35
+ totalInputTokens: sql<number>`coalesce(sum(${usageDaily.totalInputTokens}), 0)`,
36
+ totalOutputTokens: sql<number>`coalesce(sum(${usageDaily.totalOutputTokens}), 0)`,
37
+ totalTokensSaved: sql<number>`coalesce(sum(${usageDaily.totalTokensSaved}), 0)`,
38
+ totalCost: sql<number>`coalesce(sum(${usageDaily.totalCost}), 0)`,
39
+ totalErrors: sql<number>`coalesce(sum(${usageDaily.totalErrors}), 0)`,
40
+ totalFallbacks: sql<number>`coalesce(sum(${usageDaily.totalFallbacks}), 0)`,
41
+ totalCacheHits: sql<number>`coalesce(sum(${usageDaily.totalCacheHits}), 0)`,
42
+ }).from(usageDaily).where(gte(usageDaily.date, since))
43
+
44
+ const total = stats?.totalRequests ?? 0
45
+ const hits = stats?.totalCacheHits ?? 0
46
+ const errors = stats?.totalErrors ?? 0
47
+ const fallbacks = stats?.totalFallbacks ?? 0
48
+
49
+ const [latency] = await db.select({
50
+ avg: sql<number>`coalesce(avg(${requestLogs.latencyMs}), 0)`,
51
+ }).from(requestLogs).where(sql`${requestLogs.createdAt} >= ${since}`)
52
+
53
+ return {
54
+ totalRequests: total,
55
+ totalInputTokens: stats?.totalInputTokens ?? 0,
56
+ totalOutputTokens: stats?.totalOutputTokens ?? 0,
57
+ totalTokensSaved: stats?.totalTokensSaved ?? 0,
58
+ totalCost: Number(stats?.totalCost ?? 0),
59
+ totalErrors: errors,
60
+ totalFallbacks: fallbacks,
61
+ totalCacheHits: hits,
62
+ avgLatencyMs: Math.round(Number(latency?.avg ?? 0)),
63
+ cacheHitRate: total > 0 ? hits / total : 0,
64
+ errorRate: total > 0 ? errors / total : 0,
65
+ fallbackRate: total > 0 ? fallbacks / total : 0,
66
+ }
67
+ } catch {
68
+ return {
69
+ totalRequests: 0, totalInputTokens: 0, totalOutputTokens: 0,
70
+ totalTokensSaved: 0, totalCost: 0, totalErrors: 0,
71
+ totalFallbacks: 0, totalCacheHits: 0, avgLatencyMs: 0,
72
+ cacheHitRate: 0, errorRate: 0, fallbackRate: 0,
73
+ }
74
+ }
75
+ }
76
+
77
+ async getUsageTimeSeries(days = 30): Promise<TimeSeriesPoint[]> {
78
+ try {
79
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
80
+
81
+ const rows = await db.select({
82
+ date: usageDaily.date,
83
+ requests: sql<number>`coalesce(sum(${usageDaily.totalRequests}), 0)`,
84
+ tokens: sql<number>`coalesce(sum(${usageDaily.totalInputTokens} + ${usageDaily.totalOutputTokens}), 0)`,
85
+ cost: sql<number>`coalesce(sum(${usageDaily.totalCost}), 0)`,
86
+ }).from(usageDaily).where(gte(usageDaily.date, since)).groupBy(usageDaily.date).orderBy(usageDaily.date)
87
+
88
+ return rows.map((r) => ({
89
+ date: r.date,
90
+ requests: r.requests,
91
+ tokens: r.tokens,
92
+ cost: Number(r.cost),
93
+ }))
94
+ } catch {
95
+ return []
96
+ }
97
+ }
98
+
99
+ async getProviderPerformance() {
100
+ try {
101
+ const health = await db.select().from(providerHealth)
102
+ return health.map((h) => ({
103
+ providerId: h.providerId,
104
+ status: h.status,
105
+ avgLatencyMs: h.avgLatencyMs,
106
+ p95LatencyMs: h.p95LatencyMs,
107
+ errorRate: h.errorRate,
108
+ successRate: h.successRate,
109
+ consecutiveFailures: h.consecutiveFailures,
110
+ }))
111
+ } catch {
112
+ return []
113
+ }
114
+ }
115
+
116
+ async getTopModels(limit = 10) {
117
+ try {
118
+ const rows = await db.select({
119
+ model: requestLogs.model,
120
+ count: sql<number>`count(*)`,
121
+ totalTokens: sql<number>`coalesce(sum(${requestLogs.inputTokens} + ${requestLogs.outputTokens}), 0)`,
122
+ totalCost: sql<number>`coalesce(sum(${requestLogs.cost}), 0)`,
123
+ avgLatency: sql<number>`coalesce(avg(${requestLogs.latencyMs}), 0)`,
124
+ }).from(requestLogs)
125
+ .groupBy(requestLogs.model)
126
+ .orderBy(desc(sql`count(*)`))
127
+ .limit(limit)
128
+
129
+ return rows.map((r) => ({
130
+ model: r.model,
131
+ requests: r.count,
132
+ tokens: r.totalTokens,
133
+ cost: Number(r.totalCost),
134
+ avgLatency: Math.round(Number(r.avgLatency)),
135
+ }))
136
+ } catch {
137
+ return []
138
+ }
139
+ }
140
+
141
+ async recordDailyUsage(data: {
142
+ date: string
143
+ providerId?: string
144
+ model?: string
145
+ requests?: number
146
+ inputTokens?: number
147
+ outputTokens?: number
148
+ tokensSaved?: number
149
+ cost?: number
150
+ errors?: number
151
+ fallbacks?: number
152
+ cacheHits?: number
153
+ }) {
154
+ try {
155
+ await db.insert(usageDaily).values({
156
+ id: crypto.randomUUID(),
157
+ date: data.date,
158
+ providerId: data.providerId || null,
159
+ model: data.model || null,
160
+ totalRequests: data.requests || 0,
161
+ totalInputTokens: data.inputTokens || 0,
162
+ totalOutputTokens: data.outputTokens || 0,
163
+ totalTokensSaved: data.tokensSaved || 0,
164
+ totalCost: data.cost || 0,
165
+ totalErrors: data.errors || 0,
166
+ totalFallbacks: data.fallbacks || 0,
167
+ totalCacheHits: data.cacheHits || 0,
168
+ }).onConflictDoUpdate({
169
+ target: [usageDaily.date, usageDaily.providerId, usageDaily.model],
170
+ set: {
171
+ totalRequests: sql`${usageDaily.totalRequests} + ${data.requests || 0}`,
172
+ totalInputTokens: sql`${usageDaily.totalInputTokens} + ${data.inputTokens || 0}`,
173
+ totalOutputTokens: sql`${usageDaily.totalOutputTokens} + ${data.outputTokens || 0}`,
174
+ totalTokensSaved: sql`${usageDaily.totalTokensSaved} + ${data.tokensSaved || 0}`,
175
+ totalCost: sql`${usageDaily.totalCost} + ${data.cost || 0}`,
176
+ totalErrors: sql`${usageDaily.totalErrors} + ${data.errors || 0}`,
177
+ totalFallbacks: sql`${usageDaily.totalFallbacks} + ${data.fallbacks || 0}`,
178
+ totalCacheHits: sql`${usageDaily.totalCacheHits} + ${data.cacheHits || 0}`,
179
+ },
180
+ })
181
+ } catch (err) {
182
+ logger.warn({ error: (err as Error).message }, 'Failed to record daily usage')
183
+ }
184
+ }
185
+ }
186
+
187
+ export const analytics = new AnalyticsAggregator()
@@ -0,0 +1,71 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { Zap } from 'lucide-react'
6
+
7
+ export function useAuth() {
8
+ const [user, setUser] = useState<{ id: string; role: string; name: string } | null>(null)
9
+ const [loading, setLoading] = useState(true)
10
+ const router = useRouter()
11
+
12
+ useEffect(() => {
13
+ const token = localStorage.getItem('synapse_token')
14
+ if (!token) {
15
+ setLoading(false)
16
+ return
17
+ }
18
+
19
+ fetch('/api/auth/login', {
20
+ headers: { Authorization: `Bearer ${token}` },
21
+ })
22
+ .then((r) => {
23
+ if (!r.ok) throw new Error('Invalid token')
24
+ return r.json()
25
+ })
26
+ .then((data) => {
27
+ setUser(data.user)
28
+ setLoading(false)
29
+ })
30
+ .catch(() => {
31
+ localStorage.removeItem('synapse_token')
32
+ localStorage.removeItem('synapse_user')
33
+ setLoading(false)
34
+ })
35
+ }, [router])
36
+
37
+ function logout() {
38
+ localStorage.removeItem('synapse_token')
39
+ localStorage.removeItem('synapse_user')
40
+ setUser(null)
41
+ router.push('/login')
42
+ }
43
+
44
+ return { user, loading, logout }
45
+ }
46
+
47
+ export function AuthGuard({ children }: { children: React.ReactNode }) {
48
+ const { loading, user } = useAuth()
49
+ const router = useRouter()
50
+
51
+ useEffect(() => {
52
+ if (!loading && !user) {
53
+ router.push('/login')
54
+ }
55
+ }, [loading, user, router])
56
+
57
+ if (loading) {
58
+ return (
59
+ <div className="min-h-screen flex items-center justify-center bg-background">
60
+ <div className="flex items-center gap-2 text-muted-foreground">
61
+ <Zap className="h-5 w-5 text-primary animate-pulse" />
62
+ <span className="text-sm">Loading...</span>
63
+ </div>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ if (!user) return null
69
+
70
+ return <>{children}</>
71
+ }
@@ -0,0 +1,105 @@
1
+ import { SignJWT, jwtVerify } from 'jose'
2
+ import { db } from '../db'
3
+ import { apiKeys } from '../db/schema'
4
+ import { eq } from 'drizzle-orm'
5
+ import { createHash, randomBytes } from 'crypto'
6
+
7
+ const JWT_ALG = 'HS256'
8
+
9
+ function getSecret(): Uint8Array {
10
+ const secret = process.env.JWT_SECRET || 'synapse-default-secret-change-me-in-production'
11
+ return new TextEncoder().encode(secret)
12
+ }
13
+
14
+ export async function signJwt(payload: {
15
+ sub: string
16
+ role: string
17
+ name?: string
18
+ }): Promise<string> {
19
+ return new SignJWT({ role: payload.role, name: payload.name })
20
+ .setProtectedHeader({ alg: JWT_ALG })
21
+ .setSubject(payload.sub)
22
+ .setIssuedAt()
23
+ .setExpirationTime('7d')
24
+ .setIssuer('synapse')
25
+ .sign(getSecret())
26
+ }
27
+
28
+ export async function verifyJwt(token: string): Promise<{
29
+ sub: string
30
+ role: string
31
+ name?: string
32
+ } | null> {
33
+ try {
34
+ const { payload } = await jwtVerify(token, getSecret(), { issuer: 'synapse' })
35
+ return {
36
+ sub: payload.sub as string,
37
+ role: (payload as Record<string, unknown>).role as string,
38
+ name: (payload as Record<string, unknown>).name as string | undefined,
39
+ }
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ export async function hashApiKey(key: string): Promise<string> {
46
+ return createHash('sha256').update(key).digest('hex')
47
+ }
48
+
49
+ export async function generateApiKey(name: string): Promise<{ id: string; key: string }> {
50
+ const rawKey = `adn_${randomBytes(32).toString('hex')}`
51
+ const keyHash = await hashApiKey(rawKey)
52
+ const id = crypto.randomUUID()
53
+
54
+ await db.insert(apiKeys).values({
55
+ id,
56
+ name,
57
+ keyHash,
58
+ permissions: '{}',
59
+ })
60
+
61
+ return { id, key: rawKey }
62
+ }
63
+
64
+ export async function validateApiKey(key: string): Promise<{
65
+ valid: boolean
66
+ id?: string
67
+ name?: string
68
+ }> {
69
+ if (!key) return { valid: false }
70
+
71
+ const keyHash = await hashApiKey(key)
72
+
73
+ try {
74
+ const rows = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).limit(1)
75
+ if (rows.length === 0) return { valid: false }
76
+
77
+ await db.update(apiKeys).set({
78
+ lastUsedAt: new Date().toISOString(),
79
+ }).where(eq(apiKeys.id, rows[0].id))
80
+
81
+ return { valid: true, id: rows[0].id, name: rows[0].name }
82
+ } catch {
83
+ return { valid: false }
84
+ }
85
+ }
86
+
87
+ export async function listApiKeys() {
88
+ try {
89
+ const allKeys = await db.select().from(apiKeys)
90
+ return allKeys.map((k) => ({
91
+ id: k.id,
92
+ name: k.name,
93
+ keyPrefix: `adn_****${k.keyHash.slice(-4)}`,
94
+ createdAt: k.createdAt,
95
+ lastUsedAt: k.lastUsedAt,
96
+ requestCount: 0,
97
+ }))
98
+ } catch {
99
+ return []
100
+ }
101
+ }
102
+
103
+ export async function revokeApiKey(id: string) {
104
+ await db.delete(apiKeys).where(eq(apiKeys.id, id))
105
+ }
@@ -0,0 +1,64 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { NextRequest } from 'next/server'
3
+ import { verifyJwt, validateApiKey } from '../auth'
4
+
5
+ export async function authenticateRequest(request: NextRequest): Promise<{
6
+ authenticated: boolean
7
+ userId?: string
8
+ role?: string
9
+ authMethod?: 'jwt' | 'api_key' | 'none'
10
+ }> {
11
+ if (request.nextUrl.pathname.startsWith('/api/v1/')) {
12
+ return checkGatewayAuth(request)
13
+ }
14
+
15
+ if (request.nextUrl.pathname.startsWith('/api/auth/')) {
16
+ return { authenticated: true, authMethod: 'none' }
17
+ }
18
+
19
+ return { authenticated: true, authMethod: 'none' }
20
+ }
21
+
22
+ async function checkGatewayAuth(request: NextRequest): Promise<{
23
+ authenticated: boolean
24
+ userId?: string
25
+ role?: string
26
+ authMethod?: 'jwt' | 'api_key' | 'none'
27
+ }> {
28
+ const authHeader = request.headers.get('authorization')
29
+ if (authHeader?.startsWith('Bearer ')) {
30
+ const token = authHeader.slice(7)
31
+
32
+ if (token.startsWith('adn_')) {
33
+ const result = await validateApiKey(token)
34
+ if (result.valid) {
35
+ return { authenticated: true, userId: result.id, role: 'api_user', authMethod: 'api_key' }
36
+ }
37
+ }
38
+
39
+ const jwtResult = await verifyJwt(token)
40
+ if (jwtResult) {
41
+ return { authenticated: true, userId: jwtResult.sub, role: jwtResult.role, authMethod: 'jwt' }
42
+ }
43
+
44
+ return { authenticated: false }
45
+ }
46
+
47
+ const apiKeyHeader = request.headers.get('x-api-key')
48
+ if (apiKeyHeader) {
49
+ const result = await validateApiKey(apiKeyHeader)
50
+ if (result.valid) {
51
+ return { authenticated: true, userId: result.id, role: 'api_user', authMethod: 'api_key' }
52
+ }
53
+ return { authenticated: false }
54
+ }
55
+
56
+ return { authenticated: true, authMethod: 'none' }
57
+ }
58
+
59
+ export function unauthenticatedResponse() {
60
+ return NextResponse.json(
61
+ { error: { type: 'authentication_error', message: 'Invalid or missing authentication' } },
62
+ { status: 401 },
63
+ )
64
+ }