@plurnk/plurnk-providers 1.5.0 → 1.6.0

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 (89) hide show
  1. package/.env.defaults +36 -22
  2. package/SPEC.md +133 -59
  3. package/dist/AiSdkProvider.d.ts +19 -26
  4. package/dist/AiSdkProvider.d.ts.map +1 -1
  5. package/dist/AiSdkProvider.js +318 -106
  6. package/dist/AiSdkProvider.js.map +1 -1
  7. package/dist/Mock.d.ts +4 -9
  8. package/dist/Mock.d.ts.map +1 -1
  9. package/dist/Mock.js +36 -9
  10. package/dist/Mock.js.map +1 -1
  11. package/dist/Pool.d.ts +2 -21
  12. package/dist/Pool.d.ts.map +1 -1
  13. package/dist/Pool.js +19 -14
  14. package/dist/Pool.js.map +1 -1
  15. package/dist/accounting.d.ts +5 -2
  16. package/dist/accounting.d.ts.map +1 -1
  17. package/dist/accounting.js +100 -16
  18. package/dist/accounting.js.map +1 -1
  19. package/dist/aiSdkTransport.d.ts +9 -2
  20. package/dist/aiSdkTransport.d.ts.map +1 -1
  21. package/dist/aiSdkTransport.js +160 -62
  22. package/dist/aiSdkTransport.js.map +1 -1
  23. package/dist/catalogProvider.d.ts +7 -3
  24. package/dist/catalogProvider.d.ts.map +1 -1
  25. package/dist/catalogProvider.js +30 -24
  26. package/dist/catalogProvider.js.map +1 -1
  27. package/dist/compatibleProvider.d.ts.map +1 -1
  28. package/dist/compatibleProvider.js +18 -7
  29. package/dist/compatibleProvider.js.map +1 -1
  30. package/dist/cost.d.ts +10 -10
  31. package/dist/cost.d.ts.map +1 -1
  32. package/dist/cost.js +90 -42
  33. package/dist/cost.js.map +1 -1
  34. package/dist/env.d.ts +5 -1
  35. package/dist/env.d.ts.map +1 -1
  36. package/dist/env.js +30 -10
  37. package/dist/env.js.map +1 -1
  38. package/dist/errors.d.ts +14 -2
  39. package/dist/errors.d.ts.map +1 -1
  40. package/dist/errors.js +58 -2
  41. package/dist/errors.js.map +1 -1
  42. package/dist/index.d.ts +4 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +3 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/ollama.js +3 -3
  47. package/dist/ollama.js.map +1 -1
  48. package/dist/sdkModels.d.ts +6 -2
  49. package/dist/sdkModels.d.ts.map +1 -1
  50. package/dist/sdkModels.js +38 -5
  51. package/dist/sdkModels.js.map +1 -1
  52. package/dist/types.d.ts +33 -31
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/usage.d.ts +21 -5
  55. package/dist/usage.d.ts.map +1 -1
  56. package/dist/usage.js +164 -83
  57. package/dist/usage.js.map +1 -1
  58. package/package.json +7 -6
  59. package/src/AiSdkProvider.test.ts +788 -191
  60. package/src/AiSdkProvider.ts +381 -124
  61. package/src/Mock.test.ts +37 -12
  62. package/src/Mock.ts +45 -14
  63. package/src/Pool.test.ts +19 -6
  64. package/src/Pool.ts +20 -16
  65. package/src/ProviderRegistry.test.ts +16 -11
  66. package/src/accounting.test.ts +58 -22
  67. package/src/accounting.ts +120 -18
  68. package/src/aiSdkTransport.test.ts +42 -49
  69. package/src/aiSdkTransport.ts +174 -62
  70. package/src/boundaries.test.ts +1 -0
  71. package/src/catalogProvider.test.ts +258 -22
  72. package/src/catalogProvider.ts +42 -27
  73. package/src/compatibleProvider.test.ts +6 -3
  74. package/src/compatibleProvider.ts +20 -7
  75. package/src/cost.test.ts +55 -36
  76. package/src/cost.ts +111 -50
  77. package/src/defaults.test.ts +13 -3
  78. package/src/env.test.ts +54 -5
  79. package/src/env.ts +43 -18
  80. package/src/errors.test.ts +47 -2
  81. package/src/errors.ts +67 -3
  82. package/src/index.ts +21 -5
  83. package/src/ollama.test.ts +4 -1
  84. package/src/ollama.ts +3 -3
  85. package/src/sdkModels.test.ts +76 -4
  86. package/src/sdkModels.ts +45 -7
  87. package/src/types.ts +77 -38
  88. package/src/usage.test.ts +112 -116
  89. package/src/usage.ts +209 -93
package/src/Mock.test.ts CHANGED
@@ -3,6 +3,7 @@ import { strict as assert } from "node:assert";
3
3
  import Mock from "./Mock.ts";
4
4
  import type { Provider } from "./types.ts";
5
5
  import type { MockResponse } from "./Mock.ts";
6
+ import { ProviderError } from "./errors.ts";
6
7
 
7
8
  const build = (responses: MockResponse[] = [{ assistant: { content: "hi", reasoning: null } }]) =>
8
9
  new Mock({ contextWindow: 100000, responses });
@@ -34,19 +35,23 @@ test("Mock: prompt counting is exact for its declared mock vocabulary", async ()
34
35
  });
35
36
  });
36
37
 
37
- test("Mock: calculateCost returns its deliberate zero estimate", () => {
38
- const m = build();
39
- assert.equal(m.calculateCost({ prompt: 100, completion: 20, reasoning: 10, cached: 5, total: 130 }), 0);
40
- });
41
-
42
38
  // — Transport ({§provider-interface}) —
43
39
 
44
40
  test("Mock: generate resolves a valid ProviderResponse shape", async () => {
45
41
  const m = build([{ assistant: { content: "hello", reasoning: "cot" } }]);
46
- const { assistant, assistantRaw } = await m.generate({ messages: [] });
42
+ const { assistant, assistantRaw, accounting } = await m.generate({ messages: [] });
47
43
  assert.equal(assistant.content, "hello");
48
44
  assert.equal(assistant.reasoning, "cot");
49
- assert.deepEqual(assistant.usage, { prompt: 0, completion: 0, reasoning: 0, cached: 0, total: 0 });
45
+ assert.deepEqual(accounting[0]?.usage, {
46
+ inputTokens: 0,
47
+ outputTokens: 0,
48
+ totalTokens: 0,
49
+ });
50
+ assert.deepEqual(accounting[0]?.cost, {
51
+ kind: "estimated",
52
+ amount: { amount: "0", currency: "USD" },
53
+ source: "mock provider fixture",
54
+ });
50
55
  assert.equal(assistant.finishReason, "stop");
51
56
  assert.equal(assistant.model, "mock");
52
57
  assert.equal(assistantRaw, null); // present, defaulted
@@ -57,16 +62,20 @@ test("Mock: generate applies caller-supplied overrides", async () => {
57
62
  assistant: {
58
63
  content: "x",
59
64
  reasoning: null,
60
- usage: { prompt: 1, completion: 2, reasoning: 0, cached: 0, total: 3 },
61
65
  finishReason: "length",
62
66
  model: "mock-xl",
63
67
  },
68
+ usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
64
69
  assistantRaw: { wire: true },
65
70
  }]);
66
- const { assistant, assistantRaw } = await m.generate({ messages: [] });
71
+ const { assistant, assistantRaw, accounting } = await m.generate({ messages: [] });
67
72
  assert.equal(assistant.finishReason, "length");
68
73
  assert.equal(assistant.model, "mock-xl");
69
- assert.deepEqual(assistant.usage, { prompt: 1, completion: 2, reasoning: 0, cached: 0, total: 3 });
74
+ assert.deepEqual(accounting[0]?.usage, {
75
+ inputTokens: 1,
76
+ outputTokens: 2,
77
+ totalTokens: 3,
78
+ });
70
79
  assert.deepEqual(assistantRaw, { wire: true });
71
80
  });
72
81
 
@@ -116,9 +125,25 @@ test("Mock: remaining decrements as responses are consumed", async () => {
116
125
  assert.equal(m.remaining, 1);
117
126
  });
118
127
 
119
- test("Mock: exhausted queue throws a specific error", async () => {
128
+ test("Mock: exhausted queue throws a ProviderError carrying its settled accounting", async () => {
120
129
  const m = build([]);
121
- await assert.rejects(() => m.generate({ messages: [] }), /exhausted/);
130
+ await assert.rejects(
131
+ () => m.generate({ messages: [] }),
132
+ (error: unknown) => {
133
+ assert.ok(error instanceof ProviderError);
134
+ assert.match(error.message, /exhausted/);
135
+ assert.deepEqual(error.accounting, [{
136
+ provider: "provider:mock",
137
+ model: "mock",
138
+ outcome: "error",
139
+ cost: {
140
+ kind: "unknown",
141
+ reason: "mock provider exhausted before producing a response",
142
+ },
143
+ }]);
144
+ return true;
145
+ },
146
+ );
122
147
  });
123
148
 
124
149
  // -- {§provider-generation-envelope} --
package/src/Mock.ts CHANGED
@@ -5,15 +5,14 @@
5
5
  // Provider contract. Production providers don't expose the `ops` escape
6
6
  // hatch — that's an intg-only convenience.
7
7
 
8
- import type { AuthoritativeCharge, ChatMessage, FinishReason, GrammarEvidence, PromptTokenMeasurement, Provider, ProviderAssistant, ProviderEncryptedReasoningItem, ProviderResponse, ProviderUsage } from "./types.ts";
9
- import type { ProviderCost } from "@plurnk/plurnk-contracts";
8
+ import type { ChatMessage, FinishReason, GrammarEvidence, PromptTokenMeasurement, Provider, ProviderAssistant, ProviderCost, ProviderEncryptedReasoningItem, ProviderRequestAccounting, ProviderResponse, ProviderUsage } from "./types.ts";
10
9
  import { resolveEnvelopeFromEnv } from "./env.ts";
10
+ import { validateProviderRequestAccounting } from "./accounting.ts";
11
+ import { ProviderError } from "./errors.ts";
11
12
 
12
13
  export type MockAssistant = {
13
14
  content: string;
14
15
  reasoning: string | null;
15
- // Partial — omitted fields fall back to DEFAULT_USAGE (e.g. reasoning: 0).
16
- usage?: Partial<ProviderUsage>;
17
16
  finishReason?: FinishReason;
18
17
  model?: string;
19
18
  // Provider-normalized encrypted reasoning fixture.
@@ -28,7 +27,9 @@ export type MockAssistant = {
28
27
  export type MockResponse = {
29
28
  assistant: MockAssistant;
30
29
  assistantRaw?: unknown;
31
- charge?: AuthoritativeCharge;
30
+ // Partial — omitted fields fall back to the deliberate zero fixture.
31
+ usage?: Partial<ProviderUsage>;
32
+ cost?: ProviderCost;
32
33
  grammarEvidence?: GrammarEvidence;
33
34
  };
34
35
 
@@ -36,7 +37,11 @@ export type MockResponse = {
36
37
  export type MockReturnedAssistant = ProviderAssistant & { ops?: unknown[] };
37
38
  export type MockReturnedResponse = ProviderResponse & { assistant: MockReturnedAssistant };
38
39
 
39
- const DEFAULT_USAGE: ProviderUsage = { prompt: 0, completion: 0, reasoning: 0, cached: 0, total: 0 };
40
+ const DEFAULT_USAGE: ProviderUsage = {
41
+ inputTokens: 0,
42
+ outputTokens: 0,
43
+ totalTokens: 0,
44
+ };
40
45
  type MockGenerateArgs = Omit<Parameters<Provider["generate"]>[0], "workerId"> & { workerId?: string };
41
46
 
42
47
  export default class Mock implements Provider {
@@ -77,22 +82,48 @@ export default class Mock implements Provider {
77
82
  };
78
83
  }
79
84
 
80
- // Mock is free.
81
- calculateCost(_usage: ProviderUsage): number { return 0; }
82
- calculateCharge(_usage: ProviderUsage): Exclude<ProviderCost, { kind: "authoritative" }> { return { kind: "free", source: "mock provider" }; }
83
-
84
- async generate({ signal, grammar }: MockGenerateArgs): Promise<MockReturnedResponse> {
85
+ async generate({ signal, grammar, observeRequest }: MockGenerateArgs): Promise<MockReturnedResponse> {
85
86
  // Honor abort before consuming the queue — an aborted call makes no
86
87
  // "wire call" and must not exhaust a queued response
87
88
  // ({§provider-failure-normalization}).
88
89
  signal?.throwIfAborted();
90
+ const settle = await observeRequest?.({ provider: "provider:mock", model: this.model });
89
91
  const next = this.#queue.shift();
90
- if (next === undefined) throw new Error("Mock provider exhausted: no more queued responses");
92
+ if (next === undefined) {
93
+ const accounting = validateProviderRequestAccounting({
94
+ provider: "provider:mock",
95
+ model: this.model,
96
+ outcome: "error",
97
+ cost: { kind: "unknown", reason: "mock provider exhausted before producing a response" },
98
+ });
99
+ await settle?.(accounting);
100
+ throw new ProviderError(
101
+ "mock",
102
+ "invalid_response",
103
+ "Mock provider exhausted: no more queued responses",
104
+ { accounting: [accounting] },
105
+ );
106
+ }
91
107
  const a = next.assistant;
108
+ const usage: ProviderUsage = {
109
+ ...DEFAULT_USAGE,
110
+ ...next.usage,
111
+ };
112
+ const requestAccounting: ProviderRequestAccounting = validateProviderRequestAccounting({
113
+ provider: "provider:mock",
114
+ model: a.model ?? this.model,
115
+ outcome: "response",
116
+ usage,
117
+ cost: next.cost ?? {
118
+ kind: "estimated",
119
+ amount: { amount: "0", currency: "USD" },
120
+ source: "mock provider fixture",
121
+ },
122
+ });
123
+ await settle?.(requestAccounting);
92
124
  const assistant: MockReturnedAssistant = {
93
125
  content: a.content,
94
126
  reasoning: a.reasoning,
95
- usage: { ...DEFAULT_USAGE, ...a.usage },
96
127
  ...(a.reasoningEncrypted !== undefined ? { reasoningEncrypted: a.reasoningEncrypted } : {}),
97
128
  finishReason: a.finishReason ?? "stop",
98
129
  model: a.model ?? "mock",
@@ -105,7 +136,7 @@ export default class Mock implements Provider {
105
136
  return {
106
137
  assistant,
107
138
  assistantRaw: next.assistantRaw ?? null,
108
- ...(next.charge === undefined ? {} : { charge: next.charge }),
139
+ accounting: [requestAccounting],
109
140
  ...(grammarEvidence !== undefined ? { grammarEvidence } : {}),
110
141
  };
111
142
  }
package/src/Pool.test.ts CHANGED
@@ -7,13 +7,28 @@ import { resetEmittedWarnings } from "./warnings.ts";
7
7
 
8
8
  test.afterEach(() => { resetEmittedWarnings(); });
9
9
 
10
- const RESP = { assistant: { usage: { prompt: 1, completion: 1, reasoning: 0, cached: 0, total: 2 } }, assistantRaw: null } as unknown as ProviderResponse;
10
+ const RESP: ProviderResponse = {
11
+ assistant: {
12
+ content: "ok",
13
+ reasoning: null,
14
+ finishReason: "stop",
15
+ model: "gemma",
16
+ },
17
+ assistantRaw: null,
18
+ accounting: [{
19
+ provider: "provider:test",
20
+ model: "gemma",
21
+ outcome: "response",
22
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
23
+ cost: { kind: "unknown", reason: "test fixture has no cost" },
24
+ }],
25
+ };
11
26
 
12
27
  type FakeOpts = {
13
28
  model?: string; window?: number | null; servedModel?: string;
14
29
  constrainsOutput?: boolean; requiresMaxTokens?: boolean;
15
30
  reasoningReserve?: number | null; completionReserve?: number | null;
16
- tokenize?: boolean; cost?: number; throws?: Error;
31
+ tokenize?: boolean; throws?: Error;
17
32
  promptMeasurement?: PromptTokenMeasurement;
18
33
  };
19
34
  // A fake backend that records which workers it served, and optionally throws.
@@ -33,7 +48,6 @@ const backend = (opts: FakeOpts = {}) => {
33
48
  tokens: messages.reduce((sum, { content }) => sum + content.length, 0),
34
49
  source: "test:exact",
35
50
  }),
36
- calculateCost: () => opts.cost ?? 0,
37
51
  generate: async (args: Parameters<Provider["generate"]>[0]): Promise<ProviderResponse> => {
38
52
  served.push(args.workerId);
39
53
  if (opts.throws !== undefined) throw opts.throws;
@@ -94,12 +108,11 @@ test("Pool: tokenize is exposed iff every backend has it", () => {
94
108
  assert.equal(new Pool([backend({ tokenize: true }).b, backend({ tokenize: false }).b]).tokenize, undefined);
95
109
  });
96
110
 
97
- test("Pool: prompt counting + calculateCost delegate to a backend", async () => {
98
- const p = new Pool([backend({ cost: 42 }).b]);
111
+ test("Pool: prompt counting delegates conservatively to its backends", async () => {
112
+ const p = new Pool([backend().b]);
99
113
  assert.deepEqual(await p.countPromptTokens([{ role: "user", content: "abcd" }]), {
100
114
  kind: "exact", tokens: 4, source: "pool:test:exact",
101
115
  });
102
- assert.equal(p.calculateCost({ prompt: 0, completion: 0, reasoning: 0, cached: 0, total: 0 }), 42);
103
116
  });
104
117
 
105
118
  test("Pool: prompt evidence is conservative across every routable backend", async () => {
package/src/Pool.ts CHANGED
@@ -1,6 +1,4 @@
1
- import type { Provider, ProviderResponse, ProviderUsage, ChatMessage, PromptTokenMeasurement } from "./types.ts";
2
- import type { ProviderCost } from "@plurnk/plurnk-contracts";
3
- import { resolveProviderCost } from "./cost.ts";
1
+ import type { Provider, ProviderRequestAccounting, ProviderResponse, ChatMessage, PromptTokenMeasurement } from "./types.ts";
4
2
  import { ProviderError, type ProviderErrorKind } from "./errors.ts";
5
3
  import { emitWarningOnce } from "./warnings.ts";
6
4
  import { assertPromptTokenMeasurement } from "./promptTokens.ts";
@@ -125,32 +123,38 @@ export default class Pool implements Provider {
125
123
  source: `pool:${sources}`,
126
124
  };
127
125
  }
128
- calculateCost(usage: ProviderUsage): number { return this.#backends[0].calculateCost(usage); }
129
- calculateCharge(usage: ProviderUsage): Exclude<ProviderCost, { kind: "authoritative" }> {
130
- const backend = this.#backends[0];
131
- return resolveProviderCost(undefined, backend.calculateCharge?.(usage)) as Exclude<ProviderCost, { kind: "authoritative" }>;
132
- }
133
-
134
126
  // --- dispatch ---
135
127
 
136
- async generate(args: { messages: ChatMessage[]; workerId: string; primaryWorkerId?: string; signal?: AbortSignal; grammar?: string; maxTokens?: number; attributions?: string[]; client?: string; strikes?: number; workspaceId?: string; loop?: number; turn?: number; sampling?: Record<string, unknown> }): Promise<ProviderResponse> {
128
+ async generate(args: Parameters<Provider["generate"]>[0]): Promise<ProviderResponse> {
137
129
  const { workerId, signal } = args;
138
130
  if (workerId === undefined || workerId.length === 0) throw new Error("Pool.generate: workerId is required - affinity keys on it");
139
131
  const tried = new Set<number>();
132
+ const priorAccounting: ProviderRequestAccounting[] = [];
140
133
  let idx = this.#route(workerId);
141
- let lastErr: unknown;
142
134
  for (;;) {
143
135
  tried.add(idx);
144
136
  try {
145
- return await this.#backends[idx].generate(args);
137
+ const response = await this.#backends[idx].generate(args);
138
+ return priorAccounting.length === 0
139
+ ? response
140
+ : { ...response, accounting: [...priorAccounting, ...response.accounting] };
146
141
  } catch (err) {
147
- lastErr = err;
148
- if (signal?.aborted) throw err; // caller cancellation is never a failover
142
+ if (signal?.aborted) {
143
+ if (err instanceof ProviderError) err.prependAccounting(priorAccounting);
144
+ throw err;
145
+ }
149
146
  // Only a backend-AVAILABILITY failure overflows; auth/quota/content/
150
147
  // malformed fail the same on a peer, so they propagate.
151
- if (!(err instanceof ProviderError) || !OVERFLOW_KINDS.has(err.kind)) throw err;
148
+ if (!(err instanceof ProviderError) || !OVERFLOW_KINDS.has(err.kind)) {
149
+ if (err instanceof ProviderError) err.prependAccounting(priorAccounting);
150
+ throw err;
151
+ }
152
152
  const next = this.#nextUntried(tried);
153
- if (next === null) throw lastErr; // the whole fleet is unavailable
153
+ if (next === null) {
154
+ err.prependAccounting(priorAccounting);
155
+ throw err;
156
+ }
157
+ priorAccounting.push(...err.accounting);
154
158
  this.#affinity.set(workerId, next); // re-stick: the worker's cache moves with it
155
159
  idx = next;
156
160
  }
@@ -14,8 +14,10 @@ const mapOf = (entries: Record<string, string>, skipped: Record<string, string>
14
14
 
15
15
  const fullEnv = Object.freeze({
16
16
  PLURNK_PROVIDERS_FETCH_TIMEOUT: "600000",
17
+ PLURNK_PROVIDERS_OPERATION_TIMEOUT: "2700000",
18
+ PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT: "600000",
17
19
  PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT: "0",
18
- PLURNK_PROVIDERS_REASONING: "off", PLURNK_PROVIDERS_TEMPERATURE: "0.2", PLURNK_PROVIDERS_REPEAT_PENALTY: "1.15", PLURNK_PROVIDERS_FREQUENCY_PENALTY: "0.4", PLURNK_PROVIDERS_REASONING_RESERVE: "10%", PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%", PLURNK_PROVIDERS_PROBE_ATTEMPTS: "3", PLURNK_PROVIDERS_PROBE_DELAY: "1", PLURNK_PROVIDERS_RETRY_ATTEMPTS: "0", PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT: "512", PLURNK_PROVIDERS_PROMPT_CACHE_KEY: "1",
20
+ PLURNK_PROVIDERS_REASONING: "off", PLURNK_PROVIDERS_TEMPERATURE: "0.2", PLURNK_PROVIDERS_REPEAT_PENALTY: "1.15", PLURNK_PROVIDERS_FREQUENCY_PENALTY: "0.4", PLURNK_PROVIDERS_REASONING_RESERVE: "10%", PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%", PLURNK_PROVIDERS_PROBE_ATTEMPTS: "3", PLURNK_PROVIDERS_PROBE_DELAY: "1", PLURNK_PROVIDERS_RETRY_ATTEMPTS: "0", PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT: "512", PLURNK_PROVIDERS_CACHE_AFFINITY: "1", PLURNK_PROVIDERS_CACHE_WRITE_POLICY: "stable-system",
19
21
  OPENAI_BASE_URL: "http://x",
20
22
  });
21
23
 
@@ -271,30 +273,33 @@ test("{§deepseek-reasoning-request} #157: direct DeepSeek composes catalog fact
271
273
  assert.deepEqual(request.thinking, { type: "disabled" });
272
274
  assert.equal(provider.contextWindow, 1_000_000);
273
275
  assert.equal(response.assistant.content, "ok");
274
- assert.deepEqual(response.assistant.usage, {
275
- prompt: 10,
276
- completion: 2,
277
- reasoning: 0,
278
- cached: 8,
279
- total: 12,
276
+ assert.deepEqual(response.accounting[0]?.usage, {
277
+ inputTokens: 10,
278
+ outputTokens: 2,
279
+ totalTokens: 12,
280
+ inputTokenDetails: { noCacheTokens: 2, cacheReadTokens: 8 },
281
+ });
282
+ assert.deepEqual(response.accounting[0]?.cost, {
283
+ kind: "estimated",
284
+ amount: { amount: "0.0000008624", currency: "USD" },
285
+ source: "Models.dev catalog rates",
280
286
  });
281
- assert.ok(Math.abs(provider.calculateCost(response.assistant.usage) - 0.0000008624) < 1e-15);
282
287
  mock.restoreAll();
283
288
  });
284
289
 
285
- test("an explicit malformed operator override still fails at its owning contract", async () => {
290
+ test("an explicit malformed cache-affinity override still fails at its owning contract", async () => {
286
291
  await assert.rejects(
287
292
  () => instantiateProvider(
288
293
  "fireworks",
289
294
  {
290
295
  FIREWORKS_API_KEY: "fw",
291
- PLURNK_PROVIDERS_PROMPT_CACHE_KEY: "malformed",
296
+ PLURNK_PROVIDERS_CACHE_AFFINITY: "malformed",
292
297
  },
293
298
  "deepseek-v4-pro",
294
299
  async () => ({}),
295
300
  mapOf({}),
296
301
  ),
297
- /fireworks provider: PLURNK_PROVIDERS_PROMPT_CACHE_KEY must be "0" or "1"/,
302
+ /fireworks provider: PLURNK_PROVIDERS_CACHE_AFFINITY must be "0" or "1"/,
298
303
  );
299
304
  });
300
305
 
@@ -1,18 +1,27 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { authoritativeChargeNormalizer } from "./accounting.ts";
3
+ import {
4
+ aggregateProviderAccounting,
5
+ plurnkCostNormalizer,
6
+ providerCostNormalizer,
7
+ } from "./accounting.ts";
4
8
 
5
- const evidence = ({ providerMetadata, usage }: { providerMetadata?: unknown; usage?: unknown }) => ({
9
+ const evidence = ({ providerMetadata, usage, charge }: {
10
+ providerMetadata?: unknown;
11
+ usage?: unknown;
12
+ charge?: unknown;
13
+ }) => ({
6
14
  ...(providerMetadata === undefined ? {} : { providerMetadata }),
7
15
  ...(usage === undefined ? {} : { usage }),
16
+ ...(charge === undefined ? {} : { charge }),
8
17
  response: { id: "response-1" },
9
18
  });
10
19
 
11
- test("xAI response ticks normalize to an exact provider-authoritative charge", () => {
12
- const normalize = authoritativeChargeNormalizer("@ai-sdk/xai");
20
+ test("xAI response ticks normalize to a directly charged request", () => {
21
+ const normalize = providerCostNormalizer("@ai-sdk/xai");
13
22
  assert.notEqual(normalize, undefined);
14
23
  assert.deepEqual(normalize!(evidence({ usage: { cost_in_usd_ticks: 15_493_500 } })), {
15
- kind: "authoritative",
24
+ kind: "charged",
16
25
  amount: { amount: "15493500", currency: "USDTICK" },
17
26
  usdEquivalent: "0.00154935",
18
27
  source: "xAI response usage.cost_in_usd_ticks",
@@ -20,39 +29,66 @@ test("xAI response ticks normalize to an exact provider-authoritative charge", (
20
29
  });
21
30
 
22
31
  test("OpenRouter response cost normalizes without rate reconstruction", () => {
23
- const normalize = authoritativeChargeNormalizer("@openrouter/ai-sdk-provider");
32
+ const normalize = providerCostNormalizer("@openrouter/ai-sdk-provider");
24
33
  assert.notEqual(normalize, undefined);
25
34
  assert.deepEqual(normalize!(evidence({ providerMetadata: { openrouter: { usage: { cost: 3.2e-7 } } } })), {
26
- kind: "authoritative",
35
+ kind: "charged",
27
36
  amount: { amount: "0.00000032", currency: "USD" },
28
- usdEquivalent: "0.00000032",
29
37
  source: "OpenRouter response usage.cost",
30
38
  });
31
39
  });
32
40
 
33
- test("DeepInfra's documented response estimate wins over local rate reconstruction", () => {
34
- const normalize = authoritativeChargeNormalizer("@ai-sdk/deepinfra");
41
+ test("DeepInfra's documented response estimate remains estimated", () => {
42
+ const normalize = providerCostNormalizer("@ai-sdk/deepinfra");
35
43
  assert.notEqual(normalize, undefined);
36
44
  assert.deepEqual(normalize!(evidence({ usage: { estimated_cost: 5.04e-5 } })), {
37
- kind: "authoritative",
45
+ kind: "estimated",
38
46
  amount: { amount: "0.0000504", currency: "USD" },
39
- usdEquivalent: "0.0000504",
40
47
  source: "DeepInfra response usage.estimated_cost",
41
48
  });
42
49
  });
43
50
 
51
+ test("first-party charged evidence is validated at its adapter boundary", () => {
52
+ const charged = {
53
+ kind: "charged",
54
+ amount: { amount: "0.01", currency: "USD" },
55
+ source: "plurnk endpoint",
56
+ } as const;
57
+ assert.deepEqual(plurnkCostNormalizer(evidence({ charge: charged })), charged);
58
+ assert.equal(plurnkCostNormalizer(evidence({})), undefined);
59
+ });
60
+
44
61
  test("response cost normalization is an explicit adapter capability", () => {
45
- assert.equal(authoritativeChargeNormalizer("@ai-sdk/anthropic"), undefined);
46
- assert.equal(
47
- authoritativeChargeNormalizer("@ai-sdk/xai")!(evidence({ usage: {} })),
48
- undefined,
49
- );
62
+ assert.equal(providerCostNormalizer("@ai-sdk/anthropic"), undefined);
63
+ assert.equal(providerCostNormalizer("@ai-sdk/xai")!(evidence({ usage: {} })), undefined);
50
64
  assert.throws(
51
- () => authoritativeChargeNormalizer("@ai-sdk/xai")!(evidence({ usage: { cost_in_usd_ticks: "1" } })),
65
+ () => providerCostNormalizer("@ai-sdk/xai")!(evidence({ usage: { cost_in_usd_ticks: "1" } })),
52
66
  /cost_in_usd_ticks must be numeric/,
53
67
  );
54
- assert.throws(
55
- () => authoritativeChargeNormalizer("@ai-sdk/deepinfra")!(evidence({ usage: { estimated_cost: "1" } })),
56
- /estimated_cost must be numeric/,
57
- );
68
+ });
69
+
70
+ test("aggregateProviderAccounting preserves request order and only sums known fields", () => {
71
+ const accounting = aggregateProviderAccounting([
72
+ {
73
+ provider: "provider:a",
74
+ model: "m",
75
+ outcome: "error",
76
+ status: 429,
77
+ cost: { kind: "unknown", reason: "no response accounting" },
78
+ },
79
+ {
80
+ provider: "provider:b",
81
+ model: "m",
82
+ outcome: "response",
83
+ usage: { inputTokens: 2, outputTokens: 3, totalTokens: 5 },
84
+ cost: {
85
+ kind: "charged",
86
+ amount: { amount: "0.25", currency: "USD" },
87
+ source: "provider b",
88
+ },
89
+ },
90
+ ]);
91
+ assert.deepEqual(accounting.requests.map(({ provider }) => provider), ["provider:a", "provider:b"]);
92
+ assert.equal(accounting.usage, null, "an unknown failed request prevents fabricated totals");
93
+ assert.equal(accounting.costUsd, null);
58
94
  });