@plurnk/plurnk-providers 1.7.0 → 1.9.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 (91) hide show
  1. package/.env.defaults +25 -13
  2. package/README.md +8 -1
  3. package/SPEC.md +103 -15
  4. package/dist/AiSdkProvider.d.ts +7 -4
  5. package/dist/AiSdkProvider.d.ts.map +1 -1
  6. package/dist/AiSdkProvider.js +150 -53
  7. package/dist/AiSdkProvider.js.map +1 -1
  8. package/dist/Mock.d.ts +2 -1
  9. package/dist/Mock.d.ts.map +1 -1
  10. package/dist/Mock.js +6 -1
  11. package/dist/Mock.js.map +1 -1
  12. package/dist/Pool.d.ts +2 -0
  13. package/dist/Pool.d.ts.map +1 -1
  14. package/dist/Pool.js +3 -0
  15. package/dist/Pool.js.map +1 -1
  16. package/dist/ProviderRegistry.d.ts.map +1 -1
  17. package/dist/ProviderRegistry.js +11 -10
  18. package/dist/ProviderRegistry.js.map +1 -1
  19. package/dist/accounting.d.ts.map +1 -1
  20. package/dist/accounting.js +16 -8
  21. package/dist/accounting.js.map +1 -1
  22. package/dist/aiSdkTransport.d.ts +4 -2
  23. package/dist/aiSdkTransport.d.ts.map +1 -1
  24. package/dist/aiSdkTransport.js +33 -6
  25. package/dist/aiSdkTransport.js.map +1 -1
  26. package/dist/catalogProvider.d.ts +4 -1
  27. package/dist/catalogProvider.d.ts.map +1 -1
  28. package/dist/catalogProvider.js +94 -3
  29. package/dist/catalogProvider.js.map +1 -1
  30. package/dist/compatibleProvider.d.ts.map +1 -1
  31. package/dist/compatibleProvider.js +2 -0
  32. package/dist/compatibleProvider.js.map +1 -1
  33. package/dist/cost.d.ts.map +1 -1
  34. package/dist/cost.js +5 -4
  35. package/dist/cost.js.map +1 -1
  36. package/dist/discover.d.ts +2 -0
  37. package/dist/discover.d.ts.map +1 -1
  38. package/dist/discover.js +13 -2
  39. package/dist/discover.js.map +1 -1
  40. package/dist/env.d.ts +3 -2
  41. package/dist/env.d.ts.map +1 -1
  42. package/dist/env.js +11 -4
  43. package/dist/env.js.map +1 -1
  44. package/dist/index.d.ts +10 -6
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +6 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/notices.d.ts +1 -1
  49. package/dist/notices.d.ts.map +1 -1
  50. package/dist/openai.d.ts +1 -1
  51. package/dist/openai.d.ts.map +1 -1
  52. package/dist/openai.js +1 -1
  53. package/dist/openai.js.map +1 -1
  54. package/dist/sdkModels.d.ts +2 -0
  55. package/dist/sdkModels.d.ts.map +1 -1
  56. package/dist/sdkModels.js +163 -19
  57. package/dist/sdkModels.js.map +1 -1
  58. package/dist/types.d.ts +10 -2
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/types.js +10 -1
  61. package/dist/types.js.map +1 -1
  62. package/package.json +9 -9
  63. package/src/AiSdkProvider.test.ts +214 -34
  64. package/src/AiSdkProvider.ts +181 -56
  65. package/src/Mock.test.ts +6 -1
  66. package/src/Mock.ts +6 -1
  67. package/src/Pool.test.ts +1 -0
  68. package/src/Pool.ts +5 -0
  69. package/src/ProviderRegistry.test.ts +27 -14
  70. package/src/ProviderRegistry.ts +19 -10
  71. package/src/accounting.test.ts +30 -2
  72. package/src/accounting.ts +16 -8
  73. package/src/aiSdkTransport.test.ts +3 -0
  74. package/src/aiSdkTransport.ts +38 -8
  75. package/src/catalogProvider.test.ts +151 -19
  76. package/src/catalogProvider.ts +125 -3
  77. package/src/compatibleProvider.test.ts +13 -10
  78. package/src/compatibleProvider.ts +2 -0
  79. package/src/cost.ts +5 -4
  80. package/src/discover.test.ts +27 -0
  81. package/src/discover.ts +20 -3
  82. package/src/env.test.ts +23 -8
  83. package/src/env.ts +18 -9
  84. package/src/errors.test.ts +2 -2
  85. package/src/index.ts +17 -8
  86. package/src/notices.ts +1 -1
  87. package/src/openai.ts +1 -1
  88. package/src/providerDefaults.test.ts +50 -0
  89. package/src/sdkModels.test.ts +142 -8
  90. package/src/sdkModels.ts +201 -19
  91. package/src/types.ts +22 -0
@@ -4,7 +4,7 @@ import { instantiateProvider, loadActiveProvider, resetDiscoveryCache } from "./
4
4
  import type { PluginAttributionContext } from "@plurnk/plurnk-meta";
5
5
 
6
6
  const mapOf = (entries: Record<string, string>, skipped: Record<string, string> = {}) =>
7
- async () => ({ registry: new Map(Object.entries(entries)), skipped: new Map(Object.entries(skipped)), attributions: new Map<string, string | string[]>() });
7
+ async () => ({ registry: new Map(Object.entries(entries)), skipped: new Map(Object.entries(skipped)), attributions: new Map<string, string | string[]>(), grammarStyles: new Map() });
8
8
 
9
9
  // Alias parsing is tested in @plurnk/plurnk-aliases (its owner). Here we
10
10
  // exercise the resolution + two-tier instantiation this module owns; the active
@@ -31,7 +31,7 @@ test("instantiateProvider: cataloged name resolves in-framework, no scan, no imp
31
31
  let scanned = false;
32
32
  const p = await instantiateProvider("openai", { ...fullEnv }, "m",
33
33
  async (s) => { imports.push(s); return {}; },
34
- async () => { scanned = true; return { registry: new Map(), skipped: new Map(), attributions: new Map() }; });
34
+ async () => { scanned = true; return { registry: new Map(), skipped: new Map(), attributions: new Map(), grammarStyles: new Map() }; });
35
35
  assert.equal(p.model, "m");
36
36
  assert.deepEqual(imports, []); // tier 1 never touches the importer…
37
37
  assert.equal(scanned, false); // …nor the scan
@@ -77,7 +77,7 @@ test("instantiateProvider: a selected plugin composes its static and runtime att
77
77
  registry: new Map([["acme", "@acme/ai-provider"]]),
78
78
  skipped: new Map(),
79
79
  attributions: new Map([["acme", "static:provider"]]),
80
- packageAttributions: new Map([["@acme/ai-provider", ["static:provider"]]]),
80
+ packageAttributions: new Map([["@acme/ai-provider", ["static:provider"]]]), grammarStyles: new Map(),
81
81
  }),
82
82
  );
83
83
 
@@ -107,7 +107,7 @@ test("instantiateProvider: a per-alias baseUrl drives the standard openai probe
107
107
  return new Response(JSON.stringify({ data: [] }), { status: 200 });
108
108
  });
109
109
  await instantiateProvider("openai", { ...fullEnv }, "m", // fullEnv.OPENAI_BASE_URL is http://x — the override must win
110
- async () => ({}), async () => ({ registry: new Map(), skipped: new Map(), attributions: new Map() }),
110
+ async () => ({}), async () => ({ registry: new Map(), skipped: new Map(), attributions: new Map(), grammarStyles: new Map() }),
111
111
  "http://hazel2:8080/v1");
112
112
  assert.ok(probed.some((u) => u === "http://hazel2:8080/v1/models"), `probe hit the override host; saw ${probed.join(", ")}`);
113
113
  assert.equal(probed.some((u) => u.startsWith("http://x")), false); // never the per-name OPENAI_BASE_URL
@@ -193,13 +193,13 @@ test("instantiateProvider: per-alias knobs scope through to the provider (per-al
193
193
  });
194
194
  const env = { ...fullEnv, PLURNK_PROVIDERS_CONTEXT_WINDOW_turbo: "12345", PLURNK_PROVIDERS_LLAMA_SERVER_turbo: "1" };
195
195
  const p = await instantiateProvider("openai", env, "m",
196
- async () => ({}), async () => ({ registry: new Map(), skipped: new Map(), attributions: new Map() }),
196
+ async () => ({}), async () => ({ registry: new Map(), skipped: new Map(), attributions: new Map(), grammarStyles: new Map() }),
197
197
  undefined, "turbo");
198
198
  assert.equal(p.contextWindow, 12345); // _turbo CONTEXT_WINDOW reached the provider
199
199
  assert.equal(p.constrainsOutput, true); // _turbo LLAMA_SERVER pin reached it too
200
200
  // same env, DIFFERENT alias: neither override applies
201
201
  const q = await instantiateProvider("openai", env, "m",
202
- async () => ({}), async () => ({ registry: new Map(), skipped: new Map(), attributions: new Map() }),
202
+ async () => ({}), async () => ({ registry: new Map(), skipped: new Map(), attributions: new Map(), grammarStyles: new Map() }),
203
203
  undefined, "plain");
204
204
  assert.equal(q.contextWindow, null);
205
205
  assert.equal(q.constrainsOutput, false);
@@ -310,7 +310,7 @@ test("a catalog provider with unknown model metadata never falls through to plug
310
310
  "cloudflare",
311
311
  {
312
312
  CLOUDFLARE_ACCOUNT_ID: "account",
313
- CLOUDFLARE_API_TOKEN: "token",
313
+ CLOUDFLARE_API_KEY: "token",
314
314
  },
315
315
  "vendor/model-outside-snapshot",
316
316
  async () => { throw new Error("plugin import must not run"); },
@@ -319,7 +319,7 @@ test("a catalog provider with unknown model metadata never falls through to plug
319
319
  return {
320
320
  registry: new Map([["cloudflare", "@plurnk/plurnk-providers-cloudflare"]]),
321
321
  skipped: new Map(),
322
- attributions: new Map(),
322
+ attributions: new Map(), grammarStyles: new Map(),
323
323
  };
324
324
  },
325
325
  ),
@@ -334,7 +334,7 @@ test("explicit metadata constructs an out-of-snapshot Cloudflare model in the co
334
334
  "cloudflare",
335
335
  {
336
336
  CLOUDFLARE_ACCOUNT_ID: "account",
337
- CLOUDFLARE_API_TOKEN: "token",
337
+ CLOUDFLARE_API_KEY: "token",
338
338
  PLURNK_PROVIDERS_CONTEXT_WINDOW: "128000",
339
339
  },
340
340
  "vendor/model-outside-snapshot",
@@ -344,7 +344,7 @@ test("explicit metadata constructs an out-of-snapshot Cloudflare model in the co
344
344
  return {
345
345
  registry: new Map([["cloudflare", "@plurnk/plurnk-providers-cloudflare"]]),
346
346
  skipped: new Map(),
347
- attributions: new Map(),
347
+ attributions: new Map(), grammarStyles: new Map(),
348
348
  };
349
349
  },
350
350
  );
@@ -382,7 +382,7 @@ test("{§provider-tagged-reasoning} a Cloudflare model alias carries its explici
382
382
  {
383
383
  ...fullEnv,
384
384
  CLOUDFLARE_ACCOUNT_ID: "account",
385
- CLOUDFLARE_API_TOKEN: "token",
385
+ CLOUDFLARE_API_KEY: "token",
386
386
  PLURNK_PROVIDERS_REASONING_RESPONSE_STYLE: "verbatim",
387
387
  PLURNK_PROVIDERS_REASONING_RESPONSE_STYLE_cfds1: "think-tags",
388
388
  },
@@ -406,7 +406,17 @@ test("two Fireworks aliases independently select default and priority service ti
406
406
  const bodies: Record<string, unknown>[] = [];
407
407
  mock.method(globalThis, "fetch", async (_url: string, init?: RequestInit) => {
408
408
  bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
409
- return new Response(JSON.stringify({ choices: [{ message: { content: "ok" }, finish_reason: "stop" }] }), { status: 200, headers: { "Content-Type": "application/json" } });
409
+ const chunk = {
410
+ id: "fireworks-test",
411
+ object: "chat.completion.chunk",
412
+ created: 1,
413
+ model: "fireworks-test",
414
+ choices: [{ index: 0, delta: { content: "ok" }, finish_reason: "stop" }],
415
+ };
416
+ return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, {
417
+ status: 200,
418
+ headers: { "content-type": "text/event-stream" },
419
+ });
410
420
  });
411
421
  const env = {
412
422
  ...fullEnv,
@@ -419,7 +429,7 @@ test("two Fireworks aliases independently select default and priority service ti
419
429
  PLURNK_PROVIDERS_SERVICE_TIER_standard: "default",
420
430
  };
421
431
  const imports = async () => ({});
422
- const discover = async () => ({ registry: new Map(), skipped: new Map(), attributions: new Map() });
432
+ const discover = async () => ({ registry: new Map(), skipped: new Map(), attributions: new Map(), grammarStyles: new Map() });
423
433
  const fast = await instantiateProvider("fireworks", env, "accounts/fireworks/routers/glm-5p2-fast", imports, discover, undefined, "fast");
424
434
  const standard = await instantiateProvider("fireworks", env, "deepseek-v4-pro", imports, discover, undefined, "standard");
425
435
  await fast.generate({ workerId: "fast-worker", messages: [] });
@@ -445,5 +455,8 @@ test("loadActiveProvider: resolves the alias cascade to an installed AI SDK prov
445
455
  });
446
456
 
447
457
  test("loadActiveProvider: throws a named error when no alias is active", async () => {
448
- await assert.rejects(() => loadActiveProvider({ ...fullEnv }), /set PLURNK_MODEL to an alias/);
458
+ await assert.rejects(
459
+ () => loadActiveProvider({ ...fullEnv }),
460
+ /set PLURNK_MODEL to a declared alias or provider\/model route/,
461
+ );
449
462
  });
@@ -1,7 +1,7 @@
1
- // Provider instantiation + active-alias resolution. Alias PARSING (the
1
+ // Provider instantiation + active model-route resolution. Alias parsing (the
2
2
  // PLURNK_MODEL_<alias>=<provider>/<model> cascade + PLURNK_BASEURL_<alias>
3
- // overrides) lives in @plurnk/plurnk-aliases — the zero-dep parser shared with
4
- // thin clients; this module resolves the active alias to a Provider.
3
+ // overrides) lives in @plurnk/plurnk-aliases; this module resolves the active
4
+ // alias-or-route selector to a Provider.
5
5
  //
6
6
  // {§provider-resolution} Models.dev catalog → PLURNK provider declaration
7
7
  // → local protocol adapter → scope-agnostic AI SDK plugin discovery. Generic
@@ -11,7 +11,7 @@
11
11
  import type { AiSdkProviderPlugin, Provider } from "./types.ts";
12
12
  import { catalogProviderFromEnv, providerFromSdkModel } from "./catalogProvider.ts";
13
13
  import { discover, type DiscoverOptions, type Discovery } from "./discover.ts";
14
- import { resolveActiveAlias } from "@plurnk/plurnk-aliases";
14
+ import { resolveActiveRoute } from "@plurnk/plurnk-aliases";
15
15
  import { scopeEnvToAlias } from "./env.ts";
16
16
  import { ollamaProviderFromEnv } from "./ollama.ts";
17
17
  import { compatibleProviderFromEnv } from "./compatibleProvider.ts";
@@ -61,7 +61,7 @@ export const instantiateProvider = async (
61
61
  if (catalog !== null) return catalog;
62
62
  if (name === "ollama") return ollamaProviderFromEnv(env, model, baseUrl === undefined ? undefined : { baseUrl });
63
63
  if (name === "openai" || name === "plurnk") return compatibleProviderFromEnv(name, env, model, baseUrl);
64
- const { registry, skipped, packageAttributions = new Map() } = await providerPackages(discoverFn, env);
64
+ const { registry, skipped, packageAttributions = new Map(), grammarStyles = new Map() } = await providerPackages(discoverFn, env);
65
65
  const specifier = registry.get(name);
66
66
  if (specifier === undefined) {
67
67
  const declined = skipped.get(name);
@@ -99,21 +99,30 @@ export const instantiateProvider = async (
99
99
  languageModel: sdkProvider.languageModel(model),
100
100
  contextWindow,
101
101
  attributions,
102
+ ...(grammarStyles.get(name) === undefined ? {} : { grammarStyle: grammarStyles.get(name) }),
102
103
  });
103
104
  };
104
105
 
105
106
  // Test-only: drop the memoized discovery so a fresh scan/injection runs next.
106
107
  export const resetDiscoveryCache = (): void => { discoveredCache = null; };
107
108
 
108
- // Boot convenience: resolve the active alias cascade and instantiate it.
109
+ // Boot convenience: resolve the active selector and instantiate its exact route.
109
110
  export const loadActiveProvider = async (
110
111
  env: NodeJS.ProcessEnv = process.env,
111
112
  importImpl: ImportModule = importModule,
112
113
  discoverFn: DiscoverFn = discover,
113
114
  ): Promise<Provider> => {
114
- const alias = resolveActiveAlias(env);
115
- if (alias === null) {
116
- throw new Error("no active provider: set PLURNK_MODEL to an alias declared via PLURNK_MODEL_<alias>=<provider>/<model>");
115
+ const route = resolveActiveRoute(env);
116
+ if (route === null) {
117
+ throw new Error("no active provider: set PLURNK_MODEL to a declared alias or provider/model route");
117
118
  }
118
- return instantiateProvider(alias.provider, env, alias.model, importImpl, discoverFn, alias.baseUrl, alias.alias);
119
+ return instantiateProvider(
120
+ route.provider,
121
+ env,
122
+ route.model,
123
+ importImpl,
124
+ discoverFn,
125
+ route.baseUrl,
126
+ route.alias,
127
+ );
119
128
  };
@@ -89,6 +89,34 @@ test("aggregateProviderAccounting preserves request order and only sums known fi
89
89
  },
90
90
  ]);
91
91
  assert.deepEqual(accounting.requests.map(({ provider }) => provider), ["provider:a", "provider:b"]);
92
- assert.equal(accounting.usage, null, "an unknown failed request prevents fabricated totals");
93
- assert.equal(accounting.costUsd, null);
92
+ assert.deepEqual(accounting.usage, {
93
+ inputTokens: 2,
94
+ outputTokens: 3,
95
+ totalTokens: 5,
96
+ }, "a response-less failure is skipped, never allowed to erase reported usage");
97
+ assert.equal(accounting.costUsd, "0.25", "a response-less failure is skipped; the expressible cost survives");
98
+ });
99
+
100
+ test("aggregateProviderAccounting omits unknown nested usage fields from its JSON projection", () => {
101
+ const accounting = aggregateProviderAccounting([{
102
+ provider: "provider:a",
103
+ model: "m",
104
+ outcome: "response",
105
+ usage: {
106
+ inputTokens: 2,
107
+ outputTokens: 3,
108
+ totalTokens: 5,
109
+ inputTokenDetails: { cacheReadTokens: 1 },
110
+ outputTokenDetails: { reasoningTokens: 2 },
111
+ },
112
+ cost: { kind: "unknown", reason: "no direct cost" },
113
+ }]);
114
+
115
+ assert.deepEqual(accounting.usage, {
116
+ inputTokens: 2,
117
+ outputTokens: 3,
118
+ totalTokens: 5,
119
+ inputTokenDetails: { cacheReadTokens: 1 },
120
+ outputTokenDetails: { reasoningTokens: 2 },
121
+ });
94
122
  });
package/src/accounting.ts CHANGED
@@ -126,12 +126,13 @@ const sumKnown = (
126
126
  requests: readonly ProviderRequestAccounting[],
127
127
  read: (usage: ProviderUsage) => number | undefined,
128
128
  ): number | undefined => {
129
- const values = requests.map((request) => request.usage === undefined
130
- ? undefined
131
- : read(request.usage));
132
- return values.some((value) => value === undefined)
133
- ? undefined
134
- : (values as number[]).reduce((sum, value) => sum + value, 0);
129
+ // {§tokenomics-provider-usage} — aggregate usage sums every reported
130
+ // quantity; an unreported one (a response-less failure) is skipped, never
131
+ // invented as zero and never allowed to erase the reported evidence.
132
+ const known = requests
133
+ .map((request) => request.usage === undefined ? undefined : read(request.usage))
134
+ .filter((value): value is number => value !== undefined);
135
+ return known.length === 0 ? undefined : known.reduce((sum, value) => sum + value, 0);
135
136
  };
136
137
 
137
138
  export const aggregateProviderAccounting = (
@@ -167,10 +168,17 @@ export const aggregateProviderAccounting = (
167
168
  const inputTokenDetails = noCacheTokens === undefined
168
169
  && cacheReadTokens === undefined && cacheWriteTokens === undefined
169
170
  ? undefined
170
- : { noCacheTokens, cacheReadTokens, cacheWriteTokens };
171
+ : {
172
+ ...(noCacheTokens === undefined ? {} : { noCacheTokens }),
173
+ ...(cacheReadTokens === undefined ? {} : { cacheReadTokens }),
174
+ ...(cacheWriteTokens === undefined ? {} : { cacheWriteTokens }),
175
+ };
171
176
  const outputTokenDetails = textTokens === undefined && reasoningTokens === undefined
172
177
  ? undefined
173
- : { textTokens, reasoningTokens };
178
+ : {
179
+ ...(textTokens === undefined ? {} : { textTokens }),
180
+ ...(reasoningTokens === undefined ? {} : { reasoningTokens }),
181
+ };
174
182
  const usage = inputTokens === undefined && outputTokens === undefined && totalTokens === undefined
175
183
  && inputTokenDetails === undefined && outputTokenDetails === undefined
176
184
  ? null
@@ -208,6 +208,7 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
208
208
  });
209
209
 
210
210
  await t.test("streamed Fireworks-style channels do not invent an output split", async () => {
211
+ const reasoning: string[] = [];
211
212
  const chunks = [
212
213
  {
213
214
  id: "response-1",
@@ -232,6 +233,7 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
232
233
  const result = await executeOpenAICompatible({
233
234
  ...request,
234
235
  streaming: true,
236
+ observeReasoning: (delta) => reasoning.push(delta),
235
237
  fetch: async () => new Response(
236
238
  `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`,
237
239
  { headers: { "content-type": "text/event-stream" } },
@@ -242,5 +244,6 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
242
244
  outputTokens: 10,
243
245
  totalTokens: 12,
244
246
  });
247
+ assert.equal(reasoning.join(""), "bbbbbb", "readable reasoning is observed before transport completion");
245
248
  });
246
249
  });
@@ -1,7 +1,7 @@
1
1
  import { createOpenAICompatible, type ProviderErrorStructure } from "@ai-sdk/openai-compatible";
2
- import { APICallError, generateText, streamText, type JSONValue, type LanguageModel, type LanguageModelUsage } from "ai";
2
+ import { APICallError, generateText, streamText, type CallWarning, type JSONValue, type LanguageModel, type LanguageModelUsage } from "ai";
3
3
  import { z } from "zod/v4";
4
- import type { ChatMessage, ProviderAttemptFinishReason, ProviderChargeEvidence, ProviderUsage, TokenLogprob } from "./types.ts";
4
+ import type { ChatMessage, ProviderAttemptFinishReason, ProviderChargeEvidence, ProviderReasoningObserver, ProviderUsage, TokenLogprob } from "./types.ts";
5
5
  import { normalizeUsage, type RawUsage } from "./usage.ts";
6
6
  import { emitWarningOnce } from "./warnings.ts";
7
7
  import { ProviderTimeoutError, providerTimeoutOf } from "./errors.ts";
@@ -157,6 +157,7 @@ export type AiSdkTransportRequest = {
157
157
  streamIdleTimeoutMs?: number;
158
158
  streaming: boolean;
159
159
  captureRawBody: boolean;
160
+ observeReasoning?: ProviderReasoningObserver;
160
161
  };
161
162
 
162
163
  export type AiSdkTransportResponse = {
@@ -176,6 +177,7 @@ export type AiSdkTransportResponse = {
176
177
  logprobs: TokenLogprob[];
177
178
  chargeEvidence: ProviderChargeEvidence;
178
179
  rawBody?: unknown;
180
+ warnings: readonly CallWarning[];
179
181
  };
180
182
 
181
183
  export type AiSdkModelRequest = Omit<AiSdkTransportRequest, "url" | "model" | "body" | "fetch"> & {
@@ -221,11 +223,32 @@ const transportTimeout = (
221
223
 
222
224
  const streamFailureValues = new WeakMap<object, readonly unknown[]>();
223
225
 
224
- const applyRetryDirective = (error: unknown): unknown => {
225
- if (!APICallError.isInstance(error)) return error;
226
+ const retainStreamFailureValues = <T extends object>(source: object, target: T): T => {
227
+ const values = streamFailureValues.get(source);
228
+ if (values !== undefined) streamFailureValues.set(target, values);
229
+ return target;
230
+ };
231
+
232
+ const normalizeRetryAttemptError = (error: unknown): unknown => {
233
+ if (!APICallError.isInstance(error)) {
234
+ // Node's Undici stream reader reports a peer-aborted HTTP/2 body as this
235
+ // raw TypeError after headers have arrived. Normalize it at the attempt
236
+ // boundary so the owned scheduler sees the same retryability that the
237
+ // public ProviderError contract would otherwise assign too late.
238
+ if (error instanceof TypeError && error.message.trim().toLowerCase() === "terminated") {
239
+ return retainStreamFailureValues(error, new APICallError({
240
+ message: error.message,
241
+ url: "model:generation",
242
+ requestBodyValues: {},
243
+ cause: error,
244
+ isRetryable: true,
245
+ }));
246
+ }
247
+ return error;
248
+ }
226
249
  const directed = retryDirective(error.statusCode, error.responseHeaders ?? {});
227
250
  if (directed === null || directed === error.isRetryable) return error;
228
- return new APICallError({
251
+ return retainStreamFailureValues(error, new APICallError({
229
252
  message: error.message,
230
253
  url: error.url,
231
254
  requestBodyValues: error.requestBodyValues,
@@ -235,7 +258,7 @@ const applyRetryDirective = (error: unknown): unknown => {
235
258
  cause: error,
236
259
  isRetryable: directed,
237
260
  data: error.data,
238
- });
261
+ }));
239
262
  };
240
263
 
241
264
  const executeModel = async (
@@ -246,7 +269,7 @@ const executeModel = async (
246
269
  } catch (cause) {
247
270
  if (request.signal?.aborted) throw request.signal.reason;
248
271
  const timeout = transportTimeout(cause, request);
249
- if (timeout === null) throw applyRetryDirective(cause);
272
+ if (timeout === null) throw normalizeRetryAttemptError(cause);
250
273
  throw new APICallError({
251
274
  message: timeout.message,
252
275
  url: "model:generation",
@@ -364,6 +387,7 @@ const executeModelOnce = async (
364
387
  },
365
388
  },
366
389
  ...(request.captureRawBody ? { rawBody } : {}),
390
+ warnings: result.warnings ?? [],
367
391
  };
368
392
  }
369
393
 
@@ -376,6 +400,9 @@ const executeModelOnce = async (
376
400
  let streamError: unknown;
377
401
  for await (const part of result.fullStream) {
378
402
  if (part.type === "raw") rawChunks.push(part.rawValue);
403
+ if (part.type === "reasoning-delta" && part.text.length > 0) {
404
+ request.observeReasoning?.(part.text);
405
+ }
379
406
  if (part.type === "error") streamError ??= part.error;
380
407
  }
381
408
  if (streamError !== undefined) {
@@ -389,9 +416,10 @@ const executeModelOnce = async (
389
416
  const content = await result.text;
390
417
  const reasoningText = evidence.reasoning || (await result.reasoningText) || "";
391
418
  const rawFinishReason = await result.rawFinishReason;
392
- const [response, providerMetadata] = await Promise.all([
419
+ const [response, providerMetadata, warnings] = await Promise.all([
393
420
  result.response,
394
421
  result.providerMetadata,
422
+ result.warnings,
395
423
  ]);
396
424
  return {
397
425
  model: response.modelId,
@@ -416,6 +444,7 @@ const executeModelOnce = async (
416
444
  },
417
445
  },
418
446
  ...(request.captureRawBody ? { rawBody: rawChunks } : {}),
447
+ warnings: warnings ?? [],
419
448
  };
420
449
  };
421
450
 
@@ -450,6 +479,7 @@ export const executeOpenAICompatible = async (
450
479
  streamIdleTimeoutMs: request.streamIdleTimeoutMs,
451
480
  streaming: request.streaming,
452
481
  captureRawBody: request.captureRawBody,
482
+ ...(request.observeReasoning === undefined ? {} : { observeReasoning: request.observeReasoning }),
453
483
  });
454
484
  };
455
485