@plurnk/plurnk-providers 1.4.0 → 1.6.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 +40 -34
- package/README.md +3 -0
- package/SPEC.md +153 -62
- package/dist/AiSdkProvider.d.ts +19 -25
- package/dist/AiSdkProvider.d.ts.map +1 -1
- package/dist/AiSdkProvider.js +353 -120
- package/dist/AiSdkProvider.js.map +1 -1
- package/dist/Mock.d.ts +7 -13
- package/dist/Mock.d.ts.map +1 -1
- package/dist/Mock.js +36 -8
- package/dist/Mock.js.map +1 -1
- package/dist/Pool.d.ts +2 -21
- package/dist/Pool.d.ts.map +1 -1
- package/dist/Pool.js +19 -14
- package/dist/Pool.js.map +1 -1
- package/dist/accounting.d.ts +6 -0
- package/dist/accounting.d.ts.map +1 -0
- package/dist/accounting.js +168 -0
- package/dist/accounting.js.map +1 -0
- package/dist/aiSdkTransport.d.ts +11 -3
- package/dist/aiSdkTransport.d.ts.map +1 -1
- package/dist/aiSdkTransport.js +198 -29
- package/dist/aiSdkTransport.js.map +1 -1
- package/dist/catalogProvider.d.ts +7 -2
- package/dist/catalogProvider.d.ts.map +1 -1
- package/dist/catalogProvider.js +32 -26
- package/dist/catalogProvider.js.map +1 -1
- package/dist/compatibleProvider.d.ts.map +1 -1
- package/dist/compatibleProvider.js +18 -7
- package/dist/compatibleProvider.js.map +1 -1
- package/dist/cost.d.ts +10 -10
- package/dist/cost.d.ts.map +1 -1
- package/dist/cost.js +88 -43
- package/dist/cost.js.map +1 -1
- package/dist/env.d.ts +5 -7
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +30 -32
- package/dist/env.js.map +1 -1
- package/dist/errors.d.ts +14 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +60 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/ollama.js +3 -3
- package/dist/ollama.js.map +1 -1
- package/dist/sdkModels.d.ts +6 -0
- package/dist/sdkModels.d.ts.map +1 -1
- package/dist/sdkModels.js +46 -3
- package/dist/sdkModels.js.map +1 -1
- package/dist/types.d.ts +40 -29
- package/dist/types.d.ts.map +1 -1
- package/dist/usage.d.ts +21 -4
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +188 -74
- package/dist/usage.js.map +1 -1
- package/package.json +9 -7
- package/src/AiSdkProvider.test.ts +1039 -182
- package/src/AiSdkProvider.ts +428 -141
- package/src/Mock.test.ts +37 -12
- package/src/Mock.ts +46 -12
- package/src/Pool.test.ts +19 -6
- package/src/Pool.ts +20 -16
- package/src/ProviderRegistry.test.ts +16 -11
- package/src/accounting.test.ts +94 -0
- package/src/accounting.ts +190 -0
- package/src/aiSdkTransport.test.ts +42 -49
- package/src/aiSdkTransport.ts +218 -32
- package/src/boundaries.test.ts +2 -0
- package/src/catalogProvider.test.ts +271 -24
- package/src/catalogProvider.ts +44 -28
- package/src/compatibleProvider.test.ts +6 -3
- package/src/compatibleProvider.ts +20 -7
- package/src/cost.test.ts +55 -35
- package/src/cost.ts +110 -54
- package/src/defaults.test.ts +13 -3
- package/src/env.test.ts +50 -26
- package/src/env.ts +43 -42
- package/src/errors.test.ts +47 -2
- package/src/errors.ts +68 -3
- package/src/index.ts +21 -5
- package/src/ollama.test.ts +4 -1
- package/src/ollama.ts +3 -3
- package/src/sdkModels.test.ts +94 -3
- package/src/sdkModels.ts +53 -3
- package/src/types.ts +91 -33
- package/src/usage.test.ts +112 -108
- package/src/usage.ts +233 -84
package/src/errors.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import { strict as assert } from "node:assert";
|
|
3
3
|
import { APICallError, RetryError } from "ai";
|
|
4
|
-
import { ProviderError, classifyProviderError, toProviderError } from "./errors.ts";
|
|
4
|
+
import { ProviderError, ProviderTimeoutError, classifyProviderError, toProviderError } from "./errors.ts";
|
|
5
5
|
import type { ProviderAttempt } from "./types.ts";
|
|
6
6
|
import { providerSource } from "./notices.ts";
|
|
7
7
|
|
|
@@ -29,12 +29,27 @@ test("classifyProviderError maps HTTP status to kind", () => {
|
|
|
29
29
|
assert.equal(k(403), "unauthorized");
|
|
30
30
|
assert.equal(k(402), "quota_exceeded");
|
|
31
31
|
assert.equal(k(429), "rate_limit");
|
|
32
|
+
assert.equal(k(408), "network_failure");
|
|
33
|
+
assert.equal(k(409), "network_failure");
|
|
32
34
|
assert.equal(k(500), "network_failure");
|
|
33
35
|
assert.equal(k(503), "network_failure");
|
|
34
36
|
assert.equal(k(400), "invalid_response");
|
|
35
37
|
assert.equal(k(404), "invalid_response");
|
|
36
38
|
});
|
|
37
39
|
|
|
40
|
+
test("provider retry directives survive HTTP failure normalization", () => {
|
|
41
|
+
const final = new APICallError({
|
|
42
|
+
message: "edge router says not to replay",
|
|
43
|
+
url: "https://example.test/v1/chat/completions",
|
|
44
|
+
requestBodyValues: {},
|
|
45
|
+
statusCode: 524,
|
|
46
|
+
isRetryable: false,
|
|
47
|
+
});
|
|
48
|
+
const error = toProviderError(final, "provider:test");
|
|
49
|
+
assert.equal(error.kind, "network_failure");
|
|
50
|
+
assert.equal(error.problem.retryable, false);
|
|
51
|
+
});
|
|
52
|
+
|
|
38
53
|
test("classifyProviderError: a 422 flagged grammar_invalid is distinct; other 422s are invalid responses", () => {
|
|
39
54
|
const rejected = apiError(422, JSON.stringify({ error: { type: "grammar_invalid", message: "non-conforming emission rejected: ..." } }));
|
|
40
55
|
assert.equal(classifyProviderError(rejected).kind, "grammar_invalid");
|
|
@@ -75,11 +90,17 @@ test("#161: ProviderError carries resource-interrupted attempt evidence outside
|
|
|
75
90
|
assistant: {
|
|
76
91
|
content: "partial",
|
|
77
92
|
reasoning: null,
|
|
78
|
-
usage: { prompt: 3, completion: 1, reasoning: 0, cached: 0, total: 4 },
|
|
79
93
|
finishReason: "resource_interrupted",
|
|
80
94
|
model: "served-model",
|
|
81
95
|
},
|
|
82
96
|
assistantRaw: { rawFinishReason: "insufficient_system_resource" },
|
|
97
|
+
accounting: [{
|
|
98
|
+
provider: "provider:deepseek",
|
|
99
|
+
model: "served-model",
|
|
100
|
+
outcome: "response",
|
|
101
|
+
usage: { inputTokens: 3, outputTokens: 1, totalTokens: 4 },
|
|
102
|
+
cost: { kind: "unknown", reason: "fixture has no monetary evidence" },
|
|
103
|
+
}],
|
|
83
104
|
} as ProviderAttempt;
|
|
84
105
|
const error = new ProviderError(
|
|
85
106
|
"provider:deepseek",
|
|
@@ -96,6 +117,7 @@ test("#161: ProviderError carries resource-interrupted attempt evidence outside
|
|
|
96
117
|
);
|
|
97
118
|
|
|
98
119
|
assert.equal(error.attempt, attempt);
|
|
120
|
+
assert.deepEqual(error.accounting, attempt.accounting);
|
|
99
121
|
assert.deepEqual(error.problem, {
|
|
100
122
|
type: "https://problems.plurnk.dev/provider/deepseek/resource-interrupted",
|
|
101
123
|
title: "Resource interrupted",
|
|
@@ -146,3 +168,26 @@ test("retry exhaustion is explicit and does not recommend another automatic repl
|
|
|
146
168
|
assert.equal(error.problem.retryExhausted, true);
|
|
147
169
|
assert.equal(error.cause, cause);
|
|
148
170
|
});
|
|
171
|
+
|
|
172
|
+
test("retry exhaustion retains the exact inner deadline phase", () => {
|
|
173
|
+
const failures = [1, 2].map(() => new APICallError({
|
|
174
|
+
message: "attempt timed out",
|
|
175
|
+
url: "https://example.test/v1/chat/completions",
|
|
176
|
+
requestBodyValues: {},
|
|
177
|
+
cause: new ProviderTimeoutError("attempt", 10),
|
|
178
|
+
isRetryable: true,
|
|
179
|
+
}));
|
|
180
|
+
const cause = new RetryError({
|
|
181
|
+
message: "Failed after 2 attempts.",
|
|
182
|
+
reason: "maxRetriesExceeded",
|
|
183
|
+
errors: failures,
|
|
184
|
+
});
|
|
185
|
+
const error = toProviderError(cause, "provider:test");
|
|
186
|
+
assert.equal(error.kind, "network_failure");
|
|
187
|
+
assert.equal(error.status, 503);
|
|
188
|
+
assert.equal(error.problem.retryable, false);
|
|
189
|
+
assert.equal(error.problem.attempts, 2);
|
|
190
|
+
assert.equal(error.problem.retryExhausted, true);
|
|
191
|
+
assert.equal(error.problem.timeoutPhase, "attempt");
|
|
192
|
+
assert.equal(error.problem.timeoutMs, 10);
|
|
193
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Problems, type ProblemDetails } from "@plurnk/plurnk-contracts";
|
|
2
2
|
import { APICallError, RetryError } from "ai";
|
|
3
3
|
import { providerSource } from "./notices.ts";
|
|
4
|
-
import type { ProviderAttempt } from "./types.ts";
|
|
4
|
+
import type { ProviderAttempt, ProviderRequestAccounting } from "./types.ts";
|
|
5
5
|
|
|
6
6
|
export type ProviderErrorKind =
|
|
7
7
|
| "rate_limit"
|
|
8
8
|
| "network_failure"
|
|
9
|
+
| "deadline_exceeded"
|
|
9
10
|
| "model_refused"
|
|
10
11
|
| "invalid_response"
|
|
11
12
|
| "unauthorized"
|
|
@@ -19,8 +20,40 @@ export interface ClassifiedProviderError {
|
|
|
19
20
|
retryable?: boolean;
|
|
20
21
|
attempts?: number;
|
|
21
22
|
retryExhausted?: boolean;
|
|
23
|
+
extensions?: Readonly<Record<string, unknown>>;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
export type ProviderTimeoutPhase = "attempt" | "first_content" | "stream_idle" | "operation";
|
|
27
|
+
|
|
28
|
+
export class ProviderTimeoutError extends Error {
|
|
29
|
+
readonly phase: ProviderTimeoutPhase;
|
|
30
|
+
readonly timeoutMs: number;
|
|
31
|
+
|
|
32
|
+
constructor(phase: ProviderTimeoutPhase, timeoutMs: number, cause?: unknown) {
|
|
33
|
+
const labels: Record<ProviderTimeoutPhase, string> = {
|
|
34
|
+
attempt: "Provider attempt",
|
|
35
|
+
first_content: "First provider content",
|
|
36
|
+
stream_idle: "Provider stream idle",
|
|
37
|
+
operation: "Provider operation",
|
|
38
|
+
};
|
|
39
|
+
super(`${labels[phase]} exceeded its ${timeoutMs} ms deadline.`, cause === undefined ? undefined : { cause });
|
|
40
|
+
this.name = "ProviderTimeoutError";
|
|
41
|
+
this.phase = phase;
|
|
42
|
+
this.timeoutMs = timeoutMs;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const providerTimeoutOf = (error: unknown): ProviderTimeoutError | null => {
|
|
47
|
+
const seen = new Set<unknown>();
|
|
48
|
+
let current = error;
|
|
49
|
+
while (typeof current === "object" && current !== null && !seen.has(current)) {
|
|
50
|
+
if (current instanceof ProviderTimeoutError) return current;
|
|
51
|
+
seen.add(current);
|
|
52
|
+
current = (current as { cause?: unknown }).cause;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
};
|
|
56
|
+
|
|
24
57
|
const defaultStatus = (kind: ProviderErrorKind): number => {
|
|
25
58
|
switch (kind) {
|
|
26
59
|
case "unauthorized": return 401;
|
|
@@ -29,6 +62,7 @@ const defaultStatus = (kind: ProviderErrorKind): number => {
|
|
|
29
62
|
case "model_refused":
|
|
30
63
|
case "grammar_invalid": return 422;
|
|
31
64
|
case "invalid_response": return 502;
|
|
65
|
+
case "deadline_exceeded": return 504;
|
|
32
66
|
case "network_failure":
|
|
33
67
|
case "resource_interrupted": return 503;
|
|
34
68
|
}
|
|
@@ -39,6 +73,7 @@ const retryable = (kind: ProviderErrorKind): boolean => {
|
|
|
39
73
|
case "rate_limit":
|
|
40
74
|
case "network_failure":
|
|
41
75
|
return true;
|
|
76
|
+
case "deadline_exceeded":
|
|
42
77
|
case "invalid_response":
|
|
43
78
|
case "grammar_invalid":
|
|
44
79
|
case "resource_interrupted":
|
|
@@ -60,6 +95,7 @@ const buildProblem = (
|
|
|
60
95
|
const code: Record<ProviderErrorKind, string> = {
|
|
61
96
|
rate_limit: "rate-limit",
|
|
62
97
|
network_failure: "network-failure",
|
|
98
|
+
deadline_exceeded: "deadline-exceeded",
|
|
63
99
|
model_refused: "model-refused",
|
|
64
100
|
invalid_response: "invalid-response",
|
|
65
101
|
unauthorized: "unauthorized",
|
|
@@ -84,6 +120,7 @@ export class ProviderError extends Error {
|
|
|
84
120
|
readonly kind: ProviderErrorKind;
|
|
85
121
|
readonly problem: ProblemDetails;
|
|
86
122
|
readonly attempt?: ProviderAttempt;
|
|
123
|
+
#accounting: ProviderRequestAccounting[];
|
|
87
124
|
|
|
88
125
|
constructor(
|
|
89
126
|
source: string,
|
|
@@ -95,6 +132,7 @@ export class ProviderError extends Error {
|
|
|
95
132
|
retryable?: boolean;
|
|
96
133
|
extensions?: Readonly<Record<string, unknown>>;
|
|
97
134
|
attempt?: ProviderAttempt;
|
|
135
|
+
accounting?: readonly ProviderRequestAccounting[];
|
|
98
136
|
} = {},
|
|
99
137
|
) {
|
|
100
138
|
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
@@ -102,6 +140,7 @@ export class ProviderError extends Error {
|
|
|
102
140
|
this.source = providerSource(source);
|
|
103
141
|
this.kind = kind;
|
|
104
142
|
this.attempt = options.attempt;
|
|
143
|
+
this.#accounting = [...(options.accounting ?? options.attempt?.accounting ?? [])];
|
|
105
144
|
const status = options.status !== null && options.status !== undefined
|
|
106
145
|
&& Number.isInteger(options.status) && options.status >= 400 && options.status <= 599
|
|
107
146
|
? options.status
|
|
@@ -119,6 +158,16 @@ export class ProviderError extends Error {
|
|
|
119
158
|
get status(): number {
|
|
120
159
|
return this.problem.status;
|
|
121
160
|
}
|
|
161
|
+
|
|
162
|
+
get accounting(): readonly ProviderRequestAccounting[] {
|
|
163
|
+
return this.#accounting;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A capacity pool adds the already-settled requests from prior backends as
|
|
167
|
+
// the same failure crosses that orchestration boundary.
|
|
168
|
+
prependAccounting(accounting: readonly ProviderRequestAccounting[]): void {
|
|
169
|
+
if (accounting.length > 0) this.#accounting = [...accounting, ...this.#accounting];
|
|
170
|
+
}
|
|
122
171
|
}
|
|
123
172
|
|
|
124
173
|
const wireErrorType = (body: string): string | null => {
|
|
@@ -150,6 +199,17 @@ export const classifyProviderError = (
|
|
|
150
199
|
};
|
|
151
200
|
}
|
|
152
201
|
if (APICallError.isInstance(err)) {
|
|
202
|
+
const timeout = providerTimeoutOf(err);
|
|
203
|
+
if (timeout !== null) {
|
|
204
|
+
return {
|
|
205
|
+
kind: "network_failure",
|
|
206
|
+
message: timeout.message,
|
|
207
|
+
extensions: {
|
|
208
|
+
timeoutPhase: timeout.phase,
|
|
209
|
+
timeoutMs: timeout.timeoutMs,
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
153
213
|
const status = err.statusCode ?? 0;
|
|
154
214
|
const message = err.message.trim().length > 0
|
|
155
215
|
? preview(err.message, detailLimit)
|
|
@@ -157,8 +217,12 @@ export const classifyProviderError = (
|
|
|
157
217
|
const body = err.responseBody ?? "";
|
|
158
218
|
if (status === 401 || status === 403) return { kind: "unauthorized", message };
|
|
159
219
|
if (status === 402) return { kind: "quota_exceeded", message };
|
|
160
|
-
if (status === 429) return { kind: "rate_limit", message };
|
|
161
|
-
if (status
|
|
220
|
+
if (status === 429) return { kind: "rate_limit", message, retryable: err.isRetryable };
|
|
221
|
+
if (status === 408 || status === 409) {
|
|
222
|
+
return { kind: "network_failure", message, retryable: err.isRetryable };
|
|
223
|
+
}
|
|
224
|
+
if (status === 0 && err.isRetryable) return { kind: "network_failure", message };
|
|
225
|
+
if (status >= 500) return { kind: "network_failure", message, retryable: err.isRetryable };
|
|
162
226
|
if (status === 422 && wireErrorType(body) === "grammar_invalid") {
|
|
163
227
|
return { kind: "grammar_invalid", message };
|
|
164
228
|
}
|
|
@@ -198,6 +262,7 @@ export const toProviderError = (
|
|
|
198
262
|
cause: err,
|
|
199
263
|
retryable: classified.retryable,
|
|
200
264
|
extensions: {
|
|
265
|
+
...(classified.extensions ?? {}),
|
|
201
266
|
...(classified.attempts === undefined ? {} : { attempts: classified.attempts }),
|
|
202
267
|
...(classified.retryExhausted === undefined
|
|
203
268
|
? {}
|
package/src/index.ts
CHANGED
|
@@ -10,13 +10,20 @@ export type {
|
|
|
10
10
|
ProviderOptions,
|
|
11
11
|
ProviderResponse,
|
|
12
12
|
ProviderEncryptedReasoningItem,
|
|
13
|
+
ProviderAccounting,
|
|
14
|
+
ProviderCost,
|
|
15
|
+
ProviderCostNormalizer,
|
|
16
|
+
ProviderCallKind,
|
|
17
|
+
ProviderGenerateArgs,
|
|
18
|
+
ProviderRequestAccounting,
|
|
19
|
+
ProviderRequestIdentity,
|
|
20
|
+
ProviderRequestObserver,
|
|
21
|
+
ProviderRequestSettlement,
|
|
13
22
|
ProviderUsage,
|
|
14
23
|
PromptTokenMeasurement,
|
|
15
24
|
TokenLogprob,
|
|
16
25
|
TokenAlternative,
|
|
17
|
-
AuthoritativeCharge,
|
|
18
26
|
} from "./types.ts";
|
|
19
|
-
export type { ProviderCost } from "@plurnk/plurnk-contracts";
|
|
20
27
|
export { assertPromptTokenMeasurement } from "./promptTokens.ts";
|
|
21
28
|
|
|
22
29
|
// Alias cascade — re-exported from the zero-dep @plurnk/plurnk-aliases, so
|
|
@@ -45,14 +52,23 @@ export { default as Pool } from "./Pool.ts";
|
|
|
45
52
|
export type { ProviderFetch } from "./AiSdkProvider.ts";
|
|
46
53
|
export { parseRequiredInt, parseOptionalInt, parseRequiredFloat, parseOptionalFloat, requireEnv, reasoningFromEnv, reasoningResponseStyleFromEnv, scopeEnvToAlias, dataCaptureFromEnv, contextWindowFromEnv, effectiveContextWindow, envelopeFromEnv, resolveReserve, PROVIDERS_KNOBS } from "./env.ts";
|
|
47
54
|
export type { Reasoning, ReasoningMode, ReasoningResponseStyle, ReserveSpec } from "./env.ts";
|
|
48
|
-
export { normalizeUsage,
|
|
55
|
+
export { normalizeUsage, calculateCostUsdDecimal, validateProviderUsage } from "./usage.ts";
|
|
49
56
|
export {
|
|
50
|
-
|
|
57
|
+
addDecimals,
|
|
58
|
+
estimateProviderCost,
|
|
51
59
|
providerCostUsd,
|
|
52
60
|
resolveProviderCost,
|
|
53
|
-
|
|
61
|
+
sumProviderCostsUsd,
|
|
62
|
+
validateChargedCost,
|
|
63
|
+
validateDecimal,
|
|
54
64
|
validateProviderCost,
|
|
55
65
|
} from "./cost.ts";
|
|
66
|
+
export {
|
|
67
|
+
aggregateProviderAccounting,
|
|
68
|
+
plurnkCostNormalizer,
|
|
69
|
+
providerCostNormalizer,
|
|
70
|
+
validateProviderRequestAccounting,
|
|
71
|
+
} from "./accounting.ts";
|
|
56
72
|
export type { RawUsage, TokenRates } from "./usage.ts";
|
|
57
73
|
export { ProviderError, classifyProviderError, toProviderError } from "./errors.ts";
|
|
58
74
|
export { providerSource } from "./notices.ts";
|
package/src/ollama.test.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { ollamaProviderFromEnv } from "./ollama.ts";
|
|
|
4
4
|
|
|
5
5
|
const env = Object.freeze({
|
|
6
6
|
PLURNK_PROVIDERS_FETCH_TIMEOUT: "1000",
|
|
7
|
+
PLURNK_PROVIDERS_OPERATION_TIMEOUT: "3000",
|
|
8
|
+
PLURNK_PROVIDERS_FIRST_CONTENT_TIMEOUT: "1000",
|
|
7
9
|
PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT: "0",
|
|
8
10
|
PLURNK_PROVIDERS_REASONING: "off",
|
|
9
11
|
PLURNK_PROVIDERS_TEMPERATURE: "0.2",
|
|
@@ -13,7 +15,8 @@ const env = Object.freeze({
|
|
|
13
15
|
PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%",
|
|
14
16
|
PLURNK_PROVIDERS_RETRY_ATTEMPTS: "0",
|
|
15
17
|
PLURNK_PROVIDERS_ERROR_DETAIL_LIMIT: "512",
|
|
16
|
-
|
|
18
|
+
PLURNK_PROVIDERS_CACHE_AFFINITY: "1",
|
|
19
|
+
PLURNK_PROVIDERS_CACHE_WRITE_POLICY: "stable-system",
|
|
17
20
|
});
|
|
18
21
|
|
|
19
22
|
test.afterEach(() => mock.restoreAll());
|
package/src/ollama.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
2
|
-
import { contextWindowFromEnv, effectiveContextWindow,
|
|
2
|
+
import { contextWindowFromEnv, effectiveContextWindow, parseTimeoutMs, requireEnv } from "./env.ts";
|
|
3
3
|
import { providerFromSdkModel } from "./catalogProvider.ts";
|
|
4
4
|
import type { Provider, ProviderOptions } from "./types.ts";
|
|
5
5
|
|
|
@@ -20,7 +20,7 @@ const fetchContextWindow = async ({
|
|
|
20
20
|
method: "POST",
|
|
21
21
|
headers: { "Content-Type": "application/json" },
|
|
22
22
|
body: JSON.stringify({ model }),
|
|
23
|
-
signal: AbortSignal.timeout(timeout),
|
|
23
|
+
...(timeout > 0 ? { signal: AbortSignal.timeout(timeout) } : {}),
|
|
24
24
|
});
|
|
25
25
|
if (!response.ok) throw new Error(`ollama provider: /api/show returned ${response.status}`);
|
|
26
26
|
const data = await response.json() as ShowResponse;
|
|
@@ -41,7 +41,7 @@ export const ollamaProviderFromEnv = async (
|
|
|
41
41
|
"OLLAMA_BASE_URL or OLLAMA_HOST (or a PLURNK_BASEURL_<alias> override)",
|
|
42
42
|
"ollama",
|
|
43
43
|
).replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
44
|
-
const timeout =
|
|
44
|
+
const timeout = parseTimeoutMs(
|
|
45
45
|
env.PLURNK_PROVIDERS_FETCH_TIMEOUT,
|
|
46
46
|
"PLURNK_PROVIDERS_FETCH_TIMEOUT",
|
|
47
47
|
"ollama",
|
package/src/sdkModels.test.ts
CHANGED
|
@@ -19,9 +19,49 @@ 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.deepEqual(sdk?.cacheAffinity, { target: "header", name: "x-grok-conv-id" });
|
|
28
|
+
assert.notEqual(sdk?.normalizeCost, undefined);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("createSdkModel constructs Cerebras from Models.dev facts", () => {
|
|
32
|
+
const sdk = createSdkModel("cerebras", "gemma-4-31b", {
|
|
33
|
+
CEREBRAS_API_KEY: "test-key",
|
|
34
|
+
});
|
|
35
|
+
assert.notEqual(sdk, null);
|
|
36
|
+
assert.equal(sdk?.catalog?.npm, "@ai-sdk/cerebras");
|
|
37
|
+
assert.notEqual(sdk?.languageModel, undefined);
|
|
38
|
+
assert.equal(sdk?.compatible, undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the Google SDK adapter owns its readable-reasoning response projection", () => {
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
createSdkModel("google", "gemini-3.7-flash", { GEMINI_API_KEY: "test-key" })?.reasoningResponseProviderOptions,
|
|
44
|
+
{ google: { thinkingConfig: { includeThoughts: true } } },
|
|
45
|
+
);
|
|
46
|
+
assert.equal(
|
|
47
|
+
createSdkModel("cerebras", "gemma-4-31b", { CEREBRAS_API_KEY: "test-key" })?.reasoningResponseProviderOptions,
|
|
48
|
+
undefined,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("createSdkModel attaches DeepInfra's documented response-cost normalizer", () => {
|
|
53
|
+
const sdk = createSdkModel("deepinfra", "zai-org/GLM-5.2", {
|
|
54
|
+
DEEPINFRA_API_KEY: "test-key",
|
|
55
|
+
});
|
|
56
|
+
assert.notEqual(sdk?.languageModel, undefined);
|
|
57
|
+
assert.deepEqual(sdk?.normalizeCost?.({
|
|
58
|
+
usage: { estimated_cost: 5.04e-5 },
|
|
59
|
+
response: { id: "response-1" },
|
|
60
|
+
}), {
|
|
61
|
+
kind: "estimated",
|
|
62
|
+
amount: { amount: "0.0000504", currency: "USD" },
|
|
63
|
+
source: "DeepInfra response usage.estimated_cost",
|
|
64
|
+
});
|
|
25
65
|
});
|
|
26
66
|
|
|
27
67
|
test("createSdkModel expands catalog endpoint variables without treating them as credentials", () => {
|
|
@@ -35,6 +75,57 @@ test("createSdkModel expands catalog endpoint variables without treating them as
|
|
|
35
75
|
url: "https://api.cloudflare.com/client/v4/accounts/account/ai/v1/chat/completions",
|
|
36
76
|
headers: { Authorization: "Bearer token" },
|
|
37
77
|
});
|
|
78
|
+
assert.deepEqual(sdk?.cacheAffinity, { target: "header", name: "x-session-affinity" });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("catalog routes own their documented cache-affinity request projection", () => {
|
|
82
|
+
assert.deepEqual(
|
|
83
|
+
createSdkModel("openai", "gpt-4.1-mini", { OPENAI_API_KEY: "key" })?.cacheAffinity,
|
|
84
|
+
{ target: "provider-option", provider: "openai", name: "promptCacheKey" },
|
|
85
|
+
);
|
|
86
|
+
assert.deepEqual(
|
|
87
|
+
createSdkModel("deepinfra", "zai-org/GLM-5.2", { DEEPINFRA_API_KEY: "key" })?.cacheAffinity,
|
|
88
|
+
{ target: "provider-option", provider: "deepinfra", name: "prompt_cache_key" },
|
|
89
|
+
);
|
|
90
|
+
assert.deepEqual(
|
|
91
|
+
createSdkModel("openrouter", "openai/gpt-5", { OPENROUTER_API_KEY: "key" })?.cacheAffinity,
|
|
92
|
+
{ target: "header", name: "x-session-id" },
|
|
93
|
+
);
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
createSdkModel("fireworks", "accounts/fireworks/models/test", { FIREWORKS_API_KEY: "key" })?.cacheAffinity,
|
|
96
|
+
{ target: "body", name: "prompt_cache_key" },
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("explicit stable-system cache breakpoints exist only on supported Claude routes", () => {
|
|
101
|
+
const cacheControl = { type: "ephemeral" };
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
createSdkModel("anthropic", "claude-sonnet-4-6", { ANTHROPIC_API_KEY: "key" })?.systemCacheProviderOptions,
|
|
104
|
+
{ anthropic: { cacheControl } },
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
createSdkModel("openrouter", "anthropic/claude-sonnet-4.6", { OPENROUTER_API_KEY: "key" })?.systemCacheProviderOptions,
|
|
108
|
+
{ openrouter: { cacheControl } },
|
|
109
|
+
);
|
|
110
|
+
assert.equal(
|
|
111
|
+
createSdkModel("openrouter", "openai/gpt-5", { OPENROUTER_API_KEY: "key" })?.systemCacheProviderOptions,
|
|
112
|
+
undefined,
|
|
113
|
+
);
|
|
114
|
+
assert.equal(
|
|
115
|
+
createSdkModel("deepseek", "deepseek-v4-flash", { DEEPSEEK_API_KEY: "key" })?.systemCacheProviderOptions,
|
|
116
|
+
undefined,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("an operator-declared compatible provider receives no guessed cache extension", () => {
|
|
121
|
+
const sdk = createSdkModel("acme", "model", {
|
|
122
|
+
ACME_API_KEY: "key",
|
|
123
|
+
PLURNK_PROVIDERS_PROVIDER_ACME_NPM: "@ai-sdk/openai-compatible",
|
|
124
|
+
PLURNK_PROVIDERS_PROVIDER_ACME_BASE_URL: "https://api.acme.test/v1",
|
|
125
|
+
PLURNK_PROVIDERS_PROVIDER_ACME_API_KEY_ENV: "ACME_API_KEY",
|
|
126
|
+
});
|
|
127
|
+
assert.equal(sdk?.cacheAffinity, undefined);
|
|
128
|
+
assert.equal(sdk?.systemCacheProviderOptions, undefined);
|
|
38
129
|
});
|
|
39
130
|
|
|
40
131
|
test("#157: a cataloged compatible provider fails before transport when its declared credential is absent", () => {
|
package/src/sdkModels.ts
CHANGED
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";
|
|
2
2
|
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
3
|
+
import { createCerebras } from "@ai-sdk/cerebras";
|
|
3
4
|
import { createDeepInfra } from "@ai-sdk/deepinfra";
|
|
4
5
|
import { createGoogle } from "@ai-sdk/google";
|
|
5
6
|
import { createGroq } from "@ai-sdk/groq";
|
|
6
7
|
import { createMistral } from "@ai-sdk/mistral";
|
|
7
8
|
import { createOpenAI } from "@ai-sdk/openai";
|
|
8
9
|
import { createTogetherAI } from "@ai-sdk/togetherai";
|
|
9
|
-
import { createXai } from "@ai-sdk/xai";
|
|
10
10
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
|
11
11
|
import { lookupProvider, type ProviderInfo } from "@plurnk/plurnk-models";
|
|
12
12
|
import type { LanguageModel } from "ai";
|
|
13
|
+
import { providerCostNormalizer } from "./accounting.ts";
|
|
14
|
+
import type { AiSdkProviderOptions, CacheAffinity } from "./AiSdkProvider.ts";
|
|
15
|
+
import type { ProviderCostNormalizer } from "./types.ts";
|
|
13
16
|
|
|
14
17
|
export type SdkModel = {
|
|
15
18
|
readonly languageModel?: LanguageModel;
|
|
19
|
+
readonly normalizeCost?: ProviderCostNormalizer;
|
|
16
20
|
readonly compatible?: {
|
|
17
21
|
readonly url: string;
|
|
18
22
|
readonly headers: Readonly<Record<string, string>>;
|
|
19
23
|
};
|
|
24
|
+
readonly cacheAffinity?: CacheAffinity;
|
|
25
|
+
readonly systemCacheProviderOptions?: AiSdkProviderOptions;
|
|
26
|
+
readonly reasoningResponseProviderOptions?: AiSdkProviderOptions;
|
|
20
27
|
readonly catalog: ProviderInfo | null;
|
|
21
28
|
};
|
|
22
29
|
|
|
30
|
+
const cacheControl = { type: "ephemeral" as const };
|
|
31
|
+
|
|
23
32
|
const envPrefix = (provider: string): string =>
|
|
24
33
|
provider.replaceAll(/[^a-zA-Z0-9]/g, "_").toUpperCase();
|
|
25
34
|
|
|
@@ -105,11 +114,15 @@ export const createSdkModel = (
|
|
|
105
114
|
const catalog = lookupProvider(provider) ?? configuredProviderInfo(provider, env);
|
|
106
115
|
if (catalog === null) return null;
|
|
107
116
|
const url = baseUrl(provider, env, catalog, baseUrlOverride);
|
|
117
|
+
const normalizeCost = providerCostNormalizer(catalog.npm);
|
|
108
118
|
|
|
109
119
|
switch (catalog.npm) {
|
|
110
120
|
case "@ai-sdk/openai":
|
|
111
121
|
return {
|
|
112
122
|
languageModel: createOpenAI({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).chat(model),
|
|
123
|
+
...(catalog.id === "openai"
|
|
124
|
+
? { cacheAffinity: { target: "provider-option" as const, provider: "openai", name: "promptCacheKey" } }
|
|
125
|
+
: {}),
|
|
113
126
|
catalog,
|
|
114
127
|
};
|
|
115
128
|
case "@ai-sdk/groq":
|
|
@@ -117,6 +130,11 @@ export const createSdkModel = (
|
|
|
117
130
|
languageModel: createGroq({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
|
|
118
131
|
catalog,
|
|
119
132
|
};
|
|
133
|
+
case "@ai-sdk/cerebras":
|
|
134
|
+
return {
|
|
135
|
+
languageModel: createCerebras({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
|
|
136
|
+
catalog,
|
|
137
|
+
};
|
|
120
138
|
case "@ai-sdk/mistral":
|
|
121
139
|
return {
|
|
122
140
|
languageModel: createMistral({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
|
|
@@ -130,21 +148,41 @@ export const createSdkModel = (
|
|
|
130
148
|
case "@ai-sdk/deepinfra":
|
|
131
149
|
return {
|
|
132
150
|
languageModel: createDeepInfra({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
|
|
151
|
+
...(normalizeCost === undefined ? {} : { normalizeCost }),
|
|
152
|
+
...(catalog.id === "deepinfra"
|
|
153
|
+
? { cacheAffinity: { target: "provider-option" as const, provider: "deepinfra", name: "prompt_cache_key" } }
|
|
154
|
+
: {}),
|
|
133
155
|
catalog,
|
|
134
156
|
};
|
|
135
157
|
case "@ai-sdk/google":
|
|
136
158
|
return {
|
|
137
159
|
languageModel: createGoogle({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
|
|
160
|
+
reasoningResponseProviderOptions: {
|
|
161
|
+
google: { thinkingConfig: { includeThoughts: true } },
|
|
162
|
+
},
|
|
138
163
|
catalog,
|
|
139
164
|
};
|
|
140
|
-
case "@ai-sdk/xai":
|
|
165
|
+
case "@ai-sdk/xai": {
|
|
166
|
+
const key = requireApiKey(provider, env, catalog);
|
|
167
|
+
const compatibleBase = url ?? "https://api.x.ai/v1";
|
|
141
168
|
return {
|
|
142
|
-
|
|
169
|
+
compatible: {
|
|
170
|
+
url: `${compatibleBase}/chat/completions`,
|
|
171
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
172
|
+
},
|
|
173
|
+
...(catalog.id === "xai"
|
|
174
|
+
? { cacheAffinity: { target: "header" as const, name: "x-grok-conv-id" } }
|
|
175
|
+
: {}),
|
|
176
|
+
...(normalizeCost === undefined ? {} : { normalizeCost }),
|
|
143
177
|
catalog,
|
|
144
178
|
};
|
|
179
|
+
}
|
|
145
180
|
case "@ai-sdk/anthropic":
|
|
146
181
|
return {
|
|
147
182
|
languageModel: createAnthropic({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
|
|
183
|
+
...(catalog.id === "anthropic"
|
|
184
|
+
? { systemCacheProviderOptions: { anthropic: { cacheControl } } }
|
|
185
|
+
: {}),
|
|
148
186
|
catalog,
|
|
149
187
|
};
|
|
150
188
|
case "@ai-sdk/amazon-bedrock":
|
|
@@ -169,6 +207,13 @@ export const createSdkModel = (
|
|
|
169
207
|
...(env.OPENROUTER_X_TITLE === undefined ? {} : { "X-Title": env.OPENROUTER_X_TITLE }),
|
|
170
208
|
},
|
|
171
209
|
}).languageModel(model),
|
|
210
|
+
...(catalog.id === "openrouter"
|
|
211
|
+
? { cacheAffinity: { target: "header" as const, name: "x-session-id" } }
|
|
212
|
+
: {}),
|
|
213
|
+
...(catalog.id === "openrouter" && model.replace(/^~/, "").startsWith("anthropic/")
|
|
214
|
+
? { systemCacheProviderOptions: { openrouter: { cacheControl } } }
|
|
215
|
+
: {}),
|
|
216
|
+
...(normalizeCost === undefined ? {} : { normalizeCost }),
|
|
172
217
|
catalog,
|
|
173
218
|
};
|
|
174
219
|
case "@ai-sdk/openai-compatible":
|
|
@@ -182,6 +227,11 @@ export const createSdkModel = (
|
|
|
182
227
|
? {}
|
|
183
228
|
: { Authorization: `Bearer ${key}` },
|
|
184
229
|
},
|
|
230
|
+
...(catalog.id === "cloudflare-workers-ai"
|
|
231
|
+
? { cacheAffinity: { target: "header" as const, name: "x-session-affinity" } }
|
|
232
|
+
: catalog.id === "fireworks-ai"
|
|
233
|
+
? { cacheAffinity: { target: "body" as const, name: "prompt_cache_key" } }
|
|
234
|
+
: {}),
|
|
185
235
|
catalog,
|
|
186
236
|
};
|
|
187
237
|
default:
|