@plurnk/plurnk-providers 1.6.1 → 1.8.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 (99) hide show
  1. package/.env.defaults +25 -13
  2. package/README.md +13 -4
  3. package/SPEC.md +104 -22
  4. package/dist/AiSdkProvider.d.ts +6 -3
  5. package/dist/AiSdkProvider.d.ts.map +1 -1
  6. package/dist/AiSdkProvider.js +108 -51
  7. package/dist/AiSdkProvider.js.map +1 -1
  8. package/dist/Mock.d.ts +1 -0
  9. package/dist/Mock.d.ts.map +1 -1
  10. package/dist/Mock.js +2 -0
  11. package/dist/Mock.js.map +1 -1
  12. package/dist/Pool.d.ts +2 -0
  13. package/dist/Pool.d.ts.map +1 -1
  14. package/dist/Pool.js +3 -0
  15. package/dist/Pool.js.map +1 -1
  16. package/dist/ProviderRegistry.d.ts.map +1 -1
  17. package/dist/ProviderRegistry.js +11 -10
  18. package/dist/ProviderRegistry.js.map +1 -1
  19. package/dist/accounting.d.ts.map +1 -1
  20. package/dist/accounting.js +7 -6
  21. package/dist/accounting.js.map +1 -1
  22. package/dist/aiSdkTransport.d.ts +2 -1
  23. package/dist/aiSdkTransport.d.ts.map +1 -1
  24. package/dist/aiSdkTransport.js +29 -6
  25. package/dist/aiSdkTransport.js.map +1 -1
  26. package/dist/catalogProvider.d.ts +4 -1
  27. package/dist/catalogProvider.d.ts.map +1 -1
  28. package/dist/catalogProvider.js +94 -3
  29. package/dist/catalogProvider.js.map +1 -1
  30. package/dist/compatibleProvider.d.ts.map +1 -1
  31. package/dist/compatibleProvider.js +2 -0
  32. package/dist/compatibleProvider.js.map +1 -1
  33. package/dist/cost.d.ts.map +1 -1
  34. package/dist/cost.js +5 -4
  35. package/dist/cost.js.map +1 -1
  36. package/dist/discover.d.ts +2 -0
  37. package/dist/discover.d.ts.map +1 -1
  38. package/dist/discover.js +13 -2
  39. package/dist/discover.js.map +1 -1
  40. package/dist/env.d.ts +3 -2
  41. package/dist/env.d.ts.map +1 -1
  42. package/dist/env.js +11 -4
  43. package/dist/env.js.map +1 -1
  44. package/dist/errors.d.ts +5 -23
  45. package/dist/errors.d.ts.map +1 -1
  46. package/dist/errors.js +2 -90
  47. package/dist/errors.js.map +1 -1
  48. package/dist/index.d.ts +9 -5
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +6 -3
  51. package/dist/index.js.map +1 -1
  52. package/dist/notices.d.ts +1 -1
  53. package/dist/notices.d.ts.map +1 -1
  54. package/dist/openai.d.ts +1 -1
  55. package/dist/openai.d.ts.map +1 -1
  56. package/dist/openai.js +1 -1
  57. package/dist/openai.js.map +1 -1
  58. package/dist/providerError.d.ts +25 -0
  59. package/dist/providerError.d.ts.map +1 -0
  60. package/dist/providerError.js +91 -0
  61. package/dist/providerError.js.map +1 -0
  62. package/dist/sdkModels.d.ts +2 -0
  63. package/dist/sdkModels.d.ts.map +1 -1
  64. package/dist/sdkModels.js +163 -19
  65. package/dist/sdkModels.js.map +1 -1
  66. package/dist/types.d.ts +8 -2
  67. package/dist/types.d.ts.map +1 -1
  68. package/dist/types.js +10 -1
  69. package/dist/types.js.map +1 -1
  70. package/package.json +15 -10
  71. package/src/AiSdkProvider.test.ts +206 -32
  72. package/src/AiSdkProvider.ts +140 -54
  73. package/src/Mock.ts +2 -0
  74. package/src/Pool.test.ts +1 -0
  75. package/src/Pool.ts +5 -0
  76. package/src/ProviderRegistry.test.ts +27 -14
  77. package/src/ProviderRegistry.ts +19 -10
  78. package/src/accounting.test.ts +6 -2
  79. package/src/accounting.ts +7 -6
  80. package/src/aiSdkTransport.ts +32 -7
  81. package/src/boundaries.test.ts +27 -15
  82. package/src/catalogProvider.test.ts +151 -19
  83. package/src/catalogProvider.ts +125 -3
  84. package/src/compatibleProvider.test.ts +13 -10
  85. package/src/compatibleProvider.ts +2 -0
  86. package/src/cost.ts +5 -4
  87. package/src/discover.test.ts +27 -0
  88. package/src/discover.ts +20 -3
  89. package/src/env.test.ts +23 -8
  90. package/src/env.ts +17 -8
  91. package/src/errors.ts +5 -136
  92. package/src/index.ts +16 -8
  93. package/src/notices.ts +1 -1
  94. package/src/openai.ts +1 -1
  95. package/src/providerDefaults.test.ts +50 -0
  96. package/src/providerError.ts +139 -0
  97. package/src/sdkModels.test.ts +142 -8
  98. package/src/sdkModels.ts +201 -19
  99. package/src/types.ts +16 -0
@@ -182,6 +182,7 @@ export const compatibleProviderFromEnv = async (
182
182
  }
183
183
  const envelope = generationEnvelopeFromEnv(env, provider, contextWindow, null);
184
184
  const reasoning = reasoningFromEnv(env, provider, envelope.reasoningBudget);
185
+ const supportedReasoningPolicies = ["off", "adaptive"] as const;
185
186
  return new AiSdkProvider({
186
187
  model,
187
188
  url,
@@ -191,6 +192,7 @@ export const compatibleProviderFromEnv = async (
191
192
  maxOutputTokens: null,
192
193
  outputBudget: envelope.outputBudget,
193
194
  reasoningBudget: reasoning.budget,
195
+ supportedReasoningPolicies,
194
196
  fetchTimeoutMs: timeout,
195
197
  operationTimeoutMs: parseTimeoutMs(env.PLURNK_PROVIDERS_OPERATION_TIMEOUT, "PLURNK_PROVIDERS_OPERATION_TIMEOUT", provider),
196
198
  firstContentTimeoutMs: parseTimeoutMs(env.PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT, "PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT", provider),
package/src/cost.ts CHANGED
@@ -132,8 +132,9 @@ export const addDecimals = (values: readonly string[]): string => {
132
132
  };
133
133
 
134
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[]);
135
+ // {§tokenomics-provider-usage} — a request without USD-expressible cost
136
+ // (an uncataloged model, or a response-less failure) is skipped; it never
137
+ // erases the expressible evidence. Null only when nothing is expressible.
138
+ const values = costs.map(providerCostUsd).filter((value): value is string => value !== null);
139
+ return values.length === 0 ? null : addDecimals(values);
139
140
  };
@@ -47,6 +47,33 @@ test("discover: a provider package missing plurnk.name is ignored, not crashed",
47
47
  assert.deepEqual([...registry.keys()], ["named"]);
48
48
  });
49
49
 
50
+ test("{§provider-grammar-transport} discover: the manifest grammarStyle declaration is recorded and validated", async (t) => {
51
+ const root = await buildModules(t, {
52
+ "@acme/llamacpp-rail": {
53
+ name: "@acme/llamacpp-rail",
54
+ plurnk: { kind: "provider", name: "rail", grammarStyle: "llamacpp" },
55
+ },
56
+ "@acme/plain": {
57
+ name: "@acme/plain",
58
+ plurnk: { kind: "provider", name: "plain" },
59
+ },
60
+ });
61
+ const { grammarStyles } = await discover({ cwd: root });
62
+ assert.equal(grammarStyles.get("rail"), "llamacpp");
63
+ assert.equal(grammarStyles.get("plain"), "none");
64
+ assert.equal(grammarStyles.get("absent"), undefined);
65
+ });
66
+
67
+ test("{§provider-grammar-transport} discover: an invalid grammarStyle fails loudly, never guessing", async (t) => {
68
+ const root = await buildModules(t, {
69
+ "@acme/bad-grammar": {
70
+ name: "@acme/bad-grammar",
71
+ plurnk: { kind: "provider", name: "bad", grammarStyle: "guff" },
72
+ },
73
+ });
74
+ await assert.rejects(discover({ cwd: root }), /grammarStyle must be "none" or "llamacpp"/);
75
+ });
76
+
50
77
  test("discover: an array kind claims no provider family", async (t) => {
51
78
  const root = await buildModules(t, {
52
79
  "@acme/dual": {
package/src/discover.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  PluginAttribution,
7
7
  PluginAttributionDeclaration,
8
8
  } from "@plurnk/plurnk-meta";
9
+ import type { GrammarStyle } from "./AiSdkProvider.ts";
9
10
 
10
11
  // Scope-agnostic discovery of installed AI SDK provider packages
11
12
  // ({§plugin-family-kind}).
@@ -40,6 +41,9 @@ export type Discovery = {
40
41
  // Published name-keyed projection retained for 1.x consumers.
41
42
  attributions: Map<string, string | string[]>;
42
43
  packageAttributions: PackageAttributions;
44
+ // {§provider-grammar-transport} — plugin-declared constrained-decoding
45
+ // capability per provider name; "none" unless the manifest declares one.
46
+ grammarStyles: Map<string, GrammarStyle>;
43
47
  };
44
48
 
45
49
 
@@ -51,6 +55,7 @@ export const discover = async (options: DiscoverOptions = {}): Promise<Discovery
51
55
  const skipped = new Map<string, string>();
52
56
  const attributions = new Map<string, PluginAttributionDeclaration>();
53
57
  const packageAttributions = new Map<string, PluginAttribution>();
58
+ const grammarStyles = new Map<string, GrammarStyle>();
54
59
  for (const dir of dirs) {
55
60
  const info = await readProviderInfo(dir);
56
61
  if (info === null) continue;
@@ -67,11 +72,12 @@ export const discover = async (options: DiscoverOptions = {}): Promise<Discovery
67
72
  }
68
73
  const tags = Meta.normalizeAttribution(info.attribution, info.packageName);
69
74
  registry.set(info.name, info.packageName);
75
+ grammarStyles.set(info.name, info.grammarStyle);
70
76
  const attribution = attributionProjection(info.attribution, tags);
71
77
  if (attribution !== undefined) attributions.set(info.name, attribution);
72
78
  if (tags.length > 0) packageAttributions.set(info.packageName, tags);
73
79
  }
74
- return { registry, skipped, attributions, packageAttributions };
80
+ return { registry, skipped, attributions, packageAttributions, grammarStyles };
75
81
  };
76
82
 
77
83
  // Enumerate every installed package directory — scoped and unscoped — under
@@ -84,7 +90,7 @@ const defaultPackageDirs = async (cwd: string): Promise<string[]> => {
84
90
  // One inert manifest record for a provider package, or null for anything that
85
91
  // isn't one. Attribution remains unknown until trust admission, then the shared
86
92
  // {§plugin-attribution} boundary validates it.
87
- type ProviderInfo = { name: string; packageName: string; attribution: unknown };
93
+ type ProviderInfo = { name: string; packageName: string; attribution: unknown; grammarStyle: GrammarStyle };
88
94
 
89
95
  const readProviderInfo = async (dir: string): Promise<ProviderInfo | null> => {
90
96
  let raw: string;
@@ -107,7 +113,18 @@ const readProviderInfo = async (dir: string): Promise<ProviderInfo | null> => {
107
113
  if (!Meta.declaresKind(plurnkRec, "provider")) return null;
108
114
  if (typeof plurnkRec.name !== "string" || plurnkRec.name === "") return null;
109
115
  if (typeof record.name !== "string" || record.name === "") return null;
110
- return { name: plurnkRec.name, packageName: record.name, attribution: plurnkRec.attribution };
116
+ const grammarStyle = plurnkRec.grammarStyle;
117
+ if (grammarStyle !== undefined && grammarStyle !== "none" && grammarStyle !== "llamacpp") {
118
+ throw new Error(
119
+ `${record.name}: plurnk.grammarStyle must be "none" or "llamacpp", got ${JSON.stringify(grammarStyle)}.`,
120
+ );
121
+ }
122
+ return {
123
+ name: plurnkRec.name,
124
+ packageName: record.name,
125
+ attribution: plurnkRec.attribution,
126
+ grammarStyle: grammarStyle === undefined ? "none" : grammarStyle,
127
+ };
111
128
  };
112
129
 
113
130
  const attributionProjection = (
package/src/env.test.ts CHANGED
@@ -48,14 +48,14 @@ test("parseOptionalInt: rejects fractional and negative values", () => {
48
48
  assert.throws(() => parseOptionalInt("-8", "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), /must be a non-negative integer/);
49
49
  });
50
50
 
51
- test("reasoningFromEnv: activation is independent from an optional explicit budget", () => {
51
+ test("reasoningFromEnv: durable policy is independent from an optional explicit budget", () => {
52
52
  assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "off" }, "openai"), { mode: "off", budget: null });
53
53
  assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "adaptive" }, "openai"), { mode: "adaptive", budget: null });
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 });
54
+ assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "high" }, "openai"), { mode: "high", budget: null });
55
+ assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "high" }, "openai", 4096), { mode: "high", budget: 4096 });
56
56
  assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "adaptive" }, "openai", 4096), { mode: "adaptive", budget: 4096 });
57
57
  assert.throws(() => reasoningFromEnv({}, "openai"), /PLURNK_PROVIDERS_REASONING must be set/);
58
- assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "8192" }, "openai"), /must be one of "off", "adaptive", "on"/); // the old numeric habit fails loudly
58
+ assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "8192" }, "openai"), /must be one of "off", "adaptive", "low", "medium", "high"/); // the old numeric habit fails loudly
59
59
  });
60
60
 
61
61
  test("{§provider-tagged-reasoning} response style is explicit and invalid values fail at the provider boundary", () => {
@@ -108,7 +108,7 @@ test("scopeEnvToAlias: suffixed knob wins, bare is the fallback, other aliases i
108
108
  const { scopeEnvToAlias } = await import("./env.ts");
109
109
  const env = {
110
110
  PLURNK_PROVIDERS_REASONING: "off",
111
- PLURNK_PROVIDERS_REASONING_turboderp: "on",
111
+ PLURNK_PROVIDERS_REASONING_turboderp: "high",
112
112
  PLURNK_PROVIDERS_REASONING_BUDGET_TURBODERP: "4096", // case-folds like PLURNK_MODEL_ keys
113
113
  PLURNK_PROVIDERS_REASONING_RESPONSE_STYLE_TURBODERP: "think-tags",
114
114
  PLURNK_PROVIDERS_CONTEXT_WINDOW_turboderp: "8000",
@@ -116,7 +116,7 @@ test("scopeEnvToAlias: suffixed knob wins, bare is the fallback, other aliases i
116
116
  PLURNK_PROVIDERS_CONTEXT_WINDOW_other: "1",
117
117
  } as NodeJS.ProcessEnv;
118
118
  const scoped = scopeEnvToAlias(env, "turboderp");
119
- assert.equal(scoped.PLURNK_PROVIDERS_REASONING, "on");
119
+ assert.equal(scoped.PLURNK_PROVIDERS_REASONING, "high");
120
120
  assert.equal(scoped.PLURNK_PROVIDERS_REASONING_BUDGET, "4096");
121
121
  assert.equal(scoped.PLURNK_PROVIDERS_REASONING_RESPONSE_STYLE, "think-tags");
122
122
  assert.equal(scoped.PLURNK_PROVIDERS_CONTEXT_WINDOW, "8000");
@@ -185,7 +185,7 @@ test("scopeEnvToAlias: a caller-supplied knob list scopes consumer-owned vars",
185
185
  assert.equal(cloud.PLURNK_SERVICE_LOOP_TIMEOUT, "16384"); // 64k envelope untouched by gemma overrides
186
186
  assert.equal(cloud.PLURNK_SERVICE_EXEC_HOLD_MS, "49152");
187
187
  // custom list does NOT scope providers-family knobs (closed-list isolation both ways)
188
- const mixed = scopeEnvToAlias({ PLURNK_PROVIDERS_REASONING: "off", PLURNK_PROVIDERS_REASONING_turboderp: "on" } as NodeJS.ProcessEnv, "turboderp", SERVICE_KNOBS);
188
+ const mixed = scopeEnvToAlias({ PLURNK_PROVIDERS_REASONING: "off", PLURNK_PROVIDERS_REASONING_turboderp: "high" } as NodeJS.ProcessEnv, "turboderp", SERVICE_KNOBS);
189
189
  assert.equal(mixed.PLURNK_PROVIDERS_REASONING, "off");
190
190
  });
191
191
 
@@ -224,11 +224,26 @@ test("still-set old THINKING names fail hard with the rename pointer", () => {
224
224
  );
225
225
  });
226
226
 
227
+ test("reasoning policy accepts only the exact portable durable vocabulary", () => {
228
+ for (const mode of ["off", "adaptive", "low", "medium", "high"] as const) {
229
+ assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: mode }, "openai"), {
230
+ mode,
231
+ budget: null,
232
+ });
233
+ }
234
+ for (const retired of ["on", "minimal", "xhigh"]) {
235
+ assert.throws(
236
+ () => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: retired }, "openai"),
237
+ /must be one of "off", "adaptive", "low", "medium", "high"/,
238
+ );
239
+ }
240
+ });
241
+
227
242
  test("the shipped floor defers reasoning posture to the provider by default (adaptive)", async () => {
228
243
  const { readFileSync } = await import("node:fs");
229
244
  const defaults = readFileSync(new URL("../.env.defaults", import.meta.url), "utf8");
230
245
  assert.ok(defaults.includes("PLURNK_PROVIDERS_REASONING=adaptive"), "floor must ship REASONING=adaptive");
231
- assert.ok(!defaults.match(/^PLURNK_PROVIDERS_REASONING_BUDGET=/m), "no shipped magnitude — an explicit on-mode budget is optional");
246
+ assert.ok(!defaults.match(/^PLURNK_PROVIDERS_REASONING_BUDGET=/m), "no shipped magnitude — provider-adaptive depth remains unpinned");
232
247
  });
233
248
 
234
249
  test("the shipped DRY floor is off and claims no universally safe shape", async () => {
package/src/env.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // Env-parsing helpers shared by provider construction. `label` keeps failures
2
2
  // local to the selected provider.
3
3
 
4
+ import { REASONING_POLICIES, Validator, type ReasoningPolicy } from "@plurnk/plurnk-contracts";
5
+
4
6
  export const parseRequiredInt = (raw: string | undefined, name: string, label: string): number => {
5
7
  if (raw === undefined || raw.length === 0) throw new Error(`${label} provider: ${name} must be set`);
6
8
  const n = Number(raw);
@@ -231,15 +233,22 @@ export const resolveGenerationEnvelopeFromEnv = (
231
233
  );
232
234
  };
233
235
 
234
- // {§provider-configuration} The side-channel reasoning knobs — activation and budget
235
- // are separate vars, so a numeric budget can never silently flip wire flags:
236
- // PLURNK_PROVIDERS_REASONING off | adaptive | on (REQUIRED, fail-hard)
236
+ // {§provider-configuration} The side-channel reasoning knobs — policy and budget
237
+ // are separate vars, so a numeric budget can never silently select an effort:
238
+ // PLURNK_PROVIDERS_REASONING off | adaptive | low | medium | high (REQUIRED, fail-hard)
237
239
  // PLURNK_PROVIDERS_REASONING_BUDGET optional reasoning subset of the total
238
240
  // output budget, used for tier/budget mapping where the backend supports it.
239
241
  // The provider maps intent to the backend's mechanism; the consumer states
240
242
  // intent, never mechanism. PLAN is a separate public intended-goals record.
241
- export type ReasoningMode = "off" | "adaptive" | "on";
242
- export type Reasoning = { mode: ReasoningMode; budget: number | null };
243
+ export type Reasoning = { mode: ReasoningPolicy; budget: number | null };
244
+
245
+ export const parseReasoningPolicy = (value: unknown, label: string): ReasoningPolicy => {
246
+ const result = Validator.validateReasoningPolicy(value);
247
+ if (!result.valid) {
248
+ throw new Error(`${label} must be one of ${REASONING_POLICIES.map((policy) => `"${policy}"`).join(", ")} (got "${String(value)}")`);
249
+ }
250
+ return value as ReasoningPolicy;
251
+ };
243
252
 
244
253
  export type ReasoningResponseStyle = "verbatim" | "think-tags";
245
254
 
@@ -265,9 +274,9 @@ export const reasoningFromEnv = (
265
274
  shedRenamed(env, "PLURNK_PROVIDERS_THINKING_CAPACITY", "PLURNK_PROVIDERS_REASONING_BUDGET", label, "provider configuration contract"); // lexicon-allow
266
275
  const name = "PLURNK_PROVIDERS_REASONING";
267
276
  const raw = env[name];
268
- if (raw === undefined || raw.length === 0) throw new Error(`${label} provider: ${name} must be set (off | adaptive | on)`);
269
- if (raw !== "off" && raw !== "adaptive" && raw !== "on") throw new Error(`${label} provider: ${name} must be one of "off", "adaptive", "on" (got "${raw}")`);
270
- return { mode: raw, budget: raw === "off" ? null : resolvedBudget };
277
+ if (raw === undefined || raw.length === 0) throw new Error(`${label} provider: ${name} must be set (${REASONING_POLICIES.join(" | ")})`);
278
+ const mode = parseReasoningPolicy(raw, `${label} provider: ${name}`);
279
+ return { mode, budget: mode === "off" ? null : resolvedBudget };
271
280
  };
272
281
 
273
282
  // ── Per-alias knob scoping (per-alias scoping doctrine, user 2026-07-03): PLURNK_PROVIDERS_<KNOB>[_<alias>] ──
package/src/errors.ts CHANGED
@@ -1,19 +1,10 @@
1
- import { Problems, type ProblemDetails } from "@plurnk/plurnk-contracts";
2
1
  import { APICallError, RetryError } from "ai";
3
- import { providerSource } from "./notices.ts";
4
- import type { ProviderAttempt, ProviderRequestAccounting, ProviderRequestCapacity } from "./types.ts";
2
+ import { ProviderError } from "./providerError.ts";
3
+ import type { ProviderErrorKind } from "./providerError.ts";
4
+ import type { ProviderRequestCapacity } from "./types.ts";
5
5
 
6
- export type ProviderErrorKind =
7
- | "rate_limit"
8
- | "network_failure"
9
- | "deadline_exceeded"
10
- | "model_refused"
11
- | "invalid_response"
12
- | "unauthorized"
13
- | "quota_exceeded"
14
- | "grammar_invalid"
15
- | "capacity_exceeded"
16
- | "resource_interrupted";
6
+ export { ProviderError } from "./providerError.ts";
7
+ export type { ProviderErrorKind } from "./providerError.ts";
17
8
 
18
9
  export interface ClassifiedProviderError {
19
10
  kind: ProviderErrorKind;
@@ -55,128 +46,6 @@ export const providerTimeoutOf = (error: unknown): ProviderTimeoutError | null =
55
46
  return null;
56
47
  };
57
48
 
58
- const defaultStatus = (kind: ProviderErrorKind): number => {
59
- switch (kind) {
60
- case "unauthorized": return 401;
61
- case "quota_exceeded": return 402;
62
- case "capacity_exceeded": return 413;
63
- case "rate_limit": return 429;
64
- case "model_refused":
65
- case "grammar_invalid": return 422;
66
- case "invalid_response": return 502;
67
- case "deadline_exceeded": return 504;
68
- case "network_failure":
69
- case "resource_interrupted": return 503;
70
- }
71
- };
72
-
73
- const retryable = (kind: ProviderErrorKind): boolean => {
74
- switch (kind) {
75
- case "rate_limit":
76
- case "network_failure":
77
- return true;
78
- case "deadline_exceeded":
79
- case "invalid_response":
80
- case "grammar_invalid":
81
- case "capacity_exceeded":
82
- case "resource_interrupted":
83
- case "model_refused":
84
- case "unauthorized":
85
- case "quota_exceeded":
86
- return false;
87
- }
88
- };
89
-
90
- const buildProblem = (
91
- source: string,
92
- kind: ProviderErrorKind,
93
- message: string,
94
- status: number,
95
- extensions: Readonly<Record<string, unknown>>,
96
- retryableOverride: boolean | undefined,
97
- ): ProblemDetails => {
98
- const code: Record<ProviderErrorKind, string> = {
99
- rate_limit: "rate-limit",
100
- network_failure: "network-failure",
101
- deadline_exceeded: "deadline-exceeded",
102
- model_refused: "model-refused",
103
- invalid_response: "invalid-response",
104
- unauthorized: "unauthorized",
105
- quota_exceeded: "quota-exceeded",
106
- grammar_invalid: "grammar-invalid",
107
- capacity_exceeded: "capacity-exceeded",
108
- resource_interrupted: "resource-interrupted",
109
- };
110
- return Problems.create(source, code[kind], status, message, {
111
- providerKind: kind,
112
- stage: "provider-request",
113
- retryable: retryableOverride ?? retryable(kind),
114
- ...extensions,
115
- });
116
- };
117
-
118
- // A provider operation failed before a completed exchange existed. An
119
- // interrupted response may still carry attempt evidence for its consumer.
120
- // The standardized Problem is the public failure contract; kind remains the
121
- // provider pool's routing discriminator and is repeated as a Problem extension.
122
- export class ProviderError extends Error {
123
- readonly source: string;
124
- readonly kind: ProviderErrorKind;
125
- readonly problem: ProblemDetails;
126
- readonly attempt?: ProviderAttempt;
127
- readonly capacity?: ProviderRequestCapacity;
128
- #accounting: ProviderRequestAccounting[];
129
-
130
- constructor(
131
- source: string,
132
- kind: ProviderErrorKind,
133
- message: string,
134
- options: {
135
- status?: number | null;
136
- cause?: unknown;
137
- retryable?: boolean;
138
- extensions?: Readonly<Record<string, unknown>>;
139
- attempt?: ProviderAttempt;
140
- accounting?: readonly ProviderRequestAccounting[];
141
- capacity?: ProviderRequestCapacity;
142
- } = {},
143
- ) {
144
- super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
145
- this.name = "ProviderError";
146
- this.source = providerSource(source);
147
- this.kind = kind;
148
- this.attempt = options.attempt;
149
- this.capacity = options.capacity ?? options.attempt?.capacity;
150
- this.#accounting = [...(options.accounting ?? options.attempt?.accounting ?? [])];
151
- const status = options.status !== null && options.status !== undefined
152
- && Number.isInteger(options.status) && options.status >= 400 && options.status <= 599
153
- ? options.status
154
- : defaultStatus(kind);
155
- this.problem = buildProblem(
156
- this.source,
157
- kind,
158
- message,
159
- status,
160
- options.extensions ?? {},
161
- options.retryable,
162
- );
163
- }
164
-
165
- get status(): number {
166
- return this.problem.status;
167
- }
168
-
169
- get accounting(): readonly ProviderRequestAccounting[] {
170
- return this.#accounting;
171
- }
172
-
173
- // A capacity pool adds the already-settled requests from prior backends as
174
- // the same failure crosses that orchestration boundary.
175
- prependAccounting(accounting: readonly ProviderRequestAccounting[]): void {
176
- if (accounting.length > 0) this.#accounting = [...accounting, ...this.#accounting];
177
- }
178
- }
179
-
180
49
  const wireError = (body: string): { type: string | null; code: string | null; message: string | null } => {
181
50
  try {
182
51
  const { error } = JSON.parse(body) as { error?: { type?: unknown } };
package/src/index.ts CHANGED
@@ -29,32 +29,40 @@ export type {
29
29
  export { assertPromptTokenMeasurement } from "./promptTokens.ts";
30
30
  export { assessRequestCapacity, effectiveInputCapacity, effectiveOutputBudget, requestCapacityDecision } from "./capacity.ts";
31
31
 
32
- // Alias cascade — re-exported from the zero-dep @plurnk/plurnk-aliases, so
33
- // the "." surface is unchanged for existing importers and there's one source of
34
- // truth for the parser (thin clients depend on that package directly).
35
- export type { ProviderAlias } from "@plurnk/plurnk-aliases";
36
- export { parseAliasesFromEnv, resolveActiveAlias } from "@plurnk/plurnk-aliases";
32
+ // Selector and alias parsing stay runtime-free in @plurnk/plurnk-aliases;
33
+ // ModelRoute is the contracts-owned client wire shape; ProviderSpec is the
34
+ // daemon-private construction identity that may retain an endpoint override.
35
+ export type { ModelRoute, ProviderAlias, ProviderSpec } from "@plurnk/plurnk-aliases";
36
+ export {
37
+ parseAliasesFromEnv,
38
+ resolveActiveRoute,
39
+ resolveModelSelector,
40
+ } from "@plurnk/plurnk-aliases";
37
41
 
38
42
  export {
39
43
  instantiateProvider,
40
44
  loadActiveProvider,
41
45
  resetDiscoveryCache,
42
46
  } from "./ProviderRegistry.ts";
47
+ export { providerReadiness } from "./sdkModels.ts";
43
48
 
44
49
  // Scope-agnostic plugin discovery ({§plugin-family-kind}).
45
50
  export { discover } from "./discover.ts";
46
51
  export type { DiscoverOptions, Discovery } from "./discover.ts";
47
52
 
48
53
  // Stable PLURNK adapter over AI SDK language models and compatible local URLs.
49
- export { default as AiSdkProvider, effortFromBudget } from "./AiSdkProvider.ts";
54
+ export { default as AiSdkProvider } from "./AiSdkProvider.ts";
50
55
  export type { AiSdkProviderConfig, ReasoningStyle, GrammarStyle } from "./AiSdkProvider.ts";
51
56
  // {§provider-capacity-pool} Front N interchangeable backends as one Provider -
52
57
  // worker-sticky for KV-cache reuse, overflow to a healthy sibling; the blend
53
58
  // DECISION stays the consumer's, by choosing which pool to call.
54
59
  export { default as Pool } from "./Pool.ts";
55
60
  export type { ProviderFetch } from "./AiSdkProvider.ts";
56
- export { parseRequiredInt, parseOptionalInt, parseRequiredFloat, parseOptionalFloat, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv, scopeEnvToAlias, dataCaptureFromEnv, contextWindowFromEnv, effectiveContextWindow, generationEnvelopeFromEnv, resolveGenerationEnvelopeFromEnv, resolveTokenBudget, PROVIDERS_KNOBS } from "./env.ts";
57
- export type { GenerationEnvelope, Reasoning, ReasoningMode, ReasoningResponseStyle, TokenBudgetSpec } from "./env.ts";
61
+ export { parseRequiredInt, parseOptionalInt, parseRequiredFloat, parseOptionalFloat, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv, parseReasoningPolicy, scopeEnvToAlias, dataCaptureFromEnv, contextWindowFromEnv, effectiveContextWindow, generationEnvelopeFromEnv, resolveGenerationEnvelopeFromEnv, resolveTokenBudget, PROVIDERS_KNOBS } from "./env.ts";
62
+ export type { GenerationEnvelope, Reasoning, ReasoningResponseStyle, TokenBudgetSpec } from "./env.ts";
63
+ export { REASONING_POLICIES } from "@plurnk/plurnk-contracts";
64
+ export type { ReasoningPolicy } from "@plurnk/plurnk-contracts";
65
+ export { UnsupportedReasoningPolicyError } from "./types.ts";
58
66
  export { normalizeUsage, calculateCostUsdDecimal, validateProviderUsage } from "./usage.ts";
59
67
  export {
60
68
  addDecimals,
package/src/notices.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ProviderNoticeKind = "grammar_unenforced";
1
+ export type ProviderNoticeKind = "grammar_unenforced" | "provider_warning";
2
2
 
3
3
  // Observations about a completed model exchange. These never represent a
4
4
  // failed provider operation; transport failures throw ProviderError with an
package/src/openai.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { default as AiSdkProvider, effortFromBudget } from "./AiSdkProvider.ts";
1
+ export { default as AiSdkProvider } from "./AiSdkProvider.ts";
2
2
  export type { AiSdkProviderConfig, GrammarStyle, ProviderFetch, ReasoningStyle } from "./AiSdkProvider.ts";
3
3
  export type {
4
4
  ChatMessage,
@@ -0,0 +1,50 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { lookupProvider, providerIdMap } from "@plurnk/plurnk-models";
6
+
7
+ const defaultsPath = fileURLToPath(new URL("../.env.defaults", import.meta.url));
8
+ const declarations = readFileSync(defaultsPath, "utf8")
9
+ .split("\n")
10
+ .map((line) => line.trim())
11
+ .filter((line) => line.length > 0 && !line.startsWith("#"))
12
+ .map((line) => /^([A-Z0-9_]+)=(.*)$/.exec(line))
13
+ .filter((match): match is RegExpExecArray => match !== null)
14
+ .map((match) => ({ key: match[1]!, value: match[2]! }));
15
+
16
+ const providerFact = (key: string): { provider: string; fact: string } | null => {
17
+ const match = /^PLURNK_PROVIDERS_PROVIDER_([A-Z0-9_]+)_(NPM|BASE_URL|API_KEY_ENV)$/.exec(key);
18
+ return match === null ? null : { provider: match[1]!.toLowerCase(), fact: match[2]! };
19
+ };
20
+
21
+ test("{§provider-fact-authority} package defaults never redefine cataloged provider facts", () => {
22
+ const ids = providerIdMap();
23
+ for (const { key, value } of declarations) {
24
+ const fact = providerFact(key);
25
+ if (fact === null) continue;
26
+ const catalogId = ids[fact.provider] ?? fact.provider;
27
+ assert.equal(
28
+ lookupProvider(catalogId),
29
+ null,
30
+ `package default '${key}=${value}' redefines a Models.dev-cataloged provider fact (${catalogId})`,
31
+ );
32
+ }
33
+ });
34
+
35
+ test("{§provider-fact-authority} package defaults never ship ordered credential fallbacks", () => {
36
+ for (const { key, value } of declarations) {
37
+ if (!key.endsWith("_API_KEY_ENV")) continue;
38
+ assert.ok(
39
+ /^[A-Z0-9_]+$/.test(value),
40
+ `package default '${key}' must hold one exact environment name, got '${value}'`,
41
+ );
42
+ }
43
+ });
44
+
45
+ test("{§openrouter-app-attribution} the shipped floor identifies the public Plurnk application", () => {
46
+ const values = new Map(declarations.map(({ key, value }) => [key, value]));
47
+ assert.equal(values.get("OPENROUTER_HTTP_REFERER"), "https://github.com/plurnk/plurnk-service");
48
+ assert.equal(values.get("OPENROUTER_APP_TITLE"), "Plurnk");
49
+ assert.equal(values.has("OPENROUTER_X_TITLE"), false);
50
+ });