@providerkit/core 0.12.2 → 0.13.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/README.md +1 -0
- package/dist/context.js +2 -2
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +14 -0
- package/dist/errors.js.map +1 -1
- package/dist/jev.d.ts +123 -0
- package/dist/jev.d.ts.map +1 -0
- package/dist/jev.js +278 -0
- package/dist/jev.js.map +1 -0
- package/dist/presets.d.ts +3 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts +5 -4
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +137 -16
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +2 -0
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +36 -17
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +74 -39
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +1 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +16 -8
- package/dist/providers/responses.js.map +1 -1
- package/dist/types.d.ts +47 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +84 -1
- package/dist/types.js.map +1 -1
- package/package.json +5 -1
- package/src/context.ts +2 -2
- package/src/errors.ts +20 -0
- package/src/jev.ts +444 -0
- package/src/presets.ts +3 -2
- package/src/providers/anthropic.ts +152 -23
- package/src/providers/gemini.ts +2 -0
- package/src/providers/openai.ts +76 -41
- package/src/providers/responses.ts +20 -10
- package/src/types.ts +106 -2
package/src/errors.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// empty balance, "check your key" for a plan that never included the API).
|
|
12
12
|
|
|
13
13
|
import { parseUsageLimitBody, type RateLimitWindow } from "./rate-limit.ts";
|
|
14
|
+
import type { FilePart } from "./types.ts";
|
|
14
15
|
|
|
15
16
|
/** What kind of failure this is, named by what actually fixes it. */
|
|
16
17
|
export type ErrorKind =
|
|
@@ -417,6 +418,10 @@ const AUTH_PATTERNS: readonly RegExp[] = [
|
|
|
417
418
|
/unrecognizedclient|unauthorizedexception|accessdeniedexception/i,
|
|
418
419
|
/expired[_\s]?token/i,
|
|
419
420
|
/OAuth token has been revoked/i,
|
|
421
|
+
// Cloudflare's code 7003, for an account id that names no account. It
|
|
422
|
+
// arrives as a 404, which on status alone reads as a missing model — but
|
|
423
|
+
// the fix is the credentials, and a model swap would fail the same way.
|
|
424
|
+
/perhaps your object identifier is invalid/i,
|
|
420
425
|
];
|
|
421
426
|
|
|
422
427
|
const CONTENT_PATTERNS: readonly RegExp[] = [
|
|
@@ -691,3 +696,18 @@ export function describeProviderError(err: unknown): Record<string, unknown> {
|
|
|
691
696
|
}
|
|
692
697
|
return { error: messageOf(err), kind: classify(err) };
|
|
693
698
|
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* A file part on a wire that cannot carry one. Refused, never dropped: a
|
|
702
|
+
* dropped attachment is a model answering about a file it never saw, on the
|
|
703
|
+
* happy path, where nothing retries and nothing logs. `adapter` names the
|
|
704
|
+
* wire, because `provider` is often a gateway's id rather than the shape's.
|
|
705
|
+
*/
|
|
706
|
+
export function fileRefused(provider: string, adapter: string, part: FilePart): ProviderError {
|
|
707
|
+
return new ProviderError(
|
|
708
|
+
provider,
|
|
709
|
+
"invalid",
|
|
710
|
+
`${provider}: the ${adapter} adapter can't send a file (${part.mimeType}). Only the Gemini ` +
|
|
711
|
+
"adapter takes files. Send this request to a Gemini model, or leave the file out.",
|
|
712
|
+
);
|
|
713
|
+
}
|
package/src/jev.ts
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// Jev — TypeSafe's decision model, on any of the four hosts that serve it.
|
|
2
|
+
// `@providerkit/core/jev`.
|
|
3
|
+
//
|
|
4
|
+
// Jev does not write text. It reads a `state` and answers typed questions —
|
|
5
|
+
// pick one option (choice), is this true (noul), where on this scale (score) —
|
|
6
|
+
// each with the probabilities behind the answer, in well under a second, for
|
|
7
|
+
// $0.042 per million input tokens with output free (TypeSafe's list price,
|
|
8
|
+
// 2026-09-27). That is why it is not a `Provider`: there is no stream and no
|
|
9
|
+
// message, nothing the chat seam could carry without lying about it. It gets
|
|
10
|
+
// its own entry point and borrows the rest of the package — the error
|
|
11
|
+
// envelope, the classifier, retry — so a throttled Jev call behaves exactly
|
|
12
|
+
// like a throttled chat call.
|
|
13
|
+
//
|
|
14
|
+
// The four hosts serve one model and read the same questions. They differ
|
|
15
|
+
// only in the envelope: the URL, the auth headers, how the body is wrapped,
|
|
16
|
+
// and where the answers and the usage sit in the reply. One row per host.
|
|
17
|
+
//
|
|
18
|
+
// Prices stay with the caller, for the reason in usage.ts. OpenRouter reports
|
|
19
|
+
// what each call cost; the caller's `ModelRate` prices the other three.
|
|
20
|
+
import { attributionHeaders, type Attribution } from "./attribution.ts";
|
|
21
|
+
import { ProviderError, streamError } from "./errors.ts";
|
|
22
|
+
import { openRouterCostUsd } from "./providers/openai.ts";
|
|
23
|
+
import { withRetry, type RetryOptions } from "./retry.ts";
|
|
24
|
+
import { postJson } from "./transport.ts";
|
|
25
|
+
import type { TokenUsage } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
export type JevHost = "typesafe" | "openrouter" | "cloudflare" | "vercel";
|
|
28
|
+
|
|
29
|
+
/** What a question asks, beyond its options: a sentence, or any JSON the
|
|
30
|
+
* model should read alongside it (a goal, a list of rules). */
|
|
31
|
+
export type JevInstructions = string | Record<string, unknown> | readonly unknown[];
|
|
32
|
+
|
|
33
|
+
/** Pick one option. */
|
|
34
|
+
export interface ChoiceQuestion<Option extends string = string> {
|
|
35
|
+
type: "choice";
|
|
36
|
+
instructions?: JevInstructions;
|
|
37
|
+
/** Each option, mapped to what it means — `null` when its name says it all.
|
|
38
|
+
* TypeSafe takes up to 255. */
|
|
39
|
+
criteria: Record<Option, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Is this statement true? */
|
|
43
|
+
export interface NoulQuestion {
|
|
44
|
+
type: "noul";
|
|
45
|
+
instructions: JevInstructions;
|
|
46
|
+
/** What yes and what no mean, when the statement alone leaves it open. */
|
|
47
|
+
criteria?: { true?: string; false?: string };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Where on this ordered scale? */
|
|
51
|
+
export interface ScoreQuestion {
|
|
52
|
+
type: "score";
|
|
53
|
+
instructions?: JevInstructions;
|
|
54
|
+
/** The levels, lowest first. TypeSafe takes 2 to 10. */
|
|
55
|
+
criteria: readonly unknown[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type JevQuestion = ChoiceQuestion | NoulQuestion | ScoreQuestion;
|
|
59
|
+
|
|
60
|
+
export interface ChoiceAnswer<Option extends string = string> {
|
|
61
|
+
type: "choice";
|
|
62
|
+
choice: Option;
|
|
63
|
+
/** Every offered option's probability. The whole distribution is kept
|
|
64
|
+
* because a caller acting on it needs more than the pick: the runner-up,
|
|
65
|
+
* P(yes) when the pick was no, a joint probability across two questions. */
|
|
66
|
+
probabilities: Record<Option, number>;
|
|
67
|
+
/** How peaked `probabilities` is, 0 to 1 — NOT the probability of the pick.
|
|
68
|
+
* TypeSafe's own example spreads 0.57 / 0.43 and reports 0.35, so reading
|
|
69
|
+
* it as P(pick) would put the pick below the option it beat. Null when the
|
|
70
|
+
* host sent none. */
|
|
71
|
+
confidence: number | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface NoulAnswer {
|
|
75
|
+
type: "noul";
|
|
76
|
+
/** The probability that the statement is true. */
|
|
77
|
+
noul: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ScoreAnswer {
|
|
81
|
+
type: "score";
|
|
82
|
+
/** The probability-weighted level, fractional: 1.43 sits between level 1
|
|
83
|
+
* and level 2, nearer 1. */
|
|
84
|
+
score: number;
|
|
85
|
+
/** Each level's probability, keyed by its index as the wire sends it:
|
|
86
|
+
* "0", "1", … */
|
|
87
|
+
probabilities: Record<string, number>;
|
|
88
|
+
confidence: number | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type JevAnswer = ChoiceAnswer | NoulAnswer | ScoreAnswer;
|
|
92
|
+
|
|
93
|
+
/** The answer type a question gets back — a choice's options carry through. */
|
|
94
|
+
export type AnswerTo<Q> =
|
|
95
|
+
Q extends ChoiceQuestion<infer Option extends string>
|
|
96
|
+
? ChoiceAnswer<Option>
|
|
97
|
+
: Q extends NoulQuestion
|
|
98
|
+
? NoulAnswer
|
|
99
|
+
: Q extends ScoreQuestion
|
|
100
|
+
? ScoreAnswer
|
|
101
|
+
: never;
|
|
102
|
+
|
|
103
|
+
export interface JevReply<Q extends Record<string, JevQuestion>> {
|
|
104
|
+
/** One per question — `null` when that answer did not pass the checks in
|
|
105
|
+
* `readAnswer`. Each fails alone: in a fan-out, a malformed answer to a
|
|
106
|
+
* question the caller never acts on must not cost the one it does. */
|
|
107
|
+
answers: { [Id in keyof Q]: AnswerTo<Q[Id]> | null };
|
|
108
|
+
usage: TokenUsage;
|
|
109
|
+
/** The model that answered, as the host names it — `jev-1.13.0` on
|
|
110
|
+
* TypeSafe, `typesafe/jev-1.13-20260917` on OpenRouter. */
|
|
111
|
+
model: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface JevConfig {
|
|
115
|
+
host: JevHost;
|
|
116
|
+
apiKey: string;
|
|
117
|
+
/** Cloudflare only: the account the Workers AI call runs on. */
|
|
118
|
+
accountId?: string;
|
|
119
|
+
/** Defaults to each host's always-current alias. */
|
|
120
|
+
model?: string;
|
|
121
|
+
/** OpenRouter only: names your app on its rankings. */
|
|
122
|
+
attribution?: Attribution;
|
|
123
|
+
/** One attempt's deadline, in ms. Default 15 s: Jev answers in under a
|
|
124
|
+
* second, so a call still open after this is not coming back. */
|
|
125
|
+
timeoutMs?: number;
|
|
126
|
+
/** Default: three attempts, transient failures only, Retry-After honoured. */
|
|
127
|
+
retry?: Omit<RetryOptions, "signal">;
|
|
128
|
+
/** Swapped in tests, or to route through a proxy. */
|
|
129
|
+
fetchImpl?: typeof fetch;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface JevClient {
|
|
133
|
+
readonly host: JevHost;
|
|
134
|
+
readonly model: string;
|
|
135
|
+
ask<Q extends Record<string, JevQuestion>>(
|
|
136
|
+
state: unknown,
|
|
137
|
+
questions: Q,
|
|
138
|
+
opts?: { signal?: AbortSignal },
|
|
139
|
+
): Promise<JevReply<Q>>;
|
|
140
|
+
/** Proves the key, the account and the model work, with one real question:
|
|
141
|
+
* about 40 input tokens, a few millionths of a dollar. A free read (a
|
|
142
|
+
* model list, a balance) proves only the key — not that this account can
|
|
143
|
+
* run this model — and Vercel has none. Throws the classified failure. */
|
|
144
|
+
checkKey(opts?: { signal?: AbortSignal }): Promise<void>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
148
|
+
|
|
149
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
150
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
151
|
+
? (value as Record<string, unknown>)
|
|
152
|
+
: undefined;
|
|
153
|
+
|
|
154
|
+
// ── the four envelopes ────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
interface HostRow {
|
|
157
|
+
model: string;
|
|
158
|
+
url(config: JevConfig): string;
|
|
159
|
+
headers(config: JevConfig, model: string): Record<string, string>;
|
|
160
|
+
body(model: string, state: unknown, questions: Record<string, JevQuestion>): unknown;
|
|
161
|
+
/** The reply in TypeSafe's own shape — `{ model, answers, usage }` —
|
|
162
|
+
* whatever the host wrapped it in. */
|
|
163
|
+
unwrap(json: unknown): Record<string, unknown> | undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const HOSTS: Record<JevHost, HostRow> = {
|
|
167
|
+
typesafe: {
|
|
168
|
+
model: "jev-latest",
|
|
169
|
+
url: () => "https://api.typesafe.ai/v1/systemone",
|
|
170
|
+
headers: () => ({}),
|
|
171
|
+
body: (model, state, questions) => ({ model, state, questions }),
|
|
172
|
+
unwrap: record,
|
|
173
|
+
},
|
|
174
|
+
openrouter: {
|
|
175
|
+
// Both this alias and the pinned `typesafe/jev-1.13` answered, live,
|
|
176
|
+
// 2026-09-27. OpenRouter also serves `/api/alpha/decisions`; this is the
|
|
177
|
+
// path its Jev guide documents next to it, and the one that answered.
|
|
178
|
+
model: "~typesafe/jev-latest",
|
|
179
|
+
url: () => "https://openrouter.ai/api/v1/systemone",
|
|
180
|
+
headers: (config) => attributionHeaders(config.attribution ?? {}),
|
|
181
|
+
body: (model, state, questions) => ({ model, state, questions }),
|
|
182
|
+
unwrap: record,
|
|
183
|
+
},
|
|
184
|
+
cloudflare: {
|
|
185
|
+
model: "typesafe/jev",
|
|
186
|
+
url: (config) =>
|
|
187
|
+
`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(config.accountId ?? "")}/ai/run`,
|
|
188
|
+
headers: () => ({}),
|
|
189
|
+
body: (model, state, questions) => ({ model, input: { state, questions } }),
|
|
190
|
+
// The v4 envelope puts the model's reply under `result`, and a task
|
|
191
|
+
// record can wrap it once more. A task that has not finished carries no
|
|
192
|
+
// answers, so it fails as an unreadable reply without a status check —
|
|
193
|
+
// which is as well, since two sources disagree on that field's name.
|
|
194
|
+
unwrap: (json) => {
|
|
195
|
+
const result = record(record(json)?.result);
|
|
196
|
+
return record(result?.result) ?? result;
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
vercel: {
|
|
200
|
+
model: "typesafe-ai/jev",
|
|
201
|
+
url: () => "https://ai-gateway.vercel.sh/v4/ai/evaluation-model",
|
|
202
|
+
// Without these the gateway answers 400 "Unsupported gateway protocol
|
|
203
|
+
// version" (checked, 2026-09-27). The model rides in a header, not the body.
|
|
204
|
+
headers: (_config, model) => ({
|
|
205
|
+
"ai-gateway-protocol-version": "0.0.1",
|
|
206
|
+
"ai-gateway-auth-method": "api-key",
|
|
207
|
+
"ai-evaluation-model-specification-version": "4",
|
|
208
|
+
"ai-model-id": model,
|
|
209
|
+
}),
|
|
210
|
+
// The gateway's own spelling of noul is `boolean`: its validator lists
|
|
211
|
+
// exactly `choice | score | boolean` (checked, 2026-09-27).
|
|
212
|
+
body: (_model, state, questions) => ({
|
|
213
|
+
state,
|
|
214
|
+
questions: Object.fromEntries(
|
|
215
|
+
Object.entries(questions).map(([id, q]) => [
|
|
216
|
+
id,
|
|
217
|
+
q.type === "noul" ? { ...q, type: "boolean" } : q,
|
|
218
|
+
]),
|
|
219
|
+
),
|
|
220
|
+
}),
|
|
221
|
+
unwrap: fromVercel,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Vercel's reply, back in TypeSafe's shape: a `boolean` answer carries its
|
|
227
|
+
* probability as `probability`, confidence moves out of the answer into
|
|
228
|
+
* `providerMetadata.typesafe.confidence`, and usage is camelCase.
|
|
229
|
+
*
|
|
230
|
+
* ponytail: read from @jkudish/jev-agent-tools' adapter, not from a recorded
|
|
231
|
+
* reply — no Vercel key has run it here. If a real reply disagrees, this
|
|
232
|
+
* function is the whole fix.
|
|
233
|
+
*/
|
|
234
|
+
function fromVercel(json: unknown): Record<string, unknown> | undefined {
|
|
235
|
+
const reply = record(json);
|
|
236
|
+
const answers = record(reply?.answers);
|
|
237
|
+
if (!reply || !answers) return reply;
|
|
238
|
+
const confidence = record(record(record(reply.providerMetadata)?.typesafe)?.confidence) ?? {};
|
|
239
|
+
const usage = record(reply.usage);
|
|
240
|
+
return {
|
|
241
|
+
model: reply.model,
|
|
242
|
+
answers: Object.fromEntries(
|
|
243
|
+
Object.entries(answers).map(([id, raw]) => {
|
|
244
|
+
const answer = record(raw);
|
|
245
|
+
if (answer?.type === "boolean") return [id, { type: "noul", noul: answer.probability }];
|
|
246
|
+
if (!answer || answer.confidence !== undefined) return [id, raw];
|
|
247
|
+
return [id, { ...answer, confidence: confidence[id] }];
|
|
248
|
+
}),
|
|
249
|
+
),
|
|
250
|
+
usage: usage && { input_tokens: usage.inputTokens, output_tokens: usage.outputTokens },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── reading an answer ─────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
const unit = (value: unknown): value is number =>
|
|
257
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Exactly the offered keys, each a probability, summing to one.
|
|
261
|
+
*
|
|
262
|
+
* Jev rounds every probability to two decimals, so each option carrying
|
|
263
|
+
* weight can drift the sum by 0.005: eight live options can honestly sum to
|
|
264
|
+
* 0.98. The tolerance grows with that count, floored at 1% and capped at 5%
|
|
265
|
+
* so garbage still fails.
|
|
266
|
+
*/
|
|
267
|
+
function distribution(value: unknown, keys: readonly string[]): Record<string, number> | null {
|
|
268
|
+
const given = record(value);
|
|
269
|
+
if (!given || keys.length === 0 || Object.keys(given).length !== keys.length) return null;
|
|
270
|
+
const out: Record<string, number> = {};
|
|
271
|
+
let sum = 0;
|
|
272
|
+
let live = 0;
|
|
273
|
+
for (const key of keys) {
|
|
274
|
+
const p = given[key];
|
|
275
|
+
if (!Object.hasOwn(given, key) || !unit(p)) return null;
|
|
276
|
+
out[key] = p;
|
|
277
|
+
sum += p;
|
|
278
|
+
if (p > 0) live++;
|
|
279
|
+
}
|
|
280
|
+
const tolerance = Math.min(0.05, Math.max(0.01, 0.005 * live)) + 1e-9;
|
|
281
|
+
return Math.abs(sum - 1) <= tolerance ? out : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* One answer, checked against the question that asked it — or null.
|
|
286
|
+
*
|
|
287
|
+
* A Jev answer usually becomes an action (a click, a label, a route), so it is
|
|
288
|
+
* read fail-closed: the right type; for a choice, a pick among the offered
|
|
289
|
+
* options that is also the most likely one in its own full distribution. A
|
|
290
|
+
* distribution is never rebuilt from `confidence`, which measures spread, not
|
|
291
|
+
* the pick.
|
|
292
|
+
*
|
|
293
|
+
* `ask` runs this on every answer. It is exported for the app that has to
|
|
294
|
+
* keep its own request — its own error copy, its own auth refresh — and can
|
|
295
|
+
* still take the checks.
|
|
296
|
+
*/
|
|
297
|
+
export function readAnswer<Q extends JevQuestion>(question: Q, raw: unknown): AnswerTo<Q> | null;
|
|
298
|
+
export function readAnswer(question: JevQuestion, raw: unknown): JevAnswer | null {
|
|
299
|
+
const answer = record(raw);
|
|
300
|
+
if (!answer || answer.type !== question.type) return null;
|
|
301
|
+
if (question.type === "noul") {
|
|
302
|
+
return unit(answer.noul) ? { type: "noul", noul: answer.noul } : null;
|
|
303
|
+
}
|
|
304
|
+
const confidence = answer.confidence ?? null;
|
|
305
|
+
if (confidence !== null && !unit(confidence)) return null;
|
|
306
|
+
if (question.type === "choice") {
|
|
307
|
+
const options = Object.keys(question.criteria);
|
|
308
|
+
const probabilities = distribution(answer.probabilities, options);
|
|
309
|
+
const choice = answer.choice;
|
|
310
|
+
if (!probabilities || typeof choice !== "string" || !options.includes(choice)) return null;
|
|
311
|
+
// 0.001 admits a tie at two decimals; a different winner never passes.
|
|
312
|
+
if ((probabilities[choice] ?? 0) + 0.001 < Math.max(...Object.values(probabilities))) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
return { type: "choice", choice, probabilities, confidence };
|
|
316
|
+
}
|
|
317
|
+
const levels = question.criteria.map((_, index) => String(index));
|
|
318
|
+
const probabilities = distribution(answer.probabilities, levels);
|
|
319
|
+
const score = answer.score;
|
|
320
|
+
if (!probabilities || typeof score !== "number" || !(score >= 0 && score <= levels.length - 1)) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
return { type: "score", score, probabilities, confidence };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const count = (value: unknown): number =>
|
|
327
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
328
|
+
|
|
329
|
+
function usageOf(host: JevHost, raw: unknown): TokenUsage {
|
|
330
|
+
const usage = record(raw) ?? {};
|
|
331
|
+
// Keyed on the host, as in the OpenAI adapter: OpenRouter is who sends the
|
|
332
|
+
// bill, and `cost` is its field in its unit. Another host's `cost` has not
|
|
333
|
+
// said what it means.
|
|
334
|
+
const reportedCostUsd =
|
|
335
|
+
host === "openrouter"
|
|
336
|
+
? openRouterCostUsd({
|
|
337
|
+
cost: usage.cost,
|
|
338
|
+
is_byok: usage.is_byok === true,
|
|
339
|
+
cost_details: record(usage.cost_details) ?? null,
|
|
340
|
+
})
|
|
341
|
+
: undefined;
|
|
342
|
+
return {
|
|
343
|
+
inputTokens: count(usage.input_tokens),
|
|
344
|
+
cachedInputTokens: 0,
|
|
345
|
+
outputTokens: count(usage.output_tokens),
|
|
346
|
+
...(reportedCostUsd !== undefined ? { reportedCostUsd } : {}),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── the client ────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/** The question `checkKey` asks: the cheapest one whose answer can be checked. */
|
|
353
|
+
const KEY_CHECK = {
|
|
354
|
+
ok: {
|
|
355
|
+
type: "choice",
|
|
356
|
+
instructions: "Does the text say hello?",
|
|
357
|
+
criteria: { YES: null, NO: null },
|
|
358
|
+
},
|
|
359
|
+
} satisfies Record<string, JevQuestion>;
|
|
360
|
+
|
|
361
|
+
export function createJevClient(config: JevConfig): JevClient {
|
|
362
|
+
const row = HOSTS[config.host];
|
|
363
|
+
if (config.host === "cloudflare" && !config.accountId) {
|
|
364
|
+
// Sent anyway, the URL has an empty account and Cloudflare answers 404,
|
|
365
|
+
// which reads as a missing model — the wrong thing to go and fix.
|
|
366
|
+
throw new Error("providerkit: Jev on Cloudflare needs the accountId of the Workers AI account");
|
|
367
|
+
}
|
|
368
|
+
const model = config.model ?? row.model;
|
|
369
|
+
const provider = `jev:${config.host}`;
|
|
370
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
371
|
+
|
|
372
|
+
async function post(body: unknown, signal: AbortSignal | undefined): Promise<unknown> {
|
|
373
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
374
|
+
try {
|
|
375
|
+
return await postJson({
|
|
376
|
+
url: row.url(config),
|
|
377
|
+
headers: { Authorization: `Bearer ${config.apiKey}`, ...row.headers(config, model) },
|
|
378
|
+
body,
|
|
379
|
+
provider,
|
|
380
|
+
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
|
|
381
|
+
fetchImpl: config.fetchImpl,
|
|
382
|
+
});
|
|
383
|
+
} catch (err) {
|
|
384
|
+
// Our deadline is a timeout, and retried; the caller's Stop leaves
|
|
385
|
+
// exactly as it arrived and never is (invariant 2).
|
|
386
|
+
if (deadline.aborted && !signal?.aborted) {
|
|
387
|
+
throw new ProviderError(provider, "timeout", `${provider}: no answer in ${timeoutMs} ms`, {
|
|
388
|
+
cause: err,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const client: JevClient = {
|
|
396
|
+
host: config.host,
|
|
397
|
+
model,
|
|
398
|
+
ask(state, questions, opts = {}) {
|
|
399
|
+
const { signal } = opts;
|
|
400
|
+
return withRetry(
|
|
401
|
+
async () => {
|
|
402
|
+
const json = await post(row.body(model, state, questions), signal);
|
|
403
|
+
const reply = row.unwrap(json);
|
|
404
|
+
const raw = record(reply?.answers);
|
|
405
|
+
if (!reply || !raw) {
|
|
406
|
+
// A failure inside a 200 is still a failure (invariant 11): a
|
|
407
|
+
// body that names one is classified like any other; a body that
|
|
408
|
+
// names nothing is a contract we do not understand, and is not
|
|
409
|
+
// retried into hiding it.
|
|
410
|
+
const payload = record(json);
|
|
411
|
+
const error = payload?.error ?? payload?.errors;
|
|
412
|
+
if (error !== undefined) throw streamError(provider, error);
|
|
413
|
+
throw new ProviderError(provider, "unknown", `${provider}: the reply has no answers`, {
|
|
414
|
+
body: (JSON.stringify(json) ?? "").slice(0, 2_000),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
const answers: Record<string, JevAnswer | null> = {};
|
|
418
|
+
for (const [id, question] of Object.entries(questions)) {
|
|
419
|
+
answers[id] = readAnswer(question, raw[id]);
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
// Sound: `readAnswer` returns each answer in its own question's
|
|
423
|
+
// type, with a choice limited to that question's options.
|
|
424
|
+
answers: answers as JevReply<typeof questions>["answers"],
|
|
425
|
+
usage: usageOf(config.host, reply.usage),
|
|
426
|
+
model: typeof reply.model === "string" && reply.model ? reply.model : model,
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
{ ...config.retry, signal },
|
|
430
|
+
);
|
|
431
|
+
},
|
|
432
|
+
async checkKey(opts) {
|
|
433
|
+
const reply = await client.ask("hello", KEY_CHECK, opts);
|
|
434
|
+
if (!reply.answers.ok) {
|
|
435
|
+
throw new ProviderError(
|
|
436
|
+
provider,
|
|
437
|
+
"unknown",
|
|
438
|
+
`${provider}: the key works, but the answer failed its checks`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
return client;
|
|
444
|
+
}
|
package/src/presets.ts
CHANGED
|
@@ -56,8 +56,9 @@ export interface ProviderPreset {
|
|
|
56
56
|
* `thinking: { type: "disabled" }` marker when effort is none. For
|
|
57
57
|
* endpoints where an ABSENT field means the model's default — thinking ON
|
|
58
58
|
* for reasoning-mandatory models (measured on Z.ai's coding endpoint:
|
|
59
|
-
* omit → thinking block; disabled → none).
|
|
60
|
-
*
|
|
59
|
+
* omit → thinking block; disabled → none). Leave it unset for native
|
|
60
|
+
* Anthropic: the adapter spells none per Claude model, and ignores this
|
|
61
|
+
* flag for every Claude from 4.6 on. */
|
|
61
62
|
explicitNone?: boolean;
|
|
62
63
|
/** Output ceiling when the caller sets none. Thinking and the answer share
|
|
63
64
|
* it, so a model that reasons past the adapter's default ends the turn with
|