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,112 @@
|
|
|
1
|
+
import { BaseAdapter } from '../base-adapter'
|
|
2
|
+
import type { ProviderInfo, ProviderAccount, NormalizedRequest, NormalizedResponse, NormalizedChunk, Model, HealthStatusType } from '../types'
|
|
3
|
+
|
|
4
|
+
export class GeminiAdapter extends BaseAdapter {
|
|
5
|
+
info: ProviderInfo
|
|
6
|
+
|
|
7
|
+
constructor(info?: Partial<ProviderInfo>) {
|
|
8
|
+
super()
|
|
9
|
+
this.info = {
|
|
10
|
+
id: info?.id || 'gemini',
|
|
11
|
+
name: info?.name || 'Gemini',
|
|
12
|
+
prefix: info?.prefix || 'gm/',
|
|
13
|
+
authType: info?.authType || 'api_key',
|
|
14
|
+
baseUrl: info?.baseUrl || 'https://generativelanguage.googleapis.com',
|
|
15
|
+
enabled: info?.enabled ?? true,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async fetchModels(): Promise<Model[]> {
|
|
20
|
+
try {
|
|
21
|
+
const data = await this.fetchJson<{ models?: Array<{ name: string; displayName?: string; inputTokenLimit?: number; outputTokenLimit?: number }> }>(
|
|
22
|
+
`${this.info.baseUrl}/v1beta/models`,
|
|
23
|
+
{ headers: {} }
|
|
24
|
+
)
|
|
25
|
+
return (data.models || [])
|
|
26
|
+
.filter((m) => m.name.includes('gemini'))
|
|
27
|
+
.map((m) => {
|
|
28
|
+
const id = m.name.replace('models/', '')
|
|
29
|
+
return {
|
|
30
|
+
id: `${this.info.prefix}${id}`,
|
|
31
|
+
name: id,
|
|
32
|
+
displayName: m.displayName || id,
|
|
33
|
+
providerId: this.info.id,
|
|
34
|
+
pricingTier: 'pay_per_use' as const,
|
|
35
|
+
contextWindow: m.inputTokenLimit,
|
|
36
|
+
available: true,
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
} catch {
|
|
40
|
+
return [
|
|
41
|
+
{ id: 'gm/gemini-2.5-flash', name: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', providerId: this.info.id, pricingTier: 'pay_per_use', contextWindow: 1048576, available: true },
|
|
42
|
+
{ id: 'gm/gemini-2.5-pro', name: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro', providerId: this.info.id, pricingTier: 'pay_per_use', contextWindow: 2097152, available: true },
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async chatCompletion(req: NormalizedRequest, account: ProviderAccount): Promise<NormalizedResponse> {
|
|
48
|
+
const startTime = Date.now()
|
|
49
|
+
const modelName = req.model.replace('gm/', '')
|
|
50
|
+
const contents = req.messages
|
|
51
|
+
.filter((m) => m.role !== 'system')
|
|
52
|
+
.map((m) => ({
|
|
53
|
+
role: m.role === 'assistant' ? 'model' : 'user',
|
|
54
|
+
parts: [{ text: m.content }],
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
const systemInstruction = req.messages.find((m) => m.role === 'system')
|
|
58
|
+
|
|
59
|
+
const geminiBody: Record<string, unknown> = {
|
|
60
|
+
contents,
|
|
61
|
+
generationConfig: {
|
|
62
|
+
temperature: req.temperature,
|
|
63
|
+
maxOutputTokens: req.maxTokens,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
if (systemInstruction) {
|
|
67
|
+
geminiBody.systemInstruction = { parts: [{ text: systemInstruction.content }] }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const raw = await this.fetchJson<{
|
|
71
|
+
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> }; finishReason?: string }>
|
|
72
|
+
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number }
|
|
73
|
+
}>(`${this.info.baseUrl}/v1beta/models/${modelName}:generateContent?key=${account.authData.apiKey}`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify(geminiBody),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const content = raw.candidates?.[0]?.content?.parts?.[0]?.text || ''
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
id: crypto.randomUUID(),
|
|
83
|
+
model: modelName,
|
|
84
|
+
choices: [{
|
|
85
|
+
index: 0,
|
|
86
|
+
message: { role: 'assistant', content },
|
|
87
|
+
finishReason: raw.candidates?.[0]?.finishReason || 'stop',
|
|
88
|
+
}],
|
|
89
|
+
usage: {
|
|
90
|
+
inputTokens: raw.usageMetadata?.promptTokenCount || 0,
|
|
91
|
+
outputTokens: raw.usageMetadata?.candidatesTokenCount || 0,
|
|
92
|
+
totalTokens: raw.usageMetadata?.totalTokenCount || 0,
|
|
93
|
+
},
|
|
94
|
+
latencyMs: Date.now() - startTime,
|
|
95
|
+
provider: this.info.name,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async *chatCompletionStream(): AsyncIterable<NormalizedChunk> {
|
|
100
|
+
yield { id: '', model: '', choices: [{ index: 0, delta: { content: 'Streaming not implemented for Gemini adapter yet' }, finishReason: 'stop' }] }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async healthCheck(): Promise<{ status: HealthStatusType; latencyMs: number }> {
|
|
104
|
+
const start = Date.now()
|
|
105
|
+
try {
|
|
106
|
+
await fetch(`${this.info.baseUrl}/v1beta/models?key=test`, { method: 'GET', signal: AbortSignal.timeout(10000) })
|
|
107
|
+
return { status: 'healthy', latencyMs: Date.now() - start }
|
|
108
|
+
} catch {
|
|
109
|
+
return { status: 'down', latencyMs: Date.now() - start }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { BaseAdapter } from '../base-adapter'
|
|
2
|
+
import type { ProviderInfo, ProviderAccount, NormalizedRequest, NormalizedResponse, NormalizedChunk, Model, HealthStatusType } from '../types'
|
|
3
|
+
|
|
4
|
+
export class OpenAIAdapter extends BaseAdapter {
|
|
5
|
+
info: ProviderInfo
|
|
6
|
+
|
|
7
|
+
constructor(info?: Partial<ProviderInfo>) {
|
|
8
|
+
super()
|
|
9
|
+
this.info = {
|
|
10
|
+
id: info?.id || 'openai',
|
|
11
|
+
name: info?.name || 'OpenAI',
|
|
12
|
+
prefix: info?.prefix || 'oa/',
|
|
13
|
+
authType: info?.authType || 'api_key',
|
|
14
|
+
baseUrl: info?.baseUrl || 'https://api.openai.com/v1',
|
|
15
|
+
enabled: info?.enabled ?? true,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async fetchModels(): Promise<Model[]> {
|
|
20
|
+
try {
|
|
21
|
+
const data = await this.fetchJson<{ data: Array<{ id: string }> }>(
|
|
22
|
+
`${this.info.baseUrl}/models`,
|
|
23
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
24
|
+
)
|
|
25
|
+
return data.data
|
|
26
|
+
.filter((m) => m.id.startsWith('gpt-') || m.id.startsWith('o1') || m.id.startsWith('o3'))
|
|
27
|
+
.map((m) => ({
|
|
28
|
+
id: `${this.info.prefix}${m.id}`,
|
|
29
|
+
name: m.id,
|
|
30
|
+
displayName: m.id,
|
|
31
|
+
providerId: this.info.id,
|
|
32
|
+
pricingTier: 'pay_per_use' as const,
|
|
33
|
+
available: true,
|
|
34
|
+
}))
|
|
35
|
+
} catch {
|
|
36
|
+
return []
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async chatCompletion(req: NormalizedRequest, account: ProviderAccount): Promise<NormalizedResponse> {
|
|
41
|
+
const startTime = Date.now()
|
|
42
|
+
const body = this.createOpenAIRequest(req)
|
|
43
|
+
|
|
44
|
+
const raw = await this.fetchJson<{
|
|
45
|
+
id?: string
|
|
46
|
+
model?: string
|
|
47
|
+
choices?: Array<{ index?: number; message?: { role?: string; content?: string }; finish_reason?: string }>
|
|
48
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
|
49
|
+
}>(`${this.info.baseUrl}/chat/completions`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
Authorization: `Bearer ${account.authData.apiKey}`,
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return this.parseOpenAIResponse(raw, this.info.name, startTime)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async *chatCompletionStream(req: NormalizedRequest, account: ProviderAccount): AsyncIterable<NormalizedChunk> {
|
|
62
|
+
const body = { ...this.createOpenAIRequest(req), stream: true }
|
|
63
|
+
const response = await this.fetchWithRetry(`${this.info.baseUrl}/chat/completions`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
Authorization: `Bearer ${account.authData.apiKey}`,
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify(body),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (!response.body) return
|
|
73
|
+
|
|
74
|
+
const reader = response.body.getReader()
|
|
75
|
+
const decoder = new TextDecoder()
|
|
76
|
+
let buffer = ''
|
|
77
|
+
|
|
78
|
+
while (true) {
|
|
79
|
+
const { done, value } = await reader.read()
|
|
80
|
+
if (done) break
|
|
81
|
+
|
|
82
|
+
buffer += decoder.decode(value, { stream: true })
|
|
83
|
+
const lines = buffer.split('\n')
|
|
84
|
+
buffer = lines.pop() || ''
|
|
85
|
+
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
const trimmed = line.trim()
|
|
88
|
+
if (!trimmed.startsWith('data: ')) continue
|
|
89
|
+
const data = trimmed.slice(6)
|
|
90
|
+
if (data === '[DONE]') return
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(data)
|
|
94
|
+
yield {
|
|
95
|
+
id: parsed.id || '',
|
|
96
|
+
model: parsed.model || '',
|
|
97
|
+
choices: (parsed.choices || []).map((c: Record<string, unknown>) => ({
|
|
98
|
+
index: (c.index as number) || 0,
|
|
99
|
+
delta: {
|
|
100
|
+
role: (c.delta as Record<string, unknown>)?.role as 'assistant' | undefined,
|
|
101
|
+
content: (c.delta as Record<string, unknown>)?.content as string | undefined,
|
|
102
|
+
},
|
|
103
|
+
finishReason: (c.finish_reason as string) || null,
|
|
104
|
+
})),
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async healthCheck(): Promise<{ status: HealthStatusType; latencyMs: number }> {
|
|
114
|
+
const start = Date.now()
|
|
115
|
+
try {
|
|
116
|
+
await fetch(`${this.info.baseUrl}/models`, { method: 'GET', signal: AbortSignal.timeout(10000) })
|
|
117
|
+
return { status: 'healthy', latencyMs: Date.now() - start }
|
|
118
|
+
} catch {
|
|
119
|
+
return { status: 'down', latencyMs: Date.now() - start }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { BaseAdapter } from '../base-adapter'
|
|
2
|
+
import type { ProviderInfo, ProviderAccount, NormalizedRequest, NormalizedResponse, NormalizedChunk, Model, HealthStatusType } from '../types'
|
|
3
|
+
|
|
4
|
+
export class OpenRouterAdapter extends BaseAdapter {
|
|
5
|
+
info: ProviderInfo
|
|
6
|
+
|
|
7
|
+
constructor(info?: Partial<ProviderInfo>) {
|
|
8
|
+
super()
|
|
9
|
+
this.info = {
|
|
10
|
+
id: info?.id || 'openrouter',
|
|
11
|
+
name: info?.name || 'OpenRouter',
|
|
12
|
+
prefix: info?.prefix || 'or/',
|
|
13
|
+
authType: info?.authType || 'api_key',
|
|
14
|
+
baseUrl: info?.baseUrl || 'https://openrouter.ai/api/v1',
|
|
15
|
+
enabled: info?.enabled ?? true,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async fetchModels(): Promise<Model[]> {
|
|
20
|
+
try {
|
|
21
|
+
const data = await this.fetchJson<{ data: Array<{ id: string; name?: string; context_length?: number; pricing?: { prompt?: string; completion?: string } }> }>(
|
|
22
|
+
`${this.info.baseUrl}/models`,
|
|
23
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
24
|
+
)
|
|
25
|
+
return data.data.map((m) => ({
|
|
26
|
+
id: `${this.info.prefix}${m.id}`,
|
|
27
|
+
name: m.id,
|
|
28
|
+
displayName: m.name || m.id,
|
|
29
|
+
providerId: this.info.id,
|
|
30
|
+
pricingTier: 'pay_per_use' as const,
|
|
31
|
+
costPer1mInput: m.pricing?.prompt ? parseFloat(m.pricing.prompt) * 1_000_000 : undefined,
|
|
32
|
+
costPer1mOutput: m.pricing?.completion ? parseFloat(m.pricing.completion) * 1_000_000 : undefined,
|
|
33
|
+
contextWindow: m.context_length,
|
|
34
|
+
available: true,
|
|
35
|
+
}))
|
|
36
|
+
} catch {
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async chatCompletion(req: NormalizedRequest, account: ProviderAccount): Promise<NormalizedResponse> {
|
|
42
|
+
const startTime = Date.now()
|
|
43
|
+
const body = this.createOpenAIRequest(req)
|
|
44
|
+
const raw = await this.fetchJson<{
|
|
45
|
+
id?: string; model?: string
|
|
46
|
+
choices?: Array<{ index?: number; message?: { role?: string; content?: string }; finish_reason?: string }>
|
|
47
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
|
48
|
+
}>(`${this.info.baseUrl}/chat/completions`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: {
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
Authorization: `Bearer ${account.authData.apiKey}`,
|
|
53
|
+
'HTTP-Referer': 'https://synapse.dev',
|
|
54
|
+
'X-Title': 'Synapse',
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
})
|
|
58
|
+
return this.parseOpenAIResponse(raw, this.info.name, startTime)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async *chatCompletionStream(req: NormalizedRequest, account: ProviderAccount): AsyncIterable<NormalizedChunk> {
|
|
62
|
+
const body = { ...this.createOpenAIRequest(req), stream: true }
|
|
63
|
+
const response = await this.fetchWithRetry(`${this.info.baseUrl}/chat/completions`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
Authorization: `Bearer ${account.authData.apiKey}`,
|
|
68
|
+
'HTTP-Referer': 'https://synapse.dev',
|
|
69
|
+
'X-Title': 'Synapse',
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
})
|
|
73
|
+
if (!response.body) return
|
|
74
|
+
const reader = response.body.getReader()
|
|
75
|
+
const decoder = new TextDecoder()
|
|
76
|
+
let buffer = ''
|
|
77
|
+
while (true) {
|
|
78
|
+
const { done, value } = await reader.read()
|
|
79
|
+
if (done) break
|
|
80
|
+
buffer += decoder.decode(value, { stream: true })
|
|
81
|
+
const lines = buffer.split('\n')
|
|
82
|
+
buffer = lines.pop() || ''
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
const trimmed = line.trim()
|
|
85
|
+
if (!trimmed.startsWith('data: ')) continue
|
|
86
|
+
const data = trimmed.slice(6)
|
|
87
|
+
if (data === '[DONE]') return
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(data)
|
|
90
|
+
yield {
|
|
91
|
+
id: parsed.id || '', model: parsed.model || '',
|
|
92
|
+
choices: (parsed.choices || []).map((c: Record<string, unknown>) => ({
|
|
93
|
+
index: (c.index as number) || 0,
|
|
94
|
+
delta: { role: (c.delta as Record<string, unknown>)?.role as 'assistant' | undefined, content: (c.delta as Record<string, unknown>)?.content as string | undefined },
|
|
95
|
+
finishReason: (c.finish_reason as string) || null,
|
|
96
|
+
})),
|
|
97
|
+
}
|
|
98
|
+
} catch { continue }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async healthCheck(): Promise<{ status: HealthStatusType; latencyMs: number }> {
|
|
104
|
+
const start = Date.now()
|
|
105
|
+
try {
|
|
106
|
+
await fetch(`${this.info.baseUrl}/models`, { method: 'GET', signal: AbortSignal.timeout(10000) })
|
|
107
|
+
return { status: 'healthy', latencyMs: Date.now() - start }
|
|
108
|
+
} catch {
|
|
109
|
+
return { status: 'down', latencyMs: Date.now() - start }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProviderAdapter,
|
|
3
|
+
ProviderInfo,
|
|
4
|
+
ProviderAccount,
|
|
5
|
+
NormalizedRequest,
|
|
6
|
+
NormalizedResponse,
|
|
7
|
+
NormalizedChunk,
|
|
8
|
+
Model,
|
|
9
|
+
HealthStatusType,
|
|
10
|
+
Message,
|
|
11
|
+
} from './types'
|
|
12
|
+
|
|
13
|
+
export abstract class BaseAdapter implements ProviderAdapter {
|
|
14
|
+
abstract info: ProviderInfo
|
|
15
|
+
|
|
16
|
+
protected timeout = 30000
|
|
17
|
+
protected maxRetries = 3
|
|
18
|
+
|
|
19
|
+
abstract fetchModels(): Promise<Model[]>
|
|
20
|
+
abstract chatCompletion(req: NormalizedRequest, account: ProviderAccount): Promise<NormalizedResponse>
|
|
21
|
+
abstract chatCompletionStream(req: NormalizedRequest, account: ProviderAccount): AsyncIterable<NormalizedChunk>
|
|
22
|
+
abstract healthCheck(): Promise<{ status: HealthStatusType; latencyMs: number }>
|
|
23
|
+
|
|
24
|
+
protected async fetchWithRetry(
|
|
25
|
+
url: string,
|
|
26
|
+
options: RequestInit,
|
|
27
|
+
maxRetries = this.maxRetries
|
|
28
|
+
): Promise<Response> {
|
|
29
|
+
let lastError: Error | null = null
|
|
30
|
+
|
|
31
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
32
|
+
try {
|
|
33
|
+
const controller = new AbortController()
|
|
34
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
|
35
|
+
|
|
36
|
+
const response = await fetch(url, {
|
|
37
|
+
...options,
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
clearTimeout(timeoutId)
|
|
42
|
+
|
|
43
|
+
if (response.status === 429 && attempt < maxRetries - 1) {
|
|
44
|
+
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return response
|
|
50
|
+
} catch (error) {
|
|
51
|
+
lastError = error as Error
|
|
52
|
+
if (attempt < maxRetries - 1) {
|
|
53
|
+
const delay = Math.pow(2, attempt) * 500
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw lastError || new Error('Request failed after retries')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
protected async fetchJson<T>(url: string, options: RequestInit): Promise<T> {
|
|
63
|
+
const response = await this.fetchWithRetry(url, options)
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const body = await response.text().catch(() => '')
|
|
66
|
+
throw new Error(`HTTP ${response.status}: ${body}`)
|
|
67
|
+
}
|
|
68
|
+
return response.json() as Promise<T>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
protected createOpenAIRequest(req: NormalizedRequest): Record<string, unknown> {
|
|
72
|
+
const body: Record<string, unknown> = {
|
|
73
|
+
model: req.model,
|
|
74
|
+
messages: req.messages.map((m) => ({
|
|
75
|
+
role: m.role,
|
|
76
|
+
content: m.content,
|
|
77
|
+
})),
|
|
78
|
+
stream: req.stream ?? false,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (req.temperature !== undefined) body.temperature = req.temperature
|
|
82
|
+
if (req.maxTokens !== undefined) body.max_tokens = req.maxTokens
|
|
83
|
+
if (req.tools) body.tools = req.tools
|
|
84
|
+
|
|
85
|
+
return body
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
protected parseOpenAIResponse(raw: {
|
|
89
|
+
id?: string
|
|
90
|
+
model?: string
|
|
91
|
+
choices?: Array<{
|
|
92
|
+
index?: number
|
|
93
|
+
message?: { role?: string; content?: string; tool_calls?: unknown[] }
|
|
94
|
+
finish_reason?: string
|
|
95
|
+
}>
|
|
96
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
|
97
|
+
}, providerName: string, startTime: number): NormalizedResponse {
|
|
98
|
+
const message: Message = {
|
|
99
|
+
role: 'assistant',
|
|
100
|
+
content: raw.choices?.[0]?.message?.content || '',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
id: raw.id || crypto.randomUUID(),
|
|
105
|
+
model: raw.model || '',
|
|
106
|
+
choices: [
|
|
107
|
+
{
|
|
108
|
+
index: raw.choices?.[0]?.index || 0,
|
|
109
|
+
message,
|
|
110
|
+
finishReason: raw.choices?.[0]?.finish_reason || 'stop',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
usage: {
|
|
114
|
+
inputTokens: raw.usage?.prompt_tokens || 0,
|
|
115
|
+
outputTokens: raw.usage?.completion_tokens || 0,
|
|
116
|
+
totalTokens: raw.usage?.total_tokens || 0,
|
|
117
|
+
},
|
|
118
|
+
latencyMs: Date.now() - startTime,
|
|
119
|
+
provider: providerName,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ProviderAdapter, MeshProvider, ProviderAccount, Model } from './types'
|
|
2
|
+
|
|
3
|
+
class ProviderRegistry {
|
|
4
|
+
private adapters = new Map<string, ProviderAdapter>()
|
|
5
|
+
|
|
6
|
+
register(adapter: ProviderAdapter): void {
|
|
7
|
+
this.adapters.set(adapter.info.prefix, adapter)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
unregister(prefix: string): void {
|
|
11
|
+
this.adapters.delete(prefix)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get(prefix: string): ProviderAdapter | undefined {
|
|
15
|
+
return this.adapters.get(prefix)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
list(): ProviderAdapter[] {
|
|
19
|
+
return Array.from(this.adapters.values())
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
resolveModel(modelId: string): { adapter: ProviderAdapter; modelName: string } | null {
|
|
23
|
+
const slashIndex = modelId.indexOf('/')
|
|
24
|
+
if (slashIndex === -1) return null
|
|
25
|
+
|
|
26
|
+
const prefix = modelId.substring(0, slashIndex) + '/'
|
|
27
|
+
const modelName = modelId.substring(slashIndex + 1)
|
|
28
|
+
|
|
29
|
+
const adapter = this.adapters.get(prefix)
|
|
30
|
+
if (!adapter) return null
|
|
31
|
+
|
|
32
|
+
return { adapter, modelName }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
resolveAllProviders(modelName: string): Array<{ adapter: ProviderAdapter; localName: string }> {
|
|
36
|
+
const results: Array<{ adapter: ProviderAdapter; localName: string }> = []
|
|
37
|
+
|
|
38
|
+
for (const adapter of this.adapters.values()) {
|
|
39
|
+
results.push({ adapter, localName: modelName })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return results
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const registry = new ProviderRegistry()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export type AuthType = 'oauth' | 'api_key' | 'none' | 'service_account'
|
|
2
|
+
export type PricingTier = 'free' | 'cheap' | 'subscription' | 'pay_per_use'
|
|
3
|
+
export type HealthStatusType = 'healthy' | 'degraded' | 'down' | 'disabled'
|
|
4
|
+
export type TaskType = 'code' | 'chat' | 'reason' | 'review' | 'debug' | 'doc' | 'translate' | 'fast'
|
|
5
|
+
|
|
6
|
+
export interface Model {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
displayName?: string
|
|
10
|
+
providerId: string
|
|
11
|
+
pricingTier?: PricingTier
|
|
12
|
+
costPer1mInput?: number
|
|
13
|
+
costPer1mOutput?: number
|
|
14
|
+
contextWindow?: number
|
|
15
|
+
capabilities?: string[]
|
|
16
|
+
available: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProviderInfo {
|
|
20
|
+
id: string
|
|
21
|
+
name: string
|
|
22
|
+
prefix: string
|
|
23
|
+
authType: AuthType
|
|
24
|
+
baseUrl: string
|
|
25
|
+
enabled: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ProviderAccount {
|
|
29
|
+
id: string
|
|
30
|
+
providerId: string
|
|
31
|
+
label?: string
|
|
32
|
+
authData: Record<string, unknown>
|
|
33
|
+
enabled: boolean
|
|
34
|
+
priority: number
|
|
35
|
+
quotaUsedTokens: number
|
|
36
|
+
quotaLimitTokens?: number
|
|
37
|
+
quotaResetAt?: Date
|
|
38
|
+
lastUsedAt?: Date
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Message {
|
|
42
|
+
role: 'system' | 'user' | 'assistant' | 'tool'
|
|
43
|
+
content: string
|
|
44
|
+
toolCallId?: string
|
|
45
|
+
toolCalls?: ToolCall[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ToolCall {
|
|
49
|
+
id: string
|
|
50
|
+
type: 'function'
|
|
51
|
+
function: { name: string; arguments: string }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NormalizedRequest {
|
|
55
|
+
model: string
|
|
56
|
+
messages: Message[]
|
|
57
|
+
temperature?: number
|
|
58
|
+
maxTokens?: number
|
|
59
|
+
stream?: boolean
|
|
60
|
+
tools?: unknown[]
|
|
61
|
+
metadata?: Record<string, unknown>
|
|
62
|
+
skillId?: string
|
|
63
|
+
taskType?: TaskType
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface NormalizedResponse {
|
|
67
|
+
id: string
|
|
68
|
+
model: string
|
|
69
|
+
choices: Array<{
|
|
70
|
+
index: number
|
|
71
|
+
message: Message
|
|
72
|
+
finishReason: string
|
|
73
|
+
}>
|
|
74
|
+
usage: {
|
|
75
|
+
inputTokens: number
|
|
76
|
+
outputTokens: number
|
|
77
|
+
totalTokens: number
|
|
78
|
+
}
|
|
79
|
+
latencyMs: number
|
|
80
|
+
provider: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface NormalizedChunk {
|
|
84
|
+
id: string
|
|
85
|
+
model: string
|
|
86
|
+
choices: Array<{
|
|
87
|
+
index: number
|
|
88
|
+
delta: Partial<Message>
|
|
89
|
+
finishReason: string | null
|
|
90
|
+
}>
|
|
91
|
+
usage?: {
|
|
92
|
+
inputTokens: number
|
|
93
|
+
outputTokens: number
|
|
94
|
+
totalTokens: number
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface QuotaInfo {
|
|
99
|
+
usedTokens: number
|
|
100
|
+
limitTokens?: number
|
|
101
|
+
resetAt?: Date
|
|
102
|
+
unlimited: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ProviderAdapter {
|
|
106
|
+
info: ProviderInfo
|
|
107
|
+
fetchModels(): Promise<Model[]>
|
|
108
|
+
chatCompletion(req: NormalizedRequest, account: ProviderAccount): Promise<NormalizedResponse>
|
|
109
|
+
chatCompletionStream(req: NormalizedRequest, account: ProviderAccount): AsyncIterable<NormalizedChunk>
|
|
110
|
+
healthCheck(): Promise<{ status: HealthStatusType; latencyMs: number }>
|
|
111
|
+
getQuota?(account: ProviderAccount): Promise<QuotaInfo>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface MeshProvider {
|
|
115
|
+
provider: ProviderInfo
|
|
116
|
+
account: ProviderAccount
|
|
117
|
+
model: Model
|
|
118
|
+
health: HealthStatusType
|
|
119
|
+
cost: number
|
|
120
|
+
latency: number
|
|
121
|
+
}
|