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,1835 @@
1
+ import { randomUUID, createHash } from "crypto";
2
+ import { promises as fs } from "fs";
3
+ import path from "path";
4
+ import { StoragePaths } from "./files";
5
+
6
+ const DEFAULT_CAPTURE_CONFIG: CaptureConfig = {
7
+ enabled: false,
8
+ retentionDays: 30,
9
+ maxBytes: 20 * 1024 * 1024 * 1024,
10
+ };
11
+
12
+ const DATA_URL_RE = /^data:([^;]+);base64,(.+)$/i;
13
+
14
+ export interface CaptureConfig {
15
+ enabled: boolean;
16
+ retentionDays: number;
17
+ maxBytes: number;
18
+ }
19
+
20
+ export interface CaptureRoutingInfo {
21
+ publicModel?: string;
22
+ endpointId?: string;
23
+ endpointName?: string;
24
+ upstreamModel?: string;
25
+ }
26
+
27
+ export interface CaptureRecordInput {
28
+ route: string;
29
+ method: string;
30
+ statusCode: number;
31
+ latencyMs: number;
32
+ requestHeaders?: Record<string, string | string[] | undefined>;
33
+ responseHeaders?: Record<string, string | string[] | undefined>;
34
+ requestBody?: unknown;
35
+ responseBody?: unknown;
36
+ derivedRequest?: Record<string, unknown>;
37
+ routing?: CaptureRoutingInfo;
38
+ error?: { type?: string; message?: string };
39
+ }
40
+
41
+ export interface CaptureRecord {
42
+ id: string;
43
+ timestamp: string;
44
+ route: string;
45
+ method: string;
46
+ captureEnabledSnapshot: boolean;
47
+ statusCode: number;
48
+ latencyMs: number;
49
+ request: {
50
+ headers: Record<string, string>;
51
+ body?: unknown;
52
+ derived?: Record<string, unknown>;
53
+ };
54
+ response: {
55
+ headers: Record<string, string>;
56
+ body?: unknown;
57
+ error?: { type?: string; message?: string };
58
+ };
59
+ routing: CaptureRoutingInfo;
60
+ analysis: CaptureAnalysisProjection;
61
+ artifacts: CaptureArtifact[];
62
+ }
63
+
64
+ export interface CaptureArtifact {
65
+ hash: string;
66
+ mime: string;
67
+ bytes: number;
68
+ blobRef: string;
69
+ kind: "image" | "audio" | "binary";
70
+ }
71
+
72
+ export interface CaptureAnalysisProjection {
73
+ systemMessages: CaptureTextMessage[];
74
+ userMessages: CaptureTextMessage[];
75
+ assistantMessages: CaptureAssistantMessage[];
76
+ toolMessages: CaptureToolMessage[];
77
+ requestTimeline: CaptureTimelineEntry[];
78
+ responseTimeline: CaptureTimelineEntry[];
79
+ tools: Array<{ name: string; description?: string }>;
80
+ mcpToolDescriptions: string[];
81
+ agentsMdHints: string[];
82
+ rawSections: string[];
83
+ tokenFlow: CaptureTokenFlow;
84
+ }
85
+
86
+ export type CaptureTokenFlowMethod =
87
+ | "exact_totals_estimated_categories"
88
+ | "estimated_only"
89
+ | "unavailable";
90
+
91
+ export interface CaptureTokenFlowBucket {
92
+ key: string;
93
+ label: string;
94
+ tokens: number;
95
+ }
96
+
97
+ export interface CaptureTokenFlow {
98
+ eligible: boolean;
99
+ reason?: string;
100
+ method: CaptureTokenFlowMethod;
101
+ totals: {
102
+ inputTokens: number | null;
103
+ outputTokens: number | null;
104
+ totalTokens: number | null;
105
+ };
106
+ input: CaptureTokenFlowBucket[];
107
+ output: CaptureTokenFlowBucket[];
108
+ notes?: string[];
109
+ }
110
+
111
+ export interface CaptureTextMessage {
112
+ content: string;
113
+ truncated?: boolean;
114
+ originalLength?: number;
115
+ }
116
+
117
+ export interface CaptureAssistantMessage extends CaptureTextMessage {
118
+ reasoningContent?: string;
119
+ toolCalls?: CaptureToolCall[];
120
+ asksForClarification?: boolean;
121
+ }
122
+
123
+ export interface CaptureToolMessage extends CaptureTextMessage {
124
+ toolCallId?: string;
125
+ }
126
+
127
+ export interface CaptureToolCall {
128
+ id?: string;
129
+ type?: string;
130
+ function?: {
131
+ name?: string;
132
+ arguments?: string;
133
+ };
134
+ }
135
+
136
+ export interface CaptureTimelineEntry {
137
+ direction: "request" | "response";
138
+ kind:
139
+ | "message"
140
+ | "tool_definition"
141
+ | "tool_call"
142
+ | "tool_result"
143
+ | "reasoning"
144
+ | "instructions"
145
+ | "stream_preview"
146
+ | "error";
147
+ index: number;
148
+ sourcePath: string;
149
+ role?: "system" | "user" | "assistant" | "tool" | "developer";
150
+ content?: string;
151
+ name?: string;
152
+ arguments?: string;
153
+ toolCallId?: string;
154
+ metadata?: Record<string, unknown>;
155
+ }
156
+
157
+ export interface CaptureCalendarDaySummary {
158
+ date: string;
159
+ count: number;
160
+ }
161
+
162
+ interface CaptureIndexEntry {
163
+ id: string;
164
+ timestamp: string;
165
+ route: string;
166
+ method: string;
167
+ statusCode: number;
168
+ latencyMs: number;
169
+ model?: string;
170
+ file: string;
171
+ }
172
+
173
+ interface ListCaptureRecordsOptions {
174
+ limit?: number;
175
+ offset?: number;
176
+ date?: string;
177
+ timeZone?: string;
178
+ }
179
+
180
+ function captureDir(paths: StoragePaths): string {
181
+ return path.join(paths.baseDir, "capture");
182
+ }
183
+
184
+ function captureConfigPath(paths: StoragePaths): string {
185
+ return path.join(captureDir(paths), "config.json");
186
+ }
187
+
188
+ function captureIndexPath(paths: StoragePaths): string {
189
+ return path.join(captureDir(paths), "index.jsonl");
190
+ }
191
+
192
+ function captureRecordsDir(paths: StoragePaths): string {
193
+ return path.join(captureDir(paths), "records");
194
+ }
195
+
196
+ function captureBlobsDir(paths: StoragePaths): string {
197
+ return path.join(captureDir(paths), "blobs");
198
+ }
199
+
200
+ export async function ensureCaptureStore(paths: StoragePaths): Promise<void> {
201
+ await fs.mkdir(captureDir(paths), { recursive: true });
202
+ await fs.mkdir(captureRecordsDir(paths), { recursive: true });
203
+ await fs.mkdir(captureBlobsDir(paths), { recursive: true });
204
+ const configPath = captureConfigPath(paths);
205
+ try {
206
+ await fs.access(configPath);
207
+ } catch {
208
+ await fs.writeFile(configPath, JSON.stringify(DEFAULT_CAPTURE_CONFIG, null, 2), "utf8");
209
+ }
210
+ }
211
+
212
+ export async function getCaptureConfig(paths: StoragePaths): Promise<CaptureConfig> {
213
+ await ensureCaptureStore(paths);
214
+ try {
215
+ const raw = await fs.readFile(captureConfigPath(paths), "utf8");
216
+ const parsed = JSON.parse(raw) as Partial<CaptureConfig>;
217
+ return normalizeCaptureConfig(parsed);
218
+ } catch {
219
+ return { ...DEFAULT_CAPTURE_CONFIG };
220
+ }
221
+ }
222
+
223
+ export async function updateCaptureConfig(
224
+ paths: StoragePaths,
225
+ patch: Partial<CaptureConfig>
226
+ ): Promise<CaptureConfig> {
227
+ const current = await getCaptureConfig(paths);
228
+ const next = normalizeCaptureConfig({ ...current, ...patch });
229
+ await fs.writeFile(captureConfigPath(paths), JSON.stringify(next, null, 2), "utf8");
230
+ return next;
231
+ }
232
+
233
+ export async function isCaptureEnabled(paths: StoragePaths): Promise<boolean> {
234
+ const config = await getCaptureConfig(paths);
235
+ return config.enabled;
236
+ }
237
+
238
+ export async function persistCaptureRecord(
239
+ paths: StoragePaths,
240
+ input: CaptureRecordInput
241
+ ): Promise<CaptureRecord | null> {
242
+ const config = await getCaptureConfig(paths);
243
+ if (!config.enabled) {
244
+ return null;
245
+ }
246
+
247
+ const id = randomUUID();
248
+ const now = new Date();
249
+ const timestamp = now.toISOString();
250
+ const artifacts: CaptureArtifact[] = [];
251
+ const requestBodyPreview = await buildPreviewBody(paths, input.requestBody, artifacts);
252
+ const responseBodyPreview = await buildPreviewBody(paths, input.responseBody, artifacts);
253
+
254
+ const record: CaptureRecord = {
255
+ id,
256
+ timestamp,
257
+ route: input.route,
258
+ method: input.method,
259
+ captureEnabledSnapshot: true,
260
+ statusCode: input.statusCode,
261
+ latencyMs: input.latencyMs,
262
+ request: {
263
+ headers: normalizeHeaderRecord(input.requestHeaders),
264
+ body: input.requestBody,
265
+ derived: input.derivedRequest,
266
+ },
267
+ response: {
268
+ headers: normalizeHeaderRecord(input.responseHeaders),
269
+ body: input.responseBody,
270
+ error: input.error,
271
+ },
272
+ routing: input.routing ?? {},
273
+ analysis: buildAnalysisProjection(input.route, input.requestBody, input.responseBody, input.derivedRequest),
274
+ artifacts,
275
+ };
276
+
277
+ // Attach preview representations in derived block for UI readability.
278
+ if (requestBodyPreview !== undefined || responseBodyPreview !== undefined) {
279
+ record.request.derived = {
280
+ ...(record.request.derived ?? {}),
281
+ preview: {
282
+ request: requestBodyPreview,
283
+ response: responseBodyPreview,
284
+ },
285
+ };
286
+ }
287
+
288
+ const datePath = path.join(
289
+ captureRecordsDir(paths),
290
+ `${now.getUTCFullYear()}`,
291
+ `${String(now.getUTCMonth() + 1).padStart(2, "0")}`,
292
+ `${String(now.getUTCDate()).padStart(2, "0")}`
293
+ );
294
+ await fs.mkdir(datePath, { recursive: true });
295
+ const fileName = `${timestamp.replace(/[:.]/g, "-")}_${id}.json`;
296
+ const absoluteRecordPath = path.join(datePath, fileName);
297
+ await fs.writeFile(absoluteRecordPath, JSON.stringify(record, null, 2), "utf8");
298
+
299
+ const relRecordPath = path.relative(captureDir(paths), absoluteRecordPath);
300
+ const entry: CaptureIndexEntry = {
301
+ id,
302
+ timestamp,
303
+ route: input.route,
304
+ method: input.method,
305
+ statusCode: input.statusCode,
306
+ latencyMs: input.latencyMs,
307
+ model: input.routing?.publicModel,
308
+ file: relRecordPath,
309
+ };
310
+ await fs.appendFile(captureIndexPath(paths), `${JSON.stringify(entry)}\n`, "utf8");
311
+ await applyCaptureRetention(paths, config);
312
+ return record;
313
+ }
314
+
315
+ export async function runCaptureRetention(paths: StoragePaths): Promise<void> {
316
+ const config = await getCaptureConfig(paths);
317
+ await applyCaptureRetention(paths, config);
318
+ }
319
+
320
+ export async function listCaptureRecords(
321
+ paths: StoragePaths,
322
+ options: number | ListCaptureRecordsOptions = 5
323
+ ): Promise<{ data: CaptureIndexEntry[]; total: number }> {
324
+ await ensureCaptureStore(paths);
325
+ const entries = await readCaptureIndex(paths, { pruneMissing: true });
326
+ const opts =
327
+ typeof options === "number"
328
+ ? { limit: options, offset: 0 }
329
+ : { limit: 5, offset: 0, ...options };
330
+ const timeZone = normalizeTimeZone(opts.timeZone);
331
+ const limit = Math.max(1, Math.min(200, Math.floor(opts.limit ?? 5)));
332
+ const offset = Math.max(0, Math.floor(opts.offset ?? 0));
333
+ const filtered = opts.date
334
+ ? entries.filter((entry) => dateStringForTimeZone(entry.timestamp, timeZone) === opts.date)
335
+ : entries;
336
+ const newestFirst = [...filtered].reverse();
337
+ return {
338
+ data: newestFirst.slice(offset, offset + limit),
339
+ total: filtered.length,
340
+ };
341
+ }
342
+
343
+ export async function getCaptureRecordById(
344
+ paths: StoragePaths,
345
+ id: string
346
+ ): Promise<CaptureRecord | null> {
347
+ const entries = await readCaptureIndex(paths, { pruneMissing: true });
348
+ const match = entries.find((entry) => entry.id === id);
349
+ if (!match) {
350
+ return null;
351
+ }
352
+ const absolute = path.join(captureDir(paths), match.file);
353
+ try {
354
+ const raw = await fs.readFile(absolute, "utf8");
355
+ return hydrateCaptureRecord(JSON.parse(raw) as CaptureRecord);
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+
361
+ export async function getCaptureCalendarMonth(
362
+ paths: StoragePaths,
363
+ month: string,
364
+ timeZone = "UTC"
365
+ ): Promise<CaptureCalendarDaySummary[]> {
366
+ await ensureCaptureStore(paths);
367
+ const entries = await readCaptureIndex(paths, { pruneMissing: true });
368
+ const normalizedTimeZone = normalizeTimeZone(timeZone);
369
+ const counts = new Map<string, number>();
370
+ for (const entry of entries) {
371
+ const date = dateStringForTimeZone(entry.timestamp, normalizedTimeZone);
372
+ if (!date.startsWith(`${month}-`)) continue;
373
+ counts.set(date, (counts.get(date) ?? 0) + 1);
374
+ }
375
+ return Array.from(counts.entries())
376
+ .sort((a, b) => a[0].localeCompare(b[0]))
377
+ .map(([date, count]) => ({ date, count }));
378
+ }
379
+
380
+ export async function findCaptureBlobPath(
381
+ paths: StoragePaths,
382
+ hash: string
383
+ ): Promise<{ path: string; mime: string } | null> {
384
+ await ensureCaptureStore(paths);
385
+ const blobDir = captureBlobsDir(paths);
386
+ let files: string[];
387
+ try {
388
+ files = await fs.readdir(blobDir);
389
+ } catch {
390
+ return null;
391
+ }
392
+ const candidate = files.find((name) => name.startsWith(`${hash}.`));
393
+ if (!candidate) return null;
394
+ const ext = candidate.split(".").pop()?.toLowerCase() ?? "bin";
395
+ return {
396
+ path: path.join(blobDir, candidate),
397
+ mime: extToMime(ext),
398
+ };
399
+ }
400
+
401
+ function normalizeCaptureConfig(input: Partial<CaptureConfig>): CaptureConfig {
402
+ const retentionDays = Number.isFinite(input.retentionDays) ? Number(input.retentionDays) : DEFAULT_CAPTURE_CONFIG.retentionDays;
403
+ const maxBytes = Number.isFinite(input.maxBytes) ? Number(input.maxBytes) : DEFAULT_CAPTURE_CONFIG.maxBytes;
404
+ return {
405
+ enabled: input.enabled === true,
406
+ retentionDays: Math.max(1, Math.min(365, Math.floor(retentionDays))),
407
+ maxBytes: Math.max(50 * 1024 * 1024, Math.floor(maxBytes)),
408
+ };
409
+ }
410
+
411
+ function normalizeHeaderRecord(
412
+ headers: Record<string, string | string[] | undefined> | undefined
413
+ ): Record<string, string> {
414
+ const out: Record<string, string> = {};
415
+ if (!headers) return out;
416
+ for (const [key, value] of Object.entries(headers)) {
417
+ if (!value) continue;
418
+ out[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : String(value);
419
+ }
420
+ return out;
421
+ }
422
+
423
+ function buildAnalysisProjection(
424
+ route: string,
425
+ requestBody: unknown,
426
+ responseBody: unknown,
427
+ derived?: Record<string, unknown>
428
+ ): CaptureAnalysisProjection {
429
+ const source = (derived?.normalizedRequest as Record<string, unknown> | undefined) ?? asRecord(requestBody);
430
+ const messages = Array.isArray(source?.messages) ? source.messages : [];
431
+ const toolsRaw = Array.isArray(source?.tools) ? source.tools : [];
432
+ const systemMessages: CaptureTextMessage[] = [];
433
+ const userMessages: CaptureTextMessage[] = [];
434
+ const assistantMessages: CaptureAssistantMessage[] = [];
435
+ const toolMessages: CaptureToolMessage[] = [];
436
+ const tools: Array<{ name: string; description?: string }> = [];
437
+ const mcpToolDescriptions: string[] = [];
438
+ const hints = new Set<string>();
439
+ const rawSections: string[] = [];
440
+ const requestTimeline = buildRequestTimeline(source, rawSections);
441
+ const responseTimeline = buildResponseTimeline(responseBody);
442
+
443
+ for (const message of messages) {
444
+ const m = asRecord(message);
445
+ if (!m) continue;
446
+ const role = typeof m.role === "string" ? m.role : "unknown";
447
+ const content = extractTextContent(m.content);
448
+ const reasoningContent =
449
+ typeof m.reasoning_content === "string" ? m.reasoning_content : undefined;
450
+ const combinedText = [content, reasoningContent].filter(Boolean).join("\n\n").trim();
451
+ if (combinedText && /(agents\.md|guardrail|mcp|tool|policy)/i.test(combinedText)) {
452
+ hints.add(combinedText);
453
+ }
454
+
455
+ if (role === "system" && content) {
456
+ systemMessages.push({ content });
457
+ continue;
458
+ }
459
+ if (role === "user" && content) {
460
+ userMessages.push({ content });
461
+ continue;
462
+ }
463
+ if (role === "assistant") {
464
+ const assistantMessage: CaptureAssistantMessage = {
465
+ content,
466
+ };
467
+ if (reasoningContent) assistantMessage.reasoningContent = reasoningContent;
468
+ const toolCalls = extractToolCalls(m.tool_calls);
469
+ if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls;
470
+ if (looksLikeClarification(content)) assistantMessage.asksForClarification = true;
471
+ if (
472
+ assistantMessage.content ||
473
+ assistantMessage.reasoningContent ||
474
+ (assistantMessage.toolCalls && assistantMessage.toolCalls.length > 0)
475
+ ) {
476
+ assistantMessages.push(assistantMessage);
477
+ }
478
+ continue;
479
+ }
480
+ if (role === "tool") {
481
+ const toolMessage: CaptureToolMessage = {
482
+ content,
483
+ };
484
+ if (typeof m.tool_call_id === "string") toolMessage.toolCallId = m.tool_call_id;
485
+ if (toolMessage.content || toolMessage.toolCallId) {
486
+ toolMessages.push(toolMessage);
487
+ }
488
+ }
489
+ }
490
+
491
+ for (const tool of toolsRaw) {
492
+ const t = asRecord(tool);
493
+ if (!t) continue;
494
+ const fn = asRecord(t.function);
495
+ const name = typeof fn?.name === "string" ? fn.name : undefined;
496
+ const description = typeof fn?.description === "string" ? fn.description : undefined;
497
+ if (!name) continue;
498
+ tools.push({ name, description });
499
+ if (description) {
500
+ mcpToolDescriptions.push(`${name}: ${description}`);
501
+ if (/(agents\.md|guardrail|mcp|policy)/i.test(description)) {
502
+ hints.add(description);
503
+ }
504
+ }
505
+ }
506
+
507
+ return {
508
+ systemMessages,
509
+ userMessages,
510
+ assistantMessages,
511
+ toolMessages,
512
+ requestTimeline,
513
+ responseTimeline,
514
+ tools,
515
+ mcpToolDescriptions,
516
+ agentsMdHints: Array.from(hints),
517
+ rawSections,
518
+ tokenFlow: buildTokenFlowProjection(route, source, responseBody),
519
+ };
520
+ }
521
+
522
+ function buildRequestTimeline(
523
+ source: Record<string, unknown> | null | undefined,
524
+ rawSections: string[]
525
+ ): CaptureTimelineEntry[] {
526
+ const timeline: CaptureTimelineEntry[] = [];
527
+ if (!source) return timeline;
528
+ const push = createTimelinePusher("request", timeline);
529
+
530
+ if (typeof source.instructions === "string") {
531
+ rawSections.push("request.body.instructions");
532
+ push({
533
+ kind: "instructions",
534
+ role: "system",
535
+ sourcePath: "request.body.instructions",
536
+ content: source.instructions,
537
+ });
538
+ }
539
+
540
+ if (Array.isArray(source.messages)) {
541
+ rawSections.push("request.body.messages");
542
+ source.messages.forEach((message, idx) => pushMessageEntries(push, message, `request.body.messages[${idx}]`));
543
+ }
544
+
545
+ if (Array.isArray(source.input)) {
546
+ rawSections.push("request.body.input");
547
+ source.input.forEach((item, idx) => pushInputOutputEntry(push, item, `request.body.input[${idx}]`, "request"));
548
+ }
549
+
550
+ if (Array.isArray(source.tools)) {
551
+ rawSections.push("request.body.tools");
552
+ source.tools.forEach((tool, idx) => {
553
+ const t = asRecord(tool);
554
+ const fn = asRecord(t?.function);
555
+ const name = typeof fn?.name === "string" ? fn.name : undefined;
556
+ const description = typeof fn?.description === "string" ? fn.description : undefined;
557
+ push({
558
+ kind: "tool_definition",
559
+ sourcePath: `request.body.tools[${idx}]`,
560
+ name,
561
+ content: description,
562
+ metadata: t ?? undefined,
563
+ });
564
+ });
565
+ }
566
+
567
+ return timeline;
568
+ }
569
+
570
+ function buildResponseTimeline(responseBody: unknown): CaptureTimelineEntry[] {
571
+ const timeline: CaptureTimelineEntry[] = [];
572
+ const push = createTimelinePusher("response", timeline);
573
+ const body = asRecord(responseBody);
574
+ if (!body) {
575
+ if (typeof responseBody === "string" && responseBody) {
576
+ push({ kind: "message", sourcePath: "response.body", content: responseBody });
577
+ }
578
+ return timeline;
579
+ }
580
+
581
+ if (asRecord(body.error)) {
582
+ const error = asRecord(body.error)!;
583
+ push({
584
+ kind: "error",
585
+ sourcePath: "response.body.error",
586
+ content: typeof error.message === "string" ? error.message : JSON.stringify(error, null, 2),
587
+ metadata: error,
588
+ });
589
+ }
590
+
591
+ if (body.$type === "stream") {
592
+ const text = typeof body.text === "string" ? body.text : undefined;
593
+ if (text) {
594
+ const merged = buildMergedSsePreview(text);
595
+ if (merged) {
596
+ push({
597
+ kind: "stream_preview",
598
+ sourcePath: "response.body.stream",
599
+ content: merged.content,
600
+ metadata: merged.metadata,
601
+ });
602
+ }
603
+ const streamedToolCalls = extractStreamToolCalls(text);
604
+ streamedToolCalls.forEach((toolCall, idx) =>
605
+ push({
606
+ kind: "tool_call",
607
+ role: "assistant",
608
+ sourcePath: `response.body.stream.tool_calls[${idx}]`,
609
+ name: toolCall.function?.name,
610
+ arguments: toolCall.function?.arguments,
611
+ toolCallId: toolCall.id,
612
+ metadata: toolCall.type ? { type: toolCall.type } : undefined,
613
+ })
614
+ );
615
+ } else {
616
+ push({
617
+ kind: "stream_preview",
618
+ sourcePath: "response.body",
619
+ content: typeof body.note === "string" ? body.note : "Stream response metadata only",
620
+ metadata: body,
621
+ });
622
+ }
623
+ return timeline;
624
+ }
625
+
626
+ if (Array.isArray(body.output)) {
627
+ body.output.forEach((item, idx) => pushInputOutputEntry(push, item, `response.body.output[${idx}]`, "response"));
628
+ }
629
+
630
+ if (Array.isArray(body.choices)) {
631
+ body.choices.forEach((choice, idx) => {
632
+ const choiceRecord = asRecord(choice);
633
+ const message = asRecord(choiceRecord?.message);
634
+ if (message) {
635
+ pushMessageEntries(push, message, `response.body.choices[${idx}].message`);
636
+ }
637
+ });
638
+ }
639
+
640
+ if (timeline.length === 0) {
641
+ push({
642
+ kind: "message",
643
+ sourcePath: "response.body",
644
+ content: JSON.stringify(responseBody, null, 2),
645
+ });
646
+ }
647
+ return timeline;
648
+ }
649
+
650
+ function extractTextContent(content: unknown): string {
651
+ if (typeof content === "string") {
652
+ return content;
653
+ }
654
+ if (!Array.isArray(content)) {
655
+ return "";
656
+ }
657
+ const chunks: string[] = [];
658
+ for (const part of content) {
659
+ const p = asRecord(part);
660
+ if (!p) continue;
661
+ if (p.type === "text" && typeof p.text === "string") {
662
+ chunks.push(p.text);
663
+ }
664
+ }
665
+ return chunks.join(" ");
666
+ }
667
+
668
+ function extractToolCalls(value: unknown): CaptureToolCall[] {
669
+ if (!Array.isArray(value)) return [];
670
+ const calls: CaptureToolCall[] = [];
671
+ for (const item of value) {
672
+ const record = asRecord(item);
673
+ if (!record) continue;
674
+ const call: CaptureToolCall = {};
675
+ if (typeof record.id === "string") call.id = record.id;
676
+ if (typeof record.type === "string") call.type = record.type;
677
+ const fn = asRecord(record.function);
678
+ if (fn) {
679
+ call.function = {};
680
+ if (typeof fn.name === "string") call.function.name = fn.name;
681
+ if (typeof fn.arguments === "string") call.function.arguments = fn.arguments;
682
+ if (!call.function.name && !call.function.arguments) delete call.function;
683
+ }
684
+ calls.push(call);
685
+ }
686
+ return calls;
687
+ }
688
+
689
+ function pushMessageEntries(
690
+ push: (entry: Omit<CaptureTimelineEntry, "direction" | "index">) => void,
691
+ message: unknown,
692
+ sourcePath: string
693
+ ): void {
694
+ const m = asRecord(message);
695
+ if (!m) return;
696
+ const role = normalizeRole(m.role);
697
+ const content = extractTextContent(m.content);
698
+ if (content) {
699
+ push({
700
+ kind: role === "tool" ? "tool_result" : "message",
701
+ role,
702
+ sourcePath,
703
+ content,
704
+ toolCallId: typeof m.tool_call_id === "string" ? m.tool_call_id : undefined,
705
+ });
706
+ }
707
+ if (typeof m.reasoning_content === "string") {
708
+ push({
709
+ kind: "reasoning",
710
+ role: role ?? "assistant",
711
+ sourcePath: `${sourcePath}.reasoning_content`,
712
+ content: m.reasoning_content,
713
+ });
714
+ }
715
+ const toolCalls = extractToolCalls(m.tool_calls);
716
+ toolCalls.forEach((toolCall, idx) =>
717
+ push({
718
+ kind: "tool_call",
719
+ role: "assistant",
720
+ sourcePath: `${sourcePath}.tool_calls[${idx}]`,
721
+ name: toolCall.function?.name,
722
+ arguments: toolCall.function?.arguments,
723
+ toolCallId: toolCall.id,
724
+ metadata: toolCall.type ? { type: toolCall.type } : undefined,
725
+ })
726
+ );
727
+ }
728
+
729
+ function pushInputOutputEntry(
730
+ push: (entry: Omit<CaptureTimelineEntry, "direction" | "index">) => void,
731
+ item: unknown,
732
+ sourcePath: string,
733
+ direction: "request" | "response"
734
+ ): void {
735
+ const record = asRecord(item);
736
+ if (!record) {
737
+ if (typeof item === "string") {
738
+ push({ kind: "message", sourcePath, content: item });
739
+ }
740
+ return;
741
+ }
742
+
743
+ const type = typeof record.type === "string" ? record.type : undefined;
744
+ if (type === "message") {
745
+ const role = normalizeRole(record.role);
746
+ const content = extractTextContentFromResponseItem(record);
747
+ if (content) {
748
+ push({ kind: "message", role, sourcePath, content });
749
+ }
750
+ return;
751
+ }
752
+ if (type === "function_call") {
753
+ push({
754
+ kind: "tool_call",
755
+ role: direction === "response" ? "assistant" : undefined,
756
+ sourcePath,
757
+ name: typeof record.name === "string" ? record.name : undefined,
758
+ arguments: typeof record.arguments === "string" ? record.arguments : undefined,
759
+ toolCallId: typeof record.call_id === "string" ? record.call_id : undefined,
760
+ });
761
+ return;
762
+ }
763
+ if (type === "function_call_output") {
764
+ push({
765
+ kind: "tool_result",
766
+ role: "tool",
767
+ sourcePath,
768
+ content: typeof record.output === "string" ? record.output : stringifyMaybe(record.output),
769
+ toolCallId: typeof record.call_id === "string" ? record.call_id : undefined,
770
+ });
771
+ return;
772
+ }
773
+ if (type === "reasoning") {
774
+ push({
775
+ kind: "reasoning",
776
+ role: "assistant",
777
+ sourcePath,
778
+ content: extractReasoningItemText(record),
779
+ });
780
+ return;
781
+ }
782
+
783
+ push({
784
+ kind: "message",
785
+ sourcePath,
786
+ content: stringifyMaybe(record),
787
+ metadata: type ? { type } : undefined,
788
+ });
789
+ }
790
+
791
+ function looksLikeClarification(content: string): boolean {
792
+ const text = content.trim();
793
+ if (!text) return false;
794
+ return /(?:^|\b)(could you|can you|would you|which|what|do you want|please clarify|clarify)\b/i.test(text)
795
+ || text.includes("?");
796
+ }
797
+
798
+ function createTimelinePusher(
799
+ direction: "request" | "response",
800
+ timeline: CaptureTimelineEntry[]
801
+ ): (entry: Omit<CaptureTimelineEntry, "direction" | "index">) => void {
802
+ return (entry) => {
803
+ timeline.push({
804
+ direction,
805
+ index: timeline.length,
806
+ ...entry,
807
+ });
808
+ };
809
+ }
810
+
811
+ function extractTextContentFromResponseItem(item: Record<string, unknown>): string {
812
+ const content = item.content;
813
+ if (typeof content === "string") return content;
814
+ if (!Array.isArray(content)) return "";
815
+ const chunks: string[] = [];
816
+ for (const part of content) {
817
+ const record = asRecord(part);
818
+ if (!record) continue;
819
+ if (typeof record.text === "string") chunks.push(record.text);
820
+ else if (typeof record.content === "string") chunks.push(record.content);
821
+ }
822
+ return chunks.join("\n\n");
823
+ }
824
+
825
+ function extractReasoningItemText(item: Record<string, unknown>): string {
826
+ if (typeof item.summary === "string") return item.summary;
827
+ if (Array.isArray(item.summary)) {
828
+ return item.summary
829
+ .map((part) => {
830
+ const record = asRecord(part);
831
+ if (!record) return "";
832
+ if (typeof record.text === "string") return record.text;
833
+ return "";
834
+ })
835
+ .filter(Boolean)
836
+ .join("\n");
837
+ }
838
+ if (typeof item.content === "string") return item.content;
839
+ return stringifyMaybe(item);
840
+ }
841
+
842
+ function parseSsePreview(text: string): Array<{ content: string; metadata?: Record<string, unknown> }> {
843
+ const entries: Array<{ content: string; metadata?: Record<string, unknown> }> = [];
844
+ const blocks = text.split(/\n\n+/).map((part) => part.trim()).filter(Boolean);
845
+ for (const block of blocks) {
846
+ const dataLines = block
847
+ .split("\n")
848
+ .filter((line) => line.startsWith("data:"))
849
+ .map((line) => line.slice(5).trim());
850
+ if (dataLines.length === 0) continue;
851
+ const joined = dataLines.join("\n");
852
+ try {
853
+ const parsed = JSON.parse(joined) as Record<string, unknown>;
854
+ entries.push({
855
+ content: stringifyMaybe(parsed),
856
+ metadata: parsed,
857
+ });
858
+ } catch {
859
+ entries.push({ content: joined });
860
+ }
861
+ }
862
+ return entries;
863
+ }
864
+
865
+ function buildMergedSsePreview(text: string): { content: string; metadata?: Record<string, unknown> } | null {
866
+ const entries = parseSsePreview(text);
867
+ if (entries.length === 0) return null;
868
+
869
+ const mergedText = mergeUsefulSseText(entries);
870
+ if (mergedText.trim()) {
871
+ return {
872
+ content: mergedText,
873
+ metadata: { events: entries.length, mode: "merged_text" },
874
+ };
875
+ }
876
+
877
+ return {
878
+ content: entries.map((entry) => entry.content).join("\n\n"),
879
+ metadata: { events: entries.length, mode: "merged_events" },
880
+ };
881
+ }
882
+
883
+ function extractMergedUsefulSseText(text: string): string {
884
+ return mergeUsefulSseText(parseSsePreview(text));
885
+ }
886
+
887
+ function mergeUsefulSseText(entries: Array<{ content: string; metadata?: Record<string, unknown> }>): string {
888
+ const textChunks: string[] = [];
889
+ for (const entry of entries) {
890
+ const extracted = extractStreamText(entry.metadata);
891
+ if (extracted) {
892
+ textChunks.push(extracted);
893
+ }
894
+ }
895
+ return textChunks.join("");
896
+ }
897
+
898
+ function mergeSseEventPayloadText(text: string): string {
899
+ const entries = parseSsePreview(text);
900
+ if (entries.length === 0) return "";
901
+ return entries.map((entry) => entry.content).join("\n\n");
902
+ }
903
+
904
+ function extractStreamText(value: unknown): string {
905
+ const record = asRecord(value);
906
+ if (!record) return "";
907
+
908
+ if (typeof record.delta === "string") {
909
+ return record.delta;
910
+ }
911
+ if (typeof record.text === "string") {
912
+ return record.text;
913
+ }
914
+
915
+ const choices = Array.isArray(record.choices) ? record.choices : [];
916
+ const choiceChunks: string[] = [];
917
+ for (const choice of choices) {
918
+ const choiceRecord = asRecord(choice);
919
+ if (!choiceRecord) continue;
920
+ const delta = asRecord(choiceRecord.delta);
921
+ if (typeof delta?.content === "string") choiceChunks.push(delta.content);
922
+ if (typeof delta?.reasoning_content === "string") choiceChunks.push(delta.reasoning_content);
923
+ const message = asRecord(choiceRecord.message);
924
+ if (typeof message?.content === "string") choiceChunks.push(message.content);
925
+ }
926
+ if (choiceChunks.length > 0) {
927
+ return choiceChunks.join("");
928
+ }
929
+
930
+ if (typeof record.type === "string") {
931
+ if (
932
+ record.type === "response.output_text.delta" ||
933
+ record.type === "response.reasoning.delta" ||
934
+ record.type === "response.output_text"
935
+ ) {
936
+ if (typeof record.delta === "string") return record.delta;
937
+ if (typeof record.text === "string") return record.text;
938
+ }
939
+ }
940
+
941
+ const output = Array.isArray(record.output) ? record.output : [];
942
+ const outputChunks: string[] = [];
943
+ for (const item of output) {
944
+ const itemRecord = asRecord(item);
945
+ if (!itemRecord) continue;
946
+ const content = itemRecord.content;
947
+ if (typeof content === "string") {
948
+ outputChunks.push(content);
949
+ continue;
950
+ }
951
+ if (Array.isArray(content)) {
952
+ for (const part of content) {
953
+ const partRecord = asRecord(part);
954
+ if (!partRecord) continue;
955
+ if (typeof partRecord.text === "string") outputChunks.push(partRecord.text);
956
+ }
957
+ }
958
+ }
959
+ return outputChunks.join("");
960
+ }
961
+
962
+ function extractStreamToolCalls(text: string): CaptureToolCall[] {
963
+ const entries = parseSsePreview(text);
964
+ type Builder = {
965
+ id?: string;
966
+ type?: string;
967
+ functionName: string;
968
+ functionArguments: string;
969
+ };
970
+ const byIndex = new Map<number, Builder>();
971
+ const order: number[] = [];
972
+
973
+ const ensureBuilder = (index: number): Builder => {
974
+ const existing = byIndex.get(index);
975
+ if (existing) return existing;
976
+ const created: Builder = { functionName: "", functionArguments: "" };
977
+ byIndex.set(index, created);
978
+ order.push(index);
979
+ return created;
980
+ };
981
+
982
+ for (const entry of entries) {
983
+ const payload = asRecord(entry.metadata);
984
+ if (!payload) continue;
985
+
986
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
987
+ for (const choice of choices) {
988
+ const choiceRecord = asRecord(choice);
989
+ if (!choiceRecord) continue;
990
+ const delta = asRecord(choiceRecord.delta);
991
+ if (!delta) continue;
992
+
993
+ const deltaToolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
994
+ for (const item of deltaToolCalls) {
995
+ const callRecord = asRecord(item);
996
+ if (!callRecord) continue;
997
+ const index = typeof callRecord.index === "number" ? Math.max(0, Math.floor(callRecord.index)) : 0;
998
+ const builder = ensureBuilder(index);
999
+ if (typeof callRecord.id === "string") builder.id = callRecord.id;
1000
+ if (typeof callRecord.type === "string") builder.type = callRecord.type;
1001
+ const fn = asRecord(callRecord.function);
1002
+ if (fn) {
1003
+ if (typeof fn.name === "string") builder.functionName += fn.name;
1004
+ if (typeof fn.arguments === "string") builder.functionArguments += fn.arguments;
1005
+ }
1006
+ }
1007
+
1008
+ const legacyFunctionCall = asRecord(delta.function_call);
1009
+ if (legacyFunctionCall) {
1010
+ const builder = ensureBuilder(0);
1011
+ builder.type = builder.type ?? "function";
1012
+ if (typeof legacyFunctionCall.name === "string") builder.functionName += legacyFunctionCall.name;
1013
+ if (typeof legacyFunctionCall.arguments === "string") builder.functionArguments += legacyFunctionCall.arguments;
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ const calls: CaptureToolCall[] = [];
1019
+ for (const index of order) {
1020
+ const built = byIndex.get(index);
1021
+ if (!built) continue;
1022
+ const call: CaptureToolCall = {};
1023
+ if (built.id) call.id = built.id;
1024
+ if (built.type) call.type = built.type;
1025
+ const hasName = built.functionName.trim().length > 0;
1026
+ const hasArguments = built.functionArguments.length > 0;
1027
+ if (hasName || hasArguments) {
1028
+ call.function = {};
1029
+ if (hasName) call.function.name = built.functionName;
1030
+ if (hasArguments) call.function.arguments = built.functionArguments;
1031
+ }
1032
+ if (call.id || call.type || call.function) {
1033
+ calls.push(call);
1034
+ }
1035
+ }
1036
+ return calls;
1037
+ }
1038
+
1039
+ const INPUT_TOKEN_CATEGORIES: Array<{ key: string; label: string }> = [
1040
+ { key: "instructions", label: "Instructions" },
1041
+ { key: "system", label: "System" },
1042
+ { key: "developer", label: "Developer" },
1043
+ { key: "user", label: "User" },
1044
+ { key: "assistant_history", label: "Assistant History" },
1045
+ { key: "input_media", label: "Input Media" },
1046
+ { key: "tool_results", label: "Tool Results" },
1047
+ { key: "tool_definitions", label: "Tool Definitions" },
1048
+ { key: "unattributed_input", label: "Unattributed Input" },
1049
+ ];
1050
+
1051
+ const OUTPUT_TOKEN_CATEGORIES: Array<{ key: string; label: string }> = [
1052
+ { key: "assistant_text", label: "Assistant Text" },
1053
+ { key: "reasoning", label: "Reasoning" },
1054
+ { key: "tool_calls", label: "Tool Calls" },
1055
+ { key: "tool_results", label: "Tool Results" },
1056
+ { key: "errors", label: "Errors" },
1057
+ { key: "unattributed_output", label: "Unattributed Output" },
1058
+ ];
1059
+
1060
+ function buildTokenFlowProjection(
1061
+ route: string,
1062
+ source: Record<string, unknown> | null | undefined,
1063
+ responseBody: unknown
1064
+ ): CaptureTokenFlow {
1065
+ if (!route.startsWith("/v1/chat/completions")) {
1066
+ return {
1067
+ eligible: false,
1068
+ reason: "Token flow is available only for /v1/chat/completions captures.",
1069
+ method: "unavailable",
1070
+ totals: { inputTokens: null, outputTokens: null, totalTokens: null },
1071
+ input: INPUT_TOKEN_CATEGORIES.map((category) => ({ ...category, tokens: 0 })),
1072
+ output: OUTPUT_TOKEN_CATEGORIES.map((category) => ({ ...category, tokens: 0 })),
1073
+ notes: ["Route is not eligible for token flow analysis."],
1074
+ };
1075
+ }
1076
+
1077
+ const usage = extractTokenUsageTotals(responseBody);
1078
+ const estimatedInput = estimateInputCategoryTokens(source);
1079
+ const estimatedOutput = estimateOutputCategoryTokens(responseBody);
1080
+ const streamSource = estimatedOutput.streamSource;
1081
+ const estimatedOutputTokens = estimatedOutput.tokens;
1082
+
1083
+ const hasExactSides = usage.inputTokens !== null && usage.outputTokens !== null;
1084
+ if (hasExactSides) {
1085
+ const inputExact = usage.inputTokens as number;
1086
+ const outputExact = usage.outputTokens as number;
1087
+ const inputBuckets = scaleCategoryTokens(INPUT_TOKEN_CATEGORIES, estimatedInput, inputExact);
1088
+ const outputBuckets = scaleCategoryTokens(OUTPUT_TOKEN_CATEGORIES, estimatedOutputTokens, outputExact);
1089
+ const totalTokens = usage.totalTokens ?? inputExact + outputExact;
1090
+ const notes = [
1091
+ "Input/output totals are exact from response usage.",
1092
+ "Category slices are estimated from captured content structure.",
1093
+ ];
1094
+ if (streamSource === "timeline_text_with_tool_calls") {
1095
+ notes.push(
1096
+ "For streamed responses, output categories are estimated from extracted timeline-equivalent SSE text and reconstructed streamed tool-call deltas."
1097
+ );
1098
+ } else if (streamSource === "timeline_text") {
1099
+ notes.push("For streamed responses, output categories are estimated from extracted timeline-equivalent SSE text.");
1100
+ } else if (streamSource === "timeline_tool_calls_only") {
1101
+ notes.push(
1102
+ "For streamed responses, output categories are estimated from reconstructed streamed tool-call deltas."
1103
+ );
1104
+ } else if (streamSource === "fallback_events") {
1105
+ notes.push("For streamed responses, no useful SSE text was extracted; output fallback uses merged SSE event payload text (lower confidence).");
1106
+ }
1107
+ return {
1108
+ eligible: true,
1109
+ method: "exact_totals_estimated_categories",
1110
+ totals: {
1111
+ inputTokens: inputExact,
1112
+ outputTokens: outputExact,
1113
+ totalTokens,
1114
+ },
1115
+ input: inputBuckets,
1116
+ output: outputBuckets,
1117
+ notes,
1118
+ };
1119
+ }
1120
+
1121
+ const rawInputTotal = sumTokenMapValues(estimatedInput);
1122
+ const rawOutputTotal = sumTokenMapValues(estimatedOutputTokens);
1123
+ let estimatedInputTotal = rawInputTotal;
1124
+ let estimatedOutputTotal = rawOutputTotal;
1125
+ if (usage.totalTokens !== null && usage.totalTokens > 0 && rawInputTotal + rawOutputTotal > 0) {
1126
+ const split = splitTotalByWeights(usage.totalTokens, [rawInputTotal, rawOutputTotal]);
1127
+ estimatedInputTotal = split[0];
1128
+ estimatedOutputTotal = split[1];
1129
+ }
1130
+
1131
+ const inputBuckets =
1132
+ estimatedInputTotal !== rawInputTotal
1133
+ ? scaleCategoryTokens(INPUT_TOKEN_CATEGORIES, estimatedInput, estimatedInputTotal)
1134
+ : mapTokensToBuckets(INPUT_TOKEN_CATEGORIES, estimatedInput);
1135
+ const outputBuckets =
1136
+ estimatedOutputTotal !== rawOutputTotal
1137
+ ? scaleCategoryTokens(OUTPUT_TOKEN_CATEGORIES, estimatedOutputTokens, estimatedOutputTotal)
1138
+ : mapTokensToBuckets(OUTPUT_TOKEN_CATEGORIES, estimatedOutputTokens);
1139
+
1140
+ const notes = [
1141
+ "Input/output totals are estimated from captured content.",
1142
+ "Category slices are estimated from captured content structure.",
1143
+ ];
1144
+ if (streamSource === "timeline_text_with_tool_calls") {
1145
+ notes.push(
1146
+ "For streamed responses, output categories are estimated from extracted timeline-equivalent SSE text and reconstructed streamed tool-call deltas."
1147
+ );
1148
+ } else if (streamSource === "timeline_text") {
1149
+ notes.push("For streamed responses, output categories are estimated from extracted timeline-equivalent SSE text.");
1150
+ } else if (streamSource === "timeline_tool_calls_only") {
1151
+ notes.push("For streamed responses, output categories are estimated from reconstructed streamed tool-call deltas.");
1152
+ } else if (streamSource === "fallback_events") {
1153
+ notes.push("For streamed responses, no useful SSE text was extracted; output fallback uses merged SSE event payload text (lower confidence).");
1154
+ }
1155
+
1156
+ return {
1157
+ eligible: true,
1158
+ method: "estimated_only",
1159
+ totals: {
1160
+ inputTokens: estimatedInputTotal,
1161
+ outputTokens: estimatedOutputTotal,
1162
+ totalTokens: usage.totalTokens ?? estimatedInputTotal + estimatedOutputTotal,
1163
+ },
1164
+ input: inputBuckets,
1165
+ output: outputBuckets,
1166
+ notes,
1167
+ };
1168
+ }
1169
+
1170
+ function extractTokenUsageTotals(responseBody: unknown): {
1171
+ inputTokens: number | null;
1172
+ outputTokens: number | null;
1173
+ totalTokens: number | null;
1174
+ } {
1175
+ const body = asRecord(responseBody);
1176
+ const usage = asRecord(body?.usage);
1177
+ if (!usage) {
1178
+ return { inputTokens: null, outputTokens: null, totalTokens: null };
1179
+ }
1180
+
1181
+ const promptTokens = toNullableInt(usage.prompt_tokens);
1182
+ const completionTokens = toNullableInt(usage.completion_tokens);
1183
+ const inputTokens = toNullableInt(usage.input_tokens);
1184
+ const outputTokens = toNullableInt(usage.output_tokens);
1185
+ const totalTokens = toNullableInt(usage.total_tokens);
1186
+
1187
+ const normalizedInput = promptTokens ?? inputTokens;
1188
+ const normalizedOutput = completionTokens ?? outputTokens;
1189
+ const normalizedTotal =
1190
+ totalTokens ?? (normalizedInput !== null && normalizedOutput !== null ? normalizedInput + normalizedOutput : null);
1191
+
1192
+ return {
1193
+ inputTokens: normalizedInput,
1194
+ outputTokens: normalizedOutput,
1195
+ totalTokens: normalizedTotal,
1196
+ };
1197
+ }
1198
+
1199
+ function estimateInputCategoryTokens(source: Record<string, unknown> | null | undefined): Record<string, number> {
1200
+ const tokens = initCategoryTokenMap(INPUT_TOKEN_CATEGORIES);
1201
+ if (!source) {
1202
+ return tokens;
1203
+ }
1204
+
1205
+ if (typeof source.instructions === "string") {
1206
+ tokens.instructions += roughTokenEstimate(source.instructions);
1207
+ }
1208
+
1209
+ if (Array.isArray(source.messages)) {
1210
+ for (const message of source.messages) {
1211
+ const record = asRecord(message);
1212
+ if (!record) continue;
1213
+ const role = normalizeRole(record.role);
1214
+ const text = extractTextContent(record.content);
1215
+ tokens.input_media += estimateMediaTokensFromContent(record.content);
1216
+ if (role === "system") tokens.system += roughTokenEstimate(text);
1217
+ else if (role === "developer") tokens.developer += roughTokenEstimate(text);
1218
+ else if (role === "user") tokens.user += roughTokenEstimate(text);
1219
+ else if (role === "assistant") tokens.assistant_history += roughTokenEstimate(text);
1220
+ else if (role === "tool") tokens.tool_results += roughTokenEstimate(text);
1221
+ else tokens.unattributed_input += roughTokenEstimate(text);
1222
+
1223
+ if (role === "assistant" && typeof record.reasoning_content === "string") {
1224
+ tokens.assistant_history += roughTokenEstimate(record.reasoning_content);
1225
+ }
1226
+ const toolCalls = extractToolCalls(record.tool_calls);
1227
+ for (const toolCall of toolCalls) {
1228
+ tokens.assistant_history += roughTokenEstimate(`${toolCall.function?.name ?? ""} ${toolCall.function?.arguments ?? ""}`.trim());
1229
+ }
1230
+ }
1231
+ }
1232
+
1233
+ if (Array.isArray(source.input)) {
1234
+ for (const item of source.input) {
1235
+ const record = asRecord(item);
1236
+ if (!record) {
1237
+ if (typeof item === "string") {
1238
+ tokens.user += roughTokenEstimate(item);
1239
+ } else {
1240
+ tokens.unattributed_input += roughTokenEstimate(stringifyMaybe(item));
1241
+ }
1242
+ continue;
1243
+ }
1244
+ const type = typeof record.type === "string" ? record.type : "";
1245
+ if (type === "message") {
1246
+ const role = normalizeRole(record.role);
1247
+ const text = extractTextContent(record.content);
1248
+ tokens.input_media += estimateMediaTokensFromContent(record.content);
1249
+ if (role === "system") tokens.system += roughTokenEstimate(text);
1250
+ else if (role === "developer") tokens.developer += roughTokenEstimate(text);
1251
+ else if (role === "user") tokens.user += roughTokenEstimate(text);
1252
+ else if (role === "assistant") tokens.assistant_history += roughTokenEstimate(text);
1253
+ else if (role === "tool") tokens.tool_results += roughTokenEstimate(text);
1254
+ else tokens.unattributed_input += roughTokenEstimate(text);
1255
+ } else if (isMediaInputItem(record)) {
1256
+ tokens.input_media += estimateMediaTokensFromItem(record);
1257
+ } else if (type === "function_call_output") {
1258
+ tokens.tool_results += roughTokenEstimate(stringifyMaybe(record.output));
1259
+ } else if (type === "function_call") {
1260
+ tokens.assistant_history += roughTokenEstimate(`${record.name ?? ""} ${record.arguments ?? ""}`.trim());
1261
+ } else {
1262
+ tokens.unattributed_input += roughTokenEstimate(stringifyMaybe(record));
1263
+ }
1264
+ }
1265
+ }
1266
+
1267
+ if (Array.isArray(source.tools)) {
1268
+ for (const tool of source.tools) {
1269
+ const record = asRecord(tool);
1270
+ if (!record) continue;
1271
+ const fn = asRecord(record.function);
1272
+ const name = typeof fn?.name === "string" ? fn.name : "";
1273
+ const description = typeof fn?.description === "string" ? fn.description : "";
1274
+ const parameters = fn?.parameters ? stringifyMaybe(fn.parameters) : "";
1275
+ tokens.tool_definitions += roughTokenEstimate(`${name}\n${description}\n${parameters}`.trim());
1276
+ }
1277
+ }
1278
+
1279
+ return tokens;
1280
+ }
1281
+
1282
+ function estimateMediaTokensFromContent(content: unknown): number {
1283
+ if (!Array.isArray(content)) return 0;
1284
+ let total = 0;
1285
+ for (const part of content) {
1286
+ const record = asRecord(part);
1287
+ if (!record) continue;
1288
+ if (isTextPart(record)) continue;
1289
+ if (isMediaInputItem(record)) {
1290
+ total += estimateMediaTokensFromItem(record);
1291
+ }
1292
+ }
1293
+ return total;
1294
+ }
1295
+
1296
+ function isTextPart(record: Record<string, unknown>): boolean {
1297
+ const type = typeof record.type === "string" ? record.type : "";
1298
+ return type === "text" || type === "input_text" || type === "output_text";
1299
+ }
1300
+
1301
+ function isMediaInputItem(record: Record<string, unknown>): boolean {
1302
+ const type = typeof record.type === "string" ? record.type.toLowerCase() : "";
1303
+ if (type.includes("image") || type.includes("audio")) return true;
1304
+ return (
1305
+ record.image_url !== undefined ||
1306
+ record.input_image !== undefined ||
1307
+ record.image !== undefined ||
1308
+ record.input_audio !== undefined ||
1309
+ record.audio !== undefined
1310
+ );
1311
+ }
1312
+
1313
+ function estimateMediaTokensFromItem(record: Record<string, unknown>): number {
1314
+ const type = typeof record.type === "string" ? record.type.toLowerCase() : "";
1315
+ if (type.includes("image") || record.image_url !== undefined || record.input_image !== undefined || record.image !== undefined) {
1316
+ return estimateImageTokens(record);
1317
+ }
1318
+ if (type.includes("audio") || record.input_audio !== undefined || record.audio !== undefined) {
1319
+ return 256;
1320
+ }
1321
+ return 128;
1322
+ }
1323
+
1324
+ function estimateImageTokens(record: Record<string, unknown>): number {
1325
+ let detail: unknown = record.detail;
1326
+ const imageUrl = asRecord(record.image_url);
1327
+ if (detail === undefined && imageUrl) detail = imageUrl.detail;
1328
+ if (detail === "high") return 768;
1329
+ if (detail === "low") return 128;
1330
+ return 256;
1331
+ }
1332
+
1333
+ function estimateOutputCategoryTokens(responseBody: unknown): {
1334
+ tokens: Record<string, number>;
1335
+ streamSource?:
1336
+ | "timeline_text"
1337
+ | "timeline_text_with_tool_calls"
1338
+ | "timeline_tool_calls_only"
1339
+ | "fallback_events"
1340
+ | "none";
1341
+ } {
1342
+ const tokens = initCategoryTokenMap(OUTPUT_TOKEN_CATEGORIES);
1343
+ const body = asRecord(responseBody);
1344
+ if (!body) {
1345
+ tokens.unattributed_output += roughTokenEstimate(stringifyMaybe(responseBody));
1346
+ return { tokens };
1347
+ }
1348
+
1349
+ const error = asRecord(body.error);
1350
+ if (error) {
1351
+ tokens.errors += roughTokenEstimate(
1352
+ typeof error.message === "string" ? error.message : stringifyMaybe(error)
1353
+ );
1354
+ }
1355
+
1356
+ if (body.$type === "stream") {
1357
+ if (typeof body.text === "string") {
1358
+ const usefulText = extractMergedUsefulSseText(body.text);
1359
+ const streamedToolCalls = extractStreamToolCalls(body.text);
1360
+ const hasUsefulText = usefulText.trim().length > 0;
1361
+ const hasToolCalls = streamedToolCalls.length > 0;
1362
+
1363
+ if (hasUsefulText) {
1364
+ tokens.assistant_text += roughTokenEstimate(usefulText);
1365
+ }
1366
+ if (hasToolCalls) {
1367
+ for (const toolCall of streamedToolCalls) {
1368
+ const callText = `${toolCall.function?.name ?? ""} ${toolCall.function?.arguments ?? ""}`.trim();
1369
+ tokens.tool_calls += roughTokenEstimate(callText);
1370
+ }
1371
+ }
1372
+ if (hasUsefulText && hasToolCalls) {
1373
+ return { tokens, streamSource: "timeline_text_with_tool_calls" };
1374
+ }
1375
+ if (hasUsefulText) {
1376
+ return { tokens, streamSource: "timeline_text" };
1377
+ }
1378
+ if (hasToolCalls) {
1379
+ return { tokens, streamSource: "timeline_tool_calls_only" };
1380
+ }
1381
+
1382
+ const fallback = mergeSseEventPayloadText(body.text);
1383
+ if (fallback.trim()) {
1384
+ tokens.unattributed_output += roughTokenEstimate(fallback);
1385
+ return { tokens, streamSource: "fallback_events" };
1386
+ }
1387
+ } else if (typeof body.note === "string") {
1388
+ tokens.unattributed_output += roughTokenEstimate(body.note);
1389
+ return { tokens, streamSource: "none" };
1390
+ }
1391
+ return { tokens, streamSource: "none" };
1392
+ }
1393
+
1394
+ if (Array.isArray(body.output)) {
1395
+ for (const item of body.output) {
1396
+ const record = asRecord(item);
1397
+ if (!record) continue;
1398
+ const type = typeof record.type === "string" ? record.type : "";
1399
+ if (type === "message") {
1400
+ tokens.assistant_text += roughTokenEstimate(extractTextContentFromResponseItem(record));
1401
+ } else if (type === "reasoning") {
1402
+ tokens.reasoning += roughTokenEstimate(extractReasoningItemText(record));
1403
+ } else if (type === "function_call") {
1404
+ tokens.tool_calls += roughTokenEstimate(`${record.name ?? ""} ${record.arguments ?? ""}`.trim());
1405
+ } else if (type === "function_call_output") {
1406
+ tokens.tool_results += roughTokenEstimate(stringifyMaybe(record.output));
1407
+ } else {
1408
+ tokens.unattributed_output += roughTokenEstimate(stringifyMaybe(record));
1409
+ }
1410
+ }
1411
+ return { tokens };
1412
+ }
1413
+
1414
+ if (Array.isArray(body.choices)) {
1415
+ for (const choice of body.choices) {
1416
+ const choiceRecord = asRecord(choice);
1417
+ const message = asRecord(choiceRecord?.message);
1418
+ if (!message) continue;
1419
+ tokens.assistant_text += roughTokenEstimate(extractTextContent(message.content));
1420
+ if (typeof message.reasoning_content === "string") {
1421
+ tokens.reasoning += roughTokenEstimate(message.reasoning_content);
1422
+ }
1423
+ const toolCalls = extractToolCalls(message.tool_calls);
1424
+ for (const toolCall of toolCalls) {
1425
+ tokens.tool_calls += roughTokenEstimate(`${toolCall.function?.name ?? ""} ${toolCall.function?.arguments ?? ""}`.trim());
1426
+ }
1427
+ }
1428
+ return { tokens };
1429
+ }
1430
+
1431
+ tokens.unattributed_output += roughTokenEstimate(stringifyMaybe(body));
1432
+ return { tokens };
1433
+ }
1434
+
1435
+ function initCategoryTokenMap(categories: Array<{ key: string }>): Record<string, number> {
1436
+ const out: Record<string, number> = {};
1437
+ for (const category of categories) {
1438
+ out[category.key] = 0;
1439
+ }
1440
+ return out;
1441
+ }
1442
+
1443
+ function mapTokensToBuckets(
1444
+ categories: Array<{ key: string; label: string }>,
1445
+ values: Record<string, number>
1446
+ ): CaptureTokenFlowBucket[] {
1447
+ return categories.map((category) => ({
1448
+ key: category.key,
1449
+ label: category.label,
1450
+ tokens: Math.max(0, Math.round(values[category.key] ?? 0)),
1451
+ }));
1452
+ }
1453
+
1454
+ function scaleCategoryTokens(
1455
+ categories: Array<{ key: string; label: string }>,
1456
+ values: Record<string, number>,
1457
+ targetTotal: number
1458
+ ): CaptureTokenFlowBucket[] {
1459
+ const sanitizedTarget = Math.max(0, Math.round(targetTotal));
1460
+ if (sanitizedTarget === 0) {
1461
+ return categories.map((category) => ({ key: category.key, label: category.label, tokens: 0 }));
1462
+ }
1463
+ const weights = categories.map((category) => Math.max(0, values[category.key] ?? 0));
1464
+ const scaled = splitTotalByWeights(sanitizedTarget, weights);
1465
+ return categories.map((category, idx) => ({
1466
+ key: category.key,
1467
+ label: category.label,
1468
+ tokens: scaled[idx] ?? 0,
1469
+ }));
1470
+ }
1471
+
1472
+ function splitTotalByWeights(total: number, weights: number[]): number[] {
1473
+ const sanitizedTotal = Math.max(0, Math.round(total));
1474
+ const normalizedWeights = weights.map((value) => Math.max(0, value));
1475
+ const sum = normalizedWeights.reduce((acc, value) => acc + value, 0);
1476
+ if (sanitizedTotal === 0) return normalizedWeights.map(() => 0);
1477
+ if (sum <= 0) {
1478
+ const equal = Math.floor(sanitizedTotal / normalizedWeights.length);
1479
+ let remainder = sanitizedTotal - equal * normalizedWeights.length;
1480
+ return normalizedWeights.map((_value, idx) => equal + (remainder-- > 0 ? 1 : 0));
1481
+ }
1482
+
1483
+ const rawShares = normalizedWeights.map((value) => (value / sum) * sanitizedTotal);
1484
+ const floors = rawShares.map((value) => Math.floor(value));
1485
+ let remainder = sanitizedTotal - floors.reduce((acc, value) => acc + value, 0);
1486
+ const order = rawShares
1487
+ .map((value, idx) => ({ idx, frac: value - floors[idx] }))
1488
+ .sort((a, b) => b.frac - a.frac);
1489
+ for (let i = 0; i < order.length && remainder > 0; i += 1) {
1490
+ floors[order[i].idx] += 1;
1491
+ remainder -= 1;
1492
+ }
1493
+ return floors;
1494
+ }
1495
+
1496
+ function sumTokenMapValues(values: Record<string, number>): number {
1497
+ return Object.values(values).reduce((acc, value) => acc + Math.max(0, Math.round(value)), 0);
1498
+ }
1499
+
1500
+ function toNullableInt(value: unknown): number | null {
1501
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
1502
+ return Math.max(0, Math.round(value));
1503
+ }
1504
+
1505
+ function roughTokenEstimate(value: string): number {
1506
+ if (!value) return 0;
1507
+ return Math.ceil(Buffer.byteLength(value, "utf8") / 4);
1508
+ }
1509
+
1510
+ function normalizeRole(value: unknown): CaptureTimelineEntry["role"] | undefined {
1511
+ if (
1512
+ value === "system" ||
1513
+ value === "user" ||
1514
+ value === "assistant" ||
1515
+ value === "tool" ||
1516
+ value === "developer"
1517
+ ) {
1518
+ return value;
1519
+ }
1520
+ return undefined;
1521
+ }
1522
+
1523
+ function stringifyMaybe(value: unknown): string {
1524
+ if (typeof value === "string") return value;
1525
+ if (value === undefined) return "";
1526
+ try {
1527
+ return JSON.stringify(value, null, 2);
1528
+ } catch {
1529
+ return String(value);
1530
+ }
1531
+ }
1532
+
1533
+ function dateStringForTimeZone(timestamp: string, timeZone: string): string {
1534
+ const value = new Date(timestamp);
1535
+ if (!Number.isFinite(value.getTime())) {
1536
+ return timestamp.slice(0, 10);
1537
+ }
1538
+ const formatter = new Intl.DateTimeFormat("en-CA", {
1539
+ timeZone,
1540
+ year: "numeric",
1541
+ month: "2-digit",
1542
+ day: "2-digit",
1543
+ });
1544
+ const parts = formatter.formatToParts(value);
1545
+ const year = parts.find((part) => part.type === "year")?.value ?? "0000";
1546
+ const month = parts.find((part) => part.type === "month")?.value ?? "00";
1547
+ const day = parts.find((part) => part.type === "day")?.value ?? "00";
1548
+ return `${year}-${month}-${day}`;
1549
+ }
1550
+
1551
+ function normalizeTimeZone(input: string | undefined): string {
1552
+ if (!input) return "UTC";
1553
+ try {
1554
+ new Intl.DateTimeFormat("en-US", { timeZone: input });
1555
+ return input;
1556
+ } catch {
1557
+ return "UTC";
1558
+ }
1559
+ }
1560
+
1561
+ function hydrateCaptureRecord(record: CaptureRecord): CaptureRecord {
1562
+ const analysis = buildAnalysisProjection(record.route, record.request.body, record.response.body, record.request.derived);
1563
+ return {
1564
+ ...record,
1565
+ analysis: {
1566
+ ...record.analysis,
1567
+ ...analysis,
1568
+ tools: record.analysis?.tools?.length ? record.analysis.tools : analysis.tools,
1569
+ mcpToolDescriptions: record.analysis?.mcpToolDescriptions?.length
1570
+ ? record.analysis.mcpToolDescriptions
1571
+ : analysis.mcpToolDescriptions,
1572
+ agentsMdHints: record.analysis?.agentsMdHints?.length ? record.analysis.agentsMdHints : analysis.agentsMdHints,
1573
+ rawSections: record.analysis?.rawSections?.length ? record.analysis.rawSections : analysis.rawSections,
1574
+ },
1575
+ };
1576
+ }
1577
+
1578
+ async function buildPreviewBody(
1579
+ paths: StoragePaths,
1580
+ value: unknown,
1581
+ artifacts: CaptureArtifact[]
1582
+ ): Promise<unknown> {
1583
+ if (value === null || value === undefined) return value;
1584
+ if (typeof value === "string") {
1585
+ return previewString(value);
1586
+ }
1587
+ if (Array.isArray(value)) {
1588
+ const out: unknown[] = [];
1589
+ for (const item of value) {
1590
+ out.push(await buildPreviewBody(paths, item, artifacts));
1591
+ }
1592
+ return out;
1593
+ }
1594
+ if (typeof value === "object") {
1595
+ const out: Record<string, unknown> = {};
1596
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
1597
+ if (typeof v === "string") {
1598
+ const dataMatch = v.match(DATA_URL_RE);
1599
+ if (dataMatch) {
1600
+ out[key] = await storeDataUrlArtifact(paths, v, artifacts);
1601
+ continue;
1602
+ }
1603
+ }
1604
+ out[key] = await buildPreviewBody(paths, v, artifacts);
1605
+ }
1606
+ return out;
1607
+ }
1608
+ return value;
1609
+ }
1610
+
1611
+ function previewString(input: string): unknown {
1612
+ if (input.length <= 4000) return input;
1613
+ return {
1614
+ $type: "long_text",
1615
+ length: input.length,
1616
+ preview: `${input.slice(0, 320)}…`,
1617
+ };
1618
+ }
1619
+
1620
+ async function storeDataUrlArtifact(
1621
+ paths: StoragePaths,
1622
+ value: string,
1623
+ artifacts: CaptureArtifact[]
1624
+ ): Promise<unknown> {
1625
+ const match = value.match(DATA_URL_RE);
1626
+ if (!match) return previewString(value);
1627
+ const mime = match[1].toLowerCase();
1628
+ let buffer: Buffer;
1629
+ try {
1630
+ buffer = Buffer.from(match[2], "base64");
1631
+ } catch {
1632
+ return {
1633
+ $type: "data_url",
1634
+ mime,
1635
+ error: "invalid_base64",
1636
+ };
1637
+ }
1638
+ const hash = createHash("sha256").update(buffer).digest("hex");
1639
+ const ext = mimeToExt(mime);
1640
+ const blobFile = `${hash}.${ext}`;
1641
+ const blobPath = path.join(captureBlobsDir(paths), blobFile);
1642
+ try {
1643
+ await fs.access(blobPath);
1644
+ } catch {
1645
+ await fs.writeFile(blobPath, buffer);
1646
+ }
1647
+ const artifact: CaptureArtifact = {
1648
+ hash,
1649
+ mime,
1650
+ bytes: buffer.byteLength,
1651
+ blobRef: `/admin/capture/blobs/${hash}`,
1652
+ kind: mime.startsWith("image/")
1653
+ ? "image"
1654
+ : mime.startsWith("audio/")
1655
+ ? "audio"
1656
+ : "binary",
1657
+ };
1658
+ if (!artifacts.some((item) => item.hash === hash)) {
1659
+ artifacts.push(artifact);
1660
+ }
1661
+ return {
1662
+ $type: "data_url_ref",
1663
+ mime,
1664
+ bytes: buffer.byteLength,
1665
+ blobRef: artifact.blobRef,
1666
+ };
1667
+ }
1668
+
1669
+ function mimeToExt(mime: string): string {
1670
+ if (mime === "image/png") return "png";
1671
+ if (mime === "image/jpeg") return "jpg";
1672
+ if (mime === "image/webp") return "webp";
1673
+ if (mime === "image/gif") return "gif";
1674
+ if (mime === "audio/mpeg") return "mp3";
1675
+ if (mime === "audio/wav") return "wav";
1676
+ if (mime === "audio/ogg") return "ogg";
1677
+ if (mime === "audio/webm") return "webm";
1678
+ return "bin";
1679
+ }
1680
+
1681
+ function extToMime(ext: string): string {
1682
+ if (ext === "png") return "image/png";
1683
+ if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
1684
+ if (ext === "webp") return "image/webp";
1685
+ if (ext === "gif") return "image/gif";
1686
+ if (ext === "mp3") return "audio/mpeg";
1687
+ if (ext === "wav") return "audio/wav";
1688
+ if (ext === "ogg") return "audio/ogg";
1689
+ if (ext === "webm") return "audio/webm";
1690
+ return "application/octet-stream";
1691
+ }
1692
+
1693
+ async function readCaptureIndex(
1694
+ paths: StoragePaths,
1695
+ options?: { pruneMissing?: boolean }
1696
+ ): Promise<CaptureIndexEntry[]> {
1697
+ try {
1698
+ const raw = await fs.readFile(captureIndexPath(paths), "utf8");
1699
+ const entries = raw
1700
+ .split("\n")
1701
+ .filter((line) => line.trim().length > 0)
1702
+ .map((line) => JSON.parse(line) as CaptureIndexEntry)
1703
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
1704
+ if (!options?.pruneMissing) {
1705
+ return entries;
1706
+ }
1707
+
1708
+ const existing: CaptureIndexEntry[] = [];
1709
+ let removed = false;
1710
+ for (const entry of entries) {
1711
+ try {
1712
+ await fs.access(path.join(captureDir(paths), entry.file));
1713
+ existing.push(entry);
1714
+ } catch {
1715
+ removed = true;
1716
+ }
1717
+ }
1718
+ if (removed) {
1719
+ const content = existing.map((entry) => JSON.stringify(entry)).join("\n");
1720
+ await fs.writeFile(captureIndexPath(paths), content ? `${content}\n` : "", "utf8");
1721
+ }
1722
+ return existing;
1723
+ } catch {
1724
+ return [];
1725
+ }
1726
+ }
1727
+
1728
+ async function applyCaptureRetention(paths: StoragePaths, config: CaptureConfig): Promise<void> {
1729
+ const cutoff = Date.now() - config.retentionDays * 24 * 60 * 60 * 1000;
1730
+ let entries = await readCaptureIndex(paths);
1731
+ if (entries.length === 0) return;
1732
+
1733
+ entries = entries.filter((entry) => {
1734
+ const ts = new Date(entry.timestamp).getTime();
1735
+ if (!Number.isFinite(ts) || ts < cutoff) {
1736
+ return false;
1737
+ }
1738
+ return true;
1739
+ });
1740
+
1741
+ let total = await dirSize(captureDir(paths));
1742
+ if (total > config.maxBytes) {
1743
+ const sorted = [...entries].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
1744
+ while (total > config.maxBytes && sorted.length > 0) {
1745
+ const oldest = sorted.shift();
1746
+ if (!oldest) break;
1747
+ const recPath = path.join(captureDir(paths), oldest.file);
1748
+ try {
1749
+ await fs.unlink(recPath);
1750
+ } catch {
1751
+ // noop
1752
+ }
1753
+ entries = entries.filter((entry) => entry.id !== oldest.id);
1754
+ total = await dirSize(captureDir(paths));
1755
+ }
1756
+ } else {
1757
+ const existingIds = new Set(entries.map((entry) => entry.id));
1758
+ const allEntries = await readCaptureIndex(paths);
1759
+ for (const item of allEntries) {
1760
+ if (existingIds.has(item.id)) continue;
1761
+ try {
1762
+ await fs.unlink(path.join(captureDir(paths), item.file));
1763
+ } catch {
1764
+ // noop
1765
+ }
1766
+ }
1767
+ }
1768
+
1769
+ await cleanupOrphanBlobs(paths, entries);
1770
+ const content = entries.map((entry) => JSON.stringify(entry)).join("\n");
1771
+ await fs.writeFile(captureIndexPath(paths), content ? `${content}\n` : "", "utf8");
1772
+ }
1773
+
1774
+ async function cleanupOrphanBlobs(paths: StoragePaths, entries: CaptureIndexEntry[]): Promise<void> {
1775
+ const referenced = new Set<string>();
1776
+ for (const entry of entries) {
1777
+ try {
1778
+ const raw = await fs.readFile(path.join(captureDir(paths), entry.file), "utf8");
1779
+ const record = JSON.parse(raw) as CaptureRecord;
1780
+ for (const artifact of record.artifacts ?? []) {
1781
+ referenced.add(artifact.hash);
1782
+ }
1783
+ } catch {
1784
+ // noop
1785
+ }
1786
+ }
1787
+ let files: string[];
1788
+ try {
1789
+ files = await fs.readdir(captureBlobsDir(paths));
1790
+ } catch {
1791
+ return;
1792
+ }
1793
+ for (const file of files) {
1794
+ const hash = file.split(".")[0];
1795
+ if (!referenced.has(hash)) {
1796
+ try {
1797
+ await fs.unlink(path.join(captureBlobsDir(paths), file));
1798
+ } catch {
1799
+ // noop
1800
+ }
1801
+ }
1802
+ }
1803
+ }
1804
+
1805
+ async function dirSize(root: string): Promise<number> {
1806
+ let total = 0;
1807
+ async function walk(dir: string): Promise<void> {
1808
+ let entries: Array<import("fs").Dirent>;
1809
+ try {
1810
+ entries = await fs.readdir(dir, { withFileTypes: true });
1811
+ } catch {
1812
+ return;
1813
+ }
1814
+ for (const entry of entries) {
1815
+ const full = path.join(dir, entry.name);
1816
+ if (entry.isDirectory()) {
1817
+ await walk(full);
1818
+ } else if (entry.isFile()) {
1819
+ try {
1820
+ const stat = await fs.stat(full);
1821
+ total += stat.size;
1822
+ } catch {
1823
+ // noop
1824
+ }
1825
+ }
1826
+ }
1827
+ }
1828
+ await walk(root);
1829
+ return total;
1830
+ }
1831
+
1832
+ function asRecord(value: unknown): Record<string, unknown> | null {
1833
+ if (!value || typeof value !== "object") return null;
1834
+ return value as Record<string, unknown>;
1835
+ }