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,797 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import path from "path";
4
+ import { promises as fs } from "fs";
5
+ import Fastify from "fastify";
6
+ import {
7
+ appendCaptureStreamChunk,
8
+ registerRequestCaptureMiddleware,
9
+ setCaptureRouting,
10
+ startCaptureStreamResponse,
11
+ } from "../src/middleware/requestCapture";
12
+ import {
13
+ ensureCaptureStore,
14
+ getCaptureCalendarMonth,
15
+ getCaptureConfig,
16
+ listCaptureRecords,
17
+ persistCaptureRecord,
18
+ updateCaptureConfig,
19
+ getCaptureRecordById,
20
+ } from "../src/storage/captureRepository";
21
+ import type { StoragePaths } from "../src/storage/files";
22
+
23
+ function makePaths(baseDir: string): StoragePaths {
24
+ return {
25
+ baseDir,
26
+ configPath: path.join(baseDir, "config.yaml"),
27
+ healthPath: path.join(baseDir, "health.json"),
28
+ providerHealthPath: path.join(baseDir, "providers_health.json"),
29
+ requestLogPath: path.join(baseDir, "request_logs.jsonl"),
30
+ providersPath: path.join(baseDir, "providers.json"),
31
+ poolsPath: path.join(baseDir, "pools.json"),
32
+ poolStatePath: path.join(baseDir, "pool_state.json"),
33
+ };
34
+ }
35
+
36
+ async function makeWorkspaceTempDir(prefix: string): Promise<string> {
37
+ const base = path.join(process.cwd(), "tmp");
38
+ await fs.mkdir(base, { recursive: true });
39
+ return fs.mkdtemp(path.join(base, prefix));
40
+ }
41
+
42
+ test("capture repository toggles config and persists record with media artifact previews", async () => {
43
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-test-");
44
+ const paths = makePaths(baseDir);
45
+
46
+ await ensureCaptureStore(paths);
47
+ const initial = await getCaptureConfig(paths);
48
+ assert.equal(initial.enabled, false);
49
+
50
+ await updateCaptureConfig(paths, { enabled: true });
51
+ const enabled = await getCaptureConfig(paths);
52
+ assert.equal(enabled.enabled, true);
53
+
54
+ const pngData =
55
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2w2pQAAAAASUVORK5CYII=";
56
+
57
+ const persisted = await persistCaptureRecord(paths, {
58
+ route: "/v1/chat/completions",
59
+ method: "POST",
60
+ statusCode: 200,
61
+ latencyMs: 42,
62
+ requestBody: {
63
+ model: "prov/model",
64
+ messages: [{ role: "system", content: "Read AGENTS.md" }],
65
+ tools: [{ type: "function", function: { name: "my_tool", description: "MCP helper" } }],
66
+ image: pngData,
67
+ },
68
+ responseBody: { ok: true },
69
+ routing: { publicModel: "prov/model", endpointId: "ep-1", upstreamModel: "u-1" },
70
+ });
71
+
72
+ assert.ok(persisted);
73
+ assert.equal(persisted?.artifacts.length, 1);
74
+ assert.equal(persisted?.analysis.systemMessages.length, 1);
75
+ assert.equal(persisted?.analysis.systemMessages[0]?.content, "Read AGENTS.md");
76
+ assert.equal(persisted?.analysis.mcpToolDescriptions.length, 1);
77
+
78
+ const list = await listCaptureRecords(paths, 5);
79
+ assert.equal(list.data.length, 1);
80
+ const loaded = await getCaptureRecordById(paths, list.data[0].id);
81
+ assert.ok(loaded);
82
+ assert.equal(loaded?.route, "/v1/chat/completions");
83
+ assert.equal(loaded?.analysis.requestTimeline[0]?.kind, "message");
84
+ });
85
+
86
+ test("capture analysis preserves full text and assistant reasoning/tool structure", async () => {
87
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-analysis-");
88
+ const paths = makePaths(baseDir);
89
+
90
+ await ensureCaptureStore(paths);
91
+ await updateCaptureConfig(paths, { enabled: true });
92
+
93
+ const longSystem = "You are opencode. " + "A".repeat(1800);
94
+ const persisted = await persistCaptureRecord(paths, {
95
+ route: "/v1/chat/completions",
96
+ method: "POST",
97
+ statusCode: 200,
98
+ latencyMs: 73,
99
+ requestBody: {
100
+ messages: [
101
+ { role: "system", content: longSystem },
102
+ { role: "user", content: "Could you analyze the repo and clarify the minimal cleanup?" },
103
+ {
104
+ role: "assistant",
105
+ content: "I can do that. Could you confirm whether archive files should be kept?",
106
+ reasoning_content: "I should inspect the repo and then ask one clarifying question.",
107
+ tool_calls: [
108
+ {
109
+ id: "call_1",
110
+ type: "function",
111
+ function: {
112
+ name: "exec_command",
113
+ arguments: "{\"cmd\":\"rg --files\"}",
114
+ },
115
+ },
116
+ ],
117
+ },
118
+ {
119
+ role: "tool",
120
+ tool_call_id: "call_1",
121
+ content: "README.md\nAGENTS.md",
122
+ },
123
+ ],
124
+ tools: [{ type: "function", function: { name: "exec_command", description: "Run shell commands with policy" } }],
125
+ },
126
+ responseBody: { ok: true },
127
+ });
128
+
129
+ assert.ok(persisted);
130
+ assert.equal(persisted?.analysis.systemMessages[0]?.content, longSystem);
131
+ assert.equal(
132
+ persisted?.analysis.assistantMessages[0]?.reasoningContent,
133
+ "I should inspect the repo and then ask one clarifying question.",
134
+ );
135
+ assert.equal(persisted?.analysis.assistantMessages[0]?.toolCalls?.[0]?.function?.name, "exec_command");
136
+ assert.equal(persisted?.analysis.assistantMessages[0]?.asksForClarification, true);
137
+ assert.equal(persisted?.analysis.toolMessages[0]?.toolCallId, "call_1");
138
+ assert.match(persisted?.analysis.agentsMdHints[0] ?? "", /AGENTS\.md/);
139
+ assert.deepEqual(
140
+ persisted?.analysis.requestTimeline.map((entry) => entry.kind),
141
+ ["message", "message", "message", "reasoning", "tool_call", "tool_result", "tool_definition"],
142
+ );
143
+ });
144
+
145
+ test("capture list prunes stale index entries whose record files are missing", async () => {
146
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-stale-index-");
147
+ const paths = makePaths(baseDir);
148
+
149
+ await ensureCaptureStore(paths);
150
+ await updateCaptureConfig(paths, { enabled: true });
151
+
152
+ const first = await persistCaptureRecord(paths, {
153
+ route: "/v1/models",
154
+ method: "GET",
155
+ statusCode: 200,
156
+ latencyMs: 5,
157
+ responseBody: { data: [{ id: "demo" }] },
158
+ });
159
+ const second = await persistCaptureRecord(paths, {
160
+ route: "/v1/chat/completions",
161
+ method: "POST",
162
+ statusCode: 200,
163
+ latencyMs: 7,
164
+ responseBody: { id: "chatcmpl-1" },
165
+ });
166
+
167
+ assert.ok(first);
168
+ assert.ok(second);
169
+
170
+ const indexPath = path.join(baseDir, "capture", "index.jsonl");
171
+ const rawIndex = await fs.readFile(indexPath, "utf8");
172
+ const firstEntry = rawIndex
173
+ .split("\n")
174
+ .filter(Boolean)
175
+ .map((line) => JSON.parse(line) as { id: string; file: string })
176
+ .find((entry) => entry.id === first?.id);
177
+
178
+ assert.ok(firstEntry);
179
+ await fs.unlink(path.join(baseDir, "capture", firstEntry.file));
180
+
181
+ const listed = await listCaptureRecords(paths, 10);
182
+ assert.equal(listed.total, 1);
183
+ assert.deepEqual(listed.data.map((entry) => entry.id), [second?.id]);
184
+
185
+ const rewrittenIndex = await fs.readFile(indexPath, "utf8");
186
+ assert.doesNotMatch(rewrittenIndex, new RegExp(first!.id));
187
+ });
188
+
189
+ test("capture day bucketing follows requested timezone with UTC fallback", async () => {
190
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-timezone-buckets-");
191
+ const paths = makePaths(baseDir);
192
+
193
+ await ensureCaptureStore(paths);
194
+ await updateCaptureConfig(paths, { enabled: true });
195
+
196
+ const record = await persistCaptureRecord(paths, {
197
+ route: "/v1/chat/completions",
198
+ method: "POST",
199
+ statusCode: 200,
200
+ latencyMs: 8,
201
+ requestBody: { model: "prov/model", messages: [{ role: "user", content: "hello" }] },
202
+ responseBody: { id: "resp-1" },
203
+ });
204
+ assert.ok(record);
205
+
206
+ const indexPath = path.join(baseDir, "capture", "index.jsonl");
207
+ const lines = (await fs.readFile(indexPath, "utf8")).split("\n").filter(Boolean);
208
+ const patched = lines.map((line) => {
209
+ const entry = JSON.parse(line) as { id: string; timestamp: string };
210
+ if (entry.id === record!.id) {
211
+ entry.timestamp = "2026-01-01T01:30:00.000Z";
212
+ }
213
+ return JSON.stringify(entry);
214
+ });
215
+ await fs.writeFile(indexPath, `${patched.join("\n")}\n`, "utf8");
216
+
217
+ const utcList = await listCaptureRecords(paths, { date: "2026-01-01", timeZone: "UTC", limit: 5 });
218
+ assert.equal(utcList.total, 1);
219
+ const chicagoList = await listCaptureRecords(paths, { date: "2025-12-31", timeZone: "America/Chicago", limit: 5 });
220
+ assert.equal(chicagoList.total, 1);
221
+ const chicagoWrongDay = await listCaptureRecords(paths, { date: "2026-01-01", timeZone: "America/Chicago", limit: 5 });
222
+ assert.equal(chicagoWrongDay.total, 0);
223
+
224
+ const fallbackList = await listCaptureRecords(paths, { date: "2026-01-01", timeZone: "Not/A_Zone", limit: 5 });
225
+ assert.equal(fallbackList.total, 1);
226
+
227
+ const utcCalendar = await getCaptureCalendarMonth(paths, "2026-01", "UTC");
228
+ assert.deepEqual(utcCalendar, [{ date: "2026-01-01", count: 1 }]);
229
+ const chicagoCalendar = await getCaptureCalendarMonth(paths, "2025-12", "America/Chicago");
230
+ assert.deepEqual(chicagoCalendar, [{ date: "2025-12-31", count: 1 }]);
231
+ const fallbackCalendar = await getCaptureCalendarMonth(paths, "2026-01", "Not/A_Zone");
232
+ assert.deepEqual(fallbackCalendar, [{ date: "2026-01-01", count: 1 }]);
233
+ });
234
+
235
+ test("streamed capture persists routing, headers, and response timeline before onResponse", async () => {
236
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-stream-");
237
+ const paths = makePaths(baseDir);
238
+
239
+ await ensureCaptureStore(paths);
240
+ await updateCaptureConfig(paths, { enabled: true });
241
+
242
+ const app = Fastify();
243
+ const chunks = [
244
+ 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
245
+ "data: [DONE]\n\n",
246
+ ];
247
+ await registerRequestCaptureMiddleware(app, paths);
248
+ app.post("/v1/chat/completions", async (_req, reply) => {
249
+ const headers = { "content-type": "text/event-stream" };
250
+
251
+ reply.hijack();
252
+ setCaptureRouting(reply, {
253
+ publicModel: "demo/model",
254
+ endpointId: "ep-1",
255
+ endpointName: "Demo Endpoint",
256
+ upstreamModel: "upstream/demo",
257
+ });
258
+ startCaptureStreamResponse(reply, headers, "text/event-stream");
259
+ reply.raw.writeHead(200, headers);
260
+ for (const chunk of chunks) {
261
+ const buffer = Buffer.from(chunk, "utf8");
262
+ appendCaptureStreamChunk(reply, buffer, { contentType: "text/event-stream", headers });
263
+ reply.raw.write(buffer);
264
+ }
265
+ reply.raw.end();
266
+ return reply;
267
+ });
268
+
269
+ const response = await app.inject({
270
+ method: "POST",
271
+ url: "/v1/chat/completions",
272
+ payload: { model: "demo/model", stream: true, messages: [{ role: "user", content: "hello" }] },
273
+ });
274
+ assert.equal(response.statusCode, 200);
275
+
276
+ let listed = await listCaptureRecords(paths, 5);
277
+ for (let attempt = 0; attempt < 10 && listed.data.length === 0; attempt += 1) {
278
+ await new Promise((resolve) => setTimeout(resolve, 10));
279
+ listed = await listCaptureRecords(paths, 5);
280
+ }
281
+ assert.equal(listed.data.length, 1);
282
+ let loaded = await getCaptureRecordById(paths, listed.data[0].id);
283
+ for (let attempt = 0; attempt < 10 && !loaded; attempt += 1) {
284
+ await new Promise((resolve) => setTimeout(resolve, 10));
285
+ loaded = await getCaptureRecordById(paths, listed.data[0].id);
286
+ }
287
+
288
+ assert.ok(loaded);
289
+ assert.equal(loaded?.routing.endpointId, "ep-1");
290
+ assert.equal(loaded?.response.headers["content-type"], "text/event-stream");
291
+ assert.equal((loaded?.response.body as { $type?: string })?.$type, "stream");
292
+ assert.equal((loaded?.response.body as { bytes?: number })?.bytes, Buffer.byteLength(chunks.join("")));
293
+ assert.match(String((loaded?.response.body as { text?: string })?.text), /Hello/);
294
+ assert.equal(loaded?.analysis.responseTimeline.length, 1);
295
+ assert.equal(loaded?.analysis.responseTimeline[0]?.kind, "stream_preview");
296
+ assert.match(loaded?.analysis.responseTimeline[0]?.content ?? "", /Hello/);
297
+
298
+ await app.close();
299
+ });
300
+
301
+ test("capture token flow uses exact usage totals with estimated category slices", async () => {
302
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-token-flow-exact-");
303
+ const paths = makePaths(baseDir);
304
+
305
+ await ensureCaptureStore(paths);
306
+ await updateCaptureConfig(paths, { enabled: true });
307
+
308
+ const persisted = await persistCaptureRecord(paths, {
309
+ route: "/v1/chat/completions",
310
+ method: "POST",
311
+ statusCode: 200,
312
+ latencyMs: 44,
313
+ requestBody: {
314
+ instructions: "Stay concise.",
315
+ messages: [
316
+ { role: "system", content: "System policy." },
317
+ { role: "user", content: "Explain the request." },
318
+ ],
319
+ tools: [{ type: "function", function: { name: "lookup", description: "Find things" } }],
320
+ },
321
+ responseBody: {
322
+ usage: { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200 },
323
+ choices: [{ message: { role: "assistant", content: "Here is the answer." } }],
324
+ },
325
+ });
326
+
327
+ assert.ok(persisted);
328
+ assert.equal(persisted?.analysis.tokenFlow.eligible, true);
329
+ assert.equal(persisted?.analysis.tokenFlow.method, "exact_totals_estimated_categories");
330
+ assert.equal(persisted?.analysis.tokenFlow.totals.inputTokens, 120);
331
+ assert.equal(persisted?.analysis.tokenFlow.totals.outputTokens, 80);
332
+ assert.equal(persisted?.analysis.tokenFlow.totals.totalTokens, 200);
333
+ assert.equal(
334
+ persisted?.analysis.tokenFlow.input.reduce((sum, item) => sum + item.tokens, 0),
335
+ 120
336
+ );
337
+ assert.equal(
338
+ persisted?.analysis.tokenFlow.output.reduce((sum, item) => sum + item.tokens, 0),
339
+ 80
340
+ );
341
+ });
342
+
343
+ test("capture token flow falls back to estimated mode without usage", async () => {
344
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-token-flow-estimated-");
345
+ const paths = makePaths(baseDir);
346
+
347
+ await ensureCaptureStore(paths);
348
+ await updateCaptureConfig(paths, { enabled: true });
349
+
350
+ const persisted = await persistCaptureRecord(paths, {
351
+ route: "/v1/chat/completions",
352
+ method: "POST",
353
+ statusCode: 200,
354
+ latencyMs: 31,
355
+ requestBody: {
356
+ messages: [{ role: "user", content: "stream this response please" }],
357
+ },
358
+ responseBody: {
359
+ $type: "stream",
360
+ contentType: "text/event-stream",
361
+ bytes: 42,
362
+ text: "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}",
363
+ },
364
+ });
365
+
366
+ assert.ok(persisted);
367
+ assert.equal(persisted?.analysis.tokenFlow.eligible, true);
368
+ assert.equal(persisted?.analysis.tokenFlow.method, "estimated_only");
369
+ assert.ok((persisted?.analysis.tokenFlow.totals.totalTokens ?? 0) > 0);
370
+ assert.ok((persisted?.analysis.tokenFlow.input.find((item) => item.key === "unattributed_input")?.tokens ?? 0) >= 0);
371
+ assert.ok((persisted?.analysis.tokenFlow.output.find((item) => item.key === "assistant_text")?.tokens ?? 0) >= 0);
372
+ });
373
+
374
+ test("capture token flow stream output uses extracted SSE text instead of raw payload size", async () => {
375
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-token-flow-stream-extracted-");
376
+ const paths = makePaths(baseDir);
377
+
378
+ await ensureCaptureStore(paths);
379
+ await updateCaptureConfig(paths, { enabled: true });
380
+
381
+ const huge = "A".repeat(20000);
382
+ const sse = [
383
+ "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}",
384
+ "",
385
+ `data: {\"attachments\":[{\"b64_json\":\"${huge}\"}]}`,
386
+ "",
387
+ ].join("\n");
388
+
389
+ const persisted = await persistCaptureRecord(paths, {
390
+ route: "/v1/chat/completions",
391
+ method: "POST",
392
+ statusCode: 200,
393
+ latencyMs: 77,
394
+ requestBody: {
395
+ messages: [{ role: "user", content: "brief reply please" }],
396
+ },
397
+ responseBody: {
398
+ $type: "stream",
399
+ contentType: "text/event-stream",
400
+ bytes: Buffer.byteLength(sse),
401
+ text: sse,
402
+ },
403
+ });
404
+
405
+ assert.ok(persisted);
406
+ const assistantText = persisted?.analysis.tokenFlow.output.find((item) => item.key === "assistant_text")?.tokens ?? 0;
407
+ const unattributedOutput = persisted?.analysis.tokenFlow.output.find((item) => item.key === "unattributed_output")?.tokens ?? 0;
408
+ assert.equal(assistantText, 1);
409
+ assert.equal(unattributedOutput, 0);
410
+ assert.ok(
411
+ (persisted?.analysis.tokenFlow.notes ?? []).some((note) =>
412
+ note.includes("timeline-equivalent SSE text")
413
+ )
414
+ );
415
+ });
416
+
417
+ test("capture token flow stream output ignores non-text SSE metadata when useful text exists", async () => {
418
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-token-flow-stream-mixed-");
419
+ const paths = makePaths(baseDir);
420
+
421
+ await ensureCaptureStore(paths);
422
+ await updateCaptureConfig(paths, { enabled: true });
423
+
424
+ const huge = "Z".repeat(12000);
425
+ const useful = "hello reason";
426
+ const sse = [
427
+ "data: {\"choices\":[{\"delta\":{\"content\":\"hello \"}}]}",
428
+ "",
429
+ "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"reason\"}}]}",
430
+ "",
431
+ `data: {\"image\":{\"b64_json\":\"${huge}\"}}`,
432
+ "",
433
+ ].join("\n");
434
+
435
+ const persisted = await persistCaptureRecord(paths, {
436
+ route: "/v1/chat/completions",
437
+ method: "POST",
438
+ statusCode: 200,
439
+ latencyMs: 66,
440
+ requestBody: {
441
+ messages: [{ role: "user", content: "mixed stream test" }],
442
+ },
443
+ responseBody: {
444
+ $type: "stream",
445
+ contentType: "text/event-stream",
446
+ bytes: Buffer.byteLength(sse),
447
+ text: sse,
448
+ },
449
+ });
450
+
451
+ assert.ok(persisted);
452
+ const expected = Math.ceil(Buffer.byteLength(useful, "utf8") / 4);
453
+ const assistantText = persisted?.analysis.tokenFlow.output.find((item) => item.key === "assistant_text")?.tokens ?? 0;
454
+ const unattributedOutput = persisted?.analysis.tokenFlow.output.find((item) => item.key === "unattributed_output")?.tokens ?? 0;
455
+ assert.equal(assistantText, expected);
456
+ assert.equal(unattributedOutput, 0);
457
+ });
458
+
459
+ test("capture token flow stream output attributes both assistant_text and tool_calls", async () => {
460
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-token-flow-stream-text-and-tools-");
461
+ const paths = makePaths(baseDir);
462
+
463
+ await ensureCaptureStore(paths);
464
+ await updateCaptureConfig(paths, { enabled: true });
465
+
466
+ const sse = [
467
+ "data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}",
468
+ "",
469
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"task\",\"arguments\":\"{\\\"prompt\\\":\\\"hello\\\"}\"}}]}}]}",
470
+ "",
471
+ ].join("\n");
472
+
473
+ const persisted = await persistCaptureRecord(paths, {
474
+ route: "/v1/chat/completions",
475
+ method: "POST",
476
+ statusCode: 200,
477
+ latencyMs: 63,
478
+ requestBody: {
479
+ messages: [{ role: "user", content: "run task then answer" }],
480
+ },
481
+ responseBody: {
482
+ $type: "stream",
483
+ contentType: "text/event-stream",
484
+ bytes: Buffer.byteLength(sse),
485
+ text: sse,
486
+ },
487
+ });
488
+
489
+ assert.ok(persisted);
490
+ const assistantText = persisted?.analysis.tokenFlow.output.find((item) => item.key === "assistant_text")?.tokens ?? 0;
491
+ const toolCalls = persisted?.analysis.tokenFlow.output.find((item) => item.key === "tool_calls")?.tokens ?? 0;
492
+ assert.ok(assistantText > 0);
493
+ assert.ok(toolCalls > 0);
494
+ assert.ok(
495
+ (persisted?.analysis.tokenFlow.notes ?? []).some((note) =>
496
+ note.includes("timeline-equivalent SSE text and reconstructed streamed tool-call deltas")
497
+ )
498
+ );
499
+ });
500
+
501
+ test("capture token flow stream output attributes tool_calls without fallback when stream has no assistant text", async () => {
502
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-token-flow-stream-tools-only-");
503
+ const paths = makePaths(baseDir);
504
+
505
+ await ensureCaptureStore(paths);
506
+ await updateCaptureConfig(paths, { enabled: true });
507
+
508
+ const sse = [
509
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"task\",\"arguments\":\"{\"}}]}}]}",
510
+ "",
511
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"prompt\\\":\\\"hello\\\"}\"}}]}}]}",
512
+ "",
513
+ ].join("\n");
514
+
515
+ const persisted = await persistCaptureRecord(paths, {
516
+ route: "/v1/chat/completions",
517
+ method: "POST",
518
+ statusCode: 200,
519
+ latencyMs: 52,
520
+ requestBody: {
521
+ messages: [{ role: "user", content: "call tool only" }],
522
+ },
523
+ responseBody: {
524
+ $type: "stream",
525
+ contentType: "text/event-stream",
526
+ bytes: Buffer.byteLength(sse),
527
+ text: sse,
528
+ },
529
+ });
530
+
531
+ assert.ok(persisted);
532
+ const assistantText = persisted?.analysis.tokenFlow.output.find((item) => item.key === "assistant_text")?.tokens ?? 0;
533
+ const toolCalls = persisted?.analysis.tokenFlow.output.find((item) => item.key === "tool_calls")?.tokens ?? 0;
534
+ const unattributedOutput = persisted?.analysis.tokenFlow.output.find((item) => item.key === "unattributed_output")?.tokens ?? 0;
535
+ assert.equal(assistantText, 0);
536
+ assert.ok(toolCalls > 0);
537
+ assert.equal(unattributedOutput, 0);
538
+ assert.ok(
539
+ (persisted?.analysis.tokenFlow.notes ?? []).some((note) =>
540
+ note.includes("reconstructed streamed tool-call deltas")
541
+ )
542
+ );
543
+ assert.ok(
544
+ (persisted?.analysis.tokenFlow.notes ?? []).every((note) =>
545
+ !note.includes("fallback uses merged SSE event payload text")
546
+ )
547
+ );
548
+ });
549
+
550
+ test("capture token flow stream output falls back to merged event payload when no text or tool calls are extractable", async () => {
551
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-token-flow-stream-fallback-only-");
552
+ const paths = makePaths(baseDir);
553
+
554
+ await ensureCaptureStore(paths);
555
+ await updateCaptureConfig(paths, { enabled: true });
556
+
557
+ const sse = [
558
+ "data: {\"event\":\"metadata\",\"blob\":{\"id\":\"abc\",\"size\":2048}}",
559
+ "",
560
+ "data: {\"event\":\"stats\",\"dur_ms\":15}",
561
+ "",
562
+ ].join("\n");
563
+
564
+ const persisted = await persistCaptureRecord(paths, {
565
+ route: "/v1/chat/completions",
566
+ method: "POST",
567
+ statusCode: 200,
568
+ latencyMs: 41,
569
+ requestBody: {
570
+ messages: [{ role: "user", content: "metadata only stream" }],
571
+ },
572
+ responseBody: {
573
+ $type: "stream",
574
+ contentType: "text/event-stream",
575
+ bytes: Buffer.byteLength(sse),
576
+ text: sse,
577
+ },
578
+ });
579
+
580
+ assert.ok(persisted);
581
+ const assistantText = persisted?.analysis.tokenFlow.output.find((item) => item.key === "assistant_text")?.tokens ?? 0;
582
+ const toolCalls = persisted?.analysis.tokenFlow.output.find((item) => item.key === "tool_calls")?.tokens ?? 0;
583
+ const unattributedOutput = persisted?.analysis.tokenFlow.output.find((item) => item.key === "unattributed_output")?.tokens ?? 0;
584
+ assert.equal(assistantText, 0);
585
+ assert.equal(toolCalls, 0);
586
+ assert.ok(unattributedOutput > 0);
587
+ assert.ok(
588
+ (persisted?.analysis.tokenFlow.notes ?? []).some((note) =>
589
+ note.includes("fallback uses merged SSE event payload text")
590
+ )
591
+ );
592
+ });
593
+
594
+ test("streamed response timeline includes reconstructed tool_call entries from delta.tool_calls", async () => {
595
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-stream-tool-calls-");
596
+ const paths = makePaths(baseDir);
597
+
598
+ await ensureCaptureStore(paths);
599
+ await updateCaptureConfig(paths, { enabled: true });
600
+
601
+ const sse = [
602
+ "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"thinking\"}}]}",
603
+ "",
604
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"type\":\"function\",\"function\":{\"name\":\"task\",\"arguments\":\"{\"}}]}}]}",
605
+ "",
606
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"description\\\":\\\"Print\\\"\"}}]}}]}",
607
+ "",
608
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\",\\\"prompt\\\":\\\"Print hello\\\"}\"}}]}}]}",
609
+ "",
610
+ "data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}",
611
+ "",
612
+ ].join("\n");
613
+
614
+ const persisted = await persistCaptureRecord(paths, {
615
+ route: "/v1/chat/completions",
616
+ method: "POST",
617
+ statusCode: 200,
618
+ latencyMs: 12,
619
+ requestBody: {
620
+ messages: [{ role: "user", content: "use task tool" }],
621
+ },
622
+ responseBody: {
623
+ $type: "stream",
624
+ contentType: "text/event-stream",
625
+ bytes: Buffer.byteLength(sse),
626
+ text: sse,
627
+ },
628
+ });
629
+
630
+ assert.ok(persisted);
631
+ const responseTimeline = persisted?.analysis.responseTimeline ?? [];
632
+ assert.ok(responseTimeline.some((entry) => entry.kind === "stream_preview"));
633
+ const toolCalls = responseTimeline.filter((entry) => entry.kind === "tool_call");
634
+ assert.equal(toolCalls.length, 1);
635
+ assert.equal(toolCalls[0]?.name, "task");
636
+ assert.equal(toolCalls[0]?.toolCallId, "call_123");
637
+ assert.match(toolCalls[0]?.arguments ?? "", /"description":"Print"/);
638
+ assert.match(toolCalls[0]?.arguments ?? "", /"prompt":"Print hello"/);
639
+ });
640
+
641
+ test("streamed response timeline reconstructs multiple tool calls split across chunks", async () => {
642
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-stream-multi-tool-calls-");
643
+ const paths = makePaths(baseDir);
644
+
645
+ await ensureCaptureStore(paths);
646
+ await updateCaptureConfig(paths, { enabled: true });
647
+
648
+ const sse = [
649
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"task\",\"arguments\":\"{\"}},{\"index\":1,\"id\":\"call_b\",\"type\":\"function\",\"function\":{\"name\":\"task\",\"arguments\":\"{\"}}]}}]}",
650
+ "",
651
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"description\\\":\\\"A\\\"}\"}},{\"index\":1,\"function\":{\"arguments\":\"\\\"description\\\":\\\"B\\\"}\"}}]}}]}",
652
+ "",
653
+ ].join("\n");
654
+
655
+ const persisted = await persistCaptureRecord(paths, {
656
+ route: "/v1/chat/completions",
657
+ method: "POST",
658
+ statusCode: 200,
659
+ latencyMs: 10,
660
+ requestBody: {
661
+ messages: [{ role: "user", content: "two tools" }],
662
+ },
663
+ responseBody: {
664
+ $type: "stream",
665
+ contentType: "text/event-stream",
666
+ bytes: Buffer.byteLength(sse),
667
+ text: sse,
668
+ },
669
+ });
670
+
671
+ assert.ok(persisted);
672
+ const toolCalls = (persisted?.analysis.responseTimeline ?? []).filter((entry) => entry.kind === "tool_call");
673
+ assert.equal(toolCalls.length, 2);
674
+ assert.equal(toolCalls[0]?.toolCallId, "call_a");
675
+ assert.equal(toolCalls[1]?.toolCallId, "call_b");
676
+ assert.match(toolCalls[0]?.arguments ?? "", /"description":"A"/);
677
+ assert.match(toolCalls[1]?.arguments ?? "", /"description":"B"/);
678
+ });
679
+
680
+ test("streamed response timeline remains stream_preview-only when no tool calls are present", async () => {
681
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-stream-no-tool-calls-");
682
+ const paths = makePaths(baseDir);
683
+
684
+ await ensureCaptureStore(paths);
685
+ await updateCaptureConfig(paths, { enabled: true });
686
+
687
+ const sse = [
688
+ "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think\"}}]}",
689
+ "",
690
+ "data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}",
691
+ "",
692
+ ].join("\n");
693
+
694
+ const persisted = await persistCaptureRecord(paths, {
695
+ route: "/v1/chat/completions",
696
+ method: "POST",
697
+ statusCode: 200,
698
+ latencyMs: 14,
699
+ requestBody: {
700
+ messages: [{ role: "user", content: "no tool call" }],
701
+ },
702
+ responseBody: {
703
+ $type: "stream",
704
+ contentType: "text/event-stream",
705
+ bytes: Buffer.byteLength(sse),
706
+ text: sse,
707
+ },
708
+ });
709
+
710
+ assert.ok(persisted);
711
+ const responseTimeline = persisted?.analysis.responseTimeline ?? [];
712
+ assert.equal(responseTimeline.filter((entry) => entry.kind === "stream_preview").length, 1);
713
+ assert.equal(responseTimeline.filter((entry) => entry.kind === "tool_call").length, 0);
714
+ });
715
+
716
+ test("capture token flow assigns multimodal input to input_media bucket", async () => {
717
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-token-flow-media-");
718
+ const paths = makePaths(baseDir);
719
+
720
+ await ensureCaptureStore(paths);
721
+ await updateCaptureConfig(paths, { enabled: true });
722
+
723
+ const persisted = await persistCaptureRecord(paths, {
724
+ route: "/v1/chat/completions",
725
+ method: "POST",
726
+ statusCode: 200,
727
+ latencyMs: 28,
728
+ requestBody: {
729
+ messages: [
730
+ {
731
+ role: "user",
732
+ content: [
733
+ { type: "text", text: "Describe this image." },
734
+ { type: "image_url", image_url: { url: "https://example.com/a.png", detail: "high" } },
735
+ ],
736
+ },
737
+ ],
738
+ },
739
+ responseBody: {
740
+ usage: { prompt_tokens: 500, completion_tokens: 40, total_tokens: 540 },
741
+ choices: [{ message: { role: "assistant", content: "Done." } }],
742
+ },
743
+ });
744
+
745
+ assert.ok(persisted);
746
+ assert.equal(persisted?.analysis.tokenFlow.method, "exact_totals_estimated_categories");
747
+ assert.equal(persisted?.analysis.tokenFlow.totals.inputTokens, 500);
748
+ assert.equal(
749
+ persisted?.analysis.tokenFlow.input.reduce((sum, item) => sum + item.tokens, 0),
750
+ 500
751
+ );
752
+ assert.ok((persisted?.analysis.tokenFlow.input.find((item) => item.key === "input_media")?.tokens ?? 0) > 0);
753
+ });
754
+
755
+ test("capture record hydration backfills token flow for legacy record shape", async () => {
756
+ const baseDir = await makeWorkspaceTempDir("waypoi-capture-token-flow-hydrate-");
757
+ const paths = makePaths(baseDir);
758
+
759
+ await ensureCaptureStore(paths);
760
+ await updateCaptureConfig(paths, { enabled: true });
761
+
762
+ const persisted = await persistCaptureRecord(paths, {
763
+ route: "/v1/chat/completions",
764
+ method: "POST",
765
+ statusCode: 200,
766
+ latencyMs: 15,
767
+ requestBody: {
768
+ messages: [{ role: "user", content: "legacy record test" }],
769
+ },
770
+ responseBody: {
771
+ usage: { prompt_tokens: 10, completion_tokens: 6, total_tokens: 16 },
772
+ choices: [{ message: { role: "assistant", content: "ok" } }],
773
+ },
774
+ });
775
+ assert.ok(persisted);
776
+
777
+ const indexPath = path.join(baseDir, "capture", "index.jsonl");
778
+ const rawIndex = await fs.readFile(indexPath, "utf8");
779
+ const entry = rawIndex
780
+ .split("\n")
781
+ .filter(Boolean)
782
+ .map((line) => JSON.parse(line) as { id: string; file: string })
783
+ .find((line) => line.id === persisted?.id);
784
+ assert.ok(entry);
785
+
786
+ const recordPath = path.join(baseDir, "capture", entry!.file);
787
+ const rawRecord = await fs.readFile(recordPath, "utf8");
788
+ const parsed = JSON.parse(rawRecord) as { analysis?: Record<string, unknown> };
789
+ if (parsed.analysis) {
790
+ delete parsed.analysis.tokenFlow;
791
+ }
792
+ await fs.writeFile(recordPath, JSON.stringify(parsed, null, 2), "utf8");
793
+
794
+ const hydrated = await getCaptureRecordById(paths, persisted!.id);
795
+ assert.ok(hydrated?.analysis.tokenFlow);
796
+ assert.equal(hydrated?.analysis.tokenFlow.method, "exact_totals_estimated_categories");
797
+ });