@wener/mcps 1.0.1
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/LICENSE +21 -0
- package/dist/index.mjs +15 -0
- package/dist/mcps-cli.mjs +174727 -0
- package/lib/chat/agent.js +187 -0
- package/lib/chat/agent.js.map +1 -0
- package/lib/chat/audit.js +238 -0
- package/lib/chat/audit.js.map +1 -0
- package/lib/chat/converters.js +467 -0
- package/lib/chat/converters.js.map +1 -0
- package/lib/chat/handler.js +1068 -0
- package/lib/chat/handler.js.map +1 -0
- package/lib/chat/index.js +12 -0
- package/lib/chat/index.js.map +1 -0
- package/lib/chat/types.js +35 -0
- package/lib/chat/types.js.map +1 -0
- package/lib/contracts/AuditContract.js +85 -0
- package/lib/contracts/AuditContract.js.map +1 -0
- package/lib/contracts/McpsContract.js +113 -0
- package/lib/contracts/McpsContract.js.map +1 -0
- package/lib/contracts/index.js +3 -0
- package/lib/contracts/index.js.map +1 -0
- package/lib/dev.server.js +7 -0
- package/lib/dev.server.js.map +1 -0
- package/lib/entities/ChatRequestEntity.js +318 -0
- package/lib/entities/ChatRequestEntity.js.map +1 -0
- package/lib/entities/McpRequestEntity.js +271 -0
- package/lib/entities/McpRequestEntity.js.map +1 -0
- package/lib/entities/RequestLogEntity.js +177 -0
- package/lib/entities/RequestLogEntity.js.map +1 -0
- package/lib/entities/ResponseEntity.js +150 -0
- package/lib/entities/ResponseEntity.js.map +1 -0
- package/lib/entities/index.js +11 -0
- package/lib/entities/index.js.map +1 -0
- package/lib/entities/types.js +11 -0
- package/lib/entities/types.js.map +1 -0
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -0
- package/lib/mcps-cli.js +44 -0
- package/lib/mcps-cli.js.map +1 -0
- package/lib/providers/McpServerHandlerDef.js +40 -0
- package/lib/providers/McpServerHandlerDef.js.map +1 -0
- package/lib/providers/findMcpServerDef.js +26 -0
- package/lib/providers/findMcpServerDef.js.map +1 -0
- package/lib/providers/prometheus/def.js +24 -0
- package/lib/providers/prometheus/def.js.map +1 -0
- package/lib/providers/prometheus/index.js +2 -0
- package/lib/providers/prometheus/index.js.map +1 -0
- package/lib/providers/relay/def.js +32 -0
- package/lib/providers/relay/def.js.map +1 -0
- package/lib/providers/relay/index.js +2 -0
- package/lib/providers/relay/index.js.map +1 -0
- package/lib/providers/sql/def.js +31 -0
- package/lib/providers/sql/def.js.map +1 -0
- package/lib/providers/sql/index.js +2 -0
- package/lib/providers/sql/index.js.map +1 -0
- package/lib/providers/tencent-cls/def.js +44 -0
- package/lib/providers/tencent-cls/def.js.map +1 -0
- package/lib/providers/tencent-cls/index.js +2 -0
- package/lib/providers/tencent-cls/index.js.map +1 -0
- package/lib/scripts/bundle.js +90 -0
- package/lib/scripts/bundle.js.map +1 -0
- package/lib/server/api-routes.js +96 -0
- package/lib/server/api-routes.js.map +1 -0
- package/lib/server/audit.js +274 -0
- package/lib/server/audit.js.map +1 -0
- package/lib/server/chat-routes.js +82 -0
- package/lib/server/chat-routes.js.map +1 -0
- package/lib/server/config.js +223 -0
- package/lib/server/config.js.map +1 -0
- package/lib/server/db.js +97 -0
- package/lib/server/db.js.map +1 -0
- package/lib/server/index.js +2 -0
- package/lib/server/index.js.map +1 -0
- package/lib/server/mcp-handler.js +167 -0
- package/lib/server/mcp-handler.js.map +1 -0
- package/lib/server/mcp-routes.js +112 -0
- package/lib/server/mcp-routes.js.map +1 -0
- package/lib/server/mcps-router.js +119 -0
- package/lib/server/mcps-router.js.map +1 -0
- package/lib/server/schema.js +129 -0
- package/lib/server/schema.js.map +1 -0
- package/lib/server/server.js +166 -0
- package/lib/server/server.js.map +1 -0
- package/lib/web/ChatPage.js +827 -0
- package/lib/web/ChatPage.js.map +1 -0
- package/lib/web/McpInspectorPage.js +214 -0
- package/lib/web/McpInspectorPage.js.map +1 -0
- package/lib/web/ServersPage.js +93 -0
- package/lib/web/ServersPage.js.map +1 -0
- package/lib/web/main.js +541 -0
- package/lib/web/main.js.map +1 -0
- package/package.json +83 -0
- package/src/chat/agent.ts +240 -0
- package/src/chat/audit.ts +377 -0
- package/src/chat/converters.test.ts +325 -0
- package/src/chat/converters.ts +459 -0
- package/src/chat/handler.test.ts +137 -0
- package/src/chat/handler.ts +1233 -0
- package/src/chat/index.ts +16 -0
- package/src/chat/types.ts +72 -0
- package/src/contracts/AuditContract.ts +93 -0
- package/src/contracts/McpsContract.ts +141 -0
- package/src/contracts/index.ts +18 -0
- package/src/dev.server.ts +7 -0
- package/src/entities/ChatRequestEntity.ts +157 -0
- package/src/entities/McpRequestEntity.ts +149 -0
- package/src/entities/RequestLogEntity.ts +78 -0
- package/src/entities/ResponseEntity.ts +75 -0
- package/src/entities/index.ts +12 -0
- package/src/entities/types.ts +188 -0
- package/src/index.ts +1 -0
- package/src/mcps-cli.ts +59 -0
- package/src/providers/McpServerHandlerDef.ts +105 -0
- package/src/providers/findMcpServerDef.ts +31 -0
- package/src/providers/prometheus/def.ts +21 -0
- package/src/providers/prometheus/index.ts +1 -0
- package/src/providers/relay/def.ts +31 -0
- package/src/providers/relay/index.ts +1 -0
- package/src/providers/relay/relay.test.ts +47 -0
- package/src/providers/sql/def.ts +33 -0
- package/src/providers/sql/index.ts +1 -0
- package/src/providers/tencent-cls/def.ts +38 -0
- package/src/providers/tencent-cls/index.ts +1 -0
- package/src/scripts/bundle.ts +82 -0
- package/src/server/api-routes.ts +98 -0
- package/src/server/audit.ts +310 -0
- package/src/server/chat-routes.ts +95 -0
- package/src/server/config.test.ts +162 -0
- package/src/server/config.ts +198 -0
- package/src/server/db.ts +115 -0
- package/src/server/index.ts +1 -0
- package/src/server/mcp-handler.ts +209 -0
- package/src/server/mcp-routes.ts +133 -0
- package/src/server/mcps-router.ts +133 -0
- package/src/server/schema.ts +175 -0
- package/src/server/server.ts +163 -0
- package/src/web/ChatPage.tsx +1005 -0
- package/src/web/McpInspectorPage.tsx +254 -0
- package/src/web/ServersPage.tsx +139 -0
- package/src/web/main.tsx +600 -0
- package/src/web/styles.css +15 -0
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wener/mcps",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "",
|
|
5
|
+
"author": "",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcps": "./dist/mcps-cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.mjs",
|
|
10
|
+
"imports": {
|
|
11
|
+
"#/*": "./*"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"default": "./lib/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./server": {
|
|
19
|
+
"types": "./src/server/index.ts",
|
|
20
|
+
"default": "./lib/server/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"lib",
|
|
26
|
+
"src",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"keywords": [],
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@ai-sdk/mcp": "^1.0.18",
|
|
32
|
+
"@ai-sdk/openai-compatible": "^2.0.26",
|
|
33
|
+
"@ai-sdk/react": "^3.0.71",
|
|
34
|
+
"@base-ui/react": "^1.1.0",
|
|
35
|
+
"@hono/mcp": "^0.2.3",
|
|
36
|
+
"@hono/node-server": "^1.19.9",
|
|
37
|
+
"@hono/vite-dev-server": "^0.24.1",
|
|
38
|
+
"@mikro-orm/core": "^7.0.0-rc.0",
|
|
39
|
+
"@mikro-orm/decorators": "^7.0.0-rc.0",
|
|
40
|
+
"@mikro-orm/sqlite": "^7.0.0-rc.0",
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
42
|
+
"@orpc/client": "^1.13.4",
|
|
43
|
+
"@orpc/contract": "^1.13.4",
|
|
44
|
+
"@orpc/json-schema": "^1.13.4",
|
|
45
|
+
"@orpc/openapi": "^1.13.4",
|
|
46
|
+
"@orpc/server": "^1.13.4",
|
|
47
|
+
"@orpc/zod": "^1.13.4",
|
|
48
|
+
"@streamdown/cjk": "^1.0.1",
|
|
49
|
+
"@streamdown/code": "^1.0.1",
|
|
50
|
+
"@streamdown/math": "^1.0.1",
|
|
51
|
+
"@streamdown/mermaid": "^1.0.1",
|
|
52
|
+
"@wener/client": "^1.0.26",
|
|
53
|
+
"@wener/common": "^2.0.3",
|
|
54
|
+
"ai": "^6.0.69",
|
|
55
|
+
"commander": "^14.0.3",
|
|
56
|
+
"consola": "^3.4.2",
|
|
57
|
+
"hono": "^4.11.7",
|
|
58
|
+
"kysely": "^0.28.11",
|
|
59
|
+
"lru-cache": "^11.2.5",
|
|
60
|
+
"lucide-react": "^0.562.0",
|
|
61
|
+
"mysql2": "^3.16.3",
|
|
62
|
+
"openai": "^6.17.0",
|
|
63
|
+
"pg-query-stream": "^4.12.0",
|
|
64
|
+
"react": "^19.2.4",
|
|
65
|
+
"react-dom": "^19.2.4",
|
|
66
|
+
"react-markdown": "^10.1.0",
|
|
67
|
+
"react-router-dom": "^7.13.0",
|
|
68
|
+
"std-env": "^3.10.0",
|
|
69
|
+
"streamdown": "^2.1.0",
|
|
70
|
+
"tarn": "^3.0.2",
|
|
71
|
+
"tedious": "^19.2.0",
|
|
72
|
+
"yaml": "^2.8.2",
|
|
73
|
+
"zod": "^4.3.6",
|
|
74
|
+
"@wener/ai": "0.1.0"
|
|
75
|
+
},
|
|
76
|
+
"scripts": {
|
|
77
|
+
"build": "bun run ./src/scripts/bundle.ts",
|
|
78
|
+
"dev": "pnpm tsx --watch src/mcps-cli.ts",
|
|
79
|
+
"dev:web": "vite",
|
|
80
|
+
"test": "vitest run",
|
|
81
|
+
"typecheck": "tsc --noEmit"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Loop Agent Handler
|
|
3
|
+
* Uses AI SDK with MCP tools for agentic chat
|
|
4
|
+
*/
|
|
5
|
+
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
6
|
+
import { generateText, streamText, type Tool, stepCountIs } from 'ai';
|
|
7
|
+
import consola from 'consola';
|
|
8
|
+
import type { Hono } from 'hono';
|
|
9
|
+
import { streamSSE } from 'hono/streaming';
|
|
10
|
+
import type { ModelConfig } from '../server/schema';
|
|
11
|
+
|
|
12
|
+
const log = consola.withTag('agent');
|
|
13
|
+
|
|
14
|
+
export interface AgentHandlerOptions {
|
|
15
|
+
resolveModelConfig: (modelName: string) => ModelConfig | null;
|
|
16
|
+
getMcpTools?: (servers?: string[]) => Promise<Record<string, Tool>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AgentRequest {
|
|
20
|
+
model: string;
|
|
21
|
+
messages: Array<{
|
|
22
|
+
role: 'user' | 'assistant' | 'system';
|
|
23
|
+
content: string;
|
|
24
|
+
}>;
|
|
25
|
+
tools?: string[]; // Tool names to enable
|
|
26
|
+
mcpServers?: string[]; // MCP server names to load tools from
|
|
27
|
+
maxSteps?: number; // Max tool loop iterations (default: 5)
|
|
28
|
+
stream?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Normalize base URL - strip trailing /v1 if present
|
|
33
|
+
*/
|
|
34
|
+
function normalizeBaseUrl(url: string): string {
|
|
35
|
+
return url.replace(/\/v1\/?$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register agent routes on Hono app
|
|
40
|
+
*/
|
|
41
|
+
export function registerAgentRoutes(app: Hono, options: AgentHandlerOptions) {
|
|
42
|
+
const { resolveModelConfig, getMcpTools } = options;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* POST /v1/agent/chat
|
|
46
|
+
* Agent chat with tool loop support
|
|
47
|
+
*/
|
|
48
|
+
app.post('/v1/agent/chat', async (c) => {
|
|
49
|
+
try {
|
|
50
|
+
const body = await c.req.json<AgentRequest>();
|
|
51
|
+
const { model: modelName, messages, mcpServers, maxSteps = 5, stream = false } = body;
|
|
52
|
+
|
|
53
|
+
// Resolve model config
|
|
54
|
+
const modelConfig = resolveModelConfig(modelName);
|
|
55
|
+
if (!modelConfig) {
|
|
56
|
+
return c.json(
|
|
57
|
+
{
|
|
58
|
+
error: {
|
|
59
|
+
message: `Model not found: ${modelName}`,
|
|
60
|
+
type: 'invalid_request_error',
|
|
61
|
+
code: 'model_not_found',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
404,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Get API key
|
|
69
|
+
const apiKey = modelConfig.apiKey;
|
|
70
|
+
const baseUrl = normalizeBaseUrl(modelConfig.baseUrl || '');
|
|
71
|
+
|
|
72
|
+
if (!baseUrl) {
|
|
73
|
+
return c.json(
|
|
74
|
+
{
|
|
75
|
+
error: {
|
|
76
|
+
message: 'Model has no baseUrl configured',
|
|
77
|
+
type: 'invalid_request_error',
|
|
78
|
+
code: 'invalid_model_config',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
400,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Create AI provider
|
|
86
|
+
const provider = createOpenAICompatible({
|
|
87
|
+
name: 'mcps-agent',
|
|
88
|
+
baseURL: `${baseUrl}/v1`,
|
|
89
|
+
apiKey: apiKey || 'not-needed',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const aiModel = provider.chatModel(modelName);
|
|
93
|
+
|
|
94
|
+
// Get MCP tools if available
|
|
95
|
+
let tools: Record<string, Tool> = {};
|
|
96
|
+
if (getMcpTools && mcpServers && mcpServers.length > 0) {
|
|
97
|
+
try {
|
|
98
|
+
tools = await getMcpTools(mcpServers);
|
|
99
|
+
log.info(`Loaded ${Object.keys(tools).length} tools from MCP servers: ${mcpServers.join(', ')}`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
log.warn('Failed to load MCP tools:', err);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Convert messages
|
|
106
|
+
const aiMessages = messages.map((m) => ({
|
|
107
|
+
role: m.role as 'user' | 'assistant' | 'system',
|
|
108
|
+
content: m.content,
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
log.info(
|
|
112
|
+
`→ POST /v1/agent/chat model=${modelName} messages=${messages.length} tools=${Object.keys(tools).length} maxSteps=${maxSteps}`,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (stream) {
|
|
116
|
+
// Streaming response
|
|
117
|
+
return streamSSE(c, async (sseStream) => {
|
|
118
|
+
try {
|
|
119
|
+
const result = streamText({
|
|
120
|
+
model: aiModel,
|
|
121
|
+
messages: aiMessages,
|
|
122
|
+
tools: Object.keys(tools).length > 0 ? tools : undefined,
|
|
123
|
+
stopWhen: stepCountIs(maxSteps),
|
|
124
|
+
onStepFinish: async (step) => {
|
|
125
|
+
// Send step info
|
|
126
|
+
await sseStream.writeSSE({
|
|
127
|
+
data: JSON.stringify({
|
|
128
|
+
type: 'step',
|
|
129
|
+
text: step.text,
|
|
130
|
+
toolCalls: step.toolCalls?.map((tc) => ({
|
|
131
|
+
id: tc.toolCallId,
|
|
132
|
+
name: tc.toolName,
|
|
133
|
+
arguments: 'input' in tc ? tc.input : undefined,
|
|
134
|
+
})),
|
|
135
|
+
toolResults: step.toolResults?.map((tr) => ({
|
|
136
|
+
id: tr.toolCallId,
|
|
137
|
+
name: tr.toolName,
|
|
138
|
+
result: 'output' in tr ? tr.output : undefined,
|
|
139
|
+
})),
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Stream text deltas
|
|
146
|
+
for await (const part of result.textStream) {
|
|
147
|
+
await sseStream.writeSSE({
|
|
148
|
+
data: JSON.stringify({
|
|
149
|
+
type: 'text',
|
|
150
|
+
content: part,
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Get final result
|
|
156
|
+
const finalResult = await result;
|
|
157
|
+
const usage = await finalResult.usage;
|
|
158
|
+
const steps = await finalResult.steps;
|
|
159
|
+
|
|
160
|
+
// Send usage info
|
|
161
|
+
await sseStream.writeSSE({
|
|
162
|
+
data: JSON.stringify({
|
|
163
|
+
type: 'usage',
|
|
164
|
+
usage: {
|
|
165
|
+
promptTokens: usage?.inputTokens,
|
|
166
|
+
completionTokens: usage?.outputTokens,
|
|
167
|
+
totalTokens: (usage?.inputTokens || 0) + (usage?.outputTokens || 0),
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Send done
|
|
173
|
+
await sseStream.writeSSE({ data: '[DONE]' });
|
|
174
|
+
|
|
175
|
+
log.info(`← 200 /v1/agent/chat model=${modelName} steps=${steps?.length || 1}`);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
log.error('Agent streaming error:', err);
|
|
178
|
+
await sseStream.writeSSE({
|
|
179
|
+
data: JSON.stringify({
|
|
180
|
+
type: 'error',
|
|
181
|
+
error: err instanceof Error ? err.message : 'Streaming error',
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Non-streaming response
|
|
189
|
+
const result = await generateText({
|
|
190
|
+
model: aiModel,
|
|
191
|
+
messages: aiMessages,
|
|
192
|
+
tools: Object.keys(tools).length > 0 ? tools : undefined,
|
|
193
|
+
stopWhen: stepCountIs(maxSteps),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
log.info(
|
|
197
|
+
`← 200 /v1/agent/chat model=${modelName} steps=${result.steps?.length || 1} tokens=${result.usage?.totalTokens || 0}`,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Format response
|
|
201
|
+
return c.json({
|
|
202
|
+
id: `agent-${Date.now()}`,
|
|
203
|
+
object: 'agent.chat.completion',
|
|
204
|
+
model: modelName,
|
|
205
|
+
content: result.text,
|
|
206
|
+
reasoning: result.reasoning,
|
|
207
|
+
steps: result.steps?.map((step) => ({
|
|
208
|
+
text: step.text,
|
|
209
|
+
toolCalls: step.toolCalls?.map((tc) => ({
|
|
210
|
+
id: tc.toolCallId,
|
|
211
|
+
name: tc.toolName,
|
|
212
|
+
arguments: 'input' in tc ? tc.input : undefined,
|
|
213
|
+
})),
|
|
214
|
+
toolResults: step.toolResults?.map((tr) => ({
|
|
215
|
+
id: tr.toolCallId,
|
|
216
|
+
name: tr.toolName,
|
|
217
|
+
result: 'output' in tr ? tr.output : undefined,
|
|
218
|
+
})),
|
|
219
|
+
})),
|
|
220
|
+
usage: {
|
|
221
|
+
prompt_tokens: result.usage?.inputTokens,
|
|
222
|
+
completion_tokens: result.usage?.outputTokens,
|
|
223
|
+
total_tokens: (result.usage?.inputTokens || 0) + (result.usage?.outputTokens || 0),
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
log.error('Agent error:', error);
|
|
228
|
+
return c.json(
|
|
229
|
+
{
|
|
230
|
+
error: {
|
|
231
|
+
message: error instanceof Error ? error.message : 'Internal server error',
|
|
232
|
+
type: 'api_error',
|
|
233
|
+
code: 'internal_error',
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
500,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Request Audit Service
|
|
3
|
+
* Records all chat/LLM API requests for auditing and metering
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from 'hono';
|
|
6
|
+
import consola from 'consola';
|
|
7
|
+
import type { ChatProtocolType, RequestStatus as RequestStatusType, ChatAuditStats } from '../entities/types';
|
|
8
|
+
|
|
9
|
+
const log = consola.withTag('chat-audit');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Re-export protocol and status constants for convenience
|
|
13
|
+
*/
|
|
14
|
+
export const ChatProtocol = {
|
|
15
|
+
OPENAI: 'openai' as ChatProtocolType,
|
|
16
|
+
ANTHROPIC: 'anthropic' as ChatProtocolType,
|
|
17
|
+
GEMINI: 'gemini' as ChatProtocolType,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const RequestStatus = {
|
|
21
|
+
PENDING: 'pending' as RequestStatusType,
|
|
22
|
+
SUCCESS: 'success' as RequestStatusType,
|
|
23
|
+
ERROR: 'error' as RequestStatusType,
|
|
24
|
+
TIMEOUT: 'timeout' as RequestStatusType,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Chat audit record (in-memory representation)
|
|
29
|
+
*/
|
|
30
|
+
export interface ChatAuditRecord {
|
|
31
|
+
requestId: string;
|
|
32
|
+
requestedAt: Date;
|
|
33
|
+
completedAt?: Date;
|
|
34
|
+
status: RequestStatusType;
|
|
35
|
+
method: string;
|
|
36
|
+
endpoint: string;
|
|
37
|
+
inputProtocol: ChatProtocolType;
|
|
38
|
+
outputProtocol: ChatProtocolType;
|
|
39
|
+
model: string;
|
|
40
|
+
resolvedModel?: string;
|
|
41
|
+
provider?: string;
|
|
42
|
+
upstreamUrl?: string;
|
|
43
|
+
streaming: boolean;
|
|
44
|
+
inputTokens?: number;
|
|
45
|
+
outputTokens?: number;
|
|
46
|
+
totalTokens?: number;
|
|
47
|
+
durationMs?: number;
|
|
48
|
+
ttftMs?: number;
|
|
49
|
+
httpStatus?: number;
|
|
50
|
+
errorMessage?: string;
|
|
51
|
+
errorCode?: string;
|
|
52
|
+
clientIp?: string;
|
|
53
|
+
userAgent?: string;
|
|
54
|
+
userId?: string;
|
|
55
|
+
orgId?: string;
|
|
56
|
+
apiKeyId?: string;
|
|
57
|
+
requestMeta?: Record<string, unknown>;
|
|
58
|
+
responseMeta?: Record<string, unknown>;
|
|
59
|
+
cost?: string;
|
|
60
|
+
currency?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Audit store interface
|
|
65
|
+
*/
|
|
66
|
+
export interface ChatAuditStore {
|
|
67
|
+
/**
|
|
68
|
+
* Save a chat audit record
|
|
69
|
+
*/
|
|
70
|
+
save(record: ChatAuditRecord): Promise<void>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Query audit records
|
|
74
|
+
*/
|
|
75
|
+
query(options: ChatAuditQueryOptions): Promise<{ records: ChatAuditRecord[]; total: number }>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get aggregate statistics
|
|
79
|
+
*/
|
|
80
|
+
getStats(options: { from?: Date; to?: Date }): Promise<import('../entities/types').ChatAuditStats>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ChatAuditQueryOptions {
|
|
84
|
+
limit?: number;
|
|
85
|
+
offset?: number;
|
|
86
|
+
model?: string;
|
|
87
|
+
provider?: string;
|
|
88
|
+
status?: RequestStatusType;
|
|
89
|
+
from?: Date;
|
|
90
|
+
to?: Date;
|
|
91
|
+
userId?: string;
|
|
92
|
+
orgId?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Re-export ChatAuditStats from entities
|
|
96
|
+
export type { ChatAuditStats } from '../entities/types';
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* In-memory audit store implementation
|
|
100
|
+
*/
|
|
101
|
+
export class InMemoryChatAuditStore implements ChatAuditStore {
|
|
102
|
+
private records: ChatAuditRecord[] = [];
|
|
103
|
+
private maxSize: number;
|
|
104
|
+
|
|
105
|
+
constructor(maxSize = 10000) {
|
|
106
|
+
this.maxSize = maxSize;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async save(record: ChatAuditRecord): Promise<void> {
|
|
110
|
+
this.records.unshift(record);
|
|
111
|
+
|
|
112
|
+
// Trim to max size
|
|
113
|
+
if (this.records.length > this.maxSize) {
|
|
114
|
+
this.records = this.records.slice(0, this.maxSize);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
log.debug(`Saved audit record: ${record.requestId} model=${record.model} status=${record.status}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async query(options: ChatAuditQueryOptions): Promise<{ records: ChatAuditRecord[]; total: number }> {
|
|
121
|
+
let filtered = [...this.records];
|
|
122
|
+
|
|
123
|
+
if (options.model) {
|
|
124
|
+
filtered = filtered.filter((r) => r.model === options.model);
|
|
125
|
+
}
|
|
126
|
+
if (options.provider) {
|
|
127
|
+
filtered = filtered.filter((r) => r.provider === options.provider);
|
|
128
|
+
}
|
|
129
|
+
if (options.status) {
|
|
130
|
+
filtered = filtered.filter((r) => r.status === options.status);
|
|
131
|
+
}
|
|
132
|
+
if (options.userId) {
|
|
133
|
+
filtered = filtered.filter((r) => r.userId === options.userId);
|
|
134
|
+
}
|
|
135
|
+
if (options.orgId) {
|
|
136
|
+
filtered = filtered.filter((r) => r.orgId === options.orgId);
|
|
137
|
+
}
|
|
138
|
+
if (options.from) {
|
|
139
|
+
const from = options.from;
|
|
140
|
+
filtered = filtered.filter((r) => r.requestedAt >= from);
|
|
141
|
+
}
|
|
142
|
+
if (options.to) {
|
|
143
|
+
const to = options.to;
|
|
144
|
+
filtered = filtered.filter((r) => r.requestedAt <= to);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const total = filtered.length;
|
|
148
|
+
const offset = options.offset || 0;
|
|
149
|
+
const limit = options.limit || 50;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
records: filtered.slice(offset, offset + limit),
|
|
153
|
+
total,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async getStats(options: { from?: Date; to?: Date }): Promise<ChatAuditStats> {
|
|
158
|
+
let filtered = [...this.records];
|
|
159
|
+
|
|
160
|
+
if (options.from) {
|
|
161
|
+
const from = options.from;
|
|
162
|
+
filtered = filtered.filter((r) => r.requestedAt >= from);
|
|
163
|
+
}
|
|
164
|
+
if (options.to) {
|
|
165
|
+
const to = options.to;
|
|
166
|
+
filtered = filtered.filter((r) => r.requestedAt <= to);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const totalRequests = filtered.length;
|
|
170
|
+
const successfulRequests = filtered.filter((r) => r.status === RequestStatus.SUCCESS).length;
|
|
171
|
+
const failedRequests = filtered.filter((r) => r.status === RequestStatus.ERROR).length;
|
|
172
|
+
|
|
173
|
+
const totalInputTokens = filtered.reduce((sum, r) => sum + (r.inputTokens || 0), 0);
|
|
174
|
+
const totalOutputTokens = filtered.reduce((sum, r) => sum + (r.outputTokens || 0), 0);
|
|
175
|
+
|
|
176
|
+
const durations = filtered.map((r) => r.durationMs).filter((d): d is number => d != null);
|
|
177
|
+
const avgDurationMs = durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0;
|
|
178
|
+
|
|
179
|
+
// Group by model
|
|
180
|
+
const modelMap = new Map<string, { count: number; tokens: number }>();
|
|
181
|
+
for (const r of filtered) {
|
|
182
|
+
const existing = modelMap.get(r.model) || { count: 0, tokens: 0 };
|
|
183
|
+
existing.count++;
|
|
184
|
+
existing.tokens += (r.inputTokens || 0) + (r.outputTokens || 0);
|
|
185
|
+
modelMap.set(r.model, existing);
|
|
186
|
+
}
|
|
187
|
+
const byModel = Array.from(modelMap.entries())
|
|
188
|
+
.map(([model, data]) => ({ model, ...data }))
|
|
189
|
+
.sort((a, b) => b.count - a.count);
|
|
190
|
+
|
|
191
|
+
// Group by provider
|
|
192
|
+
const providerMap = new Map<string, { count: number; tokens: number }>();
|
|
193
|
+
for (const r of filtered) {
|
|
194
|
+
const provider = r.provider || 'unknown';
|
|
195
|
+
const existing = providerMap.get(provider) || { count: 0, tokens: 0 };
|
|
196
|
+
existing.count++;
|
|
197
|
+
existing.tokens += (r.inputTokens || 0) + (r.outputTokens || 0);
|
|
198
|
+
providerMap.set(provider, existing);
|
|
199
|
+
}
|
|
200
|
+
const byProvider = Array.from(providerMap.entries())
|
|
201
|
+
.map(([provider, data]) => ({ provider, ...data }))
|
|
202
|
+
.sort((a, b) => b.count - a.count);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
totalRequests,
|
|
206
|
+
successfulRequests,
|
|
207
|
+
failedRequests,
|
|
208
|
+
totalInputTokens,
|
|
209
|
+
totalOutputTokens,
|
|
210
|
+
avgDurationMs,
|
|
211
|
+
byModel,
|
|
212
|
+
byProvider,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Global audit store instance
|
|
218
|
+
let auditStore: ChatAuditStore = new InMemoryChatAuditStore();
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Set the audit store implementation
|
|
222
|
+
*/
|
|
223
|
+
export function setChatAuditStore(store: ChatAuditStore) {
|
|
224
|
+
auditStore = store;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get the current audit store
|
|
229
|
+
*/
|
|
230
|
+
export function getChatAuditStore(): ChatAuditStore {
|
|
231
|
+
return auditStore;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Generate a unique request ID
|
|
236
|
+
*/
|
|
237
|
+
export function generateRequestId(): string {
|
|
238
|
+
return `chat-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Create an audit context for tracking a request
|
|
243
|
+
*/
|
|
244
|
+
export function createAuditContext(options: {
|
|
245
|
+
method: string;
|
|
246
|
+
endpoint: string;
|
|
247
|
+
model: string;
|
|
248
|
+
inputProtocol: ChatProtocolType;
|
|
249
|
+
outputProtocol: ChatProtocolType;
|
|
250
|
+
streaming: boolean;
|
|
251
|
+
clientIp?: string;
|
|
252
|
+
userAgent?: string;
|
|
253
|
+
userId?: string;
|
|
254
|
+
requestMeta?: Record<string, unknown>;
|
|
255
|
+
}): ChatAuditContext {
|
|
256
|
+
return new ChatAuditContext(options);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Audit context for tracking a single request lifecycle
|
|
261
|
+
*/
|
|
262
|
+
export class ChatAuditContext {
|
|
263
|
+
private record: ChatAuditRecord;
|
|
264
|
+
private startTime: number;
|
|
265
|
+
private firstTokenTime?: number;
|
|
266
|
+
|
|
267
|
+
constructor(options: {
|
|
268
|
+
method: string;
|
|
269
|
+
endpoint: string;
|
|
270
|
+
model: string;
|
|
271
|
+
inputProtocol: ChatProtocolType;
|
|
272
|
+
outputProtocol: ChatProtocolType;
|
|
273
|
+
streaming: boolean;
|
|
274
|
+
clientIp?: string;
|
|
275
|
+
userAgent?: string;
|
|
276
|
+
userId?: string;
|
|
277
|
+
requestMeta?: Record<string, unknown>;
|
|
278
|
+
}) {
|
|
279
|
+
this.startTime = Date.now();
|
|
280
|
+
this.record = {
|
|
281
|
+
requestId: generateRequestId(),
|
|
282
|
+
requestedAt: new Date(),
|
|
283
|
+
status: RequestStatus.PENDING,
|
|
284
|
+
method: options.method,
|
|
285
|
+
endpoint: options.endpoint,
|
|
286
|
+
inputProtocol: options.inputProtocol,
|
|
287
|
+
outputProtocol: options.outputProtocol,
|
|
288
|
+
model: options.model,
|
|
289
|
+
streaming: options.streaming,
|
|
290
|
+
clientIp: options.clientIp,
|
|
291
|
+
userAgent: options.userAgent,
|
|
292
|
+
userId: options.userId,
|
|
293
|
+
requestMeta: options.requestMeta,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
get requestId(): string {
|
|
298
|
+
return this.record.requestId;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Set the resolved model and provider info
|
|
303
|
+
*/
|
|
304
|
+
setProvider(options: { resolvedModel?: string; provider?: string; upstreamUrl?: string }) {
|
|
305
|
+
Object.assign(this.record, options);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Record first token received (for TTFT)
|
|
310
|
+
*/
|
|
311
|
+
recordFirstToken() {
|
|
312
|
+
if (!this.firstTokenTime) {
|
|
313
|
+
this.firstTokenTime = Date.now();
|
|
314
|
+
this.record.ttftMs = this.firstTokenTime - this.startTime;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Record token usage
|
|
320
|
+
*/
|
|
321
|
+
setTokenUsage(input: number, output: number) {
|
|
322
|
+
this.record.inputTokens = input;
|
|
323
|
+
this.record.outputTokens = output;
|
|
324
|
+
this.record.totalTokens = input + output;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Set response metadata
|
|
329
|
+
*/
|
|
330
|
+
setResponseMeta(meta: Record<string, unknown>) {
|
|
331
|
+
this.record.responseMeta = meta;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Get current duration in ms
|
|
336
|
+
*/
|
|
337
|
+
getDuration(): number {
|
|
338
|
+
return Date.now() - this.startTime;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Complete the request successfully
|
|
343
|
+
*/
|
|
344
|
+
async complete(httpStatus: number = 200) {
|
|
345
|
+
this.record.status = RequestStatus.SUCCESS;
|
|
346
|
+
this.record.httpStatus = httpStatus;
|
|
347
|
+
this.record.completedAt = new Date();
|
|
348
|
+
this.record.durationMs = Date.now() - this.startTime;
|
|
349
|
+
|
|
350
|
+
await auditStore.save(this.record);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Complete the request with an error
|
|
355
|
+
*/
|
|
356
|
+
async error(errorMessage: string, errorCode?: string, httpStatus: number = 500) {
|
|
357
|
+
this.record.status = RequestStatus.ERROR;
|
|
358
|
+
this.record.httpStatus = httpStatus;
|
|
359
|
+
this.record.errorMessage = errorMessage;
|
|
360
|
+
this.record.errorCode = errorCode;
|
|
361
|
+
this.record.completedAt = new Date();
|
|
362
|
+
this.record.durationMs = Date.now() - this.startTime;
|
|
363
|
+
|
|
364
|
+
await auditStore.save(this.record);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Extract client IP from Hono context
|
|
370
|
+
*/
|
|
371
|
+
export function extractClientIp(c: Context): string | undefined {
|
|
372
|
+
return (
|
|
373
|
+
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
374
|
+
c.req.header('x-real-ip') ||
|
|
375
|
+
c.req.header('cf-connecting-ip')
|
|
376
|
+
);
|
|
377
|
+
}
|