waypoi 0.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.
Files changed (260) hide show
  1. package/.github/instructions/ui.instructions.md +42 -0
  2. package/.github/workflows/ci.yml +35 -0
  3. package/.github/workflows/publish.yml +71 -0
  4. package/.github/workflows/release.yml +48 -0
  5. package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
  6. package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
  7. package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
  8. package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
  10. package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
  11. package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
  12. package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
  13. package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
  14. package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
  15. package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
  16. package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
  17. package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
  18. package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
  19. package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
  20. package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
  21. package/AGENTS.md +48 -0
  22. package/CHANGELOG.md +131 -0
  23. package/README.md +552 -0
  24. package/assets/agent-mode.png +0 -0
  25. package/assets/categorize.png +0 -0
  26. package/assets/dashboard.png +0 -0
  27. package/assets/endpoint-proxy.png +0 -0
  28. package/assets/icon.png +0 -0
  29. package/assets/mcp-generate-image.png +0 -0
  30. package/assets/mcp-understand-image.png +0 -0
  31. package/assets/peek-token-flow.png +0 -0
  32. package/assets/playground.png +0 -0
  33. package/assets/sankey.png +0 -0
  34. package/cli/index.ts +2805 -0
  35. package/cli/legacyRewrite.ts +108 -0
  36. package/cli/modelRef.ts +24 -0
  37. package/dist/cli/index.js +2536 -0
  38. package/dist/cli/legacyRewrite.js +92 -0
  39. package/dist/cli/modelRef.js +20 -0
  40. package/dist/src/benchmark/artifacts.js +131 -0
  41. package/dist/src/benchmark/capabilityClassifier.js +81 -0
  42. package/dist/src/benchmark/capabilityStore.js +144 -0
  43. package/dist/src/benchmark/config.js +238 -0
  44. package/dist/src/benchmark/gates.js +118 -0
  45. package/dist/src/benchmark/jobs.js +252 -0
  46. package/dist/src/benchmark/runner.js +1847 -0
  47. package/dist/src/benchmark/schema.js +353 -0
  48. package/dist/src/benchmark/suites.js +314 -0
  49. package/dist/src/benchmark/tinyQaDataset.js +422 -0
  50. package/dist/src/benchmark/types.js +25 -0
  51. package/dist/src/config.js +47 -0
  52. package/dist/src/index.js +178 -0
  53. package/dist/src/mcp/client.js +215 -0
  54. package/dist/src/mcp/discovery.js +226 -0
  55. package/dist/src/mcp/policy.js +65 -0
  56. package/dist/src/mcp/registry.js +129 -0
  57. package/dist/src/mcp/service.js +460 -0
  58. package/dist/src/middleware/auth.js +179 -0
  59. package/dist/src/middleware/requestCapture.js +192 -0
  60. package/dist/src/middleware/requestStats.js +118 -0
  61. package/dist/src/pools/builder.js +132 -0
  62. package/dist/src/pools/repository.js +69 -0
  63. package/dist/src/pools/scheduler.js +360 -0
  64. package/dist/src/pools/types.js +2 -0
  65. package/dist/src/protocols/adapters/dashscope.js +267 -0
  66. package/dist/src/protocols/adapters/inferenceV2.js +346 -0
  67. package/dist/src/protocols/adapters/openai.js +27 -0
  68. package/dist/src/protocols/registry.js +99 -0
  69. package/dist/src/protocols/types.js +2 -0
  70. package/dist/src/providers/health.js +153 -0
  71. package/dist/src/providers/importer.js +289 -0
  72. package/dist/src/providers/modelRegistry.js +313 -0
  73. package/dist/src/providers/repository.js +361 -0
  74. package/dist/src/providers/types.js +2 -0
  75. package/dist/src/routes/admin.js +531 -0
  76. package/dist/src/routes/audio.js +295 -0
  77. package/dist/src/routes/chat.js +240 -0
  78. package/dist/src/routes/embeddings.js +157 -0
  79. package/dist/src/routes/images.js +288 -0
  80. package/dist/src/routes/mcp.js +256 -0
  81. package/dist/src/routes/mcpService.js +100 -0
  82. package/dist/src/routes/models.js +48 -0
  83. package/dist/src/routes/responses.js +711 -0
  84. package/dist/src/routes/sessions.js +450 -0
  85. package/dist/src/routes/stats.js +270 -0
  86. package/dist/src/routes/ui.js +97 -0
  87. package/dist/src/routes/videos.js +107 -0
  88. package/dist/src/routing/router.js +338 -0
  89. package/dist/src/services/imageGeneration.js +280 -0
  90. package/dist/src/services/imageUnderstanding.js +352 -0
  91. package/dist/src/services/videoGeneration.js +79 -0
  92. package/dist/src/storage/captureRepository.js +1591 -0
  93. package/dist/src/storage/files.js +157 -0
  94. package/dist/src/storage/imageCache.js +346 -0
  95. package/dist/src/storage/repositories.js +388 -0
  96. package/dist/src/storage/sessionRepository.js +370 -0
  97. package/dist/src/storage/statsRepository.js +204 -0
  98. package/dist/src/transport/httpClient.js +126 -0
  99. package/dist/src/types.js +2 -0
  100. package/dist/src/utils/messageMedia.js +285 -0
  101. package/dist/src/utils/modelCapabilities.js +108 -0
  102. package/dist/src/utils/modelDiscovery.js +170 -0
  103. package/dist/src/version.js +5 -0
  104. package/dist/src/workers/captureRetention.js +25 -0
  105. package/dist/src/workers/configWatcher.js +91 -0
  106. package/dist/src/workers/healthChecker.js +21 -0
  107. package/dist/src/workers/statsRotation.js +41 -0
  108. package/docs/LLM/output_schema.md +312 -0
  109. package/docs/benchmark.md +208 -0
  110. package/docs/mcp-guidelines.md +125 -0
  111. package/docs/mcp-service.md +178 -0
  112. package/docs/opencode.md +86 -0
  113. package/docs/providers.md +79 -0
  114. package/examples/benchmark.config.yaml +28 -0
  115. package/examples/providers/alibaba-dashscope.yaml +88 -0
  116. package/examples/providers/alibaba-llm.yaml +64 -0
  117. package/examples/providers/alibaba-registry.yaml +7 -0
  118. package/examples/providers/inference-v2-ray.yaml +29 -0
  119. package/examples/scenarios/assets/omni-call-sample.wav +0 -0
  120. package/examples/scenarios/custom.jsonl +5 -0
  121. package/examples/scenarios/custom.yaml +40 -0
  122. package/model-form-v2.png +0 -0
  123. package/package.json +66 -0
  124. package/provider-form-v2.png +0 -0
  125. package/provider-form.png +0 -0
  126. package/scripts/manual-test.sh +11 -0
  127. package/scripts/version-from-git.js +23 -0
  128. package/src/benchmark/artifacts.ts +149 -0
  129. package/src/benchmark/capabilityClassifier.ts +99 -0
  130. package/src/benchmark/capabilityStore.ts +174 -0
  131. package/src/benchmark/config.ts +337 -0
  132. package/src/benchmark/gates.ts +164 -0
  133. package/src/benchmark/jobs.ts +312 -0
  134. package/src/benchmark/runner.ts +2519 -0
  135. package/src/benchmark/schema.ts +443 -0
  136. package/src/benchmark/suites.ts +323 -0
  137. package/src/benchmark/tinyQaDataset.ts +428 -0
  138. package/src/benchmark/types.ts +442 -0
  139. package/src/config.ts +44 -0
  140. package/src/index.ts +195 -0
  141. package/src/mcp/client.ts +305 -0
  142. package/src/mcp/discovery.ts +266 -0
  143. package/src/mcp/policy.ts +105 -0
  144. package/src/mcp/registry.ts +164 -0
  145. package/src/mcp/service.ts +611 -0
  146. package/src/middleware/auth.ts +251 -0
  147. package/src/middleware/requestCapture.ts +245 -0
  148. package/src/middleware/requestStats.ts +163 -0
  149. package/src/pools/builder.ts +159 -0
  150. package/src/pools/repository.ts +71 -0
  151. package/src/pools/scheduler.ts +425 -0
  152. package/src/pools/types.ts +117 -0
  153. package/src/protocols/adapters/dashscope.ts +335 -0
  154. package/src/protocols/adapters/inferenceV2.ts +428 -0
  155. package/src/protocols/adapters/openai.ts +32 -0
  156. package/src/protocols/registry.ts +117 -0
  157. package/src/protocols/types.ts +81 -0
  158. package/src/providers/health.ts +207 -0
  159. package/src/providers/importer.ts +402 -0
  160. package/src/providers/modelRegistry.ts +415 -0
  161. package/src/providers/repository.ts +439 -0
  162. package/src/providers/types.ts +113 -0
  163. package/src/routes/admin.ts +666 -0
  164. package/src/routes/audio.ts +372 -0
  165. package/src/routes/chat.ts +301 -0
  166. package/src/routes/embeddings.ts +197 -0
  167. package/src/routes/images.ts +356 -0
  168. package/src/routes/mcp.ts +320 -0
  169. package/src/routes/mcpService.ts +114 -0
  170. package/src/routes/models.ts +50 -0
  171. package/src/routes/responses.ts +872 -0
  172. package/src/routes/sessions.ts +558 -0
  173. package/src/routes/stats.ts +312 -0
  174. package/src/routes/ui.ts +96 -0
  175. package/src/routes/videos.ts +132 -0
  176. package/src/routing/router.ts +501 -0
  177. package/src/services/imageGeneration.ts +396 -0
  178. package/src/services/imageUnderstanding.ts +449 -0
  179. package/src/services/videoGeneration.ts +127 -0
  180. package/src/storage/captureRepository.ts +1835 -0
  181. package/src/storage/files.ts +178 -0
  182. package/src/storage/imageCache.ts +405 -0
  183. package/src/storage/repositories.ts +494 -0
  184. package/src/storage/sessionRepository.ts +419 -0
  185. package/src/storage/statsRepository.ts +238 -0
  186. package/src/transport/httpClient.ts +145 -0
  187. package/src/types.ts +322 -0
  188. package/src/utils/messageMedia.ts +293 -0
  189. package/src/utils/modelCapabilities.ts +161 -0
  190. package/src/utils/modelDiscovery.ts +203 -0
  191. package/src/workers/captureRetention.ts +25 -0
  192. package/src/workers/configWatcher.ts +115 -0
  193. package/src/workers/healthChecker.ts +22 -0
  194. package/src/workers/statsRotation.ts +49 -0
  195. package/tests/benchmarkAdminRoutes.test.ts +82 -0
  196. package/tests/benchmarkBasics.test.ts +116 -0
  197. package/tests/captureAdminRoutes.test.ts +420 -0
  198. package/tests/captureRepository.test.ts +797 -0
  199. package/tests/cliLegacyRewrite.test.ts +45 -0
  200. package/tests/imageGeneration.service.test.ts +107 -0
  201. package/tests/imageUnderstanding.service.test.ts +123 -0
  202. package/tests/mcpPolicy.test.ts +105 -0
  203. package/tests/mcpService.test.ts +1245 -0
  204. package/tests/modelRef.test.ts +23 -0
  205. package/tests/modelsRoutes.test.ts +154 -0
  206. package/tests/sessionMediaCache.test.ts +167 -0
  207. package/tests/statsRoutes.test.ts +323 -0
  208. package/tsconfig.json +15 -0
  209. package/ui/index.html +16 -0
  210. package/ui/package-lock.json +8521 -0
  211. package/ui/package.json +52 -0
  212. package/ui/postcss.config.js +6 -0
  213. package/ui/public/assets/apple-touch-icon.png +0 -0
  214. package/ui/public/assets/favicon-16.png +0 -0
  215. package/ui/public/assets/favicon-32.png +0 -0
  216. package/ui/public/assets/icon-192.png +0 -0
  217. package/ui/public/assets/icon-512.png +0 -0
  218. package/ui/src/App.tsx +27 -0
  219. package/ui/src/api/client.ts +1503 -0
  220. package/ui/src/components/EndpointUsageGuide.tsx +361 -0
  221. package/ui/src/components/Layout.tsx +124 -0
  222. package/ui/src/components/MessageContent.tsx +365 -0
  223. package/ui/src/components/ToolCallMessage.tsx +179 -0
  224. package/ui/src/components/ToolPicker.tsx +442 -0
  225. package/ui/src/components/messageContentParser.test.ts +41 -0
  226. package/ui/src/components/messageContentParser.ts +73 -0
  227. package/ui/src/components/thinkingPreview.test.ts +27 -0
  228. package/ui/src/components/thinkingPreview.ts +15 -0
  229. package/ui/src/components/toMermaidSankey.test.ts +78 -0
  230. package/ui/src/components/toMermaidSankey.ts +56 -0
  231. package/ui/src/components/ui/button.tsx +58 -0
  232. package/ui/src/components/ui/input.tsx +21 -0
  233. package/ui/src/components/ui/textarea.tsx +21 -0
  234. package/ui/src/lib/utils.ts +6 -0
  235. package/ui/src/main.tsx +9 -0
  236. package/ui/src/pages/AgentPlayground.tsx +2010 -0
  237. package/ui/src/pages/Benchmark.tsx +988 -0
  238. package/ui/src/pages/Dashboard.tsx +581 -0
  239. package/ui/src/pages/Peek.tsx +962 -0
  240. package/ui/src/pages/Settings.tsx +2013 -0
  241. package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
  242. package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
  243. package/ui/src/pages/agentThinkingContent.test.ts +50 -0
  244. package/ui/src/pages/agentThinkingContent.ts +57 -0
  245. package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
  246. package/ui/src/pages/dashboardTokenUsage.ts +36 -0
  247. package/ui/src/pages/imageUpload.test.ts +39 -0
  248. package/ui/src/pages/imageUpload.ts +71 -0
  249. package/ui/src/pages/peekFilters.test.ts +29 -0
  250. package/ui/src/pages/peekFilters.ts +13 -0
  251. package/ui/src/pages/peekMedia.test.ts +58 -0
  252. package/ui/src/pages/peekMedia.ts +148 -0
  253. package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
  254. package/ui/src/pages/sessionAutoTitle.ts +106 -0
  255. package/ui/src/stores/settings.ts +58 -0
  256. package/ui/src/styles/globals.css +223 -0
  257. package/ui/src/vite-env.d.ts +8 -0
  258. package/ui/tailwind.config.js +106 -0
  259. package/ui/tsconfig.json +32 -0
  260. package/ui/vite.config.ts +37 -0
@@ -0,0 +1,251 @@
1
+ import { FastifyInstance, FastifyRequest, FastifyReply, HookHandlerDoneFunction } from "fastify";
2
+ import { loadConfig, StoragePaths } from "../storage/files";
3
+
4
+ /**
5
+ * Auth Middleware (No-op Implementation)
6
+ *
7
+ * This middleware is a placeholder for future authentication.
8
+ * When authEnabled is false (default), it passes through all requests.
9
+ *
10
+ * Extension points for implementing auth:
11
+ * 1. JWT validation
12
+ * 2. API key verification
13
+ * 3. OAuth2/OIDC integration
14
+ * 4. Basic auth for simple deployments
15
+ *
16
+ * The middleware adds req.user typing for downstream handlers.
17
+ */
18
+
19
+ declare module "fastify" {
20
+ interface FastifyRequest {
21
+ user?: {
22
+ id: string;
23
+ email?: string;
24
+ roles?: string[];
25
+ };
26
+ }
27
+ }
28
+
29
+ export interface AuthConfig {
30
+ enabled: boolean;
31
+ // Future config options:
32
+ // jwtSecret?: string;
33
+ // apiKeys?: string[];
34
+ // oauthProvider?: string;
35
+ }
36
+
37
+ let authConfig: AuthConfig = {
38
+ enabled: false,
39
+ };
40
+
41
+ /**
42
+ * Load auth configuration from the main config file.
43
+ */
44
+ export async function loadAuthConfig(paths: StoragePaths): Promise<AuthConfig> {
45
+ try {
46
+ const config = await loadConfig(paths);
47
+ return {
48
+ enabled: config.authEnabled ?? false,
49
+ };
50
+ } catch {
51
+ return { enabled: false };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Update auth config (e.g., after config hot-reload).
57
+ */
58
+ export function updateAuthConfig(config: AuthConfig): void {
59
+ authConfig = config;
60
+ }
61
+
62
+ /**
63
+ * Get current auth config.
64
+ */
65
+ export function getAuthConfig(): AuthConfig {
66
+ return authConfig;
67
+ }
68
+
69
+ /**
70
+ * Auth guard for protected routes.
71
+ *
72
+ * When auth is disabled: passes through all requests.
73
+ * When auth is enabled: checks for valid authentication.
74
+ */
75
+ export function authGuard(
76
+ req: FastifyRequest,
77
+ reply: FastifyReply,
78
+ done: HookHandlerDoneFunction
79
+ ): void {
80
+ // Auth disabled - pass through
81
+ if (!authConfig.enabled) {
82
+ done();
83
+ return;
84
+ }
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────
87
+ // Auth enabled - implement your auth logic here
88
+ // ─────────────────────────────────────────────────────────────────────────
89
+
90
+ // Example: Check for Authorization header
91
+ const authHeader = req.headers.authorization;
92
+
93
+ if (!authHeader) {
94
+ reply.status(401).send({
95
+ error: {
96
+ message: "Authentication required",
97
+ type: "auth_error",
98
+ code: "missing_auth",
99
+ },
100
+ });
101
+ return;
102
+ }
103
+
104
+ // Placeholder validation - replace with real auth logic
105
+ // For now, any Bearer token is accepted
106
+ if (authHeader.startsWith("Bearer ")) {
107
+ const token = authHeader.slice(7);
108
+
109
+ // TODO: Validate token (JWT decode, database lookup, etc.)
110
+ // For now, we just set a placeholder user
111
+ req.user = {
112
+ id: "placeholder",
113
+ email: undefined,
114
+ roles: ["user"],
115
+ };
116
+
117
+ done();
118
+ return;
119
+ }
120
+
121
+ reply.status(401).send({
122
+ error: {
123
+ message: "Invalid authentication",
124
+ type: "auth_error",
125
+ code: "invalid_auth",
126
+ },
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Optional: API key auth guard for simpler use cases.
132
+ */
133
+ export function apiKeyGuard(validKeys: Set<string>) {
134
+ return (
135
+ req: FastifyRequest,
136
+ reply: FastifyReply,
137
+ done: HookHandlerDoneFunction
138
+ ): void => {
139
+ if (!authConfig.enabled) {
140
+ done();
141
+ return;
142
+ }
143
+
144
+ const authHeader = req.headers.authorization;
145
+ const apiKey = req.headers["x-api-key"] as string | undefined;
146
+
147
+ // Check X-API-Key header
148
+ if (apiKey && validKeys.has(apiKey)) {
149
+ req.user = { id: "api-key-user", roles: ["api"] };
150
+ done();
151
+ return;
152
+ }
153
+
154
+ // Check Bearer token as API key
155
+ if (authHeader?.startsWith("Bearer ")) {
156
+ const key = authHeader.slice(7);
157
+ if (validKeys.has(key)) {
158
+ req.user = { id: "api-key-user", roles: ["api"] };
159
+ done();
160
+ return;
161
+ }
162
+ }
163
+
164
+ reply.status(401).send({
165
+ error: {
166
+ message: "Invalid API key",
167
+ type: "auth_error",
168
+ code: "invalid_api_key",
169
+ },
170
+ });
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Register auth hooks on protected route prefixes.
176
+ *
177
+ * Usage:
178
+ * await registerAuthHooks(app, paths, ["/admin", "/ui"]);
179
+ */
180
+ export async function registerAuthHooks(
181
+ app: FastifyInstance,
182
+ paths: StoragePaths,
183
+ protectedPrefixes: string[] = ["/admin", "/ui"]
184
+ ): Promise<void> {
185
+ // Load initial config
186
+ authConfig = await loadAuthConfig(paths);
187
+
188
+ app.log.info(
189
+ { authEnabled: authConfig.enabled, protectedPrefixes },
190
+ "Auth middleware initialized"
191
+ );
192
+
193
+ // Add hook for protected routes
194
+ app.addHook("onRequest", (req, reply, done) => {
195
+ const isProtected = protectedPrefixes.some((prefix) =>
196
+ req.url.startsWith(prefix)
197
+ );
198
+
199
+ if (isProtected) {
200
+ authGuard(req, reply, done);
201
+ } else {
202
+ done();
203
+ }
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Middleware factory for route-level auth.
209
+ *
210
+ * Usage in route handlers:
211
+ * app.get("/admin/something", { preHandler: [requireAuth()] }, handler);
212
+ */
213
+ export function requireAuth() {
214
+ return (
215
+ req: FastifyRequest,
216
+ reply: FastifyReply,
217
+ done: HookHandlerDoneFunction
218
+ ): void => {
219
+ authGuard(req, reply, done);
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Check if a user has a specific role.
225
+ */
226
+ export function requireRole(role: string) {
227
+ return (
228
+ req: FastifyRequest,
229
+ reply: FastifyReply,
230
+ done: HookHandlerDoneFunction
231
+ ): void => {
232
+ // First check auth
233
+ if (authConfig.enabled) {
234
+ if (!req.user) {
235
+ reply.status(401).send({
236
+ error: { message: "Authentication required", type: "auth_error" },
237
+ });
238
+ return;
239
+ }
240
+
241
+ if (!req.user.roles?.includes(role)) {
242
+ reply.status(403).send({
243
+ error: { message: "Insufficient permissions", type: "auth_error" },
244
+ });
245
+ return;
246
+ }
247
+ }
248
+
249
+ done();
250
+ };
251
+ }
@@ -0,0 +1,245 @@
1
+ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+ import { randomUUID } from "crypto";
3
+ import { StoragePaths } from "../storage/files";
4
+ import { CaptureRoutingInfo, isCaptureEnabled, persistCaptureRecord } from "../storage/captureRepository";
5
+
6
+ interface CaptureContext {
7
+ id: string;
8
+ startedAt: number;
9
+ enabled: boolean;
10
+ requestBody?: unknown;
11
+ responseBody?: unknown;
12
+ responseHeaders?: Record<string, string | string[] | undefined>;
13
+ routing?: CaptureRoutingInfo;
14
+ derivedRequest?: Record<string, unknown>;
15
+ error?: { type?: string; message?: string };
16
+ }
17
+
18
+ interface CaptureStreamBody {
19
+ $type: "stream";
20
+ contentType: string;
21
+ bytes: number;
22
+ text?: string;
23
+ note?: string;
24
+ }
25
+
26
+ const captureContexts = new WeakMap<FastifyRequest, CaptureContext>();
27
+
28
+ interface ReplyCaptureMeta {
29
+ captureRouting?: CaptureRoutingInfo;
30
+ captureDerivedRequest?: Record<string, unknown>;
31
+ captureResponseOverride?: {
32
+ body: unknown;
33
+ headers?: Record<string, string | string[] | undefined>;
34
+ };
35
+ captureError?: { type?: string; message?: string };
36
+ }
37
+
38
+ function meta(reply: FastifyReply): ReplyCaptureMeta {
39
+ return reply as unknown as ReplyCaptureMeta;
40
+ }
41
+
42
+ export async function registerRequestCaptureMiddleware(
43
+ app: FastifyInstance,
44
+ paths: StoragePaths
45
+ ): Promise<void> {
46
+ app.addHook("onRequest", async (req: FastifyRequest) => {
47
+ if (!req.url.startsWith("/v1/")) return;
48
+ let enabled = false;
49
+ try {
50
+ enabled = await isCaptureEnabled(paths);
51
+ } catch {
52
+ enabled = false;
53
+ }
54
+ captureContexts.set(req, {
55
+ id: randomUUID(),
56
+ startedAt: Date.now(),
57
+ enabled,
58
+ });
59
+ });
60
+
61
+ app.addHook("preHandler", async (req: FastifyRequest) => {
62
+ const context = captureContexts.get(req);
63
+ if (!context?.enabled) return;
64
+ context.requestBody = safeClone(req.body);
65
+ });
66
+
67
+ app.addHook("onSend", async (req: FastifyRequest, reply: FastifyReply, payload: unknown) => {
68
+ const context = captureContexts.get(req);
69
+ if (!context?.enabled) return payload;
70
+ if (!meta(reply).captureResponseOverride) {
71
+ context.responseBody = payloadToBody(payload);
72
+ context.responseHeaders = reply.getHeaders() as Record<string, string | string[] | undefined>;
73
+ }
74
+ return payload;
75
+ });
76
+
77
+ app.addHook("onResponse", async (req: FastifyRequest, reply: FastifyReply) => {
78
+ const context = captureContexts.get(req);
79
+ if (!context) return;
80
+ try {
81
+ if (!context.enabled) return;
82
+ const replyMeta = meta(reply);
83
+ context.routing = replyMeta.captureRouting;
84
+ context.derivedRequest = replyMeta.captureDerivedRequest;
85
+ context.error = replyMeta.captureError;
86
+ if (replyMeta.captureResponseOverride) {
87
+ context.responseBody = replyMeta.captureResponseOverride.body;
88
+ context.responseHeaders = replyMeta.captureResponseOverride.headers;
89
+ }
90
+
91
+ await persistCaptureRecord(paths, {
92
+ route: req.url,
93
+ method: req.method,
94
+ statusCode: reply.statusCode,
95
+ latencyMs: Date.now() - context.startedAt,
96
+ requestHeaders: req.headers as Record<string, string | string[] | undefined>,
97
+ responseHeaders:
98
+ context.responseHeaders ??
99
+ (reply.getHeaders() as Record<string, string | string[] | undefined>),
100
+ requestBody: context.requestBody,
101
+ responseBody: context.responseBody,
102
+ derivedRequest: context.derivedRequest,
103
+ routing: context.routing,
104
+ error: context.error,
105
+ });
106
+ } catch (error) {
107
+ app.log.warn({ err: error }, "Failed to persist request capture");
108
+ } finally {
109
+ captureContexts.delete(req);
110
+ }
111
+ });
112
+ }
113
+
114
+ export function setCaptureRouting(reply: FastifyReply, routing: CaptureRoutingInfo): void {
115
+ meta(reply).captureRouting = routing;
116
+ const context = captureContexts.get(reply.request);
117
+ if (context?.enabled) {
118
+ context.routing = routing;
119
+ }
120
+ }
121
+
122
+ export function setCaptureDerivedRequest(reply: FastifyReply, payload: Record<string, unknown>): void {
123
+ meta(reply).captureDerivedRequest = payload;
124
+ const context = captureContexts.get(reply.request);
125
+ if (context?.enabled) {
126
+ context.derivedRequest = payload;
127
+ }
128
+ }
129
+
130
+ export function setCaptureResponseOverride(
131
+ reply: FastifyReply,
132
+ body: unknown,
133
+ headers?: Record<string, string | string[] | undefined>
134
+ ): void {
135
+ meta(reply).captureResponseOverride = { body, headers };
136
+ const context = captureContexts.get(reply.request);
137
+ if (context?.enabled) {
138
+ context.responseBody = body;
139
+ if (headers) {
140
+ context.responseHeaders = headers;
141
+ }
142
+ }
143
+ }
144
+
145
+ export function setCaptureError(reply: FastifyReply, error: { type?: string; message?: string }): void {
146
+ meta(reply).captureError = error;
147
+ const context = captureContexts.get(reply.request);
148
+ if (context?.enabled) {
149
+ context.error = error;
150
+ }
151
+ }
152
+
153
+ export function startCaptureStreamResponse(
154
+ reply: FastifyReply,
155
+ headers: Record<string, string | string[] | undefined>,
156
+ contentType: string,
157
+ note?: string
158
+ ): void {
159
+ const body: CaptureStreamBody = {
160
+ $type: "stream",
161
+ contentType,
162
+ bytes: 0,
163
+ };
164
+ if (note) {
165
+ body.note = note;
166
+ }
167
+ setCaptureResponseOverride(reply, body, headers);
168
+ }
169
+
170
+ export function appendCaptureStreamChunk(
171
+ reply: FastifyReply,
172
+ chunk: Buffer,
173
+ options?: {
174
+ contentType?: string;
175
+ headers?: Record<string, string | string[] | undefined>;
176
+ }
177
+ ): void {
178
+ const context = captureContexts.get(reply.request);
179
+ if (!context?.enabled) return;
180
+ const body = ensureCaptureStreamBody(context, options?.contentType);
181
+ body.bytes += chunk.byteLength;
182
+ if (isTextLikeStream(body.contentType)) {
183
+ body.text = (body.text ?? "") + chunk.toString("utf8");
184
+ }
185
+ context.responseBody = body;
186
+ if (options?.headers) {
187
+ context.responseHeaders = options.headers;
188
+ }
189
+ meta(reply).captureResponseOverride = {
190
+ body,
191
+ headers: context.responseHeaders,
192
+ };
193
+ }
194
+
195
+ function safeClone<T>(value: T): T {
196
+ try {
197
+ return JSON.parse(JSON.stringify(value)) as T;
198
+ } catch {
199
+ return value;
200
+ }
201
+ }
202
+
203
+ function payloadToBody(payload: unknown): unknown {
204
+ if (payload === null || payload === undefined) return payload;
205
+ if (Buffer.isBuffer(payload)) {
206
+ return { $type: "buffer", base64: payload.toString("base64"), bytes: payload.byteLength };
207
+ }
208
+ if (typeof payload === "string") {
209
+ try {
210
+ return JSON.parse(payload);
211
+ } catch {
212
+ return payload;
213
+ }
214
+ }
215
+ return payload;
216
+ }
217
+
218
+ function ensureCaptureStreamBody(
219
+ context: CaptureContext,
220
+ contentType?: string
221
+ ): CaptureStreamBody {
222
+ const existing = context.responseBody as CaptureStreamBody | undefined;
223
+ if (existing?.$type === "stream") {
224
+ if (contentType) {
225
+ existing.contentType = contentType;
226
+ }
227
+ return existing;
228
+ }
229
+ const body: CaptureStreamBody = {
230
+ $type: "stream",
231
+ contentType: contentType ?? "application/octet-stream",
232
+ bytes: 0,
233
+ };
234
+ context.responseBody = body;
235
+ return body;
236
+ }
237
+
238
+ function isTextLikeStream(contentType: string): boolean {
239
+ return (
240
+ contentType.includes("text/") ||
241
+ contentType.includes("json") ||
242
+ contentType.includes("xml") ||
243
+ contentType.includes("event-stream")
244
+ );
245
+ }
@@ -0,0 +1,163 @@
1
+ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+ import { randomUUID } from "crypto";
3
+ import { RequestStats } from "../types";
4
+ import { appendStats } from "../storage/statsRepository";
5
+ import { StoragePaths } from "../storage/files";
6
+
7
+ /**
8
+ * Request Statistics Middleware
9
+ *
10
+ * Captures metrics for all /v1/* requests:
11
+ * - Latency (start/end timestamps)
12
+ * - Request/response sizes
13
+ * - Token usage (from upstream response or estimated)
14
+ * - Error classification
15
+ *
16
+ * Does NOT break streaming responses.
17
+ */
18
+
19
+ interface RequestContext {
20
+ requestId: string;
21
+ startTime: number;
22
+ requestBytes: number;
23
+ route: string;
24
+ method: string;
25
+ publicModel?: string;
26
+ }
27
+
28
+ // WeakMap to store request context without polluting request object
29
+ const requestContexts = new WeakMap<FastifyRequest, RequestContext>();
30
+
31
+ export async function registerRequestStatsMiddleware(
32
+ app: FastifyInstance,
33
+ paths: StoragePaths
34
+ ): Promise<void> {
35
+ // Decorate request with stats context
36
+ app.decorateRequest("statsContext", null);
37
+
38
+ // Hook: onRequest - capture start time and request size
39
+ app.addHook("onRequest", async (req: FastifyRequest) => {
40
+ // Only track /v1/* routes
41
+ if (!req.url.startsWith("/v1/")) {
42
+ return;
43
+ }
44
+
45
+ const context: RequestContext = {
46
+ requestId: randomUUID(),
47
+ startTime: Date.now(),
48
+ requestBytes: 0,
49
+ route: req.url,
50
+ method: req.method
51
+ };
52
+
53
+ // Estimate request size from content-length header
54
+ const contentLength = req.headers["content-length"];
55
+ if (contentLength) {
56
+ context.requestBytes = parseInt(contentLength, 10) || 0;
57
+ }
58
+
59
+ requestContexts.set(req, context);
60
+ });
61
+
62
+ // Hook: preHandler - extract model from parsed body
63
+ app.addHook("preHandler", async (req: FastifyRequest) => {
64
+ const context = requestContexts.get(req);
65
+ if (!context) return;
66
+
67
+ const body = req.body as { model?: string } | undefined;
68
+ if (body?.model) {
69
+ context.publicModel = body.model;
70
+ }
71
+ });
72
+
73
+ // Hook: onResponse - log the stats
74
+ app.addHook("onResponse", async (req: FastifyRequest, reply: FastifyReply) => {
75
+ const context = requestContexts.get(req);
76
+ if (!context) return;
77
+
78
+ const latencyMs = Date.now() - context.startTime;
79
+ const statusCode = reply.statusCode;
80
+
81
+ // Try to get response size from content-length header
82
+ let responseBytes = 0;
83
+ const respContentLength = reply.getHeader("content-length");
84
+ if (respContentLength) {
85
+ responseBytes = typeof respContentLength === "number"
86
+ ? respContentLength
87
+ : parseInt(String(respContentLength), 10) || 0;
88
+ }
89
+
90
+ // Determine if there was an error
91
+ let errorType: string | undefined;
92
+ if (statusCode >= 400) {
93
+ if (statusCode >= 500) {
94
+ errorType = "server_error";
95
+ } else if (statusCode === 429) {
96
+ errorType = "rate_limit";
97
+ } else if (statusCode === 401 || statusCode === 403) {
98
+ errorType = "auth_error";
99
+ } else {
100
+ errorType = "client_error";
101
+ }
102
+ }
103
+
104
+ // Extract token info from reply (if stored during route handling)
105
+ const statsPayload = (reply as unknown as { statsPayload?: StatsPayload }).statsPayload;
106
+
107
+ const stats: RequestStats = {
108
+ requestId: context.requestId,
109
+ timestamp: new Date(),
110
+ route: context.route,
111
+ method: context.method,
112
+ publicModel: context.publicModel,
113
+ endpointId: statsPayload?.endpointId,
114
+ endpointName: statsPayload?.endpointName,
115
+ upstreamModel: statsPayload?.upstreamModel,
116
+ requestBytes: context.requestBytes,
117
+ responseBytes,
118
+ latencyMs,
119
+ statusCode,
120
+ errorType,
121
+ totalTokens: statsPayload?.totalTokens ?? estimateTokens(context.requestBytes, responseBytes),
122
+ promptTokens: statsPayload?.promptTokens ?? null,
123
+ completionTokens: statsPayload?.completionTokens ?? null
124
+ };
125
+
126
+ // Append stats asynchronously (don't block response)
127
+ appendStats(paths, stats).catch((err) => {
128
+ app.log.error({ err }, "Failed to append request stats");
129
+ });
130
+
131
+ // Cleanup
132
+ requestContexts.delete(req);
133
+ });
134
+ }
135
+
136
+ interface StatsPayload {
137
+ endpointId?: string;
138
+ endpointName?: string;
139
+ upstreamModel?: string;
140
+ totalTokens?: number | null;
141
+ promptTokens?: number | null;
142
+ completionTokens?: number | null;
143
+ }
144
+
145
+ /**
146
+ * Helper to set stats payload from route handlers
147
+ */
148
+ export function setStatsPayload(reply: FastifyReply, payload: StatsPayload): void {
149
+ (reply as unknown as { statsPayload?: StatsPayload }).statsPayload = payload;
150
+ }
151
+
152
+ /**
153
+ * Estimate token count from byte sizes when actual usage is not available.
154
+ * Uses rough approximation: ~4 characters per token, ~1 byte per character for English.
155
+ * This is intentionally conservative.
156
+ */
157
+ function estimateTokens(requestBytes: number, responseBytes: number): number | null {
158
+ if (requestBytes === 0 && responseBytes === 0) {
159
+ return null;
160
+ }
161
+ // Rough estimate: 4 bytes per token average
162
+ return Math.ceil((requestBytes + responseBytes) / 4);
163
+ }