@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
package/src/cost.test.ts CHANGED
@@ -1,61 +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: "legacy calculateCost returned zero without free-cost authority",
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("positive legacy cost remains an estimated USD compatibility result", () => {
49
- assert.deepEqual(providerCostFor({ calculateCost: () => 0.25 }, usage), {
50
- kind: "estimated",
51
- usd: "0.25",
52
- source: "legacy calculateCost",
53
- });
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);
54
74
  });
55
75
 
56
- test("rejects malformed money instead of mining or coercing it", () => {
57
- assert.throws(() => validateAuthoritativeCharge({
58
- kind: "authoritative",
76
+ test("malformed charged money is rejected instead of coerced", () => {
77
+ assert.throws(() => validateChargedCost({
78
+ kind: "charged",
59
79
  amount: { amount: "1e3", currency: "usd" },
60
80
  usdEquivalent: "1000",
61
81
  source: "wire",
package/src/cost.ts CHANGED
@@ -1,83 +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,
51
- legacy: () => number,
73
+ export const estimateProviderCost = (
74
+ usage: ProviderUsage | undefined,
75
+ rates: TokenRates | null,
76
+ source: string,
52
77
  ): ProviderCost => {
53
- if (charge !== undefined) return validateAuthoritativeCharge(charge);
54
- if (current !== undefined) return validateProviderCost(current);
55
- const cost = legacy();
56
- if (!Number.isFinite(cost) || cost < 0) {
57
- throw new TypeError("legacy provider cost must be a finite non-negative number");
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" };
58
83
  }
59
- return cost > 0
60
- ? { kind: "estimated", usd: String(cost), source: "legacy calculateCost" }
61
- : { kind: "unknown", reason: "legacy calculateCost returned zero without free-cost authority" };
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
+ };
62
95
  };
63
96
 
64
- export const providerCostUsd = (cost: ProviderCost): number | null => {
65
- const value = cost.kind === "authoritative"
66
- ? cost.usdEquivalent
67
- : cost.kind === "estimated"
68
- ? cost.usd
69
- : cost.kind === "free"
70
- ? "0"
71
- : null;
72
- 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
+ }
73
116
  };
74
117
 
75
- export const providerCostFor = (
76
- provider: { calculateCharge?(usage: ProviderUsage): Exclude<ProviderCost, AuthoritativeCharge>; calculateCost(usage: ProviderUsage): number },
77
- usage: ProviderUsage,
78
- charge?: AuthoritativeCharge,
79
- ): ProviderCost => resolveProviderCost(
80
- charge,
81
- provider.calculateCharge?.(usage),
82
- () => provider.calculateCost(usage),
83
- );
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, tokenRatesFromEnv } 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,28 +77,28 @@ 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
 
62
- test("tokenRatesFromEnv is all-or-nothing, with cached input defaulting to input", () => {
63
- assert.equal(tokenRatesFromEnv({}, "cloudflare"), null);
64
- assert.deepEqual(tokenRatesFromEnv({
65
- PLURNK_PROVIDERS_INPUT_USD_PER_MILLION: "0.435",
66
- PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION: "0.87",
67
- }, "cloudflare"), {
68
- input: 0.435,
69
- cached: 0.435,
70
- output: 0.87,
71
- });
72
- assert.deepEqual(tokenRatesFromEnv({
73
- PLURNK_PROVIDERS_INPUT_USD_PER_MILLION: "3",
74
- PLURNK_PROVIDERS_CACHE_READ_USD_PER_MILLION: "0.3",
75
- PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION: "15",
76
- }, "cloudflare"), {
77
- input: 3,
78
- cached: 0.3,
79
- output: 15,
80
- });
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", () => {
81
96
  assert.throws(
82
- () => tokenRatesFromEnv({ PLURNK_PROVIDERS_INPUT_USD_PER_MILLION: "1" }, "cloudflare"),
83
- /PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION must be set/,
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/,
84
102
  );
85
103
  });
86
104
 
@@ -111,10 +129,16 @@ test("scopeEnvToAlias: aliases with underscores resolve; a bare knob is never mi
111
129
  const env = {
112
130
  PLURNK_PROVIDERS_FETCH_TIMEOUT: "600000",
113
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",
114
136
  PLURNK_PROVIDERS_REASONING: "off",
115
137
  PLURNK_PROVIDERS_REASONING_BUDGET: "4096", // bare budget — NOT a "_capacity" alias override of REASONING
116
138
  } as NodeJS.ProcessEnv;
117
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");
118
142
  assert.equal(scopeEnvToAlias(env, "budget").PLURNK_PROVIDERS_REASONING, "off"); // collision guard
119
143
  });
120
144
 
@@ -200,11 +224,11 @@ test("still-set old THINKING names fail hard with the rename pointer", () => {
200
224
  );
201
225
  });
202
226
 
203
- 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 () => {
204
228
  const { readFileSync } = await import("node:fs");
205
229
  const defaults = readFileSync(new URL("../.env.defaults", import.meta.url), "utf8");
206
230
  assert.ok(defaults.includes("PLURNK_PROVIDERS_REASONING=adaptive"), "floor must ship REASONING=adaptive");
207
- 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");
208
232
  });
209
233
 
210
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 {
@@ -91,32 +113,11 @@ export function effectiveContextWindow(operatorCap: number | null, naturalWindow
91
113
  : Math.min(operatorCap, naturalWindow);
92
114
  }
93
115
 
94
- export type ProviderTokenRates = { input: number; cached: number; output: number };
95
-
96
- // {§model-fact-resolution} — any explicit rate opts into one complete operator
97
- // estimate; cached input alone may default to the explicit input rate.
98
- export const tokenRatesFromEnv = (env: NodeJS.ProcessEnv, label: string): ProviderTokenRates | null => {
99
- const inputName = "PLURNK_PROVIDERS_INPUT_USD_PER_MILLION";
100
- const cachedName = "PLURNK_PROVIDERS_CACHE_READ_USD_PER_MILLION";
101
- const outputName = "PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION";
102
- const configured = [inputName, cachedName, outputName]
103
- .some((name) => env[name] !== undefined && env[name] !== "");
104
- if (!configured) return null;
105
- const input = parseRequiredFloat(env[inputName], inputName, label, 0);
106
- return {
107
- input,
108
- cached: env[cachedName] === undefined || env[cachedName] === ""
109
- ? input
110
- : parseRequiredFloat(env[cachedName], cachedName, label, 0),
111
- output: parseRequiredFloat(env[outputName], outputName, label, 0),
112
- };
113
- };
114
-
115
116
  // {§provider-generation-envelope} How much of a
116
- // DETECTED context window is reserved for reasoning and for completion — the
117
+ // effective context window is reserved for reasoning and for completion — the
117
118
  // remainder (minus the consumer's own packing-safety margin) is the prompt
118
- // budget. Provider-owned: the window is a provider fact and these are amounts OF
119
- // 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
120
121
  // ("10%") or an absolute token count ("4096"); the floor ships percentages so
121
122
  // every window-advertising endpoint (llama-server n_ctx, the plurnk.ai router,
122
123
  // a cataloged cloud model) arrives at sane defaults with ZERO operator tuning.
@@ -165,8 +166,8 @@ export const resolveEnvelopeFromEnv = (env: NodeJS.ProcessEnv, window: number |
165
166
  // {§provider-configuration} The side-channel reasoning knobs — activation and budget
166
167
  // are separate vars, so a numeric budget can never silently flip wire flags:
167
168
  // PLURNK_PROVIDERS_REASONING off | adaptive | on (REQUIRED, fail-hard)
168
- // PLURNK_PROVIDERS_REASONING_BUDGET positive int, REQUIRED iff REASONING=on —
169
- // 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
170
171
  // request-scoped allowance and cannot exceed the physical reasoning reserve.
171
172
  // The provider maps intent to the backend's mechanism; the consumer states
172
173
  // intent, never mechanism. PLAN is a separate public intended-goals record.
@@ -198,7 +199,7 @@ export const reasoningFromEnv = (env: NodeJS.ProcessEnv, label: string): Reasoni
198
199
  if (raw !== "on") return { mode: raw, budget: null };
199
200
  const capName = "PLURNK_PROVIDERS_REASONING_BUDGET";
200
201
  const capRaw = env[capName];
201
- 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 };
202
203
  const n = Number(capRaw);
203
204
  if (!Number.isInteger(n) || n <= 0) throw new Error(`${label} provider: ${capName} must be a positive integer (got "${capRaw}")`);
204
205
  return { mode: "on", budget: n };
@@ -218,19 +219,19 @@ export const PROVIDERS_KNOBS = Object.freeze([
218
219
  "PLURNK_PROVIDERS_REASONING_BUDGET",
219
220
  "PLURNK_PROVIDERS_REASONING",
220
221
  "PLURNK_PROVIDERS_CONTEXT_WINDOW",
221
- "PLURNK_PROVIDERS_INPUT_USD_PER_MILLION",
222
- "PLURNK_PROVIDERS_CACHE_READ_USD_PER_MILLION",
223
- "PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION",
224
222
  "PLURNK_PROVIDERS_RETRY_ATTEMPTS",
225
223
  "PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT",
224
+ "PLURNK_PROVIDERS_OPERATION_TIMEOUT",
226
225
  "PLURNK_PROVIDERS_FETCH_TIMEOUT",
226
+ "PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT",
227
227
  "PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT",
228
228
  "PLURNK_PROVIDERS_LLAMA_SERVER",
229
229
  "PLURNK_PROVIDERS_TEMPERATURE",
230
230
  "PLURNK_PROVIDERS_REPEAT_PENALTY",
231
231
  "PLURNK_PROVIDERS_FREQUENCY_PENALTY",
232
232
  "PLURNK_PROVIDERS_SERVICE_TIER",
233
- "PLURNK_PROVIDERS_PROMPT_CACHE_KEY",
233
+ "PLURNK_PROVIDERS_CACHE_WRITE_POLICY",
234
+ "PLURNK_PROVIDERS_CACHE_AFFINITY",
234
235
  "PLURNK_PROVIDERS_REPEAT_LAST_N",
235
236
  "PLURNK_PROVIDERS_DRY_MULTIPLIER",
236
237
  "PLURNK_PROVIDERS_DRY_BASE",