@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.
- package/docs/http-api.md +80 -4
- package/package.json +1 -4
- package/src/api/__tests__/index.test.ts +443 -32
- package/src/api/index.ts +108 -127
- package/src/cli/__tests__/analyze-task.test.ts +40 -7
- package/src/cli/__tests__/index.test.ts +337 -17
- package/src/cli/__tests__/types.test.ts +0 -18
- package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
- package/src/cli/analyze-task.ts +3 -14
- package/src/cli/index.ts +73 -61
- package/src/cli/types.ts +0 -13
- package/src/cli/update-pipeline-json.ts +3 -14
- package/src/config/__tests__/paths.test.ts +46 -1
- package/src/config/__tests__/pipeline-registry.test.ts +767 -0
- package/src/config/__tests__/sse-events.test.ts +470 -0
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/paths.ts +11 -2
- package/src/config/pipeline-registry.ts +258 -0
- package/src/config/sse-events.ts +122 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/agent-step.test.ts +115 -0
- package/src/core/__tests__/config.test.ts +545 -105
- package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
- package/src/core/__tests__/job-concurrency.test.ts +260 -0
- package/src/core/__tests__/job-submission.test.ts +396 -0
- package/src/core/__tests__/job-view.test.ts +1341 -0
- package/src/core/__tests__/json-file.test.ts +111 -0
- package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
- package/src/core/__tests__/logger.test.ts +22 -0
- package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
- package/src/core/__tests__/orchestrator.test.ts +615 -2
- package/src/core/__tests__/pipeline-runner.test.ts +946 -9
- package/src/core/__tests__/redact.test.ts +153 -0
- package/src/core/__tests__/runner-liveness.test.ts +910 -0
- package/src/core/__tests__/seed-naming.test.ts +57 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/__tests__/task-runner.test.ts +594 -3
- package/src/core/__tests__/task-telemetry.test.ts +241 -0
- package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
- package/src/core/agent-step.ts +4 -1
- package/src/core/agent-types.ts +5 -4
- package/src/core/config.ts +134 -222
- package/src/core/control.ts +3 -0
- package/src/core/job-concurrency.ts +56 -20
- package/src/core/job-submission.ts +133 -0
- package/src/core/job-view.ts +473 -0
- package/src/core/json-file.ts +45 -0
- package/src/core/lifecycle-policy.ts +23 -8
- package/src/core/logger.ts +0 -29
- package/src/core/orchestrator.ts +200 -74
- package/src/core/pipeline-runner.ts +85 -53
- package/src/core/redact.ts +40 -0
- package/src/core/runner-liveness.ts +280 -0
- package/src/core/seed-naming.ts +9 -0
- package/src/core/status-writer.ts +27 -33
- package/src/core/task-runner.ts +356 -319
- package/src/core/task-telemetry.ts +107 -0
- package/src/harness/__tests__/subprocess.test.ts +112 -1
- package/src/harness/subprocess.ts +55 -16
- package/src/llm/__tests__/index.test.ts +684 -33
- package/src/llm/index.ts +310 -67
- package/src/providers/__tests__/alibaba.test.ts +67 -6
- package/src/providers/__tests__/anthropic.test.ts +35 -14
- package/src/providers/__tests__/base.test.ts +62 -0
- package/src/providers/__tests__/claude-code.test.ts +99 -14
- package/src/providers/__tests__/deepseek.test.ts +16 -6
- package/src/providers/__tests__/gemini.test.ts +47 -25
- package/src/providers/__tests__/moonshot.test.ts +27 -0
- package/src/providers/__tests__/openai.test.ts +262 -74
- package/src/providers/__tests__/opencode.test.ts +77 -0
- package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
- package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
- package/src/providers/__tests__/types.test.ts +85 -0
- package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
- package/src/providers/__tests__/zhipu.test.ts +27 -14
- package/src/providers/alibaba.ts +20 -39
- package/src/providers/anthropic.ts +23 -11
- package/src/providers/base.ts +19 -0
- package/src/providers/claude-code.ts +27 -18
- package/src/providers/deepseek.ts +9 -28
- package/src/providers/gemini.ts +20 -58
- package/src/providers/moonshot.ts +15 -11
- package/src/providers/openai.ts +79 -61
- package/src/providers/opencode.ts +16 -33
- package/src/providers/stream-accumulator.ts +27 -21
- package/src/providers/types.ts +29 -4
- package/src/providers/zhipu.ts +15 -44
- package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
- package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
- package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
- package/src/task-analysis/__tests__/repository.test.ts +65 -0
- package/src/task-analysis/__tests__/types.test.ts +63 -130
- package/src/task-analysis/analyzer.ts +91 -0
- package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
- package/src/task-analysis/index.ts +2 -36
- package/src/task-analysis/repository.ts +45 -0
- package/src/task-analysis/types.ts +42 -58
- package/src/ui/client/__tests__/api.test.ts +143 -1
- package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
- package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
- package/src/ui/client/__tests__/load-state.test.ts +70 -0
- package/src/ui/client/__tests__/types.test.ts +66 -3
- package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
- package/src/ui/client/__tests__/useJobList.test.ts +198 -23
- package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
- package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
- package/src/ui/client/adapters/job-adapter.ts +41 -16
- package/src/ui/client/api.ts +38 -15
- package/src/ui/client/bootstrap.ts +19 -14
- package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
- package/src/ui/client/hooks/useJobList.ts +26 -31
- package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
- package/src/ui/client/load-state.ts +20 -0
- package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
- package/src/ui/client/reducers/job-events.ts +137 -0
- package/src/ui/client/types.ts +16 -20
- package/src/ui/components/DAGGrid.tsx +6 -1
- package/src/ui/components/JobDetail.tsx +12 -4
- package/src/ui/components/JobTable.tsx +41 -13
- package/src/ui/components/StageTimeline.tsx +8 -20
- package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
- package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
- package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
- package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
- package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
- package/src/ui/components/types.ts +35 -15
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
- package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
- package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/pages/PipelineDetail.tsx +60 -4
- package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
- package/src/ui/pages/__tests__/pages.test.tsx +236 -42
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
- package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
- package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
- package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
- package/src/ui/server/__tests__/index.test.ts +63 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
- package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
- package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
- package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
- package/src/ui/server/__tests__/router-threading.test.ts +148 -0
- package/src/ui/server/__tests__/router.test.ts +104 -0
- package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
- package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
- package/src/ui/server/config-bridge-node.ts +8 -26
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/embedded-assets-imports.d.ts +44 -0
- package/src/ui/server/embedded-assets.ts +13 -2
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
- package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
- package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
- package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
- package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
- package/src/ui/server/endpoints/file-endpoints.ts +15 -10
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
- package/src/ui/server/endpoints/job-endpoints.ts +7 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
- package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
- package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
- package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
- package/src/ui/server/index.ts +19 -14
- package/src/ui/server/job-reader.ts +14 -2
- package/src/ui/server/router.ts +33 -10
- package/src/ui/server/sse-broadcast.ts +14 -3
- package/src/ui/server/sse-enhancer.ts +12 -2
- package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
- package/src/ui/state/__tests__/snapshot.test.ts +120 -14
- package/src/ui/state/__tests__/types.test.ts +104 -5
- package/src/ui/state/snapshot.ts +2 -2
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
- package/src/ui/state/transformers/list-transformer.ts +13 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +15 -48
- package/src/utils/__tests__/path-containment.test.ts +160 -0
- package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
- package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
- package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
- package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
- package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
- package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
- package/src/task-analysis/__tests__/index.test.ts +0 -143
- package/src/task-analysis/__tests__/parser.test.ts +0 -41
- package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
- package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
- package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
- package/src/task-analysis/extractors/artifacts.ts +0 -143
- package/src/task-analysis/extractors/llm-calls.ts +0 -117
- package/src/task-analysis/extractors/stages.ts +0 -45
- package/src/task-analysis/parser.ts +0 -20
- package/src/task-analysis/utils/ast.ts +0 -45
- package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
- package/src/ui/embedded-assets.js +0 -12
- 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 {
|
|
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
|
-
|
|
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("
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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("
|
|
274
|
-
const jsonPayload = {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
292
|
-
expect(result.usage).
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
381
|
-
|
|
400
|
+
await openaiChat({ ...baseOptions, model: "gpt-4o", requestTimeoutMs: 10_000 });
|
|
401
|
+
await openaiChat({ ...baseOptions, model: "gpt-4o", requestTimeoutMs: 60_000 });
|
|
382
402
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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"}'],
|
|
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("
|
|
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).
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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", () => {
|