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