benjivers/jev-mcp Public Public: anyone can read this; nobody can change it.

workbenchthis machine
Files f51ad86af1fe5f12e98b8ee51d0ee4616ebc14d9
392 lines · 15.9 KB · ~4.6k tok Raw History
1
/**
2
 * Pure helpers for jev-mcp. No network, no process state, no I/O.
3
 *
4
 * Everything here is deterministic so the edge cases that matter — a caller
5
 * whose own option is named "none", duplicate question ids, a malformed
6
 * timeout — are unit-testable without touching the API.
7
 */
8
9
import {
10
  APIConnectionError,
11
  APIError,
12
  APITimeoutError,
13
  APIUserAbortError,
14
  AuthenticationError,
15
  BadRequestError,
16
  NotFoundError,
17
  PermissionDeniedError,
18
  RateLimitError,
19
  UnprocessableEntityError,
20
} from "@typesafe-ai/sdk";
21
import type { EntryType } from "@typesafe-ai/sdk";
22
23
// ── Limits ──────────────────────────────────────────────────────────────────
24
25
/**
26
 * A Choice question accepts up to 255 options. This is a documented API limit,
27
 * not a local guess: docs.typesafe.ai/cookbooks/semantic_find.
28
 */
29
export const MAX_CHOICE_OPTIONS = 255;
30
31
/**
32
 * Local safety caps, not API limits. They bound cost and latency for a single
33
 * call so a malformed loop cannot run up a bill. All three are configurable.
34
 */
35
export const DEFAULT_MAX_SCORE_LEVELS = 255;
36
export const DEFAULT_MAX_QUESTIONS = 64;
37
export const DEFAULT_MAX_STATE_CHARS = 200_000;
38
export const DEFAULT_TIMEOUT_MS = 15_000;
39
40
// ── Environment parsing ─────────────────────────────────────────────────────
41
42
export interface ParsedNumber {
43
  value: number;
44
  /** Set when the raw value was present but unusable, so the caller can warn. */
45
  warning?: string;
46
}
47
48
/**
49
 * Read a positive integer from an environment value.
50
 *
51
 * `Number("abc")` is `NaN`, and passing `NaN` as a timeout silently disables
52
 * it. Anything not a finite positive number falls back and reports why.
53
 */
54
export function readPositiveInt(raw: string | undefined, fallback: number, label: string): ParsedNumber {
55
  if (raw === undefined || raw.trim() === "") return { value: fallback };
56
  const parsed = Number(raw);
57
  if (!Number.isFinite(parsed) || parsed <= 0) {
58
    return { value: fallback, warning: `${label} must be a positive number; got ${JSON.stringify(raw)}. Using ${fallback}.` };
59
  }
60
  return { value: Math.floor(parsed) };
61
}
62
63
// ── Choice option handling ──────────────────────────────────────────────────
64
65
/**
66
 * Pick a key for the "none of these" escape hatch that does not collide with a
67
 * caller's own option.
68
 *
69
 * Blindly writing `criteria.none` overwrites an option the caller named "none",
70
 * destroying its meaning and its probability. That is silent and unrecoverable,
71
 * so we step aside instead.
72
 */
73
export function resolveNoneKey(existing: readonly string[]): string {
74
  const taken = new Set(existing);
75
  if (!taken.has("none")) return "none";
76
  if (!taken.has("none_of_these")) return "none_of_these";
77
  let n = 2;
78
  while (taken.has(`none_of_these_${n}`)) n += 1;
79
  return `none_of_these_${n}`;
80
}
81
82
export interface BuiltCriteria {
83
  criteria: Record<string, EntryType>;
84
  /** The key that carries the "nothing fits" meaning, or null when not added. */
85
  noneKey: string | null;
86
}
87
88
/**
89
 * Build Choice criteria from caller options, optionally adding a no-match
90
 * outcome under a key guaranteed not to clash.
91
 *
92
 * @throws when the option set is empty or exceeds the API's 255-option limit.
93
 */
94
export function buildChoiceCriteria(
95
  options: Record<string, EntryType>,
96
  addNone: boolean,
97
  label = "options",
98
): BuiltCriteria {
99
  const keys = Object.keys(options);
100
  if (keys.length === 0) {
101
    throw new Error(`${label} must contain at least one option. Jev selects among options you supply; it cannot invent one.`);
102
  }
103
104
  const criteria: Record<string, EntryType> = { ...options };
105
  let noneKey: string | null = null;
106
  if (addNone) {
107
    noneKey = resolveNoneKey(keys);
108
    criteria[noneKey] = "None of the other options fits.";
109
  }
110
111
  const total = Object.keys(criteria).length;
112
  if (total > MAX_CHOICE_OPTIONS) {
113
    throw new Error(
114
      `A Choice question accepts at most ${MAX_CHOICE_OPTIONS} options; received ${total}. ` +
115
        "For a larger set, search in two passes: one question picks a window, a second ranks within it.",
116
    );
117
  }
118
  if (total < 2) {
119
    throw new Error(`${label} must leave at least two outcomes. Supply another option, or leave the no-match outcome enabled.`);
120
  }
121
  return { criteria, noneKey };
122
}
123
124
/** Reject duplicate question ids rather than letting a later one shadow an earlier one. */
125
export function assertUniqueIds(ids: readonly string[]): void {
126
  const seen = new Set<string>();
127
  const duplicates = new Set<string>();
128
  for (const id of ids) {
129
    if (seen.has(id)) duplicates.add(id);
130
    seen.add(id);
131
  }
132
  if (duplicates.size > 0) {
133
    throw new Error(
134
      `Duplicate question ids: ${[...duplicates].map((d) => JSON.stringify(d)).join(", ")}. ` +
135
        "Answers are keyed by id, so a repeat would silently drop every earlier question sharing it.",
136
    );
137
  }
138
}
139
140
// ── Size guards ─────────────────────────────────────────────────────────────
141
142
/** Serialized size of the state, used to bound cost before a request goes out. */
143
export function stateSize(state: unknown): number {
144
  return typeof state === "string" ? state.length : JSON.stringify(state ?? null).length;
145
}
146
147
/**
148
 * Reject oversized state instead of truncating it.
149
 *
150
 * Truncation silently changes the material the judgment rests on, which turns a
151
 * size problem into a wrong answer. Failing loudly keeps the caller in control.
152
 */
153
export function assertStateWithinLimit(state: unknown, maxChars: number): void {
154
  const size = stateSize(state);
155
  if (size > maxChars) {
156
    throw new Error(
157
      `State is ${size} characters, above the ${maxChars} limit. ` +
158
        "Shorten it or select the relevant part first; raise JEV_MAX_STATE_CHARS if the limit is wrong for your workload.",
159
    );
160
  }
161
}
162
163
// ── Confidence gating ───────────────────────────────────────────────────────
164
165
export type Gate = "act" | "review" | "abstain";
166
167
/**
168
 * Turn a Choice/Score confidence into a recommended action.
169
 *
170
 * Thresholds are starting points to calibrate on your own data, never universal
171
 * rules. Confidence describes how concentrated the distribution is; it is not a
172
 * statement that the answer is correct or that acting is safe.
173
 */
174
export function gateConfidence(confidence: number | null | undefined, actAbove: number, reviewAbove: number): Gate {
175
  if (typeof confidence !== "number" || Number.isNaN(confidence)) return "abstain";
176
  if (confidence >= actAbove) return "act";
177
  if (confidence >= reviewAbove) return "review";
178
  return "abstain";
179
}
180
181
export type Verdict = "yes" | "no" | "uncertain";
182
183
/**
184
 * Turn a Noul probability into a verdict.
185
 *
186
 * A Noul has no confidence value. A probability near 0.5 means yes and no are
187
 * close to equally likely, not that the answer is "medium", so the middle band
188
 * is reported as uncertain rather than rounded to the nearer side.
189
 */
190
export function gateProbability(probability: number, yesAtOrAbove: number, noAtOrBelow: number): Verdict {
191
  if (probability >= yesAtOrAbove) return "yes";
192
  if (probability <= noAtOrBelow) return "no";
193
  return "uncertain";
194
}
195
196
// ── Error reporting ─────────────────────────────────────────────────────────
197
198
export type ErrorKind =
199
  | "no_api_key"
200
  | "authentication"
201
  | "permission_denied"
202
  | "invalid_request"
203
  | "not_found"
204
  | "rate_limit"
205
  | "server_error"
206
  | "timeout"
207
  | "connection"
208
  | "cancelled"
209
  | "invalid_arguments"
210
  | "malformed_response"
211
  | "unknown";
212
213
export interface DescribedError {
214
  kind: ErrorKind;
215
  message: string;
216
  /** True when the same request may succeed later. The SDK already retried. */
217
  retryable: boolean;
218
  status?: number;
219
  /** TypeSafe request id, the thing support needs to trace a failure. */
220
  requestId?: string;
221
  /** What the caller should actually do next. */
222
  hint?: string;
223
}
224
225
/**
226
 * Classify a failure so a caller can branch on it.
227
 *
228
 * Flattening every failure to a string makes a missing key, a malformed
229
 * question, and a rate limit indistinguishable, so callers retry things that
230
 * will never succeed and give up on things that would.
231
 */
232
export function describeError(error: unknown): DescribedError {
233
  const message = error instanceof Error ? error.message : String(error);
234
235
  if (error instanceof MalformedResponseError) {
236
    return {
237
      kind: "malformed_response",
238
      message,
239
      retryable: true,
240
      hint: "The API returned an answer that does not match the question sent. Nothing here should be acted on. Retry once; if it persists, report it with the model id.",
241
    };
242
  }
243
  if (error instanceof APIUserAbortError) {
244
    return { kind: "cancelled", message, retryable: false, hint: "The client cancelled the request." };
245
  }
246
  if (error instanceof APITimeoutError) {
247
    return {
248
      kind: "timeout",
249
      message,
250
      retryable: true,
251
      hint: "Shorten the state or raise JEV_TIMEOUT_MS.",
252
    };
253
  }
254
  if (error instanceof APIConnectionError) {
255
    return { kind: "connection", message, retryable: true, hint: "Check network access to api.typesafe.ai." };
256
  }
257
258
  if (error instanceof APIError) {
259
    const base = { message, status: error.status, requestId: error.requestId };
260
    if (error instanceof AuthenticationError) {
261
      return { ...base, kind: "authentication", retryable: false, hint: "The API key was rejected. Check TYPESAFE_API_KEY in the server's environment." };
262
    }
263
    if (error instanceof PermissionDeniedError) {
264
      return { ...base, kind: "permission_denied", retryable: false, hint: "The key is valid but lacks access to this model or account." };
265
    }
266
    if (error instanceof RateLimitError) {
267
      return { ...base, kind: "rate_limit", retryable: true, hint: "Rate limited after the SDK's own retries. Back off before trying again." };
268
    }
269
    if (error instanceof UnprocessableEntityError || error instanceof BadRequestError) {
270
      return { ...base, kind: "invalid_request", retryable: false, hint: "The request failed validation. The message names the offending field." };
271
    }
272
    if (error instanceof NotFoundError) {
273
      return { ...base, kind: "not_found", retryable: false, hint: "Check the model id in JEV_MODEL." };
274
    }
275
    return { ...base, kind: "server_error", retryable: error.status >= 500, hint: "TypeSafe returned an error. Retry if it persists." };
276
  }
277
278
  if (/^No API key/i.test(message)) {
279
    return {
280
      kind: "no_api_key",
281
      message,
282
      retryable: false,
283
      hint: "Set TYPESAFE_API_KEY in the environment of the MCP client, not as a tool argument. Many clients filter the environment, so pass it explicitly when registering the server.",
284
    };
285
  }
286
287
  return { kind: "invalid_arguments", message, retryable: false };
288
}
289
290
// ── Answer validation ───────────────────────────────────────────────────────
291
292
/**
293
 * Thrown when the API returns an answer that does not match the question that
294
 * was sent. Nothing downstream should act on such an answer.
295
 */
296
export class MalformedResponseError extends Error {
297
  constructor(message: string) {
298
    super(message);
299
    this.name = "MalformedResponseError";
300
  }
301
}
302
303
/** Probabilities may drift a little in serialization; more than this is a bug. */
304
export const PROBABILITY_SUM_TOLERANCE = 0.02;
305
306
function isUnitInterval(value: unknown): value is number {
307
  return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
308
}
309
310
function sameKeySet(actual: readonly string[], expected: readonly string[]): boolean {
311
  if (actual.length !== expected.length) return false;
312
  const set = new Set(expected);
313
  return actual.every((key) => set.has(key));
314
}
315
316
/** A finite distribution over exactly the expected keys that sums to about one. */
317
function checkDistribution(probabilities: unknown, expectedKeys: readonly string[], label: string): Record<string, number> {
318
  if (typeof probabilities !== "object" || probabilities === null || Array.isArray(probabilities)) {
319
    throw new MalformedResponseError(`Answer '${label}' has no probabilities object.`);
320
  }
321
  const dist = probabilities as Record<string, unknown>;
322
  if (!sameKeySet(Object.keys(dist), expectedKeys)) {
323
    throw new MalformedResponseError(
324
      `Answer '${label}' has probabilities for ${JSON.stringify(Object.keys(dist))}, but the question offered ${JSON.stringify(expectedKeys)}.`,
325
    );
326
  }
327
  let sum = 0;
328
  for (const [key, value] of Object.entries(dist)) {
329
    if (!isUnitInterval(value)) throw new MalformedResponseError(`Answer '${label}' has a probability outside [0, 1] for '${key}'.`);
330
    sum += value;
331
  }
332
  if (Math.abs(sum - 1) > PROBABILITY_SUM_TOLERANCE) {
333
    throw new MalformedResponseError(`Answer '${label}' has probabilities summing to ${sum.toFixed(3)}, not 1.`);
334
  }
335
  return dist as Record<string, number>;
336
}
337
338
function asRecord(answer: unknown, label: string): Record<string, unknown> {
339
  if (typeof answer !== "object" || answer === null || Array.isArray(answer)) {
340
    throw new MalformedResponseError(`No answer came back for '${label}'.`);
341
  }
342
  return answer as Record<string, unknown>;
343
}
344
345
/**
346
 * Check a Choice answer against the options that were sent.
347
 *
348
 * The selected option must be one that was offered, the distribution must
349
 * cover exactly those options, and the selection must carry the highest
350
 * probability. Any of these failing means the answer cannot be trusted, and a
351
 * caller acting on `choice` alone would execute something never offered.
352
 */
353
export function validateChoiceAnswer(answer: unknown, expectedKeys: readonly string[], label: string): void {
354
  const a = asRecord(answer, label);
355
  if (typeof a.choice !== "string" || !expectedKeys.includes(a.choice)) {
356
    throw new MalformedResponseError(`Answer '${label}' chose ${JSON.stringify(a.choice)}, which was not among the offered options.`);
357
  }
358
  if (!isUnitInterval(a.confidence)) throw new MalformedResponseError(`Answer '${label}' has no confidence in [0, 1].`);
359
  const dist = checkDistribution(a.probabilities, expectedKeys, label);
360
  const top = Math.max(...Object.values(dist));
361
  if ((dist[a.choice] ?? -1) < top - 1e-6) {
362
    throw new MalformedResponseError(`Answer '${label}' chose '${a.choice}' but a different option carries the highest probability.`);
363
  }
364
}
365
366
/**
367
 * Check a Score answer against the number of levels that were sent.
368
 *
369
 * The legend and the distribution must both describe exactly the levels
370
 * offered, and the score itself must be a finite number.
371
 */
372
export function validateScoreAnswer(answer: unknown, levelCount: number, label: string): void {
373
  const a = asRecord(answer, label);
374
  if (typeof a.score !== "number" || !Number.isFinite(a.score)) {
375
    throw new MalformedResponseError(`Answer '${label}' has no finite score.`);
376
  }
377
  if (!isUnitInterval(a.confidence)) throw new MalformedResponseError(`Answer '${label}' has no confidence in [0, 1].`);
378
  if (typeof a.legend !== "object" || a.legend === null || Array.isArray(a.legend)) {
379
    throw new MalformedResponseError(`Answer '${label}' has no legend.`);
380
  }
381
  const legendKeys = Object.keys(a.legend as Record<string, unknown>);
382
  if (legendKeys.length !== levelCount) {
383
    throw new MalformedResponseError(`Answer '${label}' has a legend of ${legendKeys.length} levels; the question sent ${levelCount}.`);
384
  }
385
  checkDistribution(a.probabilities, legendKeys, label);
386
}
387
388
/** Check a Noul answer: one finite probability in [0, 1]. */
389
export function validateNoulAnswer(answer: unknown, label: string): void {
390
  const a = asRecord(answer, label);
391
  if (!isUnitInterval(a.noul)) throw new MalformedResponseError(`Answer '${label}' has no probability in [0, 1].`);
392
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts