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,139 @@
|
|
|
1
|
+
import type { Message, NormalizedRequest } from '../providers/types'
|
|
2
|
+
|
|
3
|
+
export interface AnthropicMessage {
|
|
4
|
+
role: 'user' | 'assistant'
|
|
5
|
+
content: string | AnthropicContentBlock[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AnthropicContentBlock {
|
|
9
|
+
type: 'text' | 'image' | 'tool_use' | 'tool_result'
|
|
10
|
+
text?: string
|
|
11
|
+
tool_use_id?: string
|
|
12
|
+
name?: string
|
|
13
|
+
input?: unknown
|
|
14
|
+
content?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AnthropicRequest {
|
|
18
|
+
model: string
|
|
19
|
+
messages: AnthropicMessage[]
|
|
20
|
+
system?: string
|
|
21
|
+
max_tokens: number
|
|
22
|
+
temperature?: number
|
|
23
|
+
stream?: boolean
|
|
24
|
+
tools?: unknown[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toAnthropicRequest(req: NormalizedRequest): AnthropicRequest {
|
|
28
|
+
const messages: AnthropicMessage[] = []
|
|
29
|
+
let system: string | undefined
|
|
30
|
+
|
|
31
|
+
for (const msg of req.messages) {
|
|
32
|
+
if (msg.role === 'system') {
|
|
33
|
+
system = (system ? system + '\n\n' : '') + msg.content
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const anthropicMsg: AnthropicMessage = {
|
|
38
|
+
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
|
39
|
+
content: msg.content,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
|
|
43
|
+
anthropicMsg.content = [
|
|
44
|
+
{ type: 'text', text: msg.content },
|
|
45
|
+
...msg.toolCalls.map((tc) => ({
|
|
46
|
+
type: 'tool_use' as const,
|
|
47
|
+
id: tc.id,
|
|
48
|
+
name: tc.function.name,
|
|
49
|
+
input: JSON.parse(tc.function.arguments),
|
|
50
|
+
})),
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (msg.role === 'tool') {
|
|
55
|
+
anthropicMsg.content = [
|
|
56
|
+
{
|
|
57
|
+
type: 'tool_result' as const,
|
|
58
|
+
tool_use_id: msg.toolCallId,
|
|
59
|
+
content: msg.content,
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
messages.push(anthropicMsg)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (messages.length === 0) {
|
|
68
|
+
messages.push({ role: 'user', content: ' ' })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (messages[0].role !== 'user') {
|
|
72
|
+
messages.unshift({ role: 'user', content: ' ' })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let filtered = messages.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
76
|
+
const merged: AnthropicMessage[] = []
|
|
77
|
+
for (const msg of filtered) {
|
|
78
|
+
if (merged.length > 0 && merged[merged.length - 1].role === msg.role) {
|
|
79
|
+
const last = merged[merged.length - 1]
|
|
80
|
+
const lastText = typeof last.content === 'string' ? last.content : ''
|
|
81
|
+
const currText = typeof msg.content === 'string' ? msg.content : ''
|
|
82
|
+
merged[merged.length - 1] = { ...last, content: lastText + '\n' + currText }
|
|
83
|
+
} else {
|
|
84
|
+
merged.push(msg)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
model: req.model,
|
|
90
|
+
messages: merged,
|
|
91
|
+
system,
|
|
92
|
+
max_tokens: req.maxTokens || 4096,
|
|
93
|
+
temperature: req.temperature,
|
|
94
|
+
stream: req.stream,
|
|
95
|
+
tools: req.tools,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function fromAnthropicResponse(data: Record<string, unknown>) {
|
|
100
|
+
const content = data.content as Array<{ type: string; text?: string }>
|
|
101
|
+
const textContent = content?.filter((c) => c.type === 'text').map((c) => c.text || '').join('') || ''
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
id: data.id as string,
|
|
105
|
+
model: data.model as string,
|
|
106
|
+
choices: [
|
|
107
|
+
{
|
|
108
|
+
index: 0,
|
|
109
|
+
message: { role: 'assistant' as const, content: textContent },
|
|
110
|
+
finishReason: (data.stop_reason as string) || 'end_turn',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
usage: {
|
|
114
|
+
inputTokens: (data.usage as Record<string, number>)?.input_tokens || 0,
|
|
115
|
+
outputTokens: (data.usage as Record<string, number>)?.output_tokens || 0,
|
|
116
|
+
totalTokens:
|
|
117
|
+
((data.usage as Record<string, number>)?.input_tokens || 0) +
|
|
118
|
+
((data.usage as Record<string, number>)?.output_tokens || 0),
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function fromAnthropicChunk(data: Record<string, unknown>) {
|
|
124
|
+
const delta = data.delta as { type: string; text?: string; stop_reason?: string } | undefined
|
|
125
|
+
const isContent = delta?.type === 'text_delta'
|
|
126
|
+
const isStop = delta?.type === 'message_stop' || delta?.stop_reason
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
id: (data.id as string) || '',
|
|
130
|
+
model: data.model as string,
|
|
131
|
+
choices: [
|
|
132
|
+
{
|
|
133
|
+
index: 0,
|
|
134
|
+
delta: isContent ? { role: 'assistant' as const, content: delta?.text || '' } : {},
|
|
135
|
+
finishReason: isStop ? (delta?.stop_reason as string) || 'end_turn' : null,
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { NormalizedRequest } from '../providers/types'
|
|
2
|
+
|
|
3
|
+
export interface GeminiContent {
|
|
4
|
+
role: 'user' | 'model'
|
|
5
|
+
parts: GeminiPart[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface GeminiPart {
|
|
9
|
+
text?: string
|
|
10
|
+
functionCall?: { name: string; args: Record<string, unknown> }
|
|
11
|
+
functionResponse?: { name: string; response: Record<string, unknown> }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GeminiRequest {
|
|
15
|
+
model: string
|
|
16
|
+
contents: GeminiContent[]
|
|
17
|
+
systemInstruction?: { parts: Array<{ text: string }> }
|
|
18
|
+
generationConfig: {
|
|
19
|
+
maxOutputTokens?: number
|
|
20
|
+
temperature?: number
|
|
21
|
+
}
|
|
22
|
+
tools?: unknown[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toGeminiRequest(req: NormalizedRequest): GeminiRequest {
|
|
26
|
+
const contents: GeminiContent[] = []
|
|
27
|
+
const systemParts: Array<{ text: string }> = []
|
|
28
|
+
|
|
29
|
+
for (const msg of req.messages) {
|
|
30
|
+
if (msg.role === 'system') {
|
|
31
|
+
systemParts.push({ text: msg.content })
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const parts: GeminiPart[] = []
|
|
36
|
+
|
|
37
|
+
if (msg.role === 'assistant' && msg.toolCalls) {
|
|
38
|
+
for (const tc of msg.toolCalls) {
|
|
39
|
+
parts.push({
|
|
40
|
+
functionCall: {
|
|
41
|
+
name: tc.function.name,
|
|
42
|
+
args: JSON.parse(tc.function.arguments),
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
if (msg.content) {
|
|
47
|
+
parts.unshift({ text: msg.content })
|
|
48
|
+
}
|
|
49
|
+
} else if (msg.role === 'tool') {
|
|
50
|
+
parts.push({
|
|
51
|
+
functionResponse: {
|
|
52
|
+
name: msg.toolCallId || '',
|
|
53
|
+
response: { content: msg.content },
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
} else {
|
|
57
|
+
parts.push({ text: msg.content })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (parts.length > 0) {
|
|
61
|
+
contents.push({
|
|
62
|
+
role: msg.role === 'assistant' ? 'model' : 'user',
|
|
63
|
+
parts,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const merged: GeminiContent[] = []
|
|
69
|
+
for (const c of contents) {
|
|
70
|
+
if (merged.length > 0 && merged[merged.length - 1].role === c.role) {
|
|
71
|
+
merged[merged.length - 1] = { ...merged[merged.length - 1], parts: [...merged[merged.length - 1].parts, ...c.parts] }
|
|
72
|
+
} else {
|
|
73
|
+
merged.push(c)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
model: req.model,
|
|
79
|
+
contents: merged,
|
|
80
|
+
...(systemParts.length > 0 ? { systemInstruction: { parts: systemParts } } : {}),
|
|
81
|
+
generationConfig: {
|
|
82
|
+
maxOutputTokens: req.maxTokens,
|
|
83
|
+
temperature: req.temperature,
|
|
84
|
+
},
|
|
85
|
+
tools: req.tools,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function fromGeminiResponse(data: Record<string, unknown>) {
|
|
90
|
+
const candidates = data.candidates as Array<{ content: { parts: GeminiPart[] }; finishReason: string }> | undefined
|
|
91
|
+
const candidate = candidates?.[0]
|
|
92
|
+
const text = candidate?.content?.parts?.map((p) => p.text || '').join('') || ''
|
|
93
|
+
|
|
94
|
+
const usage = data.usageMetadata as { promptTokenCount: number; candidatesTokenCount: number; totalTokenCount: number } | undefined
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
id: crypto.randomUUID(),
|
|
98
|
+
model: data.model as string,
|
|
99
|
+
choices: [
|
|
100
|
+
{
|
|
101
|
+
index: 0,
|
|
102
|
+
message: { role: 'assistant' as const, content: text },
|
|
103
|
+
finishReason: candidate?.finishReason || 'STOP',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
usage: {
|
|
107
|
+
inputTokens: usage?.promptTokenCount || 0,
|
|
108
|
+
outputTokens: usage?.candidatesTokenCount || 0,
|
|
109
|
+
totalTokens: usage?.totalTokenCount || 0,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function fromGeminiChunk(data: Record<string, unknown>) {
|
|
115
|
+
const candidates = data.candidates as Array<{ content: { parts: GeminiPart[] }; finishReason: string }> | undefined
|
|
116
|
+
const candidate = candidates?.[0]
|
|
117
|
+
const text = candidate?.content?.parts?.map((p) => p.text || '').join('') || ''
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
id: '',
|
|
121
|
+
model: '',
|
|
122
|
+
choices: [
|
|
123
|
+
{
|
|
124
|
+
index: 0,
|
|
125
|
+
delta: text ? { role: 'assistant' as const, content: text } : {},
|
|
126
|
+
finishReason: candidate?.finishReason === 'STOP' ? 'stop' : null,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface OpenAIMessage {
|
|
2
|
+
role: 'system' | 'user' | 'assistant' | 'tool'
|
|
3
|
+
content: string | null
|
|
4
|
+
tool_call_id?: string
|
|
5
|
+
tool_calls?: Array<{
|
|
6
|
+
id: string
|
|
7
|
+
type: 'function'
|
|
8
|
+
function: { name: string; arguments: string }
|
|
9
|
+
}>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface OpenAIRequest {
|
|
13
|
+
model: string
|
|
14
|
+
messages: OpenAIMessage[]
|
|
15
|
+
temperature?: number
|
|
16
|
+
max_tokens?: number
|
|
17
|
+
stream?: boolean
|
|
18
|
+
tools?: unknown[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toOpenAIRequest(req: import('../providers/types').NormalizedRequest): OpenAIRequest {
|
|
22
|
+
return {
|
|
23
|
+
model: req.model,
|
|
24
|
+
messages: req.messages.map((m) => ({
|
|
25
|
+
role: m.role,
|
|
26
|
+
content: m.content,
|
|
27
|
+
...(m.toolCallId ? { tool_call_id: m.toolCallId } : {}),
|
|
28
|
+
...(m.toolCalls ? { tool_calls: m.toolCalls } : {}),
|
|
29
|
+
})),
|
|
30
|
+
temperature: req.temperature,
|
|
31
|
+
max_tokens: req.maxTokens,
|
|
32
|
+
stream: req.stream,
|
|
33
|
+
tools: req.tools,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function fromOpenAIResponse(data: Record<string, unknown>) {
|
|
38
|
+
const choices = data.choices as Array<{
|
|
39
|
+
index: number
|
|
40
|
+
message: { role: string; content: string }
|
|
41
|
+
finish_reason: string
|
|
42
|
+
}>
|
|
43
|
+
|
|
44
|
+
const usage = data.usage as { prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
id: data.id as string,
|
|
48
|
+
model: data.model as string,
|
|
49
|
+
choices: choices.map((c) => ({
|
|
50
|
+
index: c.index,
|
|
51
|
+
message: { role: c.message.role as 'assistant', content: c.message.content },
|
|
52
|
+
finishReason: c.finish_reason,
|
|
53
|
+
})),
|
|
54
|
+
usage: {
|
|
55
|
+
inputTokens: usage?.prompt_tokens || 0,
|
|
56
|
+
outputTokens: usage?.completion_tokens || 0,
|
|
57
|
+
totalTokens: usage?.total_tokens || 0,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function fromOpenAIChunk(data: Record<string, unknown>) {
|
|
63
|
+
const choices = data.choices as Array<{
|
|
64
|
+
index: number
|
|
65
|
+
delta: { role?: string; content?: string }
|
|
66
|
+
finish_reason: string | null
|
|
67
|
+
}>
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
id: data.id as string,
|
|
71
|
+
model: data.model as string,
|
|
72
|
+
choices: choices.map((c) => ({
|
|
73
|
+
index: c.index,
|
|
74
|
+
delta: c.delta.content ? { role: 'assistant' as const, content: c.delta.content } : {},
|
|
75
|
+
finishReason: c.finish_reason,
|
|
76
|
+
})),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { db } from '../db'
|
|
2
|
+
import { providerHealth, providers } from '../db/schema'
|
|
3
|
+
import { eq } from 'drizzle-orm'
|
|
4
|
+
import { registry } from '../providers/registry'
|
|
5
|
+
import { logger } from '../utils/logger'
|
|
6
|
+
import { v4 as uuid } from 'uuid'
|
|
7
|
+
|
|
8
|
+
interface HealthCheckResult {
|
|
9
|
+
providerId: string
|
|
10
|
+
status: 'healthy' | 'degraded' | 'down' | 'disabled'
|
|
11
|
+
latencyMs: number
|
|
12
|
+
timestamp: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class HealthChecker {
|
|
16
|
+
private intervalHandle: ReturnType<typeof setInterval> | null = null
|
|
17
|
+
|
|
18
|
+
start(intervalMs = 5 * 60 * 1000) {
|
|
19
|
+
if (this.intervalHandle) return
|
|
20
|
+
|
|
21
|
+
logger.info({ intervalMinutes: intervalMs / 60000 }, 'Health checker started')
|
|
22
|
+
|
|
23
|
+
this.intervalHandle = setInterval(async () => {
|
|
24
|
+
try {
|
|
25
|
+
await this.checkAll()
|
|
26
|
+
} catch (err) {
|
|
27
|
+
logger.error({ error: (err as Error).message }, 'Health check failed')
|
|
28
|
+
}
|
|
29
|
+
}, intervalMs)
|
|
30
|
+
|
|
31
|
+
setTimeout(() => this.checkAll(), 10000)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
stop() {
|
|
35
|
+
if (this.intervalHandle) {
|
|
36
|
+
clearInterval(this.intervalHandle)
|
|
37
|
+
this.intervalHandle = null
|
|
38
|
+
logger.info('Health checker stopped')
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async checkAll(): Promise<HealthCheckResult[]> {
|
|
43
|
+
const adapters = registry.list()
|
|
44
|
+
const results: HealthCheckResult[] = []
|
|
45
|
+
|
|
46
|
+
for (const adapter of adapters) {
|
|
47
|
+
try {
|
|
48
|
+
const result = await adapter.healthCheck()
|
|
49
|
+
await this.recordResult(adapter.info.id, result.status, result.latencyMs)
|
|
50
|
+
results.push({
|
|
51
|
+
providerId: adapter.info.id,
|
|
52
|
+
status: result.status,
|
|
53
|
+
latencyMs: result.latencyMs,
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
})
|
|
56
|
+
} catch (err) {
|
|
57
|
+
await this.recordResult(adapter.info.id, 'down', 0)
|
|
58
|
+
results.push({
|
|
59
|
+
providerId: adapter.info.id,
|
|
60
|
+
status: 'down',
|
|
61
|
+
latencyMs: 0,
|
|
62
|
+
timestamp: new Date().toISOString(),
|
|
63
|
+
})
|
|
64
|
+
logger.warn({ provider: adapter.info.id, error: (err as Error).message }, 'Health check failed')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return results
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async checkProvider(providerId: string): Promise<HealthCheckResult | null> {
|
|
72
|
+
const adapters = registry.list()
|
|
73
|
+
const adapter = adapters.find((a) => a.info.id === providerId)
|
|
74
|
+
if (!adapter) return null
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const result = await adapter.healthCheck()
|
|
78
|
+
await this.recordResult(providerId, result.status, result.latencyMs)
|
|
79
|
+
return {
|
|
80
|
+
providerId,
|
|
81
|
+
status: result.status,
|
|
82
|
+
latencyMs: result.latencyMs,
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
await this.recordResult(providerId, 'down', 0)
|
|
87
|
+
return { providerId, status: 'down', latencyMs: 0, timestamp: new Date().toISOString() }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async recordResult(providerId: string, status: 'healthy' | 'degraded' | 'down' | 'disabled', latencyMs: number) {
|
|
92
|
+
try {
|
|
93
|
+
const existing = await db.select().from(providerHealth)
|
|
94
|
+
.where(eq(providerHealth.providerId, providerId)).limit(1)
|
|
95
|
+
|
|
96
|
+
const now = new Date().toISOString()
|
|
97
|
+
|
|
98
|
+
if (existing.length > 0) {
|
|
99
|
+
const row = existing[0]
|
|
100
|
+
const consecutiveFailures = status === 'down'
|
|
101
|
+
? (row.consecutiveFailures || 0) + 1
|
|
102
|
+
: 0
|
|
103
|
+
|
|
104
|
+
const totalChecks = (row.successRate || 0) * 100 + 1
|
|
105
|
+
const newSuccessRate = status === 'healthy'
|
|
106
|
+
? ((row.successRate || 0) * 100 + 1) / totalChecks
|
|
107
|
+
: (row.successRate || 0) * 100 / totalChecks
|
|
108
|
+
|
|
109
|
+
const totalLatencyChecks = 100
|
|
110
|
+
const newAvgLatency = Math.round(
|
|
111
|
+
((row.avgLatencyMs || 0) * (totalLatencyChecks - 1) + latencyMs) / totalLatencyChecks
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
await db.update(providerHealth).set({
|
|
115
|
+
status: status as 'healthy' | 'degraded' | 'down' | 'disabled',
|
|
116
|
+
avgLatencyMs: newAvgLatency,
|
|
117
|
+
errorRate: 1 - newSuccessRate,
|
|
118
|
+
successRate: newSuccessRate,
|
|
119
|
+
consecutiveFailures,
|
|
120
|
+
lastCheckAt: now,
|
|
121
|
+
...(status === 'healthy' ? { lastSuccessAt: now } : {}),
|
|
122
|
+
updatedAt: now,
|
|
123
|
+
}).where(eq(providerHealth.id, row.id))
|
|
124
|
+
} else {
|
|
125
|
+
await db.insert(providerHealth).values({
|
|
126
|
+
id: uuid(),
|
|
127
|
+
providerId,
|
|
128
|
+
status: status as 'healthy' | 'degraded' | 'down' | 'disabled',
|
|
129
|
+
avgLatencyMs: latencyMs,
|
|
130
|
+
p95LatencyMs: latencyMs,
|
|
131
|
+
errorRate: status === 'healthy' ? 0 : 1,
|
|
132
|
+
successRate: status === 'healthy' ? 1 : 0,
|
|
133
|
+
consecutiveFailures: status === 'down' ? 1 : 0,
|
|
134
|
+
lastCheckAt: now,
|
|
135
|
+
lastSuccessAt: status === 'healthy' ? now : null,
|
|
136
|
+
updatedAt: now,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
logger.warn({ error: (err as Error).message }, 'Failed to record health result')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async getProviderHealth(providerId?: string) {
|
|
145
|
+
try {
|
|
146
|
+
if (providerId) {
|
|
147
|
+
const rows = await db.select().from(providerHealth)
|
|
148
|
+
.where(eq(providerHealth.providerId, providerId)).limit(1)
|
|
149
|
+
return rows[0] || null
|
|
150
|
+
}
|
|
151
|
+
return await db.select().from(providerHealth)
|
|
152
|
+
} catch {
|
|
153
|
+
return []
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const healthChecker = new HealthChecker()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { registerTool, registerResource } from './registry'
|
|
2
|
+
import { analytics } from '../analytics'
|
|
3
|
+
import { memoryStore } from '../memory'
|
|
4
|
+
import { semanticCacheStore } from '../cache'
|
|
5
|
+
import { healthChecker } from '../health'
|
|
6
|
+
|
|
7
|
+
registerTool({
|
|
8
|
+
name: 'get_usage_stats',
|
|
9
|
+
description: 'Get usage statistics for the last N days',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: { days: { type: 'number', description: 'Number of days (default: 7)' } },
|
|
13
|
+
},
|
|
14
|
+
handler: async (args) => analytics.getUsageStats((args.days as number) || 7),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
registerTool({
|
|
18
|
+
name: 'get_health_status',
|
|
19
|
+
description: 'Get health status of all providers',
|
|
20
|
+
inputSchema: { type: 'object', properties: {} },
|
|
21
|
+
handler: async () => healthChecker.getProviderHealth(),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
registerTool({
|
|
25
|
+
name: 'get_cache_stats',
|
|
26
|
+
description: 'Get semantic cache statistics',
|
|
27
|
+
inputSchema: { type: 'object', properties: {} },
|
|
28
|
+
handler: async () => semanticCacheStore.getStats(),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
registerTool({
|
|
32
|
+
name: 'search_memory',
|
|
33
|
+
description: 'Search persistent memory for information',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
query: { type: 'string', description: 'Search query' },
|
|
38
|
+
types: { type: 'array', items: { type: 'string' }, description: 'Memory types to search' },
|
|
39
|
+
},
|
|
40
|
+
required: ['query'],
|
|
41
|
+
},
|
|
42
|
+
handler: async (args) => memoryStore.search(args.query as string, args.types as import('../memory/types').MemoryType[] | undefined),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
registerTool({
|
|
46
|
+
name: 'clear_cache',
|
|
47
|
+
description: 'Clear the semantic cache',
|
|
48
|
+
inputSchema: { type: 'object', properties: {} },
|
|
49
|
+
handler: async () => { await semanticCacheStore.clear(); return { success: true } },
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
registerTool({
|
|
53
|
+
name: 'trigger_distillation',
|
|
54
|
+
description: 'Trigger experience distillation run',
|
|
55
|
+
inputSchema: { type: 'object', properties: {} },
|
|
56
|
+
handler: async () => {
|
|
57
|
+
const { experienceDistiller } = await import('../distiller')
|
|
58
|
+
return experienceDistiller.run()
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
registerResource({
|
|
63
|
+
uri: 'synapse://config',
|
|
64
|
+
name: 'Synapse Configuration',
|
|
65
|
+
description: 'Current gateway configuration',
|
|
66
|
+
mimeType: 'application/json',
|
|
67
|
+
read: async () => {
|
|
68
|
+
const { loadConfig } = await import('../config')
|
|
69
|
+
const config = await loadConfig()
|
|
70
|
+
return JSON.stringify(config, null, 2)
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
registerResource({
|
|
75
|
+
uri: 'synapse://providers',
|
|
76
|
+
name: 'Provider List',
|
|
77
|
+
description: 'List of registered providers',
|
|
78
|
+
mimeType: 'application/json',
|
|
79
|
+
read: async () => {
|
|
80
|
+
const { registry } = await import('../providers/registry')
|
|
81
|
+
return JSON.stringify(registry.list().map((a) => ({ id: a.info.id, name: a.info.name, prefix: a.info.prefix })), null, 2)
|
|
82
|
+
},
|
|
83
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { registerTool, registerResource, listTools, listResources, callTool, readResource } from './registry'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface MCPTool {
|
|
2
|
+
name: string
|
|
3
|
+
description: string
|
|
4
|
+
inputSchema: Record<string, unknown>
|
|
5
|
+
handler: (args: Record<string, unknown>) => Promise<unknown>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface MCPResource {
|
|
9
|
+
uri: string
|
|
10
|
+
name: string
|
|
11
|
+
description: string
|
|
12
|
+
mimeType: string
|
|
13
|
+
read: () => Promise<string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const tools: MCPTool[] = []
|
|
17
|
+
const resources: MCPResource[] = []
|
|
18
|
+
|
|
19
|
+
export function registerTool(tool: MCPTool) {
|
|
20
|
+
const existing = tools.findIndex((t) => t.name === tool.name)
|
|
21
|
+
if (existing >= 0) tools[existing] = tool
|
|
22
|
+
else tools.push(tool)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerResource(resource: MCPResource) {
|
|
26
|
+
const existing = resources.findIndex((r) => r.uri === resource.uri)
|
|
27
|
+
if (existing >= 0) resources[existing] = resource
|
|
28
|
+
else resources.push(resource)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function listTools(): Array<{ name: string; description: string; inputSchema: Record<string, unknown> }> {
|
|
32
|
+
return tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listResources(): Array<{ uri: string; name: string; description: string; mimeType: string }> {
|
|
36
|
+
return resources.map((r) => ({ uri: r.uri, name: r.name, description: r.description, mimeType: r.mimeType }))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
40
|
+
const tool = tools.find((t) => t.name === name)
|
|
41
|
+
if (!tool) throw new Error(`Tool not found: ${name}`)
|
|
42
|
+
return tool.handler(args)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function readResource(uri: string): Promise<string> {
|
|
46
|
+
const resource = resources.find((r) => r.uri === uri)
|
|
47
|
+
if (!resource) throw new Error(`Resource not found: ${uri}`)
|
|
48
|
+
return resource.read()
|
|
49
|
+
}
|