@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
package/src/llm/index.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  // Central LLM gateway: dispatcher, factories, telemetry, and utilities.
3
3
 
4
4
  import { EventEmitter } from "node:events";
5
- import { writeFile } from "node:fs/promises";
5
+ import { writeFile, chmod } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
6
8
  import { getConfig } from "../core/config.ts";
9
+ import { redactSecrets } from "../core/redact.ts";
7
10
  import { anthropicChat } from "../providers/anthropic.ts";
8
11
  import { openaiChat } from "../providers/openai.ts";
9
12
  import { geminiChat } from "../providers/gemini.ts";
@@ -13,7 +16,7 @@ import { moonshotChat } from "../providers/moonshot.ts";
13
16
  import { zaiChat } from "../providers/zhipu.ts";
14
17
  import { claudeCodeChat, isClaudeCodeAvailable } from "../providers/claude-code.ts";
15
18
  import { opencodeChat, isOpenCodeAvailable } from "../providers/opencode.ts";
16
- import { ensureMessagesPresent } from "../providers/base.ts";
19
+ import { ensureMessagesPresent, isJsonResponseFormat } from "../providers/base.ts";
17
20
  import {
18
21
  MODEL_CONFIG,
19
22
  PROVIDER_FUNCTIONS,
@@ -37,7 +40,9 @@ import type {
37
40
  LLMRequestStartEvent,
38
41
  LLMRequestCompleteEvent,
39
42
  LLMRequestErrorEvent,
43
+ UsageSource,
40
44
  } from "../providers/types.ts";
45
+ import { ProviderJsonParseError } from "../providers/types.ts";
41
46
 
42
47
  // ─── Provider Name Mapping ───────────────────────────────────────────────────
43
48
  // types.ts uses "claudecode" (one word), config/models.ts uses "claude-code" (hyphenated).
@@ -60,37 +65,17 @@ function fromConfigProvider(configName: string): ProviderName {
60
65
  const llmEvents = new EventEmitter();
61
66
  let mockProvider: MockProvider | null = null;
62
67
  let requestCounter = 0;
68
+ const warnedUnknownAliases = new Set<string>();
63
69
 
64
- // ─── JSON Format Inference ───────────────────────────────────────────────────
65
- // For OpenAI, DeepSeek, Gemini, Moonshot: if responseFormat is falsy,
66
- // check first two messages for "json" (case-insensitive) and infer "json_object".
67
-
68
- const JSON_INFER_PROVIDERS = new Set<ProviderName>([
69
- "openai",
70
- "deepseek",
71
- "gemini",
72
- "moonshot",
73
- "alibaba",
74
- ]);
75
-
76
- function inferJsonFormat(options: ChatOptions): ChatOptions {
77
- if (options.responseFormat) return options;
78
- if (!JSON_INFER_PROVIDERS.has(options.provider)) return options;
79
-
80
- const first2 = options.messages.slice(0, 2);
81
- const hasJson = first2.some((m) =>
82
- m.content.toLowerCase().includes("json"),
83
- );
84
-
85
- if (hasJson) {
86
- return { ...options, responseFormat: "json_object" };
87
- }
88
- return options;
70
+ function warnUnknownAliasOnce(alias: string): void {
71
+ if (warnedUnknownAliases.has(alias)) return;
72
+ warnedUnknownAliases.add(alias);
73
+ console.warn(`[llm] unknown model alias "${alias}"; reporting cost as estimated`);
89
74
  }
90
75
 
91
76
  // ─── Adapter Dispatch ────────────────────────────────────────────────────────
92
77
 
93
- async function callAdapter(
78
+ async function dispatchByProvider(
94
79
  options: ChatOptions,
95
80
  ): Promise<AdapterResponse> {
96
81
  const { provider, messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries, requestTimeoutMs } = options;
@@ -146,27 +131,198 @@ async function callAdapter(
146
131
  }
147
132
  }
148
133
 
134
+ async function callAdapter(
135
+ options: ChatOptions,
136
+ ): Promise<AdapterResponse> {
137
+ const response = await dispatchByProvider(options);
138
+ response.usageAvailable = resolveUsageAvailable(options.provider, options);
139
+ return response;
140
+ }
141
+
142
+ // ─── JSON Repair (AC-15) ─────────────────────────────────────────────────────
143
+
144
+ const REPAIR_SAMPLE_MAX_LEN = 2000;
145
+
146
+ function buildJsonRepairMessages(
147
+ messages: ChatMessage[],
148
+ parseError: ProviderJsonParseError,
149
+ ): ChatMessage[] {
150
+ const sample = String(redactSecrets(parseError.sample)).slice(0, REPAIR_SAMPLE_MAX_LEN);
151
+ return [
152
+ ...messages,
153
+ {
154
+ role: "user",
155
+ content:
156
+ `Your previous response was not valid JSON. ` +
157
+ `Error sample (redacted, truncated to ${REPAIR_SAMPLE_MAX_LEN} characters):\n\n${sample}\n\n` +
158
+ `Please respond again with valid JSON only, no prose or code fences.`,
159
+ },
160
+ ];
161
+ }
162
+
163
+ async function performJsonRepair(
164
+ opts: ChatOptions,
165
+ repairMessages: ChatMessage[],
166
+ ): Promise<AdapterResponse> {
167
+ const result = await callAdapter({ ...opts, messages: repairMessages });
168
+ return result;
169
+ }
170
+
171
+ // ─── Usage Capability ────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Returns whether a provider can structurally report token usage for the given
175
+ * options. This is the single source of truth for `usageAvailable`; adapters
176
+ * never set it themselves. Forward-compatible with the #25 provider registry,
177
+ * where this becomes a registry column.
178
+ */
179
+ export function resolveUsageAvailable(
180
+ provider: ProviderName,
181
+ options: ChatOptions,
182
+ ): boolean {
183
+ if (provider === "claudecode") return false;
184
+ if (provider === "opencode") {
185
+ if (options.opencode?.mode === "cli") return false;
186
+ if (options.opencode?.mode === "sdk") return true;
187
+ const baseUrl =
188
+ options.opencode?.baseUrl ||
189
+ process.env.PO_OPENCODE_BASE_URL ||
190
+ process.env.OPENCODE_BASE_URL;
191
+ return baseUrl != null;
192
+ }
193
+ return true;
194
+ }
195
+
149
196
  // ─── Usage Normalization ─────────────────────────────────────────────────────
150
197
 
151
198
  function normalizeUsage(adapter: AdapterResponse, text: string): NormalizedUsage {
152
- if (adapter.usage) {
153
- return {
154
- promptTokens: adapter.usage.prompt_tokens,
155
- completionTokens: adapter.usage.completion_tokens,
156
- totalTokens: adapter.usage.total_tokens,
157
- };
158
- }
159
- // Estimate at ~4 chars/token
160
199
  const contentStr = typeof adapter.content === "string"
161
200
  ? adapter.content
162
201
  : JSON.stringify(adapter.content);
202
+
203
+ if (adapter.usage) {
204
+ const prompt = adapter.usage.prompt_tokens;
205
+ const completion = adapter.usage.completion_tokens;
206
+ const total = adapter.usage.total_tokens;
207
+
208
+ const promptPresent = typeof prompt === "number";
209
+ const completionPresent = typeof completion === "number";
210
+
211
+ if (promptPresent && completionPresent) {
212
+ if (prompt === 0 && completion === 0) {
213
+ return zeroUsage();
214
+ }
215
+ return {
216
+ promptTokens: prompt,
217
+ completionTokens: completion,
218
+ totalTokens: typeof total === "number" ? total : prompt + completion,
219
+ source: "reported",
220
+ };
221
+ }
222
+
223
+ if (typeof total === "number" && !promptPresent && !completionPresent) {
224
+ const promptEst = estimateTokens(text);
225
+ const completionEst = estimateTokens(contentStr);
226
+ const estSum = promptEst + completionEst;
227
+ const promptTokens = estSum > 0 ? Math.round((promptEst / estSum) * total) : 0;
228
+ return {
229
+ promptTokens,
230
+ completionTokens: total - promptTokens,
231
+ totalTokens: total,
232
+ source: "estimated",
233
+ };
234
+ }
235
+
236
+ if (promptPresent && !completionPresent) {
237
+ const completionEst = estimateTokens(contentStr);
238
+ if (typeof total === "number") {
239
+ return {
240
+ promptTokens: prompt,
241
+ completionTokens: Math.max(0, total - prompt),
242
+ totalTokens: total,
243
+ source: "estimated",
244
+ };
245
+ }
246
+ return {
247
+ promptTokens: prompt,
248
+ completionTokens: completionEst,
249
+ totalTokens: prompt + completionEst,
250
+ source: "estimated",
251
+ };
252
+ }
253
+
254
+ if (completionPresent && !promptPresent) {
255
+ const promptEst = estimateTokens(text);
256
+ if (typeof total === "number") {
257
+ return {
258
+ promptTokens: Math.max(0, total - completion),
259
+ completionTokens: completion,
260
+ totalTokens: total,
261
+ source: "estimated",
262
+ };
263
+ }
264
+ return {
265
+ promptTokens: promptEst,
266
+ completionTokens: completion,
267
+ totalTokens: promptEst + completion,
268
+ source: "estimated",
269
+ };
270
+ }
271
+ }
272
+
273
+ const source: UsageSource = adapter.usageAvailable === false ? "unavailable" : "estimated";
163
274
  return {
164
275
  promptTokens: estimateTokens(text),
165
276
  completionTokens: estimateTokens(contentStr),
166
277
  totalTokens: estimateTokens(text) + estimateTokens(contentStr),
278
+ source,
167
279
  };
168
280
  }
169
281
 
282
+ function zeroUsage(): NormalizedUsage {
283
+ return {
284
+ promptTokens: 0,
285
+ completionTokens: 0,
286
+ totalTokens: 0,
287
+ source: "zero",
288
+ };
289
+ }
290
+
291
+ function combineUsageSource(left: UsageSource, right: UsageSource): UsageSource {
292
+ if (left === right) return left;
293
+ if (left === "unavailable" || right === "unavailable") return "unavailable";
294
+ if (left === "estimated" || right === "estimated") return "estimated";
295
+ if (left === "reported" || right === "reported") return "reported";
296
+ return "zero";
297
+ }
298
+
299
+ function combineNormalizedUsage(usages: NormalizedUsage[]): NormalizedUsage {
300
+ return usages.reduce<NormalizedUsage>(
301
+ (total, usage) => ({
302
+ promptTokens: total.promptTokens + usage.promptTokens,
303
+ completionTokens: total.completionTokens + usage.completionTokens,
304
+ totalTokens: total.totalTokens + usage.totalTokens,
305
+ source: combineUsageSource(total.source, usage.source),
306
+ }),
307
+ zeroUsage(),
308
+ );
309
+ }
310
+
311
+ function normalizeParseErrorUsage(
312
+ err: ProviderJsonParseError,
313
+ messagesText: string,
314
+ opts: ChatOptions,
315
+ ): NormalizedUsage {
316
+ return normalizeUsage(
317
+ {
318
+ content: err.text ?? err.sample,
319
+ usage: err.usage,
320
+ usageAvailable: err.usageAvailable ?? resolveUsageAvailable(opts.provider, opts),
321
+ },
322
+ messagesText,
323
+ );
324
+ }
325
+
170
326
  // ─── Debug Log ───────────────────────────────────────────────────────────────
171
327
 
172
328
  async function writeDebugLog(options: ChatOptions, response: ChatResponse): Promise<void> {
@@ -179,7 +335,18 @@ async function writeDebugLog(options: ChatOptions, response: ChatResponse): Prom
179
335
  response: response.content,
180
336
  usage: response.usage,
181
337
  };
182
- await writeFile("/tmp/messages.log", JSON.stringify(entry, null, 2) + "\n");
338
+ const path = join(tmpdir(), `pop-llm-debug-${process.pid}.log`);
339
+ try {
340
+ try {
341
+ await chmod(path, 0o600);
342
+ } catch {
343
+ // The file may not exist yet; creation below still uses 0600.
344
+ }
345
+ await writeFile(path, JSON.stringify(entry, null, 2) + "\n", { flag: "a", mode: 0o600 });
346
+ await chmod(path, 0o600);
347
+ } catch {
348
+ // best-effort: a debug-log write failure must never break a run
349
+ }
183
350
  }
184
351
 
185
352
  // ─── Public API ──────────────────────────────────────────────────────────────
@@ -187,10 +354,10 @@ async function writeDebugLog(options: ChatOptions, response: ChatResponse): Prom
187
354
  export async function chat(options: ChatOptions): Promise<ChatResponse> {
188
355
  ensureMessagesPresent(options.messages, options.provider);
189
356
  const configTimeout = getConfig().taskRunner.llmRequestTimeout;
190
- const opts = inferJsonFormat({
357
+ const opts = {
191
358
  ...options,
192
359
  requestTimeoutMs: options.requestTimeoutMs ?? configTimeout,
193
- });
360
+ };
194
361
  const id = `llm-${++requestCounter}-${Date.now()}`;
195
362
  const model = opts.model ?? "";
196
363
  const startTime = Date.now();
@@ -204,11 +371,52 @@ export async function chat(options: ChatOptions): Promise<ChatResponse> {
204
371
  };
205
372
  llmEvents.emit("llm:request:start", startEvent);
206
373
 
374
+ const failedAttemptUsage: NormalizedUsage[] = [];
375
+
207
376
  try {
208
- const adapterResult = await callAdapter(opts);
209
- const messagesText = opts.messages.map((m) => m.content).join(" ");
210
- const usage = normalizeUsage(adapterResult, messagesText);
211
- const cost = calculateCost(opts.provider, model, usage);
377
+ let adapterResult: AdapterResponse;
378
+ let messagesText: string;
379
+ try {
380
+ adapterResult = await callAdapter(opts);
381
+ messagesText = opts.messages.map((m) => m.content).join(" ");
382
+ } catch (err) {
383
+ if (
384
+ err instanceof ProviderJsonParseError &&
385
+ isJsonResponseFormat(opts.responseFormat)
386
+ ) {
387
+ const originalMessagesText = opts.messages.map((m) => m.content).join(" ");
388
+ failedAttemptUsage.push(normalizeParseErrorUsage(err, originalMessagesText, opts));
389
+ const repairMessages = buildJsonRepairMessages(opts.messages, err);
390
+ const repairMessagesText = repairMessages.map((m) => m.content).join(" ");
391
+ try {
392
+ adapterResult = await performJsonRepair(opts, repairMessages);
393
+ messagesText = repairMessagesText;
394
+ } catch (repairErr) {
395
+ if (repairErr instanceof ProviderJsonParseError) {
396
+ failedAttemptUsage.push(normalizeParseErrorUsage(repairErr, repairMessagesText, opts));
397
+ }
398
+ throw repairErr;
399
+ }
400
+ } else {
401
+ throw err;
402
+ }
403
+ }
404
+ const usage = combineNormalizedUsage([
405
+ ...failedAttemptUsage,
406
+ normalizeUsage(adapterResult, messagesText),
407
+ ]);
408
+ const price = findModelPrice(opts.provider, model);
409
+ const cost = price
410
+ ? Math.max(
411
+ 0,
412
+ (usage.promptTokens / 1_000_000) * price.tokenCostInPerMillion +
413
+ (usage.completionTokens / 1_000_000) * price.tokenCostOutPerMillion,
414
+ )
415
+ : 0;
416
+ const costEstimated = price === null || usage.source !== "reported";
417
+ if (price === null) {
418
+ warnUnknownAliasOnce(`${toConfigProvider(opts.provider)}:${model}`);
419
+ }
212
420
  const duration = Date.now() - startTime;
213
421
 
214
422
  const completeEvent: LLMRequestCompleteEvent = {
@@ -218,6 +426,8 @@ export async function chat(options: ChatOptions): Promise<ChatResponse> {
218
426
  completionTokens: usage.completionTokens,
219
427
  totalTokens: usage.totalTokens,
220
428
  cost,
429
+ usageSource: usage.source,
430
+ costEstimated,
221
431
  };
222
432
  llmEvents.emit("llm:request:complete", completeEvent);
223
433
 
@@ -227,16 +437,33 @@ export async function chat(options: ChatOptions): Promise<ChatResponse> {
227
437
  raw: adapterResult.raw,
228
438
  };
229
439
 
230
- // Fire-and-forget debug log
231
- writeDebugLog(opts, response).catch(() => {});
440
+ await writeDebugLog(opts, response);
232
441
 
233
442
  return response;
234
443
  } catch (err) {
235
444
  const duration = Date.now() - startTime;
445
+ const messagesText = opts.messages.map((m) => m.content).join(" ");
446
+ const promptTokens = estimateTokens(messagesText);
447
+ const usage: NormalizedUsage = failedAttemptUsage.length > 0
448
+ ? combineNormalizedUsage(failedAttemptUsage)
449
+ : err instanceof ProviderJsonParseError && isJsonResponseFormat(opts.responseFormat)
450
+ ? normalizeParseErrorUsage(err, messagesText, opts)
451
+ : {
452
+ promptTokens,
453
+ completionTokens: 0,
454
+ totalTokens: promptTokens,
455
+ source: "estimated",
456
+ };
236
457
  const errorEvent: LLMRequestErrorEvent = {
237
458
  ...startEvent,
238
459
  duration,
239
460
  error: err instanceof Error ? err.message : String(err),
461
+ promptTokens: usage.promptTokens,
462
+ completionTokens: usage.completionTokens,
463
+ totalTokens: usage.totalTokens,
464
+ cost: calculateCost(opts.provider, model, usage),
465
+ usageSource: usage.source,
466
+ costEstimated: true,
240
467
  };
241
468
  llmEvents.emit("llm:request:error", errorEvent);
242
469
  throw err;
@@ -471,34 +698,50 @@ export function estimateTokens(text: string): number {
471
698
  return Math.ceil(text.length / 4);
472
699
  }
473
700
 
474
- export function calculateCost(
701
+ export interface ModelPrice {
702
+ readonly tokenCostInPerMillion: number;
703
+ readonly tokenCostOutPerMillion: number;
704
+ }
705
+
706
+ /**
707
+ * Looks up the per-million-token price for a gateway `(provider, model)` pair.
708
+ * Returns `null` when no price is known. This is the single cost-lookup surface
709
+ * used by both `calculateCost` and the `costEstimated` discriminator in `chat()`.
710
+ */
711
+ export function findModelPrice(
475
712
  provider: string,
476
713
  model: string,
477
- usage: NormalizedUsage,
478
- ): number {
479
- // Map gateway provider name to config provider name for lookup
480
- const configProvider = provider === "claudecode" ? "claude-code"
481
- : provider === "zhipu" ? "zai"
482
- : provider;
483
-
714
+ ): ModelPrice | null {
715
+ const configProvider = toConfigProvider(provider as ProviderName);
484
716
  const alias = `${configProvider}:${model}`;
485
- const config = getModelConfig(alias);
486
-
487
- if (!config) {
488
- // Try to find by scanning MODEL_CONFIG for matching provider+model
489
- for (const [, entry] of Object.entries(MODEL_CONFIG)) {
490
- if (entry.provider === configProvider && entry.model === model) {
491
- const inCost = (usage.promptTokens / 1_000_000) * entry.tokenCostInPerMillion;
492
- const outCost = (usage.completionTokens / 1_000_000) * entry.tokenCostOutPerMillion;
493
- return inCost + outCost;
494
- }
717
+ const direct = getModelConfig(alias);
718
+ if (direct) {
719
+ return {
720
+ tokenCostInPerMillion: direct.tokenCostInPerMillion,
721
+ tokenCostOutPerMillion: direct.tokenCostOutPerMillion,
722
+ };
723
+ }
724
+ for (const entry of Object.values(MODEL_CONFIG)) {
725
+ if (entry.provider === configProvider && entry.model === model) {
726
+ return {
727
+ tokenCostInPerMillion: entry.tokenCostInPerMillion,
728
+ tokenCostOutPerMillion: entry.tokenCostOutPerMillion,
729
+ };
495
730
  }
496
- return 0;
497
731
  }
732
+ return null;
733
+ }
498
734
 
499
- const inCost = (usage.promptTokens / 1_000_000) * config.tokenCostInPerMillion;
500
- const outCost = (usage.completionTokens / 1_000_000) * config.tokenCostOutPerMillion;
501
- return inCost + outCost;
735
+ export function calculateCost(
736
+ provider: string,
737
+ model: string,
738
+ usage: NormalizedUsage,
739
+ ): number {
740
+ const price = findModelPrice(provider, model);
741
+ if (!price) return 0;
742
+ const inCost = (usage.promptTokens / 1_000_000) * price.tokenCostInPerMillion;
743
+ const outCost = (usage.completionTokens / 1_000_000) * price.tokenCostOutPerMillion;
744
+ return Math.max(0, inCost + outCost);
502
745
  }
503
746
 
504
747
  // ─── Factory Helpers ─────────────────────────────────────────────────────────
@@ -69,6 +69,15 @@ function mockErrorResponse(body: unknown, status: number) {
69
69
  } as unknown as Response;
70
70
  }
71
71
 
72
+ function mockNonStreamingResponse(body: unknown, status = 200): Response {
73
+ return {
74
+ ok: status >= 200 && status < 300,
75
+ status,
76
+ json: vi.fn().mockResolvedValue(body),
77
+ text: vi.fn().mockResolvedValue(JSON.stringify(body)),
78
+ } as unknown as Response;
79
+ }
80
+
72
81
  const baseOptions: AlibabaOptions = {
73
82
  messages: [
74
83
  { role: "system", content: "You are helpful." },
@@ -268,17 +277,13 @@ describe("alibabaChat", () => {
268
277
  });
269
278
  });
270
279
 
271
- it("defaults usage to zeros when stream provides no usage", async () => {
280
+ it("returns undefined usage when stream reports none", async () => {
272
281
  const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
273
282
 
274
283
  fetchMock.mockResolvedValue(mockStreamingResponse(events));
275
284
 
276
285
  const result = await alibabaChat(baseOptions);
277
- expect(result.usage).toEqual({
278
- prompt_tokens: 0,
279
- completion_tokens: 0,
280
- total_tokens: 0,
281
- });
286
+ expect(result.usage).toBeUndefined();
282
287
  });
283
288
 
284
289
  it("retries on timeout then succeeds on second attempt", async () => {
@@ -299,4 +304,60 @@ describe("alibabaChat", () => {
299
304
  expect(result.content).toEqual({ ok: true });
300
305
  });
301
306
  });
307
+
308
+ describe("non-streaming fallback", () => {
309
+ it("returns undefined usage when non-streaming response reports none", async () => {
310
+ fetchMock
311
+ .mockRejectedValueOnce(
312
+ new Error("response_format not supported in stream mode"),
313
+ )
314
+ .mockResolvedValueOnce(
315
+ mockNonStreamingResponse({
316
+ choices: [{ message: { content: '{"ok":true}' } }],
317
+ }),
318
+ );
319
+
320
+ const result = await alibabaChat(baseOptions);
321
+
322
+ expect(result.usage).toBeUndefined();
323
+ });
324
+
325
+ it("preserves full usage from non-streaming response", async () => {
326
+ fetchMock
327
+ .mockRejectedValueOnce(
328
+ new Error("response_format not supported in stream mode"),
329
+ )
330
+ .mockResolvedValueOnce(
331
+ mockNonStreamingResponse({
332
+ choices: [{ message: { content: '{"ok":true}' } }],
333
+ usage: { prompt_tokens: 12, completion_tokens: 8, total_tokens: 20 },
334
+ }),
335
+ );
336
+
337
+ const result = await alibabaChat(baseOptions);
338
+
339
+ expect(result.usage).toEqual({
340
+ prompt_tokens: 12,
341
+ completion_tokens: 8,
342
+ total_tokens: 20,
343
+ });
344
+ });
345
+
346
+ it("preserves partial usage from non-streaming response without coercion", async () => {
347
+ fetchMock
348
+ .mockRejectedValueOnce(
349
+ new Error("response_format not supported in stream mode"),
350
+ )
351
+ .mockResolvedValueOnce(
352
+ mockNonStreamingResponse({
353
+ choices: [{ message: { content: '{"ok":true}' } }],
354
+ usage: { prompt_tokens: 7 },
355
+ }),
356
+ );
357
+
358
+ const result = await alibabaChat(baseOptions);
359
+
360
+ expect(result.usage).toEqual({ prompt_tokens: 7 });
361
+ });
362
+ });
302
363
  });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import { anthropicChat } from "../anthropic.ts";
3
- import { ProviderJsonModeError, ProviderJsonParseError } from "../types.ts";
3
+ import { ProviderJsonParseError } from "../types.ts";
4
4
  import type { AnthropicOptions } from "../types.ts";
5
5
  import type { Mock } from "vitest";
6
6
 
@@ -146,25 +146,30 @@ describe("anthropicChat", () => {
146
146
  expect(result.content).toEqual(jsonPayload);
147
147
  });
148
148
 
149
- it("throws ProviderJsonModeError when responseFormat is invalid", async () => {
150
- await expect(
151
- anthropicChat({
152
- ...baseOptions,
153
- responseFormat: "text",
154
- }),
155
- ).rejects.toThrow(ProviderJsonModeError);
149
+ it("returns plain text content when responseFormat is omitted (AC-13)", async () => {
150
+ const plainText = "Hello, this is a plain text response.";
151
+ const events = makeAnthropicSseEvents(plainText);
152
+ fetchMock.mockResolvedValue(mockStreamingResponse(events));
153
+
154
+ const result = await anthropicChat({
155
+ messages: [{ role: "user", content: "Return JSON." }],
156
+ });
157
+
158
+ expect(typeof result.content).toBe("string");
159
+ expect(result.content).toBe(plainText);
160
+ expect(result.text).toBe(plainText);
156
161
  });
157
162
 
158
- it("defaults to json responseFormat when responseFormat is omitted", async () => {
159
- const jsonPayload = { defaultFormat: true };
160
- const events = makeAnthropicSseEvents(JSON.stringify(jsonPayload));
163
+ it("does not parse as JSON when responseFormat is omitted even if the message says 'json' (AC-13)", async () => {
164
+ const events = makeAnthropicSseEvents("not json, just text");
161
165
  fetchMock.mockResolvedValue(mockStreamingResponse(events));
162
166
 
163
- // responseFormat defaults to "json" — should not throw
164
167
  const result = await anthropicChat({
165
- messages: baseOptions.messages,
168
+ messages: [{ role: "user", content: "Give me json output please." }],
166
169
  });
167
- expect(result.content).toEqual(jsonPayload);
170
+
171
+ expect(result.content).toBe("not json, just text");
172
+ expect(fetchMock).toHaveBeenCalledTimes(1);
168
173
  });
169
174
 
170
175
  it("throws ProviderJsonParseError for non-JSON text in JSON mode", async () => {
@@ -328,6 +333,22 @@ describe("anthropicChat", () => {
328
333
  });
329
334
  });
330
335
 
336
+ it("returns undefined usage when accumulated stream reports none", async () => {
337
+ const jsonPayload = { noUsage: true };
338
+ const events = [
339
+ `event: content_block_delta\ndata: ${JSON.stringify({
340
+ type: "content_block_delta",
341
+ delta: { text: JSON.stringify(jsonPayload) },
342
+ })}\n\n`,
343
+ ];
344
+
345
+ fetchMock.mockResolvedValue(mockStreamingResponse(events));
346
+
347
+ const result = await anthropicChat(baseOptions);
348
+ expect(result.content).toEqual(jsonPayload);
349
+ expect(result.usage).toBeUndefined();
350
+ });
351
+
331
352
  it("retries on timeout then succeeds on second attempt", async () => {
332
353
  const events = makeAnthropicSseEvents(JSON.stringify({ ok: true }));
333
354