@almadar/llm 2.51.0 → 2.53.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 +4 -4
- package/dist/index.js +26 -8
- 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/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/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
|
+
}
|
package/src/token-tracker.ts
CHANGED
|
@@ -169,7 +169,15 @@ function refreshPricingCache(): void {
|
|
|
169
169
|
});
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Look up a model's pricing row without a zero-cost fallback — `undefined`
|
|
174
|
+
* means "no pricing known yet" (OpenRouter fetch pending, or the model
|
|
175
|
+
* isn't listed), distinct from a model that is genuinely free. Single
|
|
176
|
+
* lookup path: `getCostForModel` (zero-fallback, for the tracker's own
|
|
177
|
+
* running totals) and `estimateCostUSD` (pure, callers outside the
|
|
178
|
+
* tracker) both resolve through this.
|
|
179
|
+
*/
|
|
180
|
+
function getCostForModelIfKnown(model: string): TokenCost | undefined {
|
|
173
181
|
const pricing = getPricing();
|
|
174
182
|
// Try direct match on OpenRouter ID
|
|
175
183
|
const orId = MODEL_ID_MAP[model];
|
|
@@ -180,8 +188,64 @@ function getCostForModel(model: string): TokenCost {
|
|
|
180
188
|
for (const [key, cost] of Object.entries(pricing)) {
|
|
181
189
|
if (key.includes(model) || model.includes(key.split('/')[1] ?? '')) return cost;
|
|
182
190
|
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getCostForModel(model: string): TokenCost {
|
|
183
195
|
// No pricing available — return zero (OpenRouter fetch pending or model not listed)
|
|
184
|
-
return { promptCostPer1K: 0, completionCostPer1K: 0 };
|
|
196
|
+
return getCostForModelIfKnown(model) ?? { promptCostPer1K: 0, completionCostPer1K: 0 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Cache-aware cost formula shared by `TokenTracker.costFor` (instance,
|
|
200
|
+
* zero-fallback pricing) and `estimateCostUSD` (pure, `undefined` pricing
|
|
201
|
+
* propagates to the caller instead of silently pricing at $0). */
|
|
202
|
+
function priceTokens(
|
|
203
|
+
costs: TokenCost,
|
|
204
|
+
promptTokens: number,
|
|
205
|
+
completionTokens: number,
|
|
206
|
+
cached: number,
|
|
207
|
+
written: number,
|
|
208
|
+
): number {
|
|
209
|
+
const cacheReadRate = costs.cacheReadCostPer1K ?? costs.promptCostPer1K;
|
|
210
|
+
const cacheWriteRate = costs.cacheWriteCostPer1K ?? costs.promptCostPer1K;
|
|
211
|
+
const uncached = Math.max(0, promptTokens - cached - written);
|
|
212
|
+
return (
|
|
213
|
+
(uncached / 1000) * costs.promptCostPer1K +
|
|
214
|
+
(cached / 1000) * cacheReadRate +
|
|
215
|
+
(written / 1000) * cacheWriteRate +
|
|
216
|
+
(completionTokens / 1000) * costs.completionCostPer1K
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface EstimateCostTokens {
|
|
221
|
+
promptTokens: number;
|
|
222
|
+
completionTokens: number;
|
|
223
|
+
/** Subset of `promptTokens` served from the provider's prefix cache. */
|
|
224
|
+
cachedPromptTokens?: number;
|
|
225
|
+
/** Subset of `promptTokens` written to cache (Anthropic cache-write). */
|
|
226
|
+
cacheWriteTokens?: number;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Pure per-call cost estimate from token counts alone, priced from the SAME
|
|
231
|
+
* OpenRouter-fetched table `TokenTracker` uses for the whole-run total (the
|
|
232
|
+
* 24h disk cache in `getPricing` — no second pricing source). Callers with
|
|
233
|
+
* tokens + a model name but no `TokenTracker` instance (e.g. a trace-event
|
|
234
|
+
* emitter) use this instead of reimplementing the cache-aware math.
|
|
235
|
+
*
|
|
236
|
+
* Returns `undefined` when the model has no known pricing row — the
|
|
237
|
+
* caller's job to render that as "unknown", never as `$0`.
|
|
238
|
+
*/
|
|
239
|
+
export function estimateCostUSD(model: string, tokens: EstimateCostTokens): number | undefined {
|
|
240
|
+
const costs = getCostForModelIfKnown(model);
|
|
241
|
+
if (costs === undefined) return undefined;
|
|
242
|
+
return priceTokens(
|
|
243
|
+
costs,
|
|
244
|
+
tokens.promptTokens,
|
|
245
|
+
tokens.completionTokens,
|
|
246
|
+
Math.max(0, tokens.cachedPromptTokens ?? 0),
|
|
247
|
+
Math.max(0, tokens.cacheWriteTokens ?? 0),
|
|
248
|
+
);
|
|
185
249
|
}
|
|
186
250
|
|
|
187
251
|
// ---------------------------------------------------------------------------
|
|
@@ -220,16 +284,7 @@ export class TokenTracker {
|
|
|
220
284
|
|
|
221
285
|
/** Cache-aware cost for one (or an aggregate of) call(s), in USD. */
|
|
222
286
|
private costFor(model: string, promptTokens: number, completionTokens: number, cached: number, written: number): number {
|
|
223
|
-
|
|
224
|
-
const cacheReadRate = costs.cacheReadCostPer1K ?? costs.promptCostPer1K;
|
|
225
|
-
const cacheWriteRate = costs.cacheWriteCostPer1K ?? costs.promptCostPer1K;
|
|
226
|
-
const uncached = Math.max(0, promptTokens - cached - written);
|
|
227
|
-
return (
|
|
228
|
-
(uncached / 1000) * costs.promptCostPer1K +
|
|
229
|
-
(cached / 1000) * cacheReadRate +
|
|
230
|
-
(written / 1000) * cacheWriteRate +
|
|
231
|
-
(completionTokens / 1000) * costs.completionCostPer1K
|
|
232
|
-
);
|
|
287
|
+
return priceTokens(getCostForModel(model), promptTokens, completionTokens, cached, written);
|
|
233
288
|
}
|
|
234
289
|
|
|
235
290
|
/**
|