@opengeni/jev 0.1.0-canary.36199476632001
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/LICENSE +190 -0
- package/README.md +140 -0
- package/dist/circuit-breaker.d.ts +58 -0
- package/dist/client.d.ts +173 -0
- package/dist/code-search/config.d.ts +142 -0
- package/dist/code-search/judge.d.ts +169 -0
- package/dist/code-search/leads.d.ts +96 -0
- package/dist/code-search/pack.d.ts +106 -0
- package/dist/code-search/recall.d.ts +155 -0
- package/dist/code-search/search.d.ts +93 -0
- package/dist/code-search/session.d.ts +33 -0
- package/dist/code-search/text.d.ts +35 -0
- package/dist/code-search/tool.d.ts +34 -0
- package/dist/code-search/windows.d.ts +85 -0
- package/dist/code-search/workspace.d.ts +51 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3110 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/circuit-breaker.ts +135 -0
- package/src/client.ts +577 -0
- package/src/code-search/config.ts +282 -0
- package/src/code-search/judge.ts +413 -0
- package/src/code-search/leads.ts +442 -0
- package/src/code-search/pack.ts +354 -0
- package/src/code-search/recall.ts +648 -0
- package/src/code-search/search.ts +773 -0
- package/src/code-search/session.ts +89 -0
- package/src/code-search/text.ts +159 -0
- package/src/code-search/tool.ts +209 -0
- package/src/code-search/windows.ts +617 -0
- package/src/code-search/workspace.ts +55 -0
- package/src/index.ts +71 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal client for TypeSafe Jev ("System One"): POST {baseUrl}/v1/systemone with
|
|
3
|
+
* {state, model, questions} and get one typed answer per question back.
|
|
4
|
+
*
|
|
5
|
+
* - Splits a large question map across several requests so each stays inside the documented
|
|
6
|
+
* limits (32k tokens for state + longest question, 64k for state + all questions).
|
|
7
|
+
* - Retries only network errors (including per-attempt timeouts), 429 and 5xx, with bounded
|
|
8
|
+
* backoff that honours `retry-after` up to `maxRetryAfterMs`. Other 4xx are never retried.
|
|
9
|
+
* - The caller's AbortSignal cancels queued and in-flight requests and rejects with its reason.
|
|
10
|
+
* - The API key is only ever sent in the Authorization header; it never appears in errors.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const JEV_DEFAULT_BASE_URL = "https://api.typesafe.ai";
|
|
14
|
+
export const JEV_DEFAULT_MODEL = "jev-latest";
|
|
15
|
+
/** USD per 1M input tokens; output tokens are free. */
|
|
16
|
+
export const JEV_PRICE_PER_MILLION_INPUT_TOKENS_USD = 0.042;
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Questions and answers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Instructions and criteria may be a string or structured JSON. */
|
|
23
|
+
export type JevInstructions = string | Record<string, unknown> | unknown[];
|
|
24
|
+
|
|
25
|
+
export interface JevNoulQuestion {
|
|
26
|
+
type: "noul";
|
|
27
|
+
instructions: JevInstructions;
|
|
28
|
+
criteria?: { true?: JevInstructions; false?: JevInstructions };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface JevChoiceQuestion {
|
|
32
|
+
type: "choice";
|
|
33
|
+
instructions: JevInstructions;
|
|
34
|
+
criteria: Record<string, JevInstructions | null>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface JevScoreQuestion {
|
|
38
|
+
type: "score";
|
|
39
|
+
instructions: JevInstructions;
|
|
40
|
+
criteria: JevInstructions[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type JevQuestion = JevNoulQuestion | JevChoiceQuestion | JevScoreQuestion;
|
|
44
|
+
|
|
45
|
+
export interface JevNoulAnswer {
|
|
46
|
+
type: "noul";
|
|
47
|
+
/** Probability of "yes" in [0, 1]; NaN when the API returned no usable number. */
|
|
48
|
+
probability: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface JevChoiceAnswer {
|
|
52
|
+
type: "choice";
|
|
53
|
+
option: string;
|
|
54
|
+
probabilities?: Record<string, number>;
|
|
55
|
+
confidence?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Passed through as the API returns it. */
|
|
59
|
+
export interface JevScoreAnswer {
|
|
60
|
+
type: "score";
|
|
61
|
+
score: number;
|
|
62
|
+
legend?: Record<string, string>;
|
|
63
|
+
probabilities?: Record<string, number>;
|
|
64
|
+
confidence?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type JevAnswer = JevNoulAnswer | JevChoiceAnswer | JevScoreAnswer;
|
|
68
|
+
|
|
69
|
+
export type JevAnswerFor<Q> = Q extends JevNoulQuestion
|
|
70
|
+
? JevNoulAnswer
|
|
71
|
+
: Q extends JevChoiceQuestion
|
|
72
|
+
? JevChoiceAnswer
|
|
73
|
+
: Q extends JevScoreQuestion
|
|
74
|
+
? JevScoreAnswer
|
|
75
|
+
: JevAnswer;
|
|
76
|
+
|
|
77
|
+
export interface JevAskResult<Q extends Record<string, JevQuestion>> {
|
|
78
|
+
answers: { [K in keyof Q]: JevAnswerFor<Q[K]> };
|
|
79
|
+
/** Versioned model id that answered (for example jev-1.13.0). */
|
|
80
|
+
model: string;
|
|
81
|
+
usage: { inputTokens: number };
|
|
82
|
+
/** HTTP requests (chunks) that made up this ask. */
|
|
83
|
+
requests: number;
|
|
84
|
+
costUsd: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface JevAskOptions {
|
|
88
|
+
/** Short label used in error messages (for example "code_search:wave1"). */
|
|
89
|
+
tag?: string | undefined;
|
|
90
|
+
signal?: AbortSignal | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function noul(
|
|
94
|
+
instructions: JevInstructions,
|
|
95
|
+
criteria?: { true?: JevInstructions; false?: JevInstructions },
|
|
96
|
+
): JevNoulQuestion {
|
|
97
|
+
return criteria ? { type: "noul", instructions, criteria } : { type: "noul", instructions };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function jevCostUsd(inputTokens: number): number {
|
|
101
|
+
return (inputTokens * JEV_PRICE_PER_MILLION_INPUT_TOKENS_USD) / 1_000_000;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Errors
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export class JevError extends Error {
|
|
109
|
+
/** HTTP status of the failed response, when there was one. */
|
|
110
|
+
readonly status: number | undefined;
|
|
111
|
+
/** Machine-readable reason when known (for example "max_tokens_exceeded" or "state_too_large"). */
|
|
112
|
+
readonly code: string | undefined;
|
|
113
|
+
|
|
114
|
+
constructor(message: string, options: { status?: number; code?: string; cause?: unknown } = {}) {
|
|
115
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
116
|
+
this.name = new.target.name;
|
|
117
|
+
this.status = options.status;
|
|
118
|
+
this.code = options.code;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Jev cannot be used right now: network, timeout, 5xx, 429 after retries, or 401/402/403 auth and billing. */
|
|
123
|
+
export class JevUnavailableError extends JevError {}
|
|
124
|
+
|
|
125
|
+
/** Jev rejected the request itself (other 4xx), or the request cannot fit the limits. Never retried. */
|
|
126
|
+
export class JevRequestError extends JevError {}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Limits and chunking
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
export interface JevLimits {
|
|
133
|
+
/** Documented: state + longest question. */
|
|
134
|
+
stateLongestMax: number;
|
|
135
|
+
/** Documented: state + all questions. */
|
|
136
|
+
stateAllMax: number;
|
|
137
|
+
/** Fraction of each limit used (token estimates are approximate). */
|
|
138
|
+
safety: number;
|
|
139
|
+
/** Measured fixed overhead per request (~265 tokens). */
|
|
140
|
+
perRequestOverhead: number;
|
|
141
|
+
/** Measured framing overhead per question (~7-10 tokens) on top of its own text. */
|
|
142
|
+
perQuestionOverhead: number;
|
|
143
|
+
/** No documented cap; keeps one request reasonable. */
|
|
144
|
+
maxQuestionsPerRequest: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const JEV_DEFAULT_LIMITS: JevLimits = {
|
|
148
|
+
stateLongestMax: 32_000,
|
|
149
|
+
stateAllMax: 64_000,
|
|
150
|
+
safety: 0.9,
|
|
151
|
+
perRequestOverhead: 300,
|
|
152
|
+
perQuestionOverhead: 12,
|
|
153
|
+
maxQuestionsPerRequest: 256,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Measured: TS code 3.2-3.5 chars/token, prose 4.4-4.7; 3.0 keeps the limit math conservative. */
|
|
157
|
+
export const JEV_CHARS_PER_TOKEN = 3.0;
|
|
158
|
+
|
|
159
|
+
export function estimateJevTokens(value: unknown, charsPerToken = JEV_CHARS_PER_TOKEN): number {
|
|
160
|
+
const s = typeof value === "string" ? value : JSON.stringify(value ?? "");
|
|
161
|
+
return Math.ceil(s.length / charsPerToken);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Greedy packing of question ids into requests that respect both limits.
|
|
166
|
+
* Throws JevRequestError (code state_too_large) when the state plus one question cannot fit.
|
|
167
|
+
*/
|
|
168
|
+
export function planChunks(
|
|
169
|
+
stateTokens: number,
|
|
170
|
+
questionTokens: ReadonlyArray<readonly [string, number]>,
|
|
171
|
+
limits: JevLimits = JEV_DEFAULT_LIMITS,
|
|
172
|
+
): string[][] {
|
|
173
|
+
const longestCap = limits.stateLongestMax * limits.safety;
|
|
174
|
+
const allCap = limits.stateAllMax * limits.safety;
|
|
175
|
+
const base = stateTokens + limits.perRequestOverhead;
|
|
176
|
+
const chunks: string[][] = [];
|
|
177
|
+
let cur: string[] = [];
|
|
178
|
+
let curSum = 0;
|
|
179
|
+
for (const [id, qt] of questionTokens) {
|
|
180
|
+
const cost = qt + limits.perQuestionOverhead;
|
|
181
|
+
if (base + cost > longestCap) {
|
|
182
|
+
throw new JevRequestError(
|
|
183
|
+
`Jev state (~${stateTokens} tokens) plus question "${id}" (~${qt} tokens) exceeds ~${Math.floor(longestCap)} tokens`,
|
|
184
|
+
{ code: "state_too_large" },
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
cur.length > 0 &&
|
|
189
|
+
(base + curSum + cost > allCap || cur.length >= limits.maxQuestionsPerRequest)
|
|
190
|
+
) {
|
|
191
|
+
chunks.push(cur);
|
|
192
|
+
cur = [];
|
|
193
|
+
curSum = 0;
|
|
194
|
+
}
|
|
195
|
+
cur.push(id);
|
|
196
|
+
curSum += cost;
|
|
197
|
+
}
|
|
198
|
+
if (cur.length) chunks.push(cur);
|
|
199
|
+
return chunks;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Concurrency limiter (abortable while queued)
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
export class JevLimiter {
|
|
207
|
+
private active = 0;
|
|
208
|
+
private readonly queue: Array<{ grant: () => void }> = [];
|
|
209
|
+
|
|
210
|
+
constructor(readonly max: number) {}
|
|
211
|
+
|
|
212
|
+
async run<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
213
|
+
await this.acquire(signal);
|
|
214
|
+
try {
|
|
215
|
+
return await fn();
|
|
216
|
+
} finally {
|
|
217
|
+
this.release();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private acquire(signal?: AbortSignal): Promise<void> {
|
|
222
|
+
signal?.throwIfAborted();
|
|
223
|
+
if (this.active < this.max) {
|
|
224
|
+
this.active += 1;
|
|
225
|
+
return Promise.resolve();
|
|
226
|
+
}
|
|
227
|
+
return new Promise<void>((resolve, reject) => {
|
|
228
|
+
const onAbort = () => {
|
|
229
|
+
const i = this.queue.indexOf(entry);
|
|
230
|
+
if (i >= 0) this.queue.splice(i, 1);
|
|
231
|
+
reject(signal?.reason);
|
|
232
|
+
};
|
|
233
|
+
const entry = {
|
|
234
|
+
grant: () => {
|
|
235
|
+
signal?.removeEventListener("abort", onAbort);
|
|
236
|
+
resolve();
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
this.queue.push(entry);
|
|
240
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A released slot passes straight to the next waiter, so `active` never overshoots `max`. */
|
|
245
|
+
private release(): void {
|
|
246
|
+
const next = this.queue.shift();
|
|
247
|
+
if (next) next.grant();
|
|
248
|
+
else this.active -= 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Client
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
export type JevFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
257
|
+
|
|
258
|
+
export interface JevClientOptions {
|
|
259
|
+
apiKey: string;
|
|
260
|
+
baseUrl?: string | undefined;
|
|
261
|
+
model?: string | undefined;
|
|
262
|
+
/** Per-attempt timeout. */
|
|
263
|
+
timeoutMs?: number | undefined;
|
|
264
|
+
/** Retries after the first attempt. */
|
|
265
|
+
maxRetries?: number | undefined;
|
|
266
|
+
/** Cap on a server `retry-after` hint; an interactive tool fails fast rather than sleeping 30 s. */
|
|
267
|
+
maxRetryAfterMs?: number | undefined;
|
|
268
|
+
/** Max concurrent HTTP requests from this client. */
|
|
269
|
+
concurrency?: number | undefined;
|
|
270
|
+
/** First backoff delay; doubles per attempt up to maxRetryAfterMs. */
|
|
271
|
+
retryBaseDelayMs?: number | undefined;
|
|
272
|
+
fetch?: JevFetch | undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const RETRYABLE_STATUS = (status: number) => status === 429 || (status >= 500 && status <= 599);
|
|
276
|
+
const AUTH_STATUS = (status: number) => status === 401 || status === 402 || status === 403;
|
|
277
|
+
/** Keep-alive connections are reused for a few seconds after a request; no warm-up is needed inside that window. */
|
|
278
|
+
const WARM_WINDOW_MS = 4_000;
|
|
279
|
+
const WARM_TIMEOUT_MS = 3_000;
|
|
280
|
+
const MAX_DETAIL_CHARS = 200;
|
|
281
|
+
|
|
282
|
+
export class JevClient {
|
|
283
|
+
readonly baseUrl: string;
|
|
284
|
+
readonly model: string;
|
|
285
|
+
private readonly apiKey: string;
|
|
286
|
+
private readonly timeoutMs: number;
|
|
287
|
+
private readonly maxRetries: number;
|
|
288
|
+
private readonly maxRetryAfterMs: number;
|
|
289
|
+
private readonly retryBaseDelayMs: number;
|
|
290
|
+
private readonly fetchImpl: JevFetch;
|
|
291
|
+
private readonly limiter: JevLimiter;
|
|
292
|
+
private lastActivityAt = -Infinity;
|
|
293
|
+
|
|
294
|
+
constructor(options: JevClientOptions) {
|
|
295
|
+
if (!options.apiKey) throw new JevUnavailableError("Jev API key is not configured");
|
|
296
|
+
this.apiKey = options.apiKey;
|
|
297
|
+
this.baseUrl = (options.baseUrl ?? JEV_DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
298
|
+
this.model = options.model ?? JEV_DEFAULT_MODEL;
|
|
299
|
+
this.timeoutMs = options.timeoutMs ?? 10_000;
|
|
300
|
+
this.maxRetries = Math.max(0, options.maxRetries ?? 2);
|
|
301
|
+
this.maxRetryAfterMs = Math.max(0, options.maxRetryAfterMs ?? 2_000);
|
|
302
|
+
this.retryBaseDelayMs = Math.max(0, options.retryBaseDelayMs ?? 250);
|
|
303
|
+
this.limiter = new JevLimiter(Math.max(1, options.concurrency ?? 16));
|
|
304
|
+
this.fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Ask many questions about one state. Splits the questions into several requests when they exceed the
|
|
309
|
+
* limits, runs them concurrently (bounded by `concurrency`) and merges the answers.
|
|
310
|
+
*/
|
|
311
|
+
async ask<Q extends Record<string, JevQuestion>>(
|
|
312
|
+
state: unknown,
|
|
313
|
+
questions: Q,
|
|
314
|
+
options: JevAskOptions = {},
|
|
315
|
+
): Promise<JevAskResult<Q>> {
|
|
316
|
+
const { signal } = options;
|
|
317
|
+
signal?.throwIfAborted();
|
|
318
|
+
const ids = Object.keys(questions);
|
|
319
|
+
if (!ids.length) throw new JevRequestError("Jev ask() needs at least one question");
|
|
320
|
+
const stateTokens = estimateJevTokens(state);
|
|
321
|
+
const chunks = planChunks(
|
|
322
|
+
stateTokens,
|
|
323
|
+
ids.map((id) => [id, estimateJevTokens(questions[id])] as const),
|
|
324
|
+
);
|
|
325
|
+
const label = options.tag ? `${options.tag}: ` : "";
|
|
326
|
+
const results = await Promise.all(
|
|
327
|
+
chunks.map((chunk) =>
|
|
328
|
+
this.limiter.run(
|
|
329
|
+
() =>
|
|
330
|
+
this.post(
|
|
331
|
+
JSON.stringify({
|
|
332
|
+
state,
|
|
333
|
+
model: this.model,
|
|
334
|
+
questions: Object.fromEntries(chunk.map((id) => [id, questions[id]])),
|
|
335
|
+
}),
|
|
336
|
+
label,
|
|
337
|
+
signal,
|
|
338
|
+
),
|
|
339
|
+
signal,
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
const answers: Record<string, JevAnswer> = {};
|
|
344
|
+
let inputTokens = 0;
|
|
345
|
+
let model = "";
|
|
346
|
+
results.forEach((json, i) => {
|
|
347
|
+
const got = isRecord(json.answers) ? json.answers : {};
|
|
348
|
+
for (const id of chunks[i]!) {
|
|
349
|
+
const answer = normalizeAnswer(got[id]);
|
|
350
|
+
if (!answer)
|
|
351
|
+
throw new JevUnavailableError(`${label}Jev response is missing the answer "${id}"`);
|
|
352
|
+
answers[id] = answer;
|
|
353
|
+
}
|
|
354
|
+
const usage = isRecord(json.usage) ? json.usage : {};
|
|
355
|
+
inputTokens += Number(usage.input_tokens ?? 0) || 0;
|
|
356
|
+
if (typeof json.model === "string" && json.model) model = json.model;
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
answers: answers as JevAskResult<Q>["answers"],
|
|
360
|
+
model,
|
|
361
|
+
usage: { inputTokens },
|
|
362
|
+
requests: results.length,
|
|
363
|
+
costUsd: jevCostUsd(inputTokens),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Open keep-alive connections before a burst of parallel requests (a cold TLS connection costs ~0.7 s).
|
|
369
|
+
* GET /healthz is free. Skipped when the client was active within the last few seconds; never throws.
|
|
370
|
+
*/
|
|
371
|
+
warmUp(connections: number): void {
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
if (connections <= 0 || now - this.lastActivityAt < WARM_WINDOW_MS) return;
|
|
374
|
+
this.lastActivityAt = now;
|
|
375
|
+
for (let i = 0; i < connections; i++) {
|
|
376
|
+
const timeout = AbortSignal.timeout(WARM_TIMEOUT_MS);
|
|
377
|
+
this.fetchImpl(`${this.baseUrl}/healthz`, { method: "GET", signal: timeout })
|
|
378
|
+
.then((res) => res.arrayBuffer())
|
|
379
|
+
.catch(() => undefined);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** One HTTP request with retries. Returns the parsed JSON body of a 2xx response. */
|
|
384
|
+
private async post(
|
|
385
|
+
body: string,
|
|
386
|
+
label: string,
|
|
387
|
+
signal: AbortSignal | undefined,
|
|
388
|
+
): Promise<Record<string, unknown>> {
|
|
389
|
+
const url = `${this.baseUrl}/v1/systemone`;
|
|
390
|
+
const headers = { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` };
|
|
391
|
+
let lastError = "unknown error";
|
|
392
|
+
let lastStatus: number | undefined;
|
|
393
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
394
|
+
signal?.throwIfAborted();
|
|
395
|
+
const attemptSignal = withTimeout(signal, this.timeoutMs);
|
|
396
|
+
let delay = this.backoff(attempt);
|
|
397
|
+
let response: { res: Response; text: string } | null = null;
|
|
398
|
+
try {
|
|
399
|
+
const res = await this.fetchImpl(url, {
|
|
400
|
+
method: "POST",
|
|
401
|
+
headers,
|
|
402
|
+
body,
|
|
403
|
+
signal: attemptSignal.signal,
|
|
404
|
+
});
|
|
405
|
+
response = { res, text: await res.text() };
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (signal?.aborted) throw signal.reason;
|
|
408
|
+
lastStatus = undefined;
|
|
409
|
+
lastError = attemptSignal.timedOut()
|
|
410
|
+
? `timed out after ${this.timeoutMs} ms`
|
|
411
|
+
: describeError(error);
|
|
412
|
+
} finally {
|
|
413
|
+
attemptSignal.dispose();
|
|
414
|
+
this.lastActivityAt = Date.now();
|
|
415
|
+
}
|
|
416
|
+
if (response) {
|
|
417
|
+
const { res, text } = response;
|
|
418
|
+
if (res.ok) {
|
|
419
|
+
const json = parseJson(text);
|
|
420
|
+
if (!isRecord(json))
|
|
421
|
+
throw new JevUnavailableError(`${label}Jev returned an invalid response body`);
|
|
422
|
+
return json;
|
|
423
|
+
}
|
|
424
|
+
const detail = errorDetail(text);
|
|
425
|
+
const suffix = detail.message ? `: ${detail.message}` : "";
|
|
426
|
+
if (AUTH_STATUS(res.status)) {
|
|
427
|
+
const what = res.status === 402 ? "billing or credits" : "authentication";
|
|
428
|
+
throw new JevUnavailableError(
|
|
429
|
+
`${label}Jev ${what} failed (HTTP ${res.status}${suffix})`,
|
|
430
|
+
errorOptions(res.status, detail.code),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (!RETRYABLE_STATUS(res.status)) {
|
|
434
|
+
throw new JevRequestError(
|
|
435
|
+
`${label}Jev rejected the request (HTTP ${res.status}${suffix})`,
|
|
436
|
+
errorOptions(res.status, detail.code),
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
lastStatus = res.status;
|
|
440
|
+
lastError = `HTTP ${res.status}${suffix}`;
|
|
441
|
+
const retryAfter = parseRetryAfter(res.headers.get("retry-after"));
|
|
442
|
+
if (retryAfter !== null) delay = Math.min(retryAfter, this.maxRetryAfterMs);
|
|
443
|
+
}
|
|
444
|
+
if (attempt < this.maxRetries) await sleep(delay, signal);
|
|
445
|
+
}
|
|
446
|
+
throw new JevUnavailableError(
|
|
447
|
+
`${label}Jev unavailable after ${this.maxRetries + 1} attempts (${lastError})`,
|
|
448
|
+
lastStatus === undefined ? {} : { status: lastStatus },
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private backoff(attempt: number): number {
|
|
453
|
+
const base = Math.min(this.maxRetryAfterMs, this.retryBaseDelayMs * 2 ** attempt);
|
|
454
|
+
return Math.round(base * (1 - Math.random() * 0.25));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
// Helpers
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
463
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function parseJson(text: string): unknown {
|
|
467
|
+
try {
|
|
468
|
+
return text ? JSON.parse(text) : null;
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function normalizeAnswer(raw: unknown): JevAnswer | null {
|
|
475
|
+
if (!isRecord(raw)) return null;
|
|
476
|
+
if (raw.type === "noul") {
|
|
477
|
+
return { type: "noul", probability: typeof raw.noul === "number" ? raw.noul : Number.NaN };
|
|
478
|
+
}
|
|
479
|
+
if (raw.type === "choice") {
|
|
480
|
+
const answer: JevChoiceAnswer = {
|
|
481
|
+
type: "choice",
|
|
482
|
+
option: String(raw.choice ?? raw.option ?? ""),
|
|
483
|
+
};
|
|
484
|
+
if (isRecord(raw.probabilities))
|
|
485
|
+
answer.probabilities = raw.probabilities as Record<string, number>;
|
|
486
|
+
if (typeof raw.confidence === "number") answer.confidence = raw.confidence;
|
|
487
|
+
return answer;
|
|
488
|
+
}
|
|
489
|
+
if (raw.type === "score")
|
|
490
|
+
return { ...raw, type: "score", score: Number(raw.score) } as JevScoreAnswer;
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function errorOptions(status: number, code: string | undefined): { status: number; code?: string } {
|
|
495
|
+
return code === undefined ? { status } : { status, code };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Best-effort code and message from an error body; bounded so a large body never floods a message. */
|
|
499
|
+
function errorDetail(text: string): { code: string | undefined; message: string } {
|
|
500
|
+
const json = parseJson(text);
|
|
501
|
+
const codes: string[] = [];
|
|
502
|
+
let message = "";
|
|
503
|
+
const visit = (value: unknown) => {
|
|
504
|
+
if (typeof value === "string") {
|
|
505
|
+
if (!message) message = value;
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (Array.isArray(value)) {
|
|
509
|
+
if (!message) message = JSON.stringify(value);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (!isRecord(value)) return;
|
|
513
|
+
for (const key of ["code", "type"]) {
|
|
514
|
+
const v = value[key];
|
|
515
|
+
if (typeof v === "string" && v !== "error") codes.push(v);
|
|
516
|
+
}
|
|
517
|
+
for (const key of ["detail", "error", "message", "msg"]) if (key in value) visit(value[key]);
|
|
518
|
+
};
|
|
519
|
+
if (json === null) message = text.trim();
|
|
520
|
+
else visit(json);
|
|
521
|
+
let code = codes[0];
|
|
522
|
+
if (!code && /^[a-z][a-z0-9_]{2,60}$/.test(message)) code = message;
|
|
523
|
+
return { code, message: message.replace(/\s+/g, " ").slice(0, MAX_DETAIL_CHARS) };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function parseRetryAfter(header: string | null): number | null {
|
|
527
|
+
if (!header) return null;
|
|
528
|
+
const secs = Number(header);
|
|
529
|
+
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
|
|
530
|
+
const at = Date.parse(header);
|
|
531
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function describeError(error: unknown): string {
|
|
535
|
+
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
536
|
+
return text.slice(0, MAX_DETAIL_CHARS);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** A per-attempt signal that fires on the caller's abort or after timeoutMs (no AbortSignal.any: Node 18). */
|
|
540
|
+
function withTimeout(
|
|
541
|
+
parent: AbortSignal | undefined,
|
|
542
|
+
timeoutMs: number,
|
|
543
|
+
): { signal: AbortSignal; timedOut: () => boolean; dispose: () => void } {
|
|
544
|
+
const controller = new AbortController();
|
|
545
|
+
let timedOut = false;
|
|
546
|
+
const onAbort = () => controller.abort(parent?.reason);
|
|
547
|
+
const timer = setTimeout(() => {
|
|
548
|
+
timedOut = true;
|
|
549
|
+
controller.abort(new JevUnavailableError(`Jev request timed out after ${timeoutMs} ms`));
|
|
550
|
+
}, timeoutMs);
|
|
551
|
+
if (parent?.aborted) controller.abort(parent.reason);
|
|
552
|
+
else parent?.addEventListener("abort", onAbort, { once: true });
|
|
553
|
+
return {
|
|
554
|
+
signal: controller.signal,
|
|
555
|
+
timedOut: () => timedOut,
|
|
556
|
+
dispose: () => {
|
|
557
|
+
clearTimeout(timer);
|
|
558
|
+
parent?.removeEventListener("abort", onAbort);
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
564
|
+
if (ms <= 0) return Promise.resolve();
|
|
565
|
+
return new Promise<void>((resolve, reject) => {
|
|
566
|
+
const onAbort = () => {
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
reject(signal?.reason);
|
|
569
|
+
};
|
|
570
|
+
const timer = setTimeout(() => {
|
|
571
|
+
signal?.removeEventListener("abort", onAbort);
|
|
572
|
+
resolve();
|
|
573
|
+
}, ms);
|
|
574
|
+
if (signal?.aborted) onAbort();
|
|
575
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
576
|
+
});
|
|
577
|
+
}
|