@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/sdkModels.ts CHANGED
@@ -9,7 +9,13 @@ import { createOpenAI } from "@ai-sdk/openai";
9
9
  import { createTogetherAI } from "@ai-sdk/togetherai";
10
10
  import { createXai } from "@ai-sdk/xai";
11
11
  import { createOpenRouter } from "@openrouter/ai-sdk-provider";
12
- import { lookupProvider, type ProviderInfo } from "@plurnk/plurnk-models";
12
+ import {
13
+ isProviderCredentialName,
14
+ lookupProvider,
15
+ providerCatalogSnapshot,
16
+ type ProviderInfo,
17
+ } from "@plurnk/plurnk-models";
18
+ import { Validator, type ModelReadiness, type ModelReadinessCause } from "@plurnk/plurnk-contracts";
13
19
  import type { LanguageModel } from "ai";
14
20
  import { providerCostNormalizer } from "./accounting.ts";
15
21
  import type { AiSdkProviderOptions, CacheAffinity } from "./AiSdkProvider.ts";
@@ -30,10 +36,57 @@ export type SdkModel = {
30
36
  };
31
37
 
32
38
  const cacheControl = { type: "ephemeral" as const };
39
+ // The release generator admits only packages implemented by this package.
40
+ // Derive runtime readiness from that same pinned provider projection instead
41
+ // of maintaining a second support list beside the construction switch.
42
+ const supportedSdkPackages = new Set(
43
+ Object.values(providerCatalogSnapshot()).map(({ npm }) => npm),
44
+ );
45
+
46
+ const openRouterHeaders = (
47
+ provider: string,
48
+ env: NodeJS.ProcessEnv,
49
+ catalog: ProviderInfo,
50
+ ): Readonly<Record<string, string>> => {
51
+ if (catalog.id !== "openrouter") return {};
52
+ if (env.OPENROUTER_X_TITLE !== undefined) {
53
+ throw new Error(`${provider} provider: OPENROUTER_X_TITLE was renamed to OPENROUTER_APP_TITLE.`);
54
+ }
55
+ const referer = env.OPENROUTER_HTTP_REFERER?.trim();
56
+ if (referer === undefined || referer === "") return {};
57
+ let parsed: URL;
58
+ try {
59
+ parsed = new URL(referer);
60
+ } catch (cause) {
61
+ throw new Error(`${provider} provider: OPENROUTER_HTTP_REFERER must be an absolute HTTP(S) URL.`, { cause });
62
+ }
63
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
64
+ throw new Error(`${provider} provider: OPENROUTER_HTTP_REFERER must be an absolute HTTP(S) URL.`);
65
+ }
66
+ const title = env.OPENROUTER_APP_TITLE?.trim();
67
+ return {
68
+ "HTTP-Referer": parsed.href,
69
+ ...(title === undefined || title === "" ? {} : { "X-OpenRouter-Title": title }),
70
+ };
71
+ };
33
72
 
34
73
  const envPrefix = (provider: string): string =>
35
74
  provider.replaceAll(/[^a-zA-Z0-9]/g, "_").toUpperCase();
36
75
 
76
+ // {§provider-fact-authority} — one credential declaration holds exactly one
77
+ // name. An ordered fallback list would paper over an operator/catalog naming
78
+ // mismatch; that mismatch belongs at its owning boundary instead.
79
+ const singleCredentialName = (provider: string, value: string): string => {
80
+ const name = value.trim();
81
+ if (name.length === 0) throw new Error(`${provider} provider: API_KEY_ENV must name one environment variable.`);
82
+ if (name.includes(",")) {
83
+ throw new Error(
84
+ `${provider} provider: API_KEY_ENV holds one exact name, never an ordered fallback; reconcile the operator environment with the Models.dev contract instead.`,
85
+ );
86
+ }
87
+ return name;
88
+ };
89
+
37
90
  export const configuredProviderInfo = (
38
91
  provider: string,
39
92
  env: NodeJS.ProcessEnv,
@@ -41,13 +94,14 @@ export const configuredProviderInfo = (
41
94
  const prefix = `PLURNK_PROVIDERS_PROVIDER_${envPrefix(provider)}`;
42
95
  const npm = env[`${prefix}_NPM`];
43
96
  if (npm === undefined || npm.length === 0) return null;
44
- const keyNames = env[`${prefix}_API_KEY_ENV`]
45
- ?.split(",")
46
- .map((name) => name.trim())
47
- .filter(Boolean) ?? [];
97
+ const declared = env[`${prefix}_API_KEY_ENV`];
98
+ const keyNames = declared === undefined
99
+ ? []
100
+ : [singleCredentialName(provider, declared)];
48
101
  const api = env[`${prefix}_BASE_URL`];
49
102
  return {
50
103
  id: provider,
104
+ name: provider,
51
105
  npm,
52
106
  env: keyNames,
53
107
  ...(api === undefined || api.length === 0 ? {} : { api }),
@@ -62,6 +116,11 @@ const firstSet = (env: NodeJS.ProcessEnv, names: readonly string[]): string | un
62
116
  return undefined;
63
117
  };
64
118
 
119
+ const isSet = (env: NodeJS.ProcessEnv, name: string): boolean => {
120
+ const value = env[name];
121
+ return value !== undefined && value.length > 0;
122
+ };
123
+
65
124
  const configuredKeyNames = (
66
125
  provider: string,
67
126
  env: NodeJS.ProcessEnv,
@@ -70,7 +129,125 @@ const configuredKeyNames = (
70
129
  const configured = env[`PLURNK_PROVIDERS_PROVIDER_${envPrefix(provider)}_API_KEY_ENV`];
71
130
  return configured === undefined || configured.length === 0
72
131
  ? catalog.env
73
- : configured.split(",").map((name) => name.trim()).filter(Boolean);
132
+ : [singleCredentialName(provider, configured)];
133
+ };
134
+
135
+ const credentialCandidates = (
136
+ provider: string,
137
+ env: NodeJS.ProcessEnv,
138
+ catalog: ProviderInfo,
139
+ ): readonly string[] => {
140
+ const names = configuredKeyNames(provider, env, catalog);
141
+ const credentials = names.filter(isProviderCredentialName);
142
+ return credentials.length > 0 ? credentials : names;
143
+ };
144
+
145
+ const configuredBaseUrl = (
146
+ provider: string,
147
+ env: NodeJS.ProcessEnv,
148
+ catalog: ProviderInfo,
149
+ override?: string,
150
+ ): string | undefined => {
151
+ const prefix = envPrefix(provider);
152
+ return override
153
+ ?? env[`PLURNK_PROVIDERS_PROVIDER_${prefix}_BASE_URL`]
154
+ ?? env[`${prefix}_BASE_URL`]
155
+ ?? catalog.api;
156
+ };
157
+
158
+ const templateEnvironmentNames = (value: string | undefined): readonly string[] => value === undefined
159
+ ? []
160
+ : [...new Set([...value.matchAll(/\$\{([A-Z0-9_]+)\}/g)].map((match) => match[1]!))];
161
+
162
+ const missingCause = (
163
+ kind: ModelReadinessCause["kind"],
164
+ alternatives: readonly (readonly string[])[],
165
+ ): ModelReadinessCause => ({
166
+ kind,
167
+ alternatives: alternatives.map((alternative) => [...alternative]) as ModelReadinessCause["alternatives"],
168
+ });
169
+
170
+ const bedrockReadinessCauses = (
171
+ env: NodeJS.ProcessEnv,
172
+ hasExplicitBaseUrl: boolean,
173
+ ): ModelReadinessCause[] => {
174
+ const bearer = isSet(env, "AWS_BEARER_TOKEN_BEDROCK");
175
+ const sigv4 = isSet(env, "AWS_ACCESS_KEY_ID") && isSet(env, "AWS_SECRET_ACCESS_KEY");
176
+ const region = isSet(env, "AWS_REGION") || isSet(env, "AWS_DEFAULT_REGION");
177
+ if (bearer) {
178
+ return hasExplicitBaseUrl || region
179
+ ? []
180
+ : [missingCause("configuration", [["AWS_REGION"], ["AWS_DEFAULT_REGION"]])];
181
+ }
182
+ if (sigv4) {
183
+ return region
184
+ ? []
185
+ : [missingCause("configuration", [["AWS_REGION"], ["AWS_DEFAULT_REGION"]])];
186
+ }
187
+ return [missingCause("credential", hasExplicitBaseUrl
188
+ ? [
189
+ ["AWS_BEARER_TOKEN_BEDROCK"],
190
+ ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
191
+ ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_DEFAULT_REGION"],
192
+ ]
193
+ : [
194
+ ["AWS_BEARER_TOKEN_BEDROCK", "AWS_REGION"],
195
+ ["AWS_BEARER_TOKEN_BEDROCK", "AWS_DEFAULT_REGION"],
196
+ ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
197
+ ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_DEFAULT_REGION"],
198
+ ])];
199
+ };
200
+
201
+ // Local evidence only: this shares the exact environment and endpoint rules
202
+ // used by createSdkModel, but never probes a provider or validates a secret.
203
+ export const providerReadiness = (
204
+ provider: string,
205
+ env: NodeJS.ProcessEnv,
206
+ baseUrlOverride?: string,
207
+ ): ModelReadiness | null => {
208
+ const catalog = lookupProvider(provider) ?? configuredProviderInfo(provider, env);
209
+ if (catalog === null) return null;
210
+ if (!supportedSdkPackages.has(catalog.npm)) return null;
211
+ const rawUrl = configuredBaseUrl(provider, env, catalog, baseUrlOverride);
212
+ const missingCoordinates = templateEnvironmentNames(rawUrl).filter((name) => !isSet(env, name));
213
+ const causes: ModelReadinessCause[] = missingCoordinates.length === 0
214
+ ? []
215
+ : [missingCause("configuration", [missingCoordinates])];
216
+
217
+ if (catalog.npm === "@ai-sdk/amazon-bedrock") {
218
+ causes.push(...bedrockReadinessCauses(env, rawUrl !== undefined));
219
+ } else {
220
+ const candidates = credentialCandidates(provider, env, catalog);
221
+ const credentialRequired = catalog.npm !== "@ai-sdk/openai-compatible" || candidates.length > 0;
222
+ if (credentialRequired && candidates.length === 0) {
223
+ causes.push(missingCause("configuration", [[
224
+ `PLURNK_PROVIDERS_PROVIDER_${envPrefix(provider)}_API_KEY_ENV`,
225
+ ]]));
226
+ } else if (credentialRequired && firstSet(env, candidates) === undefined) {
227
+ causes.push(missingCause("credential", candidates.map((name) => [name])));
228
+ }
229
+ if (catalog.npm === "@ai-sdk/openai-compatible" && rawUrl === undefined) {
230
+ const prefix = envPrefix(provider);
231
+ causes.push(missingCause("configuration", [
232
+ [`PLURNK_PROVIDERS_PROVIDER_${prefix}_BASE_URL`],
233
+ [`${prefix}_BASE_URL`],
234
+ ]));
235
+ }
236
+ }
237
+ return Validator.assertModelReadiness({ ready: causes.length === 0, causes });
238
+ };
239
+
240
+ const assertProviderReady = (
241
+ provider: string,
242
+ env: NodeJS.ProcessEnv,
243
+ baseUrlOverride?: string,
244
+ ): void => {
245
+ const readiness = providerReadiness(provider, env, baseUrlOverride);
246
+ if (readiness === null || readiness.ready) return;
247
+ const requirements = readiness.causes
248
+ .map(({ alternatives }) => alternatives.map((group) => group.join(" and ")).join(" or "))
249
+ .join("; ");
250
+ throw new Error(`${provider} provider: ${requirements} must be set`);
74
251
  };
75
252
 
76
253
  const expandEnv = (value: string, env: NodeJS.ProcessEnv, provider: string): string =>
@@ -88,11 +265,11 @@ const baseUrl = (
88
265
  catalog: ProviderInfo,
89
266
  override?: string,
90
267
  ): string | undefined => {
91
- const prefix = envPrefix(provider);
92
- const configured = env[`PLURNK_PROVIDERS_PROVIDER_${prefix}_BASE_URL`]
93
- ?? env[`${prefix}_BASE_URL`]
94
- ?? catalog.api;
95
- const value = override ?? configured;
268
+ // {§provider-fact-authority} — distinct sources, one precedence: the PLURNK
269
+ // knob, then the provider-native convention (OPENAI_BASE_URL etc.), then
270
+ // the catalog. This is not an alias fallback: each source is a different
271
+ // owner, and the catalog remains the last authority.
272
+ const value = configuredBaseUrl(provider, env, catalog, override);
96
273
  return value === undefined ? undefined : expandEnv(value, env, provider).replace(/\/+$/, "");
97
274
  };
98
275
 
@@ -101,9 +278,11 @@ const requireApiKey = (
101
278
  env: NodeJS.ProcessEnv,
102
279
  catalog: ProviderInfo,
103
280
  ): string => {
104
- const names = configuredKeyNames(provider, env, catalog);
105
- const key = firstSet(env, names);
106
- if (key === undefined) throw new Error(`${provider} provider: ${names.join(" or ")} must be set`);
281
+ // {§provider-fact-authority} — a catalog `env` list mixes credentials with
282
+ // non-secret coordinates; the credential is the credential-named one.
283
+ const candidates = credentialCandidates(provider, env, catalog);
284
+ const key = firstSet(env, candidates);
285
+ if (key === undefined) throw new Error(`${provider} provider: ${candidates.join(" or ")} must be set`);
107
286
  return key;
108
287
  };
109
288
 
@@ -115,6 +294,10 @@ export const createSdkModel = (
115
294
  ): SdkModel | null => {
116
295
  const catalog = lookupProvider(provider) ?? configuredProviderInfo(provider, env);
117
296
  if (catalog === null) return null;
297
+ if (!supportedSdkPackages.has(catalog.npm)) {
298
+ throw new Error(`${provider} provider: Models.dev declares unsupported AI SDK package ${catalog.npm}`);
299
+ }
300
+ assertProviderReady(provider, env, baseUrlOverride);
118
301
  const url = baseUrl(provider, env, catalog, baseUrlOverride);
119
302
  const normalizeCost = providerCostNormalizer(catalog.npm);
120
303
 
@@ -192,7 +375,9 @@ export const createSdkModel = (
192
375
  apiKey: env.AWS_BEARER_TOKEN_BEDROCK,
193
376
  baseURL: url,
194
377
  }).languageModel(model),
195
- additiveReasoningProvider: "bedrock",
378
+ ...(model.includes("anthropic")
379
+ ? { additiveReasoningProvider: "bedrock" as const }
380
+ : {}),
196
381
  catalog,
197
382
  };
198
383
  case "@openrouter/ai-sdk-provider":
@@ -200,10 +385,7 @@ export const createSdkModel = (
200
385
  languageModel: createOpenRouter({
201
386
  apiKey: requireApiKey(provider, env, catalog),
202
387
  baseURL: url,
203
- headers: {
204
- ...(env.OPENROUTER_HTTP_REFERER === undefined ? {} : { "HTTP-Referer": env.OPENROUTER_HTTP_REFERER }),
205
- ...(env.OPENROUTER_X_TITLE === undefined ? {} : { "X-Title": env.OPENROUTER_X_TITLE }),
206
- },
388
+ headers: openRouterHeaders(provider, env, catalog),
207
389
  }).languageModel(model),
208
390
  ...(catalog.id === "openrouter"
209
391
  ? { cacheAffinity: { target: "header" as const, name: "x-session-id" } }
package/src/types.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  import type {
13
13
  ProviderCost,
14
14
  ProviderRequestAccounting,
15
+ ReasoningPolicy,
15
16
  } from "@plurnk/plurnk-contracts";
16
17
 
17
18
  export type {
@@ -19,8 +20,21 @@ export type {
19
20
  ProviderCost,
20
21
  ProviderRequestAccounting,
21
22
  ProviderUsage,
23
+ ReasoningPolicy,
22
24
  } from "@plurnk/plurnk-contracts";
23
25
 
26
+ export class UnsupportedReasoningPolicyError extends Error {
27
+ readonly policy: ReasoningPolicy;
28
+ readonly supported: readonly ReasoningPolicy[];
29
+
30
+ constructor(source: string, policy: ReasoningPolicy, supported: readonly ReasoningPolicy[]) {
31
+ super(`${source}: reasoning policy '${policy}' is unsupported; supported policies: ${supported.join(", ")}`);
32
+ this.name = "UnsupportedReasoningPolicyError";
33
+ this.policy = policy;
34
+ this.supported = supported;
35
+ }
36
+ }
37
+
24
38
  export interface ChatMessage {
25
39
  role: "system" | "user" | "assistant";
26
40
  content: string;
@@ -277,6 +291,8 @@ export interface Provider {
277
291
  readonly maxOutputTokens: number | null;
278
292
  readonly outputBudget: number | null;
279
293
  readonly reasoningBudget: number | null;
294
+ // Exact durable policies this adapter can represent without coercion.
295
+ readonly supportedReasoningPolicies: readonly ReasoningPolicy[];
280
296
  readonly inputCapacity: number | null;
281
297
  readonly model: string;
282
298
  // Optional: the backend's self-reported served model id, from a