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,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
|
+
}
|