@almadar/llm 2.50.0 → 2.52.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/src/client.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  getGlobalRateLimiter,
23
23
  type RateLimiterOptions,
24
24
  } from './rate-limiter.js';
25
- import { TokenTracker, getGlobalTokenTracker } from './token-tracker.js';
25
+ import { TokenTracker, getGlobalTokenTracker, estimateCostUSD } from './token-tracker.js';
26
26
  import { parseJsonResponse } from './json-parser.js';
27
27
  import {
28
28
  parseChatCompletionResponse,
@@ -203,6 +203,15 @@ export interface LLMUsage {
203
203
  * `prompt_tokens_details.cached_tokens`, deepseek-native
204
204
  * `prompt_cache_hit_tokens`). Absent when the provider reports neither. */
205
205
  cachedPromptTokens?: number;
206
+ /**
207
+ * USD cost of this one call. OpenRouter's authoritative `usage.cost`
208
+ * (real, routing+cache-adjusted charge) when the provider is
209
+ * `openrouter`; otherwise `estimateCostUSD` priced from the same
210
+ * OpenRouter-fetched table `TokenTracker` uses for the whole-run total.
211
+ * Absent — never `0` — when neither is available (pricing fetch
212
+ * pending, or the model isn't in OpenRouter's catalog).
213
+ */
214
+ costUSD?: number;
206
215
  }
207
216
 
208
217
  export type LLMFinishReason =
@@ -1329,11 +1338,23 @@ export class LLMClient {
1329
1338
  };
1330
1339
  const cachedTokens =
1331
1340
  rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens;
1341
+ // Prefer OpenRouter's authoritative charge; otherwise price the
1342
+ // tokens from the same pricing table the tracker uses (absent,
1343
+ // never 0, when the model has no known pricing row yet).
1344
+ const costUSD =
1345
+ typeof rawUsage.cost === 'number'
1346
+ ? rawUsage.cost
1347
+ : estimateCostUSD(this.modelName, {
1348
+ promptTokens: parsed.usage.prompt_tokens,
1349
+ completionTokens: parsed.usage.completion_tokens,
1350
+ cachedPromptTokens: cachedTokens ?? 0,
1351
+ });
1332
1352
  usage = {
1333
1353
  promptTokens: parsed.usage.prompt_tokens,
1334
1354
  completionTokens: parsed.usage.completion_tokens,
1335
1355
  totalTokens: parsed.usage.total_tokens,
1336
1356
  ...(cachedTokens !== undefined ? { cachedPromptTokens: cachedTokens } : {}),
1357
+ ...(costUSD !== undefined ? { costUSD } : {}),
1337
1358
  };
1338
1359
  if (this.tokenTracker) {
1339
1360
  this.tokenTracker.addUsage(usage.promptTokens, usage.completionTokens, {
@@ -41,13 +41,15 @@ export const LOCAL_IMAGE_MODELS = {
41
41
  Z_IMAGE_TURBO: 'z-image-turbo',
42
42
  FLUX_SCHNELL: 'flux-schnell',
43
43
  FLUX_DEV: 'flux-dev',
44
+ FLUX_KONTEXT: 'flux-kontext',
45
+ FLUX2_KLEIN: 'flux2-klein',
44
46
  } as const;
45
47
 
46
48
  export const LOCAL_IMAGE_DEFAULTS = {
47
49
  /** Z-Image-Turbo is the default: public on Hugging Face, no token required (FLUX is gated). */
48
- sheet: LOCAL_IMAGE_MODELS.Z_IMAGE_TURBO,
49
- preview: LOCAL_IMAGE_MODELS.Z_IMAGE_TURBO,
50
- } as const;
50
+ sheet: process.env.LOCAL_IMAGE_SHEET_MODEL ?? LOCAL_IMAGE_MODELS.Z_IMAGE_TURBO,
51
+ preview: process.env.LOCAL_IMAGE_PREVIEW_MODEL ?? LOCAL_IMAGE_MODELS.Z_IMAGE_TURBO,
52
+ };
51
53
 
52
54
  export interface ImageClientOptions {
53
55
  provider?: ImageProvider;
@@ -72,6 +74,10 @@ export interface ImageGenerateOptions {
72
74
  quality?: 'auto' | 'low' | 'medium' | 'high';
73
75
  background?: 'auto' | 'transparent' | 'opaque';
74
76
  seed?: number;
77
+ /** Diffusion step count (local shim; 1–100). */
78
+ steps?: number;
79
+ /** img2img denoise strength 0–1 (local shim `image_strength`), used with `references`. */
80
+ strength?: number;
75
81
  references?: ReadonlyArray<ImageReference>;
76
82
  provider?: { order?: string[]; only?: string[]; allowFallbacks?: boolean };
77
83
  }
@@ -121,6 +127,8 @@ const API_KEY_ENV_VARS: Record<ImageProvider, string> = {
121
127
  const IMAGE_CHECKED_PARAM_NAMES = {
122
128
  background: 'background',
123
129
  seed: 'seed',
130
+ steps: 'steps',
131
+ strength: 'image_strength',
124
132
  references: 'input_references',
125
133
  } as const;
126
134
 
@@ -218,7 +226,11 @@ export class ImageClient {
218
226
 
219
227
  // The local shim serves no `.../endpoints` metadata; declare what it honors.
220
228
  if (this.provider === 'local') {
221
- const local: ImageModelCapabilities = { model, supportedParameters: new Set(['seed', IMAGE_CHECKED_PARAM_NAMES.references]), raw: {} };
229
+ const local: ImageModelCapabilities = {
230
+ model,
231
+ supportedParameters: new Set(['seed', IMAGE_CHECKED_PARAM_NAMES.steps, IMAGE_CHECKED_PARAM_NAMES.strength, IMAGE_CHECKED_PARAM_NAMES.references]),
232
+ raw: {},
233
+ };
222
234
  this.capabilitiesCache.set(model, local);
223
235
  return local;
224
236
  }
@@ -259,6 +271,14 @@ export class ImageClient {
259
271
  delete filtered.seed;
260
272
  dropped.push(IMAGE_CHECKED_PARAM_NAMES.seed);
261
273
  }
274
+ if (filtered.steps !== undefined && !caps.supportedParameters.has(IMAGE_CHECKED_PARAM_NAMES.steps)) {
275
+ delete filtered.steps;
276
+ dropped.push(IMAGE_CHECKED_PARAM_NAMES.steps);
277
+ }
278
+ if (filtered.strength !== undefined && !caps.supportedParameters.has(IMAGE_CHECKED_PARAM_NAMES.strength)) {
279
+ delete filtered.strength;
280
+ dropped.push(IMAGE_CHECKED_PARAM_NAMES.strength);
281
+ }
262
282
  if (
263
283
  filtered.references !== undefined &&
264
284
  filtered.references.length > 0 &&
@@ -289,6 +309,7 @@ export class ImageClient {
289
309
  if (opts.quality !== undefined) requestBody.quality = opts.quality;
290
310
  if (opts.background !== undefined) requestBody.background = opts.background;
291
311
  if (opts.seed !== undefined) requestBody.seed = opts.seed;
312
+ if (opts.steps !== undefined) requestBody.steps = opts.steps;
292
313
  if (opts.references !== undefined && opts.references.length > 0) {
293
314
  requestBody.input_references = opts.references.map((r) => ({ type: 'image_url', image_url: { url: r.url } }));
294
315
  }
@@ -318,6 +339,8 @@ export class ImageClient {
318
339
  if (opts.n !== undefined) requestBody.n = opts.n;
319
340
  if (opts.size !== undefined) requestBody.size = opts.size;
320
341
  if (opts.seed !== undefined) requestBody.seed = opts.seed;
342
+ if (opts.steps !== undefined) requestBody.steps = opts.steps;
343
+ if (opts.strength !== undefined) requestBody.image_strength = opts.strength;
321
344
  if (opts.references !== undefined && opts.references.length > 0) {
322
345
  requestBody.input_references = opts.references.map((r) => ({ type: 'image_url', image_url: { url: r.url } }));
323
346
  }
package/src/index.ts CHANGED
@@ -59,7 +59,9 @@ export {
59
59
  TokenTracker,
60
60
  getGlobalTokenTracker,
61
61
  resetGlobalTokenTracker,
62
+ estimateCostUSD,
62
63
  type TokenUsage,
64
+ type EstimateCostTokens,
63
65
  } from './token-tracker.js';
64
66
 
65
67
  export {
@@ -158,4 +160,27 @@ export {
158
160
  type RankedEdit,
159
161
  type RankEditsResult,
160
162
  type MasarHealthResult,
163
+ JevProvider,
164
+ JevError,
165
+ getJevProvider,
166
+ resetJevProvider,
167
+ isJevAvailable,
168
+ JEV_MODELS,
169
+ JEV_DECISIONS_URL,
170
+ type JevModelId,
171
+ type JevJsonValue,
172
+ type JevState,
173
+ type JevNoulQuestion,
174
+ type JevChoiceQuestion,
175
+ type JevScoreQuestion,
176
+ type JevQuestion,
177
+ type JevNoulAnswer,
178
+ type JevChoiceAnswer,
179
+ type JevScoreAnswer,
180
+ type JevAnswer,
181
+ type JevDecideRequest,
182
+ type JevUsage,
183
+ type JevDecideResult,
184
+ type JevProviderOptions,
185
+ type JevHealthResult,
161
186
  } from './providers/index.js';
@@ -22,3 +22,29 @@ export {
22
22
  type RankEditsResult,
23
23
  type MasarHealthResult,
24
24
  } from './masar.js';
25
+
26
+ export {
27
+ JevProvider,
28
+ JevError,
29
+ getJevProvider,
30
+ resetJevProvider,
31
+ isJevAvailable,
32
+ JEV_MODELS,
33
+ JEV_DECISIONS_URL,
34
+ type JevModelId,
35
+ type JevJsonValue,
36
+ type JevState,
37
+ type JevNoulQuestion,
38
+ type JevChoiceQuestion,
39
+ type JevScoreQuestion,
40
+ type JevQuestion,
41
+ type JevNoulAnswer,
42
+ type JevChoiceAnswer,
43
+ type JevScoreAnswer,
44
+ type JevAnswer,
45
+ type JevDecideRequest,
46
+ type JevUsage,
47
+ type JevDecideResult,
48
+ type JevProviderOptions,
49
+ type JevHealthResult,
50
+ } from './jev.js';
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Jev Provider
3
+ *
4
+ * Thin HTTP client for TypeSafe's "System One" decision model on OpenRouter.
5
+ * Not a chat model — `POST /api/alpha/decisions`, one or more `noul` / `choice`
6
+ * / `score` questions batched into a single round-trip. No temperature/top_p/
7
+ * max_tokens, no streaming, no system prompt.
8
+ *
9
+ * See docs/Almadar_Rabit.md § "Jev decision provider" for the verified contract.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import { getGlobalTokenTracker } from '../token-tracker.js';
15
+
16
+ // ============================================================================
17
+ // Models
18
+ // ============================================================================
19
+
20
+ export const JEV_MODELS = {
21
+ JEV_1_13: 'typesafe/jev-1.13',
22
+ JEV_LATEST: '~typesafe/jev-latest',
23
+ } as const;
24
+
25
+ export type JevModelId = (typeof JEV_MODELS)[keyof typeof JEV_MODELS];
26
+
27
+ export const JEV_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
28
+
29
+ const JEV_MODELS_BASE_URL = 'https://openrouter.ai/api/v1/models';
30
+ const DEFAULT_TIMEOUT_MS = 30_000;
31
+
32
+ // ============================================================================
33
+ // JSON value (recursive, not Record<string, unknown> — repo lint bans that)
34
+ // ============================================================================
35
+
36
+ export type JevJsonValue =
37
+ | string
38
+ | number
39
+ | boolean
40
+ | null
41
+ | JevJsonValue[]
42
+ | { [key: string]: JevJsonValue };
43
+
44
+ export type JevState = string | Record<string, JevJsonValue>;
45
+
46
+ // ============================================================================
47
+ // Questions
48
+ // ============================================================================
49
+
50
+ export interface JevNoulQuestion {
51
+ type: 'noul';
52
+ /** Must pose the yes/no question directly (docs.typesafe.ai/api, e.g. "Does this convey urgency?") — the noul answer is P(yes) TO THIS TEXT, never a policy paragraph. */
53
+ instructions: string;
54
+ /** Optional descriptions of what a yes and a no mean (docs-verified request shape); passed through verbatim to `/api/alpha/decisions`. */
55
+ criteria?: { true: string; false: string };
56
+ }
57
+
58
+ export interface JevChoiceQuestion {
59
+ type: 'choice';
60
+ instructions: string;
61
+ criteria: Record<string, string>;
62
+ }
63
+
64
+ export interface JevScoreQuestion {
65
+ type: 'score';
66
+ instructions: string;
67
+ /** Ordered level descriptions, 2–10 entries (verified docs.typesafe.ai/api). */
68
+ criteria: string[];
69
+ }
70
+
71
+ export type JevQuestion = JevNoulQuestion | JevChoiceQuestion | JevScoreQuestion;
72
+
73
+ // ============================================================================
74
+ // Answers
75
+ // ============================================================================
76
+
77
+ export interface JevNoulAnswer {
78
+ type: 'noul';
79
+ noul: number;
80
+ }
81
+
82
+ export interface JevChoiceAnswer {
83
+ type: 'choice';
84
+ choice: string;
85
+ probabilities: Record<string, number>;
86
+ confidence: number;
87
+ }
88
+
89
+ export interface JevScoreAnswer {
90
+ type: 'score';
91
+ score: number;
92
+ legend: Record<string, string>;
93
+ probabilities: Record<string, number>;
94
+ confidence: number;
95
+ }
96
+
97
+ export type JevAnswer = JevNoulAnswer | JevChoiceAnswer | JevScoreAnswer;
98
+
99
+ // ============================================================================
100
+ // Request / Result
101
+ // ============================================================================
102
+
103
+ export interface JevDecideRequest<Q extends Record<string, JevQuestion>> {
104
+ state: JevState;
105
+ questions: Q;
106
+ model?: JevModelId;
107
+ }
108
+
109
+ export interface JevUsage {
110
+ inputTokens: number;
111
+ outputTokens: number;
112
+ costUSD: number;
113
+ }
114
+
115
+ // The extends-check must run on JevAnswerFor's OWN naked type parameter, not
116
+ // inline on the indexed access `Q[K]` — a conditional keyed off an indexed
117
+ // access type isn't distributive, so a wide Q (e.g. Record<string,
118
+ // JevQuestion>) would collapse every key to the final `never`/last branch
119
+ // instead of distributing per union member.
120
+ type JevAnswerFor<TQ extends JevQuestion> = TQ extends JevNoulQuestion
121
+ ? JevNoulAnswer
122
+ : TQ extends JevChoiceQuestion
123
+ ? JevChoiceAnswer
124
+ : TQ extends JevScoreQuestion
125
+ ? JevScoreAnswer
126
+ : never;
127
+
128
+ type JevAnswersOf<Q extends Record<string, JevQuestion>> = {
129
+ [K in keyof Q]: JevAnswerFor<Q[K]>;
130
+ };
131
+
132
+ export interface JevDecideResult<Q extends Record<string, JevQuestion>> {
133
+ answers: JevAnswersOf<Q>;
134
+ model: string;
135
+ id: string;
136
+ provider: string;
137
+ usage: JevUsage;
138
+ durationMs: number;
139
+ }
140
+
141
+ export interface JevProviderOptions {
142
+ apiKey?: string;
143
+ model?: JevModelId;
144
+ baseUrl?: string;
145
+ timeoutMs?: number;
146
+ fetchImpl?: typeof fetch;
147
+ }
148
+
149
+ export interface JevHealthResult {
150
+ ok: boolean;
151
+ model: string;
152
+ endpoints: number;
153
+ }
154
+
155
+ // ============================================================================
156
+ // Error
157
+ // ============================================================================
158
+
159
+ export class JevError extends Error {
160
+ constructor(
161
+ message: string,
162
+ public readonly status?: number,
163
+ public readonly body?: string,
164
+ ) {
165
+ super(message);
166
+ this.name = 'JevError';
167
+ }
168
+ }
169
+
170
+ // ============================================================================
171
+ // Runtime validation — parse untrusted JSON into narrow local types via
172
+ // explicit checks, never `as any` / `as unknown as X`.
173
+ // ============================================================================
174
+
175
+ function isRecord(value: unknown): value is { [key: string]: unknown } {
176
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
177
+ }
178
+
179
+ function isNumberRecord(value: unknown): value is Record<string, number> {
180
+ return isRecord(value) && Object.values(value).every((v) => typeof v === 'number');
181
+ }
182
+
183
+ function isStringRecord(value: unknown): value is Record<string, string> {
184
+ return isRecord(value) && Object.values(value).every((v) => typeof v === 'string');
185
+ }
186
+
187
+ function isNoulAnswer(value: unknown): value is JevNoulAnswer {
188
+ return isRecord(value) && value.type === 'noul' && typeof value.noul === 'number';
189
+ }
190
+
191
+ function isChoiceAnswer(value: unknown): value is JevChoiceAnswer {
192
+ return (
193
+ isRecord(value) &&
194
+ value.type === 'choice' &&
195
+ typeof value.choice === 'string' &&
196
+ typeof value.confidence === 'number' &&
197
+ isNumberRecord(value.probabilities)
198
+ );
199
+ }
200
+
201
+ function isScoreAnswer(value: unknown): value is JevScoreAnswer {
202
+ return (
203
+ isRecord(value) &&
204
+ value.type === 'score' &&
205
+ typeof value.score === 'number' &&
206
+ typeof value.confidence === 'number' &&
207
+ isStringRecord(value.legend) &&
208
+ isNumberRecord(value.probabilities)
209
+ );
210
+ }
211
+
212
+ function isJevAnswer(value: unknown): value is JevAnswer {
213
+ return isNoulAnswer(value) || isChoiceAnswer(value) || isScoreAnswer(value);
214
+ }
215
+
216
+ interface JevWireBody {
217
+ model: string;
218
+ id: string;
219
+ provider: string;
220
+ answers: Record<string, JevAnswer>;
221
+ usage: { inputTokens: number; outputTokens: number; cost: number };
222
+ }
223
+
224
+ function parseWireBody(value: unknown, status: number, rawBody: string): JevWireBody {
225
+ if (!isRecord(value)) {
226
+ throw new JevError('Jev decide: response body is not a JSON object', status, rawBody);
227
+ }
228
+ const { model, id, provider, answers, usage } = value;
229
+ if (typeof model !== 'string') {
230
+ throw new JevError('Jev decide: response is missing string "model"', status, rawBody);
231
+ }
232
+ if (typeof id !== 'string') {
233
+ throw new JevError('Jev decide: response is missing string "id"', status, rawBody);
234
+ }
235
+ if (typeof provider !== 'string') {
236
+ throw new JevError('Jev decide: response is missing string "provider"', status, rawBody);
237
+ }
238
+ if (!isRecord(answers)) {
239
+ throw new JevError('Jev decide: response is missing an "answers" object', status, rawBody);
240
+ }
241
+ if (!isRecord(usage)) {
242
+ throw new JevError('Jev decide: response is missing a "usage" object', status, rawBody);
243
+ }
244
+ const { input_tokens: inputTokens, output_tokens: outputTokens, cost } = usage;
245
+ if (typeof inputTokens !== 'number' || typeof outputTokens !== 'number' || typeof cost !== 'number') {
246
+ throw new JevError('Jev decide: "usage" is missing input_tokens/output_tokens/cost', status, rawBody);
247
+ }
248
+
249
+ const parsedAnswers: Record<string, JevAnswer> = {};
250
+ for (const [key, answer] of Object.entries(answers)) {
251
+ if (!isJevAnswer(answer)) {
252
+ throw new JevError(`Jev decide: answer "${key}" has an invalid or unrecognized shape`, status, rawBody);
253
+ }
254
+ parsedAnswers[key] = answer;
255
+ }
256
+
257
+ return { model, id, provider, answers: parsedAnswers, usage: { inputTokens, outputTokens, cost } };
258
+ }
259
+
260
+ // ============================================================================
261
+ // Provider
262
+ // ============================================================================
263
+
264
+ export class JevProvider {
265
+ private readonly apiKeyOverride: string | undefined;
266
+ private readonly defaultModel: JevModelId;
267
+ private readonly baseUrl: string;
268
+ private readonly timeoutMs: number;
269
+ private readonly fetchImpl: typeof fetch;
270
+
271
+ constructor(options?: JevProviderOptions) {
272
+ this.apiKeyOverride = options?.apiKey;
273
+ this.defaultModel = options?.model ?? JEV_MODELS.JEV_1_13;
274
+ this.baseUrl = options?.baseUrl ?? JEV_DECISIONS_URL;
275
+ this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
276
+ this.fetchImpl = options?.fetchImpl ?? fetch;
277
+ }
278
+
279
+ /**
280
+ * Answer one or more `noul` / `choice` / `score` questions about `state`
281
+ * in a single round-trip.
282
+ *
283
+ * POST /api/alpha/decisions
284
+ */
285
+ async decide<Q extends Record<string, JevQuestion>>(
286
+ req: JevDecideRequest<Q>,
287
+ ): Promise<JevDecideResult<Q>> {
288
+ const apiKey = this.resolveApiKey();
289
+ const model = req.model ?? this.defaultModel;
290
+
291
+ const controller = new AbortController();
292
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
293
+ const startedAt = Date.now();
294
+
295
+ let response: Response;
296
+ try {
297
+ response = await this.fetchImpl(this.baseUrl, {
298
+ method: 'POST',
299
+ headers: {
300
+ Authorization: `Bearer ${apiKey}`,
301
+ 'Content-Type': 'application/json',
302
+ },
303
+ body: JSON.stringify({ model, state: req.state, questions: req.questions }),
304
+ signal: controller.signal,
305
+ });
306
+ } catch (error) {
307
+ clearTimeout(timer);
308
+ if (error instanceof DOMException && error.name === 'AbortError') {
309
+ throw new JevError(`Jev decide timed out after ${this.timeoutMs}ms`);
310
+ }
311
+ const message = error instanceof Error ? error.message : String(error);
312
+ throw new JevError(`Jev decide request failed: ${message}`);
313
+ }
314
+ clearTimeout(timer);
315
+
316
+ const rawBody = await response.text();
317
+ const durationMs = Date.now() - startedAt;
318
+
319
+ if (!response.ok) {
320
+ throw new JevError(`Jev decide failed with status ${response.status}`, response.status, rawBody);
321
+ }
322
+
323
+ let parsedJson: unknown;
324
+ try {
325
+ parsedJson = JSON.parse(rawBody);
326
+ } catch {
327
+ throw new JevError('Jev decide: response body is not valid JSON', response.status, rawBody);
328
+ }
329
+
330
+ const wire = parseWireBody(parsedJson, response.status, rawBody);
331
+
332
+ const answers: Record<string, JevAnswer> = {};
333
+ for (const key of Object.keys(req.questions)) {
334
+ const question = req.questions[key];
335
+ const answer = wire.answers[key];
336
+ if (!answer) {
337
+ throw new JevError(`Jev decide: missing answer for question "${key}"`, response.status, rawBody);
338
+ }
339
+ if (answer.type !== question.type) {
340
+ throw new JevError(
341
+ `Jev decide: answer "${key}" has type "${answer.type}", expected "${question.type}"`,
342
+ response.status,
343
+ rawBody,
344
+ );
345
+ }
346
+ if (answer.type === 'choice' && question.type === 'choice' && !(answer.choice in question.criteria)) {
347
+ throw new JevError(
348
+ `Jev decide: answer "${key}" chose "${answer.choice}", which is not one of the declared criteria`,
349
+ response.status,
350
+ rawBody,
351
+ );
352
+ }
353
+ answers[key] = answer;
354
+ }
355
+
356
+ getGlobalTokenTracker(model).addUsage(wire.usage.inputTokens, wire.usage.outputTokens, {
357
+ provider: 'jev',
358
+ durationMs,
359
+ // Jev is absent from OpenRouter's /api/v1/models catalog, so cost must
360
+ // come from the decisions endpoint's own authoritative usage.cost.
361
+ costUSD: wire.usage.cost,
362
+ });
363
+
364
+ return {
365
+ // Unavoidable: Object.keys(req.questions) erases each key to plain
366
+ // `string`, so TS can't prove a loop-built object satisfies the
367
+ // generic mapped type JevAnswersOf<Q> per key — this one assertion
368
+ // follows the per-key runtime validation (present, type, criteria) above.
369
+ answers: answers as JevAnswersOf<Q>,
370
+ model: wire.model,
371
+ id: wire.id,
372
+ provider: wire.provider,
373
+ usage: {
374
+ inputTokens: wire.usage.inputTokens,
375
+ outputTokens: wire.usage.outputTokens,
376
+ costUSD: wire.usage.cost,
377
+ },
378
+ durationMs,
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Check the model is servable. `~typesafe/jev-latest` legitimately reports
384
+ * `endpoints: []` yet still works as a `model` value, so `ok` tracks HTTP
385
+ * 200 only — an empty endpoint list is never treated as "down".
386
+ *
387
+ * GET /api/v1/models/<model>/endpoints
388
+ */
389
+ async health(): Promise<JevHealthResult> {
390
+ const apiKey = this.resolveApiKey();
391
+ const model = this.defaultModel;
392
+ const url = `${JEV_MODELS_BASE_URL}/${model}/endpoints`;
393
+
394
+ let response: Response;
395
+ try {
396
+ response = await this.fetchImpl(url, {
397
+ headers: { Authorization: `Bearer ${apiKey}` },
398
+ });
399
+ } catch {
400
+ return { ok: false, model, endpoints: 0 };
401
+ }
402
+ if (!response.ok) {
403
+ return { ok: false, model, endpoints: 0 };
404
+ }
405
+
406
+ let endpoints = 0;
407
+ try {
408
+ const parsed: unknown = JSON.parse(await response.text());
409
+ if (isRecord(parsed) && isRecord(parsed.data) && Array.isArray(parsed.data.endpoints)) {
410
+ endpoints = parsed.data.endpoints.length;
411
+ }
412
+ } catch {
413
+ // Non-JSON body: still HTTP 200, so `ok` stays true with endpoints 0.
414
+ }
415
+ return { ok: true, model, endpoints };
416
+ }
417
+
418
+ private resolveApiKey(): string {
419
+ const apiKey = this.apiKeyOverride ?? process.env.OPENROUTER_API_KEY ?? process.env.OPEN_ROUTER_API_KEY;
420
+ if (!apiKey) {
421
+ throw new JevError(
422
+ 'Jev: no API key. Set OPENROUTER_API_KEY (or OPEN_ROUTER_API_KEY) in the environment, or pass { apiKey } to JevProvider.',
423
+ );
424
+ }
425
+ return apiKey;
426
+ }
427
+ }
428
+
429
+ // ============================================================================
430
+ // Singleton
431
+ // ============================================================================
432
+
433
+ let sharedInstance: JevProvider | null = null;
434
+
435
+ /**
436
+ * Get the singleton Jev provider instance.
437
+ *
438
+ * Creates the instance on first call, returns cached instance thereafter.
439
+ *
440
+ * @param {JevProviderOptions} [options] - Provider configuration options
441
+ * @returns {JevProvider} The Jev provider instance
442
+ */
443
+ export function getJevProvider(options?: JevProviderOptions): JevProvider {
444
+ if (!sharedInstance) {
445
+ sharedInstance = new JevProvider(options);
446
+ }
447
+ return sharedInstance;
448
+ }
449
+
450
+ export function resetJevProvider(): void {
451
+ sharedInstance = null;
452
+ }
453
+
454
+ export function isJevAvailable(): boolean {
455
+ return Boolean(process.env.OPENROUTER_API_KEY ?? process.env.OPEN_ROUTER_API_KEY);
456
+ }