@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/dist/{chunk-RBT22YSX.js → chunk-DLEZ7FGQ.js} +13 -4
- package/dist/chunk-DLEZ7FGQ.js.map +1 -0
- package/dist/chunk-MOIECDMB.js +172 -0
- package/dist/chunk-MOIECDMB.js.map +1 -0
- package/dist/chunk-OXFWONZP.js +369 -0
- package/dist/chunk-OXFWONZP.js.map +1 -0
- package/dist/{chunk-5AM54OAM.js → chunk-SJE3GTGZ.js} +5 -3
- package/dist/{chunk-5AM54OAM.js.map → chunk-SJE3GTGZ.js.map} +1 -1
- package/dist/{chunk-RPG3SUIB.js → chunk-T6AKOBX3.js} +25 -177
- package/dist/chunk-T6AKOBX3.js.map +1 -0
- package/dist/{client-SYMpnw-w.d.ts → client-DfzMDgkm.d.ts} +10 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.js +3 -2
- package/dist/index.d.ts +12 -6
- package/dist/index.js +49 -12
- package/dist/index.js.map +1 -1
- package/dist/providers/index.d.ts +138 -1
- package/dist/providers/index.js +16 -1
- package/dist/{rate-limiter-CXaf8aAy.d.ts → rate-limiter-Bz0iSJgZ.d.ts} +20 -1
- package/dist/structured-output.d.ts +1 -1
- package/dist/structured-output.js +3 -2
- package/package.json +6 -4
- package/src/client.ts +22 -1
- package/src/image-client.ts +27 -4
- package/src/index.ts +25 -0
- package/src/providers/index.ts +26 -0
- package/src/providers/jev.ts +456 -0
- package/src/token-tracker.ts +67 -12
- package/dist/chunk-MUTXGY6D.js +0 -133
- package/dist/chunk-MUTXGY6D.js.map +0 -1
- package/dist/chunk-RBT22YSX.js.map +0 -1
- package/dist/chunk-RPG3SUIB.js.map +0 -1
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, {
|
package/src/image-client.ts
CHANGED
|
@@ -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
|
-
}
|
|
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 = {
|
|
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';
|
package/src/providers/index.ts
CHANGED
|
@@ -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
|
+
}
|