@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
|
@@ -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 {
|
|
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("
|
|
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).
|
|
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("
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
)
|
|
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 () => {
|
package/src/providers/alibaba.ts
CHANGED
|
@@ -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
|
|
104
|
-
completion_tokens
|
|
105
|
-
total_tokens
|
|
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
|
|
121
|
-
completion_tokens
|
|
122
|
-
total_tokens
|
|
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 =
|
|
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) {
|