@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
package/src/sdkModels.ts CHANGED
@@ -6,13 +6,15 @@ import { createGroq } from "@ai-sdk/groq";
6
6
  import { createMistral } from "@ai-sdk/mistral";
7
7
  import { createOpenAI } from "@ai-sdk/openai";
8
8
  import { createTogetherAI } from "@ai-sdk/togetherai";
9
- import { createXai } from "@ai-sdk/xai";
10
9
  import { createOpenRouter } from "@openrouter/ai-sdk-provider";
11
10
  import { lookupProvider, type ProviderInfo } from "@plurnk/plurnk-models";
12
11
  import type { LanguageModel } from "ai";
12
+ import { authoritativeChargeNormalizer } from "./accounting.ts";
13
+ import type { AuthoritativeChargeNormalizer } from "./types.ts";
13
14
 
14
15
  export type SdkModel = {
15
16
  readonly languageModel?: LanguageModel;
17
+ readonly normalizeCharge?: AuthoritativeChargeNormalizer;
16
18
  readonly compatible?: {
17
19
  readonly url: string;
18
20
  readonly headers: Readonly<Record<string, string>>;
@@ -105,6 +107,7 @@ export const createSdkModel = (
105
107
  const catalog = lookupProvider(provider) ?? configuredProviderInfo(provider, env);
106
108
  if (catalog === null) return null;
107
109
  const url = baseUrl(provider, env, catalog, baseUrlOverride);
110
+ const normalizeCharge = authoritativeChargeNormalizer(catalog.npm);
108
111
 
109
112
  switch (catalog.npm) {
110
113
  case "@ai-sdk/openai":
@@ -130,6 +133,7 @@ export const createSdkModel = (
130
133
  case "@ai-sdk/deepinfra":
131
134
  return {
132
135
  languageModel: createDeepInfra({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
136
+ ...(normalizeCharge === undefined ? {} : { normalizeCharge }),
133
137
  catalog,
134
138
  };
135
139
  case "@ai-sdk/google":
@@ -137,11 +141,18 @@ export const createSdkModel = (
137
141
  languageModel: createGoogle({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
138
142
  catalog,
139
143
  };
140
- case "@ai-sdk/xai":
144
+ case "@ai-sdk/xai": {
145
+ const key = requireApiKey(provider, env, catalog);
146
+ const compatibleBase = url ?? "https://api.x.ai/v1";
141
147
  return {
142
- languageModel: createXai({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
148
+ compatible: {
149
+ url: `${compatibleBase}/chat/completions`,
150
+ headers: { Authorization: `Bearer ${key}` },
151
+ },
152
+ ...(normalizeCharge === undefined ? {} : { normalizeCharge }),
143
153
  catalog,
144
154
  };
155
+ }
145
156
  case "@ai-sdk/anthropic":
146
157
  return {
147
158
  languageModel: createAnthropic({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
@@ -169,6 +180,7 @@ export const createSdkModel = (
169
180
  ...(env.OPENROUTER_X_TITLE === undefined ? {} : { "X-Title": env.OPENROUTER_X_TITLE }),
170
181
  },
171
182
  }).languageModel(model),
183
+ ...(normalizeCharge === undefined ? {} : { normalizeCharge }),
172
184
  catalog,
173
185
  };
174
186
  case "@ai-sdk/openai-compatible":
package/src/types.ts CHANGED
@@ -46,6 +46,25 @@ export interface ProviderUsage {
46
46
 
47
47
  export type AuthoritativeCharge = Extract<ProviderCost, { kind: "authoritative" }>;
48
48
 
49
+ // Evidence exposed by the transport to the provider adapter that owns its
50
+ // vendor protocol. Core and downstream consumers receive only the normalized
51
+ // charge, never a requirement to understand provider metadata fields.
52
+ export interface ProviderChargeEvidence {
53
+ readonly providerMetadata?: unknown;
54
+ // Provider-owned raw usage projection retained independently of optional
55
+ // full-body capture. Accounting fields cannot disappear merely because
56
+ // forensic raw-body capture is disabled.
57
+ readonly usage?: unknown;
58
+ readonly response: {
59
+ readonly id: string;
60
+ readonly headers?: Readonly<Record<string, string>>;
61
+ };
62
+ }
63
+
64
+ export type AuthoritativeChargeNormalizer = (
65
+ evidence: ProviderChargeEvidence,
66
+ ) => AuthoritativeCharge | undefined;
67
+
49
68
  // A successful exchange's closed finish set. ProviderAttemptFinishReason adds
50
69
  // the failed disposition that may occur only on ProviderError attempt evidence.
51
70
  export type FinishReason = "stop" | "length" | "tool_calls" | "content_filter" | null;
@@ -232,10 +251,10 @@ export interface Provider {
232
251
  // consumers (the tokenizer seam) prefer this over any client-side data.
233
252
  tokenize?(text: string): Promise<number[]>;
234
253
  // {§model-fact-resolution} — frozen 1.x local USD estimate compatibility.
235
- // Consumers adapt its zero to unknown; only calculateCharge can prove free.
254
+ // This is not a monetary-reporting authority.
236
255
  calculateCost(usage: ProviderUsage): number;
237
- // Current monetary result. The numeric calculateCost surface remains frozen
238
- // for 1.x compatibility; zero on that legacy surface cannot prove `free`.
256
+ // Models.dev-derived monetary fallback. Direct response charges travel on
257
+ // ProviderResponse and take precedence at the consuming attempt boundary.
239
258
  calculateCharge?(usage: ProviderUsage): Exclude<ProviderCost, AuthoritativeCharge>;
240
259
  }
241
260
 
package/src/usage.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 { normalizeUsage, calculateCostUsd } from "./usage.ts";
3
+ import { normalizeUsage, calculateCostUsd, calculateCostUsdDecimal } from "./usage.ts";
4
4
 
5
5
  // — normalizeUsage —
6
6
 
@@ -143,3 +143,11 @@ test("calculateCostUsd: zero rates → 0", () => {
143
143
  const usage = { prompt: 9, completion: 9, reasoning: 9, cached: 9, total: 27 };
144
144
  assert.equal(calculateCostUsd(usage, { input: 0, output: 0, cached: 0 }), 0);
145
145
  });
146
+
147
+ test("calculateCostUsdDecimal preserves Models.dev rates without floating-point artifacts", () => {
148
+ const usage = { prompt: 1_000, completion: 100, reasoning: 50, cached: 400, total: 1_150 };
149
+ assert.equal(
150
+ calculateCostUsdDecimal(usage, { input: 0.14, output: 0.28, cached: 0.0028 }),
151
+ "0.00012712",
152
+ );
153
+ });
package/src/usage.ts CHANGED
@@ -90,15 +90,48 @@ export const normalizeUsage = (raw: RawUsage | null | undefined, reasoningText =
90
90
  // Conventional provider pricing: USD per million tokens, matching Models.dev.
91
91
  export type TokenRates = { input: number; output: number; cached: number };
92
92
 
93
+ const decimalParts = (value: number): { coefficient: bigint; scale: number } => {
94
+ if (!Number.isFinite(value) || value < 0) {
95
+ throw new TypeError("token rates must be finite non-negative numbers");
96
+ }
97
+ const match = /^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(String(value));
98
+ if (match === null) throw new TypeError(`cannot represent token rate ${value} as a decimal`);
99
+ const fraction = match[2] ?? "";
100
+ const exponent = Number(match[3] ?? "0");
101
+ const scale = fraction.length - exponent;
102
+ const coefficient = BigInt(`${match[1]}${fraction}`);
103
+ return scale < 0
104
+ ? { coefficient: coefficient * 10n ** BigInt(-scale), scale: 0 }
105
+ : { coefficient, scale };
106
+ };
107
+
108
+ const decimalString = (coefficient: bigint, scale: number): string => {
109
+ const digits = String(coefficient).padStart(scale + 1, "0");
110
+ if (scale === 0) return digits;
111
+ const integer = digits.slice(0, -scale);
112
+ const fraction = digits.slice(-scale).replace(/0+$/, "");
113
+ return fraction.length === 0 ? integer : `${integer}.${fraction}`;
114
+ };
115
+
116
+ // Models.dev rates are decimal USD-per-million values. Calculate their
117
+ // projection as decimal arithmetic so ProviderCost preserves the table and
118
+ // exact response counts without binary floating-point artifacts.
119
+ export const calculateCostUsdDecimal = (usage: ProviderUsage, rates: TokenRates): string => {
120
+ const parts = [rates.input, rates.cached, rates.output].map(decimalParts);
121
+ const rateScale = Math.max(...parts.map(({ scale }) => scale));
122
+ const [input, cached, output] = parts.map(({ coefficient, scale }) =>
123
+ coefficient * 10n ** BigInt(rateScale - scale));
124
+ const nonCachedPrompt = Math.max(0, usage.prompt - usage.cached);
125
+ const outputTokens = usage.completion + usage.reasoning;
126
+ const coefficient = BigInt(nonCachedPrompt) * input!
127
+ + BigInt(usage.cached) * cached!
128
+ + BigInt(outputTokens) * output!;
129
+ return decimalString(coefficient, rateScale + 6);
130
+ };
131
+
93
132
  // The one cost formula every provider uses: non-cached prompt at the input
94
133
  // rate, cached prompt at the cache rate, and billable output (completion +
95
134
  // reasoning) at the output rate.
96
135
  export const calculateCostUsd = (usage: ProviderUsage, rates: TokenRates): number => {
97
- const nonCachedPrompt = Math.max(0, usage.prompt - usage.cached);
98
- const output = usage.completion + usage.reasoning;
99
- return (
100
- nonCachedPrompt * rates.input
101
- + usage.cached * rates.cached
102
- + output * rates.output
103
- ) / 1_000_000;
136
+ return Number(calculateCostUsdDecimal(usage, rates));
104
137
  };