@plurnk/plurnk-providers 1.7.0 → 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 (88) hide show
  1. package/.env.defaults +25 -13
  2. package/README.md +8 -1
  3. package/SPEC.md +93 -15
  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/index.d.ts +9 -5
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +6 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/notices.d.ts +1 -1
  49. package/dist/notices.d.ts.map +1 -1
  50. package/dist/openai.d.ts +1 -1
  51. package/dist/openai.d.ts.map +1 -1
  52. package/dist/openai.js +1 -1
  53. package/dist/openai.js.map +1 -1
  54. package/dist/sdkModels.d.ts +2 -0
  55. package/dist/sdkModels.d.ts.map +1 -1
  56. package/dist/sdkModels.js +163 -19
  57. package/dist/sdkModels.js.map +1 -1
  58. package/dist/types.d.ts +8 -2
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/types.js +10 -1
  61. package/dist/types.js.map +1 -1
  62. package/package.json +9 -9
  63. package/src/AiSdkProvider.test.ts +206 -32
  64. package/src/AiSdkProvider.ts +140 -54
  65. package/src/Mock.ts +2 -0
  66. package/src/Pool.test.ts +1 -0
  67. package/src/Pool.ts +5 -0
  68. package/src/ProviderRegistry.test.ts +27 -14
  69. package/src/ProviderRegistry.ts +19 -10
  70. package/src/accounting.test.ts +6 -2
  71. package/src/accounting.ts +7 -6
  72. package/src/aiSdkTransport.ts +32 -7
  73. package/src/catalogProvider.test.ts +151 -19
  74. package/src/catalogProvider.ts +125 -3
  75. package/src/compatibleProvider.test.ts +13 -10
  76. package/src/compatibleProvider.ts +2 -0
  77. package/src/cost.ts +5 -4
  78. package/src/discover.test.ts +27 -0
  79. package/src/discover.ts +20 -3
  80. package/src/env.test.ts +23 -8
  81. package/src/env.ts +17 -8
  82. package/src/index.ts +16 -8
  83. package/src/notices.ts +1 -1
  84. package/src/openai.ts +1 -1
  85. package/src/providerDefaults.test.ts +50 -0
  86. package/src/sdkModels.test.ts +142 -8
  87. package/src/sdkModels.ts +201 -19
  88. package/src/types.ts +16 -0
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/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
+ });
@@ -1,18 +1,106 @@
1
1
  import test from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
- import { configuredProviderInfo, createSdkModel } from "./sdkModels.ts";
3
+ import { configuredProviderInfo, createSdkModel, providerReadiness } from "./sdkModels.ts";
4
4
 
5
- test("configuredProviderInfo translates one env declaration into provider facts", () => {
5
+ test("{§provider-fact-authority} one env declaration holds one credential name", () => {
6
6
  assert.deepEqual(configuredProviderInfo("acme-cloud", {
7
7
  PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_NPM: "@ai-sdk/openai-compatible",
8
8
  PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_BASE_URL: "https://api.acme.test/v1",
9
- PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_API_KEY_ENV: "ACME_API_KEY, ACME_TOKEN",
9
+ PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_API_KEY_ENV: "ACME_API_KEY",
10
10
  }), {
11
11
  id: "acme-cloud",
12
+ name: "acme-cloud",
12
13
  npm: "@ai-sdk/openai-compatible",
13
- env: ["ACME_API_KEY", "ACME_TOKEN"],
14
+ env: ["ACME_API_KEY"],
14
15
  api: "https://api.acme.test/v1",
15
16
  });
17
+ assert.throws(
18
+ () => configuredProviderInfo("acme-cloud", {
19
+ PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_NPM: "@ai-sdk/openai-compatible",
20
+ PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_API_KEY_ENV: "ACME_API_KEY,ACME_TOKEN",
21
+ }),
22
+ /one exact name, never an ordered fallback/,
23
+ );
24
+ });
25
+
26
+ test("{§model-catalog-readiness}: readiness uses the construction credential and endpoint requirements without exposing values", () => {
27
+ assert.deepEqual(providerReadiness("google", {}), {
28
+ ready: false,
29
+ causes: [{
30
+ kind: "credential",
31
+ alternatives: [["GOOGLE_API_KEY"], ["GOOGLE_GENERATIVE_AI_API_KEY"], ["GEMINI_API_KEY"]],
32
+ }],
33
+ });
34
+ assert.deepEqual(providerReadiness("google", { GEMINI_API_KEY: "secret-value" }), {
35
+ ready: true,
36
+ causes: [],
37
+ });
38
+ assert.doesNotMatch(JSON.stringify(providerReadiness("google", { GEMINI_API_KEY: "secret-value" })), /secret-value/);
39
+
40
+ assert.deepEqual(providerReadiness("cloudflare", { CLOUDFLARE_API_KEY: "key" }), {
41
+ ready: false,
42
+ causes: [{
43
+ kind: "configuration",
44
+ alternatives: [["CLOUDFLARE_ACCOUNT_ID"]],
45
+ }],
46
+ });
47
+ assert.deepEqual(providerReadiness("cloudflare", {
48
+ CLOUDFLARE_ACCOUNT_ID: "account",
49
+ CLOUDFLARE_API_KEY: "key",
50
+ }), { ready: true, causes: [] });
51
+ });
52
+
53
+ test("{§model-catalog-readiness}: Bedrock reports its actual alternative authentication sets and region requirement", () => {
54
+ assert.deepEqual(providerReadiness("bedrock", {}), {
55
+ ready: false,
56
+ causes: [{
57
+ kind: "credential",
58
+ alternatives: [
59
+ ["AWS_BEARER_TOKEN_BEDROCK", "AWS_REGION"],
60
+ ["AWS_BEARER_TOKEN_BEDROCK", "AWS_DEFAULT_REGION"],
61
+ ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
62
+ ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_DEFAULT_REGION"],
63
+ ],
64
+ }],
65
+ });
66
+ assert.deepEqual(providerReadiness("bedrock", {
67
+ AWS_BEARER_TOKEN_BEDROCK: "bearer",
68
+ AWS_REGION: "us-east-1",
69
+ }), { ready: true, causes: [] });
70
+ assert.deepEqual(providerReadiness("bedrock", {
71
+ AWS_ACCESS_KEY_ID: "access",
72
+ AWS_SECRET_ACCESS_KEY: "secret",
73
+ }), {
74
+ ready: false,
75
+ causes: [{
76
+ kind: "configuration",
77
+ alternatives: [["AWS_REGION"], ["AWS_DEFAULT_REGION"]],
78
+ }],
79
+ });
80
+ });
81
+
82
+ test("{§model-catalog-readiness}: operator-declared unauthenticated compatible endpoints are ready without an invented credential", () => {
83
+ assert.deepEqual(providerReadiness("acme", {
84
+ PLURNK_PROVIDERS_PROVIDER_ACME_NPM: "@ai-sdk/openai-compatible",
85
+ PLURNK_PROVIDERS_PROVIDER_ACME_BASE_URL: "http://127.0.0.1:9000/v1",
86
+ }), { ready: true, causes: [] });
87
+ });
88
+
89
+ test("{§model-catalog-readiness}: an authenticated operator declaration reports its missing credential-name configuration", () => {
90
+ const env = {
91
+ PLURNK_PROVIDERS_PROVIDER_ACME_NPM: "@ai-sdk/openai",
92
+ };
93
+ assert.deepEqual(providerReadiness("acme", env), {
94
+ ready: false,
95
+ causes: [{
96
+ kind: "configuration",
97
+ alternatives: [["PLURNK_PROVIDERS_PROVIDER_ACME_API_KEY_ENV"]],
98
+ }],
99
+ });
100
+ assert.throws(
101
+ () => createSdkModel("acme", "model", env),
102
+ /PLURNK_PROVIDERS_PROVIDER_ACME_API_KEY_ENV must be set/,
103
+ );
16
104
  });
17
105
 
18
106
  test("createSdkModel uses Models.dev provider facts and operator credentials", () => {
@@ -35,6 +123,23 @@ test("createSdkModel constructs Cerebras from Models.dev facts", () => {
35
123
  assert.equal(sdk?.compatible, undefined);
36
124
  });
37
125
 
126
+ test("{§openrouter-app-attribution} attribution rejects malformed URLs and the retired title name", () => {
127
+ assert.throws(
128
+ () => createSdkModel("openrouter", "openai/gpt-5", {
129
+ OPENROUTER_API_KEY: "key",
130
+ OPENROUTER_HTTP_REFERER: "plurnk",
131
+ }),
132
+ /OPENROUTER_HTTP_REFERER must be an absolute HTTP\(S\) URL/,
133
+ );
134
+ assert.throws(
135
+ () => createSdkModel("openrouter", "openai/gpt-5", {
136
+ OPENROUTER_API_KEY: "key",
137
+ OPENROUTER_X_TITLE: "Plurnk",
138
+ }),
139
+ /OPENROUTER_X_TITLE was renamed to OPENROUTER_APP_TITLE/,
140
+ );
141
+ });
142
+
38
143
  test("the Google SDK adapter owns its readable-reasoning response projection", () => {
39
144
  assert.deepEqual(
40
145
  createSdkModel("google", "gemini-3.7-flash", { GEMINI_API_KEY: "test-key" })?.reasoningResponseProviderOptions,
@@ -61,20 +166,49 @@ test("createSdkModel attaches DeepInfra's documented response-cost normalizer",
61
166
  });
62
167
  });
63
168
 
64
- test("createSdkModel expands catalog endpoint variables without treating them as credentials", () => {
169
+ test("{§provider-fact-authority} catalog credential names are law without any package or operator alias", () => {
65
170
  const sdk = createSdkModel("cloudflare", "@cf/google/gemma-4-26b-a4b-it", {
66
171
  CLOUDFLARE_ACCOUNT_ID: "account",
67
- CLOUDFLARE_API_TOKEN: "token",
68
- PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_API_KEY_ENV: "CLOUDFLARE_API_TOKEN,CLOUDFLARE_API_KEY",
172
+ CLOUDFLARE_API_KEY: "key",
69
173
  });
70
174
  assert.equal(sdk?.languageModel, undefined);
71
175
  assert.deepEqual(sdk?.compatible, {
72
176
  url: "https://api.cloudflare.com/client/v4/accounts/account/ai/v1/chat/completions",
73
- headers: { Authorization: "Bearer token" },
177
+ headers: { Authorization: "Bearer key" },
74
178
  });
75
179
  assert.deepEqual(sdk?.cacheAffinity, { target: "header", name: "x-session-affinity" });
76
180
  });
77
181
 
182
+ test("{§provider-fact-authority} an operator credential override holds one exact name", () => {
183
+ const sdk = createSdkModel("cloudflare", "@cf/google/gemma-4-26b-a4b-it", {
184
+ CLOUDFLARE_ACCOUNT_ID: "account",
185
+ MY_ORG_CLOUDFLARE_KEY: "key",
186
+ PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_API_KEY_ENV: "MY_ORG_CLOUDFLARE_KEY",
187
+ });
188
+ assert.deepEqual(sdk?.compatible, {
189
+ url: "https://api.cloudflare.com/client/v4/accounts/account/ai/v1/chat/completions",
190
+ headers: { Authorization: "Bearer key" },
191
+ });
192
+ });
193
+
194
+ test("{§provider-fact-authority} ordered credential fallbacks are rejected at construction", () => {
195
+ assert.throws(
196
+ () => createSdkModel("cloudflare", "@cf/google/gemma-4-26b-a4b-it", {
197
+ CLOUDFLARE_ACCOUNT_ID: "account",
198
+ CLOUDFLARE_API_TOKEN: "token",
199
+ PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_API_KEY_ENV: "CLOUDFLARE_API_TOKEN,CLOUDFLARE_API_KEY",
200
+ }),
201
+ /one exact name, never an ordered fallback/,
202
+ );
203
+ assert.throws(
204
+ () => createSdkModel("acme", "model", {
205
+ PLURNK_PROVIDERS_PROVIDER_ACME_NPM: "@ai-sdk/openai-compatible",
206
+ PLURNK_PROVIDERS_PROVIDER_ACME_API_KEY_ENV: "ACME_API_KEY,ACME_TOKEN",
207
+ }),
208
+ /one exact name, never an ordered fallback/,
209
+ );
210
+ });
211
+
78
212
  test("catalog routes own their documented cache-affinity request projection", () => {
79
213
  assert.deepEqual(
80
214
  createSdkModel("openai", "gpt-4.1-mini", { OPENAI_API_KEY: "key" })?.cacheAffinity,