@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,5 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
- import { ProviderJsonModeError, ProviderJsonParseError } from "../types.ts";
2
+ import { ProviderJsonParseError } from "../types.ts";
3
3
  import type { OpenAIOptions } from "../types.ts";
4
4
 
5
5
  // Use var to avoid TDZ — vi.mock is hoisted above let/const declarations
@@ -27,7 +27,7 @@ import { openaiChat, _resetClient } from "../openai.ts";
27
27
  */
28
28
  function makeStreamingChatCompletion(
29
29
  textChunks: string[],
30
- usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number },
30
+ usage?: Partial<{ prompt_tokens: number; completion_tokens: number; total_tokens: number }>,
31
31
  ): AsyncIterable<Record<string, unknown>> {
32
32
  const events: Record<string, unknown>[] = [];
33
33
 
@@ -53,11 +53,12 @@ function makeStreamingChatCompletion(
53
53
 
54
54
  /**
55
55
  * Creates an async iterable that yields streaming Responses API events.
56
+ * Pass `null` for `usage` to omit the usage block entirely; pass a partial
57
+ * object to simulate partial provider reporting.
56
58
  */
57
59
  function makeStreamingResponsesResult(
58
60
  textChunks: string[],
59
- inputTokens = 10,
60
- outputTokens = 20,
61
+ usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number } | null,
61
62
  ): AsyncIterable<Record<string, unknown>> {
62
63
  const events: Record<string, unknown>[] = [];
63
64
 
@@ -68,14 +69,19 @@ function makeStreamingResponsesResult(
68
69
  });
69
70
  }
70
71
 
72
+ const resolvedUsage =
73
+ usage === undefined
74
+ ? { input_tokens: 10, output_tokens: 20, total_tokens: 30 }
75
+ : usage;
76
+
77
+ const response: Record<string, unknown> = {};
78
+ if (resolvedUsage !== null) {
79
+ response["usage"] = resolvedUsage;
80
+ }
81
+
71
82
  events.push({
72
83
  type: "response.completed",
73
- response: {
74
- usage: {
75
- input_tokens: inputTokens,
76
- output_tokens: outputTokens,
77
- },
78
- },
84
+ response,
79
85
  });
80
86
 
81
87
  return {
@@ -224,19 +230,39 @@ describe("openaiChat", () => {
224
230
  expect(mockChatCompletionsCreate).toHaveBeenCalledTimes(1);
225
231
  });
226
232
 
227
- it("throws ProviderJsonModeError when responseFormat is invalid", async () => {
228
- await expect(
229
- openaiChat({
230
- ...baseOptions,
231
- responseFormat: "text",
232
- }),
233
- ).rejects.toThrow(ProviderJsonModeError);
233
+ it("returns plain text content when responseFormat is omitted (AC-13)", async () => {
234
+ mockChatCompletionsCreate.mockResolvedValue(
235
+ makeStreamingChatCompletion(["not json, just text"]),
236
+ );
237
+
238
+ const result = await openaiChat({
239
+ messages: [{ role: "user", content: "Give me json output please." }],
240
+ model: "gpt-4o",
241
+ });
242
+
243
+ expect(typeof result.content).toBe("string");
244
+ expect(result.content).toBe("not json, just text");
245
+ });
246
+
247
+ it("does not set response_format on Chat Completions when responseFormat is omitted even if the message says 'json' (AC-13)", async () => {
248
+ mockChatCompletionsCreate.mockResolvedValue(
249
+ makeStreamingChatCompletion(["ok"]),
250
+ );
251
+
252
+ await openaiChat({
253
+ messages: [{ role: "user", content: "Return JSON please." }],
254
+ model: "gpt-4o",
255
+ });
256
+
257
+ const callArgs = mockChatCompletionsCreate.mock.calls[0]![0] as Record<string, unknown>;
258
+ expect(callArgs["response_format"]).toBeUndefined();
234
259
  });
235
260
 
236
261
  it("throws ProviderJsonParseError for non-JSON text in JSON mode", async () => {
237
262
  const nonJsonText = "This is plain text, not JSON at all.";
263
+ const usage = { prompt_tokens: 10, completion_tokens: 6, total_tokens: 16 };
238
264
  mockChatCompletionsCreate.mockResolvedValue(
239
- makeStreamingChatCompletion([nonJsonText]),
265
+ makeStreamingChatCompletion([nonJsonText], usage),
240
266
  );
241
267
 
242
268
  try {
@@ -251,10 +277,12 @@ describe("openaiChat", () => {
251
277
  expect(parseErr.provider).toBe("openai");
252
278
  expect(parseErr.model).toBe("gpt-4o");
253
279
  expect(parseErr.sample).toBeTruthy();
280
+ expect(parseErr.text).toBe(nonJsonText);
281
+ expect(parseErr.usage).toEqual(usage);
254
282
  }
255
283
  });
256
284
 
257
- it("defaults to gpt-5-chat-latest model and json_object format", async () => {
285
+ it("defaults to gpt-5-chat-latest model", async () => {
258
286
  const jsonPayload = { default: true };
259
287
  mockResponsesCreate.mockResolvedValue(
260
288
  makeStreamingResponsesResult([JSON.stringify(jsonPayload)]),
@@ -262,6 +290,7 @@ describe("openaiChat", () => {
262
290
 
263
291
  await openaiChat({
264
292
  messages: baseOptions.messages,
293
+ responseFormat: "json_object",
265
294
  });
266
295
 
267
296
  // gpt-5-chat-latest matches /^gpt-5/i, so Responses API should be used
@@ -270,28 +299,19 @@ describe("openaiChat", () => {
270
299
  expect(callArgs["model"]).toBe("gpt-5-chat-latest");
271
300
  });
272
301
 
273
- it("estimates usage at ~4 chars/token when Responses API stream lacks usage", async () => {
274
- const jsonPayload = { estimated: true };
275
- const text = JSON.stringify(jsonPayload);
276
-
277
- // Streaming response that completes without a response.completed event carrying usage
278
- const noUsageStream: AsyncIterable<Record<string, unknown>> = {
279
- async *[Symbol.asyncIterator]() {
280
- yield { type: "response.output_text.delta", delta: text };
281
- // No response.completed event with usage
282
- },
283
- };
284
- mockResponsesCreate.mockResolvedValue(noUsageStream);
302
+ it("returns undefined usage when streaming Responses reports none", async () => {
303
+ const jsonPayload = { noUsage: true };
304
+ mockResponsesCreate.mockResolvedValue(
305
+ makeStreamingResponsesResult([JSON.stringify(jsonPayload)], null),
306
+ );
285
307
 
286
308
  const result = await openaiChat({
287
309
  ...baseOptions,
288
310
  model: "gpt-5",
289
311
  });
290
312
 
291
- // Estimation: ~4 chars/token
292
- expect(result.usage).toBeDefined();
293
- expect(result.usage!.prompt_tokens).toBeGreaterThan(0);
294
- expect(result.usage!.completion_tokens).toBeGreaterThan(0);
313
+ expect(result.content).toEqual(jsonPayload);
314
+ expect(result.usage).toBeUndefined();
295
315
  });
296
316
 
297
317
  it("retries on retryable errors and eventually succeeds", async () => {
@@ -372,48 +392,190 @@ describe("openaiChat", () => {
372
392
  );
373
393
  });
374
394
 
375
- it("creates separate client instances for different timeout values", async () => {
376
- mockChatCompletionsCreate.mockResolvedValue(
377
- makeStreamingChatCompletion([JSON.stringify({ ok: true })]),
378
- );
395
+ it("creates separate client instances for different timeout values", async () => {
396
+ mockChatCompletionsCreate.mockResolvedValue(
397
+ makeStreamingChatCompletion([JSON.stringify({ ok: true })]),
398
+ );
379
399
 
380
- await openaiChat({ ...baseOptions, model: "gpt-4o", requestTimeoutMs: 10_000 });
381
- await openaiChat({ ...baseOptions, model: "gpt-4o", requestTimeoutMs: 60_000 });
400
+ await openaiChat({ ...baseOptions, model: "gpt-4o", requestTimeoutMs: 10_000 });
401
+ await openaiChat({ ...baseOptions, model: "gpt-4o", requestTimeoutMs: 60_000 });
382
402
 
383
- // Two distinct timeout values should produce two client constructions
384
- const timeouts = MockOpenAI.mock.calls.map(
385
- (call: unknown[]) => (call[0] as { timeout: number }).timeout,
386
- );
387
- expect(timeouts).toContain(10_000);
388
- expect(timeouts).toContain(60_000);
389
- });
403
+ // Two distinct timeout values should produce two client constructions
404
+ const timeouts = MockOpenAI.mock.calls.map(
405
+ (call: unknown[]) => (call[0] as { timeout: number }).timeout,
406
+ );
407
+ expect(timeouts).toContain(10_000);
408
+ expect(timeouts).toContain(60_000);
409
+ });
390
410
 
391
- it("passes json_schema to the streaming Responses API when provided", async () => {
392
- mockResponsesCreate.mockResolvedValue(
393
- makeStreamingResponsesResult([JSON.stringify({ ok: true })]),
394
- );
411
+ it("passes json_schema to the streaming Responses API when provided", async () => {
412
+ mockResponsesCreate.mockResolvedValue(
413
+ makeStreamingResponsesResult([JSON.stringify({ ok: true })]),
414
+ );
395
415
 
396
- await openaiChat({
397
- ...baseOptions,
398
- model: "gpt-5",
399
- responseFormat: {
400
- json_schema: {
401
- type: "object",
402
- properties: { ok: { type: "boolean" } },
416
+ await openaiChat({
417
+ ...baseOptions,
418
+ model: "gpt-5",
419
+ responseFormat: {
420
+ json_schema: {
421
+ type: "object",
422
+ properties: { ok: { type: "boolean" } },
423
+ },
403
424
  },
404
- },
425
+ });
426
+
427
+ const callArgs = mockResponsesCreate.mock.calls[0]![0] as Record<string, unknown>;
428
+ expect(callArgs["text"]).toEqual({
429
+ format: {
430
+ type: "json_schema",
431
+ name: "Response",
432
+ schema: {
433
+ type: "object",
434
+ properties: { ok: { type: "boolean" } },
435
+ },
436
+ },
437
+ });
405
438
  });
406
439
 
407
- const callArgs = mockResponsesCreate.mock.calls[0]![0] as Record<string, unknown>;
408
- expect(callArgs["text"]).toEqual({
409
- format: {
440
+ it("passes native json_schema to Chat Completions API when a schema is supplied (AC-14)", async () => {
441
+ mockChatCompletionsCreate.mockResolvedValue(
442
+ makeStreamingChatCompletion([JSON.stringify({ ok: true })]),
443
+ );
444
+
445
+ const schema = {
446
+ type: "object",
447
+ properties: { ok: { type: "boolean" } },
448
+ };
449
+
450
+ await openaiChat({
451
+ messages: baseOptions.messages,
452
+ model: "gpt-4o",
453
+ responseFormat: { json_schema: schema },
454
+ });
455
+
456
+ const callArgs = mockChatCompletionsCreate.mock.calls[0]![0] as Record<string, unknown>;
457
+ expect(callArgs["response_format"]).toEqual({
410
458
  type: "json_schema",
411
- name: "Response",
412
- schema: {
413
- type: "object",
414
- properties: { ok: { type: "boolean" } },
459
+ json_schema: { name: "Response", schema },
460
+ });
461
+ });
462
+ });
463
+
464
+ describe("requestTimeoutMs (AC-12)", () => {
465
+ const TIMEOUT_MS = 1_000;
466
+
467
+ beforeEach(() => {
468
+ vi.useFakeTimers();
469
+ _resetClient();
470
+ mockResponsesCreate.mockReset();
471
+ mockChatCompletionsCreate.mockReset();
472
+ process.env["OPENAI_API_KEY"] = "test-key";
473
+ });
474
+
475
+ afterEach(() => {
476
+ vi.useRealTimers();
477
+ _resetClient();
478
+ delete process.env["OPENAI_API_KEY"];
479
+ });
480
+
481
+ /**
482
+ * Returns a stalled async iterable (OpenAI SDK stream shape) that rejects
483
+ * when `signal` aborts. The signal is passed inline in the SDK call's
484
+ * first argument (`{ ..., signal }`), not as a separate options arg.
485
+ */
486
+ function stalledAsyncIterable(
487
+ signal: AbortSignal | undefined,
488
+ ): AsyncIterable<Record<string, unknown>> {
489
+ return {
490
+ [Symbol.asyncIterator]() {
491
+ return {
492
+ next(): Promise<IteratorResult<Record<string, unknown>>> {
493
+ return new Promise((_resolve, reject) => {
494
+ if (!signal) {
495
+ reject(new Error("signal missing"));
496
+ return;
497
+ }
498
+ signal.addEventListener(
499
+ "abort",
500
+ () => {
501
+ const reason = signal.reason;
502
+ reject(
503
+ reason instanceof Error
504
+ ? reason
505
+ : new DOMException("The user aborted a request.", "AbortError"),
506
+ );
507
+ },
508
+ { once: true },
509
+ );
510
+ });
511
+ },
512
+ };
513
+ },
514
+ };
515
+ }
516
+
517
+ it("applies requestTimeoutMs to the Chat Completions idle abort", async () => {
518
+ mockChatCompletionsCreate.mockImplementation((params: { signal?: AbortSignal }) =>
519
+ Promise.resolve(stalledAsyncIterable(params.signal)),
520
+ );
521
+
522
+ const promise = openaiChat({
523
+ ...baseOptions,
524
+ model: "gpt-4o",
525
+ requestTimeoutMs: TIMEOUT_MS,
526
+ maxRetries: 0,
527
+ });
528
+
529
+ let state: "pending" | "resolved" | "rejected" = "pending";
530
+ promise.then(
531
+ () => {
532
+ state = "resolved";
533
+ },
534
+ () => {
535
+ state = "rejected";
536
+ },
537
+ );
538
+
539
+ vi.advanceTimersByTime(TIMEOUT_MS - 1);
540
+ for (let i = 0; i < 10; i++) await Promise.resolve();
541
+ expect(state).toBe("pending");
542
+
543
+ vi.advanceTimersByTime(1);
544
+ for (let i = 0; i < 50; i++) await Promise.resolve();
545
+
546
+ expect(state).toBe("rejected");
547
+ });
548
+
549
+ it("applies requestTimeoutMs to the Responses API idle abort", async () => {
550
+ mockResponsesCreate.mockImplementation((params: { signal?: AbortSignal }) =>
551
+ Promise.resolve(stalledAsyncIterable(params.signal)),
552
+ );
553
+
554
+ const promise = openaiChat({
555
+ ...baseOptions,
556
+ model: "gpt-5",
557
+ requestTimeoutMs: TIMEOUT_MS,
558
+ maxRetries: 0,
559
+ });
560
+
561
+ let state: "pending" | "resolved" | "rejected" = "pending";
562
+ promise.then(
563
+ () => {
564
+ state = "resolved";
415
565
  },
416
- },
566
+ () => {
567
+ state = "rejected";
568
+ },
569
+ );
570
+
571
+ vi.advanceTimersByTime(TIMEOUT_MS - 1);
572
+ for (let i = 0; i < 10; i++) await Promise.resolve();
573
+ expect(state).toBe("pending");
574
+
575
+ vi.advanceTimersByTime(1);
576
+ for (let i = 0; i < 50; i++) await Promise.resolve();
577
+
578
+ expect(state).toBe("rejected");
417
579
  });
418
580
  });
419
581
 
@@ -435,7 +597,11 @@ describe("openaiChat", () => {
435
597
 
436
598
  it("accumulates text across multiple streaming chunks for Responses API", async () => {
437
599
  mockResponsesCreate.mockResolvedValue(
438
- makeStreamingResponsesResult(['{"he', 'llo":"world"}'], 10, 5),
600
+ makeStreamingResponsesResult(['{"he', 'llo":"world"}'], {
601
+ input_tokens: 10,
602
+ output_tokens: 5,
603
+ total_tokens: 15,
604
+ }),
439
605
  );
440
606
 
441
607
  const result = await openaiChat({
@@ -451,7 +617,7 @@ describe("openaiChat", () => {
451
617
  });
452
618
  });
453
619
 
454
- it("defaults usage to zeros when Chat Completions stream has no usage", async () => {
620
+ it("returns undefined usage when streaming Chat Completions reports none", async () => {
455
621
  mockChatCompletionsCreate.mockResolvedValue(
456
622
  makeStreamingChatCompletion([JSON.stringify({ ok: true })]),
457
623
  );
@@ -461,11 +627,33 @@ describe("openaiChat", () => {
461
627
  model: "gpt-4o",
462
628
  });
463
629
 
464
- expect(result.usage).toEqual({
465
- prompt_tokens: 0,
466
- completion_tokens: 0,
467
- total_tokens: 0,
630
+ expect(result.usage).toBeUndefined();
631
+ });
632
+
633
+ it("preserves partial Chat Completions usage, passing through only reported fields", async () => {
634
+ mockChatCompletionsCreate.mockResolvedValue(
635
+ makeStreamingChatCompletion([JSON.stringify({ ok: true })], { prompt_tokens: 7 }),
636
+ );
637
+
638
+ const result = await openaiChat({
639
+ ...baseOptions,
640
+ model: "gpt-4o",
468
641
  });
642
+
643
+ expect(result.usage).toBeDefined();
644
+ expect(result.usage!.prompt_tokens).toBe(7);
645
+ expect(result.usage!.completion_tokens).toBeNull();
646
+ expect(result.usage!.total_tokens).toBeNull();
469
647
  });
470
648
  });
649
+
650
+ describe("openai adapter source hygiene (AC-1)", () => {
651
+ it("contains no inline usage estimate or zero-fill fallback", async () => {
652
+ const source = await Bun.file("src/providers/openai.ts").text();
653
+
654
+ expect(source).not.toContain("length / 4");
655
+ expect(source).not.toContain(
656
+ "{ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }",
657
+ );
658
+ });
471
659
  });
@@ -702,6 +702,12 @@ describe("opencodeChat", () => {
702
702
  const e = err as ProviderJsonParseError;
703
703
  expect(e.provider).toBe("opencode");
704
704
  expect(e.model).toBe("anthropic/claude-sonnet-4-5");
705
+ expect(e.text).toBe("not json at all");
706
+ expect(e.usage).toEqual({
707
+ prompt_tokens: 10,
708
+ completion_tokens: 5,
709
+ total_tokens: 15,
710
+ });
705
711
  }
706
712
  });
707
713
 
@@ -1279,6 +1285,77 @@ describe("opencodeChat", () => {
1279
1285
  vi.restoreAllMocks();
1280
1286
  });
1281
1287
  });
1288
+
1289
+ describe("usage handling", () => {
1290
+ it("returns no usage in CLI mode and leaves usageAvailable unset", async () => {
1291
+ const mockExit = Promise.resolve(0);
1292
+ const mockStdout = new ReadableStream({
1293
+ start(controller) {
1294
+ controller.enqueue(
1295
+ new TextEncoder().encode(
1296
+ '{"type":"text","part":{"text":"hi"}}\n',
1297
+ ),
1298
+ );
1299
+ controller.close();
1300
+ },
1301
+ });
1302
+ const mockStderr = new ReadableStream({
1303
+ start(controller) {
1304
+ controller.close();
1305
+ },
1306
+ });
1307
+
1308
+ vi.spyOn(Bun, "spawn").mockReturnValue({
1309
+ stdout: mockStdout,
1310
+ stderr: mockStderr,
1311
+ exited: mockExit,
1312
+ exitCode: 0,
1313
+ kill: vi.fn(),
1314
+ } as unknown as ReturnType<typeof Bun.spawn>);
1315
+
1316
+ const result = await opencodeChat({
1317
+ messages: baseMessages,
1318
+ responseFormat: "text",
1319
+ });
1320
+
1321
+ expect(result.usage).toBeUndefined();
1322
+ expect(result.usageAvailable).toBeUndefined();
1323
+
1324
+ vi.restoreAllMocks();
1325
+ });
1326
+
1327
+ it("passes SDK-reported usage through unchanged", async () => {
1328
+ const result = await opencodeChat({
1329
+ messages: baseMessages,
1330
+ opencode: { baseUrl: "http://localhost:3000" },
1331
+ });
1332
+
1333
+ expect(result.usage).toEqual({
1334
+ prompt_tokens: 10,
1335
+ completion_tokens: 5,
1336
+ total_tokens: 15,
1337
+ });
1338
+ expect(result.usageAvailable).toBeUndefined();
1339
+ });
1340
+
1341
+ it("returns undefined usage when SDK reports no tokens", async () => {
1342
+ mockPrompt.mockResolvedValueOnce({
1343
+ data: {
1344
+ info: { ...defaultPromptData.info, tokens: undefined },
1345
+ parts: [{ type: "text", text: "hi" }],
1346
+ },
1347
+ error: undefined,
1348
+ });
1349
+
1350
+ const result = await opencodeChat({
1351
+ messages: baseMessages,
1352
+ opencode: { baseUrl: "http://localhost:3000" },
1353
+ });
1354
+
1355
+ expect(result.usage).toBeUndefined();
1356
+ expect(result.usageAvailable).toBeUndefined();
1357
+ });
1358
+ });
1282
1359
  });
1283
1360
 
1284
1361
  describe("isOpenCodeAvailable", () => {