@plurnk/plurnk-providers 1.3.12 → 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 (139) hide show
  1. package/.env.defaults +47 -38
  2. package/README.md +68 -4
  3. package/SPEC.md +245 -62
  4. package/dist/AiSdkProvider.d.ts +19 -5
  5. package/dist/AiSdkProvider.d.ts.map +1 -1
  6. package/dist/AiSdkProvider.js +264 -156
  7. package/dist/AiSdkProvider.js.map +1 -1
  8. package/dist/Mock.d.ts +15 -17
  9. package/dist/Mock.d.ts.map +1 -1
  10. package/dist/Mock.js +27 -10
  11. package/dist/Mock.js.map +1 -1
  12. package/dist/Pool.d.ts +8 -2
  13. package/dist/Pool.d.ts.map +1 -1
  14. package/dist/Pool.js +41 -8
  15. package/dist/Pool.js.map +1 -1
  16. package/dist/ProviderRegistry.d.ts +4 -1
  17. package/dist/ProviderRegistry.d.ts.map +1 -1
  18. package/dist/ProviderRegistry.js +7 -3
  19. package/dist/ProviderRegistry.js.map +1 -1
  20. package/dist/accounting.d.ts +3 -0
  21. package/dist/accounting.d.ts.map +1 -0
  22. package/dist/accounting.js +84 -0
  23. package/dist/accounting.js.map +1 -0
  24. package/dist/aiSdkTransport.d.ts +5 -2
  25. package/dist/aiSdkTransport.d.ts.map +1 -1
  26. package/dist/aiSdkTransport.js +91 -5
  27. package/dist/aiSdkTransport.js.map +1 -1
  28. package/dist/catalogProvider.d.ts +5 -2
  29. package/dist/catalogProvider.d.ts.map +1 -1
  30. package/dist/catalogProvider.js +24 -11
  31. package/dist/catalogProvider.js.map +1 -1
  32. package/dist/compatibleProvider.d.ts.map +1 -1
  33. package/dist/compatibleProvider.js +11 -4
  34. package/dist/compatibleProvider.js.map +1 -1
  35. package/dist/cost.d.ts +11 -0
  36. package/dist/cost.d.ts.map +1 -0
  37. package/dist/cost.js +61 -0
  38. package/dist/cost.js.map +1 -0
  39. package/dist/discover.d.ts +2 -0
  40. package/dist/discover.d.ts.map +1 -1
  41. package/dist/discover.js +15 -9
  42. package/dist/discover.js.map +1 -1
  43. package/dist/env.d.ts +4 -6
  44. package/dist/env.d.ts.map +1 -1
  45. package/dist/env.js +27 -29
  46. package/dist/env.js.map +1 -1
  47. package/dist/errors.d.ts +27 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +152 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/index.d.ts +12 -6
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +8 -5
  54. package/dist/index.js.map +1 -1
  55. package/dist/notices.d.ts +10 -0
  56. package/dist/notices.d.ts.map +1 -0
  57. package/dist/notices.js +11 -0
  58. package/dist/notices.js.map +1 -0
  59. package/dist/ollama.d.ts.map +1 -1
  60. package/dist/ollama.js +3 -3
  61. package/dist/ollama.js.map +1 -1
  62. package/dist/openai.d.ts +1 -1
  63. package/dist/openai.d.ts.map +1 -1
  64. package/dist/promptTokens.d.ts +4 -0
  65. package/dist/promptTokens.d.ts.map +1 -0
  66. package/dist/promptTokens.js +32 -0
  67. package/dist/promptTokens.js.map +1 -0
  68. package/dist/sdkModels.d.ts +2 -0
  69. package/dist/sdkModels.d.ts.map +1 -1
  70. package/dist/sdkModels.js +17 -6
  71. package/dist/sdkModels.js.map +1 -1
  72. package/dist/types.d.ts +52 -16
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/types.js +1 -1
  75. package/dist/types.js.map +1 -1
  76. package/dist/usage.d.ts +4 -0
  77. package/dist/usage.d.ts.map +1 -1
  78. package/dist/usage.js +64 -19
  79. package/dist/usage.js.map +1 -1
  80. package/dist/warnings.js +0 -0
  81. package/dist/warnings.js.map +1 -1
  82. package/package.json +15 -10
  83. package/src/AiSdkProvider.test.ts +750 -169
  84. package/src/AiSdkProvider.ts +354 -200
  85. package/src/Mock.test.ts +29 -14
  86. package/src/Mock.ts +36 -15
  87. package/src/Pool.test.ts +43 -6
  88. package/src/Pool.ts +56 -10
  89. package/src/ProviderRegistry.test.ts +158 -9
  90. package/src/ProviderRegistry.ts +19 -6
  91. package/src/accounting.test.ts +58 -0
  92. package/src/accounting.ts +88 -0
  93. package/src/aiSdkTransport.ts +101 -8
  94. package/src/boundaries.test.ts +9 -3
  95. package/src/catalogProvider.test.ts +43 -15
  96. package/src/catalogProvider.ts +32 -16
  97. package/src/compatibleProvider.test.ts +96 -0
  98. package/src/compatibleProvider.ts +15 -6
  99. package/src/cost.test.ts +64 -0
  100. package/src/cost.ts +78 -0
  101. package/src/defaults.test.ts +1 -0
  102. package/src/discover.test.ts +48 -7
  103. package/src/discover.ts +31 -21
  104. package/src/env.test.ts +38 -48
  105. package/src/env.ts +43 -40
  106. package/src/errors.test.ts +148 -0
  107. package/src/errors.ts +208 -0
  108. package/src/index.ts +30 -8
  109. package/src/lexicon-guard.test.ts +6 -6
  110. package/src/notices.ts +22 -0
  111. package/src/ollama.test.ts +64 -0
  112. package/src/ollama.ts +6 -3
  113. package/src/openai.ts +3 -0
  114. package/src/promptTokens.ts +41 -0
  115. package/src/sdkModels.test.ts +29 -3
  116. package/src/sdkModels.ts +19 -11
  117. package/src/types.ts +125 -64
  118. package/src/usage.test.ts +24 -5
  119. package/src/usage.ts +72 -21
  120. package/src/warnings.test.ts +10 -10
  121. package/src/warnings.ts +0 -0
  122. package/dist/OpenAICompat.d.ts +0 -76
  123. package/dist/OpenAICompat.d.ts.map +0 -1
  124. package/dist/OpenAICompat.js +0 -555
  125. package/dist/OpenAICompat.js.map +0 -1
  126. package/dist/openaiStream.d.ts +0 -47
  127. package/dist/openaiStream.d.ts.map +0 -1
  128. package/dist/openaiStream.js +0 -280
  129. package/dist/openaiStream.js.map +0 -1
  130. package/dist/standardProviders.d.ts +0 -31
  131. package/dist/standardProviders.d.ts.map +0 -1
  132. package/dist/standardProviders.js +0 -518
  133. package/dist/standardProviders.js.map +0 -1
  134. package/dist/telemetry.d.ts +0 -24
  135. package/dist/telemetry.d.ts.map +0 -1
  136. package/dist/telemetry.js +0 -85
  137. package/dist/telemetry.js.map +0 -1
  138. package/src/telemetry.test.ts +0 -69
  139. package/src/telemetry.ts +0 -116
@@ -1,20 +1,21 @@
1
1
  import test, { mock } from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
3
  import { instantiateProvider, loadActiveProvider, resetDiscoveryCache } from "./ProviderRegistry.ts";
4
+ import type { PluginAttributionContext } from "@plurnk/plurnk-meta";
4
5
 
5
6
  const mapOf = (entries: Record<string, string>, skipped: Record<string, string> = {}) =>
6
7
  async () => ({ registry: new Map(Object.entries(entries)), skipped: new Map(Object.entries(skipped)), attributions: new Map<string, string | string[]>() });
7
8
 
8
- // Alias PARSING is tested in @plurnk/plurnk-aliases (its owner, #27). Here we
9
+ // Alias parsing is tested in @plurnk/plurnk-aliases (its owner). Here we
9
10
  // exercise the resolution + two-tier instantiation this module owns; the active
10
11
  // alias is driven end-to-end by loadActiveProvider below.
11
12
 
12
- // — provider resolution (SPEC §5) —
13
+ // — provider resolution ({§provider-resolution}) —
13
14
 
14
15
  const fullEnv = Object.freeze({
15
16
  PLURNK_PROVIDERS_FETCH_TIMEOUT: "600000",
16
17
  PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT: "0",
17
- 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_PROMPT_CACHE_KEY: "1",
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",
18
19
  OPENAI_BASE_URL: "http://x",
19
20
  });
20
21
 
@@ -49,6 +50,38 @@ test("instantiateProvider: an installed AI SDK provider resolves through discove
49
50
  assert.deepEqual(calls, ["@acme/ai-provider", "model-a"]);
50
51
  });
51
52
 
53
+ test("instantiateProvider: a selected plugin composes its static and runtime attribution sources", async () => {
54
+ resetDiscoveryCache();
55
+ const context: PluginAttributionContext = {
56
+ workspaceId: "workspace",
57
+ workerId: "worker",
58
+ primaryWorkerId: "primary",
59
+ loop: 3,
60
+ turn: 2,
61
+ attempt: 1,
62
+ };
63
+ const sdkProvider = {
64
+ languageModel: () => ({} as never),
65
+ attributions: ({ attempt }: PluginAttributionContext) => attempt === 1
66
+ ? ["runtime:provider", "static:provider"]
67
+ : [],
68
+ };
69
+ const provider = await instantiateProvider(
70
+ "acme",
71
+ { ...fullEnv, PLURNK_PROVIDERS_CONTEXT_WINDOW: "8192" },
72
+ "model-a",
73
+ async () => ({ default: sdkProvider }),
74
+ async () => ({
75
+ registry: new Map([["acme", "@acme/ai-provider"]]),
76
+ skipped: new Map(),
77
+ attributions: new Map([["acme", "static:provider"]]),
78
+ packageAttributions: new Map([["@acme/ai-provider", ["static:provider"]]]),
79
+ }),
80
+ );
81
+
82
+ assert.deepEqual(provider.attributions?.(context), ["runtime:provider", "static:provider"]);
83
+ });
84
+
52
85
  test("instantiateProvider: a per-alias baseUrl drives the built-in Ollama probe", async () => {
53
86
  resetDiscoveryCache();
54
87
  const calls: string[] = [];
@@ -112,7 +145,7 @@ test("instantiateProvider: unknown provider throws — no standard, no discovere
112
145
  );
113
146
  });
114
147
 
115
- test("instantiateProvider: an untrusted (skipped) provider gives a precise error, not 'unknown' (#15)", async () => {
148
+ test("instantiateProvider: an untrusted (skipped) provider gives a precise error, not 'unknown'", async () => {
116
149
  resetDiscoveryCache();
117
150
  const imports: string[] = [];
118
151
  await assert.rejects(
@@ -171,7 +204,7 @@ test("instantiateProvider: per-alias knobs scope through to the provider (per-al
171
204
  mock.restoreAll();
172
205
  });
173
206
 
174
- test("#633: public construction applies the package floor to a sparse consumer environment", async () => {
207
+ test("public construction applies the package floor to a sparse consumer environment", async () => {
175
208
  const provider = await instantiateProvider(
176
209
  "fireworks",
177
210
  { FIREWORKS_API_KEY: "fw" },
@@ -182,7 +215,74 @@ test("#633: public construction applies the package floor to a sparse consumer e
182
215
  assert.equal(provider.model, "accounts/fireworks/models/deepseek-v4-pro");
183
216
  });
184
217
 
185
- test("#633: an explicit malformed operator override still fails at its owning contract", async () => {
218
+ test("{§deepseek-reasoning-request} #157: direct DeepSeek composes catalog facts, credential, reasoning control, and cached cost", async () => {
219
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
220
+ mock.method(globalThis, "fetch", async (url: string | URL | Request, init?: RequestInit) => {
221
+ calls.push({ url: String(url), init });
222
+ const chunks = [
223
+ {
224
+ id: "deepseek-test",
225
+ object: "chat.completion.chunk",
226
+ created: 1,
227
+ model: "deepseek-v4-flash",
228
+ choices: [{ index: 0, delta: { content: "ok" }, finish_reason: "stop" }],
229
+ usage: null,
230
+ },
231
+ {
232
+ id: "deepseek-test",
233
+ object: "chat.completion.chunk",
234
+ created: 1,
235
+ model: "deepseek-v4-flash",
236
+ choices: [],
237
+ usage: {
238
+ prompt_tokens: 10,
239
+ prompt_cache_hit_tokens: 8,
240
+ prompt_cache_miss_tokens: 2,
241
+ completion_tokens: 2,
242
+ total_tokens: 12,
243
+ },
244
+ },
245
+ ];
246
+ const body = [...chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`), "data: [DONE]"].join("\n\n");
247
+ return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } });
248
+ });
249
+
250
+ const provider = await instantiateProvider(
251
+ "deepseek",
252
+ {
253
+ DEEPSEEK_API_KEY: "test-key",
254
+ PLURNK_PROVIDERS_REASONING: "off",
255
+ PLURNK_PROVIDERS_RETRY_ATTEMPTS: "0",
256
+ },
257
+ "deepseek-v4-flash",
258
+ async () => ({}),
259
+ mapOf({}),
260
+ );
261
+ const response = await provider.generate({
262
+ workerId: "worker",
263
+ messages: [{ role: "user", content: "Reply with ok." }],
264
+ });
265
+
266
+ assert.equal(calls.length, 1);
267
+ assert.equal(calls[0].url, "https://api.deepseek.com/chat/completions");
268
+ assert.equal(new Headers(calls[0].init?.headers).get("Authorization"), "Bearer test-key");
269
+ const request = JSON.parse(String(calls[0].init?.body)) as Record<string, unknown>;
270
+ assert.equal(request.model, "deepseek-v4-flash");
271
+ assert.deepEqual(request.thinking, { type: "disabled" });
272
+ assert.equal(provider.contextWindow, 1_000_000);
273
+ 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,
280
+ });
281
+ assert.ok(Math.abs(provider.calculateCost(response.assistant.usage) - 0.0000008624) < 1e-15);
282
+ mock.restoreAll();
283
+ });
284
+
285
+ test("an explicit malformed operator override still fails at its owning contract", async () => {
186
286
  await assert.rejects(
187
287
  () => instantiateProvider(
188
288
  "fireworks",
@@ -198,7 +298,7 @@ test("#633: an explicit malformed operator override still fails at its owning co
198
298
  );
199
299
  });
200
300
 
201
- test("#634: a catalog provider with unknown model metadata never falls through to plugin discovery", async () => {
301
+ test("a catalog provider with unknown model metadata never falls through to plugin discovery", async () => {
202
302
  let scanned = false;
203
303
  await assert.rejects(
204
304
  () => instantiateProvider(
@@ -223,7 +323,7 @@ test("#634: a catalog provider with unknown model metadata never falls through t
223
323
  assert.equal(scanned, false);
224
324
  });
225
325
 
226
- test("#634: explicit metadata constructs an out-of-snapshot Cloudflare model in the consolidated transport", async () => {
326
+ test("explicit metadata constructs an out-of-snapshot Cloudflare model in the consolidated transport", async () => {
227
327
  let scanned = false;
228
328
  const provider = await instantiateProvider(
229
329
  "cloudflare",
@@ -248,7 +348,56 @@ test("#634: explicit metadata constructs an out-of-snapshot Cloudflare model in
248
348
  assert.equal(scanned, false);
249
349
  });
250
350
 
251
- test("#622: two Fireworks aliases independently select default and priority service tiers", async () => {
351
+ test("{§provider-tagged-reasoning} a Cloudflare model alias carries its explicit response style through the public registry", async () => {
352
+ const calls: string[] = [];
353
+ mock.method(globalThis, "fetch", async (input: string | URL | Request) => {
354
+ calls.push(String(input));
355
+ const chunks = [
356
+ {
357
+ id: "cloudflare-reasoning",
358
+ object: "chat.completion.chunk",
359
+ created: 1,
360
+ model: "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",
361
+ choices: [{ index: 0, delta: { content: "<think>working</think>done" }, finish_reason: "stop" }],
362
+ },
363
+ {
364
+ id: "cloudflare-reasoning",
365
+ object: "chat.completion.chunk",
366
+ created: 2,
367
+ model: "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",
368
+ choices: [],
369
+ usage: { prompt_tokens: 2, completion_tokens: 4, total_tokens: 6 },
370
+ },
371
+ ];
372
+ const body = [...chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`), "data: [DONE]"].join("\n\n");
373
+ return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } });
374
+ });
375
+ const provider = await instantiateProvider(
376
+ "cloudflare",
377
+ {
378
+ ...fullEnv,
379
+ CLOUDFLARE_ACCOUNT_ID: "account",
380
+ CLOUDFLARE_API_TOKEN: "token",
381
+ PLURNK_PROVIDERS_REASONING_RESPONSE_STYLE: "verbatim",
382
+ PLURNK_PROVIDERS_REASONING_RESPONSE_STYLE_cfds1: "think-tags",
383
+ },
384
+ "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",
385
+ async () => ({}),
386
+ mapOf({}),
387
+ undefined,
388
+ "cfds1",
389
+ );
390
+
391
+ const response = await provider.generate({ workerId: "cloudflare-worker", messages: [] });
392
+
393
+ assert.equal(response.assistant.reasoning, "working");
394
+ assert.equal(response.assistant.content, "done");
395
+ assert.deepEqual(calls, [
396
+ "https://api.cloudflare.com/client/v4/accounts/account/ai/v1/chat/completions",
397
+ ]);
398
+ });
399
+
400
+ test("two Fireworks aliases independently select default and priority service tiers", async () => {
252
401
  const bodies: Record<string, unknown>[] = [];
253
402
  mock.method(globalThis, "fetch", async (_url: string, init?: RequestInit) => {
254
403
  bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
@@ -1,9 +1,9 @@
1
1
  // Provider instantiation + active-alias resolution. Alias PARSING (the
2
2
  // PLURNK_MODEL_<alias>=<provider>/<model> cascade + PLURNK_BASEURL_<alias>
3
3
  // overrides) lives in @plurnk/plurnk-aliases — the zero-dep parser shared with
4
- // thin clients (#27); this module resolves the active alias to a Provider.
4
+ // thin clients; this module resolves the active alias to a Provider.
5
5
  //
6
- // Resolution order (SPEC §5): models.dev catalog → PLURNK provider declaration
6
+ // {§provider-resolution} Models.dev catalog → PLURNK provider declaration
7
7
  // → local protocol adapter → scope-agnostic AI SDK plugin discovery. Generic
8
8
  // provider facts belong to models.dev or operator config; PLURNK owns only the
9
9
  // stable Provider contract and product-specific local behavior.
@@ -17,20 +17,27 @@ import { ollamaProviderFromEnv } from "./ollama.ts";
17
17
  import { compatibleProviderFromEnv } from "./compatibleProvider.ts";
18
18
  import { contextWindowFromEnv } from "./env.ts";
19
19
  import { withProviderDefaults } from "./defaults.ts";
20
+ import Meta, {
21
+ type PluginAttribution,
22
+ type PluginAttributionContext,
23
+ } from "@plurnk/plurnk-meta";
20
24
 
21
25
  // Two injectable seams, both defaulting to production behavior and never passed
22
26
  // by real callers: the module importer (tests exercise the bespoke path without
23
27
  // a real package on disk) and the discovery scan (tests inject a fixed map).
24
28
  type ImportModule = (specifier: string) => Promise<unknown>;
25
29
  const importModule: ImportModule = (specifier) => import(specifier);
26
- type DiscoverFn = (options?: DiscoverOptions) => Promise<Discovery>;
30
+ type DiscoveryInput = Omit<Discovery, "packageAttributions"> & {
31
+ packageAttributions?: Discovery["packageAttributions"];
32
+ };
33
+ type DiscoverFn = (options?: DiscoverOptions) => Promise<DiscoveryInput>;
27
34
 
28
35
  // The node_modules scan is filesystem work that never changes within a process,
29
36
  // so it runs once and the result is memoized. A long-lived daemon pays one scan
30
37
  // at first bespoke instantiation; every later run reuses it. The trust gate is
31
38
  // boot config, so the env from the first scan stands for the process.
32
- let discoveredCache: Discovery | null = null;
33
- const providerPackages = async (discoverFn: DiscoverFn, env: NodeJS.ProcessEnv): Promise<Discovery> => {
39
+ let discoveredCache: DiscoveryInput | null = null;
40
+ const providerPackages = async (discoverFn: DiscoverFn, env: NodeJS.ProcessEnv): Promise<DiscoveryInput> => {
34
41
  discoveredCache ??= await discoverFn({ env });
35
42
  return discoveredCache;
36
43
  };
@@ -54,7 +61,7 @@ export const instantiateProvider = async (
54
61
  if (catalog !== null) return catalog;
55
62
  if (name === "ollama") return ollamaProviderFromEnv(env, model, baseUrl === undefined ? undefined : { baseUrl });
56
63
  if (name === "openai" || name === "plurnk") return compatibleProviderFromEnv(name, env, model, baseUrl);
57
- const { registry, skipped } = await providerPackages(discoverFn, env);
64
+ const { registry, skipped, packageAttributions = new Map() } = await providerPackages(discoverFn, env);
58
65
  const specifier = registry.get(name);
59
66
  if (specifier === undefined) {
60
67
  const declined = skipped.get(name);
@@ -80,12 +87,18 @@ export const instantiateProvider = async (
80
87
  if (contextWindow === null) {
81
88
  throw new Error(`${specifier}: PLURNK_PROVIDERS_CONTEXT_WINDOW must be set because Models.dev has no metadata for provider "${name}"`);
82
89
  }
90
+ const declared = packageAttributions.get(specifier) ?? [];
91
+ const attributions = (context: PluginAttributionContext): PluginAttribution => Meta.composeAttributions(
92
+ declared,
93
+ Meta.runtimeAttribution(sdkProvider, context, specifier),
94
+ );
83
95
  return providerFromSdkModel({
84
96
  name,
85
97
  env,
86
98
  model,
87
99
  languageModel: sdkProvider.languageModel(model),
88
100
  contextWindow,
101
+ attributions,
89
102
  });
90
103
  };
91
104
 
@@ -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, FinishReason, 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,7 +67,15 @@ const wireUsageOf = (
66
67
  return null;
67
68
  };
68
69
 
69
- const finishReasonOf = (reason: string | undefined): FinishReason => {
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
+
78
+ const finishReasonOf = (reason: string | undefined): ProviderAttemptFinishReason => {
70
79
  switch (reason?.toLowerCase()) {
71
80
  case "stop":
72
81
  case "completed":
@@ -86,6 +95,8 @@ const finishReasonOf = (reason: string | undefined): FinishReason => {
86
95
  case "safety":
87
96
  case "recitation":
88
97
  return "content_filter";
98
+ case "insufficient_system_resource":
99
+ return "resource_interrupted";
89
100
  default:
90
101
  if (reason !== undefined && reason.length > 0) {
91
102
  emitWarningOnce(
@@ -133,7 +144,9 @@ export type AiSdkTransportResponse = {
133
144
  model: string;
134
145
  content: string;
135
146
  reasoning: string;
136
- finishReason: FinishReason;
147
+ reasoningProjected: boolean;
148
+ finishReason: ProviderAttemptFinishReason;
149
+ rawFinishReason?: string;
137
150
  usage: ProviderUsage;
138
151
  metadata: Record<string, unknown>;
139
152
  reasoningEncrypted: Array<{
@@ -142,6 +155,7 @@ export type AiSdkTransportResponse = {
142
155
  encrypted: Array<{ data: string; format: string | null }>;
143
156
  }>;
144
157
  logprobs: TokenLogprob[];
158
+ chargeEvidence: ProviderChargeEvidence;
145
159
  rawBody?: unknown;
146
160
  };
147
161
 
@@ -159,8 +173,46 @@ export type AiSdkModelRequest = Omit<AiSdkTransportRequest, "url" | "model" | "b
159
173
  reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "none" | "provider-default";
160
174
  };
161
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
+
162
182
  const executeModel = async (
163
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,
164
216
  ): Promise<AiSdkTransportResponse> => {
165
217
  const {
166
218
  languageModel: model,
@@ -223,17 +275,33 @@ const executeModel = async (
223
275
  const rawBody = result.response.body;
224
276
  const values = [rawBody];
225
277
  const evidence = extractEvidence(values);
278
+ const accountingUsage = wireUsageEvidenceOf(values);
226
279
  const reasoningText = evidence.reasoning || result.reasoningText || "";
280
+ const rawFinishReason = result.rawFinishReason;
227
281
  return {
228
282
  model: result.response.modelId,
229
283
  content: result.text,
230
284
  reasoning: reasoningText,
231
- finishReason: finishReasonOf(result.rawFinishReason),
285
+ reasoningProjected: evidence.reasoningProjected,
286
+ finishReason: finishReasonOf(rawFinishReason),
287
+ ...(rawFinishReason === undefined ? {} : { rawFinishReason }),
232
288
  usage: wireUsageOf(values, reasoningText, result.text)
233
289
  ?? usageOf(result.usage, reasoningText, result.text),
234
290
  metadata: metadataOf(values),
235
291
  reasoningEncrypted: evidence.reasoningEncrypted,
236
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
+ },
237
305
  ...(request.captureRawBody ? { rawBody } : {}),
238
306
  };
239
307
  }
@@ -251,18 +319,34 @@ const executeModel = async (
251
319
  }
252
320
  if (streamError !== undefined) throw streamError;
253
321
  const evidence = extractEvidence(rawChunks);
322
+ const accountingUsage = wireUsageEvidenceOf(rawChunks);
254
323
  const content = await result.text;
255
324
  const reasoningText = evidence.reasoning || (await result.reasoningText) || "";
325
+ const rawFinishReason = await result.rawFinishReason;
326
+ const [response, providerMetadata] = await Promise.all([
327
+ result.response,
328
+ result.providerMetadata,
329
+ ]);
256
330
  return {
257
- model: (await result.response).modelId,
331
+ model: response.modelId,
258
332
  content,
259
333
  reasoning: reasoningText,
260
- finishReason: finishReasonOf(await result.rawFinishReason),
334
+ reasoningProjected: evidence.reasoningProjected,
335
+ finishReason: finishReasonOf(rawFinishReason),
336
+ ...(rawFinishReason === undefined ? {} : { rawFinishReason }),
261
337
  usage: wireUsageOf(rawChunks, reasoningText, content)
262
338
  ?? usageOf(await result.usage, reasoningText, content),
263
339
  metadata: metadataOf(rawChunks),
264
340
  reasoningEncrypted: evidence.reasoningEncrypted,
265
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
+ },
266
350
  ...(request.captureRawBody ? { rawBody: rawChunks } : {}),
267
351
  };
268
352
  };
@@ -305,10 +389,12 @@ const extractEvidence = (values: unknown[]): {
305
389
  reasoningEncrypted: AiSdkTransportResponse["reasoningEncrypted"];
306
390
  logprobs: TokenLogprob[];
307
391
  reasoning: string;
392
+ reasoningProjected: boolean;
308
393
  } => {
309
394
  const encrypted = new Map<string, AiSdkTransportResponse["reasoningEncrypted"][number]>();
310
395
  const logprobs: TokenLogprob[] = [];
311
396
  let reasoning = "";
397
+ let reasoningProjected = false;
312
398
  let anonymous = 0;
313
399
  for (const value of values) {
314
400
  const choices = recordOf(value)?.choices;
@@ -336,7 +422,10 @@ const extractEvidence = (values: unknown[]): {
336
422
  }
337
423
  const message = recordOf(choice.delta) ?? recordOf(choice.message) ?? {};
338
424
  for (const key of ["reasoning_content", "reasoning", "thinking"]) { // lexicon-allow: backend wire fields
339
- if (typeof message[key] === "string") reasoning += message[key];
425
+ if (typeof message[key] === "string") {
426
+ reasoningProjected = true;
427
+ reasoning += message[key];
428
+ }
340
429
  }
341
430
  if (!Array.isArray(message.reasoning_details)) continue;
342
431
  for (const value of message.reasoning_details) {
@@ -348,6 +437,9 @@ const extractEvidence = (values: unknown[]): {
348
437
  : id === null ? `anonymous:${anonymous++}` : `id:${id}`;
349
438
  const item: AiSdkTransportResponse["reasoningEncrypted"][number] = encrypted.get(key) ?? {
350
439
  id,
440
+ // {§provider-encrypted-reasoning} The documented wire location
441
+ // is the assistant message. `id` above still identifies only
442
+ // this provider detail, never a downstream message entity.
351
443
  subtype: "message",
352
444
  encrypted: [],
353
445
  };
@@ -366,5 +458,6 @@ const extractEvidence = (values: unknown[]): {
366
458
  reasoningEncrypted: [...encrypted.values()],
367
459
  logprobs,
368
460
  reasoning,
461
+ reasoningProjected,
369
462
  };
370
463
  };