@plurnk/plurnk-providers 1.3.12 → 1.4.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 (132) hide show
  1. package/.env.defaults +39 -22
  2. package/README.md +65 -4
  3. package/SPEC.md +222 -56
  4. package/dist/AiSdkProvider.d.ts +18 -5
  5. package/dist/AiSdkProvider.d.ts.map +1 -1
  6. package/dist/AiSdkProvider.js +240 -153
  7. package/dist/AiSdkProvider.js.map +1 -1
  8. package/dist/Mock.d.ts +14 -15
  9. package/dist/Mock.d.ts.map +1 -1
  10. package/dist/Mock.js +26 -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/aiSdkTransport.d.ts +4 -2
  21. package/dist/aiSdkTransport.d.ts.map +1 -1
  22. package/dist/aiSdkTransport.js +18 -3
  23. package/dist/aiSdkTransport.js.map +1 -1
  24. package/dist/catalogProvider.d.ts +3 -1
  25. package/dist/catalogProvider.d.ts.map +1 -1
  26. package/dist/catalogProvider.js +20 -7
  27. package/dist/catalogProvider.js.map +1 -1
  28. package/dist/compatibleProvider.d.ts.map +1 -1
  29. package/dist/compatibleProvider.js +11 -4
  30. package/dist/compatibleProvider.js.map +1 -1
  31. package/dist/cost.d.ts +11 -0
  32. package/dist/cost.d.ts.map +1 -0
  33. package/dist/cost.js +64 -0
  34. package/dist/cost.js.map +1 -0
  35. package/dist/discover.d.ts +2 -0
  36. package/dist/discover.d.ts.map +1 -1
  37. package/dist/discover.js +15 -9
  38. package/dist/discover.js.map +1 -1
  39. package/dist/env.d.ts +4 -0
  40. package/dist/env.d.ts.map +1 -1
  41. package/dist/env.js +29 -9
  42. package/dist/env.js.map +1 -1
  43. package/dist/errors.d.ts +27 -0
  44. package/dist/errors.d.ts.map +1 -0
  45. package/dist/errors.js +150 -0
  46. package/dist/errors.js.map +1 -0
  47. package/dist/index.d.ts +11 -5
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +7 -4
  50. package/dist/index.js.map +1 -1
  51. package/dist/notices.d.ts +10 -0
  52. package/dist/notices.d.ts.map +1 -0
  53. package/dist/notices.js +11 -0
  54. package/dist/notices.js.map +1 -0
  55. package/dist/ollama.d.ts.map +1 -1
  56. package/dist/ollama.js +3 -3
  57. package/dist/ollama.js.map +1 -1
  58. package/dist/openai.d.ts +1 -1
  59. package/dist/openai.d.ts.map +1 -1
  60. package/dist/promptTokens.d.ts +4 -0
  61. package/dist/promptTokens.d.ts.map +1 -0
  62. package/dist/promptTokens.js +32 -0
  63. package/dist/promptTokens.js.map +1 -0
  64. package/dist/sdkModels.d.ts.map +1 -1
  65. package/dist/sdkModels.js +4 -3
  66. package/dist/sdkModels.js.map +1 -1
  67. package/dist/types.d.ts +43 -16
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js +1 -1
  70. package/dist/types.js.map +1 -1
  71. package/dist/usage.d.ts +3 -0
  72. package/dist/usage.d.ts.map +1 -1
  73. package/dist/usage.js +26 -14
  74. package/dist/usage.js.map +1 -1
  75. package/dist/warnings.js +0 -0
  76. package/dist/warnings.js.map +1 -1
  77. package/package.json +13 -9
  78. package/src/AiSdkProvider.test.ts +480 -159
  79. package/src/AiSdkProvider.ts +320 -196
  80. package/src/Mock.test.ts +29 -14
  81. package/src/Mock.ts +33 -15
  82. package/src/Pool.test.ts +43 -6
  83. package/src/Pool.ts +56 -10
  84. package/src/ProviderRegistry.test.ts +158 -9
  85. package/src/ProviderRegistry.ts +19 -6
  86. package/src/aiSdkTransport.ts +25 -6
  87. package/src/boundaries.test.ts +8 -3
  88. package/src/catalogProvider.test.ts +17 -0
  89. package/src/catalogProvider.ts +25 -10
  90. package/src/compatibleProvider.test.ts +96 -0
  91. package/src/compatibleProvider.ts +15 -6
  92. package/src/cost.test.ts +63 -0
  93. package/src/cost.ts +83 -0
  94. package/src/defaults.test.ts +1 -0
  95. package/src/discover.test.ts +48 -7
  96. package/src/discover.ts +31 -21
  97. package/src/env.test.ts +38 -23
  98. package/src/env.ts +45 -18
  99. package/src/errors.test.ts +148 -0
  100. package/src/errors.ts +207 -0
  101. package/src/index.ts +29 -7
  102. package/src/lexicon-guard.test.ts +6 -6
  103. package/src/notices.ts +22 -0
  104. package/src/ollama.test.ts +64 -0
  105. package/src/ollama.ts +6 -3
  106. package/src/openai.ts +3 -0
  107. package/src/promptTokens.ts +41 -0
  108. package/src/sdkModels.test.ts +7 -0
  109. package/src/sdkModels.ts +4 -8
  110. package/src/types.ts +106 -64
  111. package/src/usage.test.ts +15 -4
  112. package/src/usage.ts +32 -14
  113. package/src/warnings.test.ts +10 -10
  114. package/src/warnings.ts +0 -0
  115. package/dist/OpenAICompat.d.ts +0 -76
  116. package/dist/OpenAICompat.d.ts.map +0 -1
  117. package/dist/OpenAICompat.js +0 -555
  118. package/dist/OpenAICompat.js.map +0 -1
  119. package/dist/openaiStream.d.ts +0 -47
  120. package/dist/openaiStream.d.ts.map +0 -1
  121. package/dist/openaiStream.js +0 -280
  122. package/dist/openaiStream.js.map +0 -1
  123. package/dist/standardProviders.d.ts +0 -31
  124. package/dist/standardProviders.d.ts.map +0 -1
  125. package/dist/standardProviders.js +0 -518
  126. package/dist/standardProviders.js.map +0 -1
  127. package/dist/telemetry.d.ts +0 -24
  128. package/dist/telemetry.d.ts.map +0 -1
  129. package/dist/telemetry.js +0 -85
  130. package/dist/telemetry.js.map +0 -1
  131. package/src/telemetry.test.ts +0 -69
  132. 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
 
@@ -1,7 +1,7 @@
1
1
  import { createOpenAICompatible, type ProviderErrorStructure } from "@ai-sdk/openai-compatible";
2
2
  import { generateText, streamText, type JSONValue, type LanguageModel, type LanguageModelUsage } from "ai";
3
3
  import { z } from "zod/v4";
4
- import type { ChatMessage, FinishReason, ProviderUsage, TokenLogprob } from "./types.ts";
4
+ import type { ChatMessage, ProviderAttemptFinishReason, ProviderUsage, TokenLogprob } from "./types.ts";
5
5
  import { normalizeUsage, type RawUsage } from "./usage.ts";
6
6
  import { emitWarningOnce } from "./warnings.ts";
7
7
 
@@ -66,7 +66,7 @@ const wireUsageOf = (
66
66
  return null;
67
67
  };
68
68
 
69
- const finishReasonOf = (reason: string | undefined): FinishReason => {
69
+ const finishReasonOf = (reason: string | undefined): ProviderAttemptFinishReason => {
70
70
  switch (reason?.toLowerCase()) {
71
71
  case "stop":
72
72
  case "completed":
@@ -86,6 +86,8 @@ const finishReasonOf = (reason: string | undefined): FinishReason => {
86
86
  case "safety":
87
87
  case "recitation":
88
88
  return "content_filter";
89
+ case "insufficient_system_resource":
90
+ return "resource_interrupted";
89
91
  default:
90
92
  if (reason !== undefined && reason.length > 0) {
91
93
  emitWarningOnce(
@@ -133,7 +135,9 @@ export type AiSdkTransportResponse = {
133
135
  model: string;
134
136
  content: string;
135
137
  reasoning: string;
136
- finishReason: FinishReason;
138
+ reasoningProjected: boolean;
139
+ finishReason: ProviderAttemptFinishReason;
140
+ rawFinishReason?: string;
137
141
  usage: ProviderUsage;
138
142
  metadata: Record<string, unknown>;
139
143
  reasoningEncrypted: Array<{
@@ -224,11 +228,14 @@ const executeModel = async (
224
228
  const values = [rawBody];
225
229
  const evidence = extractEvidence(values);
226
230
  const reasoningText = evidence.reasoning || result.reasoningText || "";
231
+ const rawFinishReason = result.rawFinishReason;
227
232
  return {
228
233
  model: result.response.modelId,
229
234
  content: result.text,
230
235
  reasoning: reasoningText,
231
- finishReason: finishReasonOf(result.rawFinishReason),
236
+ reasoningProjected: evidence.reasoningProjected,
237
+ finishReason: finishReasonOf(rawFinishReason),
238
+ ...(rawFinishReason === undefined ? {} : { rawFinishReason }),
232
239
  usage: wireUsageOf(values, reasoningText, result.text)
233
240
  ?? usageOf(result.usage, reasoningText, result.text),
234
241
  metadata: metadataOf(values),
@@ -253,11 +260,14 @@ const executeModel = async (
253
260
  const evidence = extractEvidence(rawChunks);
254
261
  const content = await result.text;
255
262
  const reasoningText = evidence.reasoning || (await result.reasoningText) || "";
263
+ const rawFinishReason = await result.rawFinishReason;
256
264
  return {
257
265
  model: (await result.response).modelId,
258
266
  content,
259
267
  reasoning: reasoningText,
260
- finishReason: finishReasonOf(await result.rawFinishReason),
268
+ reasoningProjected: evidence.reasoningProjected,
269
+ finishReason: finishReasonOf(rawFinishReason),
270
+ ...(rawFinishReason === undefined ? {} : { rawFinishReason }),
261
271
  usage: wireUsageOf(rawChunks, reasoningText, content)
262
272
  ?? usageOf(await result.usage, reasoningText, content),
263
273
  metadata: metadataOf(rawChunks),
@@ -305,10 +315,12 @@ const extractEvidence = (values: unknown[]): {
305
315
  reasoningEncrypted: AiSdkTransportResponse["reasoningEncrypted"];
306
316
  logprobs: TokenLogprob[];
307
317
  reasoning: string;
318
+ reasoningProjected: boolean;
308
319
  } => {
309
320
  const encrypted = new Map<string, AiSdkTransportResponse["reasoningEncrypted"][number]>();
310
321
  const logprobs: TokenLogprob[] = [];
311
322
  let reasoning = "";
323
+ let reasoningProjected = false;
312
324
  let anonymous = 0;
313
325
  for (const value of values) {
314
326
  const choices = recordOf(value)?.choices;
@@ -336,7 +348,10 @@ const extractEvidence = (values: unknown[]): {
336
348
  }
337
349
  const message = recordOf(choice.delta) ?? recordOf(choice.message) ?? {};
338
350
  for (const key of ["reasoning_content", "reasoning", "thinking"]) { // lexicon-allow: backend wire fields
339
- if (typeof message[key] === "string") reasoning += message[key];
351
+ if (typeof message[key] === "string") {
352
+ reasoningProjected = true;
353
+ reasoning += message[key];
354
+ }
340
355
  }
341
356
  if (!Array.isArray(message.reasoning_details)) continue;
342
357
  for (const value of message.reasoning_details) {
@@ -348,6 +363,9 @@ const extractEvidence = (values: unknown[]): {
348
363
  : id === null ? `anonymous:${anonymous++}` : `id:${id}`;
349
364
  const item: AiSdkTransportResponse["reasoningEncrypted"][number] = encrypted.get(key) ?? {
350
365
  id,
366
+ // {§provider-encrypted-reasoning} The documented wire location
367
+ // is the assistant message. `id` above still identifies only
368
+ // this provider detail, never a downstream message entity.
351
369
  subtype: "message",
352
370
  encrypted: [],
353
371
  };
@@ -366,5 +384,6 @@ const extractEvidence = (values: unknown[]): {
366
384
  reasoningEncrypted: [...encrypted.values()],
367
385
  logprobs,
368
386
  reasoning,
387
+ reasoningProjected,
369
388
  };
370
389
  };
@@ -19,17 +19,22 @@ test("provider source does not import the service or database", () => {
19
19
  test("provider source does not import the PLURNK parser", () => {
20
20
  for (const file of sourceFiles()) {
21
21
  const source = readFileSync(join(root, file), "utf8");
22
- assert.ok(!/from\s+["']@plurnk\/plurnk-grammar/.test(source), `${file} imports the parser`);
22
+ assert.ok(
23
+ !/from\s+["']@plurnk\/plurnk-contracts\/grammar["']/.test(source),
24
+ `${file} imports the parser rather than the runtime-neutral contracts entrypoint`,
25
+ );
23
26
  }
24
27
  });
25
28
 
26
- test("#608: the OpenAI-compatible entrypoint excludes Node-owned provider machinery", () => {
29
+ test("the OpenAI-compatible entrypoint excludes Node-owned provider machinery", () => {
27
30
  const allowed = new Set([
28
31
  "AiSdkProvider.ts",
29
32
  "aiSdkTransport.ts",
30
33
  "env.ts",
34
+ "errors.ts",
35
+ "notices.ts",
31
36
  "openai.ts",
32
- "telemetry.ts",
37
+ "promptTokens.ts",
33
38
  "types.ts",
34
39
  "usage.ts",
35
40
  "warnings.ts",
@@ -15,6 +15,7 @@ const env = {
15
15
  PLURNK_PROVIDERS_REASONING_RESERVE: "10%",
16
16
  PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%",
17
17
  PLURNK_PROVIDERS_RETRY_ATTEMPTS: "0",
18
+ PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT: "512",
18
19
  PLURNK_PROVIDERS_PROMPT_CACHE_KEY: "1",
19
20
  };
20
21
 
@@ -39,6 +40,22 @@ test("catalog provider resolves model physics and Models.dev USD rates", () => {
39
40
  }) ?? 0) > 0);
40
41
  });
41
42
 
43
+ test("an operator context window caps catalog physics and percentage reserves derive from the cap", () => {
44
+ const provider = catalogProviderFromEnv("openai", {
45
+ ...env,
46
+ PLURNK_PROVIDERS_CONTEXT_WINDOW: "128000",
47
+ }, "gpt-4.1-mini");
48
+ assert.equal(provider?.contextWindow, 128_000);
49
+ assert.equal(provider?.reasoningReserve, 16_000);
50
+ assert.equal(provider?.completionReserve, 32_000);
51
+
52
+ const oversized = catalogProviderFromEnv("openai", {
53
+ ...env,
54
+ PLURNK_PROVIDERS_CONTEXT_WINDOW: "2000000",
55
+ }, "gpt-4.1-mini");
56
+ assert.equal(oversized?.contextWindow, 1_047_576, "an operator ceiling cannot enlarge model physics");
57
+ });
58
+
42
59
  test("official AI SDK provider owns the native request while PLURNK owns call settings", async () => {
43
60
  const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
44
61
  mock.method(globalThis, "fetch", async (input: string | URL | Request, init?: RequestInit) => {
@@ -1,23 +1,27 @@
1
1
  import { lookupProvider, resolveModel, type ModelInfo } from "@plurnk/plurnk-models";
2
2
  import {
3
3
  contextWindowFromEnv,
4
+ effectiveContextWindow,
4
5
  dataCaptureFromEnv,
5
6
  envelopeFromEnv,
6
7
  parseRequiredFloat,
7
8
  parseRequiredInt,
8
9
  promptCacheKeyFromEnv,
9
10
  reasoningFromEnv,
11
+ reasoningResponseStyleFromEnv,
10
12
  resolveReserve,
11
13
  tokenRatesFromEnv,
12
14
  type ReserveSpec,
13
15
  } from "./env.ts";
14
16
  import AiSdkProvider, { type ReasoningStyle } from "./AiSdkProvider.ts";
15
17
  import { configuredProviderInfo, createSdkModel } from "./sdkModels.ts";
16
- import { providerSource } from "./telemetry.ts";
18
+ import { providerSource } from "./notices.ts";
17
19
  import type { Provider, ProviderUsage } from "./types.ts";
18
20
  import { calculateCostUsd } from "./usage.ts";
19
21
  import { emitWarningOnce } from "./warnings.ts";
20
22
  import type { LanguageModel } from "ai";
23
+ import type { PluginAttribution, PluginAttributionContext } from "@plurnk/plurnk-meta";
24
+ import type { ProviderCost } from "@plurnk/plurnk-contracts";
21
25
 
22
26
  const reasoningStyleFromEnv = (
23
27
  env: NodeJS.ProcessEnv,
@@ -28,7 +32,7 @@ const reasoningStyleFromEnv = (
28
32
  if (value === undefined || value.length === 0) return undefined;
29
33
  const styles: readonly ReasoningStyle[] = [
30
34
  "none", "think", "include_reasoning", "effort",
31
- "effort_explicit", "template", "anthropic",
35
+ "effort_explicit", "thinking_effort", "template", "anthropic",
32
36
  ];
33
37
  if (!styles.includes(value as ReasoningStyle)) {
34
38
  throw new Error(`${name} provider: PLURNK_PROVIDERS_PROVIDER_${prefix}_REASONING_STYLE has invalid value "${value}"`);
@@ -45,6 +49,7 @@ export const providerFromSdkModel = ({
45
49
  headers,
46
50
  contextWindow,
47
51
  info,
52
+ attributions,
48
53
  }: {
49
54
  name: string;
50
55
  env: NodeJS.ProcessEnv;
@@ -54,10 +59,11 @@ export const providerFromSdkModel = ({
54
59
  headers?: Readonly<Record<string, string>>;
55
60
  contextWindow: number;
56
61
  info?: ModelInfo;
62
+ attributions?: (context: PluginAttributionContext) => PluginAttribution;
57
63
  }): Provider => {
58
64
  emitWarningOnce(
59
- `${name} provider: countTokens is a chars/2 upper bound — exact counts come from the mimetypes tokenizer seam or tokenize()`,
60
- "PLURNK_TOKENIZER_HEURISTIC",
65
+ `${name} provider: physical prompt counting is a chars/2 estimate; over-policy recovery fails closed without exact or bounded request evidence`,
66
+ "PLURNK_PROMPT_COUNT_ESTIMATE",
61
67
  );
62
68
 
63
69
  const reasoning = reasoningFromEnv(env, name);
@@ -86,9 +92,20 @@ export const providerFromSdkModel = ({
86
92
  const calculateCost = rates === null
87
93
  ? undefined
88
94
  : (usage: ProviderUsage): number => calculateCostUsd(usage, rates);
95
+ const rateSource = configuredRates === null ? "Models.dev catalog rates" : "operator-configured rates";
96
+ const calculateCharge: (usage: ProviderUsage) => Exclude<ProviderCost, { kind: "authoritative" }> = rates === null
97
+ ? () => ({ kind: "unknown", reason: "no provider rate or settled charge is available" })
98
+ : rates.input === 0 && rates.output === 0 && rates.cached === 0
99
+ ? () => ({ kind: "free", source: rateSource })
100
+ : (usage: ProviderUsage) => ({
101
+ kind: "estimated",
102
+ usd: String(calculateCostUsd(usage, rates)),
103
+ source: rateSource,
104
+ });
89
105
 
90
106
  return new AiSdkProvider({
91
107
  model,
108
+ ...(attributions === undefined ? {} : { attributions }),
92
109
  ...(languageModel === undefined ? {} : { languageModel }),
93
110
  ...(url === undefined ? {} : { url }),
94
111
  ...(headers === undefined ? {} : { headers: { ...headers } }),
@@ -96,16 +113,19 @@ export const providerFromSdkModel = ({
96
113
  fetchTimeoutMs: parseRequiredInt(env.PLURNK_PROVIDERS_FETCH_TIMEOUT, "PLURNK_PROVIDERS_FETCH_TIMEOUT", name),
97
114
  streamIdleTimeoutMs: parseRequiredInt(env.PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT, "PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT", name),
98
115
  reasoning,
116
+ reasoningResponseStyle: reasoningResponseStyleFromEnv(env, name),
99
117
  temperature: parseRequiredFloat(env.PLURNK_PROVIDERS_TEMPERATURE, "PLURNK_PROVIDERS_TEMPERATURE", name, 0),
100
118
  repeatPenalty: parseRequiredFloat(env.PLURNK_PROVIDERS_REPEAT_PENALTY, "PLURNK_PROVIDERS_REPEAT_PENALTY", name, 0),
101
119
  frequencyPenalty: parseRequiredFloat(env.PLURNK_PROVIDERS_FREQUENCY_PENALTY, "PLURNK_PROVIDERS_FREQUENCY_PENALTY", name, 0),
102
120
  reasoningReserve,
103
121
  completionReserve,
104
122
  retryAttempts: parseRequiredInt(env.PLURNK_PROVIDERS_RETRY_ATTEMPTS, "PLURNK_PROVIDERS_RETRY_ATTEMPTS", name),
123
+ errorDetailLimit: parseRequiredInt(env.PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT, "PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT", name),
105
124
  reasoningStyle: reasoningStyleFromEnv(env, name),
106
125
  promptCacheKey: url === undefined ? false : promptCacheKeyFromEnv(env, name),
107
126
  serviceTier: env.PLURNK_PROVIDERS_SERVICE_TIER,
108
127
  calculateCost,
128
+ calculateCharge,
109
129
  source: providerSource(name),
110
130
  gbnfDebug: env.PLURNK_PROVIDERS_GBNF_DEBUG !== undefined
111
131
  && env.PLURNK_PROVIDERS_GBNF_DEBUG !== ""
@@ -133,13 +153,8 @@ export const catalogProviderFromEnv = (
133
153
  const sdk = createSdkModel(name, wireModel, env, baseUrlOverride);
134
154
  if (sdk === null) return null;
135
155
 
136
- emitWarningOnce(
137
- `${name} provider: countTokens is a chars/2 upper bound — exact counts come from the mimetypes tokenizer seam or tokenize()`,
138
- "PLURNK_TOKENIZER_HEURISTIC",
139
- );
140
-
141
156
  const info = resolved?.info;
142
- const contextWindow = contextOverride ?? info?.contextWindow ?? null;
157
+ const contextWindow = effectiveContextWindow(contextOverride, info?.contextWindow ?? null);
143
158
  if (contextWindow === null) {
144
159
  throw new Error(
145
160
  `${name} provider: context window unresolved for "${wireModel}" — set PLURNK_PROVIDERS_CONTEXT_WINDOW or update the Models.dev snapshot`,