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

workbenchthis machine
Files 16904894ed3e75ecfac04ccf7b61044217125dad
279 lines · 10.7 KB · ~3.1k 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
  | "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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts