@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
@@ -0,0 +1,226 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { Mock } from "vitest";
3
+
4
+ import { anthropicChat } from "../anthropic.ts";
5
+ import { geminiChat } from "../gemini.ts";
6
+ import { deepseekChat } from "../deepseek.ts";
7
+ import { moonshotChat } from "../moonshot.ts";
8
+ import { zaiChat } from "../zhipu.ts";
9
+ import { alibabaChat } from "../alibaba.ts";
10
+
11
+ /**
12
+ * Returns a stalled ReadableStream that errors when `signal` aborts. Models a
13
+ * provider whose connection is open but never produces a chunk — exactly the
14
+ * condition the IdleTimeoutController is supposed to catch.
15
+ */
16
+ function stalledBody(signal: AbortSignal | undefined): ReadableStream<Uint8Array> {
17
+ return new ReadableStream<Uint8Array>({
18
+ start(controller) {
19
+ if (!signal) return;
20
+ signal.addEventListener(
21
+ "abort",
22
+ () => {
23
+ const reason = signal.reason;
24
+ controller.error(
25
+ reason instanceof Error
26
+ ? reason
27
+ : new DOMException("The user aborted a request.", "AbortError"),
28
+ );
29
+ },
30
+ { once: true },
31
+ );
32
+ },
33
+ });
34
+ }
35
+
36
+ const TIMEOUT_MS = 1_000;
37
+
38
+ const baseMessages = [
39
+ { role: "system" as const, content: "You are helpful." },
40
+ { role: "user" as const, content: "Return JSON." },
41
+ ];
42
+
43
+ /**
44
+ * Drives fake timers forward by exactly `requestTimeoutMs` and asserts the
45
+ * adapter's promise rejects. The 1ms-step split is what catches a hardcoded
46
+ * `DEFAULT_REQUEST_TIMEOUT_MS` (3_600_000): advancing by `TIMEOUT_MS - 1` must
47
+ * leave the promise pending, then crossing the threshold must reject it.
48
+ *
49
+ * The promise is never awaited directly — doing so would hang if the adapter
50
+ * ignored `requestTimeoutMs`. Instead we attach .then handlers and drain
51
+ * microtasks synchronously.
52
+ */
53
+ async function expectRejectsAtTimeout(promise: Promise<unknown>): Promise<void> {
54
+ let state: "pending" | "resolved" | "rejected" = "pending";
55
+ promise.then(
56
+ () => {
57
+ state = "resolved";
58
+ },
59
+ () => {
60
+ state = "rejected";
61
+ },
62
+ );
63
+
64
+ vi.advanceTimersByTime(TIMEOUT_MS - 1);
65
+ for (let i = 0; i < 10; i++) await Promise.resolve();
66
+ if (state !== "pending") {
67
+ throw new Error(
68
+ `adapter settled before ${TIMEOUT_MS}ms (state=${state}); expected pending`,
69
+ );
70
+ }
71
+
72
+ vi.advanceTimersByTime(1);
73
+ for (let i = 0; i < 50; i++) await Promise.resolve();
74
+
75
+ if (state === "pending") {
76
+ throw new Error(
77
+ `adapter did not apply requestTimeoutMs=${TIMEOUT_MS} (promise still pending after threshold)`,
78
+ );
79
+ }
80
+ if (state === "resolved") {
81
+ throw new Error("adapter resolved; expected rejection at requestTimeoutMs");
82
+ }
83
+ }
84
+
85
+ describe("requestTimeoutMs contract (AC-12)", () => {
86
+ describe("fetch-based adapters", () => {
87
+ let originalFetch: typeof globalThis.fetch;
88
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
89
+ let fetchMock: Mock<(...args: any[]) => any>;
90
+ let capturedSignal: AbortSignal | undefined;
91
+
92
+ beforeEach(() => {
93
+ vi.useFakeTimers();
94
+ originalFetch = globalThis.fetch;
95
+ fetchMock = vi.fn();
96
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
97
+ capturedSignal = undefined;
98
+ process.env["ANTHROPIC_API_KEY"] = "test-key";
99
+ process.env["GEMINI_API_KEY"] = "test-key";
100
+ process.env["DEEPSEEK_API_KEY"] = "test-key";
101
+ process.env["MOONSHOT_API_KEY"] = "test-key";
102
+ process.env["ZAI_API_KEY"] = "test-key";
103
+ process.env["ALIBABA_API_KEY"] = "test-key";
104
+ });
105
+
106
+ afterEach(() => {
107
+ vi.useRealTimers();
108
+ globalThis.fetch = originalFetch;
109
+ delete process.env["ANTHROPIC_API_KEY"];
110
+ delete process.env["GEMINI_API_KEY"];
111
+ delete process.env["DEEPSEEK_API_KEY"];
112
+ delete process.env["MOONSHOT_API_KEY"];
113
+ delete process.env["ZAI_API_KEY"];
114
+ delete process.env["ALIBABA_API_KEY"];
115
+ });
116
+
117
+ function installStalledFetch(): void {
118
+ fetchMock.mockImplementation(
119
+ (_url: string, init?: RequestInit) => {
120
+ const signal = init?.signal ?? undefined;
121
+ capturedSignal = signal;
122
+ return Promise.resolve({
123
+ ok: true,
124
+ status: 200,
125
+ body: stalledBody(signal),
126
+ } as unknown as Response);
127
+ },
128
+ );
129
+ }
130
+
131
+ it("anthropic applies requestTimeoutMs to the idle abort", async () => {
132
+ installStalledFetch();
133
+
134
+ await expectRejectsAtTimeout(
135
+ anthropicChat({
136
+ messages: [...baseMessages],
137
+ responseFormat: "json",
138
+ requestTimeoutMs: TIMEOUT_MS,
139
+ maxRetries: 0,
140
+ }),
141
+ );
142
+
143
+ expect(capturedSignal).toBeInstanceOf(AbortSignal);
144
+ expect(capturedSignal?.aborted).toBe(true);
145
+ });
146
+
147
+ it("gemini applies requestTimeoutMs to the idle abort", async () => {
148
+ installStalledFetch();
149
+
150
+ await expectRejectsAtTimeout(
151
+ geminiChat({
152
+ messages: [...baseMessages],
153
+ responseFormat: "json",
154
+ requestTimeoutMs: TIMEOUT_MS,
155
+ maxRetries: 0,
156
+ }),
157
+ );
158
+
159
+ expect(capturedSignal).toBeInstanceOf(AbortSignal);
160
+ expect(capturedSignal?.aborted).toBe(true);
161
+ });
162
+
163
+ it("deepseek applies requestTimeoutMs to the idle abort", async () => {
164
+ installStalledFetch();
165
+
166
+ await expectRejectsAtTimeout(
167
+ deepseekChat({
168
+ messages: [...baseMessages],
169
+ responseFormat: "json",
170
+ requestTimeoutMs: TIMEOUT_MS,
171
+ maxRetries: 0,
172
+ }),
173
+ );
174
+
175
+ expect(capturedSignal).toBeInstanceOf(AbortSignal);
176
+ expect(capturedSignal?.aborted).toBe(true);
177
+ });
178
+
179
+ it("moonshot applies requestTimeoutMs to the idle abort", async () => {
180
+ installStalledFetch();
181
+
182
+ await expectRejectsAtTimeout(
183
+ moonshotChat({
184
+ messages: [...baseMessages],
185
+ requestTimeoutMs: TIMEOUT_MS,
186
+ maxRetries: 0,
187
+ }),
188
+ );
189
+
190
+ expect(capturedSignal).toBeInstanceOf(AbortSignal);
191
+ expect(capturedSignal?.aborted).toBe(true);
192
+ });
193
+
194
+ it("zhipu (zaiChat) applies requestTimeoutMs to the idle abort", async () => {
195
+ installStalledFetch();
196
+
197
+ await expectRejectsAtTimeout(
198
+ zaiChat({
199
+ messages: [...baseMessages],
200
+ responseFormat: "json",
201
+ requestTimeoutMs: TIMEOUT_MS,
202
+ maxRetries: 0,
203
+ }),
204
+ );
205
+
206
+ expect(capturedSignal).toBeInstanceOf(AbortSignal);
207
+ expect(capturedSignal?.aborted).toBe(true);
208
+ });
209
+
210
+ it("alibaba applies requestTimeoutMs to the idle abort", async () => {
211
+ installStalledFetch();
212
+
213
+ await expectRejectsAtTimeout(
214
+ alibabaChat({
215
+ messages: [...baseMessages],
216
+ responseFormat: "json",
217
+ requestTimeoutMs: TIMEOUT_MS,
218
+ maxRetries: 0,
219
+ }),
220
+ );
221
+
222
+ expect(capturedSignal).toBeInstanceOf(AbortSignal);
223
+ expect(capturedSignal?.aborted).toBe(true);
224
+ });
225
+ });
226
+ });
@@ -171,6 +171,36 @@ describe("parseOpenAiSse", () => {
171
171
  expect(deltas[0]!.done).toBe(true);
172
172
  });
173
173
 
174
+ it("preserves partial usage fields from terminal chunks", async () => {
175
+ const frames = framesToAsyncIterable([
176
+ {
177
+ event: "",
178
+ data: JSON.stringify({
179
+ choices: [{ delta: { content: "" }, finish_reason: "stop" }],
180
+ usage: { prompt_tokens: 10 },
181
+ }),
182
+ },
183
+ { event: "", data: "[DONE]" },
184
+ ]);
185
+ const deltas = await collectDeltas(parseOpenAiSse(frames));
186
+ expect(deltas[0]!.usage).toEqual({ prompt_tokens: 10 });
187
+ });
188
+
189
+ it("preserves total-only usage from terminal chunks", async () => {
190
+ const frames = framesToAsyncIterable([
191
+ {
192
+ event: "",
193
+ data: JSON.stringify({
194
+ choices: [{ delta: { content: "" }, finish_reason: "stop" }],
195
+ usage: { total_tokens: 30 },
196
+ }),
197
+ },
198
+ { event: "", data: "[DONE]" },
199
+ ]);
200
+ const deltas = await collectDeltas(parseOpenAiSse(frames));
201
+ expect(deltas[0]!.usage).toEqual({ total_tokens: 30 });
202
+ });
203
+
174
204
  it("handles chunks with no content in delta (empty string)", async () => {
175
205
  const frames = framesToAsyncIterable([
176
206
  { event: "", data: '{"choices":[{"delta":{}}]}' },
@@ -338,6 +368,28 @@ describe("parseGeminiSse", () => {
338
368
  });
339
369
  });
340
370
 
371
+ it("preserves partial usageMetadata without zero-filling absent fields", async () => {
372
+ const stream = makeStream([
373
+ "data: " +
374
+ JSON.stringify({
375
+ candidates: [
376
+ {
377
+ content: { parts: [{ text: "done" }] },
378
+ finishReason: "STOP",
379
+ },
380
+ ],
381
+ usageMetadata: {
382
+ promptTokenCount: 5,
383
+ },
384
+ }) +
385
+ "\n\n",
386
+ ]);
387
+ const deltas = await collectDeltas(parseGeminiSse(stream));
388
+ const final = deltas[deltas.length - 1]!;
389
+ expect(final.done).toBe(true);
390
+ expect(final.usage).toEqual({ prompt_tokens: 5 });
391
+ });
392
+
341
393
  it("handles multi-part candidates", async () => {
342
394
  const stream = makeStream([
343
395
  "data: " +
@@ -3,6 +3,9 @@ import {
3
3
  ProviderJsonModeError,
4
4
  ProviderJsonParseError,
5
5
  type ChatOptions,
6
+ type LLMRequestCompleteEvent,
7
+ type LLMRequestErrorEvent,
8
+ type NormalizedUsage,
6
9
  type OpenCodeOptions,
7
10
  type OpenCodePermissionAction,
8
11
  type OpenCodePermissionConfig,
@@ -10,6 +13,7 @@ import {
10
13
  type OpenCodePermissionName,
11
14
  type OpenCodePermissionRule,
12
15
  type OpenCodeRequestConfig,
16
+ type UsageSource,
13
17
  } from "../types.ts";
14
18
 
15
19
  describe("ProviderJsonModeError", () => {
@@ -87,6 +91,24 @@ describe("ProviderJsonParseError", () => {
87
91
  expect(err.sample).toBe("raw text");
88
92
  });
89
93
 
94
+ it("can carry failed JSON response usage for gateway repair accounting", () => {
95
+ const usage = { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 };
96
+ const err = new ProviderJsonParseError(
97
+ "openai",
98
+ "gpt-5",
99
+ "raw text",
100
+ undefined,
101
+ {
102
+ text: "raw text",
103
+ usage,
104
+ usageAvailable: true,
105
+ },
106
+ );
107
+ expect(err.text).toBe("raw text");
108
+ expect(err.usage).toBe(usage);
109
+ expect(err.usageAvailable).toBe(true);
110
+ });
111
+
90
112
  it("has name set to ProviderJsonParseError", () => {
91
113
  const err = new ProviderJsonParseError("mock", "mock-model", "sample");
92
114
  expect(err.name).toBe("ProviderJsonParseError");
@@ -169,3 +191,66 @@ describe("OpenCode types", () => {
169
191
  expect(keys).toHaveLength(15);
170
192
  });
171
193
  });
194
+
195
+ describe("usage telemetry contracts", () => {
196
+ it("NormalizedUsage requires a source field", () => {
197
+ const usage: NormalizedUsage = {
198
+ promptTokens: 100,
199
+ completionTokens: 50,
200
+ totalTokens: 150,
201
+ source: "reported",
202
+ };
203
+ expect(usage.source).toBe("reported");
204
+ });
205
+
206
+ it("UsageSource accepts all four classification values", () => {
207
+ const sources: UsageSource[] = [
208
+ "reported",
209
+ "estimated",
210
+ "unavailable",
211
+ "zero",
212
+ ];
213
+ expect(sources).toHaveLength(4);
214
+ });
215
+
216
+ it("LLMRequestCompleteEvent exposes usageSource and costEstimated", () => {
217
+ const event: LLMRequestCompleteEvent = {
218
+ id: "llm-1",
219
+ provider: "openai",
220
+ model: "gpt-5",
221
+ metadata: {},
222
+ timestamp: new Date().toISOString(),
223
+ duration: 42,
224
+ promptTokens: 10,
225
+ completionTokens: 20,
226
+ totalTokens: 30,
227
+ cost: 0.001,
228
+ usageSource: "reported",
229
+ costEstimated: false,
230
+ };
231
+ expect(event.usageSource).toBe("reported");
232
+ expect(event.costEstimated).toBe(false);
233
+ });
234
+
235
+ it("LLMRequestErrorEvent exposes usage, cost, usageSource, and costEstimated", () => {
236
+ const event: LLMRequestErrorEvent = {
237
+ id: "llm-2",
238
+ provider: "anthropic",
239
+ model: "claude-sonnet-4-6",
240
+ metadata: {},
241
+ timestamp: new Date().toISOString(),
242
+ duration: 5,
243
+ error: "rate limited",
244
+ promptTokens: 15,
245
+ completionTokens: 0,
246
+ totalTokens: 15,
247
+ cost: 0.000045,
248
+ usageSource: "estimated",
249
+ costEstimated: true,
250
+ };
251
+ expect(event.promptTokens).toBe(15);
252
+ expect(event.cost).toBe(0.000045);
253
+ expect(event.usageSource).toBe("estimated");
254
+ expect(event.costEstimated).toBe(true);
255
+ });
256
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Glob } from "bun";
3
+
4
+ const FORBIDDEN_PATTERNS = [
5
+ {
6
+ name: "ZERO_USAGE constant",
7
+ regex: /ZERO_USAGE/,
8
+ regression: "const ZERO_USAGE = { prompt_tokens: 0 }",
9
+ },
10
+ {
11
+ name: "nullish-coalesced zero-fill usage object (?? { <tokens>: 0 ... })",
12
+ regex: /\?\?\s*\{\s*(?:prompt|completion|total)_tokens\s*:\s*0/,
13
+ regression: "return { usage: raw ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } };",
14
+ },
15
+ {
16
+ name: "nullish-coalesced zero on a usage field (<tokens> ?? 0)",
17
+ regex: /(?:prompt|completion|total|input|output)_tokens\s*\?\?\s*0/,
18
+ regression: "prompt_tokens: chunk.usage?.prompt_tokens ?? 0,",
19
+ },
20
+ ] as const;
21
+
22
+ async function listAdapterSources(): Promise<string[]> {
23
+ const files: string[] = [];
24
+ for await (const path of new Glob("src/providers/**/*.ts").scan()) {
25
+ if (path.includes("__tests__")) continue;
26
+ files.push(path);
27
+ }
28
+ return files.sort();
29
+ }
30
+
31
+ describe("adapter zero-fill guard (AC-1, invariant I-1)", () => {
32
+ it("forbidden patterns match representative zero-fill snippets (self-test)", () => {
33
+ for (const { name, regex, regression } of FORBIDDEN_PATTERNS) {
34
+ expect(regex.test(regression), `${name} should match: ${regression}`).toBe(true);
35
+ }
36
+ });
37
+
38
+ it("no adapter source under src/providers/ contains a forbidden zero-fill pattern", async () => {
39
+ const files = await listAdapterSources();
40
+ expect(files.length).toBeGreaterThan(0);
41
+
42
+ const offenders: string[] = [];
43
+ for (const path of files) {
44
+ const text = await Bun.file(path).text();
45
+ for (const { name, regex } of FORBIDDEN_PATTERNS) {
46
+ if (regex.test(text)) offenders.push(`${path}: ${name}`);
47
+ }
48
+ }
49
+ expect(offenders, `zero-fill patterns returned:\n${offenders.join("\n")}`).toEqual([]);
50
+ });
51
+
52
+ it("src/providers/**/*.ts does not reference usageAvailable except types.ts (invariant I-1, C-2)", async () => {
53
+ const files = await listAdapterSources();
54
+ const offenders: string[] = [];
55
+ for (const path of files) {
56
+ if (path.endsWith("types.ts")) continue;
57
+ const text = await Bun.file(path).text();
58
+ if (text.includes("usageAvailable")) offenders.push(path);
59
+ }
60
+ expect(offenders).toEqual([]);
61
+ });
62
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import { zaiChat, zhipuChat } from "../zhipu.ts";
3
- import { ProviderJsonModeError, ProviderJsonParseError } from "../types.ts";
3
+ import { ProviderJsonParseError } from "../types.ts";
4
4
  import type { ProviderOptions } from "../types.ts";
5
5
  import type { Mock } from "vitest";
6
6
 
@@ -272,17 +272,13 @@ describe("zhipuChat", () => {
272
272
  });
273
273
  });
274
274
 
275
- it("handles missing usage by defaulting to zeros", async () => {
275
+ it("returns undefined usage when accumulated stream reports none", async () => {
276
276
  const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
277
277
  fetchMock.mockResolvedValue(mockStreamingResponse(events));
278
278
 
279
279
  const result = await zhipuChat(baseOptions);
280
280
 
281
- expect(result.usage).toEqual({
282
- prompt_tokens: 0,
283
- completion_tokens: 0,
284
- total_tokens: 0,
285
- });
281
+ expect(result.usage).toBeUndefined();
286
282
  });
287
283
 
288
284
  it("throws immediately on 401 without retrying", async () => {
@@ -315,13 +311,30 @@ describe("zhipuChat", () => {
315
311
  expect(result.content).toEqual(jsonPayload);
316
312
  });
317
313
 
318
- it("throws ProviderJsonModeError when responseFormat is invalid", async () => {
319
- await expect(
320
- zhipuChat({
321
- ...baseOptions,
322
- responseFormat: "text",
323
- }),
324
- ).rejects.toThrow(ProviderJsonModeError);
314
+ it("returns plain text content when responseFormat is omitted (AC-13)", async () => {
315
+ const events = makeOpenAiSseEvents(["not json, just text"]);
316
+ fetchMock.mockResolvedValue(mockStreamingResponse(events));
317
+
318
+ const result = await zhipuChat({
319
+ messages: [{ role: "user", content: "Give me json output please." }],
320
+ });
321
+
322
+ expect(typeof result.content).toBe("string");
323
+ expect(result.content).toBe("not json, just text");
324
+ });
325
+
326
+ it("does not set response_format when responseFormat is omitted even if the message says 'json' (AC-13)", async () => {
327
+ const events = makeOpenAiSseEvents(["ok"]);
328
+ fetchMock.mockResolvedValue(mockStreamingResponse(events));
329
+
330
+ await zhipuChat({
331
+ messages: [{ role: "user", content: "Return JSON please." }],
332
+ });
333
+
334
+ const body = JSON.parse(
335
+ (fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string,
336
+ );
337
+ expect(body.response_format).toBeUndefined();
325
338
  });
326
339
 
327
340
  it("throws ProviderJsonParseError for non-JSON text in JSON mode", async () => {
@@ -6,6 +6,7 @@ import {
6
6
  extractMessages,
7
7
  ensureMessagesPresent,
8
8
  ensureJsonResponseFormat,
9
+ isJsonResponseFormat,
9
10
  isRetryableError,
10
11
  sleep,
11
12
  stripMarkdownFences,
@@ -22,7 +23,6 @@ import { ProviderJsonParseError } from "./types.ts";
22
23
  import type {
23
24
  AlibabaOptions,
24
25
  AdapterResponse,
25
- ResponseFormatObject,
26
26
  } from "./types.ts";
27
27
 
28
28
  const DEFAULT_MODEL = "qwen-plus";
@@ -30,23 +30,6 @@ const DEFAULT_TEMPERATURE = 0.7;
30
30
  const DEFAULT_THINKING: "enabled" | "disabled" = "enabled";
31
31
  const DEFAULT_MAX_RETRIES = 3;
32
32
 
33
- /**
34
- * Determines whether the response format indicates JSON mode.
35
- * Returns true for "json", "json_object", { type: "json_object" }, { json_schema: ... }.
36
- */
37
- function isJsonMode(
38
- responseFormat: string | ResponseFormatObject | undefined,
39
- ): boolean {
40
- if (!responseFormat) return false;
41
- if (typeof responseFormat === "string") {
42
- return responseFormat === "json" || responseFormat === "json_object";
43
- }
44
- return (
45
- responseFormat.type === "json_object" ||
46
- responseFormat.json_schema != null
47
- );
48
- }
49
-
50
33
  /**
51
34
  * Checks whether an error indicates response_format is unsupported in
52
35
  * streaming mode.
@@ -100,9 +83,9 @@ async function fetchNonStreaming(
100
83
  const data = (await response.json()) as {
101
84
  choices: Array<{ message: { content: string } }>;
102
85
  usage?: {
103
- prompt_tokens: number;
104
- completion_tokens: number;
105
- total_tokens: number;
86
+ prompt_tokens?: number | null;
87
+ completion_tokens?: number | null;
88
+ total_tokens?: number | null;
106
89
  };
107
90
  };
108
91
 
@@ -117,9 +100,9 @@ function parseResponseData(
117
100
  data: {
118
101
  choices: Array<{ message: { content: string } }>;
119
102
  usage?: {
120
- prompt_tokens: number;
121
- completion_tokens: number;
122
- total_tokens: number;
103
+ prompt_tokens?: number | null;
104
+ completion_tokens?: number | null;
105
+ total_tokens?: number | null;
123
106
  };
124
107
  },
125
108
  jsonMode: boolean,
@@ -134,22 +117,21 @@ function parseResponseData(
134
117
  "alibaba",
135
118
  model,
136
119
  parsed.slice(0, 200),
120
+ undefined,
121
+ {
122
+ text: rawText,
123
+ usage: data.usage,
124
+ },
137
125
  );
138
126
  }
139
127
 
140
- const usage = data.usage ?? {
141
- prompt_tokens: 0,
142
- completion_tokens: 0,
143
- total_tokens: 0,
144
- };
145
-
146
128
  return {
147
129
  content:
148
130
  typeof parsed === "string"
149
131
  ? parsed
150
132
  : (parsed as Record<string, unknown>),
151
133
  text: rawText,
152
- usage,
134
+ usage: data.usage,
153
135
  raw: data,
154
136
  };
155
137
  }
@@ -174,7 +156,7 @@ export async function alibabaChat(
174
156
 
175
157
  ensureMessagesPresent(messages, "alibaba");
176
158
 
177
- const jsonMode = isJsonMode(responseFormat);
159
+ const jsonMode = isJsonResponseFormat(responseFormat);
178
160
 
179
161
  const { systemMsg, userMessages, assistantMessages } =
180
162
  extractMessages(messages);
@@ -270,22 +252,21 @@ export async function alibabaChat(
270
252
  "alibaba",
271
253
  model,
272
254
  parsed.slice(0, 200),
255
+ undefined,
256
+ {
257
+ text: rawText,
258
+ usage: accumulated.usage,
259
+ },
273
260
  );
274
261
  }
275
262
 
276
- const usage = accumulated.usage ?? {
277
- prompt_tokens: 0,
278
- completion_tokens: 0,
279
- total_tokens: 0,
280
- };
281
-
282
263
  return {
283
264
  content:
284
265
  typeof parsed === "string"
285
266
  ? parsed
286
267
  : (parsed as Record<string, unknown>),
287
268
  text: rawText,
288
- usage,
269
+ usage: accumulated.usage,
289
270
  raw: accumulated,
290
271
  };
291
272
  } catch (err) {