@plurnk/plurnk-providers 1.5.0 → 1.6.1

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 (105) hide show
  1. package/.env.defaults +41 -34
  2. package/README.md +15 -0
  3. package/SPEC.md +242 -89
  4. package/dist/AiSdkProvider.d.ts +33 -33
  5. package/dist/AiSdkProvider.d.ts.map +1 -1
  6. package/dist/AiSdkProvider.js +442 -133
  7. package/dist/AiSdkProvider.js.map +1 -1
  8. package/dist/Mock.d.ts +10 -11
  9. package/dist/Mock.d.ts.map +1 -1
  10. package/dist/Mock.js +87 -25
  11. package/dist/Mock.js.map +1 -1
  12. package/dist/Pool.d.ts +9 -24
  13. package/dist/Pool.d.ts.map +1 -1
  14. package/dist/Pool.js +86 -25
  15. package/dist/Pool.js.map +1 -1
  16. package/dist/accounting.d.ts +5 -2
  17. package/dist/accounting.d.ts.map +1 -1
  18. package/dist/accounting.js +100 -16
  19. package/dist/accounting.js.map +1 -1
  20. package/dist/accountingPublic.d.ts +5 -0
  21. package/dist/accountingPublic.d.ts.map +1 -0
  22. package/dist/accountingPublic.js +3 -0
  23. package/dist/accountingPublic.js.map +1 -0
  24. package/dist/aiSdkTransport.d.ts +9 -2
  25. package/dist/aiSdkTransport.d.ts.map +1 -1
  26. package/dist/aiSdkTransport.js +160 -62
  27. package/dist/aiSdkTransport.js.map +1 -1
  28. package/dist/capacity.d.ts +26 -0
  29. package/dist/capacity.d.ts.map +1 -0
  30. package/dist/capacity.js +90 -0
  31. package/dist/capacity.js.map +1 -0
  32. package/dist/catalogProvider.d.ts +8 -3
  33. package/dist/catalogProvider.d.ts.map +1 -1
  34. package/dist/catalogProvider.js +45 -41
  35. package/dist/catalogProvider.js.map +1 -1
  36. package/dist/compatibleProvider.d.ts.map +1 -1
  37. package/dist/compatibleProvider.js +26 -12
  38. package/dist/compatibleProvider.js.map +1 -1
  39. package/dist/cost.d.ts +10 -10
  40. package/dist/cost.d.ts.map +1 -1
  41. package/dist/cost.js +90 -42
  42. package/dist/cost.js.map +1 -1
  43. package/dist/env.d.ts +13 -11
  44. package/dist/env.d.ts.map +1 -1
  45. package/dist/env.js +83 -46
  46. package/dist/env.js.map +1 -1
  47. package/dist/errors.d.ts +17 -3
  48. package/dist/errors.d.ts.map +1 -1
  49. package/dist/errors.js +91 -8
  50. package/dist/errors.js.map +1 -1
  51. package/dist/index.d.ts +7 -6
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/ollama.js +3 -3
  56. package/dist/ollama.js.map +1 -1
  57. package/dist/promptTokens.d.ts.map +1 -1
  58. package/dist/promptTokens.js +7 -4
  59. package/dist/promptTokens.js.map +1 -1
  60. package/dist/sdkModels.d.ts +7 -2
  61. package/dist/sdkModels.d.ts.map +1 -1
  62. package/dist/sdkModels.js +43 -13
  63. package/dist/sdkModels.js.map +1 -1
  64. package/dist/types.d.ts +55 -33
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/usage.d.ts +22 -5
  67. package/dist/usage.d.ts.map +1 -1
  68. package/dist/usage.js +169 -83
  69. package/dist/usage.js.map +1 -1
  70. package/package.json +18 -7
  71. package/src/AiSdkProvider.test.ts +964 -206
  72. package/src/AiSdkProvider.ts +545 -155
  73. package/src/Mock.test.ts +69 -30
  74. package/src/Mock.ts +99 -29
  75. package/src/Pool.test.ts +90 -19
  76. package/src/Pool.ts +96 -27
  77. package/src/ProviderRegistry.test.ts +16 -11
  78. package/src/accounting.test.ts +58 -22
  79. package/src/accounting.ts +119 -18
  80. package/src/accountingPublic.ts +9 -0
  81. package/src/aiSdkTransport.test.ts +42 -49
  82. package/src/aiSdkTransport.ts +174 -62
  83. package/src/boundaries.test.ts +2 -0
  84. package/src/capacity.test.ts +92 -0
  85. package/src/capacity.ts +140 -0
  86. package/src/catalogProvider.test.ts +339 -30
  87. package/src/catalogProvider.ts +65 -47
  88. package/src/compatibleProvider.test.ts +7 -5
  89. package/src/compatibleProvider.ts +29 -13
  90. package/src/cost.test.ts +86 -36
  91. package/src/cost.ts +111 -50
  92. package/src/defaults.test.ts +13 -3
  93. package/src/env.test.ts +103 -25
  94. package/src/env.ts +153 -65
  95. package/src/errors.test.ts +80 -2
  96. package/src/errors.ts +107 -8
  97. package/src/index.ts +26 -7
  98. package/src/ollama.test.ts +5 -3
  99. package/src/ollama.ts +3 -3
  100. package/src/promptTokens.ts +8 -5
  101. package/src/sdkModels.test.ts +77 -8
  102. package/src/sdkModels.ts +51 -15
  103. package/src/types.ts +112 -51
  104. package/src/usage.test.ts +112 -116
  105. package/src/usage.ts +214 -93
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,16 @@
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
+ generationEnvelopeFromEnv,
7
+ parseRequiredInt,
8
+ parseOptionalInt,
9
+ parseTimeoutMs,
10
+ requireEnv,
11
+ reasoningFromEnv,
12
+ reasoningResponseStyleFromEnv,
13
+ } from "./env.ts";
4
14
 
5
15
  test("parseRequiredInt: parses a non-negative integer", () => {
6
16
  assert.equal(parseRequiredInt("600000", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), 600000);
@@ -18,6 +28,15 @@ test("parseRequiredInt: rejects non-numeric, fractional, and negative values", (
18
28
  assert.throws(() => parseRequiredInt("-1", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), /must be a non-negative integer \(got "-1"\)/);
19
29
  });
20
30
 
31
+ test("parseTimeoutMs accepts disabled deadlines and rejects timer overflow", () => {
32
+ assert.equal(parseTimeoutMs("0", "PLURNK_PROVIDERS_OPERATION_TIMEOUT", "openai"), 0);
33
+ assert.equal(parseTimeoutMs("2147483647", "PLURNK_PROVIDERS_OPERATION_TIMEOUT", "openai"), 2_147_483_647);
34
+ assert.throws(
35
+ () => parseTimeoutMs("2147483648", "PLURNK_PROVIDERS_OPERATION_TIMEOUT", "openai"),
36
+ /must be at most 2147483647 milliseconds/,
37
+ );
38
+ });
39
+
21
40
  test("parseOptionalInt: absent → null, present → integer", () => {
22
41
  assert.equal(parseOptionalInt(undefined, "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), null);
23
42
  assert.equal(parseOptionalInt("", "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), null);
@@ -29,15 +48,14 @@ test("parseOptionalInt: rejects fractional and negative values", () => {
29
48
  assert.throws(() => parseOptionalInt("-8", "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), /must be a non-negative integer/);
30
49
  });
31
50
 
32
- test("reasoningFromEnv: activation modes parse; budget required IFF on; fail-hard on everything else", () => {
51
+ test("reasoningFromEnv: activation is independent from an optional explicit budget", () => {
33
52
  assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "off" }, "openai"), { mode: "off", budget: null });
34
53
  assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "adaptive" }, "openai"), { mode: "adaptive", budget: null });
35
- assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on", PLURNK_PROVIDERS_REASONING_BUDGET: "4096" }, "openai"), { mode: "on", budget: 4096 });
54
+ assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on" }, "openai"), { mode: "on", budget: null });
55
+ assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on" }, "openai", 4096), { mode: "on", budget: 4096 });
56
+ assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "adaptive" }, "openai", 4096), { mode: "adaptive", budget: 4096 });
36
57
  assert.throws(() => reasoningFromEnv({}, "openai"), /PLURNK_PROVIDERS_REASONING must be set/);
37
58
  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
- assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on", PLURNK_PROVIDERS_REASONING_BUDGET: "0" }, "openai"), /positive integer/);
40
- assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on", PLURNK_PROVIDERS_REASONING_BUDGET: "1.5" }, "openai"), /positive integer/);
41
59
  });
42
60
 
43
61
  test("{§provider-tagged-reasoning} response style is explicit and invalid values fail at the provider boundary", () => {
@@ -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 () => {
@@ -69,7 +112,7 @@ test("scopeEnvToAlias: suffixed knob wins, bare is the fallback, other aliases i
69
112
  PLURNK_PROVIDERS_REASONING_BUDGET_TURBODERP: "4096", // case-folds like PLURNK_MODEL_ keys
70
113
  PLURNK_PROVIDERS_REASONING_RESPONSE_STYLE_TURBODERP: "think-tags",
71
114
  PLURNK_PROVIDERS_CONTEXT_WINDOW_turboderp: "8000",
72
- PLURNK_PROVIDERS_COMPLETION_RESERVE_turboderp: "4096",
115
+ PLURNK_PROVIDERS_OUTPUT_BUDGET_turboderp: "4096",
73
116
  PLURNK_PROVIDERS_CONTEXT_WINDOW_other: "1",
74
117
  } as NodeJS.ProcessEnv;
75
118
  const scoped = scopeEnvToAlias(env, "turboderp");
@@ -77,7 +120,7 @@ test("scopeEnvToAlias: suffixed knob wins, bare is the fallback, other aliases i
77
120
  assert.equal(scoped.PLURNK_PROVIDERS_REASONING_BUDGET, "4096");
78
121
  assert.equal(scoped.PLURNK_PROVIDERS_REASONING_RESPONSE_STYLE, "think-tags");
79
122
  assert.equal(scoped.PLURNK_PROVIDERS_CONTEXT_WINDOW, "8000");
80
- assert.equal(scoped.PLURNK_PROVIDERS_COMPLETION_RESERVE, "4096");
123
+ assert.equal(scoped.PLURNK_PROVIDERS_OUTPUT_BUDGET, "4096");
81
124
  assert.equal(scopeEnvToAlias(env, "plain").PLURNK_PROVIDERS_REASONING, "off"); // fallback intact
82
125
  });
83
126
 
@@ -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
 
@@ -122,16 +171,16 @@ test("contextWindowFromEnv: reads the new name, sheds CONTEXT_SIZE hard, null wh
122
171
 
123
172
  test("scopeEnvToAlias: a caller-supplied knob list scopes consumer-owned vars", async () => {
124
173
  const { scopeEnvToAlias } = await import("./env.ts");
125
- const SERVICE_KNOBS = ["PLURNK_SERVICE_MAX_TURNS", "PLURNK_SERVICE_LOOP_TIMEOUT", "PLURNK_SERVICE_EXEC_HOLD_MS", "PLURNK_SERVICE_SAFETY"];
174
+ const SERVICE_KNOBS = ["PLURNK_SERVICE_MAX_TURNS", "PLURNK_SERVICE_LOOP_TIMEOUT", "PLURNK_SERVICE_EXEC_HOLD_MS", "PLURNK_SERVICE_PROMPT_PROJECTION"];
126
175
  const env = {
127
- PLURNK_SERVICE_MAX_TURNS: "163840", PLURNK_SERVICE_LOOP_TIMEOUT: "16384", PLURNK_SERVICE_EXEC_HOLD_MS: "49152", PLURNK_SERVICE_SAFETY: "1024",
176
+ PLURNK_SERVICE_MAX_TURNS: "163840", PLURNK_SERVICE_LOOP_TIMEOUT: "16384", PLURNK_SERVICE_EXEC_HOLD_MS: "49152", PLURNK_SERVICE_PROMPT_PROJECTION: "25%",
128
177
  PLURNK_SERVICE_MAX_TURNS_turboderp: "78848", PLURNK_SERVICE_LOOP_TIMEOUT_turboderp: "4096", PLURNK_SERVICE_EXEC_HOLD_MS_TURBODERP: "8192", // case-folds
129
178
  } as NodeJS.ProcessEnv;
130
179
  const gemma = scopeEnvToAlias(env, "turboderp", SERVICE_KNOBS);
131
180
  assert.equal(gemma.PLURNK_SERVICE_MAX_TURNS, "78848");
132
181
  assert.equal(gemma.PLURNK_SERVICE_LOOP_TIMEOUT, "4096");
133
182
  assert.equal(gemma.PLURNK_SERVICE_EXEC_HOLD_MS, "8192");
134
- assert.equal(gemma.PLURNK_SERVICE_SAFETY, "1024"); // bare fallback intact
183
+ assert.equal(gemma.PLURNK_SERVICE_PROMPT_PROJECTION, "25%"); // bare fallback intact
135
184
  const cloud = scopeEnvToAlias(env, "fireslow", SERVICE_KNOBS);
136
185
  assert.equal(cloud.PLURNK_SERVICE_LOOP_TIMEOUT, "16384"); // 64k envelope untouched by gemma overrides
137
186
  assert.equal(cloud.PLURNK_SERVICE_EXEC_HOLD_MS, "49152");
@@ -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 () => {
@@ -192,23 +241,52 @@ test("the shipped DRY floor is off and claims no universally safe shape", async
192
241
 
193
242
  // -- {§provider-generation-envelope} --
194
243
 
195
- test("envelopeFromEnv: percentages and absolutes parse; missing/invalid fail hard", async () => {
196
- const { envelopeFromEnv } = await import("./env.ts");
244
+ test("generationEnvelopeFromEnv: output is total and reasoning is an optional subset", () => {
245
+ assert.deepEqual(
246
+ generationEnvelopeFromEnv({
247
+ PLURNK_PROVIDERS_OUTPUT_BUDGET: "35%",
248
+ PLURNK_PROVIDERS_REASONING_BUDGET: "4096",
249
+ } as NodeJS.ProcessEnv, "x", 100_000, 32_000),
250
+ { outputBudget: 32_000, reasoningBudget: 4096 },
251
+ );
252
+ assert.throws(() => generationEnvelopeFromEnv({}, "x", 100_000, null), /PLURNK_PROVIDERS_OUTPUT_BUDGET must be set/);
253
+ assert.throws(() => generationEnvelopeFromEnv({ PLURNK_PROVIDERS_OUTPUT_BUDGET: "150%" }, "x", 100_000, null), /percentage must be in \(0, 100\)/);
254
+ assert.throws(() => generationEnvelopeFromEnv({ PLURNK_PROVIDERS_OUTPUT_BUDGET: "-5" }, "x", 100_000, null), /positive integer token count/);
255
+ assert.throws(() => generationEnvelopeFromEnv({ PLURNK_PROVIDERS_OUTPUT_BUDGET: "100000" }, "x", 100_000, null), /must leave positive input capacity/);
256
+ assert.throws(() => generationEnvelopeFromEnv({
257
+ PLURNK_PROVIDERS_OUTPUT_BUDGET: "20%",
258
+ PLURNK_PROVIDERS_REASONING_BUDGET: "25%",
259
+ }, "x", 100_000, null), /reasoning is a subset of total output/);
197
260
  assert.deepEqual(
198
- envelopeFromEnv({ PLURNK_PROVIDERS_REASONING_RESERVE: "10%", PLURNK_PROVIDERS_COMPLETION_RESERVE: "4096" } as NodeJS.ProcessEnv, "x"),
199
- { reasoningReserve: { percent: 0.1 }, completionReserve: { tokens: 4096 } },
261
+ generationEnvelopeFromEnv({ PLURNK_PROVIDERS_OUTPUT_BUDGET: "1%" }, "x", 2, null),
262
+ { outputBudget: 1, reasoningBudget: null },
263
+ "a valid percentage always resolves to at least one whole token",
264
+ );
265
+ assert.throws(
266
+ () => generationEnvelopeFromEnv({ PLURNK_PROVIDERS_OUTPUT_BUDGET: "35%" }, "x", 1, null),
267
+ /must leave positive input capacity/,
200
268
  );
201
- assert.throws(() => envelopeFromEnv({ PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%" } as NodeJS.ProcessEnv, "x"), /PLURNK_PROVIDERS_REASONING_RESERVE must be set/);
202
- assert.throws(() => envelopeFromEnv({ PLURNK_PROVIDERS_REASONING_RESERVE: "150%", PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%" } as NodeJS.ProcessEnv, "x"), /percentage must be in \(0, 100\)/);
203
- assert.throws(() => envelopeFromEnv({ PLURNK_PROVIDERS_REASONING_RESERVE: "-5", PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%" } as NodeJS.ProcessEnv, "x"), /positive integer token count/);
204
269
  });
205
270
 
206
271
  test("envelope knobs are per-alias scopable (measured envelope per box)", async () => {
207
- const { scopeEnvToAlias, envelopeFromEnv } = await import("./env.ts");
272
+ const { scopeEnvToAlias } = await import("./env.ts");
208
273
  const env = {
209
- PLURNK_PROVIDERS_REASONING_RESERVE: "10%", PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%",
210
- PLURNK_PROVIDERS_REASONING_RESERVE_turboderp: "4096", PLURNK_PROVIDERS_COMPLETION_RESERVE_turboderp: "8192",
274
+ PLURNK_PROVIDERS_OUTPUT_BUDGET: "35%",
275
+ PLURNK_PROVIDERS_REASONING_BUDGET: "10%",
276
+ PLURNK_PROVIDERS_OUTPUT_BUDGET_turboderp: "8192",
277
+ PLURNK_PROVIDERS_REASONING_BUDGET_turboderp: "4096",
211
278
  } as NodeJS.ProcessEnv;
212
- assert.deepEqual(envelopeFromEnv(scopeEnvToAlias(env, "turboderp"), "x"), { reasoningReserve: { tokens: 4096 }, completionReserve: { tokens: 8192 } });
213
- assert.deepEqual(envelopeFromEnv(scopeEnvToAlias(env, "jennifer"), "x"), { reasoningReserve: { percent: 0.1 }, completionReserve: { percent: 0.25 } });
279
+ assert.deepEqual(generationEnvelopeFromEnv(scopeEnvToAlias(env, "turboderp"), "x", 49_152, null), { outputBudget: 8192, reasoningBudget: 4096 });
280
+ assert.deepEqual(generationEnvelopeFromEnv(scopeEnvToAlias(env, "jennifer"), "x", 100_000, null), { outputBudget: 35_000, reasoningBudget: 10_000 });
281
+ });
282
+
283
+ test("retired additive reserve knobs fail rather than creating a dual contract", () => {
284
+ assert.throws(() => generationEnvelopeFromEnv({
285
+ PLURNK_PROVIDERS_OUTPUT_BUDGET: "35%",
286
+ PLURNK_PROVIDERS_REASONING_RESERVE: "10%",
287
+ }, "x", 100_000, null), /PLURNK_PROVIDERS_REASONING_RESERVE is retired/);
288
+ assert.throws(() => generationEnvelopeFromEnv({
289
+ PLURNK_PROVIDERS_OUTPUT_BUDGET: "35%",
290
+ PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%",
291
+ }, "x", 100_000, null), /PLURNK_PROVIDERS_COMPLETION_RESERVE is retired/);
214
292
  });