@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.
- package/.env.defaults +25 -13
- package/README.md +13 -4
- package/SPEC.md +104 -22
- package/dist/AiSdkProvider.d.ts +6 -3
- package/dist/AiSdkProvider.d.ts.map +1 -1
- package/dist/AiSdkProvider.js +108 -51
- package/dist/AiSdkProvider.js.map +1 -1
- package/dist/Mock.d.ts +1 -0
- package/dist/Mock.d.ts.map +1 -1
- package/dist/Mock.js +2 -0
- package/dist/Mock.js.map +1 -1
- package/dist/Pool.d.ts +2 -0
- package/dist/Pool.d.ts.map +1 -1
- package/dist/Pool.js +3 -0
- package/dist/Pool.js.map +1 -1
- package/dist/ProviderRegistry.d.ts.map +1 -1
- package/dist/ProviderRegistry.js +11 -10
- package/dist/ProviderRegistry.js.map +1 -1
- package/dist/accounting.d.ts.map +1 -1
- package/dist/accounting.js +7 -6
- package/dist/accounting.js.map +1 -1
- package/dist/aiSdkTransport.d.ts +2 -1
- package/dist/aiSdkTransport.d.ts.map +1 -1
- package/dist/aiSdkTransport.js +29 -6
- package/dist/aiSdkTransport.js.map +1 -1
- package/dist/catalogProvider.d.ts +4 -1
- package/dist/catalogProvider.d.ts.map +1 -1
- package/dist/catalogProvider.js +94 -3
- package/dist/catalogProvider.js.map +1 -1
- package/dist/compatibleProvider.d.ts.map +1 -1
- package/dist/compatibleProvider.js +2 -0
- package/dist/compatibleProvider.js.map +1 -1
- package/dist/cost.d.ts.map +1 -1
- package/dist/cost.js +5 -4
- package/dist/cost.js.map +1 -1
- package/dist/discover.d.ts +2 -0
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js +13 -2
- package/dist/discover.js.map +1 -1
- package/dist/env.d.ts +3 -2
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +11 -4
- package/dist/env.js.map +1 -1
- package/dist/errors.d.ts +5 -23
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -90
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/notices.d.ts +1 -1
- package/dist/notices.d.ts.map +1 -1
- package/dist/openai.d.ts +1 -1
- package/dist/openai.d.ts.map +1 -1
- package/dist/openai.js +1 -1
- package/dist/openai.js.map +1 -1
- package/dist/providerError.d.ts +25 -0
- package/dist/providerError.d.ts.map +1 -0
- package/dist/providerError.js +91 -0
- package/dist/providerError.js.map +1 -0
- package/dist/sdkModels.d.ts +2 -0
- package/dist/sdkModels.d.ts.map +1 -1
- package/dist/sdkModels.js +163 -19
- package/dist/sdkModels.js.map +1 -1
- package/dist/types.d.ts +8 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +10 -1
- package/dist/types.js.map +1 -1
- package/package.json +15 -10
- package/src/AiSdkProvider.test.ts +206 -32
- package/src/AiSdkProvider.ts +140 -54
- package/src/Mock.ts +2 -0
- package/src/Pool.test.ts +1 -0
- package/src/Pool.ts +5 -0
- package/src/ProviderRegistry.test.ts +27 -14
- package/src/ProviderRegistry.ts +19 -10
- package/src/accounting.test.ts +6 -2
- package/src/accounting.ts +7 -6
- package/src/aiSdkTransport.ts +32 -7
- package/src/boundaries.test.ts +27 -15
- package/src/catalogProvider.test.ts +151 -19
- package/src/catalogProvider.ts +125 -3
- package/src/compatibleProvider.test.ts +13 -10
- package/src/compatibleProvider.ts +2 -0
- package/src/cost.ts +5 -4
- package/src/discover.test.ts +27 -0
- package/src/discover.ts +20 -3
- package/src/env.test.ts +23 -8
- package/src/env.ts +17 -8
- package/src/errors.ts +5 -136
- package/src/index.ts +16 -8
- package/src/notices.ts +1 -1
- package/src/openai.ts +1 -1
- package/src/providerDefaults.test.ts +50 -0
- package/src/providerError.ts +139 -0
- package/src/sdkModels.test.ts +142 -8
- package/src/sdkModels.ts +201 -19
- 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";
|
package/src/sdkModels.test.ts
CHANGED
|
@@ -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("
|
|
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
|
|
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"
|
|
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("
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const value =
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|