@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,8 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
+
import { existsSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
|
|
4
7
|
const mockSdkPrompt = vi.fn();
|
|
5
8
|
const mockSdkCreate = vi.fn();
|
|
@@ -25,6 +28,8 @@ import {
|
|
|
25
28
|
getAvailableProviders,
|
|
26
29
|
estimateTokens,
|
|
27
30
|
calculateCost,
|
|
31
|
+
findModelPrice,
|
|
32
|
+
resolveUsageAvailable,
|
|
28
33
|
} from "../index.ts";
|
|
29
34
|
import type {
|
|
30
35
|
ChatOptions,
|
|
@@ -32,7 +37,9 @@ import type {
|
|
|
32
37
|
NormalizedUsage,
|
|
33
38
|
LLMRequestStartEvent,
|
|
34
39
|
LLMRequestCompleteEvent,
|
|
40
|
+
LLMRequestErrorEvent,
|
|
35
41
|
} from "../../providers/types.ts";
|
|
42
|
+
import { ProviderJsonParseError } from "../../providers/types.ts";
|
|
36
43
|
|
|
37
44
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
38
45
|
|
|
@@ -149,6 +156,7 @@ describe("LLM Gateway", () => {
|
|
|
149
156
|
promptTokens: 100,
|
|
150
157
|
completionTokens: 200,
|
|
151
158
|
totalTokens: 300,
|
|
159
|
+
source: "reported",
|
|
152
160
|
});
|
|
153
161
|
});
|
|
154
162
|
|
|
@@ -177,6 +185,44 @@ describe("LLM Gateway", () => {
|
|
|
177
185
|
|
|
178
186
|
expect(provider.chat).not.toHaveBeenCalled();
|
|
179
187
|
});
|
|
188
|
+
|
|
189
|
+
it("AC-8: throwing adapter emits llm:request:error with estimated input usage, cost, and costEstimated", async () => {
|
|
190
|
+
const provider: MockProvider = {
|
|
191
|
+
chat: vi.fn().mockRejectedValue(new Error("adapter boom")),
|
|
192
|
+
};
|
|
193
|
+
registerMockProvider(provider);
|
|
194
|
+
|
|
195
|
+
const errorEvents: LLMRequestErrorEvent[] = [];
|
|
196
|
+
getLLMEvents().on("llm:request:error", (e) => errorEvents.push(e));
|
|
197
|
+
|
|
198
|
+
const messages = [
|
|
199
|
+
{ role: "system" as const, content: "you are helpful" },
|
|
200
|
+
{ role: "user" as const, content: "Hello world from a failing prompt" },
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
await expect(
|
|
204
|
+
chat({ provider: "mock", model: "ac8-error-model", messages }),
|
|
205
|
+
).rejects.toThrow("adapter boom");
|
|
206
|
+
|
|
207
|
+
expect(errorEvents).toHaveLength(1);
|
|
208
|
+
const evt = errorEvents[0]!;
|
|
209
|
+
const expectedPrompt = estimateTokens(
|
|
210
|
+
messages.map((m) => m.content).join(" "),
|
|
211
|
+
);
|
|
212
|
+
expect(evt.promptTokens).toBe(expectedPrompt);
|
|
213
|
+
expect(evt.completionTokens).toBe(0);
|
|
214
|
+
expect(evt.totalTokens).toBe(expectedPrompt);
|
|
215
|
+
expect(evt.usageSource).toBe("estimated");
|
|
216
|
+
expect(evt.costEstimated).toBe(true);
|
|
217
|
+
expect(evt.cost).toBe(
|
|
218
|
+
calculateCost("mock", "ac8-error-model", {
|
|
219
|
+
promptTokens: expectedPrompt,
|
|
220
|
+
completionTokens: 0,
|
|
221
|
+
totalTokens: expectedPrompt,
|
|
222
|
+
source: "estimated",
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
180
226
|
});
|
|
181
227
|
|
|
182
228
|
// ── Telemetry Events ───────────────────────────────────────────────────
|
|
@@ -248,6 +294,229 @@ describe("LLM Gateway", () => {
|
|
|
248
294
|
});
|
|
249
295
|
});
|
|
250
296
|
|
|
297
|
+
// ── normalizeUsage classification (AC-2, AC-3) ─────────────────────────
|
|
298
|
+
|
|
299
|
+
describe("normalizeUsage classification", () => {
|
|
300
|
+
async function runChatWithUsage(usage: unknown): Promise<NormalizedUsage> {
|
|
301
|
+
const provider: MockProvider = {
|
|
302
|
+
chat: vi.fn().mockResolvedValue({
|
|
303
|
+
content: "response-text",
|
|
304
|
+
usage,
|
|
305
|
+
}),
|
|
306
|
+
};
|
|
307
|
+
registerMockProvider(provider);
|
|
308
|
+
const result = await chat({
|
|
309
|
+
provider: "mock",
|
|
310
|
+
messages: [{ role: "user", content: "some input text" }],
|
|
311
|
+
});
|
|
312
|
+
return result.usage;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
it("classifies full reported usage as reported", async () => {
|
|
316
|
+
const usage = await runChatWithUsage({
|
|
317
|
+
prompt_tokens: 100,
|
|
318
|
+
completion_tokens: 200,
|
|
319
|
+
total_tokens: 300,
|
|
320
|
+
});
|
|
321
|
+
expect(usage).toEqual({
|
|
322
|
+
promptTokens: 100,
|
|
323
|
+
completionTokens: 200,
|
|
324
|
+
totalTokens: 300,
|
|
325
|
+
source: "reported",
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("classifies all-zero usage as zero", async () => {
|
|
330
|
+
const usage = await runChatWithUsage({
|
|
331
|
+
prompt_tokens: 0,
|
|
332
|
+
completion_tokens: 0,
|
|
333
|
+
total_tokens: 0,
|
|
334
|
+
});
|
|
335
|
+
expect(usage).toEqual({
|
|
336
|
+
promptTokens: 0,
|
|
337
|
+
completionTokens: 0,
|
|
338
|
+
totalTokens: 0,
|
|
339
|
+
source: "zero",
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("classifies absent usage from a capable provider as estimated", async () => {
|
|
344
|
+
const usage = await runChatWithUsage(undefined);
|
|
345
|
+
expect(usage.source).toBe("estimated");
|
|
346
|
+
expect(usage.promptTokens).toBe(estimateTokens("some input text"));
|
|
347
|
+
expect(usage.completionTokens).toBe(estimateTokens("response-text"));
|
|
348
|
+
expect(usage.totalTokens).toBe(
|
|
349
|
+
estimateTokens("some input text") + estimateTokens("response-text"),
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("classifies absent usage from an incapable provider as unavailable", async () => {
|
|
354
|
+
vi.spyOn(Bun, "spawn").mockReturnValue(
|
|
355
|
+
createOpenCodeProc([
|
|
356
|
+
'{"type":"text","part":{"text":"hello from opencode"}}\n',
|
|
357
|
+
]),
|
|
358
|
+
);
|
|
359
|
+
const result = await chat({
|
|
360
|
+
provider: "opencode",
|
|
361
|
+
model: "anthropic/claude-sonnet-4-5",
|
|
362
|
+
messages: [{ role: "user", content: "Hello" }],
|
|
363
|
+
responseFormat: "text",
|
|
364
|
+
});
|
|
365
|
+
expect(result.usage.source).toBe("unavailable");
|
|
366
|
+
expect(result.usage.promptTokens).toBe(estimateTokens("Hello"));
|
|
367
|
+
expect(result.usage.completionTokens).toBe(
|
|
368
|
+
estimateTokens("hello from opencode"),
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("classifies prompt-only usage as estimated and estimates the completion side", async () => {
|
|
373
|
+
const usage = await runChatWithUsage({ prompt_tokens: 50 });
|
|
374
|
+
expect(usage.source).toBe("estimated");
|
|
375
|
+
expect(usage.promptTokens).toBe(50);
|
|
376
|
+
expect(usage.completionTokens).toBe(estimateTokens("response-text"));
|
|
377
|
+
expect(usage.totalTokens).toBe(50 + estimateTokens("response-text"));
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("classifies prompt plus total usage as estimated while preserving the reported total", async () => {
|
|
381
|
+
const usage = await runChatWithUsage({ prompt_tokens: 10, total_tokens: 30 });
|
|
382
|
+
expect(usage).toEqual({
|
|
383
|
+
promptTokens: 10,
|
|
384
|
+
completionTokens: 20,
|
|
385
|
+
totalTokens: 30,
|
|
386
|
+
source: "estimated",
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("classifies completion-only usage as estimated and estimates the prompt side", async () => {
|
|
391
|
+
const usage = await runChatWithUsage({ completion_tokens: 60 });
|
|
392
|
+
expect(usage.source).toBe("estimated");
|
|
393
|
+
expect(usage.promptTokens).toBe(estimateTokens("some input text"));
|
|
394
|
+
expect(usage.completionTokens).toBe(60);
|
|
395
|
+
expect(usage.totalTokens).toBe(estimateTokens("some input text") + 60);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("classifies completion plus total usage as estimated while preserving the reported total", async () => {
|
|
399
|
+
const usage = await runChatWithUsage({ completion_tokens: 12, total_tokens: 30 });
|
|
400
|
+
expect(usage).toEqual({
|
|
401
|
+
promptTokens: 18,
|
|
402
|
+
completionTokens: 12,
|
|
403
|
+
totalTokens: 30,
|
|
404
|
+
source: "estimated",
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("classifies total-only usage as estimated and splits prompt/completion by 4-char estimate, summing to total", async () => {
|
|
409
|
+
const total = 100;
|
|
410
|
+
const usage = await runChatWithUsage({ total_tokens: total });
|
|
411
|
+
expect(usage.source).toBe("estimated");
|
|
412
|
+
expect(usage.totalTokens).toBe(total);
|
|
413
|
+
expect(usage.promptTokens + usage.completionTokens).toBe(total);
|
|
414
|
+
const promptEst = estimateTokens("some input text");
|
|
415
|
+
const completionEst = estimateTokens("response-text");
|
|
416
|
+
const expectedPrompt = Math.round(
|
|
417
|
+
(promptEst / (promptEst + completionEst)) * total,
|
|
418
|
+
);
|
|
419
|
+
expect(usage.promptTokens).toBe(expectedPrompt);
|
|
420
|
+
expect(usage.completionTokens).toBe(total - expectedPrompt);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ── resolveUsageAvailable (AC-4) ───────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
describe("resolveUsageAvailable()", () => {
|
|
427
|
+
let originalEnv: Record<string, string | undefined>;
|
|
428
|
+
|
|
429
|
+
beforeEach(() => {
|
|
430
|
+
originalEnv = { ...process.env };
|
|
431
|
+
delete process.env.PO_OPENCODE_BASE_URL;
|
|
432
|
+
delete process.env.OPENCODE_BASE_URL;
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
afterEach(() => {
|
|
436
|
+
for (const key of ["PO_OPENCODE_BASE_URL", "OPENCODE_BASE_URL"]) {
|
|
437
|
+
if (!(key in originalEnv)) delete process.env[key];
|
|
438
|
+
else process.env[key] = originalEnv[key];
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("returns false for claudecode", () => {
|
|
443
|
+
expect(
|
|
444
|
+
resolveUsageAvailable("claudecode", {
|
|
445
|
+
provider: "claudecode",
|
|
446
|
+
messages: baseMessages,
|
|
447
|
+
}),
|
|
448
|
+
).toBe(false);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("returns false for opencode in cli mode", () => {
|
|
452
|
+
expect(
|
|
453
|
+
resolveUsageAvailable("opencode", {
|
|
454
|
+
provider: "opencode",
|
|
455
|
+
messages: baseMessages,
|
|
456
|
+
opencode: { mode: "cli" },
|
|
457
|
+
}),
|
|
458
|
+
).toBe(false);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("returns true for opencode in sdk mode", () => {
|
|
462
|
+
expect(
|
|
463
|
+
resolveUsageAvailable("opencode", {
|
|
464
|
+
provider: "opencode",
|
|
465
|
+
messages: baseMessages,
|
|
466
|
+
opencode: { mode: "sdk", baseUrl: "http://localhost:3000" },
|
|
467
|
+
}),
|
|
468
|
+
).toBe(true);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("returns true for opencode with mode omitted and PO_OPENCODE_BASE_URL set", () => {
|
|
472
|
+
process.env.PO_OPENCODE_BASE_URL = "http://localhost:3000";
|
|
473
|
+
expect(
|
|
474
|
+
resolveUsageAvailable("opencode", {
|
|
475
|
+
provider: "opencode",
|
|
476
|
+
messages: baseMessages,
|
|
477
|
+
}),
|
|
478
|
+
).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("returns false for opencode with mode omitted and no base URL", () => {
|
|
482
|
+
expect(
|
|
483
|
+
resolveUsageAvailable("opencode", {
|
|
484
|
+
provider: "opencode",
|
|
485
|
+
messages: baseMessages,
|
|
486
|
+
}),
|
|
487
|
+
).toBe(false);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("returns true for usage-capable providers (mock)", () => {
|
|
491
|
+
expect(
|
|
492
|
+
resolveUsageAvailable("mock", {
|
|
493
|
+
provider: "mock",
|
|
494
|
+
messages: baseMessages,
|
|
495
|
+
}),
|
|
496
|
+
).toBe(true);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// ── usageAvailable source guard (AC-4, invariant I-1) ──────────────────
|
|
501
|
+
|
|
502
|
+
describe("usageAvailable single-source guard", () => {
|
|
503
|
+
it("src/providers/*.ts (adapter sources, not tests) does not reference usageAvailable (the gateway helper owns it)", async () => {
|
|
504
|
+
const { Glob } = await import("bun");
|
|
505
|
+
const files: string[] = [];
|
|
506
|
+
for await (const path of new Glob("src/providers/*.ts").scan()) {
|
|
507
|
+
files.push(path);
|
|
508
|
+
}
|
|
509
|
+
const offenders: string[] = [];
|
|
510
|
+
for (const path of files) {
|
|
511
|
+
const text = await Bun.file(path).text();
|
|
512
|
+
if (text.includes("usageAvailable")) offenders.push(path);
|
|
513
|
+
}
|
|
514
|
+
// types.ts declares the field on AdapterResponse; that is the contract,
|
|
515
|
+
// not an adapter setting it. It is the only allowed file.
|
|
516
|
+
expect(offenders).toEqual(["src/providers/types.ts"]);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
251
520
|
// ── calculateCost ──────────────────────────────────────────────────────
|
|
252
521
|
|
|
253
522
|
describe("calculateCost()", () => {
|
|
@@ -257,6 +526,7 @@ describe("LLM Gateway", () => {
|
|
|
257
526
|
promptTokens: 1_000_000,
|
|
258
527
|
completionTokens: 1_000_000,
|
|
259
528
|
totalTokens: 2_000_000,
|
|
529
|
+
source: "reported",
|
|
260
530
|
};
|
|
261
531
|
const cost = calculateCost("anthropic", "claude-sonnet-4-6", usage);
|
|
262
532
|
// inCost: (1000000/1000000) * 3 = $3.00
|
|
@@ -269,6 +539,7 @@ describe("LLM Gateway", () => {
|
|
|
269
539
|
promptTokens: 1000,
|
|
270
540
|
completionTokens: 1000,
|
|
271
541
|
totalTokens: 2000,
|
|
542
|
+
source: "reported",
|
|
272
543
|
};
|
|
273
544
|
const cost = calculateCost("unknown", "unknown-model", usage);
|
|
274
545
|
expect(cost).toBe(0);
|
|
@@ -280,12 +551,187 @@ describe("LLM Gateway", () => {
|
|
|
280
551
|
promptTokens: 1000,
|
|
281
552
|
completionTokens: 1000,
|
|
282
553
|
totalTokens: 2000,
|
|
554
|
+
source: "reported",
|
|
283
555
|
};
|
|
284
556
|
const cost = calculateCost("claudecode", "sonnet", usage);
|
|
285
557
|
expect(cost).toBe(0);
|
|
286
558
|
});
|
|
287
559
|
});
|
|
288
560
|
|
|
561
|
+
// ── findModelPrice ─────────────────────────────────────────────────────
|
|
562
|
+
|
|
563
|
+
describe("findModelPrice()", () => {
|
|
564
|
+
it("returns the price for a known model looked up by alias", () => {
|
|
565
|
+
// anthropic:sonnet-4-6 → tokenCostInPerMillion: 3, tokenCostOutPerMillion: 15
|
|
566
|
+
const price = findModelPrice("anthropic", "claude-sonnet-4-6");
|
|
567
|
+
expect(price).toEqual({
|
|
568
|
+
tokenCostInPerMillion: 3,
|
|
569
|
+
tokenCostOutPerMillion: 15,
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("returns the zero-cost entry for claudecode provider mapping", () => {
|
|
574
|
+
// claude-code:sonnet — zero-cost subscription
|
|
575
|
+
const price = findModelPrice("claudecode", "sonnet");
|
|
576
|
+
expect(price).toEqual({
|
|
577
|
+
tokenCostInPerMillion: 0,
|
|
578
|
+
tokenCostOutPerMillion: 0,
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("returns the price for zhipu provider mapping", () => {
|
|
583
|
+
// zhipu maps to zai; zai:glm-5 → tokenCostInPerMillion: 1, tokenCostOutPerMillion: 3.2
|
|
584
|
+
const price = findModelPrice("zhipu", "glm-5");
|
|
585
|
+
expect(price).toEqual({
|
|
586
|
+
tokenCostInPerMillion: 1,
|
|
587
|
+
tokenCostOutPerMillion: 3.2,
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("returns null for an unknown provider+model", () => {
|
|
592
|
+
const price = findModelPrice("unknown", "nope");
|
|
593
|
+
expect(price).toBeNull();
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// ── cost classification (AC-5, AC-6, AC-7) ─────────────────────────────
|
|
598
|
+
|
|
599
|
+
describe("cost classification", () => {
|
|
600
|
+
it("AC-5: known model with reported usage → costEstimated:false and a computed cost", async () => {
|
|
601
|
+
// Use opencode SDK mode with model "default" (alias opencode:default is in
|
|
602
|
+
// MODEL_CONFIG). Mock the SDK to report measured tokens.
|
|
603
|
+
process.env["PO_OPENCODE_BASE_URL"] = "http://localhost:3000";
|
|
604
|
+
mockCreateClient.mockReturnValue({
|
|
605
|
+
session: {
|
|
606
|
+
create: mockSdkCreate,
|
|
607
|
+
prompt: mockSdkPrompt,
|
|
608
|
+
delete: mockSdkDelete,
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
mockSdkCreate.mockResolvedValue({
|
|
612
|
+
data: { id: "sess-ac5" },
|
|
613
|
+
error: undefined,
|
|
614
|
+
});
|
|
615
|
+
mockSdkPrompt.mockResolvedValue({
|
|
616
|
+
data: {
|
|
617
|
+
info: {
|
|
618
|
+
id: "msg-ac5",
|
|
619
|
+
sessionID: "sess-ac5",
|
|
620
|
+
role: "assistant" as const,
|
|
621
|
+
modelID: "default",
|
|
622
|
+
providerID: "opencode",
|
|
623
|
+
agent: "build",
|
|
624
|
+
cost: 0,
|
|
625
|
+
tokens: {
|
|
626
|
+
input: 150,
|
|
627
|
+
output: 80,
|
|
628
|
+
total: 230,
|
|
629
|
+
reasoning: 0,
|
|
630
|
+
cache: { read: 0, write: 0 },
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
parts: [{ type: "text" as const, text: "sdk measured response" }],
|
|
634
|
+
},
|
|
635
|
+
error: undefined,
|
|
636
|
+
});
|
|
637
|
+
mockSdkDelete.mockResolvedValue({ data: undefined, error: undefined });
|
|
638
|
+
|
|
639
|
+
const completeEvents: LLMRequestCompleteEvent[] = [];
|
|
640
|
+
getLLMEvents().on("llm:request:complete", (e) =>
|
|
641
|
+
completeEvents.push(e),
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
await chat({
|
|
646
|
+
provider: "opencode",
|
|
647
|
+
model: "default",
|
|
648
|
+
messages: baseMessages,
|
|
649
|
+
responseFormat: "text",
|
|
650
|
+
opencode: { mode: "sdk", baseUrl: "http://localhost:3000" },
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
expect(completeEvents).toHaveLength(1);
|
|
654
|
+
const evt = completeEvents[0]!;
|
|
655
|
+
expect(evt.usageSource).toBe("reported");
|
|
656
|
+
expect(evt.costEstimated).toBe(false);
|
|
657
|
+
// opencode:default is a zero-cost entry; computed cost is 0 from a known price.
|
|
658
|
+
expect(evt.cost).toBe(0);
|
|
659
|
+
} finally {
|
|
660
|
+
delete process.env["PO_OPENCODE_BASE_URL"];
|
|
661
|
+
mockCreateClient.mockClear();
|
|
662
|
+
mockSdkCreate.mockClear();
|
|
663
|
+
mockSdkPrompt.mockClear();
|
|
664
|
+
mockSdkDelete.mockClear();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("AC-6: unknown model → non-negative cost, costEstimated:true, and exactly one warning per alias", async () => {
|
|
669
|
+
const provider = makeMockProvider({
|
|
670
|
+
content: "ok",
|
|
671
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
672
|
+
});
|
|
673
|
+
registerMockProvider(provider);
|
|
674
|
+
|
|
675
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
676
|
+
const completeEvents: LLMRequestCompleteEvent[] = [];
|
|
677
|
+
getLLMEvents().on("llm:request:complete", (e) =>
|
|
678
|
+
completeEvents.push(e),
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
await chat({
|
|
683
|
+
provider: "mock",
|
|
684
|
+
model: "ac6-unknown-model",
|
|
685
|
+
messages: baseMessages,
|
|
686
|
+
});
|
|
687
|
+
await chat({
|
|
688
|
+
provider: "mock",
|
|
689
|
+
model: "ac6-unknown-model",
|
|
690
|
+
messages: baseMessages,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
expect(completeEvents).toHaveLength(2);
|
|
694
|
+
for (const evt of completeEvents) {
|
|
695
|
+
expect(evt.costEstimated).toBe(true);
|
|
696
|
+
expect(evt.cost).toBeGreaterThanOrEqual(0);
|
|
697
|
+
}
|
|
698
|
+
const aliasWarnings = warnSpy.mock.calls.filter((args) =>
|
|
699
|
+
String(args[0]).includes("mock:ac6-unknown-model"),
|
|
700
|
+
);
|
|
701
|
+
expect(aliasWarnings).toHaveLength(1);
|
|
702
|
+
} finally {
|
|
703
|
+
warnSpy.mockRestore();
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("AC-7: known model with non-reported source → costEstimated:true", async () => {
|
|
708
|
+
// opencode CLI mode → usageAvailable:false → absent usage → source "unavailable".
|
|
709
|
+
// Model "default" has a known price; the non-reported source forces costEstimated:true.
|
|
710
|
+
vi.spyOn(Bun, "spawn").mockReturnValue(
|
|
711
|
+
createOpenCodeProc([
|
|
712
|
+
'{"type":"text","part":{"text":"hello from opencode"}}\n',
|
|
713
|
+
]),
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
const completeEvents: LLMRequestCompleteEvent[] = [];
|
|
717
|
+
getLLMEvents().on("llm:request:complete", (e) =>
|
|
718
|
+
completeEvents.push(e),
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
await chat({
|
|
722
|
+
provider: "opencode",
|
|
723
|
+
model: "default",
|
|
724
|
+
messages: baseMessages,
|
|
725
|
+
responseFormat: "text",
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
expect(completeEvents).toHaveLength(1);
|
|
729
|
+
const evt = completeEvents[0]!;
|
|
730
|
+
expect(evt.usageSource).toBe("unavailable");
|
|
731
|
+
expect(evt.costEstimated).toBe(true);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
289
735
|
// ── createChain ────────────────────────────────────────────────────────
|
|
290
736
|
|
|
291
737
|
describe("createChain()", () => {
|
|
@@ -565,64 +1011,210 @@ describe("LLM Gateway", () => {
|
|
|
565
1011
|
});
|
|
566
1012
|
});
|
|
567
1013
|
|
|
568
|
-
// ── JSON Format Inference
|
|
1014
|
+
// ── JSON Format Inference (AC-13) ─────────────────────────────────────
|
|
569
1015
|
|
|
570
1016
|
describe("JSON format inference", () => {
|
|
571
|
-
it("
|
|
1017
|
+
it("AC-13: a json-bearing message with no responseFormat passes through without enabling JSON mode", async () => {
|
|
572
1018
|
const provider = makeMockProvider();
|
|
573
1019
|
registerMockProvider(provider);
|
|
574
1020
|
|
|
575
1021
|
await chat({
|
|
576
1022
|
provider: "mock",
|
|
577
|
-
messages: [{ role: "user", content: "Return JSON
|
|
578
|
-
// no responseFormat set
|
|
1023
|
+
messages: [{ role: "user", content: "Return a JSON object please" }],
|
|
579
1024
|
});
|
|
580
1025
|
|
|
581
|
-
|
|
582
|
-
|
|
1026
|
+
const callArgs = (provider.chat as ReturnType<typeof vi.fn>).mock.calls[0]![0] as ChatOptions;
|
|
1027
|
+
expect(callArgs.responseFormat).toBeUndefined();
|
|
583
1028
|
});
|
|
1029
|
+
});
|
|
584
1030
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1031
|
+
// ── JSON Repair (AC-15) ───────────────────────────────────────────────
|
|
1032
|
+
|
|
1033
|
+
describe("JSON repair", () => {
|
|
1034
|
+
it("AC-15: unparseable-then-valid triggers exactly one repair and emits counted repair usage", async () => {
|
|
1035
|
+
const provider: MockProvider = {
|
|
1036
|
+
chat: vi.fn()
|
|
1037
|
+
.mockRejectedValueOnce(
|
|
1038
|
+
new ProviderJsonParseError(
|
|
1039
|
+
"mock",
|
|
1040
|
+
"repair-valid",
|
|
1041
|
+
"{bad",
|
|
1042
|
+
undefined,
|
|
1043
|
+
{
|
|
1044
|
+
text: "{bad",
|
|
1045
|
+
usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 },
|
|
1046
|
+
},
|
|
1047
|
+
),
|
|
1048
|
+
)
|
|
1049
|
+
.mockResolvedValueOnce({
|
|
1050
|
+
content: { ok: true },
|
|
1051
|
+
usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 },
|
|
1052
|
+
}),
|
|
1053
|
+
};
|
|
1054
|
+
registerMockProvider(provider);
|
|
1055
|
+
|
|
1056
|
+
const completeEvents: LLMRequestCompleteEvent[] = [];
|
|
1057
|
+
getLLMEvents().on("llm:request:complete", (e) => completeEvents.push(e));
|
|
1058
|
+
|
|
1059
|
+
const result = await chat({
|
|
1060
|
+
provider: "mock",
|
|
1061
|
+
model: "repair-valid",
|
|
1062
|
+
messages: baseMessages,
|
|
1063
|
+
responseFormat: "json",
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
expect(result.content).toEqual({ ok: true });
|
|
1067
|
+
expect(provider.chat).toHaveBeenCalledTimes(2);
|
|
1068
|
+
expect(completeEvents).toHaveLength(1);
|
|
1069
|
+
expect(completeEvents[0]!.promptTokens).toBe(8);
|
|
1070
|
+
expect(completeEvents[0]!.completionTokens).toBe(11);
|
|
1071
|
+
expect(completeEvents[0]!.totalTokens).toBe(19);
|
|
1072
|
+
expect(completeEvents[0]!.usageSource).toBe("reported");
|
|
1073
|
+
expect(result.usage).toEqual({
|
|
1074
|
+
promptTokens: 8,
|
|
1075
|
+
completionTokens: 11,
|
|
1076
|
+
totalTokens: 19,
|
|
1077
|
+
source: "reported",
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it("AC-15: always unparseable triggers exactly one repair then throws ProviderJsonParseError", async () => {
|
|
1082
|
+
const provider: MockProvider = {
|
|
1083
|
+
chat: vi.fn()
|
|
1084
|
+
.mockRejectedValueOnce(
|
|
1085
|
+
new ProviderJsonParseError(
|
|
1086
|
+
"mock",
|
|
1087
|
+
"repair-fail",
|
|
1088
|
+
"{bad",
|
|
1089
|
+
undefined,
|
|
1090
|
+
{
|
|
1091
|
+
text: "{bad",
|
|
1092
|
+
usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 },
|
|
1093
|
+
},
|
|
1094
|
+
),
|
|
1095
|
+
)
|
|
1096
|
+
.mockRejectedValueOnce(
|
|
1097
|
+
new ProviderJsonParseError(
|
|
1098
|
+
"mock",
|
|
1099
|
+
"repair-fail",
|
|
1100
|
+
"{still bad",
|
|
1101
|
+
undefined,
|
|
1102
|
+
{
|
|
1103
|
+
text: "{still bad",
|
|
1104
|
+
usage: { prompt_tokens: 7, completion_tokens: 11, total_tokens: 18 },
|
|
1105
|
+
},
|
|
1106
|
+
),
|
|
1107
|
+
),
|
|
1108
|
+
};
|
|
1109
|
+
registerMockProvider(provider);
|
|
1110
|
+
|
|
1111
|
+
const errorEvents: LLMRequestErrorEvent[] = [];
|
|
1112
|
+
getLLMEvents().on("llm:request:error", (e) => errorEvents.push(e));
|
|
1113
|
+
|
|
1114
|
+
await expect(
|
|
1115
|
+
chat({
|
|
1116
|
+
provider: "mock",
|
|
1117
|
+
model: "repair-fail",
|
|
1118
|
+
messages: baseMessages,
|
|
1119
|
+
responseFormat: "json",
|
|
1120
|
+
}),
|
|
1121
|
+
).rejects.toBeInstanceOf(ProviderJsonParseError);
|
|
1122
|
+
|
|
1123
|
+
expect(provider.chat).toHaveBeenCalledTimes(2);
|
|
1124
|
+
expect(errorEvents).toHaveLength(1);
|
|
1125
|
+
expect(errorEvents[0]!.promptTokens).toBe(9);
|
|
1126
|
+
expect(errorEvents[0]!.completionTokens).toBe(14);
|
|
1127
|
+
expect(errorEvents[0]!.totalTokens).toBe(23);
|
|
1128
|
+
expect(errorEvents[0]!.usageSource).toBe("reported");
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it("AC-15: fed-back sample is bounded to 2,000 characters", async () => {
|
|
1132
|
+
const longSample = "a".repeat(5000);
|
|
1133
|
+
const provider: MockProvider = {
|
|
1134
|
+
chat: vi.fn()
|
|
1135
|
+
.mockRejectedValueOnce(
|
|
1136
|
+
new ProviderJsonParseError("mock", "repair-bound", longSample),
|
|
1137
|
+
)
|
|
1138
|
+
.mockResolvedValueOnce({ content: { ok: true } }),
|
|
1139
|
+
};
|
|
588
1140
|
registerMockProvider(provider);
|
|
589
1141
|
|
|
590
1142
|
await chat({
|
|
591
1143
|
provider: "mock",
|
|
592
|
-
|
|
1144
|
+
model: "repair-bound",
|
|
1145
|
+
messages: baseMessages,
|
|
1146
|
+
responseFormat: "json",
|
|
593
1147
|
});
|
|
594
1148
|
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
expect(
|
|
1149
|
+
const secondCall = (provider.chat as ReturnType<typeof vi.fn>).mock.calls[1]![0] as ChatOptions;
|
|
1150
|
+
const repairMessage = secondCall.messages.at(-1)!;
|
|
1151
|
+
expect(repairMessage.role).toBe("user");
|
|
1152
|
+
expect(repairMessage.content).toContain("a".repeat(2000));
|
|
1153
|
+
expect(repairMessage.content).not.toContain("a".repeat(2001));
|
|
598
1154
|
});
|
|
599
1155
|
|
|
600
|
-
it("
|
|
601
|
-
// We can't easily test openai without mocking fetch, but we can verify
|
|
602
|
-
// inference logic indirectly by checking mock receives correct options
|
|
603
|
-
// when we use a test that goes through the inference path
|
|
1156
|
+
it("AC-15: does not repair when responseFormat is absent (invariant I-3)", async () => {
|
|
604
1157
|
const provider: MockProvider = {
|
|
605
|
-
chat: vi.fn().
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1158
|
+
chat: vi.fn().mockRejectedValue(
|
|
1159
|
+
new ProviderJsonParseError("mock", "no-repair", "{bad"),
|
|
1160
|
+
),
|
|
1161
|
+
};
|
|
1162
|
+
registerMockProvider(provider);
|
|
1163
|
+
|
|
1164
|
+
await expect(
|
|
1165
|
+
chat({
|
|
1166
|
+
provider: "mock",
|
|
1167
|
+
model: "no-repair",
|
|
1168
|
+
messages: baseMessages,
|
|
612
1169
|
}),
|
|
1170
|
+
).rejects.toBeInstanceOf(ProviderJsonParseError);
|
|
1171
|
+
|
|
1172
|
+
expect(provider.chat).toHaveBeenCalledTimes(1);
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it("AC-15: does not repair when responseFormat is non-JSON (invariant I-3)", async () => {
|
|
1176
|
+
const provider: MockProvider = {
|
|
1177
|
+
chat: vi.fn().mockRejectedValue(
|
|
1178
|
+
new ProviderJsonParseError("mock", "text-no-repair", "{bad"),
|
|
1179
|
+
),
|
|
1180
|
+
};
|
|
1181
|
+
registerMockProvider(provider);
|
|
1182
|
+
|
|
1183
|
+
await expect(
|
|
1184
|
+
chat({
|
|
1185
|
+
provider: "mock",
|
|
1186
|
+
model: "text-no-repair",
|
|
1187
|
+
messages: baseMessages,
|
|
1188
|
+
responseFormat: "text",
|
|
1189
|
+
}),
|
|
1190
|
+
).rejects.toBeInstanceOf(ProviderJsonParseError);
|
|
1191
|
+
|
|
1192
|
+
expect(provider.chat).toHaveBeenCalledTimes(1);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
it("AC-15: repair re-ask reuses the parent requestTimeoutMs (invariant I-4)", async () => {
|
|
1196
|
+
const provider: MockProvider = {
|
|
1197
|
+
chat: vi.fn()
|
|
1198
|
+
.mockRejectedValueOnce(
|
|
1199
|
+
new ProviderJsonParseError("mock", "repair-timeout", "{bad"),
|
|
1200
|
+
)
|
|
1201
|
+
.mockResolvedValueOnce({ content: { ok: true } }),
|
|
613
1202
|
};
|
|
614
1203
|
registerMockProvider(provider);
|
|
615
1204
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1205
|
+
await chat({
|
|
1206
|
+
provider: "mock",
|
|
1207
|
+
model: "repair-timeout",
|
|
1208
|
+
messages: baseMessages,
|
|
1209
|
+
responseFormat: "json",
|
|
1210
|
+
requestTimeoutMs: 42_000,
|
|
1211
|
+
});
|
|
622
1212
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
1213
|
+
const calls = (provider.chat as ReturnType<typeof vi.fn>).mock.calls;
|
|
1214
|
+
const firstTimeout = (calls[0]![0] as ChatOptions).requestTimeoutMs;
|
|
1215
|
+
const secondTimeout = (calls[1]![0] as ChatOptions).requestTimeoutMs;
|
|
1216
|
+
expect(firstTimeout).toBe(42_000);
|
|
1217
|
+
expect(secondTimeout).toBe(42_000);
|
|
626
1218
|
});
|
|
627
1219
|
});
|
|
628
1220
|
|
|
@@ -744,6 +1336,7 @@ describe("LLM Gateway", () => {
|
|
|
744
1336
|
completionTokens: estimateTokens("hello from opencode"),
|
|
745
1337
|
totalTokens:
|
|
746
1338
|
estimateTokens("Hello") + estimateTokens("hello from opencode"),
|
|
1339
|
+
source: "unavailable",
|
|
747
1340
|
});
|
|
748
1341
|
expect(completeEvents).toHaveLength(1);
|
|
749
1342
|
expect(completeEvents[0]!.provider).toBe("opencode");
|
|
@@ -847,6 +1440,7 @@ describe("LLM Gateway", () => {
|
|
|
847
1440
|
promptTokens: 150,
|
|
848
1441
|
completionTokens: 80,
|
|
849
1442
|
totalTokens: 230,
|
|
1443
|
+
source: "reported",
|
|
850
1444
|
});
|
|
851
1445
|
expect(completeEvents).toHaveLength(1);
|
|
852
1446
|
expect(completeEvents[0]!.promptTokens).toBe(150);
|
|
@@ -855,4 +1449,61 @@ describe("LLM Gateway", () => {
|
|
|
855
1449
|
expect(completeEvents[0]!.provider).toBe("opencode");
|
|
856
1450
|
});
|
|
857
1451
|
});
|
|
1452
|
+
|
|
1453
|
+
// ── LLM_DEBUG sink (AC-7, AC-8, AC-9) ────────────────────────────────
|
|
1454
|
+
|
|
1455
|
+
describe("LLM_DEBUG sink", () => {
|
|
1456
|
+
const path = join(tmpdir(), `pop-llm-debug-${process.pid}.log`);
|
|
1457
|
+
let originalLlmDebug: string | undefined;
|
|
1458
|
+
|
|
1459
|
+
beforeEach(() => {
|
|
1460
|
+
originalLlmDebug = process.env["LLM_DEBUG"];
|
|
1461
|
+
rmSync(path, { force: true });
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
afterEach(() => {
|
|
1465
|
+
if (originalLlmDebug === undefined) {
|
|
1466
|
+
delete process.env["LLM_DEBUG"];
|
|
1467
|
+
} else {
|
|
1468
|
+
process.env["LLM_DEBUG"] = originalLlmDebug;
|
|
1469
|
+
}
|
|
1470
|
+
rmSync(path, { force: true });
|
|
1471
|
+
rmSync("/tmp/messages.log", { force: true });
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
it("writes debug entry to a per-process tmpdir file when LLM_DEBUG is set", async () => {
|
|
1475
|
+
process.env["LLM_DEBUG"] = "1";
|
|
1476
|
+
rmSync(path, { force: true });
|
|
1477
|
+
rmSync("/tmp/messages.log", { force: true });
|
|
1478
|
+
registerMockProvider(makeMockProvider());
|
|
1479
|
+
|
|
1480
|
+
await chat({ provider: "mock", model: "test-model", messages: baseMessages });
|
|
1481
|
+
|
|
1482
|
+
expect(existsSync(path)).toBe(true);
|
|
1483
|
+
expect(existsSync("/tmp/messages.log")).toBe(false);
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
it("repairs an existing broader-permission debug file to 0600", async () => {
|
|
1487
|
+
process.env["LLM_DEBUG"] = "1";
|
|
1488
|
+
rmSync(path, { force: true });
|
|
1489
|
+
writeFileSync(path, "stale\n", { mode: 0o644 });
|
|
1490
|
+
chmodSync(path, 0o644);
|
|
1491
|
+
registerMockProvider(makeMockProvider());
|
|
1492
|
+
|
|
1493
|
+
await chat({ provider: "mock", model: "test-model", messages: baseMessages });
|
|
1494
|
+
|
|
1495
|
+
const st = statSync(path);
|
|
1496
|
+
expect(st.mode & 0o777).toBe(0o600);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
it("does not write a debug file when LLM_DEBUG is unset", async () => {
|
|
1500
|
+
delete process.env["LLM_DEBUG"];
|
|
1501
|
+
rmSync(path, { force: true });
|
|
1502
|
+
registerMockProvider(makeMockProvider());
|
|
1503
|
+
|
|
1504
|
+
await chat({ provider: "mock", model: "test-model", messages: baseMessages });
|
|
1505
|
+
|
|
1506
|
+
expect(existsSync(path)).toBe(false);
|
|
1507
|
+
});
|
|
1508
|
+
});
|
|
858
1509
|
});
|