benjivers/jev-mcp Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files cbaceb505b32e2f74693377ae6d3e69cde6a98b6
| 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 | | "unknown"; |
| 211 | |
| 212 | export interface DescribedError { |
| 213 | kind: ErrorKind; |
| 214 | message: string; |
| 215 | /** True when the same request may succeed later. The SDK already retried. */ |
| 216 | retryable: boolean; |
| 217 | status?: number; |
| 218 | /** TypeSafe request id, the thing support needs to trace a failure. */ |
| 219 | requestId?: string; |
| 220 | /** What the caller should actually do next. */ |
| 221 | hint?: string; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Classify a failure so a caller can branch on it. |
| 226 | * |
| 227 | * Flattening every failure to a string makes a missing key, a malformed |
| 228 | * question, and a rate limit indistinguishable, so callers retry things that |
| 229 | * will never succeed and give up on things that would. |
| 230 | */ |
| 231 | export function describeError(error: unknown): DescribedError { |
| 232 | const message = error instanceof Error ? error.message : String(error); |
| 233 | |
| 234 | if (error instanceof APIUserAbortError) { |
| 235 | return { kind: "cancelled", message, retryable: false, hint: "The client cancelled the request." }; |
| 236 | } |
| 237 | if (error instanceof APITimeoutError) { |
| 238 | return { |
| 239 | kind: "timeout", |
| 240 | message, |
| 241 | retryable: true, |
| 242 | hint: "Shorten the state or raise JEV_TIMEOUT_MS.", |
| 243 | }; |
| 244 | } |
| 245 | if (error instanceof APIConnectionError) { |
| 246 | return { kind: "connection", message, retryable: true, hint: "Check network access to api.typesafe.ai." }; |
| 247 | } |
| 248 | |
| 249 | if (error instanceof APIError) { |
| 250 | const base = { message, status: error.status, requestId: error.requestId }; |
| 251 | if (error instanceof AuthenticationError) { |
| 252 | return { ...base, kind: "authentication", retryable: false, hint: "The API key was rejected. Check TYPESAFE_API_KEY in the server's environment." }; |
| 253 | } |
| 254 | if (error instanceof PermissionDeniedError) { |
| 255 | return { ...base, kind: "permission_denied", retryable: false, hint: "The key is valid but lacks access to this model or account." }; |
| 256 | } |
| 257 | if (error instanceof RateLimitError) { |
| 258 | return { ...base, kind: "rate_limit", retryable: true, hint: "Rate limited after the SDK's own retries. Back off before trying again." }; |
| 259 | } |
| 260 | if (error instanceof UnprocessableEntityError || error instanceof BadRequestError) { |
| 261 | return { ...base, kind: "invalid_request", retryable: false, hint: "The request failed validation. The message names the offending field." }; |
| 262 | } |
| 263 | if (error instanceof NotFoundError) { |
| 264 | return { ...base, kind: "not_found", retryable: false, hint: "Check the model id in JEV_MODEL." }; |
| 265 | } |
| 266 | return { ...base, kind: "server_error", retryable: error.status >= 500, hint: "TypeSafe returned an error. Retry if it persists." }; |
| 267 | } |
| 268 | |
| 269 | if (/^No API key/i.test(message)) { |
| 270 | return { |
| 271 | kind: "no_api_key", |
| 272 | message, |
| 273 | retryable: false, |
| 274 | 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.", |
| 275 | }; |
| 276 | } |
| 277 | |
| 278 | return { kind: "invalid_arguments", message, retryable: false }; |
| 279 | } |