@plurnk/plurnk-providers 1.3.12 → 1.4.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 +39 -22
- package/README.md +65 -4
- package/SPEC.md +222 -56
- package/dist/AiSdkProvider.d.ts +18 -5
- package/dist/AiSdkProvider.d.ts.map +1 -1
- package/dist/AiSdkProvider.js +240 -153
- package/dist/AiSdkProvider.js.map +1 -1
- package/dist/Mock.d.ts +14 -15
- package/dist/Mock.d.ts.map +1 -1
- package/dist/Mock.js +26 -10
- package/dist/Mock.js.map +1 -1
- package/dist/Pool.d.ts +8 -2
- package/dist/Pool.d.ts.map +1 -1
- package/dist/Pool.js +41 -8
- package/dist/Pool.js.map +1 -1
- package/dist/ProviderRegistry.d.ts +4 -1
- package/dist/ProviderRegistry.d.ts.map +1 -1
- package/dist/ProviderRegistry.js +7 -3
- package/dist/ProviderRegistry.js.map +1 -1
- package/dist/aiSdkTransport.d.ts +4 -2
- package/dist/aiSdkTransport.d.ts.map +1 -1
- package/dist/aiSdkTransport.js +18 -3
- package/dist/aiSdkTransport.js.map +1 -1
- package/dist/catalogProvider.d.ts +3 -1
- package/dist/catalogProvider.d.ts.map +1 -1
- package/dist/catalogProvider.js +20 -7
- package/dist/catalogProvider.js.map +1 -1
- package/dist/compatibleProvider.d.ts.map +1 -1
- package/dist/compatibleProvider.js +11 -4
- package/dist/compatibleProvider.js.map +1 -1
- package/dist/cost.d.ts +11 -0
- package/dist/cost.d.ts.map +1 -0
- package/dist/cost.js +64 -0
- package/dist/cost.js.map +1 -0
- package/dist/discover.d.ts +2 -0
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js +15 -9
- package/dist/discover.js.map +1 -1
- package/dist/env.d.ts +4 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +29 -9
- package/dist/env.js.map +1 -1
- package/dist/errors.d.ts +27 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +150 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/notices.d.ts +10 -0
- package/dist/notices.d.ts.map +1 -0
- package/dist/notices.js +11 -0
- package/dist/notices.js.map +1 -0
- package/dist/ollama.d.ts.map +1 -1
- package/dist/ollama.js +3 -3
- package/dist/ollama.js.map +1 -1
- package/dist/openai.d.ts +1 -1
- package/dist/openai.d.ts.map +1 -1
- package/dist/promptTokens.d.ts +4 -0
- package/dist/promptTokens.d.ts.map +1 -0
- package/dist/promptTokens.js +32 -0
- package/dist/promptTokens.js.map +1 -0
- package/dist/sdkModels.d.ts.map +1 -1
- package/dist/sdkModels.js +4 -3
- package/dist/sdkModels.js.map +1 -1
- package/dist/types.d.ts +43 -16
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +3 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +26 -14
- package/dist/usage.js.map +1 -1
- package/dist/warnings.js +0 -0
- package/dist/warnings.js.map +1 -1
- package/package.json +13 -9
- package/src/AiSdkProvider.test.ts +480 -159
- package/src/AiSdkProvider.ts +320 -196
- package/src/Mock.test.ts +29 -14
- package/src/Mock.ts +33 -15
- package/src/Pool.test.ts +43 -6
- package/src/Pool.ts +56 -10
- package/src/ProviderRegistry.test.ts +158 -9
- package/src/ProviderRegistry.ts +19 -6
- package/src/aiSdkTransport.ts +25 -6
- package/src/boundaries.test.ts +8 -3
- package/src/catalogProvider.test.ts +17 -0
- package/src/catalogProvider.ts +25 -10
- package/src/compatibleProvider.test.ts +96 -0
- package/src/compatibleProvider.ts +15 -6
- package/src/cost.test.ts +63 -0
- package/src/cost.ts +83 -0
- package/src/defaults.test.ts +1 -0
- package/src/discover.test.ts +48 -7
- package/src/discover.ts +31 -21
- package/src/env.test.ts +38 -23
- package/src/env.ts +45 -18
- package/src/errors.test.ts +148 -0
- package/src/errors.ts +207 -0
- package/src/index.ts +29 -7
- package/src/lexicon-guard.test.ts +6 -6
- package/src/notices.ts +22 -0
- package/src/ollama.test.ts +64 -0
- package/src/ollama.ts +6 -3
- package/src/openai.ts +3 -0
- package/src/promptTokens.ts +41 -0
- package/src/sdkModels.test.ts +7 -0
- package/src/sdkModels.ts +4 -8
- package/src/types.ts +106 -64
- package/src/usage.test.ts +15 -4
- package/src/usage.ts +32 -14
- package/src/warnings.test.ts +10 -10
- package/src/warnings.ts +0 -0
- package/dist/OpenAICompat.d.ts +0 -76
- package/dist/OpenAICompat.d.ts.map +0 -1
- package/dist/OpenAICompat.js +0 -555
- package/dist/OpenAICompat.js.map +0 -1
- package/dist/openaiStream.d.ts +0 -47
- package/dist/openaiStream.d.ts.map +0 -1
- package/dist/openaiStream.js +0 -280
- package/dist/openaiStream.js.map +0 -1
- package/dist/standardProviders.d.ts +0 -31
- package/dist/standardProviders.d.ts.map +0 -1
- package/dist/standardProviders.js +0 -518
- package/dist/standardProviders.js.map +0 -1
- package/dist/telemetry.d.ts +0 -24
- package/dist/telemetry.d.ts.map +0 -1
- package/dist/telemetry.js +0 -85
- package/dist/telemetry.js.map +0 -1
- package/src/telemetry.test.ts +0 -69
- package/src/telemetry.ts +0 -116
|
@@ -13,6 +13,7 @@ const env = {
|
|
|
13
13
|
PLURNK_PROVIDERS_REASONING_RESERVE: "10%",
|
|
14
14
|
PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%",
|
|
15
15
|
PLURNK_PROVIDERS_RETRY_ATTEMPTS: "0",
|
|
16
|
+
PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT: "512",
|
|
16
17
|
PLURNK_PROVIDERS_PROBE_ATTEMPTS: "1",
|
|
17
18
|
PLURNK_PROVIDERS_PROBE_DELAY: "0",
|
|
18
19
|
PLURNK_PROVIDERS_PROMPT_CACHE_KEY: "1",
|
|
@@ -42,3 +43,98 @@ test("compatible endpoints preserve configured prompt-cache affinity", async ()
|
|
|
42
43
|
|
|
43
44
|
assert.equal(body?.prompt_cache_key, "worker-affinity");
|
|
44
45
|
});
|
|
46
|
+
|
|
47
|
+
test("the server-wide DRY-off floor emits no DRY request fields", async () => {
|
|
48
|
+
let body: Record<string, unknown> | undefined;
|
|
49
|
+
mock.method(globalThis, "fetch", async (input: string | URL | Request, init?: RequestInit) => {
|
|
50
|
+
if (String(input).endsWith("/models")) {
|
|
51
|
+
return new Response(JSON.stringify({
|
|
52
|
+
data: [{ id: "local", meta: { n_ctx: 8192 } }],
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
56
|
+
return new Response(JSON.stringify({
|
|
57
|
+
model: "local",
|
|
58
|
+
choices: [{ message: { content: "ok" }, finish_reason: "stop" }],
|
|
59
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
60
|
+
}), { headers: { "content-type": "application/json" } });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const provider = await compatibleProviderFromEnv("openai", {
|
|
64
|
+
...env,
|
|
65
|
+
PLURNK_PROVIDERS_DRY_MULTIPLIER: "0",
|
|
66
|
+
// Stale or independently supplied shape values cannot activate DRY.
|
|
67
|
+
PLURNK_PROVIDERS_DRY_BASE: "1.75",
|
|
68
|
+
PLURNK_PROVIDERS_DRY_ALLOWED_LENGTH: "32",
|
|
69
|
+
}, "local");
|
|
70
|
+
await provider.generate({
|
|
71
|
+
workerId: "worker-dry-off",
|
|
72
|
+
messages: [{ role: "user", content: "repeat exactly" }],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
assert.equal(body?.repeat_penalty, 1.15);
|
|
76
|
+
assert.equal("dry_multiplier" in (body ?? {}), false);
|
|
77
|
+
assert.equal("dry_base" in (body ?? {}), false);
|
|
78
|
+
assert.equal("dry_allowed_length" in (body ?? {}), false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("detected llama-server measures the complete chat request through input_tokens", async () => {
|
|
82
|
+
let countUrl: string | undefined;
|
|
83
|
+
let countBody: Record<string, unknown> | undefined;
|
|
84
|
+
mock.method(globalThis, "fetch", async (input: string | URL | Request, init?: RequestInit) => {
|
|
85
|
+
const url = String(input);
|
|
86
|
+
if (url.endsWith("/models")) {
|
|
87
|
+
return new Response(JSON.stringify({
|
|
88
|
+
data: [{ id: "served.gguf", meta: { n_ctx: 8192 } }],
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
if (url.endsWith("/props")) {
|
|
92
|
+
return new Response(JSON.stringify({ total_slots: 1 }));
|
|
93
|
+
}
|
|
94
|
+
if (url.endsWith("/chat/completions/input_tokens")) {
|
|
95
|
+
countUrl = url;
|
|
96
|
+
countBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
97
|
+
return new Response(JSON.stringify({ input_tokens: 37 }), {
|
|
98
|
+
headers: { "content-type": "application/json" },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`unexpected request ${url}`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const provider = await compatibleProviderFromEnv("openai", env, "local");
|
|
105
|
+
const messages = [
|
|
106
|
+
{ role: "system" as const, content: "system slot" },
|
|
107
|
+
{ role: "user" as const, content: "漢漢漢" },
|
|
108
|
+
];
|
|
109
|
+
assert.deepEqual(await provider.countPromptTokens(messages), {
|
|
110
|
+
kind: "exact",
|
|
111
|
+
tokens: 37,
|
|
112
|
+
source: "llama-server:/v1/chat/completions/input_tokens",
|
|
113
|
+
});
|
|
114
|
+
assert.equal(countUrl, "http://local.test/v1/chat/completions/input_tokens");
|
|
115
|
+
assert.deepEqual(countBody?.messages, messages, "measurement receives the exact dispatched message slots");
|
|
116
|
+
assert.equal(countBody?.model, "local");
|
|
117
|
+
assert.deepEqual(countBody?.chat_template_kwargs, { enable_thinking: false });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a missing llama-server input-token endpoint degrades explicitly, never to a claimed bound", async () => {
|
|
121
|
+
mock.method(globalThis, "fetch", async (input: string | URL | Request) => {
|
|
122
|
+
const url = String(input);
|
|
123
|
+
if (url.endsWith("/models")) {
|
|
124
|
+
return new Response(JSON.stringify({
|
|
125
|
+
data: [{ id: "served.gguf", meta: { n_ctx: 8192 } }],
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
if (url.endsWith("/props")) return new Response(JSON.stringify({ total_slots: 1 }));
|
|
129
|
+
if (url.endsWith("/chat/completions/input_tokens")) return new Response("missing", { status: 404 });
|
|
130
|
+
throw new Error(`unexpected request ${url}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const provider = await compatibleProviderFromEnv("openai", env, "local");
|
|
134
|
+
assert.deepEqual(await provider.countPromptTokens([{ role: "user", content: "漢漢漢" }]), {
|
|
135
|
+
kind: "estimate",
|
|
136
|
+
tokens: 2,
|
|
137
|
+
source: "heuristic:chars2",
|
|
138
|
+
detail: "llama-server input-token endpoint returned HTTP 404",
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import AiSdkProvider, { type GrammarStyle, type ReasoningStyle } from "./AiSdkProvider.ts";
|
|
2
2
|
import {
|
|
3
3
|
contextWindowFromEnv,
|
|
4
|
+
effectiveContextWindow,
|
|
4
5
|
dataCaptureFromEnv,
|
|
5
6
|
envelopeFromEnv,
|
|
6
7
|
parseOptionalFloat,
|
|
@@ -9,8 +10,9 @@ import {
|
|
|
9
10
|
parseRequiredInt,
|
|
10
11
|
promptCacheKeyFromEnv,
|
|
11
12
|
reasoningFromEnv,
|
|
13
|
+
reasoningResponseStyleFromEnv,
|
|
12
14
|
} from "./env.ts";
|
|
13
|
-
import { providerSource } from "./
|
|
15
|
+
import { providerSource } from "./notices.ts";
|
|
14
16
|
import type { Provider } from "./types.ts";
|
|
15
17
|
import { emitWarningOnce } from "./warnings.ts";
|
|
16
18
|
|
|
@@ -125,7 +127,7 @@ export const compatibleProviderFromEnv = async (
|
|
|
125
127
|
attempts,
|
|
126
128
|
parseRequiredInt(env.PLURNK_PROVIDERS_PROBE_DELAY, "PLURNK_PROVIDERS_PROBE_DELAY", provider),
|
|
127
129
|
);
|
|
128
|
-
|
|
130
|
+
const contextWindow = effectiveContextWindow(contextWindowFromEnv(env, provider), probe.nCtx);
|
|
129
131
|
const pinRaw = env.PLURNK_PROVIDERS_LLAMA_SERVER;
|
|
130
132
|
if (pinRaw !== undefined && pinRaw !== "" && pinRaw !== "0" && pinRaw !== "1") {
|
|
131
133
|
throw new Error(`${provider} provider: PLURNK_PROVIDERS_LLAMA_SERVER must be "1", "0", or unset`);
|
|
@@ -150,6 +152,7 @@ export const compatibleProviderFromEnv = async (
|
|
|
150
152
|
let slotCount: number | null = null;
|
|
151
153
|
let eosText: string | undefined;
|
|
152
154
|
let tokenizeUrl: string | undefined;
|
|
155
|
+
let promptTokensUrl: string | undefined;
|
|
153
156
|
if (llamaServer) {
|
|
154
157
|
grammarStyle = "llamacpp";
|
|
155
158
|
reasoningStyle = "template";
|
|
@@ -157,12 +160,15 @@ export const compatibleProviderFromEnv = async (
|
|
|
157
160
|
slotCount = props.slotCount;
|
|
158
161
|
eosText = props.eosText ?? undefined;
|
|
159
162
|
tokenizeUrl = url.replace(/\/v1\/chat\/completions$/, "/tokenize");
|
|
163
|
+
promptTokensUrl = url.replace(/\/v1\/chat\/completions$/, "/v1/chat/completions/input_tokens");
|
|
160
164
|
}
|
|
161
165
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
if (!llamaServer) {
|
|
167
|
+
emitWarningOnce(
|
|
168
|
+
`${provider} provider: physical prompt counting is a chars/2 estimate; over-policy recovery fails closed without exact or bounded request evidence`,
|
|
169
|
+
"PLURNK_PROMPT_COUNT_ESTIMATE",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
166
172
|
const { reasoningReserve, completionReserve } = envelopeFromEnv(env, provider);
|
|
167
173
|
return new AiSdkProvider({
|
|
168
174
|
model,
|
|
@@ -172,6 +178,7 @@ export const compatibleProviderFromEnv = async (
|
|
|
172
178
|
fetchTimeoutMs: timeout,
|
|
173
179
|
streamIdleTimeoutMs: parseRequiredInt(env.PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT, "PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT", provider),
|
|
174
180
|
reasoning: reasoningFromEnv(env, provider),
|
|
181
|
+
reasoningResponseStyle: reasoningResponseStyleFromEnv(env, provider),
|
|
175
182
|
reasoningStyle,
|
|
176
183
|
temperature: parseRequiredFloat(env.PLURNK_PROVIDERS_TEMPERATURE, "PLURNK_PROVIDERS_TEMPERATURE", provider, 0),
|
|
177
184
|
repeatPenalty: parseRequiredFloat(env.PLURNK_PROVIDERS_REPEAT_PENALTY, "PLURNK_PROVIDERS_REPEAT_PENALTY", provider, 0),
|
|
@@ -184,6 +191,7 @@ export const compatibleProviderFromEnv = async (
|
|
|
184
191
|
completionReserve,
|
|
185
192
|
tuningFloors: provider !== "plurnk",
|
|
186
193
|
retryAttempts: parseRequiredInt(env.PLURNK_PROVIDERS_RETRY_ATTEMPTS, "PLURNK_PROVIDERS_RETRY_ATTEMPTS", provider),
|
|
194
|
+
errorDetailLimit: parseRequiredInt(env.PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT, "PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT", provider),
|
|
187
195
|
promptCacheKey: promptCacheKeyFromEnv(env, provider),
|
|
188
196
|
source: providerSource(provider),
|
|
189
197
|
grammarStyle,
|
|
@@ -199,6 +207,7 @@ export const compatibleProviderFromEnv = async (
|
|
|
199
207
|
slotCount,
|
|
200
208
|
eosText,
|
|
201
209
|
tokenizeUrl,
|
|
210
|
+
promptTokensUrl,
|
|
202
211
|
servedModel: probe.servedModel ?? undefined,
|
|
203
212
|
requiresMaxTokens: llamaServer || undefined,
|
|
204
213
|
});
|
package/src/cost.test.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
providerCostFor,
|
|
5
|
+
providerCostUsd,
|
|
6
|
+
validateAuthoritativeCharge,
|
|
7
|
+
} from "./cost.ts";
|
|
8
|
+
import type { ProviderUsage } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const usage: ProviderUsage = {
|
|
11
|
+
prompt: 1,
|
|
12
|
+
completion: 1,
|
|
13
|
+
reasoning: 0,
|
|
14
|
+
cached: 0,
|
|
15
|
+
total: 2,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
test("authoritative provider charge wins over a local estimate", () => {
|
|
19
|
+
const provider = {
|
|
20
|
+
calculateCost: () => 12,
|
|
21
|
+
calculateCharge: () => ({ kind: "estimated", usd: "12", source: "catalog" } as const),
|
|
22
|
+
};
|
|
23
|
+
const charge = {
|
|
24
|
+
kind: "authoritative",
|
|
25
|
+
amount: { amount: "0.0000042", currency: "XMR" },
|
|
26
|
+
usdEquivalent: "0.73",
|
|
27
|
+
source: "settled upstream turn charge",
|
|
28
|
+
} as const;
|
|
29
|
+
assert.deepEqual(providerCostFor(provider, usage, charge), charge);
|
|
30
|
+
assert.equal(providerCostUsd(charge), 0.73);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("explicit free remains distinguishable from unknown", () => {
|
|
34
|
+
const free = providerCostFor({
|
|
35
|
+
calculateCost: () => 0,
|
|
36
|
+
calculateCharge: () => ({ kind: "free", source: "local model" }),
|
|
37
|
+
}, usage);
|
|
38
|
+
const unknown = providerCostFor({ calculateCost: () => 0 }, usage);
|
|
39
|
+
assert.deepEqual(free, { kind: "free", source: "local model" });
|
|
40
|
+
assert.deepEqual(unknown, {
|
|
41
|
+
kind: "unknown",
|
|
42
|
+
reason: "legacy calculateCost returned zero without free-cost authority",
|
|
43
|
+
});
|
|
44
|
+
assert.equal(providerCostUsd(free), 0);
|
|
45
|
+
assert.equal(providerCostUsd(unknown), null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("positive legacy cost remains an estimated USD compatibility result", () => {
|
|
49
|
+
assert.deepEqual(providerCostFor({ calculateCost: () => 0.25 }, usage), {
|
|
50
|
+
kind: "estimated",
|
|
51
|
+
usd: "0.25",
|
|
52
|
+
source: "legacy calculateCost",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("rejects malformed money instead of mining or coercing it", () => {
|
|
57
|
+
assert.throws(() => validateAuthoritativeCharge({
|
|
58
|
+
kind: "authoritative",
|
|
59
|
+
amount: { amount: "1e3", currency: "usd" },
|
|
60
|
+
usdEquivalent: "1000",
|
|
61
|
+
source: "wire",
|
|
62
|
+
}), /canonical non-negative decimal string/);
|
|
63
|
+
});
|
package/src/cost.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthoritativeCharge,
|
|
3
|
+
ProviderUsage,
|
|
4
|
+
} from "./types.ts";
|
|
5
|
+
import type { ProviderCost } from "@plurnk/plurnk-contracts";
|
|
6
|
+
|
|
7
|
+
const DECIMAL = /^(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
8
|
+
const CURRENCY = /^[A-Z][A-Z0-9]{2,11}$/;
|
|
9
|
+
|
|
10
|
+
const nonEmpty = (value: string, name: string): string => {
|
|
11
|
+
if (value.trim() === "") throw new TypeError(`${name} must be non-empty`);
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const decimal = (value: string, name: string): string => {
|
|
16
|
+
if (!DECIMAL.test(value)) throw new TypeError(`${name} must be a canonical non-negative decimal string`);
|
|
17
|
+
return value;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const validateAuthoritativeCharge = (charge: AuthoritativeCharge): AuthoritativeCharge => {
|
|
21
|
+
if (charge.kind !== "authoritative") throw new TypeError("provider charge must be authoritative");
|
|
22
|
+
decimal(charge.amount.amount, "provider charge amount");
|
|
23
|
+
if (!CURRENCY.test(charge.amount.currency)) {
|
|
24
|
+
throw new TypeError("provider charge currency must be an uppercase currency code");
|
|
25
|
+
}
|
|
26
|
+
decimal(charge.usdEquivalent, "provider charge USD equivalent");
|
|
27
|
+
nonEmpty(charge.source, "provider charge source");
|
|
28
|
+
return charge;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const validateProviderCost = (cost: ProviderCost): ProviderCost => {
|
|
32
|
+
switch (cost.kind) {
|
|
33
|
+
case "authoritative":
|
|
34
|
+
return validateAuthoritativeCharge(cost);
|
|
35
|
+
case "estimated":
|
|
36
|
+
decimal(cost.usd, "provider cost estimate");
|
|
37
|
+
nonEmpty(cost.source, "provider cost estimate source");
|
|
38
|
+
return cost;
|
|
39
|
+
case "free":
|
|
40
|
+
nonEmpty(cost.source, "provider free source");
|
|
41
|
+
return cost;
|
|
42
|
+
case "unknown":
|
|
43
|
+
nonEmpty(cost.reason, "provider unknown-cost reason");
|
|
44
|
+
return cost;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const resolveProviderCost = (
|
|
49
|
+
charge: AuthoritativeCharge | undefined,
|
|
50
|
+
current: ProviderCost | undefined,
|
|
51
|
+
legacy: () => number,
|
|
52
|
+
): ProviderCost => {
|
|
53
|
+
if (charge !== undefined) return validateAuthoritativeCharge(charge);
|
|
54
|
+
if (current !== undefined) return validateProviderCost(current);
|
|
55
|
+
const cost = legacy();
|
|
56
|
+
if (!Number.isFinite(cost) || cost < 0) {
|
|
57
|
+
throw new TypeError("legacy provider cost must be a finite non-negative number");
|
|
58
|
+
}
|
|
59
|
+
return cost > 0
|
|
60
|
+
? { kind: "estimated", usd: String(cost), source: "legacy calculateCost" }
|
|
61
|
+
: { kind: "unknown", reason: "legacy calculateCost returned zero without free-cost authority" };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const providerCostUsd = (cost: ProviderCost): number | null => {
|
|
65
|
+
const value = cost.kind === "authoritative"
|
|
66
|
+
? cost.usdEquivalent
|
|
67
|
+
: cost.kind === "estimated"
|
|
68
|
+
? cost.usd
|
|
69
|
+
: cost.kind === "free"
|
|
70
|
+
? "0"
|
|
71
|
+
: null;
|
|
72
|
+
return value === null ? null : Number(value);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const providerCostFor = (
|
|
76
|
+
provider: { calculateCharge?(usage: ProviderUsage): Exclude<ProviderCost, AuthoritativeCharge>; calculateCost(usage: ProviderUsage): number },
|
|
77
|
+
usage: ProviderUsage,
|
|
78
|
+
charge?: AuthoritativeCharge,
|
|
79
|
+
): ProviderCost => resolveProviderCost(
|
|
80
|
+
charge,
|
|
81
|
+
provider.calculateCharge?.(usage),
|
|
82
|
+
() => provider.calculateCost(usage),
|
|
83
|
+
);
|
package/src/defaults.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ test("withProviderDefaults supplies the package-owned operational floor", () =>
|
|
|
7
7
|
assert.equal(env.PLURNK_PROVIDERS_PROMPT_CACHE_KEY, "1");
|
|
8
8
|
assert.equal(env.PLURNK_PROVIDERS_FETCH_TIMEOUT, "600000");
|
|
9
9
|
assert.equal(env.PLURNK_PROVIDERS_RETRY_ATTEMPTS, "3");
|
|
10
|
+
assert.equal(env.PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT, "512");
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
test("withProviderDefaults preserves every explicit operator value", () => {
|
package/src/discover.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { discover } from "./discover.ts";
|
|
7
7
|
|
|
8
|
-
//
|
|
8
|
+
// Create a temp dir and register its removal on the test context, so it is
|
|
9
9
|
// cleaned on a GREEN or RED run. A trailing rm after the assertions leaks the dir
|
|
10
10
|
// whenever one throws — thousands accumulate on a shared box at drill frequency.
|
|
11
11
|
const tempDir = async (t: TestContext, prefix: string): Promise<string> => {
|
|
@@ -47,6 +47,17 @@ test("discover: a provider package missing plurnk.name is ignored, not crashed",
|
|
|
47
47
|
assert.deepEqual([...registry.keys()], ["named"]);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test("discover: an array kind claims no provider family", async (t) => {
|
|
51
|
+
const root = await buildModules(t, {
|
|
52
|
+
"@acme/dual": {
|
|
53
|
+
name: "@acme/dual",
|
|
54
|
+
plurnk: { kind: ["provider", "scheme"], name: "dual" },
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const { registry } = await discover({ cwd: root });
|
|
58
|
+
assert.equal(registry.size, 0);
|
|
59
|
+
});
|
|
60
|
+
|
|
50
61
|
test("discover: a name claimed by two packages is a fail-hard collision", async (t) => {
|
|
51
62
|
const root = await buildModules(t, {
|
|
52
63
|
"@plurnk/plurnk-provider-native": { name: "@plurnk/plurnk-provider-native", plurnk: { kind: "provider", name: "native" } },
|
|
@@ -71,18 +82,48 @@ test("discover: node_modules entries with no package.json or malformed JSON are
|
|
|
71
82
|
assert.deepEqual([...registry.keys()], ["native"]); // only the well-formed provider survives
|
|
72
83
|
});
|
|
73
84
|
|
|
74
|
-
test("discover:
|
|
85
|
+
test("discover: normalizes attribution per package and preserves the published provider projection", async (t) => {
|
|
75
86
|
const root = await buildModules(t, {
|
|
76
87
|
"@acme/provider-solo": { name: "@acme/provider-solo", plurnk: { kind: "provider", name: "solo", attribution: "@acme/solo" } },
|
|
77
88
|
"@acme/provider-multi": { name: "@acme/provider-multi", plurnk: { kind: "provider", name: "multi", attribution: ["@acme/a", "@acme/b"] } },
|
|
78
89
|
"@acme/provider-none": { name: "@acme/provider-none", plurnk: { kind: "provider", name: "none" } },
|
|
79
|
-
"@acme/provider-bad": { name: "@acme/provider-bad", plurnk: { kind: "provider", name: "bad", attribution: 42 } }, // non-string/array
|
|
80
90
|
});
|
|
81
|
-
const { attributions } = await discover({ cwd: root });
|
|
91
|
+
const { attributions, packageAttributions } = await discover({ cwd: root });
|
|
82
92
|
assert.equal(attributions.get("solo"), "@acme/solo");
|
|
83
93
|
assert.deepEqual(attributions.get("multi"), ["@acme/a", "@acme/b"]);
|
|
84
|
-
assert.equal(attributions.has("none"), false);
|
|
85
|
-
assert.
|
|
94
|
+
assert.equal(attributions.has("none"), false);
|
|
95
|
+
assert.deepEqual([...packageAttributions], [
|
|
96
|
+
["@acme/provider-multi", ["@acme/a", "@acme/b"]],
|
|
97
|
+
["@acme/provider-solo", ["@acme/solo"]],
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("discover: validates trusted attribution before provider admission", async (t) => {
|
|
102
|
+
const root = await buildModules(t, {
|
|
103
|
+
"@acme/provider-bad": { name: "@acme/provider-bad", plurnk: { kind: "provider", name: "bad", attribution: 42 } },
|
|
104
|
+
});
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
discover({ cwd: root }),
|
|
107
|
+
/plugin '@acme\/provider-bad': plurnk\.attribution must be a non-empty string or string\[\]/,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const reservedRoot = await buildModules(t, {
|
|
111
|
+
"@acme/provider-reserved": { name: "@acme/provider-reserved", plurnk: { kind: "provider", name: "reserved", attribution: "@plurnk/staff" } },
|
|
112
|
+
});
|
|
113
|
+
await assert.rejects(discover({ cwd: reservedRoot }), /'@plurnk\/' is reserved/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("discover: an untrusted malformed attribution is withheld before validation", async (t) => {
|
|
117
|
+
const root = await buildModules(t, {
|
|
118
|
+
"@acme/provider-bad": { name: "@acme/provider-bad", plurnk: { kind: "provider", name: "bad", attribution: ["ok", 42] } },
|
|
119
|
+
});
|
|
120
|
+
const result = await discover({
|
|
121
|
+
cwd: root,
|
|
122
|
+
env: { PLURNK_PLUGINS_TRUSTED_ONLY: "1" } as NodeJS.ProcessEnv,
|
|
123
|
+
});
|
|
124
|
+
assert.equal(result.registry.size, 0);
|
|
125
|
+
assert.equal(result.skipped.get("bad"), "@acme/provider-bad");
|
|
126
|
+
assert.equal(result.packageAttributions.size, 0);
|
|
86
127
|
});
|
|
87
128
|
|
|
88
129
|
test("discover: missing node_modules yields an empty registry, not an error", async (t) => {
|
|
@@ -91,7 +132,7 @@ test("discover: missing node_modules yields an empty registry, not an error", as
|
|
|
91
132
|
assert.equal(registry.size, 0);
|
|
92
133
|
});
|
|
93
134
|
|
|
94
|
-
// — trust gate (
|
|
135
|
+
// — trust gate ({§plugin-trust-boundary}) —
|
|
95
136
|
|
|
96
137
|
const trustFixture = (t: TestContext) => buildModules(t, {
|
|
97
138
|
"@plurnk/plurnk-provider-native": { name: "@plurnk/plurnk-provider-native", plurnk: { kind: "provider", name: "native" } },
|
package/src/discover.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import Meta from "@plurnk/plurnk-meta";
|
|
4
|
+
import type {
|
|
5
|
+
PackageAttributions,
|
|
6
|
+
PluginAttribution,
|
|
7
|
+
PluginAttributionDeclaration,
|
|
8
|
+
} from "@plurnk/plurnk-meta";
|
|
4
9
|
|
|
5
|
-
// Scope-agnostic discovery of installed AI SDK provider packages
|
|
10
|
+
// Scope-agnostic discovery of installed AI SDK provider packages
|
|
11
|
+
// ({§plugin-family-kind}).
|
|
6
12
|
// Parallel to @plurnk/plurnk-execs' discover(): scan every installed package
|
|
7
13
|
// under `<cwd>/node_modules` — scoped (`@scope/name`) and unscoped — and keep
|
|
8
14
|
// the ones declaring `plurnk.kind === "provider"`. Scope-agnostic so a THIRD
|
|
@@ -16,7 +22,7 @@ import Meta from "@plurnk/plurnk-meta";
|
|
|
16
22
|
//
|
|
17
23
|
// Cataloged and operator-declared providers resolve before this scan.
|
|
18
24
|
//
|
|
19
|
-
// Host plugin trust gate (PLURNK_PLUGINS_TRUSTED_ONLY
|
|
25
|
+
// {§plugin-trust-boundary} Host plugin trust gate (PLURNK_PLUGINS_TRUSTED_ONLY)
|
|
20
26
|
// — enforced uniformly across the four scope-agnostic families. An untrusted
|
|
21
27
|
// package is discovered-but-declined (recorded in `skipped`, never registered,
|
|
22
28
|
// never thrown), so the consumer can name it in a precise error.
|
|
@@ -31,12 +37,9 @@ export type DiscoverOptions = {
|
|
|
31
37
|
export type Discovery = {
|
|
32
38
|
registry: Map<string, string>; // trusted providers, eligible to instantiate
|
|
33
39
|
skipped: Map<string, string>; // declined by the trust gate (untrusted)
|
|
34
|
-
// name
|
|
35
|
-
// provider package's manifest, for crediting the provider's author (#21,
|
|
36
|
-
// plurnk-service#249). Surfaced verbatim — the consumer applies the reservation
|
|
37
|
-
// policy (`@plurnk/` tags only from `@plurnk/`-scoped packages). Absent for
|
|
38
|
-
// providers that declare none.
|
|
40
|
+
// Published name-keyed projection retained for 1.x consumers.
|
|
39
41
|
attributions: Map<string, string | string[]>;
|
|
42
|
+
packageAttributions: PackageAttributions;
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
|
|
@@ -46,7 +49,8 @@ export const discover = async (options: DiscoverOptions = {}): Promise<Discovery
|
|
|
46
49
|
|
|
47
50
|
const registry = new Map<string, string>();
|
|
48
51
|
const skipped = new Map<string, string>();
|
|
49
|
-
const attributions = new Map<string,
|
|
52
|
+
const attributions = new Map<string, PluginAttributionDeclaration>();
|
|
53
|
+
const packageAttributions = new Map<string, PluginAttribution>();
|
|
50
54
|
for (const dir of dirs) {
|
|
51
55
|
const info = await readProviderInfo(dir);
|
|
52
56
|
if (info === null) continue;
|
|
@@ -61,10 +65,13 @@ export const discover = async (options: DiscoverOptions = {}): Promise<Discovery
|
|
|
61
65
|
+ `${existing} and ${info.packageName}`,
|
|
62
66
|
);
|
|
63
67
|
}
|
|
68
|
+
const tags = Meta.normalizeAttribution(info.attribution, info.packageName);
|
|
64
69
|
registry.set(info.name, info.packageName);
|
|
65
|
-
|
|
70
|
+
const attribution = attributionProjection(info.attribution, tags);
|
|
71
|
+
if (attribution !== undefined) attributions.set(info.name, attribution);
|
|
72
|
+
if (tags.length > 0) packageAttributions.set(info.packageName, tags);
|
|
66
73
|
}
|
|
67
|
-
return { registry, skipped, attributions };
|
|
74
|
+
return { registry, skipped, attributions, packageAttributions };
|
|
68
75
|
};
|
|
69
76
|
|
|
70
77
|
// Enumerate every installed package directory — scoped and unscoped — under
|
|
@@ -74,11 +81,10 @@ const defaultPackageDirs = async (cwd: string): Promise<string[]> => {
|
|
|
74
81
|
return (await Meta.packageDirs(nm)).map((c) => c.dir).toSorted();
|
|
75
82
|
};
|
|
76
83
|
|
|
77
|
-
// One
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
type ProviderInfo = { name: string; packageName: string; attribution?: string | string[] };
|
|
84
|
+
// One inert manifest record for a provider package, or null for anything that
|
|
85
|
+
// isn't one. Attribution remains unknown until trust admission, then the shared
|
|
86
|
+
// {§plugin-attribution} boundary validates it.
|
|
87
|
+
type ProviderInfo = { name: string; packageName: string; attribution: unknown };
|
|
82
88
|
|
|
83
89
|
const readProviderInfo = async (dir: string): Promise<ProviderInfo | null> => {
|
|
84
90
|
let raw: string;
|
|
@@ -98,12 +104,16 @@ const readProviderInfo = async (dir: string): Promise<ProviderInfo | null> => {
|
|
|
98
104
|
const plurnk = record.plurnk;
|
|
99
105
|
if (typeof plurnk !== "object" || plurnk === null) return null;
|
|
100
106
|
const plurnkRec = plurnk as Record<string, unknown>;
|
|
101
|
-
if (plurnkRec
|
|
107
|
+
if (!Meta.declaresKind(plurnkRec, "provider")) return null;
|
|
102
108
|
if (typeof plurnkRec.name !== "string" || plurnkRec.name === "") return null;
|
|
103
109
|
if (typeof record.name !== "string" || record.name === "") return null;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
return { name: plurnkRec.name, packageName: record.name, attribution: plurnkRec.attribution };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const attributionProjection = (
|
|
114
|
+
raw: unknown,
|
|
115
|
+
tags: PluginAttribution,
|
|
116
|
+
): PluginAttributionDeclaration | undefined => {
|
|
117
|
+
if (raw === undefined || raw === null) return undefined;
|
|
118
|
+
return typeof raw === "string" ? raw : [...tags];
|
|
109
119
|
};
|