@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
@@ -0,0 +1,139 @@
1
+ import { Problems, type ProblemDetails } from "@plurnk/plurnk-contracts";
2
+ import { providerSource } from "./notices.ts";
3
+ import type { ProviderAttempt, ProviderRequestAccounting, ProviderRequestCapacity } from "./types.ts";
4
+
5
+ export type ProviderErrorKind =
6
+ | "rate_limit"
7
+ | "network_failure"
8
+ | "deadline_exceeded"
9
+ | "model_refused"
10
+ | "invalid_response"
11
+ | "unauthorized"
12
+ | "quota_exceeded"
13
+ | "grammar_invalid"
14
+ | "capacity_exceeded"
15
+ | "resource_interrupted";
16
+
17
+ const defaultStatus = (kind: ProviderErrorKind): number => {
18
+ switch (kind) {
19
+ case "unauthorized": return 401;
20
+ case "quota_exceeded": return 402;
21
+ case "capacity_exceeded": return 413;
22
+ case "rate_limit": return 429;
23
+ case "model_refused":
24
+ case "grammar_invalid": return 422;
25
+ case "invalid_response": return 502;
26
+ case "deadline_exceeded": return 504;
27
+ case "network_failure":
28
+ case "resource_interrupted": return 503;
29
+ }
30
+ };
31
+
32
+ const retryable = (kind: ProviderErrorKind): boolean => {
33
+ switch (kind) {
34
+ case "rate_limit":
35
+ case "network_failure":
36
+ return true;
37
+ case "deadline_exceeded":
38
+ case "invalid_response":
39
+ case "grammar_invalid":
40
+ case "capacity_exceeded":
41
+ case "resource_interrupted":
42
+ case "model_refused":
43
+ case "unauthorized":
44
+ case "quota_exceeded":
45
+ return false;
46
+ }
47
+ };
48
+
49
+ const buildProblem = (
50
+ source: string,
51
+ kind: ProviderErrorKind,
52
+ message: string,
53
+ status: number,
54
+ extensions: Readonly<Record<string, unknown>>,
55
+ retryableOverride: boolean | undefined,
56
+ ): ProblemDetails => {
57
+ const code: Record<ProviderErrorKind, string> = {
58
+ rate_limit: "rate-limit",
59
+ network_failure: "network-failure",
60
+ deadline_exceeded: "deadline-exceeded",
61
+ model_refused: "model-refused",
62
+ invalid_response: "invalid-response",
63
+ unauthorized: "unauthorized",
64
+ quota_exceeded: "quota-exceeded",
65
+ grammar_invalid: "grammar-invalid",
66
+ capacity_exceeded: "capacity-exceeded",
67
+ resource_interrupted: "resource-interrupted",
68
+ };
69
+ return Problems.create(source, code[kind], status, message, {
70
+ providerKind: kind,
71
+ stage: "provider-request",
72
+ retryable: retryableOverride ?? retryable(kind),
73
+ ...extensions,
74
+ });
75
+ };
76
+
77
+ // A provider operation failed before a completed exchange existed. An
78
+ // interrupted response may still carry attempt evidence for its consumer.
79
+ // The standardized Problem is the public failure contract; kind remains the
80
+ // provider pool's routing discriminator and is repeated as a Problem extension.
81
+ export class ProviderError extends Error {
82
+ readonly source: string;
83
+ readonly kind: ProviderErrorKind;
84
+ readonly problem: ProblemDetails;
85
+ readonly attempt?: ProviderAttempt;
86
+ readonly capacity?: ProviderRequestCapacity;
87
+ #accounting: ProviderRequestAccounting[];
88
+
89
+ constructor(
90
+ source: string,
91
+ kind: ProviderErrorKind,
92
+ message: string,
93
+ options: {
94
+ status?: number | null;
95
+ cause?: unknown;
96
+ retryable?: boolean;
97
+ extensions?: Readonly<Record<string, unknown>>;
98
+ attempt?: ProviderAttempt;
99
+ accounting?: readonly ProviderRequestAccounting[];
100
+ capacity?: ProviderRequestCapacity;
101
+ } = {},
102
+ ) {
103
+ super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
104
+ this.name = "ProviderError";
105
+ this.source = providerSource(source);
106
+ this.kind = kind;
107
+ this.attempt = options.attempt;
108
+ this.capacity = options.capacity ?? options.attempt?.capacity;
109
+ this.#accounting = [...(options.accounting ?? options.attempt?.accounting ?? [])];
110
+ const status = options.status !== null && options.status !== undefined
111
+ && Number.isInteger(options.status) && options.status >= 400 && options.status <= 599
112
+ ? options.status
113
+ : defaultStatus(kind);
114
+ this.problem = buildProblem(
115
+ this.source,
116
+ kind,
117
+ message,
118
+ status,
119
+ options.extensions ?? {},
120
+ options.retryable,
121
+ );
122
+ }
123
+
124
+ get status(): number {
125
+ return this.problem.status;
126
+ }
127
+
128
+ get accounting(): readonly ProviderRequestAccounting[] {
129
+ return this.#accounting;
130
+ }
131
+
132
+ // A capacity pool adds the already-settled requests from prior backends as
133
+ // the same failure crosses that orchestration boundary.
134
+ prependAccounting(accounting: readonly ProviderRequestAccounting[]): void {
135
+ if (accounting.length > 0) this.#accounting = [...accounting, ...this.#accounting];
136
+ }
137
+ }
138
+
139
+ export type { ProviderAttempt, ProviderRequestAccounting, ProviderRequestCapacity } from "./types.ts";
@@ -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,
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