@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.
- package/.env.defaults +25 -13
- package/README.md +8 -1
- package/SPEC.md +93 -15
- 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/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/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 +9 -9
- 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/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/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/sdkModels.test.ts +142 -8
- package/src/sdkModels.ts +201 -19
- 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
|
-
|
|
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:
|
|
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: "
|
|
55
|
-
assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "
|
|
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", "
|
|
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: "
|
|
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, "
|
|
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: "
|
|
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 —
|
|
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 —
|
|
235
|
-
// are separate vars, so a numeric budget can never silently
|
|
236
|
-
// PLURNK_PROVIDERS_REASONING
|
|
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
|
|
242
|
-
|
|
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 (
|
|
269
|
-
|
|
270
|
-
return { mode
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
export type { ProviderAlias } from "@plurnk/plurnk-aliases";
|
|
36
|
-
export {
|
|
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
|
|
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,
|
|
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
|
|
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
|
+
});
|
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,
|