@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,7 +1,7 @@
1
1
  import test from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
3
  import { APICallError, RetryError } from "ai";
4
- import { ProviderError, classifyProviderError, toProviderError } from "./errors.ts";
4
+ import { ProviderError, ProviderTimeoutError, classifyProviderError, toProviderError } from "./errors.ts";
5
5
  import type { ProviderAttempt } from "./types.ts";
6
6
  import { providerSource } from "./notices.ts";
7
7
 
@@ -29,12 +29,27 @@ test("classifyProviderError maps HTTP status to kind", () => {
29
29
  assert.equal(k(403), "unauthorized");
30
30
  assert.equal(k(402), "quota_exceeded");
31
31
  assert.equal(k(429), "rate_limit");
32
+ assert.equal(k(408), "network_failure");
33
+ assert.equal(k(409), "network_failure");
32
34
  assert.equal(k(500), "network_failure");
33
35
  assert.equal(k(503), "network_failure");
34
36
  assert.equal(k(400), "invalid_response");
35
37
  assert.equal(k(404), "invalid_response");
36
38
  });
37
39
 
40
+ test("provider retry directives survive HTTP failure normalization", () => {
41
+ const final = new APICallError({
42
+ message: "edge router says not to replay",
43
+ url: "https://example.test/v1/chat/completions",
44
+ requestBodyValues: {},
45
+ statusCode: 524,
46
+ isRetryable: false,
47
+ });
48
+ const error = toProviderError(final, "provider:test");
49
+ assert.equal(error.kind, "network_failure");
50
+ assert.equal(error.problem.retryable, false);
51
+ });
52
+
38
53
  test("classifyProviderError: a 422 flagged grammar_invalid is distinct; other 422s are invalid responses", () => {
39
54
  const rejected = apiError(422, JSON.stringify({ error: { type: "grammar_invalid", message: "non-conforming emission rejected: ..." } }));
40
55
  assert.equal(classifyProviderError(rejected).kind, "grammar_invalid");
@@ -75,11 +90,17 @@ test("#161: ProviderError carries resource-interrupted attempt evidence outside
75
90
  assistant: {
76
91
  content: "partial",
77
92
  reasoning: null,
78
- usage: { prompt: 3, completion: 1, reasoning: 0, cached: 0, total: 4 },
79
93
  finishReason: "resource_interrupted",
80
94
  model: "served-model",
81
95
  },
82
96
  assistantRaw: { rawFinishReason: "insufficient_system_resource" },
97
+ accounting: [{
98
+ provider: "provider:deepseek",
99
+ model: "served-model",
100
+ outcome: "response",
101
+ usage: { inputTokens: 3, outputTokens: 1, totalTokens: 4 },
102
+ cost: { kind: "unknown", reason: "fixture has no monetary evidence" },
103
+ }],
83
104
  } as ProviderAttempt;
84
105
  const error = new ProviderError(
85
106
  "provider:deepseek",
@@ -96,6 +117,7 @@ test("#161: ProviderError carries resource-interrupted attempt evidence outside
96
117
  );
97
118
 
98
119
  assert.equal(error.attempt, attempt);
120
+ assert.deepEqual(error.accounting, attempt.accounting);
99
121
  assert.deepEqual(error.problem, {
100
122
  type: "https://problems.plurnk.dev/provider/deepseek/resource-interrupted",
101
123
  title: "Resource interrupted",
@@ -146,3 +168,26 @@ test("retry exhaustion is explicit and does not recommend another automatic repl
146
168
  assert.equal(error.problem.retryExhausted, true);
147
169
  assert.equal(error.cause, cause);
148
170
  });
171
+
172
+ test("retry exhaustion retains the exact inner deadline phase", () => {
173
+ const failures = [1, 2].map(() => new APICallError({
174
+ message: "attempt timed out",
175
+ url: "https://example.test/v1/chat/completions",
176
+ requestBodyValues: {},
177
+ cause: new ProviderTimeoutError("attempt", 10),
178
+ isRetryable: true,
179
+ }));
180
+ const cause = new RetryError({
181
+ message: "Failed after 2 attempts.",
182
+ reason: "maxRetriesExceeded",
183
+ errors: failures,
184
+ });
185
+ const error = toProviderError(cause, "provider:test");
186
+ assert.equal(error.kind, "network_failure");
187
+ assert.equal(error.status, 503);
188
+ assert.equal(error.problem.retryable, false);
189
+ assert.equal(error.problem.attempts, 2);
190
+ assert.equal(error.problem.retryExhausted, true);
191
+ assert.equal(error.problem.timeoutPhase, "attempt");
192
+ assert.equal(error.problem.timeoutMs, 10);
193
+ });
package/src/errors.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Problems, type ProblemDetails } from "@plurnk/plurnk-contracts";
2
2
  import { APICallError, RetryError } from "ai";
3
3
  import { providerSource } from "./notices.ts";
4
- import type { ProviderAttempt } from "./types.ts";
4
+ import type { ProviderAttempt, ProviderRequestAccounting } from "./types.ts";
5
5
 
6
6
  export type ProviderErrorKind =
7
7
  | "rate_limit"
8
8
  | "network_failure"
9
+ | "deadline_exceeded"
9
10
  | "model_refused"
10
11
  | "invalid_response"
11
12
  | "unauthorized"
@@ -19,8 +20,40 @@ export interface ClassifiedProviderError {
19
20
  retryable?: boolean;
20
21
  attempts?: number;
21
22
  retryExhausted?: boolean;
23
+ extensions?: Readonly<Record<string, unknown>>;
22
24
  }
23
25
 
26
+ export type ProviderTimeoutPhase = "attempt" | "first_content" | "stream_idle" | "operation";
27
+
28
+ export class ProviderTimeoutError extends Error {
29
+ readonly phase: ProviderTimeoutPhase;
30
+ readonly timeoutMs: number;
31
+
32
+ constructor(phase: ProviderTimeoutPhase, timeoutMs: number, cause?: unknown) {
33
+ const labels: Record<ProviderTimeoutPhase, string> = {
34
+ attempt: "Provider attempt",
35
+ first_content: "First provider content",
36
+ stream_idle: "Provider stream idle",
37
+ operation: "Provider operation",
38
+ };
39
+ super(`${labels[phase]} exceeded its ${timeoutMs} ms deadline.`, cause === undefined ? undefined : { cause });
40
+ this.name = "ProviderTimeoutError";
41
+ this.phase = phase;
42
+ this.timeoutMs = timeoutMs;
43
+ }
44
+ }
45
+
46
+ export const providerTimeoutOf = (error: unknown): ProviderTimeoutError | null => {
47
+ const seen = new Set<unknown>();
48
+ let current = error;
49
+ while (typeof current === "object" && current !== null && !seen.has(current)) {
50
+ if (current instanceof ProviderTimeoutError) return current;
51
+ seen.add(current);
52
+ current = (current as { cause?: unknown }).cause;
53
+ }
54
+ return null;
55
+ };
56
+
24
57
  const defaultStatus = (kind: ProviderErrorKind): number => {
25
58
  switch (kind) {
26
59
  case "unauthorized": return 401;
@@ -29,6 +62,7 @@ const defaultStatus = (kind: ProviderErrorKind): number => {
29
62
  case "model_refused":
30
63
  case "grammar_invalid": return 422;
31
64
  case "invalid_response": return 502;
65
+ case "deadline_exceeded": return 504;
32
66
  case "network_failure":
33
67
  case "resource_interrupted": return 503;
34
68
  }
@@ -39,6 +73,7 @@ const retryable = (kind: ProviderErrorKind): boolean => {
39
73
  case "rate_limit":
40
74
  case "network_failure":
41
75
  return true;
76
+ case "deadline_exceeded":
42
77
  case "invalid_response":
43
78
  case "grammar_invalid":
44
79
  case "resource_interrupted":
@@ -60,6 +95,7 @@ const buildProblem = (
60
95
  const code: Record<ProviderErrorKind, string> = {
61
96
  rate_limit: "rate-limit",
62
97
  network_failure: "network-failure",
98
+ deadline_exceeded: "deadline-exceeded",
63
99
  model_refused: "model-refused",
64
100
  invalid_response: "invalid-response",
65
101
  unauthorized: "unauthorized",
@@ -84,6 +120,7 @@ export class ProviderError extends Error {
84
120
  readonly kind: ProviderErrorKind;
85
121
  readonly problem: ProblemDetails;
86
122
  readonly attempt?: ProviderAttempt;
123
+ #accounting: ProviderRequestAccounting[];
87
124
 
88
125
  constructor(
89
126
  source: string,
@@ -95,6 +132,7 @@ export class ProviderError extends Error {
95
132
  retryable?: boolean;
96
133
  extensions?: Readonly<Record<string, unknown>>;
97
134
  attempt?: ProviderAttempt;
135
+ accounting?: readonly ProviderRequestAccounting[];
98
136
  } = {},
99
137
  ) {
100
138
  super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
@@ -102,6 +140,7 @@ export class ProviderError extends Error {
102
140
  this.source = providerSource(source);
103
141
  this.kind = kind;
104
142
  this.attempt = options.attempt;
143
+ this.#accounting = [...(options.accounting ?? options.attempt?.accounting ?? [])];
105
144
  const status = options.status !== null && options.status !== undefined
106
145
  && Number.isInteger(options.status) && options.status >= 400 && options.status <= 599
107
146
  ? options.status
@@ -119,6 +158,16 @@ export class ProviderError extends Error {
119
158
  get status(): number {
120
159
  return this.problem.status;
121
160
  }
161
+
162
+ get accounting(): readonly ProviderRequestAccounting[] {
163
+ return this.#accounting;
164
+ }
165
+
166
+ // A capacity pool adds the already-settled requests from prior backends as
167
+ // the same failure crosses that orchestration boundary.
168
+ prependAccounting(accounting: readonly ProviderRequestAccounting[]): void {
169
+ if (accounting.length > 0) this.#accounting = [...accounting, ...this.#accounting];
170
+ }
122
171
  }
123
172
 
124
173
  const wireErrorType = (body: string): string | null => {
@@ -150,6 +199,17 @@ export const classifyProviderError = (
150
199
  };
151
200
  }
152
201
  if (APICallError.isInstance(err)) {
202
+ const timeout = providerTimeoutOf(err);
203
+ if (timeout !== null) {
204
+ return {
205
+ kind: "network_failure",
206
+ message: timeout.message,
207
+ extensions: {
208
+ timeoutPhase: timeout.phase,
209
+ timeoutMs: timeout.timeoutMs,
210
+ },
211
+ };
212
+ }
153
213
  const status = err.statusCode ?? 0;
154
214
  const message = err.message.trim().length > 0
155
215
  ? preview(err.message, detailLimit)
@@ -157,8 +217,12 @@ export const classifyProviderError = (
157
217
  const body = err.responseBody ?? "";
158
218
  if (status === 401 || status === 403) return { kind: "unauthorized", message };
159
219
  if (status === 402) return { kind: "quota_exceeded", message };
160
- if (status === 429) return { kind: "rate_limit", message };
161
- if (status >= 500) return { kind: "network_failure", message };
220
+ if (status === 429) return { kind: "rate_limit", message, retryable: err.isRetryable };
221
+ if (status === 408 || status === 409) {
222
+ return { kind: "network_failure", message, retryable: err.isRetryable };
223
+ }
224
+ if (status === 0 && err.isRetryable) return { kind: "network_failure", message };
225
+ if (status >= 500) return { kind: "network_failure", message, retryable: err.isRetryable };
162
226
  if (status === 422 && wireErrorType(body) === "grammar_invalid") {
163
227
  return { kind: "grammar_invalid", message };
164
228
  }
@@ -198,6 +262,7 @@ export const toProviderError = (
198
262
  cause: err,
199
263
  retryable: classified.retryable,
200
264
  extensions: {
265
+ ...(classified.extensions ?? {}),
201
266
  ...(classified.attempts === undefined ? {} : { attempts: classified.attempts }),
202
267
  ...(classified.retryExhausted === undefined
203
268
  ? {}
package/src/index.ts CHANGED
@@ -10,13 +10,20 @@ export type {
10
10
  ProviderOptions,
11
11
  ProviderResponse,
12
12
  ProviderEncryptedReasoningItem,
13
+ ProviderAccounting,
14
+ ProviderCost,
15
+ ProviderCostNormalizer,
16
+ ProviderCallKind,
17
+ ProviderGenerateArgs,
18
+ ProviderRequestAccounting,
19
+ ProviderRequestIdentity,
20
+ ProviderRequestObserver,
21
+ ProviderRequestSettlement,
13
22
  ProviderUsage,
14
23
  PromptTokenMeasurement,
15
24
  TokenLogprob,
16
25
  TokenAlternative,
17
- AuthoritativeCharge,
18
26
  } from "./types.ts";
19
- export type { ProviderCost } from "@plurnk/plurnk-contracts";
20
27
  export { assertPromptTokenMeasurement } from "./promptTokens.ts";
21
28
 
22
29
  // Alias cascade — re-exported from the zero-dep @plurnk/plurnk-aliases, so
@@ -45,14 +52,23 @@ export { default as Pool } from "./Pool.ts";
45
52
  export type { ProviderFetch } from "./AiSdkProvider.ts";
46
53
  export { parseRequiredInt, parseOptionalInt, parseRequiredFloat, parseOptionalFloat, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv, scopeEnvToAlias, dataCaptureFromEnv, contextWindowFromEnv, effectiveContextWindow, envelopeFromEnv, resolveReserve, PROVIDERS_KNOBS } from "./env.ts";
47
54
  export type { Reasoning, ReasoningMode, ReasoningResponseStyle, ReserveSpec } from "./env.ts";
48
- export { normalizeUsage, calculateCostUsd } from "./usage.ts";
55
+ export { normalizeUsage, calculateCostUsdDecimal, validateProviderUsage } from "./usage.ts";
49
56
  export {
50
- providerCostFor,
57
+ addDecimals,
58
+ estimateProviderCost,
51
59
  providerCostUsd,
52
60
  resolveProviderCost,
53
- validateAuthoritativeCharge,
61
+ sumProviderCostsUsd,
62
+ validateChargedCost,
63
+ validateDecimal,
54
64
  validateProviderCost,
55
65
  } from "./cost.ts";
66
+ export {
67
+ aggregateProviderAccounting,
68
+ plurnkCostNormalizer,
69
+ providerCostNormalizer,
70
+ validateProviderRequestAccounting,
71
+ } from "./accounting.ts";
56
72
  export type { RawUsage, TokenRates } from "./usage.ts";
57
73
  export { ProviderError, classifyProviderError, toProviderError } from "./errors.ts";
58
74
  export { providerSource } from "./notices.ts";
@@ -4,6 +4,8 @@ import { ollamaProviderFromEnv } from "./ollama.ts";
4
4
 
5
5
  const env = Object.freeze({
6
6
  PLURNK_PROVIDERS_FETCH_TIMEOUT: "1000",
7
+ PLURNK_PROVIDERS_OPERATION_TIMEOUT: "3000",
8
+ PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT: "1000",
7
9
  PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT: "0",
8
10
  PLURNK_PROVIDERS_REASONING: "off",
9
11
  PLURNK_PROVIDERS_TEMPERATURE: "0.2",
@@ -13,7 +15,8 @@ const env = Object.freeze({
13
15
  PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%",
14
16
  PLURNK_PROVIDERS_RETRY_ATTEMPTS: "0",
15
17
  PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT: "512",
16
- PLURNK_PROVIDERS_PROMPT_CACHE_KEY: "1",
18
+ PLURNK_PROVIDERS_CACHE_AFFINITY: "1",
19
+ PLURNK_PROVIDERS_CACHE_WRITE_POLICY: "stable-system",
17
20
  });
18
21
 
19
22
  test.afterEach(() => mock.restoreAll());
package/src/ollama.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
2
- import { contextWindowFromEnv, effectiveContextWindow, parseRequiredInt, requireEnv } from "./env.ts";
2
+ import { contextWindowFromEnv, effectiveContextWindow, parseTimeoutMs, requireEnv } from "./env.ts";
3
3
  import { providerFromSdkModel } from "./catalogProvider.ts";
4
4
  import type { Provider, ProviderOptions } from "./types.ts";
5
5
 
@@ -20,7 +20,7 @@ const fetchContextWindow = async ({
20
20
  method: "POST",
21
21
  headers: { "Content-Type": "application/json" },
22
22
  body: JSON.stringify({ model }),
23
- signal: AbortSignal.timeout(timeout),
23
+ ...(timeout > 0 ? { signal: AbortSignal.timeout(timeout) } : {}),
24
24
  });
25
25
  if (!response.ok) throw new Error(`ollama provider: /api/show returned ${response.status}`);
26
26
  const data = await response.json() as ShowResponse;
@@ -41,7 +41,7 @@ export const ollamaProviderFromEnv = async (
41
41
  "OLLAMA_BASE_URL or OLLAMA_HOST (or a PLURNK_BASEURL_<alias> override)",
42
42
  "ollama",
43
43
  ).replace(/\/+$/, "").replace(/\/v1$/, "");
44
- const timeout = parseRequiredInt(
44
+ const timeout = parseTimeoutMs(
45
45
  env.PLURNK_PROVIDERS_FETCH_TIMEOUT,
46
46
  "PLURNK_PROVIDERS_FETCH_TIMEOUT",
47
47
  "ollama",
@@ -19,9 +19,49 @@ test("createSdkModel uses Models.dev provider facts and operator credentials", (
19
19
  const sdk = createSdkModel("xai", "grok-build-0.1", { XAI_API_KEY: "test-key" });
20
20
  assert.notEqual(sdk, null);
21
21
  assert.equal(sdk?.catalog?.npm, "@ai-sdk/xai");
22
- const languageModel = sdk?.languageModel as { provider: string; modelId: string };
23
- assert.equal(languageModel.provider, "xai.responses");
24
- assert.equal(languageModel.modelId, "grok-build-0.1");
22
+ assert.equal(sdk?.languageModel, undefined);
23
+ assert.deepEqual(sdk?.compatible, {
24
+ url: "https://api.x.ai/v1/chat/completions",
25
+ headers: { Authorization: "Bearer test-key" },
26
+ });
27
+ assert.deepEqual(sdk?.cacheAffinity, { target: "header", name: "x-grok-conv-id" });
28
+ assert.notEqual(sdk?.normalizeCost, undefined);
29
+ });
30
+
31
+ test("createSdkModel constructs Cerebras from Models.dev facts", () => {
32
+ const sdk = createSdkModel("cerebras", "gemma-4-31b", {
33
+ CEREBRAS_API_KEY: "test-key",
34
+ });
35
+ assert.notEqual(sdk, null);
36
+ assert.equal(sdk?.catalog?.npm, "@ai-sdk/cerebras");
37
+ assert.notEqual(sdk?.languageModel, undefined);
38
+ assert.equal(sdk?.compatible, undefined);
39
+ });
40
+
41
+ test("the Google SDK adapter owns its readable-reasoning response projection", () => {
42
+ assert.deepEqual(
43
+ createSdkModel("google", "gemini-3.7-flash", { GEMINI_API_KEY: "test-key" })?.reasoningResponseProviderOptions,
44
+ { google: { thinkingConfig: { includeThoughts: true } } },
45
+ );
46
+ assert.equal(
47
+ createSdkModel("cerebras", "gemma-4-31b", { CEREBRAS_API_KEY: "test-key" })?.reasoningResponseProviderOptions,
48
+ undefined,
49
+ );
50
+ });
51
+
52
+ test("createSdkModel attaches DeepInfra's documented response-cost normalizer", () => {
53
+ const sdk = createSdkModel("deepinfra", "zai-org/GLM-5.2", {
54
+ DEEPINFRA_API_KEY: "test-key",
55
+ });
56
+ assert.notEqual(sdk?.languageModel, undefined);
57
+ assert.deepEqual(sdk?.normalizeCost?.({
58
+ usage: { estimated_cost: 5.04e-5 },
59
+ response: { id: "response-1" },
60
+ }), {
61
+ kind: "estimated",
62
+ amount: { amount: "0.0000504", currency: "USD" },
63
+ source: "DeepInfra response usage.estimated_cost",
64
+ });
25
65
  });
26
66
 
27
67
  test("createSdkModel expands catalog endpoint variables without treating them as credentials", () => {
@@ -35,6 +75,57 @@ test("createSdkModel expands catalog endpoint variables without treating them as
35
75
  url: "https://api.cloudflare.com/client/v4/accounts/account/ai/v1/chat/completions",
36
76
  headers: { Authorization: "Bearer token" },
37
77
  });
78
+ assert.deepEqual(sdk?.cacheAffinity, { target: "header", name: "x-session-affinity" });
79
+ });
80
+
81
+ test("catalog routes own their documented cache-affinity request projection", () => {
82
+ assert.deepEqual(
83
+ createSdkModel("openai", "gpt-4.1-mini", { OPENAI_API_KEY: "key" })?.cacheAffinity,
84
+ { target: "provider-option", provider: "openai", name: "promptCacheKey" },
85
+ );
86
+ assert.deepEqual(
87
+ createSdkModel("deepinfra", "zai-org/GLM-5.2", { DEEPINFRA_API_KEY: "key" })?.cacheAffinity,
88
+ { target: "provider-option", provider: "deepinfra", name: "prompt_cache_key" },
89
+ );
90
+ assert.deepEqual(
91
+ createSdkModel("openrouter", "openai/gpt-5", { OPENROUTER_API_KEY: "key" })?.cacheAffinity,
92
+ { target: "header", name: "x-session-id" },
93
+ );
94
+ assert.deepEqual(
95
+ createSdkModel("fireworks", "accounts/fireworks/models/test", { FIREWORKS_API_KEY: "key" })?.cacheAffinity,
96
+ { target: "body", name: "prompt_cache_key" },
97
+ );
98
+ });
99
+
100
+ test("explicit stable-system cache breakpoints exist only on supported Claude routes", () => {
101
+ const cacheControl = { type: "ephemeral" };
102
+ assert.deepEqual(
103
+ createSdkModel("anthropic", "claude-sonnet-4-6", { ANTHROPIC_API_KEY: "key" })?.systemCacheProviderOptions,
104
+ { anthropic: { cacheControl } },
105
+ );
106
+ assert.deepEqual(
107
+ createSdkModel("openrouter", "anthropic/claude-sonnet-4.6", { OPENROUTER_API_KEY: "key" })?.systemCacheProviderOptions,
108
+ { openrouter: { cacheControl } },
109
+ );
110
+ assert.equal(
111
+ createSdkModel("openrouter", "openai/gpt-5", { OPENROUTER_API_KEY: "key" })?.systemCacheProviderOptions,
112
+ undefined,
113
+ );
114
+ assert.equal(
115
+ createSdkModel("deepseek", "deepseek-v4-flash", { DEEPSEEK_API_KEY: "key" })?.systemCacheProviderOptions,
116
+ undefined,
117
+ );
118
+ });
119
+
120
+ test("an operator-declared compatible provider receives no guessed cache extension", () => {
121
+ const sdk = createSdkModel("acme", "model", {
122
+ ACME_API_KEY: "key",
123
+ PLURNK_PROVIDERS_PROVIDER_ACME_NPM: "@ai-sdk/openai-compatible",
124
+ PLURNK_PROVIDERS_PROVIDER_ACME_BASE_URL: "https://api.acme.test/v1",
125
+ PLURNK_PROVIDERS_PROVIDER_ACME_API_KEY_ENV: "ACME_API_KEY",
126
+ });
127
+ assert.equal(sdk?.cacheAffinity, undefined);
128
+ assert.equal(sdk?.systemCacheProviderOptions, undefined);
38
129
  });
39
130
 
40
131
  test("#157: a cataloged compatible provider fails before transport when its declared credential is absent", () => {
package/src/sdkModels.ts CHANGED
@@ -1,25 +1,34 @@
1
1
  import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";
2
2
  import { createAnthropic } from "@ai-sdk/anthropic";
3
+ import { createCerebras } from "@ai-sdk/cerebras";
3
4
  import { createDeepInfra } from "@ai-sdk/deepinfra";
4
5
  import { createGoogle } from "@ai-sdk/google";
5
6
  import { createGroq } from "@ai-sdk/groq";
6
7
  import { createMistral } from "@ai-sdk/mistral";
7
8
  import { createOpenAI } from "@ai-sdk/openai";
8
9
  import { createTogetherAI } from "@ai-sdk/togetherai";
9
- import { createXai } from "@ai-sdk/xai";
10
10
  import { createOpenRouter } from "@openrouter/ai-sdk-provider";
11
11
  import { lookupProvider, type ProviderInfo } from "@plurnk/plurnk-models";
12
12
  import type { LanguageModel } from "ai";
13
+ import { providerCostNormalizer } from "./accounting.ts";
14
+ import type { AiSdkProviderOptions, CacheAffinity } from "./AiSdkProvider.ts";
15
+ import type { ProviderCostNormalizer } from "./types.ts";
13
16
 
14
17
  export type SdkModel = {
15
18
  readonly languageModel?: LanguageModel;
19
+ readonly normalizeCost?: ProviderCostNormalizer;
16
20
  readonly compatible?: {
17
21
  readonly url: string;
18
22
  readonly headers: Readonly<Record<string, string>>;
19
23
  };
24
+ readonly cacheAffinity?: CacheAffinity;
25
+ readonly systemCacheProviderOptions?: AiSdkProviderOptions;
26
+ readonly reasoningResponseProviderOptions?: AiSdkProviderOptions;
20
27
  readonly catalog: ProviderInfo | null;
21
28
  };
22
29
 
30
+ const cacheControl = { type: "ephemeral" as const };
31
+
23
32
  const envPrefix = (provider: string): string =>
24
33
  provider.replaceAll(/[^a-zA-Z0-9]/g, "_").toUpperCase();
25
34
 
@@ -105,11 +114,15 @@ export const createSdkModel = (
105
114
  const catalog = lookupProvider(provider) ?? configuredProviderInfo(provider, env);
106
115
  if (catalog === null) return null;
107
116
  const url = baseUrl(provider, env, catalog, baseUrlOverride);
117
+ const normalizeCost = providerCostNormalizer(catalog.npm);
108
118
 
109
119
  switch (catalog.npm) {
110
120
  case "@ai-sdk/openai":
111
121
  return {
112
122
  languageModel: createOpenAI({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).chat(model),
123
+ ...(catalog.id === "openai"
124
+ ? { cacheAffinity: { target: "provider-option" as const, provider: "openai", name: "promptCacheKey" } }
125
+ : {}),
113
126
  catalog,
114
127
  };
115
128
  case "@ai-sdk/groq":
@@ -117,6 +130,11 @@ export const createSdkModel = (
117
130
  languageModel: createGroq({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
118
131
  catalog,
119
132
  };
133
+ case "@ai-sdk/cerebras":
134
+ return {
135
+ languageModel: createCerebras({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
136
+ catalog,
137
+ };
120
138
  case "@ai-sdk/mistral":
121
139
  return {
122
140
  languageModel: createMistral({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
@@ -130,21 +148,41 @@ export const createSdkModel = (
130
148
  case "@ai-sdk/deepinfra":
131
149
  return {
132
150
  languageModel: createDeepInfra({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
151
+ ...(normalizeCost === undefined ? {} : { normalizeCost }),
152
+ ...(catalog.id === "deepinfra"
153
+ ? { cacheAffinity: { target: "provider-option" as const, provider: "deepinfra", name: "prompt_cache_key" } }
154
+ : {}),
133
155
  catalog,
134
156
  };
135
157
  case "@ai-sdk/google":
136
158
  return {
137
159
  languageModel: createGoogle({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
160
+ reasoningResponseProviderOptions: {
161
+ google: { thinkingConfig: { includeThoughts: true } },
162
+ },
138
163
  catalog,
139
164
  };
140
- case "@ai-sdk/xai":
165
+ case "@ai-sdk/xai": {
166
+ const key = requireApiKey(provider, env, catalog);
167
+ const compatibleBase = url ?? "https://api.x.ai/v1";
141
168
  return {
142
- languageModel: createXai({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
169
+ compatible: {
170
+ url: `${compatibleBase}/chat/completions`,
171
+ headers: { Authorization: `Bearer ${key}` },
172
+ },
173
+ ...(catalog.id === "xai"
174
+ ? { cacheAffinity: { target: "header" as const, name: "x-grok-conv-id" } }
175
+ : {}),
176
+ ...(normalizeCost === undefined ? {} : { normalizeCost }),
143
177
  catalog,
144
178
  };
179
+ }
145
180
  case "@ai-sdk/anthropic":
146
181
  return {
147
182
  languageModel: createAnthropic({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
183
+ ...(catalog.id === "anthropic"
184
+ ? { systemCacheProviderOptions: { anthropic: { cacheControl } } }
185
+ : {}),
148
186
  catalog,
149
187
  };
150
188
  case "@ai-sdk/amazon-bedrock":
@@ -169,6 +207,13 @@ export const createSdkModel = (
169
207
  ...(env.OPENROUTER_X_TITLE === undefined ? {} : { "X-Title": env.OPENROUTER_X_TITLE }),
170
208
  },
171
209
  }).languageModel(model),
210
+ ...(catalog.id === "openrouter"
211
+ ? { cacheAffinity: { target: "header" as const, name: "x-session-id" } }
212
+ : {}),
213
+ ...(catalog.id === "openrouter" && model.replace(/^~/, "").startsWith("anthropic/")
214
+ ? { systemCacheProviderOptions: { openrouter: { cacheControl } } }
215
+ : {}),
216
+ ...(normalizeCost === undefined ? {} : { normalizeCost }),
172
217
  catalog,
173
218
  };
174
219
  case "@ai-sdk/openai-compatible":
@@ -182,6 +227,11 @@ export const createSdkModel = (
182
227
  ? {}
183
228
  : { Authorization: `Bearer ${key}` },
184
229
  },
230
+ ...(catalog.id === "cloudflare-workers-ai"
231
+ ? { cacheAffinity: { target: "header" as const, name: "x-session-affinity" } }
232
+ : catalog.id === "fireworks-ai"
233
+ ? { cacheAffinity: { target: "body" as const, name: "prompt_cache_key" } }
234
+ : {}),
185
235
  catalog,
186
236
  };
187
237
  default: