@plurnk/plurnk-providers 1.16.5 → 1.18.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 +87 -200
- package/README.md +2 -1
- package/SPEC.md +73 -66
- package/dist/AiSdkProvider.d.ts +0 -1
- package/dist/AiSdkProvider.d.ts.map +1 -1
- package/dist/AiSdkProvider.js +25 -55
- package/dist/AiSdkProvider.js.map +1 -1
- package/dist/AiSdkRequestBody.d.ts +0 -1
- package/dist/AiSdkRequestBody.d.ts.map +1 -1
- package/dist/AiSdkRequestBody.js +1 -17
- package/dist/AiSdkRequestBody.js.map +1 -1
- package/dist/LeadingReasoning.d.ts +16 -0
- package/dist/LeadingReasoning.d.ts.map +1 -0
- package/dist/LeadingReasoning.js +81 -0
- package/dist/LeadingReasoning.js.map +1 -0
- package/dist/aiSdkTransport.d.ts +7 -0
- package/dist/aiSdkTransport.d.ts.map +1 -1
- package/dist/aiSdkTransport.js +19 -5
- 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 +24 -18
- package/dist/catalogProvider.js.map +1 -1
- package/dist/compatibleProvider.d.ts.map +1 -1
- package/dist/compatibleProvider.js +0 -3
- package/dist/compatibleProvider.js.map +1 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +0 -1
- package/dist/env.js.map +1 -1
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +46 -15
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/providerError.d.ts +1 -1
- package/dist/providerError.d.ts.map +1 -1
- package/dist/providerError.js +3 -0
- package/dist/providerError.js.map +1 -1
- package/dist/sdkModels.d.ts +1 -13
- package/dist/sdkModels.d.ts.map +1 -1
- package/dist/sdkModels.js +2 -94
- package/dist/sdkModels.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +18 -9
- package/dist/usage.js.map +1 -1
- package/docs/models.md +90 -0
- package/package.json +6 -6
- package/src/AiSdkProvider.test.ts +99 -38
- package/src/AiSdkProvider.ts +24 -78
- package/src/AiSdkRequestBody.ts +1 -16
- package/src/LeadingReasoning.test.ts +34 -0
- package/src/LeadingReasoning.ts +87 -0
- package/src/ProviderRegistry.test.ts +19 -1
- package/src/aiSdkTransport.test.ts +56 -0
- package/src/aiSdkTransport.ts +31 -5
- package/src/boundaries.test.ts +1 -0
- package/src/catalogProvider.test.ts +110 -20
- package/src/catalogProvider.ts +40 -18
- package/src/compatibleProvider.ts +0 -3
- package/src/env.test.ts +1 -0
- package/src/env.ts +1 -2
- package/src/errors.test.ts +58 -7
- package/src/errors.ts +55 -18
- package/src/index.ts +2 -2
- package/src/providerError.ts +4 -0
- package/src/sdkModels.test.ts +1 -42
- package/src/sdkModels.ts +1 -123
- package/src/types.ts +8 -10
- package/src/usage.ts +23 -15
package/src/errors.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 { APICallError, RetryError } from "ai";
|
|
3
|
+
import { APICallError, JSONParseError, RetryError, TypeValidationError } from "ai";
|
|
4
4
|
import { ProviderError, ProviderTimeoutError, classifyProviderError, toProviderError } from "./errors.ts";
|
|
5
5
|
import type { ProviderAttempt } from "./types.ts";
|
|
6
6
|
import { providerSource } from "./notices.ts";
|
|
@@ -15,6 +15,22 @@ const apiError = (statusCode: number, responseBody = "body") => new APICallError
|
|
|
15
15
|
|
|
16
16
|
const SOURCE_PATTERN = /^[a-z]+(:[a-z][a-z0-9-]*)?$/;
|
|
17
17
|
|
|
18
|
+
test("HTTP request rejection preserves its cause without becoming a response-contract strike", () => {
|
|
19
|
+
for (const status of [400, 404, 405, 422]) {
|
|
20
|
+
const message = "The requested model is not available on this endpoint.";
|
|
21
|
+
const cause = apiError(status, JSON.stringify({ error: { message } }));
|
|
22
|
+
Object.defineProperty(cause, "message", { value: message });
|
|
23
|
+
const failure = toProviderError(cause, "provider:example");
|
|
24
|
+
assert.equal(failure.kind, "request_rejected");
|
|
25
|
+
assert.equal(failure.status, status);
|
|
26
|
+
assert.equal(failure.message, message);
|
|
27
|
+
assert.equal(failure.problem.detail, message);
|
|
28
|
+
assert.equal(failure.problem.retryable, false);
|
|
29
|
+
assert.equal(failure.cause, cause);
|
|
30
|
+
}
|
|
31
|
+
assert.equal(classifyProviderError(apiError(200)).kind, "invalid_response", "a malformed successful response still violates the response contract");
|
|
32
|
+
});
|
|
33
|
+
|
|
18
34
|
test("providerSource produces a schema-valid colon-namespaced source", () => {
|
|
19
35
|
assert.equal(providerSource("openai"), "provider:openai");
|
|
20
36
|
assert.match(providerSource("openrouter"), SOURCE_PATTERN);
|
|
@@ -34,8 +50,8 @@ test("classifyProviderError maps HTTP status to kind", () => {
|
|
|
34
50
|
assert.equal(k(500), "network_failure");
|
|
35
51
|
assert.equal(k(503), "network_failure");
|
|
36
52
|
assert.equal(k(413), "capacity_exceeded");
|
|
37
|
-
assert.equal(k(400), "
|
|
38
|
-
assert.equal(k(404), "
|
|
53
|
+
assert.equal(k(400), "request_rejected");
|
|
54
|
+
assert.equal(k(404), "request_rejected");
|
|
39
55
|
});
|
|
40
56
|
|
|
41
57
|
test("capacity normalization prefers structured provider codes and keeps generic 400s distinct", () => {
|
|
@@ -53,7 +69,7 @@ test("capacity normalization prefers structured provider codes and keeps generic
|
|
|
53
69
|
assert.equal(normalized.problem.providerStatus, 400);
|
|
54
70
|
assert.equal(classifyProviderError(apiError(400, JSON.stringify({
|
|
55
71
|
error: { type: "invalid_request_error", code: "bad_temperature", message: "bad temperature" },
|
|
56
|
-
}))).kind, "
|
|
72
|
+
}))).kind, "request_rejected");
|
|
57
73
|
});
|
|
58
74
|
|
|
59
75
|
test("provider retry directives survive HTTP failure normalization", () => {
|
|
@@ -69,11 +85,11 @@ test("provider retry directives survive HTTP failure normalization", () => {
|
|
|
69
85
|
assert.equal(error.problem.retryable, false);
|
|
70
86
|
});
|
|
71
87
|
|
|
72
|
-
test("classifyProviderError: a 422 flagged grammar_invalid is distinct
|
|
88
|
+
test("classifyProviderError: a 422 flagged grammar_invalid is distinct from other request rejections", () => {
|
|
73
89
|
const rejected = apiError(422, JSON.stringify({ error: { type: "grammar_invalid", message: "non-conforming emission rejected: ..." } }));
|
|
74
90
|
assert.equal(classifyProviderError(rejected).kind, "grammar_invalid");
|
|
75
|
-
assert.equal(classifyProviderError(apiError(422, JSON.stringify({ error: { type: "invalid_request_error" } }))).kind, "
|
|
76
|
-
assert.equal(classifyProviderError(apiError(422, "<html>Bad</html>")).kind, "
|
|
91
|
+
assert.equal(classifyProviderError(apiError(422, JSON.stringify({ error: { type: "invalid_request_error" } }))).kind, "request_rejected");
|
|
92
|
+
assert.equal(classifyProviderError(apiError(422, "<html>Bad</html>")).kind, "request_rejected");
|
|
77
93
|
});
|
|
78
94
|
|
|
79
95
|
test("classifyProviderError treats non-HTTP errors as network_failure", () => {
|
|
@@ -225,3 +241,38 @@ test("retry exhaustion retains the exact inner deadline phase", () => {
|
|
|
225
241
|
assert.equal(error.problem.timeoutPhase, "attempt");
|
|
226
242
|
assert.equal(error.problem.timeoutMs, 10);
|
|
227
243
|
});
|
|
244
|
+
|
|
245
|
+
// {§provider-failure-cause} — the SDK wraps every processing failure of a 2xx body in one message;
|
|
246
|
+
// the durable Problem keeps a bounded classification of what actually happened underneath.
|
|
247
|
+
const processingFailure = (cause: Error) => new APICallError({
|
|
248
|
+
message: "Failed to process successful response",
|
|
249
|
+
url: "https://example.test/v1/chat/completions",
|
|
250
|
+
requestBodyValues: {},
|
|
251
|
+
statusCode: 200,
|
|
252
|
+
responseHeaders: { authorization: "Bearer never-copied", "x-request-id": "req_1" },
|
|
253
|
+
cause,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("#593: a terminated body, invalid JSON, a schema-invalid body, and an internal fault are told apart in durable evidence", () => {
|
|
257
|
+
const terminated = toProviderError(processingFailure(new TypeError("terminated")), "provider:test", 512);
|
|
258
|
+
assert.equal(terminated.problem.providerKind, "network_failure");
|
|
259
|
+
assert.deepEqual(terminated.problem.cause, { causeKind: "transport_terminated", causeName: "TypeError", causeMessage: "terminated" });
|
|
260
|
+
|
|
261
|
+
const parse = toProviderError(processingFailure(new JSONParseError({ text: "<html>502 Bad Gateway</html>".repeat(40), cause: new SyntaxError("Unexpected token <") })), "provider:test", 512);
|
|
262
|
+
assert.equal(parse.problem.providerKind, "invalid_response");
|
|
263
|
+
assert.deepEqual(parse.problem.cause, { causeKind: "invalid_json", causeName: "AI_JSONParseError", causeMessage: "JSON parsing failed (1120 characters)" });
|
|
264
|
+
assert.doesNotMatch(JSON.stringify(parse.problem), /Bad Gateway/, "the payload never enters the Problem");
|
|
265
|
+
|
|
266
|
+
const schema = toProviderError(processingFailure(new TypeValidationError({ value: { choices: "not-an-array", secret: "sk-live" }, cause: new Error("Expected array, received string at choices\nmore lines") })), "provider:test", 512);
|
|
267
|
+
assert.equal(schema.problem.providerKind, "invalid_response");
|
|
268
|
+
assert.deepEqual(schema.problem.cause, { causeKind: "schema_invalid", causeName: "AI_TypeValidationError", causeMessage: "Type validation failed: Expected array, received string at choices" });
|
|
269
|
+
assert.doesNotMatch(JSON.stringify(schema.problem), /sk-live|not-an-array/, "the value never enters the Problem");
|
|
270
|
+
|
|
271
|
+
const internal = toProviderError(processingFailure(new RangeError("Invalid array length\n at Array.push (stack frame)")), "provider:test", 512);
|
|
272
|
+
assert.deepEqual(internal.problem.cause, { causeKind: "internal", causeName: "RangeError", causeMessage: "Invalid array length" });
|
|
273
|
+
assert.doesNotMatch(JSON.stringify(internal.problem), /stack frame|authorization|Bearer|req_1/, "no stack, no headers");
|
|
274
|
+
|
|
275
|
+
const bounded = toProviderError(processingFailure(new Error("x".repeat(100))), "provider:test", 8);
|
|
276
|
+
assert.equal((bounded.problem.cause as { causeMessage: string }).causeMessage, "xxxxxxxx...", "the cause message honours the configured detail limit");
|
|
277
|
+
assert.equal(toProviderError(apiError(502), "provider:test", 512).problem.cause, undefined, "an error without a cause reports none");
|
|
278
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { APICallError, RetryError } from "ai";
|
|
1
|
+
import { APICallError, InvalidResponseDataError, JSONParseError, RetryError, TypeValidationError } from "ai";
|
|
2
2
|
import { ProviderError } from "./providerError.ts";
|
|
3
3
|
import type { ProviderErrorKind } from "./providerError.ts";
|
|
4
4
|
import type { ProviderRequestCapacity } from "./types.ts";
|
|
@@ -81,6 +81,34 @@ const preview = (value: unknown, limit: number | undefined): string => {
|
|
|
81
81
|
: text;
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
export type ProviderFailureCauseKind = "transport_terminated" | "invalid_json" | "schema_invalid" | "invalid_response_data" | "internal";
|
|
85
|
+
|
|
86
|
+
export interface ProviderFailureCause {
|
|
87
|
+
readonly causeKind: ProviderFailureCauseKind;
|
|
88
|
+
readonly causeName: string;
|
|
89
|
+
readonly causeMessage: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const firstLine = (text: string): string => text.split("\n")[0]?.trim() ?? "";
|
|
93
|
+
|
|
94
|
+
// {§provider-failure-cause} — what the SDK wrapped. "Failed to process successful response" is
|
|
95
|
+
// the SDK's one message for a body that terminated, a body that was not JSON, a body that failed
|
|
96
|
+
// the response schema, and its own internal faults; the durable Problem keeps a bounded
|
|
97
|
+
// classification of the cause so forensics can tell them apart. The SDK's parse and validation
|
|
98
|
+
// messages embed the payload, so those two report shape, never text.
|
|
99
|
+
const failureCause = (err: APICallError, detailLimit: number | undefined): ProviderFailureCause | null => {
|
|
100
|
+
const cause = err.cause;
|
|
101
|
+
if (!(cause instanceof Error)) return null;
|
|
102
|
+
if (peerTerminated(cause)) return { causeKind: "transport_terminated", causeName: cause.name, causeMessage: preview(firstLine(cause.message), detailLimit) };
|
|
103
|
+
if (JSONParseError.isInstance(cause)) return { causeKind: "invalid_json", causeName: cause.name, causeMessage: `JSON parsing failed (${cause.text.length} characters)` };
|
|
104
|
+
if (TypeValidationError.isInstance(cause)) {
|
|
105
|
+
const inner = cause.cause instanceof Error ? firstLine(cause.cause.message) : "";
|
|
106
|
+
return { causeKind: "schema_invalid", causeName: cause.name, causeMessage: inner === "" ? "Type validation failed" : `Type validation failed: ${preview(inner, detailLimit)}` };
|
|
107
|
+
}
|
|
108
|
+
if (InvalidResponseDataError.isInstance(cause)) return { causeKind: "invalid_response_data", causeName: cause.name, causeMessage: preview(firstLine(cause.message), detailLimit) };
|
|
109
|
+
return { causeKind: "internal", causeName: cause.name, causeMessage: preview(firstLine(cause.message), detailLimit) };
|
|
110
|
+
};
|
|
111
|
+
|
|
84
112
|
export const classifyProviderError = (
|
|
85
113
|
err: unknown,
|
|
86
114
|
detailLimit?: number,
|
|
@@ -94,6 +122,31 @@ export const classifyProviderError = (
|
|
|
94
122
|
};
|
|
95
123
|
}
|
|
96
124
|
if (APICallError.isInstance(err)) {
|
|
125
|
+
const classified = classifyApiCallError(err, detailLimit);
|
|
126
|
+
const cause = failureCause(err, detailLimit);
|
|
127
|
+
return cause === null ? classified : { ...classified, extensions: { ...(classified.extensions ?? {}), cause } };
|
|
128
|
+
}
|
|
129
|
+
const wire = err as { message?: unknown; type?: unknown };
|
|
130
|
+
if (wire?.type === "grammar_invalid") {
|
|
131
|
+
return {
|
|
132
|
+
kind: "grammar_invalid",
|
|
133
|
+
message: typeof wire.message === "string"
|
|
134
|
+
? preview(wire.message, detailLimit)
|
|
135
|
+
: "The provider rejected the response grammar.",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const error = err as { message?: string };
|
|
139
|
+
return {
|
|
140
|
+
kind: "network_failure",
|
|
141
|
+
message: preview(
|
|
142
|
+
(error?.message ?? String(err)) || "The provider request failed.",
|
|
143
|
+
detailLimit,
|
|
144
|
+
),
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const classifyApiCallError = (err: APICallError, detailLimit: number | undefined): ClassifiedProviderError => {
|
|
149
|
+
{
|
|
97
150
|
const timeout = providerTimeoutOf(err);
|
|
98
151
|
if (timeout !== null) {
|
|
99
152
|
return {
|
|
@@ -146,25 +199,9 @@ export const classifyProviderError = (
|
|
|
146
199
|
if (status === 422 && wire.type === "grammar_invalid") {
|
|
147
200
|
return { kind: "grammar_invalid", message };
|
|
148
201
|
}
|
|
202
|
+
if (status >= 400 && status < 500) return { kind: "request_rejected", message };
|
|
149
203
|
return { kind: "invalid_response", message };
|
|
150
204
|
}
|
|
151
|
-
const wire = err as { message?: unknown; type?: unknown };
|
|
152
|
-
if (wire?.type === "grammar_invalid") {
|
|
153
|
-
return {
|
|
154
|
-
kind: "grammar_invalid",
|
|
155
|
-
message: typeof wire.message === "string"
|
|
156
|
-
? preview(wire.message, detailLimit)
|
|
157
|
-
: "The provider rejected the response grammar.",
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
const error = err as { message?: string };
|
|
161
|
-
return {
|
|
162
|
-
kind: "network_failure",
|
|
163
|
-
message: preview(
|
|
164
|
-
(error?.message ?? String(err)) || "The provider request failed.",
|
|
165
|
-
detailLimit,
|
|
166
|
-
),
|
|
167
|
-
};
|
|
168
205
|
};
|
|
169
206
|
|
|
170
207
|
export const toProviderError = (
|
package/src/index.ts
CHANGED
|
@@ -45,8 +45,8 @@ export {
|
|
|
45
45
|
loadActiveProvider,
|
|
46
46
|
resetDiscoveryCache,
|
|
47
47
|
} from "./ProviderRegistry.ts";
|
|
48
|
-
export {
|
|
49
|
-
export
|
|
48
|
+
export { providerReadiness } from "./sdkModels.ts";
|
|
49
|
+
export { catalogReasoningPolicies } from "./catalogProvider.ts";
|
|
50
50
|
|
|
51
51
|
// Scope-agnostic plugin discovery ({§plugin-family-kind}).
|
|
52
52
|
export { discover } from "./discover.ts";
|
package/src/providerError.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type ProviderErrorKind =
|
|
|
7
7
|
| "network_failure"
|
|
8
8
|
| "deadline_exceeded"
|
|
9
9
|
| "model_refused"
|
|
10
|
+
| "request_rejected"
|
|
10
11
|
| "invalid_response"
|
|
11
12
|
| "unauthorized"
|
|
12
13
|
| "quota_exceeded"
|
|
@@ -16,6 +17,7 @@ export type ProviderErrorKind =
|
|
|
16
17
|
|
|
17
18
|
const defaultStatus = (kind: ProviderErrorKind): number => {
|
|
18
19
|
switch (kind) {
|
|
20
|
+
case "request_rejected": return 400;
|
|
19
21
|
case "unauthorized": return 401;
|
|
20
22
|
case "quota_exceeded": return 402;
|
|
21
23
|
case "capacity_exceeded": return 413;
|
|
@@ -36,6 +38,7 @@ const retryable = (kind: ProviderErrorKind): boolean => {
|
|
|
36
38
|
return true;
|
|
37
39
|
case "deadline_exceeded":
|
|
38
40
|
case "invalid_response":
|
|
41
|
+
case "request_rejected":
|
|
39
42
|
case "grammar_invalid":
|
|
40
43
|
case "capacity_exceeded":
|
|
41
44
|
case "resource_interrupted":
|
|
@@ -60,6 +63,7 @@ const buildProblem = (
|
|
|
60
63
|
deadline_exceeded: "deadline-exceeded",
|
|
61
64
|
model_refused: "model-refused",
|
|
62
65
|
invalid_response: "invalid-response",
|
|
66
|
+
request_rejected: "request-rejected",
|
|
63
67
|
unauthorized: "unauthorized",
|
|
64
68
|
quota_exceeded: "quota-exceeded",
|
|
65
69
|
grammar_invalid: "grammar-invalid",
|
package/src/sdkModels.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 { configuredProviderInfo,
|
|
3
|
+
import { configuredProviderInfo, createSdkModel, providerReadiness } from "./sdkModels.ts";
|
|
4
4
|
|
|
5
5
|
test("{§provider-fact-authority} one env declaration holds one credential name", () => {
|
|
6
6
|
assert.deepEqual(configuredProviderInfo("acme-cloud", {
|
|
@@ -301,44 +301,3 @@ test("createSdkModel fails clearly for a declared but unsupported SDK package",
|
|
|
301
301
|
/Models.dev declares unsupported AI SDK package @acme\/ai-sdk/,
|
|
302
302
|
);
|
|
303
303
|
});
|
|
304
|
-
|
|
305
|
-
test("{§provider-embedding-resolution} compatible catalog and declared routes share the standard embedding adapter", () => {
|
|
306
|
-
const cloudflare = createEmbeddingModel("cloudflare-workers-ai", "@cf/qwen/qwen3-embedding-0.6b", {
|
|
307
|
-
CLOUDFLARE_ACCOUNT_ID: "account",
|
|
308
|
-
CLOUDFLARE_API_KEY: "key",
|
|
309
|
-
});
|
|
310
|
-
assert.equal(cloudflare?.providerId, "cloudflare-workers-ai");
|
|
311
|
-
assert.equal(cloudflare?.embeddingModel.provider, "cloudflare-workers-ai.embedding");
|
|
312
|
-
assert.equal(cloudflare?.embeddingModel.modelId, "@cf/qwen/qwen3-embedding-0.6b");
|
|
313
|
-
|
|
314
|
-
const fireworks = createEmbeddingModel("fireworks-ai", "fireworks/qwen3-embedding-8b", {
|
|
315
|
-
FIREWORKS_API_KEY: "key",
|
|
316
|
-
});
|
|
317
|
-
assert.equal(fireworks?.providerId, "fireworks-ai");
|
|
318
|
-
assert.equal(fireworks?.embeddingModel.provider, "fireworks-ai.embedding");
|
|
319
|
-
|
|
320
|
-
const local = createEmbeddingModel("local-embed", "Qwen/Qwen3-Embedding-0.6B", {
|
|
321
|
-
PLURNK_PROVIDERS_PROVIDER_LOCAL_EMBED_NPM: "@ai-sdk/openai-compatible",
|
|
322
|
-
PLURNK_PROVIDERS_PROVIDER_LOCAL_EMBED_BASE_URL: "http://127.0.0.1:8080/v1",
|
|
323
|
-
});
|
|
324
|
-
assert.equal(local?.providerId, "local-embed");
|
|
325
|
-
assert.equal(local?.embeddingModel.provider, "local-embed.embedding");
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
test("{§provider-embedding-resolution} OpenRouter uses its official embedding model", () => {
|
|
329
|
-
const resolved = createEmbeddingModel("openrouter", "openai/text-embedding-3-small", {
|
|
330
|
-
OPENROUTER_API_KEY: "key",
|
|
331
|
-
OPENROUTER_HTTP_REFERER: "https://github.com/plurnk/plurnk-service",
|
|
332
|
-
OPENROUTER_APP_TITLE: "Plurnk",
|
|
333
|
-
});
|
|
334
|
-
assert.equal(resolved?.providerId, "openrouter");
|
|
335
|
-
assert.equal(resolved?.embeddingModel.provider, "openrouter");
|
|
336
|
-
assert.equal(resolved?.embeddingModel.modelId, "openai/text-embedding-3-small");
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
test("{§provider-embedding-resolution} a generation-only SDK fails before transport", () => {
|
|
340
|
-
assert.throws(
|
|
341
|
-
() => createEmbeddingModel("cerebras", "text-embedding", { CEREBRAS_API_KEY: "key" }),
|
|
342
|
-
/@ai-sdk\/cerebras does not expose an embedding model/,
|
|
343
|
-
);
|
|
344
|
-
});
|
package/src/sdkModels.ts
CHANGED
|
@@ -6,15 +6,12 @@ import { createGoogle } from "@ai-sdk/google";
|
|
|
6
6
|
import { createGroq } from "@ai-sdk/groq";
|
|
7
7
|
import { createMistral } from "@ai-sdk/mistral";
|
|
8
8
|
import { createOpenAI } from "@ai-sdk/openai";
|
|
9
|
-
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
10
9
|
import { createTogetherAI } from "@ai-sdk/togetherai";
|
|
11
10
|
import { createXai } from "@ai-sdk/xai";
|
|
12
|
-
import type { EmbeddingModelV4, EmbeddingModelV4Result } from "@ai-sdk/provider";
|
|
13
11
|
import { createOpenRouter, type OpenRouterChatSettings } from "@openrouter/ai-sdk-provider";
|
|
14
12
|
import {
|
|
15
13
|
resolveModel,
|
|
16
14
|
isProviderCredentialName,
|
|
17
|
-
lookup,
|
|
18
15
|
lookupProvider,
|
|
19
16
|
providerCatalogSnapshot,
|
|
20
17
|
type ProviderInfo,
|
|
@@ -23,13 +20,10 @@ import {
|
|
|
23
20
|
Validator,
|
|
24
21
|
type ModelReadiness,
|
|
25
22
|
type ModelReadinessCause,
|
|
26
|
-
type ProviderRequestAccounting,
|
|
27
|
-
type ProviderUsage,
|
|
28
23
|
type ReasoningPolicy,
|
|
29
24
|
} from "@plurnk/plurnk-contracts";
|
|
30
25
|
import type { LanguageModel } from "ai";
|
|
31
|
-
import { providerCostNormalizer
|
|
32
|
-
import { estimateProviderCost, resolveProviderCost } from "./cost.ts";
|
|
26
|
+
import { providerCostNormalizer } from "./accounting.ts";
|
|
33
27
|
import type { AiSdkProviderOptions, CacheAffinity } from "./AiSdkProvider.ts";
|
|
34
28
|
import type { ProviderCostNormalizer } from "./types.ts";
|
|
35
29
|
|
|
@@ -47,17 +41,6 @@ export type SdkModel = {
|
|
|
47
41
|
readonly catalog: ProviderInfo | null;
|
|
48
42
|
};
|
|
49
43
|
|
|
50
|
-
export type EmbeddingModelResolution = {
|
|
51
|
-
readonly embeddingModel: EmbeddingModelV4;
|
|
52
|
-
readonly providerId: string;
|
|
53
|
-
readonly modelId: string;
|
|
54
|
-
readonly normalizeAccounting: (evidence: {
|
|
55
|
-
readonly outcome: "response" | "error";
|
|
56
|
-
readonly status?: number;
|
|
57
|
-
readonly result?: EmbeddingModelV4Result;
|
|
58
|
-
}) => ProviderRequestAccounting;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
44
|
const cacheControl = { type: "ephemeral" as const };
|
|
62
45
|
// The release generator admits only packages implemented by this package.
|
|
63
46
|
// Derive runtime readiness from that same pinned provider projection instead
|
|
@@ -485,108 +468,3 @@ export const createSdkModel = (
|
|
|
485
468
|
throw new Error(`${provider} provider: Models.dev declares unsupported AI SDK package ${catalog.npm}`);
|
|
486
469
|
}
|
|
487
470
|
};
|
|
488
|
-
|
|
489
|
-
// {§provider-embedding-resolution} Provider construction owns transport and
|
|
490
|
-
// authentication only. Model-space facts stay in the embedding profile.
|
|
491
|
-
export const createEmbeddingModel = (
|
|
492
|
-
provider: string,
|
|
493
|
-
model: string,
|
|
494
|
-
env: NodeJS.ProcessEnv,
|
|
495
|
-
baseUrlOverride?: string,
|
|
496
|
-
): EmbeddingModelResolution | null => {
|
|
497
|
-
const resolved = resolveSdkProvider(provider, env, baseUrlOverride);
|
|
498
|
-
if (resolved === null) return null;
|
|
499
|
-
const { catalog, url } = resolved;
|
|
500
|
-
const directCost = providerCostNormalizer(catalog.npm);
|
|
501
|
-
const modelCost = lookup(provider, model)?.cost ?? lookup(catalog.id, model)?.cost;
|
|
502
|
-
const rates = modelCost === undefined
|
|
503
|
-
? null
|
|
504
|
-
: {
|
|
505
|
-
input: modelCost.inputPer1M,
|
|
506
|
-
output: modelCost.outputPer1M,
|
|
507
|
-
...(modelCost.reasoningPer1M === undefined ? {} : { reasoning: modelCost.reasoningPer1M }),
|
|
508
|
-
...(modelCost.cacheReadPer1M === undefined ? {} : { cacheRead: modelCost.cacheReadPer1M }),
|
|
509
|
-
...(modelCost.cacheWritePer1M === undefined ? {} : { cacheWrite: modelCost.cacheWritePer1M }),
|
|
510
|
-
};
|
|
511
|
-
const normalizeAccounting: EmbeddingModelResolution["normalizeAccounting"] = (evidence) => {
|
|
512
|
-
const tokens = evidence.result?.usage?.tokens;
|
|
513
|
-
const usage: ProviderUsage | undefined = Number.isSafeInteger(tokens) && tokens! >= 0
|
|
514
|
-
? { inputTokens: tokens!, outputTokens: 0, totalTokens: tokens! }
|
|
515
|
-
: undefined;
|
|
516
|
-
const body = evidence.result?.response?.body;
|
|
517
|
-
const rawUsage = typeof body === "object" && body !== null && !Array.isArray(body)
|
|
518
|
-
? (body as Record<string, unknown>).usage
|
|
519
|
-
: undefined;
|
|
520
|
-
const direct = directCost?.({
|
|
521
|
-
providerMetadata: evidence.result?.providerMetadata,
|
|
522
|
-
usage: rawUsage,
|
|
523
|
-
response: { headers: evidence.result?.response?.headers },
|
|
524
|
-
});
|
|
525
|
-
const cost = resolveProviderCost(
|
|
526
|
-
direct,
|
|
527
|
-
estimateProviderCost(
|
|
528
|
-
usage,
|
|
529
|
-
rates,
|
|
530
|
-
`Models.dev ${catalog.id}/${model} rates`,
|
|
531
|
-
),
|
|
532
|
-
);
|
|
533
|
-
return validateProviderRequestAccounting({
|
|
534
|
-
provider: catalog.id,
|
|
535
|
-
model,
|
|
536
|
-
outcome: evidence.outcome,
|
|
537
|
-
...(evidence.status === undefined ? {} : { status: evidence.status }),
|
|
538
|
-
...(usage === undefined ? {} : { usage }),
|
|
539
|
-
cost,
|
|
540
|
-
});
|
|
541
|
-
};
|
|
542
|
-
const result = (embeddingModel: EmbeddingModelV4): EmbeddingModelResolution => ({
|
|
543
|
-
embeddingModel,
|
|
544
|
-
providerId: catalog.id,
|
|
545
|
-
modelId: model,
|
|
546
|
-
normalizeAccounting,
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
switch (catalog.npm) {
|
|
550
|
-
case "@ai-sdk/openai":
|
|
551
|
-
return result(createOpenAI({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).embedding(model));
|
|
552
|
-
case "@ai-sdk/google":
|
|
553
|
-
return result(createGoogle({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).embedding(model));
|
|
554
|
-
case "@ai-sdk/mistral":
|
|
555
|
-
return result(createMistral({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).embedding(model));
|
|
556
|
-
case "@ai-sdk/deepinfra":
|
|
557
|
-
return result(createDeepInfra({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).embeddingModel(model));
|
|
558
|
-
case "@ai-sdk/togetherai":
|
|
559
|
-
return result(createTogetherAI({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).embeddingModel(model));
|
|
560
|
-
case "@ai-sdk/amazon-bedrock":
|
|
561
|
-
return result(createAmazonBedrock({
|
|
562
|
-
region: env.AWS_REGION ?? env.AWS_DEFAULT_REGION,
|
|
563
|
-
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
|
564
|
-
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
|
565
|
-
sessionToken: env.AWS_SESSION_TOKEN,
|
|
566
|
-
apiKey: env.AWS_BEARER_TOKEN_BEDROCK,
|
|
567
|
-
baseURL: url,
|
|
568
|
-
}).embedding(model));
|
|
569
|
-
case "@openrouter/ai-sdk-provider":
|
|
570
|
-
return result(createOpenRouter({
|
|
571
|
-
apiKey: requireApiKey(provider, env, catalog),
|
|
572
|
-
baseURL: url,
|
|
573
|
-
headers: openRouterHeaders(provider, env, catalog),
|
|
574
|
-
}).textEmbeddingModel(model));
|
|
575
|
-
case "@ai-sdk/openai-compatible": {
|
|
576
|
-
if (url === undefined) {
|
|
577
|
-
throw new Error(`${provider} provider: Models.dev supplies no API URL and no base URL was configured`);
|
|
578
|
-
}
|
|
579
|
-
const keyNames = configuredKeyNames(provider, env, catalog);
|
|
580
|
-
const key = keyNames.length === 0 ? undefined : requireApiKey(provider, env, catalog);
|
|
581
|
-
return result(createOpenAICompatible({
|
|
582
|
-
name: provider,
|
|
583
|
-
baseURL: url,
|
|
584
|
-
...(key === undefined ? {} : { apiKey: key }),
|
|
585
|
-
}).embeddingModel(model));
|
|
586
|
-
}
|
|
587
|
-
default:
|
|
588
|
-
throw new Error(
|
|
589
|
-
`${provider} provider: ${catalog.npm} does not expose an embedding model through Plurnk's AI SDK adapter`,
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
};
|
package/src/types.ts
CHANGED
|
@@ -187,10 +187,10 @@ export interface ProviderAssistant<TFinish extends ProviderAttemptFinishReason =
|
|
|
187
187
|
readonly meanLogprob?: number;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// {§provider-grammar-transport} — transport evidence for an operator's grammar: the exact
|
|
191
|
+
// pre-projection response, where the projected content starts within it, and whether the
|
|
192
|
+
// grammar reached the wire. Evidence only; nothing grades it.
|
|
190
193
|
export interface GrammarEvidence {
|
|
191
|
-
// Exact pre-projection response represented by the provider. A generated
|
|
192
|
-
// rail's response root composes any template prefix before @plurnk/gbnf
|
|
193
|
-
// grades it. Offsets are Unicode code points, matching validator verdicts.
|
|
194
194
|
readonly input: string;
|
|
195
195
|
readonly contentStart: number;
|
|
196
196
|
readonly transported: boolean;
|
|
@@ -203,7 +203,7 @@ export interface ProviderResponse<TFinish extends ProviderAttemptFinishReason =
|
|
|
203
203
|
// failover that preceded this response. {§provider-request-accounting}
|
|
204
204
|
readonly accounting: readonly ProviderRequestAccounting[];
|
|
205
205
|
readonly capacity: ProviderRequestCapacity;
|
|
206
|
-
// {§
|
|
206
|
+
// {§provider-grammar-transport} — transport evidence; present only when a grammar was configured.
|
|
207
207
|
readonly grammarEvidence?: GrammarEvidence;
|
|
208
208
|
// Per-turn provider→client metadata bag: the backend's non-standard top-level
|
|
209
209
|
// response fields passed through verbatim. Monetary values carry their own
|
|
@@ -250,12 +250,10 @@ export interface Provider {
|
|
|
250
250
|
// Optional package-authored folksonomy evaluated by the consumer immediately
|
|
251
251
|
// before a provider emission attempt ({§plugin-attribution}).
|
|
252
252
|
attributions?(context: PluginAttributionContext): PluginAttribution;
|
|
253
|
-
// `grammar` is an optional GBNF string
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
// to constrain and which root variant to send is consumer policy
|
|
258
|
-
// ({§gbnf-response-observation}).
|
|
253
|
+
// `grammar` is an optional GBNF string, the operator's own file read by the consumer.
|
|
254
|
+
// Backends that support grammar-constrained sampling attach it verbatim; all others
|
|
255
|
+
// ignore it. The provider never chooses or modifies the grammar
|
|
256
|
+
// ({§provider-grammar-transport}).
|
|
259
257
|
//
|
|
260
258
|
// `maxOutputTokens` may tighten the provider's configured total output
|
|
261
259
|
// budget for this call. It includes visible output and hidden reasoning;
|
package/src/usage.ts
CHANGED
|
@@ -179,16 +179,14 @@ export const normalizeUsage = (raw: RawUsage | null | undefined): ProviderUsage
|
|
|
179
179
|
? inputTokens - cacheReadTokens - cacheWriteTokens
|
|
180
180
|
: undefined);
|
|
181
181
|
|
|
182
|
+
const inputTokenDetails = nonEmptyDetails({ noCacheTokens, cacheReadTokens, cacheWriteTokens });
|
|
183
|
+
const outputTokenDetails = nonEmptyDetails({ textTokens, reasoningTokens: normalizedReasoning });
|
|
182
184
|
const usage: ProviderUsage = {
|
|
183
185
|
...(inputTokens === undefined ? {} : { inputTokens }),
|
|
184
186
|
...(outputTokens === undefined ? {} : { outputTokens }),
|
|
185
187
|
...(totalTokens === undefined ? {} : { totalTokens }),
|
|
186
|
-
...(
|
|
187
|
-
|
|
188
|
-
: { inputTokenDetails: nonEmptyDetails({ noCacheTokens, cacheReadTokens, cacheWriteTokens })! }),
|
|
189
|
-
...(nonEmptyDetails({ textTokens, reasoningTokens: normalizedReasoning }) === undefined
|
|
190
|
-
? {}
|
|
191
|
-
: { outputTokenDetails: nonEmptyDetails({ textTokens, reasoningTokens: normalizedReasoning })! }),
|
|
188
|
+
...(inputTokenDetails === undefined ? {} : { inputTokenDetails }),
|
|
189
|
+
...(outputTokenDetails === undefined ? {} : { outputTokenDetails }),
|
|
192
190
|
};
|
|
193
191
|
if (Object.keys(usage).length === 0) return undefined;
|
|
194
192
|
return validateProviderUsage(usage);
|
|
@@ -244,15 +242,25 @@ export const calculateCostUsdDecimal = (
|
|
|
244
242
|
if (cacheWriteRate !== rates.input && cacheWriteTokens === undefined) return null;
|
|
245
243
|
if (reasoningRate !== rates.output && reasoningTokens === undefined) return null;
|
|
246
244
|
|
|
247
|
-
const parts =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
245
|
+
const parts = {
|
|
246
|
+
input: decimalParts(rates.input),
|
|
247
|
+
output: decimalParts(rates.output),
|
|
248
|
+
reasoning: decimalParts(reasoningRate),
|
|
249
|
+
cacheRead: decimalParts(cacheReadRate),
|
|
250
|
+
cacheWrite: decimalParts(cacheWriteRate),
|
|
251
|
+
};
|
|
252
|
+
const rateScale = Math.max(...Object.values(parts).map(({ scale }) => scale));
|
|
253
|
+
const scaled = ({ coefficient, scale }: { coefficient: bigint; scale: number }): bigint => coefficient * 10n ** BigInt(rateScale - scale);
|
|
254
|
+
const input = scaled(parts.input);
|
|
255
|
+
const output = scaled(parts.output);
|
|
256
|
+
const reasoning = scaled(parts.reasoning);
|
|
257
|
+
const cacheRead = scaled(parts.cacheRead);
|
|
258
|
+
const cacheWrite = scaled(parts.cacheWrite);
|
|
259
|
+
const coefficient = BigInt(usage.inputTokens) * input
|
|
260
|
+
+ BigInt(usage.outputTokens) * output
|
|
261
|
+
+ BigInt(reasoningTokens ?? 0) * (reasoning - output)
|
|
262
|
+
+ BigInt(cacheReadTokens ?? 0) * (cacheRead - input)
|
|
263
|
+
+ BigInt(cacheWriteTokens ?? 0) * (cacheWrite - input);
|
|
256
264
|
if (coefficient < 0n) throw new TypeError("provider token-rate details produced a negative estimate");
|
|
257
265
|
return canonicalDecimal(coefficient, rateScale + 6);
|
|
258
266
|
};
|