@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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.mjs +15 -0
  3. package/dist/mcps-cli.mjs +174727 -0
  4. package/lib/chat/agent.js +187 -0
  5. package/lib/chat/agent.js.map +1 -0
  6. package/lib/chat/audit.js +238 -0
  7. package/lib/chat/audit.js.map +1 -0
  8. package/lib/chat/converters.js +467 -0
  9. package/lib/chat/converters.js.map +1 -0
  10. package/lib/chat/handler.js +1068 -0
  11. package/lib/chat/handler.js.map +1 -0
  12. package/lib/chat/index.js +12 -0
  13. package/lib/chat/index.js.map +1 -0
  14. package/lib/chat/types.js +35 -0
  15. package/lib/chat/types.js.map +1 -0
  16. package/lib/contracts/AuditContract.js +85 -0
  17. package/lib/contracts/AuditContract.js.map +1 -0
  18. package/lib/contracts/McpsContract.js +113 -0
  19. package/lib/contracts/McpsContract.js.map +1 -0
  20. package/lib/contracts/index.js +3 -0
  21. package/lib/contracts/index.js.map +1 -0
  22. package/lib/dev.server.js +7 -0
  23. package/lib/dev.server.js.map +1 -0
  24. package/lib/entities/ChatRequestEntity.js +318 -0
  25. package/lib/entities/ChatRequestEntity.js.map +1 -0
  26. package/lib/entities/McpRequestEntity.js +271 -0
  27. package/lib/entities/McpRequestEntity.js.map +1 -0
  28. package/lib/entities/RequestLogEntity.js +177 -0
  29. package/lib/entities/RequestLogEntity.js.map +1 -0
  30. package/lib/entities/ResponseEntity.js +150 -0
  31. package/lib/entities/ResponseEntity.js.map +1 -0
  32. package/lib/entities/index.js +11 -0
  33. package/lib/entities/index.js.map +1 -0
  34. package/lib/entities/types.js +11 -0
  35. package/lib/entities/types.js.map +1 -0
  36. package/lib/index.js +3 -0
  37. package/lib/index.js.map +1 -0
  38. package/lib/mcps-cli.js +44 -0
  39. package/lib/mcps-cli.js.map +1 -0
  40. package/lib/providers/McpServerHandlerDef.js +40 -0
  41. package/lib/providers/McpServerHandlerDef.js.map +1 -0
  42. package/lib/providers/findMcpServerDef.js +26 -0
  43. package/lib/providers/findMcpServerDef.js.map +1 -0
  44. package/lib/providers/prometheus/def.js +24 -0
  45. package/lib/providers/prometheus/def.js.map +1 -0
  46. package/lib/providers/prometheus/index.js +2 -0
  47. package/lib/providers/prometheus/index.js.map +1 -0
  48. package/lib/providers/relay/def.js +32 -0
  49. package/lib/providers/relay/def.js.map +1 -0
  50. package/lib/providers/relay/index.js +2 -0
  51. package/lib/providers/relay/index.js.map +1 -0
  52. package/lib/providers/sql/def.js +31 -0
  53. package/lib/providers/sql/def.js.map +1 -0
  54. package/lib/providers/sql/index.js +2 -0
  55. package/lib/providers/sql/index.js.map +1 -0
  56. package/lib/providers/tencent-cls/def.js +44 -0
  57. package/lib/providers/tencent-cls/def.js.map +1 -0
  58. package/lib/providers/tencent-cls/index.js +2 -0
  59. package/lib/providers/tencent-cls/index.js.map +1 -0
  60. package/lib/scripts/bundle.js +90 -0
  61. package/lib/scripts/bundle.js.map +1 -0
  62. package/lib/server/api-routes.js +96 -0
  63. package/lib/server/api-routes.js.map +1 -0
  64. package/lib/server/audit.js +274 -0
  65. package/lib/server/audit.js.map +1 -0
  66. package/lib/server/chat-routes.js +82 -0
  67. package/lib/server/chat-routes.js.map +1 -0
  68. package/lib/server/config.js +223 -0
  69. package/lib/server/config.js.map +1 -0
  70. package/lib/server/db.js +97 -0
  71. package/lib/server/db.js.map +1 -0
  72. package/lib/server/index.js +2 -0
  73. package/lib/server/index.js.map +1 -0
  74. package/lib/server/mcp-handler.js +167 -0
  75. package/lib/server/mcp-handler.js.map +1 -0
  76. package/lib/server/mcp-routes.js +112 -0
  77. package/lib/server/mcp-routes.js.map +1 -0
  78. package/lib/server/mcps-router.js +119 -0
  79. package/lib/server/mcps-router.js.map +1 -0
  80. package/lib/server/schema.js +129 -0
  81. package/lib/server/schema.js.map +1 -0
  82. package/lib/server/server.js +166 -0
  83. package/lib/server/server.js.map +1 -0
  84. package/lib/web/ChatPage.js +827 -0
  85. package/lib/web/ChatPage.js.map +1 -0
  86. package/lib/web/McpInspectorPage.js +214 -0
  87. package/lib/web/McpInspectorPage.js.map +1 -0
  88. package/lib/web/ServersPage.js +93 -0
  89. package/lib/web/ServersPage.js.map +1 -0
  90. package/lib/web/main.js +541 -0
  91. package/lib/web/main.js.map +1 -0
  92. package/package.json +83 -0
  93. package/src/chat/agent.ts +240 -0
  94. package/src/chat/audit.ts +377 -0
  95. package/src/chat/converters.test.ts +325 -0
  96. package/src/chat/converters.ts +459 -0
  97. package/src/chat/handler.test.ts +137 -0
  98. package/src/chat/handler.ts +1233 -0
  99. package/src/chat/index.ts +16 -0
  100. package/src/chat/types.ts +72 -0
  101. package/src/contracts/AuditContract.ts +93 -0
  102. package/src/contracts/McpsContract.ts +141 -0
  103. package/src/contracts/index.ts +18 -0
  104. package/src/dev.server.ts +7 -0
  105. package/src/entities/ChatRequestEntity.ts +157 -0
  106. package/src/entities/McpRequestEntity.ts +149 -0
  107. package/src/entities/RequestLogEntity.ts +78 -0
  108. package/src/entities/ResponseEntity.ts +75 -0
  109. package/src/entities/index.ts +12 -0
  110. package/src/entities/types.ts +188 -0
  111. package/src/index.ts +1 -0
  112. package/src/mcps-cli.ts +59 -0
  113. package/src/providers/McpServerHandlerDef.ts +105 -0
  114. package/src/providers/findMcpServerDef.ts +31 -0
  115. package/src/providers/prometheus/def.ts +21 -0
  116. package/src/providers/prometheus/index.ts +1 -0
  117. package/src/providers/relay/def.ts +31 -0
  118. package/src/providers/relay/index.ts +1 -0
  119. package/src/providers/relay/relay.test.ts +47 -0
  120. package/src/providers/sql/def.ts +33 -0
  121. package/src/providers/sql/index.ts +1 -0
  122. package/src/providers/tencent-cls/def.ts +38 -0
  123. package/src/providers/tencent-cls/index.ts +1 -0
  124. package/src/scripts/bundle.ts +82 -0
  125. package/src/server/api-routes.ts +98 -0
  126. package/src/server/audit.ts +310 -0
  127. package/src/server/chat-routes.ts +95 -0
  128. package/src/server/config.test.ts +162 -0
  129. package/src/server/config.ts +198 -0
  130. package/src/server/db.ts +115 -0
  131. package/src/server/index.ts +1 -0
  132. package/src/server/mcp-handler.ts +209 -0
  133. package/src/server/mcp-routes.ts +133 -0
  134. package/src/server/mcps-router.ts +133 -0
  135. package/src/server/schema.ts +175 -0
  136. package/src/server/server.ts +163 -0
  137. package/src/web/ChatPage.tsx +1005 -0
  138. package/src/web/McpInspectorPage.tsx +254 -0
  139. package/src/web/ServersPage.tsx +139 -0
  140. package/src/web/main.tsx +600 -0
  141. 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
+ }