@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/accounting.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import type {
2
- AuthoritativeChargeNormalizer,
2
+ ChargedCost,
3
+ ProviderAccounting,
4
+ ProviderCost,
5
+ ProviderCostNormalizer,
6
+ ProviderRequestAccounting,
7
+ ProviderUsage,
3
8
  } from "./types.ts";
9
+ import {
10
+ sumProviderCostsUsd,
11
+ validateChargedCost,
12
+ validateProviderCost,
13
+ } from "./cost.ts";
14
+ import { validateProviderUsage } from "./usage.ts";
4
15
 
5
16
  const recordOf = (value: unknown): Record<string, unknown> | null =>
6
17
  typeof value === "object" && value !== null && !Array.isArray(value)
@@ -33,7 +44,7 @@ const usdFromTicks = (ticks: number): string => {
33
44
  return fraction === "" ? integer : `${integer}.${fraction}`;
34
45
  };
35
46
 
36
- const xaiCharge: AuthoritativeChargeNormalizer = ({ usage }) => {
47
+ const xaiCost: ProviderCostNormalizer = ({ usage }) => {
37
48
  const wireUsage = recordOf(usage);
38
49
  if (wireUsage === null || !("cost_in_usd_ticks" in wireUsage)) return undefined;
39
50
  const ticks = wireUsage.cost_in_usd_ticks;
@@ -41,48 +52,139 @@ const xaiCharge: AuthoritativeChargeNormalizer = ({ usage }) => {
41
52
  throw new TypeError("xAI usage.cost_in_usd_ticks must be numeric");
42
53
  }
43
54
  return {
44
- kind: "authoritative",
55
+ kind: "charged",
45
56
  amount: { amount: String(ticks), currency: "USDTICK" },
46
57
  usdEquivalent: usdFromTicks(ticks),
47
58
  source: "xAI response usage.cost_in_usd_ticks",
48
59
  };
49
60
  };
50
61
 
51
- const openRouterCharge: AuthoritativeChargeNormalizer = ({ providerMetadata }) => {
62
+ const openRouterCost: ProviderCostNormalizer = ({ providerMetadata }) => {
52
63
  const usage = recordOf(recordOf(recordOf(providerMetadata)?.openrouter)?.usage);
53
64
  if (usage === null || !("cost" in usage)) return undefined;
54
65
  const cost = usage.cost;
55
66
  if (typeof cost !== "number") throw new TypeError("OpenRouter usage.cost must be numeric");
56
- const amount = decimalFromNumber(cost, "OpenRouter usage.cost");
57
67
  return {
58
- kind: "authoritative",
59
- amount: { amount, currency: "USD" },
60
- usdEquivalent: amount,
68
+ kind: "charged",
69
+ amount: { amount: decimalFromNumber(cost, "OpenRouter usage.cost"), currency: "USD" },
61
70
  source: "OpenRouter response usage.cost",
62
71
  };
63
72
  };
64
73
 
65
- const deepInfraCharge: AuthoritativeChargeNormalizer = ({ usage }) => {
74
+ const deepInfraCost: ProviderCostNormalizer = ({ usage }) => {
66
75
  const wireUsage = recordOf(usage);
67
76
  if (wireUsage === null || !("estimated_cost" in wireUsage)) return undefined;
68
77
  const cost = wireUsage.estimated_cost;
69
78
  if (typeof cost !== "number") throw new TypeError("DeepInfra usage.estimated_cost must be numeric");
70
- const amount = decimalFromNumber(cost, "DeepInfra usage.estimated_cost");
71
79
  return {
72
- kind: "authoritative",
73
- amount: { amount, currency: "USD" },
74
- usdEquivalent: amount,
80
+ kind: "estimated",
81
+ amount: { amount: decimalFromNumber(cost, "DeepInfra usage.estimated_cost"), currency: "USD" },
75
82
  source: "DeepInfra response usage.estimated_cost",
76
83
  };
77
84
  };
78
85
 
79
- export const authoritativeChargeNormalizer = (
86
+ // The first-party endpoint owns the direct charged-cost wire field.
87
+ export const plurnkCostNormalizer: ProviderCostNormalizer = ({ charge }) => {
88
+ if (charge === undefined) return undefined;
89
+ return validateChargedCost(charge) as ChargedCost;
90
+ };
91
+
92
+ export const providerCostNormalizer = (
80
93
  sdkPackage: string,
81
- ): AuthoritativeChargeNormalizer | undefined => {
94
+ ): ProviderCostNormalizer | undefined => {
82
95
  switch (sdkPackage) {
83
- case "@ai-sdk/xai": return xaiCharge;
84
- case "@ai-sdk/deepinfra": return deepInfraCharge;
85
- case "@openrouter/ai-sdk-provider": return openRouterCharge;
96
+ case "@ai-sdk/xai": return xaiCost;
97
+ case "@ai-sdk/deepinfra": return deepInfraCost;
98
+ case "@openrouter/ai-sdk-provider": return openRouterCost;
86
99
  default: return undefined;
87
100
  }
88
101
  };
102
+
103
+ export const validateProviderRequestAccounting = (
104
+ value: unknown,
105
+ ): ProviderRequestAccounting => {
106
+ const request = recordOf(value);
107
+ if (request === null) throw new TypeError("provider request accounting must be an object");
108
+ if (typeof request.provider !== "string" || request.provider.length === 0) {
109
+ throw new TypeError("provider request accounting.provider must be non-empty");
110
+ }
111
+ if (typeof request.model !== "string" || request.model.length === 0) {
112
+ throw new TypeError("provider request accounting.model must be non-empty");
113
+ }
114
+ if (request.outcome !== "response" && request.outcome !== "error") {
115
+ throw new TypeError("provider request accounting.outcome must be response or error");
116
+ }
117
+ if (request.status !== undefined
118
+ && (!Number.isInteger(request.status) || (request.status as number) < 100 || (request.status as number) > 599)) {
119
+ throw new TypeError("provider request accounting.status must be an HTTP status");
120
+ }
121
+ if (request.usage !== undefined) validateProviderUsage(request.usage as ProviderUsage);
122
+ validateProviderCost(request.cost);
123
+ return value as ProviderRequestAccounting;
124
+ };
125
+
126
+ const sumKnown = (
127
+ requests: readonly ProviderRequestAccounting[],
128
+ read: (usage: ProviderUsage) => number | undefined,
129
+ ): number | undefined => {
130
+ const values = requests.map((request) => request.usage === undefined
131
+ ? undefined
132
+ : read(request.usage));
133
+ return values.some((value) => value === undefined)
134
+ ? undefined
135
+ : (values as number[]).reduce((sum, value) => sum + value, 0);
136
+ };
137
+
138
+ export const aggregateProviderAccounting = (
139
+ values: readonly ProviderRequestAccounting[],
140
+ ): ProviderAccounting => {
141
+ const requests = values.map(validateProviderRequestAccounting);
142
+ if (requests.length === 0) {
143
+ return {
144
+ requests: [],
145
+ usage: {
146
+ inputTokens: 0,
147
+ outputTokens: 0,
148
+ totalTokens: 0,
149
+ inputTokenDetails: {
150
+ noCacheTokens: 0,
151
+ cacheReadTokens: 0,
152
+ cacheWriteTokens: 0,
153
+ },
154
+ outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
155
+ },
156
+ costUsd: "0",
157
+ };
158
+ }
159
+
160
+ const inputTokens = sumKnown(requests, (usage) => usage.inputTokens);
161
+ const outputTokens = sumKnown(requests, (usage) => usage.outputTokens);
162
+ const totalTokens = sumKnown(requests, (usage) => usage.totalTokens);
163
+ const noCacheTokens = sumKnown(requests, (usage) => usage.inputTokenDetails?.noCacheTokens);
164
+ const cacheReadTokens = sumKnown(requests, (usage) => usage.inputTokenDetails?.cacheReadTokens);
165
+ const cacheWriteTokens = sumKnown(requests, (usage) => usage.inputTokenDetails?.cacheWriteTokens);
166
+ const textTokens = sumKnown(requests, (usage) => usage.outputTokenDetails?.textTokens);
167
+ const reasoningTokens = sumKnown(requests, (usage) => usage.outputTokenDetails?.reasoningTokens);
168
+ const inputTokenDetails = noCacheTokens === undefined
169
+ && cacheReadTokens === undefined && cacheWriteTokens === undefined
170
+ ? undefined
171
+ : { noCacheTokens, cacheReadTokens, cacheWriteTokens };
172
+ const outputTokenDetails = textTokens === undefined && reasoningTokens === undefined
173
+ ? undefined
174
+ : { textTokens, reasoningTokens };
175
+ const usage = inputTokens === undefined && outputTokens === undefined && totalTokens === undefined
176
+ && inputTokenDetails === undefined && outputTokenDetails === undefined
177
+ ? null
178
+ : validateProviderUsage({
179
+ ...(inputTokens === undefined ? {} : { inputTokens }),
180
+ ...(outputTokens === undefined ? {} : { outputTokens }),
181
+ ...(totalTokens === undefined ? {} : { totalTokens }),
182
+ ...(inputTokenDetails === undefined ? {} : { inputTokenDetails }),
183
+ ...(outputTokenDetails === undefined ? {} : { outputTokenDetails }),
184
+ });
185
+ return {
186
+ requests: [...requests],
187
+ usage,
188
+ costUsd: sumProviderCostsUsd(requests.map(({ cost }) => cost)),
189
+ };
190
+ };
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { APICallError } from "ai";
3
4
  import { executeOpenAICompatible } from "./aiSdkTransport.ts";
4
5
 
5
6
  const request = {
@@ -9,29 +10,33 @@ const request = {
9
10
  body: {},
10
11
  messages: [{ role: "user" as const, content: "question" }],
11
12
  fetchTimeoutMs: 1_000,
12
- retryAttempts: 2,
13
13
  streaming: false,
14
14
  captureRawBody: false,
15
15
  };
16
16
 
17
- test("X-Should-Retry:false prevents nested retries for a normally retryable status", async () => {
17
+ test("the transport performs exactly one physical request", async () => {
18
18
  let calls = 0;
19
- await assert.rejects(executeOpenAICompatible({
20
- ...request,
21
- fetch: async () => {
22
- calls += 1;
23
- return new Response(
24
- JSON.stringify({ error: { message: "upstream attempts exhausted" } }),
25
- {
26
- status: 503,
27
- headers: {
28
- "content-type": "application/json",
29
- "x-should-retry": "false",
19
+ await assert.rejects(
20
+ executeOpenAICompatible({
21
+ ...request,
22
+ fetch: async () => {
23
+ calls += 1;
24
+ return new Response(
25
+ JSON.stringify({ error: { message: "upstream attempts exhausted" } }),
26
+ {
27
+ status: 503,
28
+ headers: {
29
+ "content-type": "application/json",
30
+ "x-should-retry": "false",
31
+ },
30
32
  },
31
- },
32
- );
33
- },
34
- }));
33
+ );
34
+ },
35
+ }),
36
+ (error) => APICallError.isInstance(error)
37
+ && error.statusCode === 503
38
+ && error.isRetryable === false,
39
+ );
35
40
  assert.equal(calls, 1);
36
41
  });
37
42
 
@@ -64,7 +69,6 @@ test("the adapter preserves PLURNK request extensions and response evidence", as
64
69
  };
65
70
  const result = await executeOpenAICompatible({
66
71
  ...request,
67
- retryAttempts: 0,
68
72
  captureRawBody: true,
69
73
  body: {
70
74
  grammar: "root ::= \"answer\"",
@@ -85,11 +89,10 @@ test("the adapter preserves PLURNK request extensions and response evidence", as
85
89
  assert.equal(result.reasoning, "because");
86
90
  assert.equal(result.finishReason, "stop");
87
91
  assert.deepEqual(result.usage, {
88
- prompt: 3,
89
- completion: 3,
90
- reasoning: 2,
91
- cached: 0,
92
- total: 8,
92
+ inputTokens: 3,
93
+ outputTokens: 5,
94
+ totalTokens: 8,
95
+ outputTokenDetails: { textTokens: 3, reasoningTokens: 2 },
93
96
  });
94
97
  assert.equal(result.logprobs[0]?.token, "answer");
95
98
  assert.deepEqual(result.metadata.balance, { amount: 1.25, currency: "USD" });
@@ -116,7 +119,6 @@ test("the adapter maps leading system messages to AI SDK instructions", async ()
116
119
  { role: "user", content: "hello" },
117
120
  ],
118
121
  fetchTimeoutMs: 1000,
119
- retryAttempts: 0,
120
122
  streaming: false,
121
123
  captureRawBody: false,
122
124
  fetch,
@@ -130,7 +132,6 @@ test("the adapter maps leading system messages to AI SDK instructions", async ()
130
132
  test("the adapter preserves nonstandard reasoning accounting after SDK parsing", async (t) => {
131
133
  const execute = (responseBody: object) => executeOpenAICompatible({
132
134
  ...request,
133
- retryAttempts: 0,
134
135
  fetch: async () => new Response(JSON.stringify(responseBody), {
135
136
  headers: { "content-type": "application/json" },
136
137
  }),
@@ -153,25 +154,22 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
153
154
  { prompt_tokens: 2, completion_tokens: 3, total_tokens: 9 },
154
155
  ));
155
156
  assert.deepEqual(result.usage, {
156
- prompt: 2,
157
- completion: 3,
158
- reasoning: 4,
159
- cached: 0,
160
- total: 9,
157
+ inputTokens: 2,
158
+ outputTokens: 7,
159
+ totalTokens: 9,
160
+ outputTokenDetails: { textTokens: 3, reasoningTokens: 4 },
161
161
  });
162
162
  });
163
163
 
164
- await t.test("Fireworks-style unitemized output is split by returned channels", async () => {
164
+ await t.test("Fireworks-style channels do not invent token attribution", async () => {
165
165
  const result = await execute(response(
166
166
  { content: "aa", reasoning_content: "bbbbbb" },
167
167
  { prompt_tokens: 2, completion_tokens: 10, total_tokens: 12 },
168
168
  ));
169
169
  assert.deepEqual(result.usage, {
170
- prompt: 2,
171
- completion: 2,
172
- reasoning: 8,
173
- cached: 0,
174
- total: 12,
170
+ inputTokens: 2,
171
+ outputTokens: 10,
172
+ totalTokens: 12,
175
173
  });
176
174
  });
177
175
 
@@ -195,7 +193,6 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
195
193
  ];
196
194
  const result = await executeOpenAICompatible({
197
195
  ...request,
198
- retryAttempts: 0,
199
196
  streaming: true,
200
197
  fetch: async () => new Response(
201
198
  `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`,
@@ -203,15 +200,14 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
203
200
  ),
204
201
  });
205
202
  assert.deepEqual(result.usage, {
206
- prompt: 2,
207
- completion: 3,
208
- reasoning: 4,
209
- cached: 0,
210
- total: 9,
203
+ inputTokens: 2,
204
+ outputTokens: 7,
205
+ totalTokens: 9,
206
+ outputTokenDetails: { textTokens: 3, reasoningTokens: 4 },
211
207
  });
212
208
  });
213
209
 
214
- await t.test("streamed Fireworks-style channels preserve the output split", async () => {
210
+ await t.test("streamed Fireworks-style channels do not invent an output split", async () => {
215
211
  const chunks = [
216
212
  {
217
213
  id: "response-1",
@@ -235,7 +231,6 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
235
231
  ];
236
232
  const result = await executeOpenAICompatible({
237
233
  ...request,
238
- retryAttempts: 0,
239
234
  streaming: true,
240
235
  fetch: async () => new Response(
241
236
  `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`,
@@ -243,11 +238,9 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
243
238
  ),
244
239
  });
245
240
  assert.deepEqual(result.usage, {
246
- prompt: 2,
247
- completion: 2,
248
- reasoning: 8,
249
- cached: 0,
250
- total: 12,
241
+ inputTokens: 2,
242
+ outputTokens: 10,
243
+ totalTokens: 12,
251
244
  });
252
245
  });
253
246
  });