@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4

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 (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import { EventEmitter } from "node:events";
3
+ import { existsSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
3
6
 
4
7
  const mockSdkPrompt = vi.fn();
5
8
  const mockSdkCreate = vi.fn();
@@ -25,6 +28,8 @@ import {
25
28
  getAvailableProviders,
26
29
  estimateTokens,
27
30
  calculateCost,
31
+ findModelPrice,
32
+ resolveUsageAvailable,
28
33
  } from "../index.ts";
29
34
  import type {
30
35
  ChatOptions,
@@ -32,7 +37,9 @@ import type {
32
37
  NormalizedUsage,
33
38
  LLMRequestStartEvent,
34
39
  LLMRequestCompleteEvent,
40
+ LLMRequestErrorEvent,
35
41
  } from "../../providers/types.ts";
42
+ import { ProviderJsonParseError } from "../../providers/types.ts";
36
43
 
37
44
  // ─── Helpers ─────────────────────────────────────────────────────────────────
38
45
 
@@ -149,6 +156,7 @@ describe("LLM Gateway", () => {
149
156
  promptTokens: 100,
150
157
  completionTokens: 200,
151
158
  totalTokens: 300,
159
+ source: "reported",
152
160
  });
153
161
  });
154
162
 
@@ -177,6 +185,44 @@ describe("LLM Gateway", () => {
177
185
 
178
186
  expect(provider.chat).not.toHaveBeenCalled();
179
187
  });
188
+
189
+ it("AC-8: throwing adapter emits llm:request:error with estimated input usage, cost, and costEstimated", async () => {
190
+ const provider: MockProvider = {
191
+ chat: vi.fn().mockRejectedValue(new Error("adapter boom")),
192
+ };
193
+ registerMockProvider(provider);
194
+
195
+ const errorEvents: LLMRequestErrorEvent[] = [];
196
+ getLLMEvents().on("llm:request:error", (e) => errorEvents.push(e));
197
+
198
+ const messages = [
199
+ { role: "system" as const, content: "you are helpful" },
200
+ { role: "user" as const, content: "Hello world from a failing prompt" },
201
+ ];
202
+
203
+ await expect(
204
+ chat({ provider: "mock", model: "ac8-error-model", messages }),
205
+ ).rejects.toThrow("adapter boom");
206
+
207
+ expect(errorEvents).toHaveLength(1);
208
+ const evt = errorEvents[0]!;
209
+ const expectedPrompt = estimateTokens(
210
+ messages.map((m) => m.content).join(" "),
211
+ );
212
+ expect(evt.promptTokens).toBe(expectedPrompt);
213
+ expect(evt.completionTokens).toBe(0);
214
+ expect(evt.totalTokens).toBe(expectedPrompt);
215
+ expect(evt.usageSource).toBe("estimated");
216
+ expect(evt.costEstimated).toBe(true);
217
+ expect(evt.cost).toBe(
218
+ calculateCost("mock", "ac8-error-model", {
219
+ promptTokens: expectedPrompt,
220
+ completionTokens: 0,
221
+ totalTokens: expectedPrompt,
222
+ source: "estimated",
223
+ }),
224
+ );
225
+ });
180
226
  });
181
227
 
182
228
  // ── Telemetry Events ───────────────────────────────────────────────────
@@ -248,6 +294,229 @@ describe("LLM Gateway", () => {
248
294
  });
249
295
  });
250
296
 
297
+ // ── normalizeUsage classification (AC-2, AC-3) ─────────────────────────
298
+
299
+ describe("normalizeUsage classification", () => {
300
+ async function runChatWithUsage(usage: unknown): Promise<NormalizedUsage> {
301
+ const provider: MockProvider = {
302
+ chat: vi.fn().mockResolvedValue({
303
+ content: "response-text",
304
+ usage,
305
+ }),
306
+ };
307
+ registerMockProvider(provider);
308
+ const result = await chat({
309
+ provider: "mock",
310
+ messages: [{ role: "user", content: "some input text" }],
311
+ });
312
+ return result.usage;
313
+ }
314
+
315
+ it("classifies full reported usage as reported", async () => {
316
+ const usage = await runChatWithUsage({
317
+ prompt_tokens: 100,
318
+ completion_tokens: 200,
319
+ total_tokens: 300,
320
+ });
321
+ expect(usage).toEqual({
322
+ promptTokens: 100,
323
+ completionTokens: 200,
324
+ totalTokens: 300,
325
+ source: "reported",
326
+ });
327
+ });
328
+
329
+ it("classifies all-zero usage as zero", async () => {
330
+ const usage = await runChatWithUsage({
331
+ prompt_tokens: 0,
332
+ completion_tokens: 0,
333
+ total_tokens: 0,
334
+ });
335
+ expect(usage).toEqual({
336
+ promptTokens: 0,
337
+ completionTokens: 0,
338
+ totalTokens: 0,
339
+ source: "zero",
340
+ });
341
+ });
342
+
343
+ it("classifies absent usage from a capable provider as estimated", async () => {
344
+ const usage = await runChatWithUsage(undefined);
345
+ expect(usage.source).toBe("estimated");
346
+ expect(usage.promptTokens).toBe(estimateTokens("some input text"));
347
+ expect(usage.completionTokens).toBe(estimateTokens("response-text"));
348
+ expect(usage.totalTokens).toBe(
349
+ estimateTokens("some input text") + estimateTokens("response-text"),
350
+ );
351
+ });
352
+
353
+ it("classifies absent usage from an incapable provider as unavailable", async () => {
354
+ vi.spyOn(Bun, "spawn").mockReturnValue(
355
+ createOpenCodeProc([
356
+ '{"type":"text","part":{"text":"hello from opencode"}}\n',
357
+ ]),
358
+ );
359
+ const result = await chat({
360
+ provider: "opencode",
361
+ model: "anthropic/claude-sonnet-4-5",
362
+ messages: [{ role: "user", content: "Hello" }],
363
+ responseFormat: "text",
364
+ });
365
+ expect(result.usage.source).toBe("unavailable");
366
+ expect(result.usage.promptTokens).toBe(estimateTokens("Hello"));
367
+ expect(result.usage.completionTokens).toBe(
368
+ estimateTokens("hello from opencode"),
369
+ );
370
+ });
371
+
372
+ it("classifies prompt-only usage as estimated and estimates the completion side", async () => {
373
+ const usage = await runChatWithUsage({ prompt_tokens: 50 });
374
+ expect(usage.source).toBe("estimated");
375
+ expect(usage.promptTokens).toBe(50);
376
+ expect(usage.completionTokens).toBe(estimateTokens("response-text"));
377
+ expect(usage.totalTokens).toBe(50 + estimateTokens("response-text"));
378
+ });
379
+
380
+ it("classifies prompt plus total usage as estimated while preserving the reported total", async () => {
381
+ const usage = await runChatWithUsage({ prompt_tokens: 10, total_tokens: 30 });
382
+ expect(usage).toEqual({
383
+ promptTokens: 10,
384
+ completionTokens: 20,
385
+ totalTokens: 30,
386
+ source: "estimated",
387
+ });
388
+ });
389
+
390
+ it("classifies completion-only usage as estimated and estimates the prompt side", async () => {
391
+ const usage = await runChatWithUsage({ completion_tokens: 60 });
392
+ expect(usage.source).toBe("estimated");
393
+ expect(usage.promptTokens).toBe(estimateTokens("some input text"));
394
+ expect(usage.completionTokens).toBe(60);
395
+ expect(usage.totalTokens).toBe(estimateTokens("some input text") + 60);
396
+ });
397
+
398
+ it("classifies completion plus total usage as estimated while preserving the reported total", async () => {
399
+ const usage = await runChatWithUsage({ completion_tokens: 12, total_tokens: 30 });
400
+ expect(usage).toEqual({
401
+ promptTokens: 18,
402
+ completionTokens: 12,
403
+ totalTokens: 30,
404
+ source: "estimated",
405
+ });
406
+ });
407
+
408
+ it("classifies total-only usage as estimated and splits prompt/completion by 4-char estimate, summing to total", async () => {
409
+ const total = 100;
410
+ const usage = await runChatWithUsage({ total_tokens: total });
411
+ expect(usage.source).toBe("estimated");
412
+ expect(usage.totalTokens).toBe(total);
413
+ expect(usage.promptTokens + usage.completionTokens).toBe(total);
414
+ const promptEst = estimateTokens("some input text");
415
+ const completionEst = estimateTokens("response-text");
416
+ const expectedPrompt = Math.round(
417
+ (promptEst / (promptEst + completionEst)) * total,
418
+ );
419
+ expect(usage.promptTokens).toBe(expectedPrompt);
420
+ expect(usage.completionTokens).toBe(total - expectedPrompt);
421
+ });
422
+ });
423
+
424
+ // ── resolveUsageAvailable (AC-4) ───────────────────────────────────────
425
+
426
+ describe("resolveUsageAvailable()", () => {
427
+ let originalEnv: Record<string, string | undefined>;
428
+
429
+ beforeEach(() => {
430
+ originalEnv = { ...process.env };
431
+ delete process.env.PO_OPENCODE_BASE_URL;
432
+ delete process.env.OPENCODE_BASE_URL;
433
+ });
434
+
435
+ afterEach(() => {
436
+ for (const key of ["PO_OPENCODE_BASE_URL", "OPENCODE_BASE_URL"]) {
437
+ if (!(key in originalEnv)) delete process.env[key];
438
+ else process.env[key] = originalEnv[key];
439
+ }
440
+ });
441
+
442
+ it("returns false for claudecode", () => {
443
+ expect(
444
+ resolveUsageAvailable("claudecode", {
445
+ provider: "claudecode",
446
+ messages: baseMessages,
447
+ }),
448
+ ).toBe(false);
449
+ });
450
+
451
+ it("returns false for opencode in cli mode", () => {
452
+ expect(
453
+ resolveUsageAvailable("opencode", {
454
+ provider: "opencode",
455
+ messages: baseMessages,
456
+ opencode: { mode: "cli" },
457
+ }),
458
+ ).toBe(false);
459
+ });
460
+
461
+ it("returns true for opencode in sdk mode", () => {
462
+ expect(
463
+ resolveUsageAvailable("opencode", {
464
+ provider: "opencode",
465
+ messages: baseMessages,
466
+ opencode: { mode: "sdk", baseUrl: "http://localhost:3000" },
467
+ }),
468
+ ).toBe(true);
469
+ });
470
+
471
+ it("returns true for opencode with mode omitted and PO_OPENCODE_BASE_URL set", () => {
472
+ process.env.PO_OPENCODE_BASE_URL = "http://localhost:3000";
473
+ expect(
474
+ resolveUsageAvailable("opencode", {
475
+ provider: "opencode",
476
+ messages: baseMessages,
477
+ }),
478
+ ).toBe(true);
479
+ });
480
+
481
+ it("returns false for opencode with mode omitted and no base URL", () => {
482
+ expect(
483
+ resolveUsageAvailable("opencode", {
484
+ provider: "opencode",
485
+ messages: baseMessages,
486
+ }),
487
+ ).toBe(false);
488
+ });
489
+
490
+ it("returns true for usage-capable providers (mock)", () => {
491
+ expect(
492
+ resolveUsageAvailable("mock", {
493
+ provider: "mock",
494
+ messages: baseMessages,
495
+ }),
496
+ ).toBe(true);
497
+ });
498
+ });
499
+
500
+ // ── usageAvailable source guard (AC-4, invariant I-1) ──────────────────
501
+
502
+ describe("usageAvailable single-source guard", () => {
503
+ it("src/providers/*.ts (adapter sources, not tests) does not reference usageAvailable (the gateway helper owns it)", async () => {
504
+ const { Glob } = await import("bun");
505
+ const files: string[] = [];
506
+ for await (const path of new Glob("src/providers/*.ts").scan()) {
507
+ files.push(path);
508
+ }
509
+ const offenders: string[] = [];
510
+ for (const path of files) {
511
+ const text = await Bun.file(path).text();
512
+ if (text.includes("usageAvailable")) offenders.push(path);
513
+ }
514
+ // types.ts declares the field on AdapterResponse; that is the contract,
515
+ // not an adapter setting it. It is the only allowed file.
516
+ expect(offenders).toEqual(["src/providers/types.ts"]);
517
+ });
518
+ });
519
+
251
520
  // ── calculateCost ──────────────────────────────────────────────────────
252
521
 
253
522
  describe("calculateCost()", () => {
@@ -257,6 +526,7 @@ describe("LLM Gateway", () => {
257
526
  promptTokens: 1_000_000,
258
527
  completionTokens: 1_000_000,
259
528
  totalTokens: 2_000_000,
529
+ source: "reported",
260
530
  };
261
531
  const cost = calculateCost("anthropic", "claude-sonnet-4-6", usage);
262
532
  // inCost: (1000000/1000000) * 3 = $3.00
@@ -269,6 +539,7 @@ describe("LLM Gateway", () => {
269
539
  promptTokens: 1000,
270
540
  completionTokens: 1000,
271
541
  totalTokens: 2000,
542
+ source: "reported",
272
543
  };
273
544
  const cost = calculateCost("unknown", "unknown-model", usage);
274
545
  expect(cost).toBe(0);
@@ -280,12 +551,187 @@ describe("LLM Gateway", () => {
280
551
  promptTokens: 1000,
281
552
  completionTokens: 1000,
282
553
  totalTokens: 2000,
554
+ source: "reported",
283
555
  };
284
556
  const cost = calculateCost("claudecode", "sonnet", usage);
285
557
  expect(cost).toBe(0);
286
558
  });
287
559
  });
288
560
 
561
+ // ── findModelPrice ─────────────────────────────────────────────────────
562
+
563
+ describe("findModelPrice()", () => {
564
+ it("returns the price for a known model looked up by alias", () => {
565
+ // anthropic:sonnet-4-6 → tokenCostInPerMillion: 3, tokenCostOutPerMillion: 15
566
+ const price = findModelPrice("anthropic", "claude-sonnet-4-6");
567
+ expect(price).toEqual({
568
+ tokenCostInPerMillion: 3,
569
+ tokenCostOutPerMillion: 15,
570
+ });
571
+ });
572
+
573
+ it("returns the zero-cost entry for claudecode provider mapping", () => {
574
+ // claude-code:sonnet — zero-cost subscription
575
+ const price = findModelPrice("claudecode", "sonnet");
576
+ expect(price).toEqual({
577
+ tokenCostInPerMillion: 0,
578
+ tokenCostOutPerMillion: 0,
579
+ });
580
+ });
581
+
582
+ it("returns the price for zhipu provider mapping", () => {
583
+ // zhipu maps to zai; zai:glm-5 → tokenCostInPerMillion: 1, tokenCostOutPerMillion: 3.2
584
+ const price = findModelPrice("zhipu", "glm-5");
585
+ expect(price).toEqual({
586
+ tokenCostInPerMillion: 1,
587
+ tokenCostOutPerMillion: 3.2,
588
+ });
589
+ });
590
+
591
+ it("returns null for an unknown provider+model", () => {
592
+ const price = findModelPrice("unknown", "nope");
593
+ expect(price).toBeNull();
594
+ });
595
+ });
596
+
597
+ // ── cost classification (AC-5, AC-6, AC-7) ─────────────────────────────
598
+
599
+ describe("cost classification", () => {
600
+ it("AC-5: known model with reported usage → costEstimated:false and a computed cost", async () => {
601
+ // Use opencode SDK mode with model "default" (alias opencode:default is in
602
+ // MODEL_CONFIG). Mock the SDK to report measured tokens.
603
+ process.env["PO_OPENCODE_BASE_URL"] = "http://localhost:3000";
604
+ mockCreateClient.mockReturnValue({
605
+ session: {
606
+ create: mockSdkCreate,
607
+ prompt: mockSdkPrompt,
608
+ delete: mockSdkDelete,
609
+ },
610
+ });
611
+ mockSdkCreate.mockResolvedValue({
612
+ data: { id: "sess-ac5" },
613
+ error: undefined,
614
+ });
615
+ mockSdkPrompt.mockResolvedValue({
616
+ data: {
617
+ info: {
618
+ id: "msg-ac5",
619
+ sessionID: "sess-ac5",
620
+ role: "assistant" as const,
621
+ modelID: "default",
622
+ providerID: "opencode",
623
+ agent: "build",
624
+ cost: 0,
625
+ tokens: {
626
+ input: 150,
627
+ output: 80,
628
+ total: 230,
629
+ reasoning: 0,
630
+ cache: { read: 0, write: 0 },
631
+ },
632
+ },
633
+ parts: [{ type: "text" as const, text: "sdk measured response" }],
634
+ },
635
+ error: undefined,
636
+ });
637
+ mockSdkDelete.mockResolvedValue({ data: undefined, error: undefined });
638
+
639
+ const completeEvents: LLMRequestCompleteEvent[] = [];
640
+ getLLMEvents().on("llm:request:complete", (e) =>
641
+ completeEvents.push(e),
642
+ );
643
+
644
+ try {
645
+ await chat({
646
+ provider: "opencode",
647
+ model: "default",
648
+ messages: baseMessages,
649
+ responseFormat: "text",
650
+ opencode: { mode: "sdk", baseUrl: "http://localhost:3000" },
651
+ });
652
+
653
+ expect(completeEvents).toHaveLength(1);
654
+ const evt = completeEvents[0]!;
655
+ expect(evt.usageSource).toBe("reported");
656
+ expect(evt.costEstimated).toBe(false);
657
+ // opencode:default is a zero-cost entry; computed cost is 0 from a known price.
658
+ expect(evt.cost).toBe(0);
659
+ } finally {
660
+ delete process.env["PO_OPENCODE_BASE_URL"];
661
+ mockCreateClient.mockClear();
662
+ mockSdkCreate.mockClear();
663
+ mockSdkPrompt.mockClear();
664
+ mockSdkDelete.mockClear();
665
+ }
666
+ });
667
+
668
+ it("AC-6: unknown model → non-negative cost, costEstimated:true, and exactly one warning per alias", async () => {
669
+ const provider = makeMockProvider({
670
+ content: "ok",
671
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
672
+ });
673
+ registerMockProvider(provider);
674
+
675
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
676
+ const completeEvents: LLMRequestCompleteEvent[] = [];
677
+ getLLMEvents().on("llm:request:complete", (e) =>
678
+ completeEvents.push(e),
679
+ );
680
+
681
+ try {
682
+ await chat({
683
+ provider: "mock",
684
+ model: "ac6-unknown-model",
685
+ messages: baseMessages,
686
+ });
687
+ await chat({
688
+ provider: "mock",
689
+ model: "ac6-unknown-model",
690
+ messages: baseMessages,
691
+ });
692
+
693
+ expect(completeEvents).toHaveLength(2);
694
+ for (const evt of completeEvents) {
695
+ expect(evt.costEstimated).toBe(true);
696
+ expect(evt.cost).toBeGreaterThanOrEqual(0);
697
+ }
698
+ const aliasWarnings = warnSpy.mock.calls.filter((args) =>
699
+ String(args[0]).includes("mock:ac6-unknown-model"),
700
+ );
701
+ expect(aliasWarnings).toHaveLength(1);
702
+ } finally {
703
+ warnSpy.mockRestore();
704
+ }
705
+ });
706
+
707
+ it("AC-7: known model with non-reported source → costEstimated:true", async () => {
708
+ // opencode CLI mode → usageAvailable:false → absent usage → source "unavailable".
709
+ // Model "default" has a known price; the non-reported source forces costEstimated:true.
710
+ vi.spyOn(Bun, "spawn").mockReturnValue(
711
+ createOpenCodeProc([
712
+ '{"type":"text","part":{"text":"hello from opencode"}}\n',
713
+ ]),
714
+ );
715
+
716
+ const completeEvents: LLMRequestCompleteEvent[] = [];
717
+ getLLMEvents().on("llm:request:complete", (e) =>
718
+ completeEvents.push(e),
719
+ );
720
+
721
+ await chat({
722
+ provider: "opencode",
723
+ model: "default",
724
+ messages: baseMessages,
725
+ responseFormat: "text",
726
+ });
727
+
728
+ expect(completeEvents).toHaveLength(1);
729
+ const evt = completeEvents[0]!;
730
+ expect(evt.usageSource).toBe("unavailable");
731
+ expect(evt.costEstimated).toBe(true);
732
+ });
733
+ });
734
+
289
735
  // ── createChain ────────────────────────────────────────────────────────
290
736
 
291
737
  describe("createChain()", () => {
@@ -565,64 +1011,210 @@ describe("LLM Gateway", () => {
565
1011
  });
566
1012
  });
567
1013
 
568
- // ── JSON Format Inference ──────────────────────────────────────────────
1014
+ // ── JSON Format Inference (AC-13) ─────────────────────────────────────
569
1015
 
570
1016
  describe("JSON format inference", () => {
571
- it("infers json_object when first message contains 'json' for supported providers", async () => {
1017
+ it("AC-13: a json-bearing message with no responseFormat passes through without enabling JSON mode", async () => {
572
1018
  const provider = makeMockProvider();
573
1019
  registerMockProvider(provider);
574
1020
 
575
1021
  await chat({
576
1022
  provider: "mock",
577
- messages: [{ role: "user", content: "Return JSON response" }],
578
- // no responseFormat set
1023
+ messages: [{ role: "user", content: "Return a JSON object please" }],
579
1024
  });
580
1025
 
581
- // Mock provider does not do JSON inference — verify with a supported provider by inspecting
582
- // We verify the inference logic via OpenAI/DeepSeek/Gemini/Moonshot targets
1026
+ const callArgs = (provider.chat as ReturnType<typeof vi.fn>).mock.calls[0]![0] as ChatOptions;
1027
+ expect(callArgs.responseFormat).toBeUndefined();
583
1028
  });
1029
+ });
584
1030
 
585
- it("does not infer for providers not in the inference list (anthropic)", async () => {
586
- // We can test that mock (not in inference set) doesn't infer
587
- const provider = makeMockProvider();
1031
+ // ── JSON Repair (AC-15) ───────────────────────────────────────────────
1032
+
1033
+ describe("JSON repair", () => {
1034
+ it("AC-15: unparseable-then-valid triggers exactly one repair and emits counted repair usage", async () => {
1035
+ const provider: MockProvider = {
1036
+ chat: vi.fn()
1037
+ .mockRejectedValueOnce(
1038
+ new ProviderJsonParseError(
1039
+ "mock",
1040
+ "repair-valid",
1041
+ "{bad",
1042
+ undefined,
1043
+ {
1044
+ text: "{bad",
1045
+ usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 },
1046
+ },
1047
+ ),
1048
+ )
1049
+ .mockResolvedValueOnce({
1050
+ content: { ok: true },
1051
+ usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 },
1052
+ }),
1053
+ };
1054
+ registerMockProvider(provider);
1055
+
1056
+ const completeEvents: LLMRequestCompleteEvent[] = [];
1057
+ getLLMEvents().on("llm:request:complete", (e) => completeEvents.push(e));
1058
+
1059
+ const result = await chat({
1060
+ provider: "mock",
1061
+ model: "repair-valid",
1062
+ messages: baseMessages,
1063
+ responseFormat: "json",
1064
+ });
1065
+
1066
+ expect(result.content).toEqual({ ok: true });
1067
+ expect(provider.chat).toHaveBeenCalledTimes(2);
1068
+ expect(completeEvents).toHaveLength(1);
1069
+ expect(completeEvents[0]!.promptTokens).toBe(8);
1070
+ expect(completeEvents[0]!.completionTokens).toBe(11);
1071
+ expect(completeEvents[0]!.totalTokens).toBe(19);
1072
+ expect(completeEvents[0]!.usageSource).toBe("reported");
1073
+ expect(result.usage).toEqual({
1074
+ promptTokens: 8,
1075
+ completionTokens: 11,
1076
+ totalTokens: 19,
1077
+ source: "reported",
1078
+ });
1079
+ });
1080
+
1081
+ it("AC-15: always unparseable triggers exactly one repair then throws ProviderJsonParseError", async () => {
1082
+ const provider: MockProvider = {
1083
+ chat: vi.fn()
1084
+ .mockRejectedValueOnce(
1085
+ new ProviderJsonParseError(
1086
+ "mock",
1087
+ "repair-fail",
1088
+ "{bad",
1089
+ undefined,
1090
+ {
1091
+ text: "{bad",
1092
+ usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 },
1093
+ },
1094
+ ),
1095
+ )
1096
+ .mockRejectedValueOnce(
1097
+ new ProviderJsonParseError(
1098
+ "mock",
1099
+ "repair-fail",
1100
+ "{still bad",
1101
+ undefined,
1102
+ {
1103
+ text: "{still bad",
1104
+ usage: { prompt_tokens: 7, completion_tokens: 11, total_tokens: 18 },
1105
+ },
1106
+ ),
1107
+ ),
1108
+ };
1109
+ registerMockProvider(provider);
1110
+
1111
+ const errorEvents: LLMRequestErrorEvent[] = [];
1112
+ getLLMEvents().on("llm:request:error", (e) => errorEvents.push(e));
1113
+
1114
+ await expect(
1115
+ chat({
1116
+ provider: "mock",
1117
+ model: "repair-fail",
1118
+ messages: baseMessages,
1119
+ responseFormat: "json",
1120
+ }),
1121
+ ).rejects.toBeInstanceOf(ProviderJsonParseError);
1122
+
1123
+ expect(provider.chat).toHaveBeenCalledTimes(2);
1124
+ expect(errorEvents).toHaveLength(1);
1125
+ expect(errorEvents[0]!.promptTokens).toBe(9);
1126
+ expect(errorEvents[0]!.completionTokens).toBe(14);
1127
+ expect(errorEvents[0]!.totalTokens).toBe(23);
1128
+ expect(errorEvents[0]!.usageSource).toBe("reported");
1129
+ });
1130
+
1131
+ it("AC-15: fed-back sample is bounded to 2,000 characters", async () => {
1132
+ const longSample = "a".repeat(5000);
1133
+ const provider: MockProvider = {
1134
+ chat: vi.fn()
1135
+ .mockRejectedValueOnce(
1136
+ new ProviderJsonParseError("mock", "repair-bound", longSample),
1137
+ )
1138
+ .mockResolvedValueOnce({ content: { ok: true } }),
1139
+ };
588
1140
  registerMockProvider(provider);
589
1141
 
590
1142
  await chat({
591
1143
  provider: "mock",
592
- messages: [{ role: "user", content: "Return JSON" }],
1144
+ model: "repair-bound",
1145
+ messages: baseMessages,
1146
+ responseFormat: "json",
593
1147
  });
594
1148
 
595
- // Check that the options passed to mock provider do NOT have responseFormat set
596
- const callArgs = (provider.chat as ReturnType<typeof vi.fn>).mock.calls[0]![0] as ChatOptions;
597
- expect(callArgs.responseFormat).toBeUndefined();
1149
+ const secondCall = (provider.chat as ReturnType<typeof vi.fn>).mock.calls[1]![0] as ChatOptions;
1150
+ const repairMessage = secondCall.messages.at(-1)!;
1151
+ expect(repairMessage.role).toBe("user");
1152
+ expect(repairMessage.content).toContain("a".repeat(2000));
1153
+ expect(repairMessage.content).not.toContain("a".repeat(2001));
598
1154
  });
599
1155
 
600
- it("infers json_object for openai when messages mention json", async () => {
601
- // We can't easily test openai without mocking fetch, but we can verify
602
- // inference logic indirectly by checking mock receives correct options
603
- // when we use a test that goes through the inference path
1156
+ it("AC-15: does not repair when responseFormat is absent (invariant I-3)", async () => {
604
1157
  const provider: MockProvider = {
605
- chat: vi.fn().mockImplementation((opts: ChatOptions) => {
606
- // Capture the responseFormat that was inferred
607
- expect(opts.responseFormat).toBe("json_object");
608
- return Promise.resolve({
609
- content: { ok: true },
610
- usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
611
- });
1158
+ chat: vi.fn().mockRejectedValue(
1159
+ new ProviderJsonParseError("mock", "no-repair", "{bad"),
1160
+ ),
1161
+ };
1162
+ registerMockProvider(provider);
1163
+
1164
+ await expect(
1165
+ chat({
1166
+ provider: "mock",
1167
+ model: "no-repair",
1168
+ messages: baseMessages,
612
1169
  }),
1170
+ ).rejects.toBeInstanceOf(ProviderJsonParseError);
1171
+
1172
+ expect(provider.chat).toHaveBeenCalledTimes(1);
1173
+ });
1174
+
1175
+ it("AC-15: does not repair when responseFormat is non-JSON (invariant I-3)", async () => {
1176
+ const provider: MockProvider = {
1177
+ chat: vi.fn().mockRejectedValue(
1178
+ new ProviderJsonParseError("mock", "text-no-repair", "{bad"),
1179
+ ),
1180
+ };
1181
+ registerMockProvider(provider);
1182
+
1183
+ await expect(
1184
+ chat({
1185
+ provider: "mock",
1186
+ model: "text-no-repair",
1187
+ messages: baseMessages,
1188
+ responseFormat: "text",
1189
+ }),
1190
+ ).rejects.toBeInstanceOf(ProviderJsonParseError);
1191
+
1192
+ expect(provider.chat).toHaveBeenCalledTimes(1);
1193
+ });
1194
+
1195
+ it("AC-15: repair re-ask reuses the parent requestTimeoutMs (invariant I-4)", async () => {
1196
+ const provider: MockProvider = {
1197
+ chat: vi.fn()
1198
+ .mockRejectedValueOnce(
1199
+ new ProviderJsonParseError("mock", "repair-timeout", "{bad"),
1200
+ )
1201
+ .mockResolvedValueOnce({ content: { ok: true } }),
613
1202
  };
614
1203
  registerMockProvider(provider);
615
1204
 
616
- // Simulate by calling with mock (which is NOT in inference set)
617
- // Instead, test the inference logic by calling chat with openai —
618
- // but that would require mocking fetch. Let's verify via a unit check.
619
- // The actual inference happens in chat() before calling the adapter.
620
- // We'll test by checking that for openai provider, responseFormat gets inferred.
621
- // Since openai adapter will throw without a real API key, let's just verify the mock flow.
1205
+ await chat({
1206
+ provider: "mock",
1207
+ model: "repair-timeout",
1208
+ messages: baseMessages,
1209
+ responseFormat: "json",
1210
+ requestTimeoutMs: 42_000,
1211
+ });
622
1212
 
623
- // Test: when provider is in JSON_INFER_PROVIDERS and messages contain "json"
624
- // We can observe this because the options passed down should have responseFormat set.
625
- // For now, we test this indirectly through the mock by patching it to intercept.
1213
+ const calls = (provider.chat as ReturnType<typeof vi.fn>).mock.calls;
1214
+ const firstTimeout = (calls[0]![0] as ChatOptions).requestTimeoutMs;
1215
+ const secondTimeout = (calls[1]![0] as ChatOptions).requestTimeoutMs;
1216
+ expect(firstTimeout).toBe(42_000);
1217
+ expect(secondTimeout).toBe(42_000);
626
1218
  });
627
1219
  });
628
1220
 
@@ -744,6 +1336,7 @@ describe("LLM Gateway", () => {
744
1336
  completionTokens: estimateTokens("hello from opencode"),
745
1337
  totalTokens:
746
1338
  estimateTokens("Hello") + estimateTokens("hello from opencode"),
1339
+ source: "unavailable",
747
1340
  });
748
1341
  expect(completeEvents).toHaveLength(1);
749
1342
  expect(completeEvents[0]!.provider).toBe("opencode");
@@ -847,6 +1440,7 @@ describe("LLM Gateway", () => {
847
1440
  promptTokens: 150,
848
1441
  completionTokens: 80,
849
1442
  totalTokens: 230,
1443
+ source: "reported",
850
1444
  });
851
1445
  expect(completeEvents).toHaveLength(1);
852
1446
  expect(completeEvents[0]!.promptTokens).toBe(150);
@@ -855,4 +1449,61 @@ describe("LLM Gateway", () => {
855
1449
  expect(completeEvents[0]!.provider).toBe("opencode");
856
1450
  });
857
1451
  });
1452
+
1453
+ // ── LLM_DEBUG sink (AC-7, AC-8, AC-9) ────────────────────────────────
1454
+
1455
+ describe("LLM_DEBUG sink", () => {
1456
+ const path = join(tmpdir(), `pop-llm-debug-${process.pid}.log`);
1457
+ let originalLlmDebug: string | undefined;
1458
+
1459
+ beforeEach(() => {
1460
+ originalLlmDebug = process.env["LLM_DEBUG"];
1461
+ rmSync(path, { force: true });
1462
+ });
1463
+
1464
+ afterEach(() => {
1465
+ if (originalLlmDebug === undefined) {
1466
+ delete process.env["LLM_DEBUG"];
1467
+ } else {
1468
+ process.env["LLM_DEBUG"] = originalLlmDebug;
1469
+ }
1470
+ rmSync(path, { force: true });
1471
+ rmSync("/tmp/messages.log", { force: true });
1472
+ });
1473
+
1474
+ it("writes debug entry to a per-process tmpdir file when LLM_DEBUG is set", async () => {
1475
+ process.env["LLM_DEBUG"] = "1";
1476
+ rmSync(path, { force: true });
1477
+ rmSync("/tmp/messages.log", { force: true });
1478
+ registerMockProvider(makeMockProvider());
1479
+
1480
+ await chat({ provider: "mock", model: "test-model", messages: baseMessages });
1481
+
1482
+ expect(existsSync(path)).toBe(true);
1483
+ expect(existsSync("/tmp/messages.log")).toBe(false);
1484
+ });
1485
+
1486
+ it("repairs an existing broader-permission debug file to 0600", async () => {
1487
+ process.env["LLM_DEBUG"] = "1";
1488
+ rmSync(path, { force: true });
1489
+ writeFileSync(path, "stale\n", { mode: 0o644 });
1490
+ chmodSync(path, 0o644);
1491
+ registerMockProvider(makeMockProvider());
1492
+
1493
+ await chat({ provider: "mock", model: "test-model", messages: baseMessages });
1494
+
1495
+ const st = statSync(path);
1496
+ expect(st.mode & 0o777).toBe(0o600);
1497
+ });
1498
+
1499
+ it("does not write a debug file when LLM_DEBUG is unset", async () => {
1500
+ delete process.env["LLM_DEBUG"];
1501
+ rmSync(path, { force: true });
1502
+ registerMockProvider(makeMockProvider());
1503
+
1504
+ await chat({ provider: "mock", model: "test-model", messages: baseMessages });
1505
+
1506
+ expect(existsSync(path)).toBe(false);
1507
+ });
1508
+ });
858
1509
  });