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,312 @@
1
+ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+ import { aggregateStats, readStatsForWindow } from "../storage/statsRepository";
3
+ import { StoragePaths } from "../storage/files";
4
+
5
+ /**
6
+ * Stats API Routes
7
+ *
8
+ * Provides endpoints for querying request statistics:
9
+ * - GET /admin/stats - aggregated statistics for time window
10
+ * - GET /admin/stats/raw - raw stats entries for detailed analysis
11
+ */
12
+
13
+ interface StatsQuery {
14
+ window?: string; // e.g., "1h", "24h", "7d"
15
+ timeZone?: string;
16
+ }
17
+
18
+ export async function registerStatsRoutes(
19
+ app: FastifyInstance,
20
+ paths: StoragePaths
21
+ ): Promise<void> {
22
+ // GET /admin/stats - aggregated statistics
23
+ app.get("/admin/stats", async (req: FastifyRequest<{ Querystring: StatsQuery }>, reply: FastifyReply) => {
24
+ const windowMs = parseWindow(req.query.window ?? "24h");
25
+ const timeZone = normalizeTimeZone(req.query.timeZone);
26
+
27
+ if (windowMs === null) {
28
+ reply.code(400).send({
29
+ error: {
30
+ message: "Invalid window format. Use format like '1h', '24h', '7d'"
31
+ }
32
+ });
33
+ return;
34
+ }
35
+
36
+ try {
37
+ const stats = await aggregateStats(paths, windowMs);
38
+ reply.send({
39
+ ...stats,
40
+ timeZone,
41
+ });
42
+ } catch (error) {
43
+ app.log.error({ error }, "Failed to aggregate stats");
44
+ reply.code(500).send({ error: { message: "Failed to retrieve statistics" } });
45
+ }
46
+ });
47
+
48
+ // GET /admin/stats/raw - raw stats entries
49
+ app.get("/admin/stats/raw", async (req: FastifyRequest<{ Querystring: StatsQuery & { limit?: string } }>, reply: FastifyReply) => {
50
+ const windowDays = parseWindowDays(req.query.window ?? "1d");
51
+ const limit = Math.min(parseInt(req.query.limit ?? "1000", 10), 10000);
52
+
53
+ if (windowDays === null) {
54
+ reply.code(400).send({
55
+ error: { message: "Invalid window format" }
56
+ });
57
+ return;
58
+ }
59
+
60
+ try {
61
+ const stats = await readStatsForWindow(paths, windowDays);
62
+ // Return most recent entries up to limit
63
+ const entries = stats.slice(-limit);
64
+ reply.send({
65
+ window: `${windowDays}d`,
66
+ count: entries.length,
67
+ totalInWindow: stats.length,
68
+ entries
69
+ });
70
+ } catch (error) {
71
+ app.log.error({ error }, "Failed to read raw stats");
72
+ reply.code(500).send({ error: { message: "Failed to retrieve statistics" } });
73
+ }
74
+ });
75
+
76
+ // GET /admin/stats/latency - latency distribution
77
+ app.get("/admin/stats/latency", async (req: FastifyRequest<{ Querystring: StatsQuery }>, reply: FastifyReply) => {
78
+ const windowMs = parseWindow(req.query.window ?? "7d");
79
+ const timeZone = normalizeTimeZone(req.query.timeZone);
80
+
81
+ if (windowMs === null) {
82
+ reply.code(400).send({ error: { message: "Invalid window format" } });
83
+ return;
84
+ }
85
+
86
+ try {
87
+ const stats = await selectStatsForWindow(paths, windowMs);
88
+ const latencies = stats.map((s) => s.latencyMs).sort((a, b) => a - b);
89
+ const window = formatWindowString(windowMs);
90
+
91
+ if (latencies.length === 0) {
92
+ reply.send({
93
+ window,
94
+ timeZone,
95
+ count: 0,
96
+ min: null,
97
+ max: null,
98
+ avg: null,
99
+ p50: null,
100
+ p95: null,
101
+ p99: null,
102
+ histogram: {}
103
+ });
104
+ return;
105
+ }
106
+
107
+ // Create histogram buckets
108
+ const buckets = [50, 100, 200, 500, 1000, 2000, 5000, 10000];
109
+ const histogram: Record<string, number> = {};
110
+
111
+ for (const bucket of buckets) {
112
+ histogram[`<${bucket}ms`] = 0;
113
+ }
114
+ histogram[">10000ms"] = 0;
115
+
116
+ for (const latency of latencies) {
117
+ let assigned = false;
118
+ for (const bucket of buckets) {
119
+ if (latency < bucket) {
120
+ histogram[`<${bucket}ms`]++;
121
+ assigned = true;
122
+ break;
123
+ }
124
+ }
125
+ if (!assigned) {
126
+ histogram[">10000ms"]++;
127
+ }
128
+ }
129
+
130
+ reply.send({
131
+ window,
132
+ timeZone,
133
+ count: latencies.length,
134
+ min: latencies[0],
135
+ max: latencies[latencies.length - 1],
136
+ avg: Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length),
137
+ p50: percentile(latencies, 50),
138
+ p95: percentile(latencies, 95),
139
+ p99: percentile(latencies, 99),
140
+ histogram
141
+ });
142
+ } catch (error) {
143
+ app.log.error({ error }, "Failed to compute latency distribution");
144
+ reply.code(500).send({ error: { message: "Failed to retrieve statistics" } });
145
+ }
146
+ });
147
+
148
+ // GET /admin/stats/tokens - token usage over time
149
+ app.get("/admin/stats/tokens", async (req: FastifyRequest<{ Querystring: StatsQuery }>, reply: FastifyReply) => {
150
+ const windowMs = parseWindow(req.query.window ?? "7d");
151
+ const timeZone = normalizeTimeZone(req.query.timeZone);
152
+
153
+ if (windowMs === null) {
154
+ reply.code(400).send({ error: { message: "Invalid window format" } });
155
+ return;
156
+ }
157
+
158
+ try {
159
+ const stats = await selectStatsForWindow(paths, windowMs);
160
+ const bucketGranularity = windowMs <= 24 * 60 * 60 * 1000 ? "hour" : "day";
161
+ const byDay: Record<string, {
162
+ count: number;
163
+ tokens: number;
164
+ estimated: number;
165
+ inputTokens: number;
166
+ outputTokens: number;
167
+ splitUnknown: number;
168
+ }> = {};
169
+ let tokenEstimatedCount = 0;
170
+ let splitUnknownCount = 0;
171
+ let totalInputTokens = 0;
172
+ let totalOutputTokens = 0;
173
+
174
+ for (const stat of stats) {
175
+ const bucket = formatTokenBucket(stat.timestamp, bucketGranularity, timeZone);
176
+ if (!byDay[bucket]) {
177
+ byDay[bucket] = {
178
+ count: 0,
179
+ tokens: 0,
180
+ estimated: 0,
181
+ inputTokens: 0,
182
+ outputTokens: 0,
183
+ splitUnknown: 0,
184
+ };
185
+ }
186
+ byDay[bucket].count++;
187
+ if (stat.totalTokens !== null && stat.totalTokens !== undefined) {
188
+ byDay[bucket].tokens += stat.totalTokens;
189
+ } else {
190
+ byDay[bucket].estimated++;
191
+ tokenEstimatedCount += 1;
192
+ }
193
+
194
+ const promptTokens = stat.promptTokens;
195
+ const completionTokens = stat.completionTokens;
196
+ const hasSplit =
197
+ promptTokens !== null &&
198
+ promptTokens !== undefined &&
199
+ completionTokens !== null &&
200
+ completionTokens !== undefined;
201
+ if (hasSplit) {
202
+ byDay[bucket].inputTokens += promptTokens;
203
+ byDay[bucket].outputTokens += completionTokens;
204
+ totalInputTokens += promptTokens;
205
+ totalOutputTokens += completionTokens;
206
+ } else {
207
+ byDay[bucket].splitUnknown++;
208
+ splitUnknownCount += 1;
209
+ }
210
+ }
211
+
212
+ const days = Object.entries(byDay)
213
+ .sort(([a], [b]) => a.localeCompare(b))
214
+ .map(([date, data]) => ({ date, ...data }));
215
+
216
+ const totalTokens = stats.reduce((sum, s) => sum + (s.totalTokens ?? 0), 0);
217
+
218
+ reply.send({
219
+ window: formatWindowString(windowMs),
220
+ totalTokens,
221
+ totalInputTokens,
222
+ totalOutputTokens,
223
+ totalRequests: stats.length,
224
+ avgTokensPerRequest: stats.length > 0 ? Math.round(totalTokens / stats.length) : 0,
225
+ byDay: days,
226
+ tokenEstimatedCount,
227
+ tokenEstimatedRate: stats.length > 0 ? tokenEstimatedCount / stats.length : 0,
228
+ splitUnknownCount,
229
+ splitUnknownRate: stats.length > 0 ? splitUnknownCount / stats.length : 0,
230
+ bucketGranularity,
231
+ bucketTimeZone: timeZone,
232
+ });
233
+ } catch (error) {
234
+ app.log.error({ error }, "Failed to compute token usage");
235
+ reply.code(500).send({ error: { message: "Failed to retrieve statistics" } });
236
+ }
237
+ });
238
+ }
239
+
240
+ function parseWindow(window: string): number | null {
241
+ const match = window.match(/^(\d+)(h|d|m)$/);
242
+ if (!match) return null;
243
+
244
+ const value = parseInt(match[1], 10);
245
+ const unit = match[2];
246
+
247
+ switch (unit) {
248
+ case "m": return value * 60 * 1000;
249
+ case "h": return value * 60 * 60 * 1000;
250
+ case "d": return value * 24 * 60 * 60 * 1000;
251
+ default: return null;
252
+ }
253
+ }
254
+
255
+ function parseWindowDays(window: string): number | null {
256
+ const ms = parseWindow(window);
257
+ if (ms === null) return null;
258
+ return Math.ceil(ms / (24 * 60 * 60 * 1000));
259
+ }
260
+
261
+ function percentile(sortedArr: number[], p: number): number {
262
+ if (sortedArr.length === 0) return 0;
263
+ const index = Math.ceil((p / 100) * sortedArr.length) - 1;
264
+ return sortedArr[Math.max(0, index)];
265
+ }
266
+
267
+ async function selectStatsForWindow(paths: StoragePaths, windowMs: number) {
268
+ const windowDays = Math.ceil(windowMs / (24 * 60 * 60 * 1000));
269
+ const stats = await readStatsForWindow(paths, windowDays);
270
+ const cutoff = Date.now() - windowMs;
271
+ return stats.filter((s) => s.timestamp.getTime() >= cutoff);
272
+ }
273
+
274
+ function formatWindowString(ms: number): string {
275
+ const hours = ms / (60 * 60 * 1000);
276
+ if (hours < 24) return `${Math.round(hours)}h`;
277
+ return `${Math.round(hours / 24)}d`;
278
+ }
279
+
280
+ function formatTokenBucket(timestamp: Date, granularity: "hour" | "day", timeZone: string): string {
281
+ const formatter = new Intl.DateTimeFormat("en-CA", {
282
+ timeZone,
283
+ year: "numeric",
284
+ month: "2-digit",
285
+ day: "2-digit",
286
+ ...(granularity === "hour"
287
+ ? {
288
+ hour: "2-digit",
289
+ hourCycle: "h23" as const,
290
+ }
291
+ : {}),
292
+ });
293
+ const parts = formatter.formatToParts(timestamp);
294
+ const year = parts.find((part) => part.type === "year")?.value ?? "0000";
295
+ const month = parts.find((part) => part.type === "month")?.value ?? "00";
296
+ const day = parts.find((part) => part.type === "day")?.value ?? "00";
297
+ if (granularity === "day") {
298
+ return `${year}-${month}-${day}`;
299
+ }
300
+ const hour = parts.find((part) => part.type === "hour")?.value ?? "00";
301
+ return `${year}-${month}-${day}T${hour}:00`;
302
+ }
303
+
304
+ function normalizeTimeZone(input: string | undefined): string {
305
+ if (!input) return "UTC";
306
+ try {
307
+ new Intl.DateTimeFormat("en-US", { timeZone: input });
308
+ return input;
309
+ } catch {
310
+ return "UTC";
311
+ }
312
+ }
@@ -0,0 +1,96 @@
1
+ import { FastifyInstance } from "fastify";
2
+ import fastifyStatic from "@fastify/static";
3
+ import path from "path";
4
+ import { promises as fs } from "fs";
5
+
6
+ /**
7
+ * Register UI routes to serve the React frontend.
8
+ *
9
+ * - Serves static files from ui/dist at /ui/*
10
+ * - Provides SPA fallback for client-side routing
11
+ */
12
+ export async function registerUiRoutes(app: FastifyInstance): Promise<void> {
13
+ // When running from dist/src/routes/ui.js, we need to go up to project root
14
+ // __dirname = dist/src/routes -> go up 3 levels to project root, then into ui/dist
15
+ const uiDistPath = path.join(__dirname, "..", "..", "..", "ui", "dist");
16
+
17
+ // Check if UI is built
18
+ const uiExists = await checkUiExists(uiDistPath);
19
+
20
+ if (!uiExists) {
21
+ // UI not built - serve a placeholder
22
+ app.get("/ui", async (_req, reply) => {
23
+ reply.type("text/html").send(`
24
+ <!DOCTYPE html>
25
+ <html>
26
+ <head>
27
+ <title>Waypoi UI</title>
28
+ <style>
29
+ body {
30
+ font-family: monospace;
31
+ background: #0a0a0c;
32
+ color: #e5e2d9;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ height: 100vh;
37
+ margin: 0;
38
+ }
39
+ .container { text-align: center; }
40
+ h1 { color: #eab308; }
41
+ code { background: #1a1a1e; padding: 4px 8px; border-radius: 4px; }
42
+ </style>
43
+ </head>
44
+ <body>
45
+ <div class="container">
46
+ <h1>Waypoi UI</h1>
47
+ <p>UI not built. Run:</p>
48
+ <p><code>cd ui && npm install && npm run build</code></p>
49
+ <p>Then restart the server.</p>
50
+ </div>
51
+ </body>
52
+ </html>
53
+ `);
54
+ });
55
+
56
+ app.get("/ui/*", async (_req, reply) => {
57
+ reply.redirect("/ui");
58
+ });
59
+
60
+ return;
61
+ }
62
+
63
+ // Register static file serving
64
+ await app.register(fastifyStatic, {
65
+ root: uiDistPath,
66
+ prefix: "/ui/",
67
+ decorateReply: false,
68
+ });
69
+
70
+ // SPA fallback - serve index.html for all /ui/* routes that don't match a file
71
+ app.setNotFoundHandler(async (req, reply) => {
72
+ if (req.url.startsWith("/ui")) {
73
+ const indexPath = path.join(uiDistPath, "index.html");
74
+ try {
75
+ const html = await fs.readFile(indexPath, "utf8");
76
+ reply.type("text/html").send(html);
77
+ } catch {
78
+ reply.code(404).send({ error: { message: "UI not found" } });
79
+ }
80
+ } else if (req.url === "/" && req.headers.accept?.includes("text/html")) {
81
+ reply.redirect("/ui");
82
+ } else {
83
+ reply.code(404).send({ error: { message: "Not found" } });
84
+ }
85
+ });
86
+ }
87
+
88
+ async function checkUiExists(distPath: string): Promise<boolean> {
89
+ try {
90
+ const indexPath = path.join(distPath, "index.html");
91
+ await fs.access(indexPath);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
@@ -0,0 +1,132 @@
1
+ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+ import { randomUUID } from "crypto";
3
+ import { logRequest } from "../storage/repositories";
4
+ import { RequestLog, VideoGenerationRequest } from "../types";
5
+ import { StoragePaths } from "../storage/files";
6
+ import { resolveVideoGenerationModel, runVideoGeneration } from "../services/videoGeneration";
7
+ import { setCaptureError, setCaptureRouting } from "../middleware/requestCapture";
8
+
9
+ export async function registerVideoRoutes(app: FastifyInstance, paths: StoragePaths): Promise<void> {
10
+ app.post("/v1/videos/generations", async (req: FastifyRequest, reply: FastifyReply) => {
11
+ const body = req.body as VideoGenerationRequest | undefined;
12
+
13
+ if (!body?.prompt) {
14
+ reply.code(400).send({ error: { message: "prompt is required" } });
15
+ return;
16
+ }
17
+
18
+ const model = await resolveVideoGenerationModel(paths, body.model);
19
+ if (!model) {
20
+ reply.code(400).send({ error: { message: "No video generation model available. Add or enable a provider model." } });
21
+ return;
22
+ }
23
+
24
+ const requestId = randomUUID();
25
+ const start = Date.now();
26
+ const controller = new AbortController();
27
+
28
+ req.raw.on("close", () => controller.abort());
29
+
30
+ try {
31
+ const generated = await runVideoGeneration(
32
+ paths,
33
+ { ...body, model },
34
+ req.headers as Record<string, string | string[] | undefined>,
35
+ controller.signal
36
+ );
37
+ setHeaders(reply, generated.headers);
38
+ reply.code(generated.statusCode).send(generated.payload);
39
+ setCaptureRouting(reply, {
40
+ publicModel: model,
41
+ endpointId: generated.route.endpointId,
42
+ endpointName: generated.route.endpointName,
43
+ upstreamModel: generated.route.upstreamModel,
44
+ });
45
+
46
+ await logRequest(paths, buildLog(
47
+ requestId,
48
+ model,
49
+ {
50
+ attempt: {
51
+ endpoint: {
52
+ id: generated.route.endpointId,
53
+ name: generated.route.endpointName,
54
+ },
55
+ upstreamModel: generated.route.upstreamModel,
56
+ response: {
57
+ statusCode: generated.statusCode,
58
+ },
59
+ },
60
+ },
61
+ Date.now() - start,
62
+ false
63
+ ));
64
+ } catch (error) {
65
+ const errorType = (error as { type?: string }).type ?? (error as Error).name;
66
+ setCaptureError(reply, { type: errorType, message: (error as Error).message });
67
+ await logRequest(paths, {
68
+ requestId,
69
+ ts: new Date(),
70
+ route: { publicModel: model },
71
+ request: { stream: false },
72
+ result: {
73
+ errorType,
74
+ errorMessage: (error as Error).message
75
+ }
76
+ });
77
+ if (errorType === "invalid_request") {
78
+ reply.code(400).send({ error: { message: (error as Error).message } });
79
+ return;
80
+ }
81
+ if (errorType === "tls_verify_failed") {
82
+ reply.code(502).send({ error: { message: (error as Error).message, type: errorType } });
83
+ return;
84
+ }
85
+ const status =
86
+ errorType === "no_endpoints" ||
87
+ errorType === "protocol_stream_unsupported" ||
88
+ errorType === "unsupported_protocol" ||
89
+ errorType === "invalid_protocol_config"
90
+ ? 400
91
+ : errorType === "rate_limited"
92
+ ? 429
93
+ : 502;
94
+ reply.code(status).send({ error: { message: "Video generation unavailable", type: errorType } });
95
+ }
96
+ });
97
+ }
98
+
99
+ function setHeaders(reply: FastifyReply, headers: Record<string, string | string[]>): void {
100
+ for (const [key, value] of Object.entries(headers)) {
101
+ if (Array.isArray(value)) {
102
+ reply.header(key.toLowerCase(), value.join(", "));
103
+ } else {
104
+ reply.header(key.toLowerCase(), value);
105
+ }
106
+ }
107
+ }
108
+
109
+ function buildLog(
110
+ requestId: string,
111
+ model: string,
112
+ outcome: { attempt: { endpoint: { id: string; name: string }; upstreamModel: string; response: { statusCode: number } } },
113
+ latencyMs: number,
114
+ stream: boolean
115
+ ): RequestLog {
116
+ return {
117
+ requestId,
118
+ ts: new Date(),
119
+ route: {
120
+ publicModel: model,
121
+ endpointId: outcome.attempt.endpoint.id,
122
+ endpointName: outcome.attempt.endpoint.name,
123
+ upstreamModel: outcome.attempt.upstreamModel
124
+ },
125
+ request: { stream },
126
+ result: {
127
+ statusCode: outcome.attempt.response.statusCode,
128
+ latencyMs,
129
+ totalTokens: null
130
+ }
131
+ };
132
+ }