benjivers/jev-mcp Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files 98937770891cfe254ef804fe1a597df1744c910b
| 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 | } |