@plurnk/plurnk-providers 1.5.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 (89) hide show
  1. package/.env.defaults +36 -22
  2. package/SPEC.md +133 -59
  3. package/dist/AiSdkProvider.d.ts +19 -26
  4. package/dist/AiSdkProvider.d.ts.map +1 -1
  5. package/dist/AiSdkProvider.js +318 -106
  6. package/dist/AiSdkProvider.js.map +1 -1
  7. package/dist/Mock.d.ts +4 -9
  8. package/dist/Mock.d.ts.map +1 -1
  9. package/dist/Mock.js +36 -9
  10. package/dist/Mock.js.map +1 -1
  11. package/dist/Pool.d.ts +2 -21
  12. package/dist/Pool.d.ts.map +1 -1
  13. package/dist/Pool.js +19 -14
  14. package/dist/Pool.js.map +1 -1
  15. package/dist/accounting.d.ts +5 -2
  16. package/dist/accounting.d.ts.map +1 -1
  17. package/dist/accounting.js +100 -16
  18. package/dist/accounting.js.map +1 -1
  19. package/dist/aiSdkTransport.d.ts +9 -2
  20. package/dist/aiSdkTransport.d.ts.map +1 -1
  21. package/dist/aiSdkTransport.js +160 -62
  22. package/dist/aiSdkTransport.js.map +1 -1
  23. package/dist/catalogProvider.d.ts +7 -3
  24. package/dist/catalogProvider.d.ts.map +1 -1
  25. package/dist/catalogProvider.js +30 -24
  26. package/dist/catalogProvider.js.map +1 -1
  27. package/dist/compatibleProvider.d.ts.map +1 -1
  28. package/dist/compatibleProvider.js +18 -7
  29. package/dist/compatibleProvider.js.map +1 -1
  30. package/dist/cost.d.ts +10 -10
  31. package/dist/cost.d.ts.map +1 -1
  32. package/dist/cost.js +90 -42
  33. package/dist/cost.js.map +1 -1
  34. package/dist/env.d.ts +5 -1
  35. package/dist/env.d.ts.map +1 -1
  36. package/dist/env.js +30 -10
  37. package/dist/env.js.map +1 -1
  38. package/dist/errors.d.ts +14 -2
  39. package/dist/errors.d.ts.map +1 -1
  40. package/dist/errors.js +58 -2
  41. package/dist/errors.js.map +1 -1
  42. package/dist/index.d.ts +4 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +3 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/ollama.js +3 -3
  47. package/dist/ollama.js.map +1 -1
  48. package/dist/sdkModels.d.ts +6 -2
  49. package/dist/sdkModels.d.ts.map +1 -1
  50. package/dist/sdkModels.js +38 -5
  51. package/dist/sdkModels.js.map +1 -1
  52. package/dist/types.d.ts +33 -31
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/usage.d.ts +21 -5
  55. package/dist/usage.d.ts.map +1 -1
  56. package/dist/usage.js +164 -83
  57. package/dist/usage.js.map +1 -1
  58. package/package.json +7 -6
  59. package/src/AiSdkProvider.test.ts +788 -191
  60. package/src/AiSdkProvider.ts +381 -124
  61. package/src/Mock.test.ts +37 -12
  62. package/src/Mock.ts +45 -14
  63. package/src/Pool.test.ts +19 -6
  64. package/src/Pool.ts +20 -16
  65. package/src/ProviderRegistry.test.ts +16 -11
  66. package/src/accounting.test.ts +58 -22
  67. package/src/accounting.ts +120 -18
  68. package/src/aiSdkTransport.test.ts +42 -49
  69. package/src/aiSdkTransport.ts +174 -62
  70. package/src/boundaries.test.ts +1 -0
  71. package/src/catalogProvider.test.ts +258 -22
  72. package/src/catalogProvider.ts +42 -27
  73. package/src/compatibleProvider.test.ts +6 -3
  74. package/src/compatibleProvider.ts +20 -7
  75. package/src/cost.test.ts +55 -36
  76. package/src/cost.ts +111 -50
  77. package/src/defaults.test.ts +13 -3
  78. package/src/env.test.ts +54 -5
  79. package/src/env.ts +43 -18
  80. package/src/errors.test.ts +47 -2
  81. package/src/errors.ts +67 -3
  82. package/src/index.ts +21 -5
  83. package/src/ollama.test.ts +4 -1
  84. package/src/ollama.ts +3 -3
  85. package/src/sdkModels.test.ts +76 -4
  86. package/src/sdkModels.ts +45 -7
  87. package/src/types.ts +77 -38
  88. package/src/usage.test.ts +112 -116
  89. package/src/usage.ts +209 -93
@@ -1,10 +1,10 @@
1
1
  import { createOpenAICompatible, type ProviderErrorStructure } from "@ai-sdk/openai-compatible";
2
2
  import { APICallError, generateText, streamText, type JSONValue, type LanguageModel, type LanguageModelUsage } from "ai";
3
- import { prepareRetries } from "ai/internal";
4
3
  import { z } from "zod/v4";
5
4
  import type { ChatMessage, ProviderAttemptFinishReason, ProviderChargeEvidence, ProviderUsage, TokenLogprob } from "./types.ts";
6
5
  import { normalizeUsage, type RawUsage } from "./usage.ts";
7
6
  import { emitWarningOnce } from "./warnings.ts";
7
+ import { ProviderTimeoutError, providerTimeoutOf } from "./errors.ts";
8
8
 
9
9
  const errorSchema = z.object({
10
10
  error: z.object({
@@ -15,18 +15,30 @@ const errorSchema = z.object({
15
15
  }).passthrough(),
16
16
  }).passthrough();
17
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
+
18
32
  const errorStructure: ProviderErrorStructure<z.infer<typeof errorSchema>> = {
19
33
  errorSchema,
20
34
  errorToMessage: ({ error }) => error.message,
21
35
  isRetryable(response) {
22
- const directive = response.headers.get("x-should-retry")?.trim().toLowerCase();
23
- if (directive === "false") return false;
24
- if (directive === "true") return true;
25
- if (response.status >= 520 && response.status <= 527) return false;
26
- return response.status === 408
36
+ return retryDirective(response.status, response.headers) ?? (
37
+ response.status === 408
27
38
  || response.status === 409
28
39
  || response.status === 429
29
- || response.status >= 500;
40
+ || response.status >= 500
41
+ );
30
42
  },
31
43
  };
32
44
 
@@ -41,30 +53,29 @@ const baseUrl = (completionUrl: string): string => {
41
53
 
42
54
  const usageOf = (
43
55
  usage: LanguageModelUsage,
44
- reasoningText: string,
45
- contentText: string,
46
- ): ProviderUsage => normalizeUsage({
56
+ ): ProviderUsage | undefined => normalizeUsage({
47
57
  prompt_tokens: usage.inputTokens,
48
58
  completion_tokens: usage.outputTokens,
49
59
  total_tokens: usage.totalTokens,
50
- 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
+ },
51
64
  completion_tokens_details: usage.outputTokenDetails.reasoningTokens !== undefined
52
65
  ? { reasoning_tokens: usage.outputTokenDetails.reasoningTokens }
53
66
  : undefined,
54
- }, reasoningText, contentText);
67
+ });
55
68
 
56
69
  const wireUsageOf = (
57
70
  values: readonly unknown[],
58
- reasoningText: string,
59
- contentText: string,
60
- ): ProviderUsage | null => {
71
+ ): ProviderUsage | undefined => {
61
72
  for (let index = values.length - 1; index >= 0; index -= 1) {
62
73
  const usage = recordOf(values[index])?.usage;
63
74
  if (usage !== null && typeof usage === "object") {
64
- return normalizeUsage(usage as RawUsage, reasoningText, contentText);
75
+ return normalizeUsage(usage as RawUsage);
65
76
  }
66
77
  }
67
- return null;
78
+ return undefined;
68
79
  };
69
80
 
70
81
  const wireUsageEvidenceOf = (values: readonly unknown[]): unknown => {
@@ -75,6 +86,14 @@ const wireUsageEvidenceOf = (values: readonly unknown[]): unknown => {
75
86
  return undefined;
76
87
  };
77
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;
95
+ };
96
+
78
97
  const finishReasonOf = (reason: string | undefined): ProviderAttemptFinishReason => {
79
98
  switch (reason?.toLowerCase()) {
80
99
  case "stop":
@@ -119,7 +138,7 @@ const metadataOf = (values: readonly unknown[]): Record<string, unknown> => {
119
138
  const record = recordOf(value);
120
139
  if (record === null) continue;
121
140
  for (const [key, item] of Object.entries(record)) {
122
- if (key !== "choices" && key !== "usage") metadata[key] = item;
141
+ if (key !== "choices" && key !== "usage" && key !== "charge") metadata[key] = item;
123
142
  }
124
143
  }
125
144
  return metadata;
@@ -134,8 +153,8 @@ export type AiSdkTransportRequest = {
134
153
  signal?: AbortSignal;
135
154
  fetch?: typeof globalThis.fetch;
136
155
  fetchTimeoutMs: number;
156
+ firstContentTimeoutMs?: number;
137
157
  streamIdleTimeoutMs?: number;
138
- retryAttempts: number;
139
158
  streaming: boolean;
140
159
  captureRawBody: boolean;
141
160
  };
@@ -147,7 +166,7 @@ export type AiSdkTransportResponse = {
147
166
  reasoningProjected: boolean;
148
167
  finishReason: ProviderAttemptFinishReason;
149
168
  rawFinishReason?: string;
150
- usage: ProviderUsage;
169
+ usage?: ProviderUsage;
151
170
  metadata: Record<string, unknown>;
152
171
  reasoningEncrypted: Array<{
153
172
  id: string | null;
@@ -162,6 +181,7 @@ export type AiSdkTransportResponse = {
162
181
  export type AiSdkModelRequest = Omit<AiSdkTransportRequest, "url" | "model" | "body" | "fetch"> & {
163
182
  languageModel: LanguageModel;
164
183
  providerOptions?: Record<string, Record<string, JSONValue | undefined>>;
184
+ systemProviderOptions?: Record<string, Record<string, JSONValue | undefined>>;
165
185
  temperature?: number;
166
186
  topP?: number;
167
187
  topK?: number;
@@ -173,42 +193,68 @@ export type AiSdkModelRequest = Omit<AiSdkTransportRequest, "url" | "model" | "b
173
193
  reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "none" | "provider-default";
174
194
  };
175
195
 
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 ?? ""));
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
+ };
181
240
 
182
241
  const executeModel = async (
183
242
  request: AiSdkModelRequest,
184
243
  ): 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
- });
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
+ }
212
258
  };
213
259
 
214
260
  const executeModelOnce = async (
@@ -217,6 +263,7 @@ const executeModelOnce = async (
217
263
  const {
218
264
  languageModel: model,
219
265
  providerOptions,
266
+ systemProviderOptions,
220
267
  temperature,
221
268
  topP,
222
269
  topK,
@@ -244,9 +291,12 @@ const executeModelOnce = async (
244
291
  if (request.messages.slice(instructionCount).some((message) => message.role === "system")) {
245
292
  throw new Error("provider messages: system instructions must precede conversational messages");
246
293
  }
247
- const instructions = request.messages.slice(0, instructionCount).map(({ content }) => ({
294
+ const instructions = request.messages.slice(0, instructionCount).map(({ content }, index) => ({
248
295
  role: "system" as const,
249
296
  content,
297
+ ...(systemProviderOptions !== undefined && index === instructionCount - 1
298
+ ? { providerOptions: systemProviderOptions }
299
+ : {}),
250
300
  }));
251
301
  const messages = request.messages.slice(instructionCount);
252
302
  const common = {
@@ -255,12 +305,21 @@ const executeModelOnce = async (
255
305
  messages: messages.length > 0
256
306
  ? messages
257
307
  : [{ role: "user" as const, content: "" }],
258
- 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,
259
311
  abortSignal: request.signal,
260
312
  headers: request.headers,
261
313
  timeout: {
262
- totalMs: request.fetchTimeoutMs,
263
- ...(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
264
323
  ? { chunkMs: request.streamIdleTimeoutMs }
265
324
  : {}),
266
325
  },
@@ -285,12 +344,14 @@ const executeModelOnce = async (
285
344
  reasoningProjected: evidence.reasoningProjected,
286
345
  finishReason: finishReasonOf(rawFinishReason),
287
346
  ...(rawFinishReason === undefined ? {} : { rawFinishReason }),
288
- usage: wireUsageOf(values, reasoningText, result.text)
289
- ?? usageOf(result.usage, reasoningText, result.text),
347
+ usage: wireUsageOf(values) ?? usageOf(result.usage),
290
348
  metadata: metadataOf(values),
291
349
  reasoningEncrypted: evidence.reasoningEncrypted,
292
350
  logprobs: evidence.logprobs,
293
351
  chargeEvidence: {
352
+ ...(wireChargeEvidenceOf(values) === undefined
353
+ ? {}
354
+ : { charge: wireChargeEvidenceOf(values) }),
294
355
  ...(accountingUsage === undefined ? {} : { usage: accountingUsage }),
295
356
  ...(result.providerMetadata === undefined
296
357
  ? {}
@@ -317,7 +378,12 @@ const executeModelOnce = async (
317
378
  if (part.type === "raw") rawChunks.push(part.rawValue);
318
379
  if (part.type === "error") streamError ??= part.error;
319
380
  }
320
- 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
+ }
321
387
  const evidence = extractEvidence(rawChunks);
322
388
  const accountingUsage = wireUsageEvidenceOf(rawChunks);
323
389
  const content = await result.text;
@@ -334,12 +400,14 @@ const executeModelOnce = async (
334
400
  reasoningProjected: evidence.reasoningProjected,
335
401
  finishReason: finishReasonOf(rawFinishReason),
336
402
  ...(rawFinishReason === undefined ? {} : { rawFinishReason }),
337
- usage: wireUsageOf(rawChunks, reasoningText, content)
338
- ?? usageOf(await result.usage, reasoningText, content),
403
+ usage: wireUsageOf(rawChunks) ?? usageOf(await result.usage),
339
404
  metadata: metadataOf(rawChunks),
340
405
  reasoningEncrypted: evidence.reasoningEncrypted,
341
406
  logprobs: evidence.logprobs,
342
407
  chargeEvidence: {
408
+ ...(wireChargeEvidenceOf(rawChunks) === undefined
409
+ ? {}
410
+ : { charge: wireChargeEvidenceOf(rawChunks) }),
343
411
  ...(accountingUsage === undefined ? {} : { usage: accountingUsage }),
344
412
  ...(providerMetadata === undefined ? {} : { providerMetadata }),
345
413
  response: {
@@ -378,13 +446,57 @@ export const executeOpenAICompatible = async (
378
446
  messages: request.messages,
379
447
  signal: request.signal,
380
448
  fetchTimeoutMs: request.fetchTimeoutMs,
449
+ firstContentTimeoutMs: request.firstContentTimeoutMs,
381
450
  streamIdleTimeoutMs: request.streamIdleTimeoutMs,
382
- retryAttempts: request.retryAttempts,
383
451
  streaming: request.streaming,
384
452
  captureRawBody: request.captureRawBody,
385
453
  });
386
454
  };
387
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
+
388
500
  const extractEvidence = (values: unknown[]): {
389
501
  reasoningEncrypted: AiSdkTransportResponse["reasoningEncrypted"];
390
502
  logprobs: TokenLogprob[];
@@ -28,6 +28,7 @@ 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",
33
34
  "cost.ts",