@plurnk/plurnk-providers 1.4.0 → 1.5.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 +9 -17
- package/README.md +3 -0
- package/SPEC.md +40 -23
- package/dist/AiSdkProvider.d.ts +2 -1
- package/dist/AiSdkProvider.d.ts.map +1 -1
- package/dist/AiSdkProvider.js +55 -34
- package/dist/AiSdkProvider.js.map +1 -1
- package/dist/Mock.d.ts +6 -7
- package/dist/Mock.d.ts.map +1 -1
- package/dist/Mock.js +1 -0
- package/dist/Mock.js.map +1 -1
- package/dist/Pool.js +1 -1
- package/dist/Pool.js.map +1 -1
- package/dist/accounting.d.ts +3 -0
- package/dist/accounting.d.ts.map +1 -0
- package/dist/accounting.js +84 -0
- package/dist/accounting.js.map +1 -0
- package/dist/aiSdkTransport.d.ts +2 -1
- package/dist/aiSdkTransport.d.ts.map +1 -1
- package/dist/aiSdkTransport.js +73 -2
- package/dist/aiSdkTransport.js.map +1 -1
- package/dist/catalogProvider.d.ts +3 -2
- package/dist/catalogProvider.d.ts.map +1 -1
- package/dist/catalogProvider.js +11 -11
- package/dist/catalogProvider.js.map +1 -1
- package/dist/cost.d.ts +1 -1
- package/dist/cost.d.ts.map +1 -1
- package/dist/cost.js +6 -9
- package/dist/cost.js.map +1 -1
- package/dist/env.d.ts +0 -6
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +0 -22
- package/dist/env.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/sdkModels.d.ts +2 -0
- package/dist/sdkModels.d.ts.map +1 -1
- package/dist/sdkModels.js +13 -3
- package/dist/sdkModels.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/usage.d.ts +1 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +38 -5
- package/dist/usage.js.map +1 -1
- package/package.json +8 -7
- package/src/AiSdkProvider.test.ts +277 -17
- package/src/AiSdkProvider.ts +70 -40
- package/src/Mock.ts +5 -2
- package/src/Pool.ts +1 -1
- package/src/accounting.test.ts +58 -0
- package/src/accounting.ts +88 -0
- package/src/aiSdkTransport.ts +77 -3
- package/src/boundaries.test.ts +1 -0
- package/src/catalogProvider.test.ts +26 -15
- package/src/catalogProvider.ts +12 -11
- package/src/cost.test.ts +7 -6
- package/src/cost.ts +4 -9
- package/src/env.test.ts +1 -26
- package/src/env.ts +0 -24
- package/src/errors.ts +1 -0
- package/src/index.ts +1 -1
- package/src/sdkModels.test.ts +22 -3
- package/src/sdkModels.ts +15 -3
- package/src/types.ts +22 -3
- package/src/usage.test.ts +9 -1
- package/src/usage.ts +40 -7
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { authoritativeChargeNormalizer } from "./accounting.ts";
|
|
4
|
+
|
|
5
|
+
const evidence = ({ providerMetadata, usage }: { providerMetadata?: unknown; usage?: unknown }) => ({
|
|
6
|
+
...(providerMetadata === undefined ? {} : { providerMetadata }),
|
|
7
|
+
...(usage === undefined ? {} : { usage }),
|
|
8
|
+
response: { id: "response-1" },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("xAI response ticks normalize to an exact provider-authoritative charge", () => {
|
|
12
|
+
const normalize = authoritativeChargeNormalizer("@ai-sdk/xai");
|
|
13
|
+
assert.notEqual(normalize, undefined);
|
|
14
|
+
assert.deepEqual(normalize!(evidence({ usage: { cost_in_usd_ticks: 15_493_500 } })), {
|
|
15
|
+
kind: "authoritative",
|
|
16
|
+
amount: { amount: "15493500", currency: "USDTICK" },
|
|
17
|
+
usdEquivalent: "0.00154935",
|
|
18
|
+
source: "xAI response usage.cost_in_usd_ticks",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("OpenRouter response cost normalizes without rate reconstruction", () => {
|
|
23
|
+
const normalize = authoritativeChargeNormalizer("@openrouter/ai-sdk-provider");
|
|
24
|
+
assert.notEqual(normalize, undefined);
|
|
25
|
+
assert.deepEqual(normalize!(evidence({ providerMetadata: { openrouter: { usage: { cost: 3.2e-7 } } } })), {
|
|
26
|
+
kind: "authoritative",
|
|
27
|
+
amount: { amount: "0.00000032", currency: "USD" },
|
|
28
|
+
usdEquivalent: "0.00000032",
|
|
29
|
+
source: "OpenRouter response usage.cost",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("DeepInfra's documented response estimate wins over local rate reconstruction", () => {
|
|
34
|
+
const normalize = authoritativeChargeNormalizer("@ai-sdk/deepinfra");
|
|
35
|
+
assert.notEqual(normalize, undefined);
|
|
36
|
+
assert.deepEqual(normalize!(evidence({ usage: { estimated_cost: 5.04e-5 } })), {
|
|
37
|
+
kind: "authoritative",
|
|
38
|
+
amount: { amount: "0.0000504", currency: "USD" },
|
|
39
|
+
usdEquivalent: "0.0000504",
|
|
40
|
+
source: "DeepInfra response usage.estimated_cost",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("response cost normalization is an explicit adapter capability", () => {
|
|
45
|
+
assert.equal(authoritativeChargeNormalizer("@ai-sdk/anthropic"), undefined);
|
|
46
|
+
assert.equal(
|
|
47
|
+
authoritativeChargeNormalizer("@ai-sdk/xai")!(evidence({ usage: {} })),
|
|
48
|
+
undefined,
|
|
49
|
+
);
|
|
50
|
+
assert.throws(
|
|
51
|
+
() => authoritativeChargeNormalizer("@ai-sdk/xai")!(evidence({ usage: { cost_in_usd_ticks: "1" } })),
|
|
52
|
+
/cost_in_usd_ticks must be numeric/,
|
|
53
|
+
);
|
|
54
|
+
assert.throws(
|
|
55
|
+
() => authoritativeChargeNormalizer("@ai-sdk/deepinfra")!(evidence({ usage: { estimated_cost: "1" } })),
|
|
56
|
+
/estimated_cost must be numeric/,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthoritativeChargeNormalizer,
|
|
3
|
+
} from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const recordOf = (value: unknown): Record<string, unknown> | null =>
|
|
6
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
7
|
+
? value as Record<string, unknown>
|
|
8
|
+
: null;
|
|
9
|
+
|
|
10
|
+
const decimalFromNumber = (value: number, subject: string): string => {
|
|
11
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
12
|
+
throw new TypeError(`${subject} must be a finite non-negative number`);
|
|
13
|
+
}
|
|
14
|
+
const source = String(value);
|
|
15
|
+
if (!/[eE]/.test(source)) return source;
|
|
16
|
+
const [coefficient, exponentSource] = source.toLowerCase().split("e");
|
|
17
|
+
const exponent = Number(exponentSource);
|
|
18
|
+
const [integer, fraction = ""] = coefficient!.split(".");
|
|
19
|
+
const digits = `${integer}${fraction}`;
|
|
20
|
+
const point = integer!.length + exponent;
|
|
21
|
+
if (point <= 0) return `0.${"0".repeat(-point)}${digits}`;
|
|
22
|
+
if (point >= digits.length) return `${digits}${"0".repeat(point - digits.length)}`;
|
|
23
|
+
return `${digits.slice(0, point)}.${digits.slice(point)}`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const usdFromTicks = (ticks: number): string => {
|
|
27
|
+
if (!Number.isSafeInteger(ticks) || ticks < 0) {
|
|
28
|
+
throw new TypeError("xAI costInUsdTicks must be a non-negative safe integer");
|
|
29
|
+
}
|
|
30
|
+
const digits = String(ticks).padStart(11, "0");
|
|
31
|
+
const integer = digits.slice(0, -10).replace(/^0+(?=\d)/, "");
|
|
32
|
+
const fraction = digits.slice(-10).replace(/0+$/, "");
|
|
33
|
+
return fraction === "" ? integer : `${integer}.${fraction}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const xaiCharge: AuthoritativeChargeNormalizer = ({ usage }) => {
|
|
37
|
+
const wireUsage = recordOf(usage);
|
|
38
|
+
if (wireUsage === null || !("cost_in_usd_ticks" in wireUsage)) return undefined;
|
|
39
|
+
const ticks = wireUsage.cost_in_usd_ticks;
|
|
40
|
+
if (typeof ticks !== "number") {
|
|
41
|
+
throw new TypeError("xAI usage.cost_in_usd_ticks must be numeric");
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
kind: "authoritative",
|
|
45
|
+
amount: { amount: String(ticks), currency: "USDTICK" },
|
|
46
|
+
usdEquivalent: usdFromTicks(ticks),
|
|
47
|
+
source: "xAI response usage.cost_in_usd_ticks",
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const openRouterCharge: AuthoritativeChargeNormalizer = ({ providerMetadata }) => {
|
|
52
|
+
const usage = recordOf(recordOf(recordOf(providerMetadata)?.openrouter)?.usage);
|
|
53
|
+
if (usage === null || !("cost" in usage)) return undefined;
|
|
54
|
+
const cost = usage.cost;
|
|
55
|
+
if (typeof cost !== "number") throw new TypeError("OpenRouter usage.cost must be numeric");
|
|
56
|
+
const amount = decimalFromNumber(cost, "OpenRouter usage.cost");
|
|
57
|
+
return {
|
|
58
|
+
kind: "authoritative",
|
|
59
|
+
amount: { amount, currency: "USD" },
|
|
60
|
+
usdEquivalent: amount,
|
|
61
|
+
source: "OpenRouter response usage.cost",
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const deepInfraCharge: AuthoritativeChargeNormalizer = ({ usage }) => {
|
|
66
|
+
const wireUsage = recordOf(usage);
|
|
67
|
+
if (wireUsage === null || !("estimated_cost" in wireUsage)) return undefined;
|
|
68
|
+
const cost = wireUsage.estimated_cost;
|
|
69
|
+
if (typeof cost !== "number") throw new TypeError("DeepInfra usage.estimated_cost must be numeric");
|
|
70
|
+
const amount = decimalFromNumber(cost, "DeepInfra usage.estimated_cost");
|
|
71
|
+
return {
|
|
72
|
+
kind: "authoritative",
|
|
73
|
+
amount: { amount, currency: "USD" },
|
|
74
|
+
usdEquivalent: amount,
|
|
75
|
+
source: "DeepInfra response usage.estimated_cost",
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const authoritativeChargeNormalizer = (
|
|
80
|
+
sdkPackage: string,
|
|
81
|
+
): AuthoritativeChargeNormalizer | undefined => {
|
|
82
|
+
switch (sdkPackage) {
|
|
83
|
+
case "@ai-sdk/xai": return xaiCharge;
|
|
84
|
+
case "@ai-sdk/deepinfra": return deepInfraCharge;
|
|
85
|
+
case "@openrouter/ai-sdk-provider": return openRouterCharge;
|
|
86
|
+
default: return undefined;
|
|
87
|
+
}
|
|
88
|
+
};
|
package/src/aiSdkTransport.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createOpenAICompatible, type ProviderErrorStructure } from "@ai-sdk/openai-compatible";
|
|
2
|
-
import { generateText, streamText, type JSONValue, type LanguageModel, type LanguageModelUsage } from "ai";
|
|
2
|
+
import { APICallError, generateText, streamText, type JSONValue, type LanguageModel, type LanguageModelUsage } from "ai";
|
|
3
|
+
import { prepareRetries } from "ai/internal";
|
|
3
4
|
import { z } from "zod/v4";
|
|
4
|
-
import type { ChatMessage, ProviderAttemptFinishReason, ProviderUsage, TokenLogprob } from "./types.ts";
|
|
5
|
+
import type { ChatMessage, ProviderAttemptFinishReason, ProviderChargeEvidence, ProviderUsage, TokenLogprob } from "./types.ts";
|
|
5
6
|
import { normalizeUsage, type RawUsage } from "./usage.ts";
|
|
6
7
|
import { emitWarningOnce } from "./warnings.ts";
|
|
7
8
|
|
|
@@ -66,6 +67,14 @@ const wireUsageOf = (
|
|
|
66
67
|
return null;
|
|
67
68
|
};
|
|
68
69
|
|
|
70
|
+
const wireUsageEvidenceOf = (values: readonly unknown[]): unknown => {
|
|
71
|
+
for (let index = values.length - 1; index >= 0; index -= 1) {
|
|
72
|
+
const record = recordOf(values[index]);
|
|
73
|
+
if (record !== null && record.usage !== undefined) return record.usage;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
};
|
|
77
|
+
|
|
69
78
|
const finishReasonOf = (reason: string | undefined): ProviderAttemptFinishReason => {
|
|
70
79
|
switch (reason?.toLowerCase()) {
|
|
71
80
|
case "stop":
|
|
@@ -146,6 +155,7 @@ export type AiSdkTransportResponse = {
|
|
|
146
155
|
encrypted: Array<{ data: string; format: string | null }>;
|
|
147
156
|
}>;
|
|
148
157
|
logprobs: TokenLogprob[];
|
|
158
|
+
chargeEvidence: ProviderChargeEvidence;
|
|
149
159
|
rawBody?: unknown;
|
|
150
160
|
};
|
|
151
161
|
|
|
@@ -163,8 +173,46 @@ export type AiSdkModelRequest = Omit<AiSdkTransportRequest, "url" | "model" | "b
|
|
|
163
173
|
reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "none" | "provider-default";
|
|
164
174
|
};
|
|
165
175
|
|
|
176
|
+
const isStreamIdleTimeout = (cause: unknown): cause is Error | DOMException =>
|
|
177
|
+
typeof cause === "object"
|
|
178
|
+
&& cause !== null
|
|
179
|
+
&& (cause as { name?: string }).name === "TimeoutError"
|
|
180
|
+
&& /chunk timeout/i.test(String((cause as { message?: unknown }).message ?? ""));
|
|
181
|
+
|
|
166
182
|
const executeModel = async (
|
|
167
183
|
request: AiSdkModelRequest,
|
|
184
|
+
): Promise<AiSdkTransportResponse> => {
|
|
185
|
+
const timeoutSignal = AbortSignal.timeout(request.fetchTimeoutMs);
|
|
186
|
+
const operationSignal = request.signal === undefined
|
|
187
|
+
? timeoutSignal
|
|
188
|
+
: AbortSignal.any([request.signal, timeoutSignal]);
|
|
189
|
+
const { retry } = prepareRetries({
|
|
190
|
+
maxRetries: request.retryAttempts,
|
|
191
|
+
abortSignal: operationSignal,
|
|
192
|
+
});
|
|
193
|
+
return retry(async () => {
|
|
194
|
+
try {
|
|
195
|
+
return await executeModelOnce({
|
|
196
|
+
...request,
|
|
197
|
+
signal: operationSignal,
|
|
198
|
+
retryAttempts: 0,
|
|
199
|
+
});
|
|
200
|
+
} catch (cause) {
|
|
201
|
+
if (operationSignal.aborted) throw operationSignal.reason;
|
|
202
|
+
if (!isStreamIdleTimeout(cause)) throw cause;
|
|
203
|
+
throw new APICallError({
|
|
204
|
+
message: cause.message,
|
|
205
|
+
url: "model:generation",
|
|
206
|
+
requestBodyValues: {},
|
|
207
|
+
cause,
|
|
208
|
+
isRetryable: true,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const executeModelOnce = async (
|
|
215
|
+
request: AiSdkModelRequest,
|
|
168
216
|
): Promise<AiSdkTransportResponse> => {
|
|
169
217
|
const {
|
|
170
218
|
languageModel: model,
|
|
@@ -227,6 +275,7 @@ const executeModel = async (
|
|
|
227
275
|
const rawBody = result.response.body;
|
|
228
276
|
const values = [rawBody];
|
|
229
277
|
const evidence = extractEvidence(values);
|
|
278
|
+
const accountingUsage = wireUsageEvidenceOf(values);
|
|
230
279
|
const reasoningText = evidence.reasoning || result.reasoningText || "";
|
|
231
280
|
const rawFinishReason = result.rawFinishReason;
|
|
232
281
|
return {
|
|
@@ -241,6 +290,18 @@ const executeModel = async (
|
|
|
241
290
|
metadata: metadataOf(values),
|
|
242
291
|
reasoningEncrypted: evidence.reasoningEncrypted,
|
|
243
292
|
logprobs: evidence.logprobs,
|
|
293
|
+
chargeEvidence: {
|
|
294
|
+
...(accountingUsage === undefined ? {} : { usage: accountingUsage }),
|
|
295
|
+
...(result.providerMetadata === undefined
|
|
296
|
+
? {}
|
|
297
|
+
: { providerMetadata: result.providerMetadata }),
|
|
298
|
+
response: {
|
|
299
|
+
id: result.response.id,
|
|
300
|
+
...(result.response.headers === undefined
|
|
301
|
+
? {}
|
|
302
|
+
: { headers: result.response.headers }),
|
|
303
|
+
},
|
|
304
|
+
},
|
|
244
305
|
...(request.captureRawBody ? { rawBody } : {}),
|
|
245
306
|
};
|
|
246
307
|
}
|
|
@@ -258,11 +319,16 @@ const executeModel = async (
|
|
|
258
319
|
}
|
|
259
320
|
if (streamError !== undefined) throw streamError;
|
|
260
321
|
const evidence = extractEvidence(rawChunks);
|
|
322
|
+
const accountingUsage = wireUsageEvidenceOf(rawChunks);
|
|
261
323
|
const content = await result.text;
|
|
262
324
|
const reasoningText = evidence.reasoning || (await result.reasoningText) || "";
|
|
263
325
|
const rawFinishReason = await result.rawFinishReason;
|
|
326
|
+
const [response, providerMetadata] = await Promise.all([
|
|
327
|
+
result.response,
|
|
328
|
+
result.providerMetadata,
|
|
329
|
+
]);
|
|
264
330
|
return {
|
|
265
|
-
model:
|
|
331
|
+
model: response.modelId,
|
|
266
332
|
content,
|
|
267
333
|
reasoning: reasoningText,
|
|
268
334
|
reasoningProjected: evidence.reasoningProjected,
|
|
@@ -273,6 +339,14 @@ const executeModel = async (
|
|
|
273
339
|
metadata: metadataOf(rawChunks),
|
|
274
340
|
reasoningEncrypted: evidence.reasoningEncrypted,
|
|
275
341
|
logprobs: evidence.logprobs,
|
|
342
|
+
chargeEvidence: {
|
|
343
|
+
...(accountingUsage === undefined ? {} : { usage: accountingUsage }),
|
|
344
|
+
...(providerMetadata === undefined ? {} : { providerMetadata }),
|
|
345
|
+
response: {
|
|
346
|
+
id: response.id,
|
|
347
|
+
...(response.headers === undefined ? {} : { headers: response.headers }),
|
|
348
|
+
},
|
|
349
|
+
},
|
|
276
350
|
...(request.captureRawBody ? { rawBody: rawChunks } : {}),
|
|
277
351
|
};
|
|
278
352
|
};
|
package/src/boundaries.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import test, { mock } from "node:test";
|
|
2
2
|
import { strict as assert } from "node:assert";
|
|
3
3
|
import { catalogProviderFromEnv } from "./catalogProvider.ts";
|
|
4
|
+
import { providerCostFor } from "./cost.ts";
|
|
4
5
|
import { resetEmittedWarnings } from "./warnings.ts";
|
|
5
6
|
|
|
6
7
|
const env = {
|
|
@@ -119,22 +120,32 @@ test("cataloged unknown model fails unless its context is explicit", () => {
|
|
|
119
120
|
assert.equal(provider?.contextWindow, 8192);
|
|
120
121
|
});
|
|
121
122
|
|
|
122
|
-
test("
|
|
123
|
-
const
|
|
123
|
+
test("Models.dev is the only fallback rate table", () => {
|
|
124
|
+
const usage = {
|
|
125
|
+
prompt: 1_000,
|
|
126
|
+
cached: 400,
|
|
127
|
+
completion: 100,
|
|
128
|
+
reasoning: 50,
|
|
129
|
+
total: 1_150,
|
|
130
|
+
};
|
|
131
|
+
const cataloged = catalogProviderFromEnv("deepseek", {
|
|
132
|
+
...env,
|
|
133
|
+
DEEPSEEK_API_KEY: "test-key",
|
|
134
|
+
}, "deepseek-v4-flash");
|
|
135
|
+
assert.notEqual(cataloged, null);
|
|
136
|
+
assert.deepEqual(providerCostFor(cataloged!, usage), {
|
|
137
|
+
kind: "estimated",
|
|
138
|
+
usd: "0.00012712",
|
|
139
|
+
source: "Models.dev catalog rates",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const uncataloged = catalogProviderFromEnv("xai", {
|
|
124
143
|
...env,
|
|
125
144
|
XAI_API_KEY: "test-key",
|
|
126
145
|
PLURNK_PROVIDERS_CONTEXT_WINDOW: "8192",
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
prompt: 1_000_000,
|
|
133
|
-
cached: 250_000,
|
|
134
|
-
completion: 500_000,
|
|
135
|
-
reasoning: 500_000,
|
|
136
|
-
total: 2_000_000,
|
|
137
|
-
};
|
|
138
|
-
assert.equal(catalogProviderFromEnv("xai", priced, "not-in-the-catalog")?.calculateCost(usage), 9.625);
|
|
139
|
-
assert.equal(catalogProviderFromEnv("openai", priced, "gpt-4.1-mini")?.calculateCost(usage), 9.625);
|
|
146
|
+
}, "not-in-the-catalog");
|
|
147
|
+
assert.deepEqual(providerCostFor(uncataloged!, usage), {
|
|
148
|
+
kind: "unknown",
|
|
149
|
+
reason: "the response reported no cost and Models.dev has no rate for this model",
|
|
150
|
+
});
|
|
140
151
|
});
|
package/src/catalogProvider.ts
CHANGED
|
@@ -10,14 +10,13 @@ import {
|
|
|
10
10
|
reasoningFromEnv,
|
|
11
11
|
reasoningResponseStyleFromEnv,
|
|
12
12
|
resolveReserve,
|
|
13
|
-
tokenRatesFromEnv,
|
|
14
13
|
type ReserveSpec,
|
|
15
14
|
} from "./env.ts";
|
|
16
15
|
import AiSdkProvider, { type ReasoningStyle } from "./AiSdkProvider.ts";
|
|
17
16
|
import { configuredProviderInfo, createSdkModel } from "./sdkModels.ts";
|
|
18
17
|
import { providerSource } from "./notices.ts";
|
|
19
|
-
import type { Provider, ProviderUsage } from "./types.ts";
|
|
20
|
-
import { calculateCostUsd } from "./usage.ts";
|
|
18
|
+
import type { AuthoritativeChargeNormalizer, Provider, ProviderUsage } from "./types.ts";
|
|
19
|
+
import { calculateCostUsd, calculateCostUsdDecimal } from "./usage.ts";
|
|
21
20
|
import { emitWarningOnce } from "./warnings.ts";
|
|
22
21
|
import type { LanguageModel } from "ai";
|
|
23
22
|
import type { PluginAttribution, PluginAttributionContext } from "@plurnk/plurnk-meta";
|
|
@@ -45,6 +44,7 @@ export const providerFromSdkModel = ({
|
|
|
45
44
|
env,
|
|
46
45
|
model,
|
|
47
46
|
languageModel,
|
|
47
|
+
normalizeCharge,
|
|
48
48
|
url,
|
|
49
49
|
headers,
|
|
50
50
|
contextWindow,
|
|
@@ -55,6 +55,7 @@ export const providerFromSdkModel = ({
|
|
|
55
55
|
env: NodeJS.ProcessEnv;
|
|
56
56
|
model: string;
|
|
57
57
|
languageModel?: LanguageModel;
|
|
58
|
+
normalizeCharge?: AuthoritativeChargeNormalizer;
|
|
58
59
|
url?: string;
|
|
59
60
|
headers?: Readonly<Record<string, string>>;
|
|
60
61
|
contextWindow: number;
|
|
@@ -82,31 +83,30 @@ export const providerFromSdkModel = ({
|
|
|
82
83
|
? configuredReasoning
|
|
83
84
|
: { tokens: Math.round(completionTokens / 2) };
|
|
84
85
|
|
|
85
|
-
const configuredRates = tokenRatesFromEnv(env, name);
|
|
86
86
|
const catalogCost = info?.cost;
|
|
87
|
-
const rates =
|
|
87
|
+
const rates = catalogCost === undefined ? null : {
|
|
88
88
|
input: catalogCost.inputPer1M,
|
|
89
89
|
output: catalogCost.outputPer1M,
|
|
90
90
|
cached: catalogCost.cacheReadPer1M ?? catalogCost.inputPer1M,
|
|
91
|
-
}
|
|
91
|
+
};
|
|
92
92
|
const calculateCost = rates === null
|
|
93
93
|
? undefined
|
|
94
94
|
: (usage: ProviderUsage): number => calculateCostUsd(usage, rates);
|
|
95
|
-
const rateSource = configuredRates === null ? "Models.dev catalog rates" : "operator-configured rates";
|
|
96
95
|
const calculateCharge: (usage: ProviderUsage) => Exclude<ProviderCost, { kind: "authoritative" }> = rates === null
|
|
97
|
-
? () => ({ kind: "unknown", reason: "no
|
|
96
|
+
? () => ({ kind: "unknown", reason: "the response reported no cost and Models.dev has no rate for this model" })
|
|
98
97
|
: rates.input === 0 && rates.output === 0 && rates.cached === 0
|
|
99
|
-
? () => ({ kind: "free", source:
|
|
98
|
+
? () => ({ kind: "free", source: "Models.dev catalog rates" })
|
|
100
99
|
: (usage: ProviderUsage) => ({
|
|
101
100
|
kind: "estimated",
|
|
102
|
-
usd:
|
|
103
|
-
source:
|
|
101
|
+
usd: calculateCostUsdDecimal(usage, rates),
|
|
102
|
+
source: "Models.dev catalog rates",
|
|
104
103
|
});
|
|
105
104
|
|
|
106
105
|
return new AiSdkProvider({
|
|
107
106
|
model,
|
|
108
107
|
...(attributions === undefined ? {} : { attributions }),
|
|
109
108
|
...(languageModel === undefined ? {} : { languageModel }),
|
|
109
|
+
...(normalizeCharge === undefined ? {} : { normalizeCharge }),
|
|
110
110
|
...(url === undefined ? {} : { url }),
|
|
111
111
|
...(headers === undefined ? {} : { headers: { ...headers } }),
|
|
112
112
|
contextWindow,
|
|
@@ -165,6 +165,7 @@ export const catalogProviderFromEnv = (
|
|
|
165
165
|
env,
|
|
166
166
|
model: wireModel,
|
|
167
167
|
languageModel: sdk.languageModel,
|
|
168
|
+
normalizeCharge: sdk.normalizeCharge,
|
|
168
169
|
url: sdk.compatible?.url,
|
|
169
170
|
headers: sdk.compatible?.headers,
|
|
170
171
|
contextWindow,
|
package/src/cost.test.ts
CHANGED
|
@@ -39,18 +39,19 @@ test("explicit free remains distinguishable from unknown", () => {
|
|
|
39
39
|
assert.deepEqual(free, { kind: "free", source: "local model" });
|
|
40
40
|
assert.deepEqual(unknown, {
|
|
41
41
|
kind: "unknown",
|
|
42
|
-
reason: "
|
|
42
|
+
reason: "the response reported no cost and Models.dev has no rate for this model",
|
|
43
43
|
});
|
|
44
44
|
assert.equal(providerCostUsd(free), 0);
|
|
45
45
|
assert.equal(providerCostUsd(unknown), null);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
test("
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
test("legacy calculateCost is not a monetary-reporting authority", () => {
|
|
49
|
+
const cost = providerCostFor({ calculateCost: () => 0.25 }, usage);
|
|
50
|
+
assert.deepEqual(cost, {
|
|
51
|
+
kind: "unknown",
|
|
52
|
+
reason: "the response reported no cost and Models.dev has no rate for this model",
|
|
53
53
|
});
|
|
54
|
+
assert.equal(providerCostUsd(cost), null);
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
test("rejects malformed money instead of mining or coercing it", () => {
|
package/src/cost.ts
CHANGED
|
@@ -48,17 +48,13 @@ export const validateProviderCost = (cost: ProviderCost): ProviderCost => {
|
|
|
48
48
|
export const resolveProviderCost = (
|
|
49
49
|
charge: AuthoritativeCharge | undefined,
|
|
50
50
|
current: ProviderCost | undefined,
|
|
51
|
-
legacy: () => number,
|
|
52
51
|
): ProviderCost => {
|
|
53
52
|
if (charge !== undefined) return validateAuthoritativeCharge(charge);
|
|
54
53
|
if (current !== undefined) return validateProviderCost(current);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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" };
|
|
54
|
+
return {
|
|
55
|
+
kind: "unknown",
|
|
56
|
+
reason: "the response reported no cost and Models.dev has no rate for this model",
|
|
57
|
+
};
|
|
62
58
|
};
|
|
63
59
|
|
|
64
60
|
export const providerCostUsd = (cost: ProviderCost): number | null => {
|
|
@@ -79,5 +75,4 @@ export const providerCostFor = (
|
|
|
79
75
|
): ProviderCost => resolveProviderCost(
|
|
80
76
|
charge,
|
|
81
77
|
provider.calculateCharge?.(usage),
|
|
82
|
-
() => provider.calculateCost(usage),
|
|
83
78
|
);
|
package/src/env.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import { strict as assert } from "node:assert";
|
|
3
|
-
import { parseRequiredInt, parseOptionalInt, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv
|
|
3
|
+
import { parseRequiredInt, parseOptionalInt, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv } from "./env.ts";
|
|
4
4
|
|
|
5
5
|
test("parseRequiredInt: parses a non-negative integer", () => {
|
|
6
6
|
assert.equal(parseRequiredInt("600000", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), 600000);
|
|
@@ -59,31 +59,6 @@ test("requireEnv: returns the value or throws a named error", () => {
|
|
|
59
59
|
assert.throws(() => requireEnv("", "GROQ_API_KEY", "groq"), /must be set/);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
test("tokenRatesFromEnv is all-or-nothing, with cached input defaulting to input", () => {
|
|
63
|
-
assert.equal(tokenRatesFromEnv({}, "cloudflare"), null);
|
|
64
|
-
assert.deepEqual(tokenRatesFromEnv({
|
|
65
|
-
PLURNK_PROVIDERS_INPUT_USD_PER_MILLION: "0.435",
|
|
66
|
-
PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION: "0.87",
|
|
67
|
-
}, "cloudflare"), {
|
|
68
|
-
input: 0.435,
|
|
69
|
-
cached: 0.435,
|
|
70
|
-
output: 0.87,
|
|
71
|
-
});
|
|
72
|
-
assert.deepEqual(tokenRatesFromEnv({
|
|
73
|
-
PLURNK_PROVIDERS_INPUT_USD_PER_MILLION: "3",
|
|
74
|
-
PLURNK_PROVIDERS_CACHE_READ_USD_PER_MILLION: "0.3",
|
|
75
|
-
PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION: "15",
|
|
76
|
-
}, "cloudflare"), {
|
|
77
|
-
input: 3,
|
|
78
|
-
cached: 0.3,
|
|
79
|
-
output: 15,
|
|
80
|
-
});
|
|
81
|
-
assert.throws(
|
|
82
|
-
() => tokenRatesFromEnv({ PLURNK_PROVIDERS_INPUT_USD_PER_MILLION: "1" }, "cloudflare"),
|
|
83
|
-
/PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION must be set/,
|
|
84
|
-
);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
62
|
// — per-alias knob scoping (per-alias scoping doctrine, user 2026-07-03) —
|
|
88
63
|
|
|
89
64
|
test("scopeEnvToAlias: suffixed knob wins, bare is the fallback, other aliases ignored", async () => {
|
package/src/env.ts
CHANGED
|
@@ -91,27 +91,6 @@ export function effectiveContextWindow(operatorCap: number | null, naturalWindow
|
|
|
91
91
|
: Math.min(operatorCap, naturalWindow);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
export type ProviderTokenRates = { input: number; cached: number; output: number };
|
|
95
|
-
|
|
96
|
-
// {§model-fact-resolution} — any explicit rate opts into one complete operator
|
|
97
|
-
// estimate; cached input alone may default to the explicit input rate.
|
|
98
|
-
export const tokenRatesFromEnv = (env: NodeJS.ProcessEnv, label: string): ProviderTokenRates | null => {
|
|
99
|
-
const inputName = "PLURNK_PROVIDERS_INPUT_USD_PER_MILLION";
|
|
100
|
-
const cachedName = "PLURNK_PROVIDERS_CACHE_READ_USD_PER_MILLION";
|
|
101
|
-
const outputName = "PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION";
|
|
102
|
-
const configured = [inputName, cachedName, outputName]
|
|
103
|
-
.some((name) => env[name] !== undefined && env[name] !== "");
|
|
104
|
-
if (!configured) return null;
|
|
105
|
-
const input = parseRequiredFloat(env[inputName], inputName, label, 0);
|
|
106
|
-
return {
|
|
107
|
-
input,
|
|
108
|
-
cached: env[cachedName] === undefined || env[cachedName] === ""
|
|
109
|
-
? input
|
|
110
|
-
: parseRequiredFloat(env[cachedName], cachedName, label, 0),
|
|
111
|
-
output: parseRequiredFloat(env[outputName], outputName, label, 0),
|
|
112
|
-
};
|
|
113
|
-
};
|
|
114
|
-
|
|
115
94
|
// {§provider-generation-envelope} How much of a
|
|
116
95
|
// DETECTED context window is reserved for reasoning and for completion — the
|
|
117
96
|
// remainder (minus the consumer's own packing-safety margin) is the prompt
|
|
@@ -218,9 +197,6 @@ export const PROVIDERS_KNOBS = Object.freeze([
|
|
|
218
197
|
"PLURNK_PROVIDERS_REASONING_BUDGET",
|
|
219
198
|
"PLURNK_PROVIDERS_REASONING",
|
|
220
199
|
"PLURNK_PROVIDERS_CONTEXT_WINDOW",
|
|
221
|
-
"PLURNK_PROVIDERS_INPUT_USD_PER_MILLION",
|
|
222
|
-
"PLURNK_PROVIDERS_CACHE_READ_USD_PER_MILLION",
|
|
223
|
-
"PLURNK_PROVIDERS_OUTPUT_USD_PER_MILLION",
|
|
224
200
|
"PLURNK_PROVIDERS_RETRY_ATTEMPTS",
|
|
225
201
|
"PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT",
|
|
226
202
|
"PLURNK_PROVIDERS_FETCH_TIMEOUT",
|
package/src/errors.ts
CHANGED
|
@@ -158,6 +158,7 @@ export const classifyProviderError = (
|
|
|
158
158
|
if (status === 401 || status === 403) return { kind: "unauthorized", message };
|
|
159
159
|
if (status === 402) return { kind: "quota_exceeded", message };
|
|
160
160
|
if (status === 429) return { kind: "rate_limit", message };
|
|
161
|
+
if (status === 0 && err.isRetryable) return { kind: "network_failure", message };
|
|
161
162
|
if (status >= 500) return { kind: "network_failure", message };
|
|
162
163
|
if (status === 422 && wireErrorType(body) === "grammar_invalid") {
|
|
163
164
|
return { kind: "grammar_invalid", message };
|
package/src/index.ts
CHANGED
|
@@ -45,7 +45,7 @@ export { default as Pool } from "./Pool.ts";
|
|
|
45
45
|
export type { ProviderFetch } from "./AiSdkProvider.ts";
|
|
46
46
|
export { parseRequiredInt, parseOptionalInt, parseRequiredFloat, parseOptionalFloat, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv, scopeEnvToAlias, dataCaptureFromEnv, contextWindowFromEnv, effectiveContextWindow, envelopeFromEnv, resolveReserve, PROVIDERS_KNOBS } from "./env.ts";
|
|
47
47
|
export type { Reasoning, ReasoningMode, ReasoningResponseStyle, ReserveSpec } from "./env.ts";
|
|
48
|
-
export { normalizeUsage, calculateCostUsd } from "./usage.ts";
|
|
48
|
+
export { normalizeUsage, calculateCostUsd, calculateCostUsdDecimal } from "./usage.ts";
|
|
49
49
|
export {
|
|
50
50
|
providerCostFor,
|
|
51
51
|
providerCostUsd,
|
package/src/sdkModels.test.ts
CHANGED
|
@@ -19,9 +19,28 @@ test("createSdkModel uses Models.dev provider facts and operator credentials", (
|
|
|
19
19
|
const sdk = createSdkModel("xai", "grok-build-0.1", { XAI_API_KEY: "test-key" });
|
|
20
20
|
assert.notEqual(sdk, null);
|
|
21
21
|
assert.equal(sdk?.catalog?.npm, "@ai-sdk/xai");
|
|
22
|
-
|
|
23
|
-
assert.
|
|
24
|
-
|
|
22
|
+
assert.equal(sdk?.languageModel, undefined);
|
|
23
|
+
assert.deepEqual(sdk?.compatible, {
|
|
24
|
+
url: "https://api.x.ai/v1/chat/completions",
|
|
25
|
+
headers: { Authorization: "Bearer test-key" },
|
|
26
|
+
});
|
|
27
|
+
assert.notEqual(sdk?.normalizeCharge, undefined);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("createSdkModel attaches DeepInfra's documented response-cost normalizer", () => {
|
|
31
|
+
const sdk = createSdkModel("deepinfra", "zai-org/GLM-5.2", {
|
|
32
|
+
DEEPINFRA_API_KEY: "test-key",
|
|
33
|
+
});
|
|
34
|
+
assert.notEqual(sdk?.languageModel, undefined);
|
|
35
|
+
assert.deepEqual(sdk?.normalizeCharge?.({
|
|
36
|
+
usage: { estimated_cost: 5.04e-5 },
|
|
37
|
+
response: { id: "response-1" },
|
|
38
|
+
}), {
|
|
39
|
+
kind: "authoritative",
|
|
40
|
+
amount: { amount: "0.0000504", currency: "USD" },
|
|
41
|
+
usdEquivalent: "0.0000504",
|
|
42
|
+
source: "DeepInfra response usage.estimated_cost",
|
|
43
|
+
});
|
|
25
44
|
});
|
|
26
45
|
|
|
27
46
|
test("createSdkModel expands catalog endpoint variables without treating them as credentials", () => {
|