@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
package/src/cost.test.ts CHANGED
@@ -1,62 +1,81 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import {
4
- providerCostFor,
4
+ addDecimals,
5
+ estimateProviderCost,
5
6
  providerCostUsd,
6
- validateAuthoritativeCharge,
7
+ resolveProviderCost,
8
+ sumProviderCostsUsd,
9
+ validateChargedCost,
7
10
  } from "./cost.ts";
8
11
  import type { ProviderUsage } from "./types.ts";
9
12
 
10
13
  const usage: ProviderUsage = {
11
- prompt: 1,
12
- completion: 1,
13
- reasoning: 0,
14
- cached: 0,
15
- total: 2,
14
+ inputTokens: 1,
15
+ outputTokens: 1,
16
+ totalTokens: 2,
16
17
  };
17
18
 
18
- test("authoritative provider charge wins over a local estimate", () => {
19
- const provider = {
20
- calculateCost: () => 12,
21
- calculateCharge: () => ({ kind: "estimated", usd: "12", source: "catalog" } as const),
22
- };
23
- const charge = {
24
- kind: "authoritative",
19
+ test("direct charged evidence wins over a Models.dev estimate", () => {
20
+ const charged = {
21
+ kind: "charged",
25
22
  amount: { amount: "0.0000042", currency: "XMR" },
26
23
  usdEquivalent: "0.73",
27
- source: "settled upstream turn charge",
24
+ source: "settled upstream request charge",
28
25
  } as const;
29
- assert.deepEqual(providerCostFor(provider, usage, charge), charge);
30
- assert.equal(providerCostUsd(charge), 0.73);
26
+ const estimated = estimateProviderCost(usage, { input: 1, output: 1 }, "Models.dev");
27
+ assert.deepEqual(resolveProviderCost(charged, estimated), charged);
28
+ assert.equal(providerCostUsd(charged), "0.73");
31
29
  });
32
30
 
33
- test("explicit free remains distinguishable from unknown", () => {
34
- const free = providerCostFor({
35
- calculateCost: () => 0,
36
- calculateCharge: () => ({ kind: "free", source: "local model" }),
37
- }, usage);
38
- const unknown = providerCostFor({ calculateCost: () => 0 }, usage);
39
- assert.deepEqual(free, { kind: "free", source: "local model" });
31
+ test("an exact zero estimate remains distinguishable from unknown cost", () => {
32
+ const zero = estimateProviderCost(usage, { input: 0, output: 0 }, "Models.dev");
33
+ const unknown = estimateProviderCost(usage, null, "Models.dev");
34
+ assert.deepEqual(zero, {
35
+ kind: "estimated",
36
+ amount: { amount: "0", currency: "USD" },
37
+ source: "Models.dev",
38
+ });
40
39
  assert.deepEqual(unknown, {
41
40
  kind: "unknown",
42
- reason: "the response reported no cost and Models.dev has no rate for this model",
41
+ reason: "Models.dev has no complete rate for this model",
43
42
  });
44
- assert.equal(providerCostUsd(free), 0);
43
+ assert.equal(providerCostUsd(zero), "0");
45
44
  assert.equal(providerCostUsd(unknown), null);
46
45
  });
47
46
 
48
- test("legacy calculateCost is not a monetary-reporting authority", () => {
49
- const cost = providerCostFor({ calculateCost: () => 0.25 }, usage);
50
- assert.deepEqual(cost, {
51
- kind: "unknown",
52
- reason: "the response reported no cost and Models.dev has no rate for this model",
53
- });
54
- assert.equal(providerCostUsd(cost), null);
47
+ test("a distinct cache rate requires the applicable token category", () => {
48
+ assert.deepEqual(
49
+ estimateProviderCost(usage, { input: 1, output: 1, cacheRead: 0.1 }, "Models.dev"),
50
+ {
51
+ kind: "unknown",
52
+ reason: "the provider response omitted a token category with a distinct Models.dev rate",
53
+ },
54
+ );
55
+ });
56
+
57
+ test("decimal aggregation is exact and becomes unknown if any request is unknown", () => {
58
+ assert.equal(addDecimals(["0.1", "0.02", "3"]), "3.12");
59
+ assert.equal(sumProviderCostsUsd([
60
+ {
61
+ kind: "charged",
62
+ amount: { amount: "0.1", currency: "USD" },
63
+ source: "provider",
64
+ },
65
+ {
66
+ kind: "estimated",
67
+ amount: { amount: "0.02", currency: "USD" },
68
+ source: "Models.dev",
69
+ },
70
+ ]), "0.12");
71
+ assert.equal(sumProviderCostsUsd([
72
+ { kind: "unknown", reason: "no evidence" },
73
+ ]), null);
55
74
  });
56
75
 
57
- test("rejects malformed money instead of mining or coercing it", () => {
58
- assert.throws(() => validateAuthoritativeCharge({
59
- kind: "authoritative",
76
+ test("malformed charged money is rejected instead of coerced", () => {
77
+ assert.throws(() => validateChargedCost({
78
+ kind: "charged",
60
79
  amount: { amount: "1e3", currency: "usd" },
61
80
  usdEquivalent: "1000",
62
81
  source: "wire",
package/src/cost.ts CHANGED
@@ -1,78 +1,139 @@
1
1
  import type {
2
- AuthoritativeCharge,
2
+ ChargedCost,
3
+ ProviderCost,
3
4
  ProviderUsage,
4
5
  } from "./types.ts";
5
- import type { ProviderCost } from "@plurnk/plurnk-contracts";
6
+ import {
7
+ calculateCostUsdDecimal,
8
+ canonicalDecimal,
9
+ type TokenRates,
10
+ } from "./usage.ts";
6
11
 
7
12
  const DECIMAL = /^(?:0|[1-9]\d*)(?:\.\d+)?$/;
8
13
  const CURRENCY = /^[A-Z][A-Z0-9]{2,11}$/;
9
14
 
10
- const nonEmpty = (value: string, name: string): string => {
11
- if (value.trim() === "") throw new TypeError(`${name} must be non-empty`);
15
+ const recordOf = (value: unknown, name: string): Record<string, unknown> => {
16
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
17
+ throw new TypeError(`${name} must be an object`);
18
+ }
19
+ return value as Record<string, unknown>;
20
+ };
21
+
22
+ const nonEmpty = (value: unknown, name: string): string => {
23
+ if (typeof value !== "string" || value.trim() === "") {
24
+ throw new TypeError(`${name} must be a non-empty string`);
25
+ }
12
26
  return value;
13
27
  };
14
28
 
15
- const decimal = (value: string, name: string): string => {
16
- if (!DECIMAL.test(value)) throw new TypeError(`${name} must be a canonical non-negative decimal string`);
29
+ export const validateDecimal = (value: unknown, name: string): string => {
30
+ if (typeof value !== "string" || !DECIMAL.test(value)) {
31
+ throw new TypeError(`${name} must be a canonical non-negative decimal string`);
32
+ }
17
33
  return value;
18
34
  };
19
35
 
20
- export const validateAuthoritativeCharge = (charge: AuthoritativeCharge): AuthoritativeCharge => {
21
- if (charge.kind !== "authoritative") throw new TypeError("provider charge must be authoritative");
22
- decimal(charge.amount.amount, "provider charge amount");
23
- if (!CURRENCY.test(charge.amount.currency)) {
24
- throw new TypeError("provider charge currency must be an uppercase currency code");
36
+ const monetaryAmount = (value: unknown, name: string): { amount: string; currency: string } => {
37
+ const amount = recordOf(value, name);
38
+ const decimalAmount = validateDecimal(amount.amount, `${name} amount`);
39
+ if (typeof amount.currency !== "string" || !CURRENCY.test(amount.currency)) {
40
+ throw new TypeError(`${name} currency must be an uppercase currency code`);
25
41
  }
26
- decimal(charge.usdEquivalent, "provider charge USD equivalent");
27
- nonEmpty(charge.source, "provider charge source");
28
- return charge;
42
+ return { amount: decimalAmount, currency: amount.currency };
29
43
  };
30
44
 
31
- export const validateProviderCost = (cost: ProviderCost): ProviderCost => {
45
+ export const validateChargedCost = (value: unknown): ChargedCost => {
46
+ const cost = recordOf(value, "provider charged cost");
47
+ if (cost.kind !== "charged") throw new TypeError("provider charged cost kind must be charged");
48
+ monetaryAmount(cost.amount, "provider charged cost");
49
+ if (cost.usdEquivalent !== undefined) {
50
+ validateDecimal(cost.usdEquivalent, "provider charged cost USD equivalent");
51
+ }
52
+ nonEmpty(cost.source, "provider charged cost source");
53
+ return value as ChargedCost;
54
+ };
55
+
56
+ export const validateProviderCost = (value: unknown): ProviderCost => {
57
+ const cost = recordOf(value, "provider cost");
32
58
  switch (cost.kind) {
33
- case "authoritative":
34
- return validateAuthoritativeCharge(cost);
59
+ case "charged":
60
+ return validateChargedCost(value);
35
61
  case "estimated":
36
- decimal(cost.usd, "provider cost estimate");
37
- nonEmpty(cost.source, "provider cost estimate source");
38
- return cost;
39
- case "free":
40
- nonEmpty(cost.source, "provider free source");
41
- return cost;
62
+ monetaryAmount(cost.amount, "provider estimated cost");
63
+ nonEmpty(cost.source, "provider estimated cost source");
64
+ return value as ProviderCost;
42
65
  case "unknown":
43
66
  nonEmpty(cost.reason, "provider unknown-cost reason");
44
- return cost;
67
+ return value as ProviderCost;
68
+ default:
69
+ throw new TypeError("provider cost kind must be charged, estimated, or unknown");
45
70
  }
46
71
  };
47
72
 
48
- export const resolveProviderCost = (
49
- charge: AuthoritativeCharge | undefined,
50
- current: ProviderCost | undefined,
73
+ export const estimateProviderCost = (
74
+ usage: ProviderUsage | undefined,
75
+ rates: TokenRates | null,
76
+ source: string,
51
77
  ): ProviderCost => {
52
- if (charge !== undefined) return validateAuthoritativeCharge(charge);
53
- if (current !== undefined) return validateProviderCost(current);
54
- return {
55
- kind: "unknown",
56
- reason: "the response reported no cost and Models.dev has no rate for this model",
57
- };
78
+ if (usage === undefined) {
79
+ return { kind: "unknown", reason: "the provider response reported no normalized usage" };
80
+ }
81
+ if (rates === null) {
82
+ return { kind: "unknown", reason: "Models.dev has no complete rate for this model" };
83
+ }
84
+ const usd = calculateCostUsdDecimal(usage, rates);
85
+ return usd === null
86
+ ? {
87
+ kind: "unknown",
88
+ reason: "the provider response omitted a token category with a distinct Models.dev rate",
89
+ }
90
+ : {
91
+ kind: "estimated",
92
+ amount: { amount: usd, currency: "USD" },
93
+ source,
94
+ };
58
95
  };
59
96
 
60
- export const providerCostUsd = (cost: ProviderCost): number | null => {
61
- const value = cost.kind === "authoritative"
62
- ? cost.usdEquivalent
63
- : cost.kind === "estimated"
64
- ? cost.usd
65
- : cost.kind === "free"
66
- ? "0"
67
- : null;
68
- return value === null ? null : Number(value);
97
+ export const resolveProviderCost = (
98
+ direct: ProviderCost | undefined,
99
+ estimated: ProviderCost,
100
+ ): ProviderCost => direct === undefined
101
+ ? validateProviderCost(estimated)
102
+ : validateProviderCost(direct);
103
+
104
+ export const providerCostUsd = (cost: ProviderCost): string | null => {
105
+ const validated = validateProviderCost(cost);
106
+ switch (validated.kind) {
107
+ case "charged":
108
+ return validated.amount.currency === "USD"
109
+ ? validated.amount.amount
110
+ : validated.usdEquivalent ?? null;
111
+ case "estimated":
112
+ return validated.amount.currency === "USD" ? validated.amount.amount : null;
113
+ case "unknown":
114
+ return null;
115
+ }
69
116
  };
70
117
 
71
- export const providerCostFor = (
72
- provider: { calculateCharge?(usage: ProviderUsage): Exclude<ProviderCost, AuthoritativeCharge>; calculateCost(usage: ProviderUsage): number },
73
- usage: ProviderUsage,
74
- charge?: AuthoritativeCharge,
75
- ): ProviderCost => resolveProviderCost(
76
- charge,
77
- provider.calculateCharge?.(usage),
78
- );
118
+ const decimalParts = (value: string): { coefficient: bigint; scale: number } => {
119
+ validateDecimal(value, "decimal amount");
120
+ const [integer, fraction = ""] = value.split(".");
121
+ return { coefficient: BigInt(`${integer}${fraction}`), scale: fraction.length };
122
+ };
123
+
124
+ export const addDecimals = (values: readonly string[]): string => {
125
+ const parts = values.map(decimalParts);
126
+ const scale = Math.max(0, ...parts.map((part) => part.scale));
127
+ const coefficient = parts.reduce(
128
+ (sum, part) => sum + part.coefficient * 10n ** BigInt(scale - part.scale),
129
+ 0n,
130
+ );
131
+ return canonicalDecimal(coefficient, scale);
132
+ };
133
+
134
+ export const sumProviderCostsUsd = (costs: readonly ProviderCost[]): string | null => {
135
+ const values = costs.map(providerCostUsd);
136
+ return values.some((value) => value === null)
137
+ ? null
138
+ : addDecimals(values as string[]);
139
+ };
@@ -4,17 +4,27 @@ import { withProviderDefaults } from "./defaults.ts";
4
4
 
5
5
  test("withProviderDefaults supplies the package-owned operational floor", () => {
6
6
  const env = withProviderDefaults({});
7
- assert.equal(env.PLURNK_PROVIDERS_PROMPT_CACHE_KEY, "1");
7
+ assert.equal(env.PLURNK_PROVIDERS_CACHE_AFFINITY, "1");
8
+ assert.equal(env.PLURNK_PROVIDERS_CACHE_WRITE_POLICY, "stable-system");
9
+ assert.equal(env.PLURNK_PROVIDERS_OPERATION_TIMEOUT, "2700000");
8
10
  assert.equal(env.PLURNK_PROVIDERS_FETCH_TIMEOUT, "600000");
11
+ assert.equal(env.PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT, "600000");
12
+ assert.equal(env.PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT, "120000");
9
13
  assert.equal(env.PLURNK_PROVIDERS_RETRY_ATTEMPTS, "3");
10
14
  assert.equal(env.PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT, "512");
11
15
  });
12
16
 
13
17
  test("withProviderDefaults preserves every explicit operator value", () => {
14
18
  const env = withProviderDefaults({
15
- PLURNK_PROVIDERS_PROMPT_CACHE_KEY: "malformed",
19
+ PLURNK_PROVIDERS_CACHE_AFFINITY: "malformed",
20
+ PLURNK_PROVIDERS_CACHE_WRITE_POLICY: "off",
21
+ PLURNK_PROVIDERS_OPERATION_TIMEOUT: "84",
16
22
  PLURNK_PROVIDERS_FETCH_TIMEOUT: "42",
23
+ PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT: "21",
17
24
  });
18
- assert.equal(env.PLURNK_PROVIDERS_PROMPT_CACHE_KEY, "malformed");
25
+ assert.equal(env.PLURNK_PROVIDERS_CACHE_AFFINITY, "malformed");
26
+ assert.equal(env.PLURNK_PROVIDERS_CACHE_WRITE_POLICY, "off");
27
+ assert.equal(env.PLURNK_PROVIDERS_OPERATION_TIMEOUT, "84");
19
28
  assert.equal(env.PLURNK_PROVIDERS_FETCH_TIMEOUT, "42");
29
+ assert.equal(env.PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT, "21");
20
30
  });
package/src/env.test.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import test from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
- import { parseRequiredInt, parseOptionalInt, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv } from "./env.ts";
3
+ import {
4
+ cacheAffinityFromEnv,
5
+ cacheWritePolicyFromEnv,
6
+ parseRequiredInt,
7
+ parseOptionalInt,
8
+ parseTimeoutMs,
9
+ requireEnv,
10
+ reasoningFromEnv,
11
+ reasoningResponseStyleFromEnv,
12
+ } from "./env.ts";
4
13
 
5
14
  test("parseRequiredInt: parses a non-negative integer", () => {
6
15
  assert.equal(parseRequiredInt("600000", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), 600000);
@@ -18,6 +27,15 @@ test("parseRequiredInt: rejects non-numeric, fractional, and negative values", (
18
27
  assert.throws(() => parseRequiredInt("-1", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), /must be a non-negative integer \(got "-1"\)/);
19
28
  });
20
29
 
30
+ test("parseTimeoutMs accepts disabled deadlines and rejects timer overflow", () => {
31
+ assert.equal(parseTimeoutMs("0", "PLURNK_PROVIDERS_OPERATION_TIMEOUT", "openai"), 0);
32
+ assert.equal(parseTimeoutMs("2147483647", "PLURNK_PROVIDERS_OPERATION_TIMEOUT", "openai"), 2_147_483_647);
33
+ assert.throws(
34
+ () => parseTimeoutMs("2147483648", "PLURNK_PROVIDERS_OPERATION_TIMEOUT", "openai"),
35
+ /must be at most 2147483647 milliseconds/,
36
+ );
37
+ });
38
+
21
39
  test("parseOptionalInt: absent → null, present → integer", () => {
22
40
  assert.equal(parseOptionalInt(undefined, "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), null);
23
41
  assert.equal(parseOptionalInt("", "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), null);
@@ -29,13 +47,13 @@ test("parseOptionalInt: rejects fractional and negative values", () => {
29
47
  assert.throws(() => parseOptionalInt("-8", "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), /must be a non-negative integer/);
30
48
  });
31
49
 
32
- test("reasoningFromEnv: activation modes parse; budget required IFF on; fail-hard on everything else", () => {
50
+ test("reasoningFromEnv: activation is independent from an optional explicit budget", () => {
33
51
  assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "off" }, "openai"), { mode: "off", budget: null });
34
52
  assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "adaptive" }, "openai"), { mode: "adaptive", budget: null });
53
+ assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on" }, "openai"), { mode: "on", budget: null });
35
54
  assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on", PLURNK_PROVIDERS_REASONING_BUDGET: "4096" }, "openai"), { mode: "on", budget: 4096 });
36
55
  assert.throws(() => reasoningFromEnv({}, "openai"), /PLURNK_PROVIDERS_REASONING must be set/);
37
56
  assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "8192" }, "openai"), /must be one of "off", "adaptive", "on"/); // the old numeric habit fails loudly
38
- assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on" }, "openai"), /PLURNK_PROVIDERS_REASONING_BUDGET must be set when/);
39
57
  assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on", PLURNK_PROVIDERS_REASONING_BUDGET: "0" }, "openai"), /positive integer/);
40
58
  assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on", PLURNK_PROVIDERS_REASONING_BUDGET: "1.5" }, "openai"), /positive integer/);
41
59
  });
@@ -59,6 +77,31 @@ test("requireEnv: returns the value or throws a named error", () => {
59
77
  assert.throws(() => requireEnv("", "GROQ_API_KEY", "groq"), /must be set/);
60
78
  });
61
79
 
80
+ test("cache policy keeps cost-neutral affinity separate from paid cache writes", () => {
81
+ assert.equal(cacheAffinityFromEnv({ PLURNK_PROVIDERS_CACHE_AFFINITY: "1" }, "openai"), true);
82
+ assert.equal(cacheAffinityFromEnv({ PLURNK_PROVIDERS_CACHE_AFFINITY: "0" }, "openai"), false);
83
+ assert.equal(cacheWritePolicyFromEnv({ PLURNK_PROVIDERS_CACHE_WRITE_POLICY: "stable-system" }, "anthropic"), "stable-system");
84
+ assert.equal(cacheWritePolicyFromEnv({ PLURNK_PROVIDERS_CACHE_WRITE_POLICY: "off" }, "anthropic"), "off");
85
+ assert.throws(
86
+ () => cacheAffinityFromEnv({ PLURNK_PROVIDERS_CACHE_AFFINITY: "auto" }, "openai"),
87
+ /PLURNK_PROVIDERS_CACHE_AFFINITY must be "0" or "1"/,
88
+ );
89
+ assert.throws(
90
+ () => cacheWritePolicyFromEnv({ PLURNK_PROVIDERS_CACHE_WRITE_POLICY: "everything" }, "anthropic"),
91
+ /PLURNK_PROVIDERS_CACHE_WRITE_POLICY must be "off" or "stable-system"/,
92
+ );
93
+ });
94
+
95
+ test("the generic prompt-cache-key knob is retired rather than retained as a compatibility path", () => {
96
+ assert.throws(
97
+ () => cacheAffinityFromEnv({
98
+ PLURNK_PROVIDERS_PROMPT_CACHE_KEY: "1",
99
+ PLURNK_PROVIDERS_CACHE_AFFINITY: "1",
100
+ }, "fireworks"),
101
+ /PLURNK_PROVIDERS_PROMPT_CACHE_KEY was renamed to PLURNK_PROVIDERS_CACHE_AFFINITY/,
102
+ );
103
+ });
104
+
62
105
  // — per-alias knob scoping (per-alias scoping doctrine, user 2026-07-03) —
63
106
 
64
107
  test("scopeEnvToAlias: suffixed knob wins, bare is the fallback, other aliases ignored", async () => {
@@ -86,10 +129,16 @@ test("scopeEnvToAlias: aliases with underscores resolve; a bare knob is never mi
86
129
  const env = {
87
130
  PLURNK_PROVIDERS_FETCH_TIMEOUT: "600000",
88
131
  PLURNK_PROVIDERS_FETCH_TIMEOUT_my_box: "5000",
132
+ PLURNK_PROVIDERS_OPERATION_TIMEOUT: "2700000",
133
+ PLURNK_PROVIDERS_OPERATION_TIMEOUT_my_box: "15000",
134
+ PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT: "600000",
135
+ PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT_my_box: "2500",
89
136
  PLURNK_PROVIDERS_REASONING: "off",
90
137
  PLURNK_PROVIDERS_REASONING_BUDGET: "4096", // bare budget — NOT a "_capacity" alias override of REASONING
91
138
  } as NodeJS.ProcessEnv;
92
139
  assert.equal(scopeEnvToAlias(env, "my_box").PLURNK_PROVIDERS_FETCH_TIMEOUT, "5000");
140
+ assert.equal(scopeEnvToAlias(env, "my_box").PLURNK_PROVIDERS_OPERATION_TIMEOUT, "15000");
141
+ assert.equal(scopeEnvToAlias(env, "my_box").PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT, "2500");
93
142
  assert.equal(scopeEnvToAlias(env, "budget").PLURNK_PROVIDERS_REASONING, "off"); // collision guard
94
143
  });
95
144
 
@@ -175,11 +224,11 @@ test("still-set old THINKING names fail hard with the rename pointer", () => {
175
224
  );
176
225
  });
177
226
 
178
- test("the shipped floor activates reasoning by default (adaptive)", async () => {
227
+ test("the shipped floor defers reasoning posture to the provider by default (adaptive)", async () => {
179
228
  const { readFileSync } = await import("node:fs");
180
229
  const defaults = readFileSync(new URL("../.env.defaults", import.meta.url), "utf8");
181
230
  assert.ok(defaults.includes("PLURNK_PROVIDERS_REASONING=adaptive"), "floor must ship REASONING=adaptive");
182
- assert.ok(!defaults.match(/^PLURNK_PROVIDERS_REASONING_BUDGET=/m), "no shipped magnitude — budget is on-mode only");
231
+ assert.ok(!defaults.match(/^PLURNK_PROVIDERS_REASONING_BUDGET=/m), "no shipped magnitude — an explicit on-mode budget is optional");
183
232
  });
184
233
 
185
234
  test("the shipped DRY floor is off and claims no universally safe shape", async () => {
package/src/env.ts CHANGED
@@ -8,6 +8,16 @@ export const parseRequiredInt = (raw: string | undefined, name: string, label: s
8
8
  return n;
9
9
  };
10
10
 
11
+ export const MAX_PROVIDER_TIMEOUT_MS = 2_147_483_647;
12
+
13
+ export const parseTimeoutMs = (raw: string | undefined, name: string, label: string): number => {
14
+ const timeoutMs = parseRequiredInt(raw, name, label);
15
+ if (timeoutMs > MAX_PROVIDER_TIMEOUT_MS) {
16
+ throw new Error(`${label} provider: ${name} must be at most ${MAX_PROVIDER_TIMEOUT_MS} milliseconds (got "${raw}")`);
17
+ }
18
+ return timeoutMs;
19
+ };
20
+
11
21
  export const parseOptionalInt = (raw: string | undefined, name: string, label: string): number | null => {
12
22
  if (raw === undefined || raw.length === 0) return null;
13
23
  const n = Number(raw);
@@ -34,15 +44,6 @@ export const requireEnv = (raw: string | undefined, name: string, label: string)
34
44
  return raw;
35
45
  };
36
46
 
37
- export const promptCacheKeyFromEnv = (env: NodeJS.ProcessEnv, label: string): boolean => {
38
- const name = "PLURNK_PROVIDERS_PROMPT_CACHE_KEY";
39
- const value = env[name];
40
- if (value !== "0" && value !== "1") {
41
- throw new Error(`${label} provider: ${name} must be "0" or "1"`);
42
- }
43
- return value === "1";
44
- };
45
-
46
47
  // {§provider-configuration} A still-set retired knob fails
47
48
  // hard pointing at its successor — never silently coexists with the new floor.
48
49
  // The retired names appear ONLY as this function's ARGUMENTS at the call sites
@@ -52,6 +53,27 @@ const shedRenamed = (env: NodeJS.ProcessEnv, oldName: string, newName: string, l
52
53
  if (stale !== undefined && stale.length > 0) throw new Error(`${label} provider: ${oldName} was renamed to ${newName} (${ref}); update the env`);
53
54
  };
54
55
 
56
+ export type CacheWritePolicy = "off" | "stable-system";
57
+
58
+ export const cacheAffinityFromEnv = (env: NodeJS.ProcessEnv, label: string): boolean => {
59
+ const name = "PLURNK_PROVIDERS_CACHE_AFFINITY";
60
+ shedRenamed(env, "PLURNK_PROVIDERS_PROMPT_CACHE_KEY", name, label, "{§provider-cache-affinity}"); // lexicon-allow
61
+ const value = env[name];
62
+ if (value !== "0" && value !== "1") {
63
+ throw new Error(`${label} provider: ${name} must be "0" or "1"`);
64
+ }
65
+ return value === "1";
66
+ };
67
+
68
+ export const cacheWritePolicyFromEnv = (env: NodeJS.ProcessEnv, label: string): CacheWritePolicy => {
69
+ const name = "PLURNK_PROVIDERS_CACHE_WRITE_POLICY";
70
+ const value = env[name];
71
+ if (value !== "off" && value !== "stable-system") {
72
+ throw new Error(`${label} provider: ${name} must be "off" or "stable-system"`);
73
+ }
74
+ return value;
75
+ };
76
+
55
77
  // {§provider-evidence} Data-capture knobs are read identically by every provider
56
78
  // (standard AND
57
79
  // plugin) so the opt-in surface is one source of truth. Both OFF by default —
@@ -79,8 +101,8 @@ export const contextWindowFromEnv = (env: NodeJS.ProcessEnv, label: string): num
79
101
  return parseOptionalInt(env.PLURNK_PROVIDERS_CONTEXT_WINDOW, "PLURNK_PROVIDERS_CONTEXT_WINDOW", label);
80
102
  };
81
103
 
82
- // {§model-fact-resolution} — an operator value caps known model physics and
83
- // declares the window only when no natural value is known.
104
+ // {§model-fact-resolution} — an operator value caps known natural model
105
+ // capacity and declares the envelope only when no natural value is known.
84
106
  export function effectiveContextWindow(operatorCap: number | null, naturalWindow: number): number;
85
107
  export function effectiveContextWindow(operatorCap: number | null, naturalWindow: number | null): number | null;
86
108
  export function effectiveContextWindow(operatorCap: number | null, naturalWindow: number | null): number | null {
@@ -92,10 +114,10 @@ export function effectiveContextWindow(operatorCap: number | null, naturalWindow
92
114
  }
93
115
 
94
116
  // {§provider-generation-envelope} How much of a
95
- // DETECTED context window is reserved for reasoning and for completion — the
117
+ // effective context window is reserved for reasoning and for completion — the
96
118
  // remainder (minus the consumer's own packing-safety margin) is the prompt
97
- // budget. Provider-owned: the window is a provider fact and these are amounts OF
98
- // it. Each knob accepts a percentage of the window
119
+ // gauge. Provider-owned: the window is the effective hard envelope and these are
120
+ // amounts OF it. Each knob accepts a percentage of the window
99
121
  // ("10%") or an absolute token count ("4096"); the floor ships percentages so
100
122
  // every window-advertising endpoint (llama-server n_ctx, the plurnk.ai router,
101
123
  // a cataloged cloud model) arrives at sane defaults with ZERO operator tuning.
@@ -144,8 +166,8 @@ export const resolveEnvelopeFromEnv = (env: NodeJS.ProcessEnv, window: number |
144
166
  // {§provider-configuration} The side-channel reasoning knobs — activation and budget
145
167
  // are separate vars, so a numeric budget can never silently flip wire flags:
146
168
  // PLURNK_PROVIDERS_REASONING off | adaptive | on (REQUIRED, fail-hard)
147
- // PLURNK_PROVIDERS_REASONING_BUDGET positive int, REQUIRED iff REASONING=on —
148
- // the magnitude for tier/budget mapping. On llama-server it is the explicit
169
+ // PLURNK_PROVIDERS_REASONING_BUDGET optional positive int when REASONING=on —
170
+ // an explicit magnitude for tier/budget mapping. On llama-server it is the
149
171
  // request-scoped allowance and cannot exceed the physical reasoning reserve.
150
172
  // The provider maps intent to the backend's mechanism; the consumer states
151
173
  // intent, never mechanism. PLAN is a separate public intended-goals record.
@@ -177,7 +199,7 @@ export const reasoningFromEnv = (env: NodeJS.ProcessEnv, label: string): Reasoni
177
199
  if (raw !== "on") return { mode: raw, budget: null };
178
200
  const capName = "PLURNK_PROVIDERS_REASONING_BUDGET";
179
201
  const capRaw = env[capName];
180
- if (capRaw === undefined || capRaw.length === 0) throw new Error(`${label} provider: ${capName} must be set when ${name}=on`);
202
+ if (capRaw === undefined || capRaw.length === 0) return { mode: "on", budget: null };
181
203
  const n = Number(capRaw);
182
204
  if (!Number.isInteger(n) || n <= 0) throw new Error(`${label} provider: ${capName} must be a positive integer (got "${capRaw}")`);
183
205
  return { mode: "on", budget: n };
@@ -199,14 +221,17 @@ export const PROVIDERS_KNOBS = Object.freeze([
199
221
  "PLURNK_PROVIDERS_CONTEXT_WINDOW",
200
222
  "PLURNK_PROVIDERS_RETRY_ATTEMPTS",
201
223
  "PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT",
224
+ "PLURNK_PROVIDERS_OPERATION_TIMEOUT",
202
225
  "PLURNK_PROVIDERS_FETCH_TIMEOUT",
226
+ "PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT",
203
227
  "PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT",
204
228
  "PLURNK_PROVIDERS_LLAMA_SERVER",
205
229
  "PLURNK_PROVIDERS_TEMPERATURE",
206
230
  "PLURNK_PROVIDERS_REPEAT_PENALTY",
207
231
  "PLURNK_PROVIDERS_FREQUENCY_PENALTY",
208
232
  "PLURNK_PROVIDERS_SERVICE_TIER",
209
- "PLURNK_PROVIDERS_PROMPT_CACHE_KEY",
233
+ "PLURNK_PROVIDERS_CACHE_WRITE_POLICY",
234
+ "PLURNK_PROVIDERS_CACHE_AFFINITY",
210
235
  "PLURNK_PROVIDERS_REPEAT_LAST_N",
211
236
  "PLURNK_PROVIDERS_DRY_MULTIPLIER",
212
237
  "PLURNK_PROVIDERS_DRY_BASE",