@ryanfw/prompt-orchestration-pipeline 1.3.4 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/docs/http-api.md +3 -0
  2. package/docs/pop-task-guide.md +8 -0
  3. package/package.json +5 -2
  4. package/src/cli/__tests__/analyze-task.test.ts +1 -2
  5. package/src/config/__tests__/config-schema.test.ts +42 -0
  6. package/src/config/__tests__/legacy-literal-guard.test.ts +156 -0
  7. package/src/config/__tests__/models.test.ts +139 -1
  8. package/src/config/models.ts +121 -19
  9. package/src/core/__tests__/agent-step.test.ts +52 -0
  10. package/src/core/__tests__/job-view.test.ts +98 -0
  11. package/src/core/agent-step.ts +5 -1
  12. package/src/core/agent-types.ts +6 -1
  13. package/src/core/job-view.ts +21 -1
  14. package/src/llm/__tests__/dispatch.test.ts +130 -0
  15. package/src/llm/__tests__/index.test.ts +17 -18
  16. package/src/llm/__tests__/legacy-normalization.test.ts +109 -0
  17. package/src/llm/index.ts +109 -112
  18. package/src/providers/__tests__/claude-code.test.ts +9 -3
  19. package/src/providers/__tests__/moonshot.test.ts +33 -3
  20. package/src/providers/__tests__/types.test.ts +43 -0
  21. package/src/providers/__tests__/zhipu.test.ts +24 -28
  22. package/src/providers/claude-code.ts +2 -2
  23. package/src/providers/moonshot.ts +7 -0
  24. package/src/providers/types.ts +7 -5
  25. package/src/providers/zhipu.ts +0 -2
  26. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  27. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +36 -1
  28. package/src/task-analysis/analyzer.ts +4 -10
  29. package/src/task-analysis/enrichers/schema-deducer.ts +8 -9
  30. package/src/ui/dist/assets/{index-DN3-zvtP.js → index-lBbVeNZC.js} +5 -2
  31. package/src/ui/dist/assets/{index-DN3-zvtP.js.map → index-lBbVeNZC.js.map} +1 -1
  32. package/src/ui/dist/index.html +1 -1
  33. package/src/ui/pages/Code.tsx +5 -2
  34. package/src/ui/server/__tests__/http-api-contract.test.ts +838 -0
  35. package/src/ui/server/__tests__/job-control-endpoints.test.ts +134 -27
  36. package/src/ui/server/__tests__/job-control-service.test.ts +282 -0
  37. package/src/ui/server/__tests__/job-process-registry.test.ts +151 -0
  38. package/src/ui/server/__tests__/job-repository.test.ts +142 -0
  39. package/src/ui/server/config-bridge.ts +9 -6
  40. package/src/ui/server/embedded-assets.ts +4 -4
  41. package/src/ui/server/endpoints/gate-endpoints.ts +24 -12
  42. package/src/ui/server/endpoints/job-control-endpoints.ts +19 -763
  43. package/src/ui/server/job-control-service.ts +520 -0
  44. package/src/ui/server/job-process-registry.ts +250 -0
  45. package/src/ui/server/job-repository.ts +316 -0
@@ -1196,6 +1196,104 @@ describe("normalizeJobView", () => {
1196
1196
  });
1197
1197
  });
1198
1198
  });
1199
+
1200
+ describe("tokenUsage provider key normalization (AC-8)", () => {
1201
+ test('rewrites a persisted "zhipu:<model>" key to "zai:<model>"', () => {
1202
+ // Arrange / Act
1203
+ const view: JobView = normalizeJobView(
1204
+ {
1205
+ state: "done",
1206
+ tasks: { alpha: { state: "done", tokenUsage: [["zhipu:glm-5", 10, 5, 0.25]] } },
1207
+ },
1208
+ JOB_ID,
1209
+ LOCATION,
1210
+ );
1211
+
1212
+ // Assert
1213
+ expect(view.tasks.alpha?.tokenUsage).toEqual([["zai:glm-5", 10, 5, 0.25]]);
1214
+ });
1215
+
1216
+ test('rewrites a persisted "claudecode:<model>" key to "claude-code:<model>"', () => {
1217
+ // Arrange / Act
1218
+ const view: JobView = normalizeJobView(
1219
+ {
1220
+ state: "done",
1221
+ tasks: { alpha: { state: "done", tokenUsage: [["claudecode:sonnet", 1, 2, 0]] } },
1222
+ },
1223
+ JOB_ID,
1224
+ LOCATION,
1225
+ );
1226
+
1227
+ // Assert
1228
+ expect(view.tasks.alpha?.tokenUsage).toEqual([["claude-code:sonnet", 1, 2, 0]]);
1229
+ });
1230
+
1231
+ test("preserves an unknown provider segment unchanged", () => {
1232
+ // Arrange / Act
1233
+ const view: JobView = normalizeJobView(
1234
+ {
1235
+ state: "done",
1236
+ tasks: { alpha: { state: "done", tokenUsage: [["unknown:model", 1, 1, 1]] } },
1237
+ },
1238
+ JOB_ID,
1239
+ LOCATION,
1240
+ );
1241
+
1242
+ // Assert
1243
+ expect(view.tasks.alpha?.tokenUsage).toEqual([["unknown:model", 1, 1, 1]]);
1244
+ });
1245
+
1246
+ test("preserves a colonless first element unchanged", () => {
1247
+ // Arrange / Act
1248
+ const view: JobView = normalizeJobView(
1249
+ {
1250
+ state: "done",
1251
+ tasks: { alpha: { state: "done", tokenUsage: [["bad"]] } },
1252
+ },
1253
+ JOB_ID,
1254
+ LOCATION,
1255
+ );
1256
+
1257
+ // Assert
1258
+ expect(view.tasks.alpha?.tokenUsage).toEqual([["bad"]]);
1259
+ });
1260
+
1261
+ test("normalizes only the first colon-delimited segment", () => {
1262
+ // Arrange / Act
1263
+ const view: JobView = normalizeJobView(
1264
+ {
1265
+ state: "done",
1266
+ tasks: { alpha: { state: "done", tokenUsage: [["zhipu:glm-5:extra", 1, 1, 1]] } },
1267
+ },
1268
+ JOB_ID,
1269
+ LOCATION,
1270
+ );
1271
+
1272
+ // Assert
1273
+ expect(view.tasks.alpha?.tokenUsage).toEqual([["zai:glm-5:extra", 1, 1, 1]]);
1274
+ });
1275
+
1276
+ test("cost totals match the pre-normalization values for a legacy key", () => {
1277
+ // Arrange / Act
1278
+ const view: JobView = normalizeJobView(
1279
+ {
1280
+ state: "done",
1281
+ tasks: { alpha: { state: "done", tokenUsage: [["zhipu:glm-5", 10, 5, 0.25]] } },
1282
+ },
1283
+ JOB_ID,
1284
+ LOCATION,
1285
+ );
1286
+
1287
+ // Assert — the numeric aggregation is independent of the key normalization.
1288
+ expect(view.costs).toMatchObject({
1289
+ totalCost: 0.25,
1290
+ totalTokens: 15,
1291
+ totalInputTokens: 10,
1292
+ totalOutputTokens: 5,
1293
+ });
1294
+ expect(view.tasks.alpha?.tokenUsage?.[0]).toEqual(["zai:glm-5", 10, 5, 0.25]);
1295
+ });
1296
+ });
1199
1297
  });
1200
1298
 
1201
1299
  describe("deriveProgress (AC-6)", () => {
@@ -292,7 +292,10 @@ interface AgentRunDeps {
292
292
  export async function executeAgent(
293
293
  args: {
294
294
  io: TaskFileIO;
295
- entry: AgentEntryConfig & { name: string };
295
+ entry: AgentEntryConfig & {
296
+ name: string;
297
+ onEvent?: (event: HarnessEvent) => void;
298
+ };
296
299
  },
297
300
  deps?: AgentRunDeps,
298
301
  ): Promise<AgentStepResult> {
@@ -347,6 +350,7 @@ export async function executeAgent(
347
350
  void io.writeLog(logName, JSON.stringify(redacted) + "\n", {
348
351
  mode: "append",
349
352
  });
353
+ entry.onEvent?.(event);
350
354
  },
351
355
  };
352
356
 
@@ -1,4 +1,4 @@
1
- import type { HarnessName, ModelPayload, Usage } from "../harness/index.ts";
1
+ import type { HarnessEvent, HarnessName, ModelPayload, Usage } from "../harness/index.ts";
2
2
 
3
3
  export interface McpServerConnection {
4
4
  url: string;
@@ -54,6 +54,11 @@ export interface TaskAgentOptions {
54
54
  idleTimeoutMs?: number;
55
55
  /** Capture a git diff of the working tree as an `agent.patch` artifact. */
56
56
  captureDiff?: boolean;
57
+ /**
58
+ * Invoked for every adapter {@link HarnessEvent} observed during the run,
59
+ * after POP's own buffer + debug-log write. Absent, behavior is unchanged.
60
+ */
61
+ onEvent?: (event: HarnessEvent) => void;
57
62
  }
58
63
 
59
64
  /** The `runAgent()` function injected into JavaScript task stages. */
@@ -1,3 +1,4 @@
1
+ import { normalizeLegacyProvider } from "../config/models.ts";
1
2
  import {
2
3
  JOB_STATUS_SYNONYMS,
3
4
  VALID_JOB_STATUSES,
@@ -145,6 +146,25 @@ function toTaskError(value: unknown): JobViewTask["error"] {
145
146
  return undefined;
146
147
  }
147
148
 
149
+ function normalizeTokenUsageEntry(entry: unknown): unknown {
150
+ if (!Array.isArray(entry)) return entry;
151
+ const first = entry[0];
152
+ if (typeof first !== "string") return entry;
153
+ const colonIndex = first.indexOf(":");
154
+ if (colonIndex === -1) return entry;
155
+ const providerSegment = first.slice(0, colonIndex);
156
+ const canonical = normalizeLegacyProvider(providerSegment);
157
+ if (canonical === null) return entry;
158
+ const copy = entry.slice();
159
+ copy[0] = `${canonical}:${first.slice(colonIndex + 1)}`;
160
+ return copy;
161
+ }
162
+
163
+ function normalizeTokenUsage(value: unknown): unknown[] | undefined {
164
+ if (!Array.isArray(value)) return undefined;
165
+ return value.map(normalizeTokenUsageEntry);
166
+ }
167
+
148
168
  function toGateInfo(value: unknown): GateInfo | null | undefined {
149
169
  if (value === null) return null;
150
170
  if (!isRecord(value)) return undefined;
@@ -189,7 +209,7 @@ function toJobViewTask(name: string, value: unknown): JobViewTask {
189
209
  currentStage: typeof task["currentStage"] === "string" ? task["currentStage"] : undefined,
190
210
  failedStage: typeof task["failedStage"] === "string" ? task["failedStage"] : undefined,
191
211
  artifacts: task["artifacts"],
192
- tokenUsage: Array.isArray(task["tokenUsage"]) ? task["tokenUsage"] : undefined,
212
+ tokenUsage: normalizeTokenUsage(task["tokenUsage"]),
193
213
  error: toTaskError(task["error"]),
194
214
  skipReason: typeof task["skipReason"] === "string" ? task["skipReason"] : undefined,
195
215
  skippedBy: typeof task["skippedBy"] === "string" ? task["skippedBy"] : undefined,
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { mock } from "bun:test";
3
+ import type { ChatOptions } from "../../providers/types.ts";
4
+
5
+ let moonshotReceived: Record<string, unknown> | undefined;
6
+ let claudeCodeReceived: Record<string, unknown> | undefined;
7
+
8
+ const moonshotChatMock = vi.fn(async (opts: ChatOptions) => {
9
+ moonshotReceived = opts as unknown as Record<string, unknown>;
10
+ return {
11
+ content: "moonshot-response",
12
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
13
+ };
14
+ });
15
+
16
+ const claudeCodeChatMock = vi.fn(async (opts: ChatOptions) => {
17
+ claudeCodeReceived = opts as unknown as Record<string, unknown>;
18
+ return { content: "claude-code-response" };
19
+ });
20
+
21
+ mock.module("../../providers/moonshot.ts", () => ({
22
+ moonshotChat: moonshotChatMock,
23
+ }));
24
+
25
+ mock.module("../../providers/claude-code.ts", () => ({
26
+ claudeCodeChat: claudeCodeChatMock,
27
+ isClaudeCodeAvailable: () => false,
28
+ }));
29
+
30
+ import { chat, registerMockProvider } from "../index.ts";
31
+ import type { MockProvider } from "../../providers/types.ts";
32
+
33
+ const baseMessages = [{ role: "user" as const, content: "hi" }];
34
+
35
+ describe("registry dispatch", () => {
36
+ it("moonshot forwards only declared sampling params (temperature, topP, stop)", async () => {
37
+ moonshotChatMock.mockClear();
38
+ await chat({
39
+ provider: "moonshot",
40
+ messages: baseMessages,
41
+ model: "kimi-k2.5",
42
+ temperature: 0.5,
43
+ topP: 0.9,
44
+ stop: ["end"],
45
+ frequencyPenalty: 1.0,
46
+ presencePenalty: 0.5,
47
+ });
48
+
49
+ expect(moonshotReceived).toBeDefined();
50
+ const opts = moonshotReceived!;
51
+ expect(opts.temperature).toBe(0.5);
52
+ expect(opts.topP).toBe(0.9);
53
+ expect(opts.stop).toEqual(["end"]);
54
+ expect(opts.frequencyPenalty).toBeUndefined();
55
+ expect(opts.presencePenalty).toBeUndefined();
56
+ });
57
+
58
+ it("claude-code (CLI) forwards no sampling params", async () => {
59
+ claudeCodeChatMock.mockClear();
60
+ await chat({
61
+ provider: "claude-code",
62
+ messages: baseMessages,
63
+ model: "sonnet",
64
+ temperature: 0.5,
65
+ topP: 0.9,
66
+ stop: ["x"],
67
+ frequencyPenalty: 1.0,
68
+ presencePenalty: 0.5,
69
+ });
70
+
71
+ expect(claudeCodeReceived).toBeDefined();
72
+ const opts = claudeCodeReceived!;
73
+ expect(opts.temperature).toBeUndefined();
74
+ expect(opts.topP).toBeUndefined();
75
+ expect(opts.stop).toBeUndefined();
76
+ expect(opts.frequencyPenalty).toBeUndefined();
77
+ expect(opts.presencePenalty).toBeUndefined();
78
+ });
79
+
80
+ it("forwards common fields to the adapter", async () => {
81
+ moonshotChatMock.mockClear();
82
+ await chat({
83
+ provider: "moonshot",
84
+ messages: baseMessages,
85
+ model: "kimi-k2.5",
86
+ maxTokens: 4096,
87
+ maxRetries: 2,
88
+ requestTimeoutMs: 30_000,
89
+ });
90
+
91
+ expect(moonshotReceived).toBeDefined();
92
+ const opts = moonshotReceived!;
93
+ expect(opts.messages).toEqual(baseMessages);
94
+ expect(opts.model).toBe("kimi-k2.5");
95
+ expect(opts.maxTokens).toBe(4096);
96
+ expect(opts.maxRetries).toBe(2);
97
+ expect(opts.requestTimeoutMs).toBe(30_000);
98
+ });
99
+
100
+ it("mock dispatches through registered mockProvider with common fields", async () => {
101
+ const mockFn = vi.fn().mockResolvedValue({
102
+ content: { mock: true },
103
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
104
+ });
105
+ registerMockProvider({ chat: mockFn } as MockProvider);
106
+
107
+ await chat({
108
+ provider: "mock",
109
+ messages: baseMessages,
110
+ model: "test-model",
111
+ });
112
+
113
+ expect(mockFn).toHaveBeenCalledTimes(1);
114
+ const received = mockFn.mock.calls[0]![0] as Record<string, unknown>;
115
+ expect(received.provider).toBe("mock");
116
+ expect(received.messages).toEqual(baseMessages);
117
+ expect(received.model).toBe("test-model");
118
+ });
119
+
120
+ it("throws for unknown provider before dispatch", async () => {
121
+ await expect(
122
+ chat({ provider: "nonexistent" as "mock", messages: baseMessages }),
123
+ ).rejects.toThrow(/unknown provider/i);
124
+ });
125
+ });
126
+
127
+ // ADAPTERS type-level exhaustiveness: ADAPTERS in src/llm/index.ts is typed
128
+ // `Record<ProviderName, AdapterFn>`. Adding a new ProviderName without an
129
+ // ADAPTERS row fails `bun run typecheck` because the mapped type requires every
130
+ // union member. This is a compile-time guarantee — no runtime test needed.
@@ -439,10 +439,10 @@ describe("LLM Gateway", () => {
439
439
  }
440
440
  });
441
441
 
442
- it("returns false for claudecode", () => {
442
+ it("returns false for claude-code", () => {
443
443
  expect(
444
- resolveUsageAvailable("claudecode", {
445
- provider: "claudecode",
444
+ resolveUsageAvailable("claude-code", {
445
+ provider: "claude-code",
446
446
  messages: baseMessages,
447
447
  }),
448
448
  ).toBe(false);
@@ -545,7 +545,7 @@ describe("LLM Gateway", () => {
545
545
  expect(cost).toBe(0);
546
546
  });
547
547
 
548
- it("handles claudecode provider name mapping", () => {
548
+ it("prices claude-code provider", () => {
549
549
  // claude-code:sonnet — tokenCostInPerMillion: 0, tokenCostOutPerMillion: 0
550
550
  const usage: NormalizedUsage = {
551
551
  promptTokens: 1000,
@@ -553,7 +553,7 @@ describe("LLM Gateway", () => {
553
553
  totalTokens: 2000,
554
554
  source: "reported",
555
555
  };
556
- const cost = calculateCost("claudecode", "sonnet", usage);
556
+ const cost = calculateCost("claude-code", "sonnet", usage);
557
557
  expect(cost).toBe(0);
558
558
  });
559
559
  });
@@ -570,18 +570,18 @@ describe("LLM Gateway", () => {
570
570
  });
571
571
  });
572
572
 
573
- it("returns the zero-cost entry for claudecode provider mapping", () => {
573
+ it("returns the zero-cost entry for claude-code", () => {
574
574
  // claude-code:sonnet — zero-cost subscription
575
- const price = findModelPrice("claudecode", "sonnet");
575
+ const price = findModelPrice("claude-code", "sonnet");
576
576
  expect(price).toEqual({
577
577
  tokenCostInPerMillion: 0,
578
578
  tokenCostOutPerMillion: 0,
579
579
  });
580
580
  });
581
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");
582
+ it("returns the price for zai", () => {
583
+ // zai:glm-5 → tokenCostInPerMillion: 1, tokenCostOutPerMillion: 3.2
584
+ const price = findModelPrice("zai", "glm-5");
585
585
  expect(price).toEqual({
586
586
  tokenCostInPerMillion: 1,
587
587
  tokenCostOutPerMillion: 3.2,
@@ -874,10 +874,10 @@ describe("LLM Gateway", () => {
874
874
  // Should have provider groups
875
875
  expect(typeof llm).toBe("object");
876
876
 
877
- // Check that known providers exist under the public gateway names.
877
+ // Check that known providers exist under the canonical gateway names.
878
878
  expect(llm["openai"]).toBeDefined();
879
879
  expect(llm["anthropic"]).toBeDefined();
880
- expect(llm["claudecode"]).toBeDefined();
880
+ expect(llm["claude-code"]).toBeDefined();
881
881
  expect(llm["zai"]).toBeDefined();
882
882
 
883
883
  // Each provider group should have callable functions
@@ -891,13 +891,14 @@ describe("LLM Gateway", () => {
891
891
  }
892
892
  });
893
893
 
894
- it("keeps the claude-code config-name alias and exposes zai directly", () => {
894
+ it("exposes canonical provider groups only (no legacy alias keys)", () => {
895
895
  const llm = createLLM();
896
896
 
897
- expect(llm["claudecode"]).toBe(llm["claude-code"]);
898
- expect(typeof llm["claudecode"]?.["sonnet"]).toBe("function");
897
+ expect(typeof llm["claude-code"]?.["sonnet"]).toBe("function");
899
898
  expect(typeof llm["zai"]?.["glm5"]).toBe("function");
900
- expect(llm["zhipu"]).toBe(llm["zai"]);
899
+ // Legacy spellings are gone from the model-map surface.
900
+ expect(llm["claudecode"]).toBeUndefined();
901
+ expect(llm["zhipu"]).toBeUndefined();
901
902
  expect(llm["xai"]).toBeUndefined();
902
903
  });
903
904
 
@@ -974,7 +975,6 @@ describe("LLM Gateway", () => {
974
975
  expect(availability.gemini).toBe(false);
975
976
  expect(availability.deepseek).toBe(false);
976
977
  expect(availability.zai).toBe(false);
977
- expect(availability.zhipu).toBe(false);
978
978
  expect(availability.moonshot).toBe(false);
979
979
  expect(availability.alibaba).toBe(false);
980
980
  });
@@ -990,7 +990,6 @@ describe("LLM Gateway", () => {
990
990
  expect(availability.openai).toBe(true);
991
991
  expect(availability.anthropic).toBe(true);
992
992
  expect(availability.zai).toBe(true);
993
- expect(availability.zhipu).toBe(true);
994
993
  expect(availability.alibaba).toBe(true);
995
994
  });
996
995
 
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { mock } from "bun:test";
3
+ import type { ChatOptions } from "../../providers/types.ts";
4
+
5
+ const zaiChatMock = vi.fn(async (_opts: ChatOptions) => ({
6
+ content: { ok: true },
7
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
8
+ }));
9
+
10
+ mock.module("../../providers/zhipu.ts", () => ({
11
+ zaiChat: zaiChatMock,
12
+ }));
13
+
14
+ mock.module("../../core/config.ts", () => ({
15
+ getConfig: () => ({
16
+ llm: {
17
+ defaultProvider: "zhipu",
18
+ defaultModel: "glm-5",
19
+ internalProvider: "zai",
20
+ internalModel: "glm-5",
21
+ maxConcurrency: 5,
22
+ retryMaxAttempts: 3,
23
+ retryBackoffMs: 1000,
24
+ },
25
+ taskRunner: {
26
+ maxRefinementAttempts: 3,
27
+ stageTimeout: 300000,
28
+ llmRequestTimeout: 3600000,
29
+ maxAttempts: 3,
30
+ },
31
+ }),
32
+ resetConfig: () => {},
33
+ }));
34
+
35
+ import {
36
+ chat,
37
+ complete,
38
+ createLLMWithOverride,
39
+ findModelPrice,
40
+ } from "../index.ts";
41
+
42
+ const baseMessages = [{ role: "user" as const, content: "hi" }];
43
+
44
+ function lastCallProvider(): string {
45
+ const calls = zaiChatMock.mock.calls as unknown as Array<
46
+ [ChatOptions]
47
+ >;
48
+ return calls[0]![0].provider;
49
+ }
50
+
51
+ describe("legacy provider normalization (AC-4)", () => {
52
+ it("chat() dispatches legacy zhipu to the zai adapter", async () => {
53
+ zaiChatMock.mockClear();
54
+
55
+ await chat({
56
+ provider: "zhipu" as never,
57
+ messages: baseMessages,
58
+ model: "glm-5",
59
+ });
60
+
61
+ expect(zaiChatMock).toHaveBeenCalledTimes(1);
62
+ expect(lastCallProvider()).toBe("zai");
63
+ });
64
+
65
+ it("complete() resolves a legacy zhipu defaultProvider to zai", async () => {
66
+ zaiChatMock.mockClear();
67
+
68
+ await complete("hello");
69
+
70
+ expect(zaiChatMock).toHaveBeenCalledTimes(1);
71
+ expect(lastCallProvider()).toBe("zai");
72
+ });
73
+
74
+ it("createLLMWithOverride() resolves a legacy zhipu override to zai", async () => {
75
+ zaiChatMock.mockClear();
76
+
77
+ const llm = createLLMWithOverride({
78
+ provider: "zhipu" as never,
79
+ model: "glm-5",
80
+ });
81
+
82
+ await llm.chat({ provider: "openai", messages: baseMessages } as ChatOptions);
83
+
84
+ expect(zaiChatMock).toHaveBeenCalledTimes(1);
85
+ expect(lastCallProvider()).toBe("zai");
86
+ });
87
+
88
+ it("findModelPrice() normalizes a persisted zhipu cost key to zai", () => {
89
+ const legacyPrice = findModelPrice("zhipu", "glm-5");
90
+ const canonicalPrice = findModelPrice("zai", "glm-5");
91
+
92
+ expect(legacyPrice).not.toBeNull();
93
+ expect(legacyPrice).toEqual(canonicalPrice);
94
+ expect(legacyPrice).toEqual({
95
+ tokenCostInPerMillion: 1,
96
+ tokenCostOutPerMillion: 3.2,
97
+ });
98
+ });
99
+
100
+ it("chat() throws an explicit Unknown provider error before adapter dispatch", async () => {
101
+ zaiChatMock.mockClear();
102
+
103
+ await expect(
104
+ chat({ provider: "neverheard" as never, messages: baseMessages }),
105
+ ).rejects.toThrow("Unknown provider: neverheard");
106
+
107
+ expect(zaiChatMock).not.toHaveBeenCalled();
108
+ });
109
+ });