@plurnk/plurnk-providers 1.4.0 → 1.6.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 (90) hide show
  1. package/.env.defaults +40 -34
  2. package/README.md +3 -0
  3. package/SPEC.md +153 -62
  4. package/dist/AiSdkProvider.d.ts +19 -25
  5. package/dist/AiSdkProvider.d.ts.map +1 -1
  6. package/dist/AiSdkProvider.js +353 -120
  7. package/dist/AiSdkProvider.js.map +1 -1
  8. package/dist/Mock.d.ts +7 -13
  9. package/dist/Mock.d.ts.map +1 -1
  10. package/dist/Mock.js +36 -8
  11. package/dist/Mock.js.map +1 -1
  12. package/dist/Pool.d.ts +2 -21
  13. package/dist/Pool.d.ts.map +1 -1
  14. package/dist/Pool.js +19 -14
  15. package/dist/Pool.js.map +1 -1
  16. package/dist/accounting.d.ts +6 -0
  17. package/dist/accounting.d.ts.map +1 -0
  18. package/dist/accounting.js +168 -0
  19. package/dist/accounting.js.map +1 -0
  20. package/dist/aiSdkTransport.d.ts +11 -3
  21. package/dist/aiSdkTransport.d.ts.map +1 -1
  22. package/dist/aiSdkTransport.js +198 -29
  23. package/dist/aiSdkTransport.js.map +1 -1
  24. package/dist/catalogProvider.d.ts +7 -2
  25. package/dist/catalogProvider.d.ts.map +1 -1
  26. package/dist/catalogProvider.js +32 -26
  27. package/dist/catalogProvider.js.map +1 -1
  28. package/dist/compatibleProvider.d.ts.map +1 -1
  29. package/dist/compatibleProvider.js +18 -7
  30. package/dist/compatibleProvider.js.map +1 -1
  31. package/dist/cost.d.ts +10 -10
  32. package/dist/cost.d.ts.map +1 -1
  33. package/dist/cost.js +88 -43
  34. package/dist/cost.js.map +1 -1
  35. package/dist/env.d.ts +5 -7
  36. package/dist/env.d.ts.map +1 -1
  37. package/dist/env.js +30 -32
  38. package/dist/env.js.map +1 -1
  39. package/dist/errors.d.ts +14 -2
  40. package/dist/errors.d.ts.map +1 -1
  41. package/dist/errors.js +60 -2
  42. package/dist/errors.js.map +1 -1
  43. package/dist/index.d.ts +4 -4
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +3 -2
  46. package/dist/index.js.map +1 -1
  47. package/dist/ollama.js +3 -3
  48. package/dist/ollama.js.map +1 -1
  49. package/dist/sdkModels.d.ts +6 -0
  50. package/dist/sdkModels.d.ts.map +1 -1
  51. package/dist/sdkModels.js +46 -3
  52. package/dist/sdkModels.js.map +1 -1
  53. package/dist/types.d.ts +40 -29
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/usage.d.ts +21 -4
  56. package/dist/usage.d.ts.map +1 -1
  57. package/dist/usage.js +188 -74
  58. package/dist/usage.js.map +1 -1
  59. package/package.json +9 -7
  60. package/src/AiSdkProvider.test.ts +1039 -182
  61. package/src/AiSdkProvider.ts +428 -141
  62. package/src/Mock.test.ts +37 -12
  63. package/src/Mock.ts +46 -12
  64. package/src/Pool.test.ts +19 -6
  65. package/src/Pool.ts +20 -16
  66. package/src/ProviderRegistry.test.ts +16 -11
  67. package/src/accounting.test.ts +94 -0
  68. package/src/accounting.ts +190 -0
  69. package/src/aiSdkTransport.test.ts +42 -49
  70. package/src/aiSdkTransport.ts +218 -32
  71. package/src/boundaries.test.ts +2 -0
  72. package/src/catalogProvider.test.ts +271 -24
  73. package/src/catalogProvider.ts +44 -28
  74. package/src/compatibleProvider.test.ts +6 -3
  75. package/src/compatibleProvider.ts +20 -7
  76. package/src/cost.test.ts +55 -35
  77. package/src/cost.ts +110 -54
  78. package/src/defaults.test.ts +13 -3
  79. package/src/env.test.ts +50 -26
  80. package/src/env.ts +43 -42
  81. package/src/errors.test.ts +47 -2
  82. package/src/errors.ts +68 -3
  83. package/src/index.ts +21 -5
  84. package/src/ollama.test.ts +4 -1
  85. package/src/ollama.ts +3 -3
  86. package/src/sdkModels.test.ts +94 -3
  87. package/src/sdkModels.ts +53 -3
  88. package/src/types.ts +91 -33
  89. package/src/usage.test.ts +112 -108
  90. package/src/usage.ts +233 -84
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { APICallError } from "ai";
3
4
  import { executeOpenAICompatible } from "./aiSdkTransport.ts";
4
5
 
5
6
  const request = {
@@ -9,29 +10,33 @@ const request = {
9
10
  body: {},
10
11
  messages: [{ role: "user" as const, content: "question" }],
11
12
  fetchTimeoutMs: 1_000,
12
- retryAttempts: 2,
13
13
  streaming: false,
14
14
  captureRawBody: false,
15
15
  };
16
16
 
17
- test("X-Should-Retry:false prevents nested retries for a normally retryable status", async () => {
17
+ test("the transport performs exactly one physical request", async () => {
18
18
  let calls = 0;
19
- await assert.rejects(executeOpenAICompatible({
20
- ...request,
21
- fetch: async () => {
22
- calls += 1;
23
- return new Response(
24
- JSON.stringify({ error: { message: "upstream attempts exhausted" } }),
25
- {
26
- status: 503,
27
- headers: {
28
- "content-type": "application/json",
29
- "x-should-retry": "false",
19
+ await assert.rejects(
20
+ executeOpenAICompatible({
21
+ ...request,
22
+ fetch: async () => {
23
+ calls += 1;
24
+ return new Response(
25
+ JSON.stringify({ error: { message: "upstream attempts exhausted" } }),
26
+ {
27
+ status: 503,
28
+ headers: {
29
+ "content-type": "application/json",
30
+ "x-should-retry": "false",
31
+ },
30
32
  },
31
- },
32
- );
33
- },
34
- }));
33
+ );
34
+ },
35
+ }),
36
+ (error) => APICallError.isInstance(error)
37
+ && error.statusCode === 503
38
+ && error.isRetryable === false,
39
+ );
35
40
  assert.equal(calls, 1);
36
41
  });
37
42
 
@@ -64,7 +69,6 @@ test("the adapter preserves PLURNK request extensions and response evidence", as
64
69
  };
65
70
  const result = await executeOpenAICompatible({
66
71
  ...request,
67
- retryAttempts: 0,
68
72
  captureRawBody: true,
69
73
  body: {
70
74
  grammar: "root ::= \"answer\"",
@@ -85,11 +89,10 @@ test("the adapter preserves PLURNK request extensions and response evidence", as
85
89
  assert.equal(result.reasoning, "because");
86
90
  assert.equal(result.finishReason, "stop");
87
91
  assert.deepEqual(result.usage, {
88
- prompt: 3,
89
- completion: 3,
90
- reasoning: 2,
91
- cached: 0,
92
- total: 8,
92
+ inputTokens: 3,
93
+ outputTokens: 5,
94
+ totalTokens: 8,
95
+ outputTokenDetails: { textTokens: 3, reasoningTokens: 2 },
93
96
  });
94
97
  assert.equal(result.logprobs[0]?.token, "answer");
95
98
  assert.deepEqual(result.metadata.balance, { amount: 1.25, currency: "USD" });
@@ -116,7 +119,6 @@ test("the adapter maps leading system messages to AI SDK instructions", async ()
116
119
  { role: "user", content: "hello" },
117
120
  ],
118
121
  fetchTimeoutMs: 1000,
119
- retryAttempts: 0,
120
122
  streaming: false,
121
123
  captureRawBody: false,
122
124
  fetch,
@@ -130,7 +132,6 @@ test("the adapter maps leading system messages to AI SDK instructions", async ()
130
132
  test("the adapter preserves nonstandard reasoning accounting after SDK parsing", async (t) => {
131
133
  const execute = (responseBody: object) => executeOpenAICompatible({
132
134
  ...request,
133
- retryAttempts: 0,
134
135
  fetch: async () => new Response(JSON.stringify(responseBody), {
135
136
  headers: { "content-type": "application/json" },
136
137
  }),
@@ -153,25 +154,22 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
153
154
  { prompt_tokens: 2, completion_tokens: 3, total_tokens: 9 },
154
155
  ));
155
156
  assert.deepEqual(result.usage, {
156
- prompt: 2,
157
- completion: 3,
158
- reasoning: 4,
159
- cached: 0,
160
- total: 9,
157
+ inputTokens: 2,
158
+ outputTokens: 7,
159
+ totalTokens: 9,
160
+ outputTokenDetails: { textTokens: 3, reasoningTokens: 4 },
161
161
  });
162
162
  });
163
163
 
164
- await t.test("Fireworks-style unitemized output is split by returned channels", async () => {
164
+ await t.test("Fireworks-style channels do not invent token attribution", async () => {
165
165
  const result = await execute(response(
166
166
  { content: "aa", reasoning_content: "bbbbbb" },
167
167
  { prompt_tokens: 2, completion_tokens: 10, total_tokens: 12 },
168
168
  ));
169
169
  assert.deepEqual(result.usage, {
170
- prompt: 2,
171
- completion: 2,
172
- reasoning: 8,
173
- cached: 0,
174
- total: 12,
170
+ inputTokens: 2,
171
+ outputTokens: 10,
172
+ totalTokens: 12,
175
173
  });
176
174
  });
177
175
 
@@ -195,7 +193,6 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
195
193
  ];
196
194
  const result = await executeOpenAICompatible({
197
195
  ...request,
198
- retryAttempts: 0,
199
196
  streaming: true,
200
197
  fetch: async () => new Response(
201
198
  `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`,
@@ -203,15 +200,14 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
203
200
  ),
204
201
  });
205
202
  assert.deepEqual(result.usage, {
206
- prompt: 2,
207
- completion: 3,
208
- reasoning: 4,
209
- cached: 0,
210
- total: 9,
203
+ inputTokens: 2,
204
+ outputTokens: 7,
205
+ totalTokens: 9,
206
+ outputTokenDetails: { textTokens: 3, reasoningTokens: 4 },
211
207
  });
212
208
  });
213
209
 
214
- await t.test("streamed Fireworks-style channels preserve the output split", async () => {
210
+ await t.test("streamed Fireworks-style channels do not invent an output split", async () => {
215
211
  const chunks = [
216
212
  {
217
213
  id: "response-1",
@@ -235,7 +231,6 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
235
231
  ];
236
232
  const result = await executeOpenAICompatible({
237
233
  ...request,
238
- retryAttempts: 0,
239
234
  streaming: true,
240
235
  fetch: async () => new Response(
241
236
  `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`,
@@ -243,11 +238,9 @@ test("the adapter preserves nonstandard reasoning accounting after SDK parsing",
243
238
  ),
244
239
  });
245
240
  assert.deepEqual(result.usage, {
246
- prompt: 2,
247
- completion: 2,
248
- reasoning: 8,
249
- cached: 0,
250
- total: 12,
241
+ inputTokens: 2,
242
+ outputTokens: 10,
243
+ totalTokens: 12,
251
244
  });
252
245
  });
253
246
  });
@@ -1,9 +1,10 @@
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
3
  import { z } from "zod/v4";
4
- import type { ChatMessage, ProviderAttemptFinishReason, ProviderUsage, TokenLogprob } from "./types.ts";
4
+ import type { ChatMessage, ProviderAttemptFinishReason, ProviderChargeEvidence, ProviderUsage, TokenLogprob } from "./types.ts";
5
5
  import { normalizeUsage, type RawUsage } from "./usage.ts";
6
6
  import { emitWarningOnce } from "./warnings.ts";
7
+ import { ProviderTimeoutError, providerTimeoutOf } from "./errors.ts";
7
8
 
8
9
  const errorSchema = z.object({
9
10
  error: z.object({
@@ -14,18 +15,30 @@ const errorSchema = z.object({
14
15
  }).passthrough(),
15
16
  }).passthrough();
16
17
 
18
+ const retryDirective = (
19
+ status: number | undefined,
20
+ headers: Headers | Readonly<Record<string, string>>,
21
+ ): boolean | null => {
22
+ const raw = headers instanceof Headers
23
+ ? headers.get("x-should-retry")
24
+ : Object.entries(headers).find(([name]) => name.toLowerCase() === "x-should-retry")?.[1];
25
+ const directive = raw?.trim().toLowerCase();
26
+ if (directive === "false") return false;
27
+ if (directive === "true") return true;
28
+ if (status !== undefined && status >= 520 && status <= 527) return false;
29
+ return null;
30
+ };
31
+
17
32
  const errorStructure: ProviderErrorStructure<z.infer<typeof errorSchema>> = {
18
33
  errorSchema,
19
34
  errorToMessage: ({ error }) => error.message,
20
35
  isRetryable(response) {
21
- const directive = response.headers.get("x-should-retry")?.trim().toLowerCase();
22
- if (directive === "false") return false;
23
- if (directive === "true") return true;
24
- if (response.status >= 520 && response.status <= 527) return false;
25
- return response.status === 408
36
+ return retryDirective(response.status, response.headers) ?? (
37
+ response.status === 408
26
38
  || response.status === 409
27
39
  || response.status === 429
28
- || response.status >= 500;
40
+ || response.status >= 500
41
+ );
29
42
  },
30
43
  };
31
44
 
@@ -40,30 +53,45 @@ const baseUrl = (completionUrl: string): string => {
40
53
 
41
54
  const usageOf = (
42
55
  usage: LanguageModelUsage,
43
- reasoningText: string,
44
- contentText: string,
45
- ): ProviderUsage => normalizeUsage({
56
+ ): ProviderUsage | undefined => normalizeUsage({
46
57
  prompt_tokens: usage.inputTokens,
47
58
  completion_tokens: usage.outputTokens,
48
59
  total_tokens: usage.totalTokens,
49
- prompt_tokens_details: { cached_tokens: usage.inputTokenDetails.cacheReadTokens },
60
+ prompt_tokens_details: {
61
+ cached_tokens: usage.inputTokenDetails.cacheReadTokens,
62
+ cache_write_tokens: usage.inputTokenDetails.cacheWriteTokens,
63
+ },
50
64
  completion_tokens_details: usage.outputTokenDetails.reasoningTokens !== undefined
51
65
  ? { reasoning_tokens: usage.outputTokenDetails.reasoningTokens }
52
66
  : undefined,
53
- }, reasoningText, contentText);
67
+ });
54
68
 
55
69
  const wireUsageOf = (
56
70
  values: readonly unknown[],
57
- reasoningText: string,
58
- contentText: string,
59
- ): ProviderUsage | null => {
71
+ ): ProviderUsage | undefined => {
60
72
  for (let index = values.length - 1; index >= 0; index -= 1) {
61
73
  const usage = recordOf(values[index])?.usage;
62
74
  if (usage !== null && typeof usage === "object") {
63
- return normalizeUsage(usage as RawUsage, reasoningText, contentText);
75
+ return normalizeUsage(usage as RawUsage);
64
76
  }
65
77
  }
66
- return null;
78
+ return undefined;
79
+ };
80
+
81
+ const wireUsageEvidenceOf = (values: readonly unknown[]): unknown => {
82
+ for (let index = values.length - 1; index >= 0; index -= 1) {
83
+ const record = recordOf(values[index]);
84
+ if (record !== null && record.usage !== undefined) return record.usage;
85
+ }
86
+ return undefined;
87
+ };
88
+
89
+ const wireChargeEvidenceOf = (values: readonly unknown[]): unknown => {
90
+ for (let index = values.length - 1; index >= 0; index -= 1) {
91
+ const record = recordOf(values[index]);
92
+ if (record !== null && record.charge !== undefined) return record.charge;
93
+ }
94
+ return undefined;
67
95
  };
68
96
 
69
97
  const finishReasonOf = (reason: string | undefined): ProviderAttemptFinishReason => {
@@ -110,7 +138,7 @@ const metadataOf = (values: readonly unknown[]): Record<string, unknown> => {
110
138
  const record = recordOf(value);
111
139
  if (record === null) continue;
112
140
  for (const [key, item] of Object.entries(record)) {
113
- if (key !== "choices" && key !== "usage") metadata[key] = item;
141
+ if (key !== "choices" && key !== "usage" && key !== "charge") metadata[key] = item;
114
142
  }
115
143
  }
116
144
  return metadata;
@@ -125,8 +153,8 @@ export type AiSdkTransportRequest = {
125
153
  signal?: AbortSignal;
126
154
  fetch?: typeof globalThis.fetch;
127
155
  fetchTimeoutMs: number;
156
+ firstContentTimeoutMs?: number;
128
157
  streamIdleTimeoutMs?: number;
129
- retryAttempts: number;
130
158
  streaming: boolean;
131
159
  captureRawBody: boolean;
132
160
  };
@@ -138,7 +166,7 @@ export type AiSdkTransportResponse = {
138
166
  reasoningProjected: boolean;
139
167
  finishReason: ProviderAttemptFinishReason;
140
168
  rawFinishReason?: string;
141
- usage: ProviderUsage;
169
+ usage?: ProviderUsage;
142
170
  metadata: Record<string, unknown>;
143
171
  reasoningEncrypted: Array<{
144
172
  id: string | null;
@@ -146,12 +174,14 @@ export type AiSdkTransportResponse = {
146
174
  encrypted: Array<{ data: string; format: string | null }>;
147
175
  }>;
148
176
  logprobs: TokenLogprob[];
177
+ chargeEvidence: ProviderChargeEvidence;
149
178
  rawBody?: unknown;
150
179
  };
151
180
 
152
181
  export type AiSdkModelRequest = Omit<AiSdkTransportRequest, "url" | "model" | "body" | "fetch"> & {
153
182
  languageModel: LanguageModel;
154
183
  providerOptions?: Record<string, Record<string, JSONValue | undefined>>;
184
+ systemProviderOptions?: Record<string, Record<string, JSONValue | undefined>>;
155
185
  temperature?: number;
156
186
  topP?: number;
157
187
  topK?: number;
@@ -163,12 +193,77 @@ export type AiSdkModelRequest = Omit<AiSdkTransportRequest, "url" | "model" | "b
163
193
  reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "none" | "provider-default";
164
194
  };
165
195
 
196
+ const transportTimeout = (
197
+ cause: unknown,
198
+ request: AiSdkModelRequest,
199
+ ): ProviderTimeoutError | null => {
200
+ const owned = providerTimeoutOf(cause);
201
+ if (owned !== null) return owned;
202
+
203
+ const seen = new Set<unknown>();
204
+ let current = cause;
205
+ while (typeof current === "object" && current !== null && !seen.has(current)) {
206
+ if ((current as { name?: string }).name === "TimeoutError") break;
207
+ seen.add(current);
208
+ current = (current as { cause?: unknown }).cause;
209
+ }
210
+ if (typeof current !== "object" || current === null) return null;
211
+
212
+ const message = String((current as { message?: unknown }).message ?? "");
213
+ if (/first chunk timeout/i.test(message)) {
214
+ return new ProviderTimeoutError("first_content", request.firstContentTimeoutMs ?? 0, cause);
215
+ }
216
+ if (/chunk timeout/i.test(message)) {
217
+ return new ProviderTimeoutError("stream_idle", request.streamIdleTimeoutMs ?? 0, cause);
218
+ }
219
+ return new ProviderTimeoutError("attempt", request.fetchTimeoutMs, cause);
220
+ };
221
+
222
+ const streamFailureValues = new WeakMap<object, readonly unknown[]>();
223
+
224
+ const applyRetryDirective = (error: unknown): unknown => {
225
+ if (!APICallError.isInstance(error)) return error;
226
+ const directed = retryDirective(error.statusCode, error.responseHeaders ?? {});
227
+ if (directed === null || directed === error.isRetryable) return error;
228
+ return new APICallError({
229
+ message: error.message,
230
+ url: error.url,
231
+ requestBodyValues: error.requestBodyValues,
232
+ statusCode: error.statusCode,
233
+ responseHeaders: error.responseHeaders,
234
+ responseBody: error.responseBody,
235
+ cause: error,
236
+ isRetryable: directed,
237
+ data: error.data,
238
+ });
239
+ };
240
+
166
241
  const executeModel = async (
167
242
  request: AiSdkModelRequest,
243
+ ): Promise<AiSdkTransportResponse> => {
244
+ try {
245
+ return await executeModelOnce(request);
246
+ } catch (cause) {
247
+ if (request.signal?.aborted) throw request.signal.reason;
248
+ const timeout = transportTimeout(cause, request);
249
+ if (timeout === null) throw applyRetryDirective(cause);
250
+ throw new APICallError({
251
+ message: timeout.message,
252
+ url: "model:generation",
253
+ requestBodyValues: {},
254
+ cause: timeout,
255
+ isRetryable: true,
256
+ });
257
+ }
258
+ };
259
+
260
+ const executeModelOnce = async (
261
+ request: AiSdkModelRequest,
168
262
  ): Promise<AiSdkTransportResponse> => {
169
263
  const {
170
264
  languageModel: model,
171
265
  providerOptions,
266
+ systemProviderOptions,
172
267
  temperature,
173
268
  topP,
174
269
  topK,
@@ -196,9 +291,12 @@ const executeModel = async (
196
291
  if (request.messages.slice(instructionCount).some((message) => message.role === "system")) {
197
292
  throw new Error("provider messages: system instructions must precede conversational messages");
198
293
  }
199
- const instructions = request.messages.slice(0, instructionCount).map(({ content }) => ({
294
+ const instructions = request.messages.slice(0, instructionCount).map(({ content }, index) => ({
200
295
  role: "system" as const,
201
296
  content,
297
+ ...(systemProviderOptions !== undefined && index === instructionCount - 1
298
+ ? { providerOptions: systemProviderOptions }
299
+ : {}),
202
300
  }));
203
301
  const messages = request.messages.slice(instructionCount);
204
302
  const common = {
@@ -207,12 +305,21 @@ const executeModel = async (
207
305
  messages: messages.length > 0
208
306
  ? messages
209
307
  : [{ role: "user" as const, content: "" }],
210
- maxRetries: request.retryAttempts,
308
+ // AiSdkProvider owns retries so every physical request is independently
309
+ // observed and accounted. The SDK transport executes exactly once.
310
+ maxRetries: 0,
211
311
  abortSignal: request.signal,
212
312
  headers: request.headers,
213
313
  timeout: {
214
- totalMs: request.fetchTimeoutMs,
215
- ...(request.streamIdleTimeoutMs !== undefined && request.streamIdleTimeoutMs > 0
314
+ ...(request.fetchTimeoutMs > 0 ? { totalMs: request.fetchTimeoutMs } : {}),
315
+ ...(request.streaming
316
+ && request.firstContentTimeoutMs !== undefined
317
+ && request.firstContentTimeoutMs > 0
318
+ ? { firstChunkMs: request.firstContentTimeoutMs }
319
+ : {}),
320
+ ...(request.streaming
321
+ && request.streamIdleTimeoutMs !== undefined
322
+ && request.streamIdleTimeoutMs > 0
216
323
  ? { chunkMs: request.streamIdleTimeoutMs }
217
324
  : {}),
218
325
  },
@@ -227,6 +334,7 @@ const executeModel = async (
227
334
  const rawBody = result.response.body;
228
335
  const values = [rawBody];
229
336
  const evidence = extractEvidence(values);
337
+ const accountingUsage = wireUsageEvidenceOf(values);
230
338
  const reasoningText = evidence.reasoning || result.reasoningText || "";
231
339
  const rawFinishReason = result.rawFinishReason;
232
340
  return {
@@ -236,11 +344,25 @@ const executeModel = async (
236
344
  reasoningProjected: evidence.reasoningProjected,
237
345
  finishReason: finishReasonOf(rawFinishReason),
238
346
  ...(rawFinishReason === undefined ? {} : { rawFinishReason }),
239
- usage: wireUsageOf(values, reasoningText, result.text)
240
- ?? usageOf(result.usage, reasoningText, result.text),
347
+ usage: wireUsageOf(values) ?? usageOf(result.usage),
241
348
  metadata: metadataOf(values),
242
349
  reasoningEncrypted: evidence.reasoningEncrypted,
243
350
  logprobs: evidence.logprobs,
351
+ chargeEvidence: {
352
+ ...(wireChargeEvidenceOf(values) === undefined
353
+ ? {}
354
+ : { charge: wireChargeEvidenceOf(values) }),
355
+ ...(accountingUsage === undefined ? {} : { usage: accountingUsage }),
356
+ ...(result.providerMetadata === undefined
357
+ ? {}
358
+ : { providerMetadata: result.providerMetadata }),
359
+ response: {
360
+ id: result.response.id,
361
+ ...(result.response.headers === undefined
362
+ ? {}
363
+ : { headers: result.response.headers }),
364
+ },
365
+ },
244
366
  ...(request.captureRawBody ? { rawBody } : {}),
245
367
  };
246
368
  }
@@ -256,23 +378,43 @@ const executeModel = async (
256
378
  if (part.type === "raw") rawChunks.push(part.rawValue);
257
379
  if (part.type === "error") streamError ??= part.error;
258
380
  }
259
- if (streamError !== undefined) throw streamError;
381
+ if (streamError !== undefined) {
382
+ if (typeof streamError === "object" && streamError !== null) {
383
+ streamFailureValues.set(streamError, [...rawChunks, streamError]);
384
+ }
385
+ throw streamError;
386
+ }
260
387
  const evidence = extractEvidence(rawChunks);
388
+ const accountingUsage = wireUsageEvidenceOf(rawChunks);
261
389
  const content = await result.text;
262
390
  const reasoningText = evidence.reasoning || (await result.reasoningText) || "";
263
391
  const rawFinishReason = await result.rawFinishReason;
392
+ const [response, providerMetadata] = await Promise.all([
393
+ result.response,
394
+ result.providerMetadata,
395
+ ]);
264
396
  return {
265
- model: (await result.response).modelId,
397
+ model: response.modelId,
266
398
  content,
267
399
  reasoning: reasoningText,
268
400
  reasoningProjected: evidence.reasoningProjected,
269
401
  finishReason: finishReasonOf(rawFinishReason),
270
402
  ...(rawFinishReason === undefined ? {} : { rawFinishReason }),
271
- usage: wireUsageOf(rawChunks, reasoningText, content)
272
- ?? usageOf(await result.usage, reasoningText, content),
403
+ usage: wireUsageOf(rawChunks) ?? usageOf(await result.usage),
273
404
  metadata: metadataOf(rawChunks),
274
405
  reasoningEncrypted: evidence.reasoningEncrypted,
275
406
  logprobs: evidence.logprobs,
407
+ chargeEvidence: {
408
+ ...(wireChargeEvidenceOf(rawChunks) === undefined
409
+ ? {}
410
+ : { charge: wireChargeEvidenceOf(rawChunks) }),
411
+ ...(accountingUsage === undefined ? {} : { usage: accountingUsage }),
412
+ ...(providerMetadata === undefined ? {} : { providerMetadata }),
413
+ response: {
414
+ id: response.id,
415
+ ...(response.headers === undefined ? {} : { headers: response.headers }),
416
+ },
417
+ },
276
418
  ...(request.captureRawBody ? { rawBody: rawChunks } : {}),
277
419
  };
278
420
  };
@@ -304,13 +446,57 @@ export const executeOpenAICompatible = async (
304
446
  messages: request.messages,
305
447
  signal: request.signal,
306
448
  fetchTimeoutMs: request.fetchTimeoutMs,
449
+ firstContentTimeoutMs: request.firstContentTimeoutMs,
307
450
  streamIdleTimeoutMs: request.streamIdleTimeoutMs,
308
- retryAttempts: request.retryAttempts,
309
451
  streaming: request.streaming,
310
452
  captureRawBody: request.captureRawBody,
311
453
  });
312
454
  };
313
455
 
456
+ const responseBodyValues = (error: APICallError): readonly unknown[] => {
457
+ if (error.responseBody === undefined || error.responseBody.length === 0) return [];
458
+ try {
459
+ return [JSON.parse(error.responseBody)];
460
+ } catch {
461
+ return [];
462
+ }
463
+ };
464
+
465
+ export type AiSdkTransportFailureEvidence = {
466
+ readonly usage?: ProviderUsage;
467
+ readonly chargeEvidence: ProviderChargeEvidence;
468
+ readonly status?: number;
469
+ };
470
+
471
+ export const transportFailureEvidence = (
472
+ error: unknown,
473
+ ): AiSdkTransportFailureEvidence => {
474
+ const values = typeof error === "object" && error !== null
475
+ ? streamFailureValues.get(error) ?? (APICallError.isInstance(error) ? responseBodyValues(error) : [])
476
+ : [];
477
+ const usage = wireUsageOf(values);
478
+ const usageEvidence = wireUsageEvidenceOf(values);
479
+ const charge = wireChargeEvidenceOf(values);
480
+ const wireStatus = values
481
+ .map(recordOf)
482
+ .find((record) => Number.isInteger(record?.status))?.status;
483
+ const apiStatus = APICallError.isInstance(error) ? error.statusCode : undefined;
484
+ const status = Number.isInteger(apiStatus) && (apiStatus as number) >= 100 && (apiStatus as number) <= 599
485
+ ? apiStatus as number
486
+ : Number.isInteger(wireStatus) && (wireStatus as number) >= 100 && (wireStatus as number) <= 599
487
+ ? wireStatus as number
488
+ : undefined;
489
+ return {
490
+ ...(usage === undefined ? {} : { usage }),
491
+ chargeEvidence: {
492
+ ...(charge === undefined ? {} : { charge }),
493
+ ...(usageEvidence === undefined ? {} : { usage: usageEvidence }),
494
+ response: {},
495
+ },
496
+ ...(status === undefined ? {} : { status }),
497
+ };
498
+ };
499
+
314
500
  const extractEvidence = (values: unknown[]): {
315
501
  reasoningEncrypted: AiSdkTransportResponse["reasoningEncrypted"];
316
502
  logprobs: TokenLogprob[];
@@ -28,8 +28,10 @@ test("provider source does not import the PLURNK parser", () => {
28
28
 
29
29
  test("the OpenAI-compatible entrypoint excludes Node-owned provider machinery", () => {
30
30
  const allowed = new Set([
31
+ "accounting.ts",
31
32
  "AiSdkProvider.ts",
32
33
  "aiSdkTransport.ts",
34
+ "cost.ts",
33
35
  "env.ts",
34
36
  "errors.ts",
35
37
  "notices.ts",