@plurnk/plurnk-providers 1.4.0 → 1.5.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 (72) hide show
  1. package/.env.defaults +9 -17
  2. package/README.md +3 -0
  3. package/SPEC.md +40 -23
  4. package/dist/AiSdkProvider.d.ts +2 -1
  5. package/dist/AiSdkProvider.d.ts.map +1 -1
  6. package/dist/AiSdkProvider.js +55 -34
  7. package/dist/AiSdkProvider.js.map +1 -1
  8. package/dist/Mock.d.ts +6 -7
  9. package/dist/Mock.d.ts.map +1 -1
  10. package/dist/Mock.js +1 -0
  11. package/dist/Mock.js.map +1 -1
  12. package/dist/Pool.js +1 -1
  13. package/dist/Pool.js.map +1 -1
  14. package/dist/accounting.d.ts +3 -0
  15. package/dist/accounting.d.ts.map +1 -0
  16. package/dist/accounting.js +84 -0
  17. package/dist/accounting.js.map +1 -0
  18. package/dist/aiSdkTransport.d.ts +2 -1
  19. package/dist/aiSdkTransport.d.ts.map +1 -1
  20. package/dist/aiSdkTransport.js +73 -2
  21. package/dist/aiSdkTransport.js.map +1 -1
  22. package/dist/catalogProvider.d.ts +3 -2
  23. package/dist/catalogProvider.d.ts.map +1 -1
  24. package/dist/catalogProvider.js +11 -11
  25. package/dist/catalogProvider.js.map +1 -1
  26. package/dist/cost.d.ts +1 -1
  27. package/dist/cost.d.ts.map +1 -1
  28. package/dist/cost.js +6 -9
  29. package/dist/cost.js.map +1 -1
  30. package/dist/env.d.ts +0 -6
  31. package/dist/env.d.ts.map +1 -1
  32. package/dist/env.js +0 -22
  33. package/dist/env.js.map +1 -1
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/errors.js +2 -0
  36. package/dist/errors.js.map +1 -1
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/sdkModels.d.ts +2 -0
  42. package/dist/sdkModels.d.ts.map +1 -1
  43. package/dist/sdkModels.js +13 -3
  44. package/dist/sdkModels.js.map +1 -1
  45. package/dist/types.d.ts +9 -0
  46. package/dist/types.d.ts.map +1 -1
  47. package/dist/usage.d.ts +1 -0
  48. package/dist/usage.d.ts.map +1 -1
  49. package/dist/usage.js +38 -5
  50. package/dist/usage.js.map +1 -1
  51. package/package.json +8 -7
  52. package/src/AiSdkProvider.test.ts +277 -17
  53. package/src/AiSdkProvider.ts +70 -40
  54. package/src/Mock.ts +5 -2
  55. package/src/Pool.ts +1 -1
  56. package/src/accounting.test.ts +58 -0
  57. package/src/accounting.ts +88 -0
  58. package/src/aiSdkTransport.ts +77 -3
  59. package/src/boundaries.test.ts +1 -0
  60. package/src/catalogProvider.test.ts +26 -15
  61. package/src/catalogProvider.ts +12 -11
  62. package/src/cost.test.ts +7 -6
  63. package/src/cost.ts +4 -9
  64. package/src/env.test.ts +1 -26
  65. package/src/env.ts +0 -24
  66. package/src/errors.ts +1 -0
  67. package/src/index.ts +1 -1
  68. package/src/sdkModels.test.ts +22 -3
  69. package/src/sdkModels.ts +15 -3
  70. package/src/types.ts +22 -3
  71. package/src/usage.test.ts +9 -1
  72. package/src/usage.ts +40 -7
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { authoritativeChargeNormalizer } from "./accounting.ts";
4
+
5
+ const evidence = ({ providerMetadata, usage }: { providerMetadata?: unknown; usage?: unknown }) => ({
6
+ ...(providerMetadata === undefined ? {} : { providerMetadata }),
7
+ ...(usage === undefined ? {} : { usage }),
8
+ response: { id: "response-1" },
9
+ });
10
+
11
+ test("xAI response ticks normalize to an exact provider-authoritative charge", () => {
12
+ const normalize = authoritativeChargeNormalizer("@ai-sdk/xai");
13
+ assert.notEqual(normalize, undefined);
14
+ assert.deepEqual(normalize!(evidence({ usage: { cost_in_usd_ticks: 15_493_500 } })), {
15
+ kind: "authoritative",
16
+ amount: { amount: "15493500", currency: "USDTICK" },
17
+ usdEquivalent: "0.00154935",
18
+ source: "xAI response usage.cost_in_usd_ticks",
19
+ });
20
+ });
21
+
22
+ test("OpenRouter response cost normalizes without rate reconstruction", () => {
23
+ const normalize = authoritativeChargeNormalizer("@openrouter/ai-sdk-provider");
24
+ assert.notEqual(normalize, undefined);
25
+ assert.deepEqual(normalize!(evidence({ providerMetadata: { openrouter: { usage: { cost: 3.2e-7 } } } })), {
26
+ kind: "authoritative",
27
+ amount: { amount: "0.00000032", currency: "USD" },
28
+ usdEquivalent: "0.00000032",
29
+ source: "OpenRouter response usage.cost",
30
+ });
31
+ });
32
+
33
+ test("DeepInfra's documented response estimate wins over local rate reconstruction", () => {
34
+ const normalize = authoritativeChargeNormalizer("@ai-sdk/deepinfra");
35
+ assert.notEqual(normalize, undefined);
36
+ assert.deepEqual(normalize!(evidence({ usage: { estimated_cost: 5.04e-5 } })), {
37
+ kind: "authoritative",
38
+ amount: { amount: "0.0000504", currency: "USD" },
39
+ usdEquivalent: "0.0000504",
40
+ source: "DeepInfra response usage.estimated_cost",
41
+ });
42
+ });
43
+
44
+ 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
+ );
50
+ assert.throws(
51
+ () => authoritativeChargeNormalizer("@ai-sdk/xai")!(evidence({ usage: { cost_in_usd_ticks: "1" } })),
52
+ /cost_in_usd_ticks must be numeric/,
53
+ );
54
+ assert.throws(
55
+ () => authoritativeChargeNormalizer("@ai-sdk/deepinfra")!(evidence({ usage: { estimated_cost: "1" } })),
56
+ /estimated_cost must be numeric/,
57
+ );
58
+ });
@@ -0,0 +1,88 @@
1
+ import type {
2
+ AuthoritativeChargeNormalizer,
3
+ } from "./types.ts";
4
+
5
+ const recordOf = (value: unknown): Record<string, unknown> | null =>
6
+ typeof value === "object" && value !== null && !Array.isArray(value)
7
+ ? value as Record<string, unknown>
8
+ : null;
9
+
10
+ const decimalFromNumber = (value: number, subject: string): string => {
11
+ if (!Number.isFinite(value) || value < 0) {
12
+ throw new TypeError(`${subject} must be a finite non-negative number`);
13
+ }
14
+ const source = String(value);
15
+ if (!/[eE]/.test(source)) return source;
16
+ const [coefficient, exponentSource] = source.toLowerCase().split("e");
17
+ const exponent = Number(exponentSource);
18
+ const [integer, fraction = ""] = coefficient!.split(".");
19
+ const digits = `${integer}${fraction}`;
20
+ const point = integer!.length + exponent;
21
+ if (point <= 0) return `0.${"0".repeat(-point)}${digits}`;
22
+ if (point >= digits.length) return `${digits}${"0".repeat(point - digits.length)}`;
23
+ return `${digits.slice(0, point)}.${digits.slice(point)}`;
24
+ };
25
+
26
+ const usdFromTicks = (ticks: number): string => {
27
+ if (!Number.isSafeInteger(ticks) || ticks < 0) {
28
+ throw new TypeError("xAI costInUsdTicks must be a non-negative safe integer");
29
+ }
30
+ const digits = String(ticks).padStart(11, "0");
31
+ const integer = digits.slice(0, -10).replace(/^0+(?=\d)/, "");
32
+ const fraction = digits.slice(-10).replace(/0+$/, "");
33
+ return fraction === "" ? integer : `${integer}.${fraction}`;
34
+ };
35
+
36
+ const xaiCharge: AuthoritativeChargeNormalizer = ({ usage }) => {
37
+ const wireUsage = recordOf(usage);
38
+ if (wireUsage === null || !("cost_in_usd_ticks" in wireUsage)) return undefined;
39
+ const ticks = wireUsage.cost_in_usd_ticks;
40
+ if (typeof ticks !== "number") {
41
+ throw new TypeError("xAI usage.cost_in_usd_ticks must be numeric");
42
+ }
43
+ return {
44
+ kind: "authoritative",
45
+ amount: { amount: String(ticks), currency: "USDTICK" },
46
+ usdEquivalent: usdFromTicks(ticks),
47
+ source: "xAI response usage.cost_in_usd_ticks",
48
+ };
49
+ };
50
+
51
+ const openRouterCharge: AuthoritativeChargeNormalizer = ({ providerMetadata }) => {
52
+ const usage = recordOf(recordOf(recordOf(providerMetadata)?.openrouter)?.usage);
53
+ if (usage === null || !("cost" in usage)) return undefined;
54
+ const cost = usage.cost;
55
+ if (typeof cost !== "number") throw new TypeError("OpenRouter usage.cost must be numeric");
56
+ const amount = decimalFromNumber(cost, "OpenRouter usage.cost");
57
+ return {
58
+ kind: "authoritative",
59
+ amount: { amount, currency: "USD" },
60
+ usdEquivalent: amount,
61
+ source: "OpenRouter response usage.cost",
62
+ };
63
+ };
64
+
65
+ const deepInfraCharge: AuthoritativeChargeNormalizer = ({ usage }) => {
66
+ const wireUsage = recordOf(usage);
67
+ if (wireUsage === null || !("estimated_cost" in wireUsage)) return undefined;
68
+ const cost = wireUsage.estimated_cost;
69
+ if (typeof cost !== "number") throw new TypeError("DeepInfra usage.estimated_cost must be numeric");
70
+ const amount = decimalFromNumber(cost, "DeepInfra usage.estimated_cost");
71
+ return {
72
+ kind: "authoritative",
73
+ amount: { amount, currency: "USD" },
74
+ usdEquivalent: amount,
75
+ source: "DeepInfra response usage.estimated_cost",
76
+ };
77
+ };
78
+
79
+ export const authoritativeChargeNormalizer = (
80
+ sdkPackage: string,
81
+ ): AuthoritativeChargeNormalizer | undefined => {
82
+ switch (sdkPackage) {
83
+ case "@ai-sdk/xai": return xaiCharge;
84
+ case "@ai-sdk/deepinfra": return deepInfraCharge;
85
+ case "@openrouter/ai-sdk-provider": return openRouterCharge;
86
+ default: return undefined;
87
+ }
88
+ };
@@ -1,7 +1,8 @@
1
1
  import { createOpenAICompatible, type ProviderErrorStructure } from "@ai-sdk/openai-compatible";
2
- import { generateText, streamText, type JSONValue, type LanguageModel, type LanguageModelUsage } from "ai";
2
+ import { APICallError, generateText, streamText, type JSONValue, type LanguageModel, type LanguageModelUsage } from "ai";
3
+ import { prepareRetries } from "ai/internal";
3
4
  import { z } from "zod/v4";
4
- import type { ChatMessage, ProviderAttemptFinishReason, ProviderUsage, TokenLogprob } from "./types.ts";
5
+ import type { ChatMessage, ProviderAttemptFinishReason, ProviderChargeEvidence, ProviderUsage, TokenLogprob } from "./types.ts";
5
6
  import { normalizeUsage, type RawUsage } from "./usage.ts";
6
7
  import { emitWarningOnce } from "./warnings.ts";
7
8
 
@@ -66,6 +67,14 @@ const wireUsageOf = (
66
67
  return null;
67
68
  };
68
69
 
70
+ const wireUsageEvidenceOf = (values: readonly unknown[]): unknown => {
71
+ for (let index = values.length - 1; index >= 0; index -= 1) {
72
+ const record = recordOf(values[index]);
73
+ if (record !== null && record.usage !== undefined) return record.usage;
74
+ }
75
+ return undefined;
76
+ };
77
+
69
78
  const finishReasonOf = (reason: string | undefined): ProviderAttemptFinishReason => {
70
79
  switch (reason?.toLowerCase()) {
71
80
  case "stop":
@@ -146,6 +155,7 @@ export type AiSdkTransportResponse = {
146
155
  encrypted: Array<{ data: string; format: string | null }>;
147
156
  }>;
148
157
  logprobs: TokenLogprob[];
158
+ chargeEvidence: ProviderChargeEvidence;
149
159
  rawBody?: unknown;
150
160
  };
151
161
 
@@ -163,8 +173,46 @@ export type AiSdkModelRequest = Omit<AiSdkTransportRequest, "url" | "model" | "b
163
173
  reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "none" | "provider-default";
164
174
  };
165
175
 
176
+ const isStreamIdleTimeout = (cause: unknown): cause is Error | DOMException =>
177
+ typeof cause === "object"
178
+ && cause !== null
179
+ && (cause as { name?: string }).name === "TimeoutError"
180
+ && /chunk timeout/i.test(String((cause as { message?: unknown }).message ?? ""));
181
+
166
182
  const executeModel = async (
167
183
  request: AiSdkModelRequest,
184
+ ): Promise<AiSdkTransportResponse> => {
185
+ const timeoutSignal = AbortSignal.timeout(request.fetchTimeoutMs);
186
+ const operationSignal = request.signal === undefined
187
+ ? timeoutSignal
188
+ : AbortSignal.any([request.signal, timeoutSignal]);
189
+ const { retry } = prepareRetries({
190
+ maxRetries: request.retryAttempts,
191
+ abortSignal: operationSignal,
192
+ });
193
+ return retry(async () => {
194
+ try {
195
+ return await executeModelOnce({
196
+ ...request,
197
+ signal: operationSignal,
198
+ retryAttempts: 0,
199
+ });
200
+ } catch (cause) {
201
+ if (operationSignal.aborted) throw operationSignal.reason;
202
+ if (!isStreamIdleTimeout(cause)) throw cause;
203
+ throw new APICallError({
204
+ message: cause.message,
205
+ url: "model:generation",
206
+ requestBodyValues: {},
207
+ cause,
208
+ isRetryable: true,
209
+ });
210
+ }
211
+ });
212
+ };
213
+
214
+ const executeModelOnce = async (
215
+ request: AiSdkModelRequest,
168
216
  ): Promise<AiSdkTransportResponse> => {
169
217
  const {
170
218
  languageModel: model,
@@ -227,6 +275,7 @@ const executeModel = async (
227
275
  const rawBody = result.response.body;
228
276
  const values = [rawBody];
229
277
  const evidence = extractEvidence(values);
278
+ const accountingUsage = wireUsageEvidenceOf(values);
230
279
  const reasoningText = evidence.reasoning || result.reasoningText || "";
231
280
  const rawFinishReason = result.rawFinishReason;
232
281
  return {
@@ -241,6 +290,18 @@ const executeModel = async (
241
290
  metadata: metadataOf(values),
242
291
  reasoningEncrypted: evidence.reasoningEncrypted,
243
292
  logprobs: evidence.logprobs,
293
+ chargeEvidence: {
294
+ ...(accountingUsage === undefined ? {} : { usage: accountingUsage }),
295
+ ...(result.providerMetadata === undefined
296
+ ? {}
297
+ : { providerMetadata: result.providerMetadata }),
298
+ response: {
299
+ id: result.response.id,
300
+ ...(result.response.headers === undefined
301
+ ? {}
302
+ : { headers: result.response.headers }),
303
+ },
304
+ },
244
305
  ...(request.captureRawBody ? { rawBody } : {}),
245
306
  };
246
307
  }
@@ -258,11 +319,16 @@ const executeModel = async (
258
319
  }
259
320
  if (streamError !== undefined) throw streamError;
260
321
  const evidence = extractEvidence(rawChunks);
322
+ const accountingUsage = wireUsageEvidenceOf(rawChunks);
261
323
  const content = await result.text;
262
324
  const reasoningText = evidence.reasoning || (await result.reasoningText) || "";
263
325
  const rawFinishReason = await result.rawFinishReason;
326
+ const [response, providerMetadata] = await Promise.all([
327
+ result.response,
328
+ result.providerMetadata,
329
+ ]);
264
330
  return {
265
- model: (await result.response).modelId,
331
+ model: response.modelId,
266
332
  content,
267
333
  reasoning: reasoningText,
268
334
  reasoningProjected: evidence.reasoningProjected,
@@ -273,6 +339,14 @@ const executeModel = async (
273
339
  metadata: metadataOf(rawChunks),
274
340
  reasoningEncrypted: evidence.reasoningEncrypted,
275
341
  logprobs: evidence.logprobs,
342
+ chargeEvidence: {
343
+ ...(accountingUsage === undefined ? {} : { usage: accountingUsage }),
344
+ ...(providerMetadata === undefined ? {} : { providerMetadata }),
345
+ response: {
346
+ id: response.id,
347
+ ...(response.headers === undefined ? {} : { headers: response.headers }),
348
+ },
349
+ },
276
350
  ...(request.captureRawBody ? { rawBody: rawChunks } : {}),
277
351
  };
278
352
  };
@@ -30,6 +30,7 @@ test("the OpenAI-compatible entrypoint excludes Node-owned provider machinery",
30
30
  const allowed = new Set([
31
31
  "AiSdkProvider.ts",
32
32
  "aiSdkTransport.ts",
33
+ "cost.ts",
33
34
  "env.ts",
34
35
  "errors.ts",
35
36
  "notices.ts",
@@ -1,6 +1,7 @@
1
1
  import test, { mock } from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
3
  import { catalogProviderFromEnv } from "./catalogProvider.ts";
4
+ import { providerCostFor } from "./cost.ts";
4
5
  import { resetEmittedWarnings } from "./warnings.ts";
5
6
 
6
7
  const env = {
@@ -119,22 +120,32 @@ test("cataloged unknown model fails unless its context is explicit", () => {
119
120
  assert.equal(provider?.contextWindow, 8192);
120
121
  });
121
122
 
122
- test("explicit operator rates price out-of-snapshot models and override catalog rates", () => {
123
- const priced = {
123
+ test("Models.dev is the only fallback rate table", () => {
124
+ const usage = {
125
+ prompt: 1_000,
126
+ cached: 400,
127
+ completion: 100,
128
+ reasoning: 50,
129
+ total: 1_150,
130
+ };
131
+ const cataloged = catalogProviderFromEnv("deepseek", {
132
+ ...env,
133
+ DEEPSEEK_API_KEY: "test-key",
134
+ }, "deepseek-v4-flash");
135
+ assert.notEqual(cataloged, null);
136
+ assert.deepEqual(providerCostFor(cataloged!, usage), {
137
+ kind: "estimated",
138
+ usd: "0.00012712",
139
+ source: "Models.dev catalog rates",
140
+ });
141
+
142
+ const uncataloged = catalogProviderFromEnv("xai", {
124
143
  ...env,
125
144
  XAI_API_KEY: "test-key",
126
145
  PLURNK_PROVIDERS_CONTEXT_WINDOW: "8192",
127
- PLURNK_PROVIDERS_INPUT_USD_PER_MILLION: "2",
128
- PLURNK_PROVIDERS_CACHE_READ_USD_PER_MILLION: "0.5",
129
- PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION: "8",
130
- };
131
- const usage = {
132
- prompt: 1_000_000,
133
- cached: 250_000,
134
- completion: 500_000,
135
- reasoning: 500_000,
136
- total: 2_000_000,
137
- };
138
- assert.equal(catalogProviderFromEnv("xai", priced, "not-in-the-catalog")?.calculateCost(usage), 9.625);
139
- assert.equal(catalogProviderFromEnv("openai", priced, "gpt-4.1-mini")?.calculateCost(usage), 9.625);
146
+ }, "not-in-the-catalog");
147
+ assert.deepEqual(providerCostFor(uncataloged!, usage), {
148
+ kind: "unknown",
149
+ reason: "the response reported no cost and Models.dev has no rate for this model",
150
+ });
140
151
  });
@@ -10,14 +10,13 @@ import {
10
10
  reasoningFromEnv,
11
11
  reasoningResponseStyleFromEnv,
12
12
  resolveReserve,
13
- tokenRatesFromEnv,
14
13
  type ReserveSpec,
15
14
  } from "./env.ts";
16
15
  import AiSdkProvider, { type ReasoningStyle } from "./AiSdkProvider.ts";
17
16
  import { configuredProviderInfo, createSdkModel } from "./sdkModels.ts";
18
17
  import { providerSource } from "./notices.ts";
19
- import type { Provider, ProviderUsage } from "./types.ts";
20
- import { calculateCostUsd } from "./usage.ts";
18
+ import type { AuthoritativeChargeNormalizer, Provider, ProviderUsage } from "./types.ts";
19
+ import { calculateCostUsd, calculateCostUsdDecimal } from "./usage.ts";
21
20
  import { emitWarningOnce } from "./warnings.ts";
22
21
  import type { LanguageModel } from "ai";
23
22
  import type { PluginAttribution, PluginAttributionContext } from "@plurnk/plurnk-meta";
@@ -45,6 +44,7 @@ export const providerFromSdkModel = ({
45
44
  env,
46
45
  model,
47
46
  languageModel,
47
+ normalizeCharge,
48
48
  url,
49
49
  headers,
50
50
  contextWindow,
@@ -55,6 +55,7 @@ export const providerFromSdkModel = ({
55
55
  env: NodeJS.ProcessEnv;
56
56
  model: string;
57
57
  languageModel?: LanguageModel;
58
+ normalizeCharge?: AuthoritativeChargeNormalizer;
58
59
  url?: string;
59
60
  headers?: Readonly<Record<string, string>>;
60
61
  contextWindow: number;
@@ -82,31 +83,30 @@ export const providerFromSdkModel = ({
82
83
  ? configuredReasoning
83
84
  : { tokens: Math.round(completionTokens / 2) };
84
85
 
85
- const configuredRates = tokenRatesFromEnv(env, name);
86
86
  const catalogCost = info?.cost;
87
- const rates = configuredRates ?? (catalogCost === undefined ? null : {
87
+ const rates = catalogCost === undefined ? null : {
88
88
  input: catalogCost.inputPer1M,
89
89
  output: catalogCost.outputPer1M,
90
90
  cached: catalogCost.cacheReadPer1M ?? catalogCost.inputPer1M,
91
- });
91
+ };
92
92
  const calculateCost = rates === null
93
93
  ? undefined
94
94
  : (usage: ProviderUsage): number => calculateCostUsd(usage, rates);
95
- const rateSource = configuredRates === null ? "Models.dev catalog rates" : "operator-configured rates";
96
95
  const calculateCharge: (usage: ProviderUsage) => Exclude<ProviderCost, { kind: "authoritative" }> = rates === null
97
- ? () => ({ kind: "unknown", reason: "no provider rate or settled charge is available" })
96
+ ? () => ({ kind: "unknown", reason: "the response reported no cost and Models.dev has no rate for this model" })
98
97
  : rates.input === 0 && rates.output === 0 && rates.cached === 0
99
- ? () => ({ kind: "free", source: rateSource })
98
+ ? () => ({ kind: "free", source: "Models.dev catalog rates" })
100
99
  : (usage: ProviderUsage) => ({
101
100
  kind: "estimated",
102
- usd: String(calculateCostUsd(usage, rates)),
103
- source: rateSource,
101
+ usd: calculateCostUsdDecimal(usage, rates),
102
+ source: "Models.dev catalog rates",
104
103
  });
105
104
 
106
105
  return new AiSdkProvider({
107
106
  model,
108
107
  ...(attributions === undefined ? {} : { attributions }),
109
108
  ...(languageModel === undefined ? {} : { languageModel }),
109
+ ...(normalizeCharge === undefined ? {} : { normalizeCharge }),
110
110
  ...(url === undefined ? {} : { url }),
111
111
  ...(headers === undefined ? {} : { headers: { ...headers } }),
112
112
  contextWindow,
@@ -165,6 +165,7 @@ export const catalogProviderFromEnv = (
165
165
  env,
166
166
  model: wireModel,
167
167
  languageModel: sdk.languageModel,
168
+ normalizeCharge: sdk.normalizeCharge,
168
169
  url: sdk.compatible?.url,
169
170
  headers: sdk.compatible?.headers,
170
171
  contextWindow,
package/src/cost.test.ts CHANGED
@@ -39,18 +39,19 @@ test("explicit free remains distinguishable from unknown", () => {
39
39
  assert.deepEqual(free, { kind: "free", source: "local model" });
40
40
  assert.deepEqual(unknown, {
41
41
  kind: "unknown",
42
- reason: "legacy calculateCost returned zero without free-cost authority",
42
+ reason: "the response reported no cost and Models.dev has no rate for this model",
43
43
  });
44
44
  assert.equal(providerCostUsd(free), 0);
45
45
  assert.equal(providerCostUsd(unknown), null);
46
46
  });
47
47
 
48
- test("positive legacy cost remains an estimated USD compatibility result", () => {
49
- assert.deepEqual(providerCostFor({ calculateCost: () => 0.25 }, usage), {
50
- kind: "estimated",
51
- usd: "0.25",
52
- source: "legacy calculateCost",
48
+ test("legacy calculateCost is not a monetary-reporting authority", () => {
49
+ const cost = providerCostFor({ calculateCost: () => 0.25 }, usage);
50
+ assert.deepEqual(cost, {
51
+ kind: "unknown",
52
+ reason: "the response reported no cost and Models.dev has no rate for this model",
53
53
  });
54
+ assert.equal(providerCostUsd(cost), null);
54
55
  });
55
56
 
56
57
  test("rejects malformed money instead of mining or coercing it", () => {
package/src/cost.ts CHANGED
@@ -48,17 +48,13 @@ export const validateProviderCost = (cost: ProviderCost): ProviderCost => {
48
48
  export const resolveProviderCost = (
49
49
  charge: AuthoritativeCharge | undefined,
50
50
  current: ProviderCost | undefined,
51
- legacy: () => number,
52
51
  ): ProviderCost => {
53
52
  if (charge !== undefined) return validateAuthoritativeCharge(charge);
54
53
  if (current !== undefined) return validateProviderCost(current);
55
- const cost = legacy();
56
- if (!Number.isFinite(cost) || cost < 0) {
57
- throw new TypeError("legacy provider cost must be a finite non-negative number");
58
- }
59
- return cost > 0
60
- ? { kind: "estimated", usd: String(cost), source: "legacy calculateCost" }
61
- : { kind: "unknown", reason: "legacy calculateCost returned zero without free-cost authority" };
54
+ return {
55
+ kind: "unknown",
56
+ reason: "the response reported no cost and Models.dev has no rate for this model",
57
+ };
62
58
  };
63
59
 
64
60
  export const providerCostUsd = (cost: ProviderCost): number | null => {
@@ -79,5 +75,4 @@ export const providerCostFor = (
79
75
  ): ProviderCost => resolveProviderCost(
80
76
  charge,
81
77
  provider.calculateCharge?.(usage),
82
- () => provider.calculateCost(usage),
83
78
  );
package/src/env.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
- import { parseRequiredInt, parseOptionalInt, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv, tokenRatesFromEnv } from "./env.ts";
3
+ import { parseRequiredInt, parseOptionalInt, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv } from "./env.ts";
4
4
 
5
5
  test("parseRequiredInt: parses a non-negative integer", () => {
6
6
  assert.equal(parseRequiredInt("600000", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), 600000);
@@ -59,31 +59,6 @@ test("requireEnv: returns the value or throws a named error", () => {
59
59
  assert.throws(() => requireEnv("", "GROQ_API_KEY", "groq"), /must be set/);
60
60
  });
61
61
 
62
- test("tokenRatesFromEnv is all-or-nothing, with cached input defaulting to input", () => {
63
- assert.equal(tokenRatesFromEnv({}, "cloudflare"), null);
64
- assert.deepEqual(tokenRatesFromEnv({
65
- PLURNK_PROVIDERS_INPUT_USD_PER_MILLION: "0.435",
66
- PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION: "0.87",
67
- }, "cloudflare"), {
68
- input: 0.435,
69
- cached: 0.435,
70
- output: 0.87,
71
- });
72
- assert.deepEqual(tokenRatesFromEnv({
73
- PLURNK_PROVIDERS_INPUT_USD_PER_MILLION: "3",
74
- PLURNK_PROVIDERS_CACHE_READ_USD_PER_MILLION: "0.3",
75
- PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION: "15",
76
- }, "cloudflare"), {
77
- input: 3,
78
- cached: 0.3,
79
- output: 15,
80
- });
81
- assert.throws(
82
- () => tokenRatesFromEnv({ PLURNK_PROVIDERS_INPUT_USD_PER_MILLION: "1" }, "cloudflare"),
83
- /PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION must be set/,
84
- );
85
- });
86
-
87
62
  // — per-alias knob scoping (per-alias scoping doctrine, user 2026-07-03) —
88
63
 
89
64
  test("scopeEnvToAlias: suffixed knob wins, bare is the fallback, other aliases ignored", async () => {
package/src/env.ts CHANGED
@@ -91,27 +91,6 @@ export function effectiveContextWindow(operatorCap: number | null, naturalWindow
91
91
  : Math.min(operatorCap, naturalWindow);
92
92
  }
93
93
 
94
- export type ProviderTokenRates = { input: number; cached: number; output: number };
95
-
96
- // {§model-fact-resolution} — any explicit rate opts into one complete operator
97
- // estimate; cached input alone may default to the explicit input rate.
98
- export const tokenRatesFromEnv = (env: NodeJS.ProcessEnv, label: string): ProviderTokenRates | null => {
99
- const inputName = "PLURNK_PROVIDERS_INPUT_USD_PER_MILLION";
100
- const cachedName = "PLURNK_PROVIDERS_CACHE_READ_USD_PER_MILLION";
101
- const outputName = "PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION";
102
- const configured = [inputName, cachedName, outputName]
103
- .some((name) => env[name] !== undefined && env[name] !== "");
104
- if (!configured) return null;
105
- const input = parseRequiredFloat(env[inputName], inputName, label, 0);
106
- return {
107
- input,
108
- cached: env[cachedName] === undefined || env[cachedName] === ""
109
- ? input
110
- : parseRequiredFloat(env[cachedName], cachedName, label, 0),
111
- output: parseRequiredFloat(env[outputName], outputName, label, 0),
112
- };
113
- };
114
-
115
94
  // {§provider-generation-envelope} How much of a
116
95
  // DETECTED context window is reserved for reasoning and for completion — the
117
96
  // remainder (minus the consumer's own packing-safety margin) is the prompt
@@ -218,9 +197,6 @@ export const PROVIDERS_KNOBS = Object.freeze([
218
197
  "PLURNK_PROVIDERS_REASONING_BUDGET",
219
198
  "PLURNK_PROVIDERS_REASONING",
220
199
  "PLURNK_PROVIDERS_CONTEXT_WINDOW",
221
- "PLURNK_PROVIDERS_INPUT_USD_PER_MILLION",
222
- "PLURNK_PROVIDERS_CACHE_READ_USD_PER_MILLION",
223
- "PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION",
224
200
  "PLURNK_PROVIDERS_RETRY_ATTEMPTS",
225
201
  "PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT",
226
202
  "PLURNK_PROVIDERS_FETCH_TIMEOUT",
package/src/errors.ts CHANGED
@@ -158,6 +158,7 @@ export const classifyProviderError = (
158
158
  if (status === 401 || status === 403) return { kind: "unauthorized", message };
159
159
  if (status === 402) return { kind: "quota_exceeded", message };
160
160
  if (status === 429) return { kind: "rate_limit", message };
161
+ if (status === 0 && err.isRetryable) return { kind: "network_failure", message };
161
162
  if (status >= 500) return { kind: "network_failure", message };
162
163
  if (status === 422 && wireErrorType(body) === "grammar_invalid") {
163
164
  return { kind: "grammar_invalid", message };
package/src/index.ts CHANGED
@@ -45,7 +45,7 @@ export { default as Pool } from "./Pool.ts";
45
45
  export type { ProviderFetch } from "./AiSdkProvider.ts";
46
46
  export { parseRequiredInt, parseOptionalInt, parseRequiredFloat, parseOptionalFloat, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv, scopeEnvToAlias, dataCaptureFromEnv, contextWindowFromEnv, effectiveContextWindow, envelopeFromEnv, resolveReserve, PROVIDERS_KNOBS } from "./env.ts";
47
47
  export type { Reasoning, ReasoningMode, ReasoningResponseStyle, ReserveSpec } from "./env.ts";
48
- export { normalizeUsage, calculateCostUsd } from "./usage.ts";
48
+ export { normalizeUsage, calculateCostUsd, calculateCostUsdDecimal } from "./usage.ts";
49
49
  export {
50
50
  providerCostFor,
51
51
  providerCostUsd,
@@ -19,9 +19,28 @@ test("createSdkModel uses Models.dev provider facts and operator credentials", (
19
19
  const sdk = createSdkModel("xai", "grok-build-0.1", { XAI_API_KEY: "test-key" });
20
20
  assert.notEqual(sdk, null);
21
21
  assert.equal(sdk?.catalog?.npm, "@ai-sdk/xai");
22
- const languageModel = sdk?.languageModel as { provider: string; modelId: string };
23
- assert.equal(languageModel.provider, "xai.responses");
24
- assert.equal(languageModel.modelId, "grok-build-0.1");
22
+ assert.equal(sdk?.languageModel, undefined);
23
+ assert.deepEqual(sdk?.compatible, {
24
+ url: "https://api.x.ai/v1/chat/completions",
25
+ headers: { Authorization: "Bearer test-key" },
26
+ });
27
+ assert.notEqual(sdk?.normalizeCharge, undefined);
28
+ });
29
+
30
+ test("createSdkModel attaches DeepInfra's documented response-cost normalizer", () => {
31
+ const sdk = createSdkModel("deepinfra", "zai-org/GLM-5.2", {
32
+ DEEPINFRA_API_KEY: "test-key",
33
+ });
34
+ assert.notEqual(sdk?.languageModel, undefined);
35
+ assert.deepEqual(sdk?.normalizeCharge?.({
36
+ usage: { estimated_cost: 5.04e-5 },
37
+ response: { id: "response-1" },
38
+ }), {
39
+ kind: "authoritative",
40
+ amount: { amount: "0.0000504", currency: "USD" },
41
+ usdEquivalent: "0.0000504",
42
+ source: "DeepInfra response usage.estimated_cost",
43
+ });
25
44
  });
26
45
 
27
46
  test("createSdkModel expands catalog endpoint variables without treating them as credentials", () => {