API

September 18, 2026 · View on GitHub

Everything pi-typesafe exports, for extension authors. The library has no dependency on Pi's runtime and is safe in tests. The README covers the tool, the commands, and how to write questions.

The client

import { createTypeSafe, choice, noul, score } from "pi-typesafe";

const typesafe = createTypeSafe({ maxRequests: 5, maxUsdPerDay: 1 });
const result = await typesafe.evaluate({
  state: { title: "Login fails after update", body: "..." },
  questions: {
    area: choice("Which area does this report concern?", { auth: "Sign-in", ui: "Layout", other: null }),
    duplicate: noul("Does the report describe the same defect as `known_issue`?"),
    severity: score("How severe is the defect?", ["Cosmetic", "Workaround exists", "Blocking"]),
  },
});
result.answers.area.choice;      // "auth" | "ui" | "other"
result.answers.duplicate.noul;   // 0..1
result.answers.severity.score;   // 0..2, may be fractional
OptionDefaultMeaning
apiKeyTYPESAFE_API_KEY, else the /typesafe login storeNever returned
modeljev-latestNo model is inferred from submitted content
timeoutMs15000Per request; no automatic retries
maxInputBytes65536UTF-8 JSON bytes, not tokens
maxRequests20Attempts per client instance, failures included
maxRequestsPerDay, maxInputTokensPerDay, maxUsdPerDaynoneLocal-day caps, persisted
usdPerMTok0.042Price used for the estimate and the USD cap
ledgerthe store next to the keyInject a ledger in tests
fetchglobal fetchInject a transport for offline tests

evaluate(request, { signal }) validates before sending and rejects with TypeSafeIntegrationError. code is one of configuration, validation, budget, aborted, timeout, http, connection, response; messages never contain upstream bodies, headers, keys, or your submitted state. listModels() verifies the key without counting toward maxRequests.

Admission

prepareEvaluationRequest(value, { maxInputBytes }) is the one admission rule, used by the tool, the playground, and evaluate. It normalizes the near-miss aliases a model produces (options / levels / choices for criteria, a string Noul criterion, a label array for a Choice), validates the schema and JSON-safety, then enforces the byte budget. DEFAULT_MAX_INPUT_BYTES, DEFAULT_MAX_QUESTIONS, and DEFAULT_MAX_REQUESTS hold the shared defaults.

Batching

evaluate is one request: up to 32 questions about one state. Both batching calls preserve input order, bound concurrency (concurrency, default 4), never throw, and stop submitting once a budget or cancellation failure appears.

CallUse
evaluateAll(request)One state, any number of questions: chunks over 32 share the state, then merge into one answers map with usage summed
evaluateMany(requests)Several requests: per-request results plus merged answers, failures, skipped
chunkEvaluationRequest(request, { maxQuestions })The splitter alone; a pure function, no validation
fanOut(items, worker, { concurrency, signal, stopOn })The pool underneath, for your own work

Every item comes back as { ok: true, index, value } or { ok: false, index, error, skipped }; skipped marks work that was never submitted.

Usage and spend

getUsage() returns this client's session counters (requestsStarted, requestsSucceeded, requestsFailed, inputTokens, outputTokens, estimatedUsd). getSpend() adds today's persisted totals, the caps in force, and the cap currently reached.

Day caps live in ~/.pi/agent/pi-typesafe/usage.json (owner-only, atomic, best-effort: an unwritable ledger never fails a request) and roll over at local midnight.

OptionEnvironmentBounds
maxRequestsPerDayPI_TYPESAFE_MAX_REQUESTS_PER_DAYrequests
maxInputTokensPerDayPI_TYPESAFE_MAX_INPUT_TOKENS_PER_DAYinput tokens
maxUsdPerDayPI_TYPESAFE_MAX_USD_PER_DAYestimated spend

The environment may lower an explicit cap, never raise it. A reached cap raises a budget error that names the cap, the amount used, and the day, before anything is submitted. Cost is estimated from input tokens only, because output is free.

openUsageLedger(options), usagePath(), estimateUsd(tokens, usdPerMTok), capsFromEnvironment(env), and mergeCaps(explicit, environment) expose the same arithmetic for your own display.

Auth state

authState() never throws. It reports kind (environment, stored, missing, unusable), keyName, path, reason, verified, verifiedAt, lastFailure, and usableusable is false when no key is present or the last authentication outcome was an HTTP 401/403 rejection.

describeAuth(state) turns that into { level: "ok" | "warning" | "error", text } for a status line or a log. The extension calls both at session start and after a rejection, so an enabled-but-unusable setup is never reported as working.

recordAuthVerified() is called by listModels() and by the first successful request; recordAuthFailure(error) records what degraded TypeSafe; clearAuthState() forgets both, and /typesafe logout calls it. keySituation() and keySourceLabel(situation) remain the lower-level, frozen-for-existing-callers pair, and resolveApiKey() the pre-0.4.0 one.

Asking without throwing

import { ask } from "pi-typesafe";

const answer = await ask(typesafe, request, { timeoutMs: 5_000, signal: mySignal });
if (!answer.ok) return { skipped: answer.errorCode === "budget" };
answer.answers; // typed, plus model, usage, elapsedMs

ask merges its deadline into your signal, takes any object with evaluate (so tests pass a stub), and never throws: a failure is { ok: false, error, errorCode } with pi-typesafe's own message. Unknown failures become a fixed message, so nothing from the transport reaches the user.

Calibration: pi-typesafe/calibrate

A small, domain-free toolkit for turning labelled cases into thresholds.

import { calibrate, formatCalibration, replay, samplesOf } from "pi-typesafe/calibrate";

const results = await replay(cases, data => scoreOne(data), { concurrency: 6 });
console.log(formatCalibration(calibrate("action guard", samplesOf(results).samples, { minPrecision: 0.8 })));
ExportPurpose
auc(samples)Rank-based AUC (Mann–Whitney, ties count half); undefined when one class is empty
metricsAt(samples, threshold), sweep(samples, thresholds)Confusion counts plus precision, recall, and flag rate
defaultThresholds(samples), pickThreshold(rows, floors)The distinct-score grid, and the lowest threshold that clears a precision and recall floor
calibrate(name, samples, options), formatCalibration(calibration)AUC, the sweep, a recommendation, and what it misses and flags, as text
replay(cases, score, options), samplesOf(results)Run labelled cases through any scorer with bounded concurrency, keep per-case failures, then extract the scored samples

replay stops on a budget failure like the batching calls, and reports each failure with the scorer's own message unless you pass describeError.

Login helpers: pi-typesafe/ui

ensureApiKey(ctx), loginWithPrompt(ctx), and promptForApiKey(ctx) use the same hidden input as /typesafe login. ensureApiKey(ctx) returns the existing key source, or prompts, verifies, and stores a new key (undefined when the user cancels). These need Pi's TUI, so call them only from extension command handlers.

One agent tool

typesafe_evaluate is the only tool the package registers. It already accepts typed Choice, Score, and Noul questions, including the aliases above, so a separate "ask Jev" tool would duplicate the admission seam and give the model two ways to do one thing. ask() is the author-facing half of that seam; both run through prepareEvaluationRequest, so what one accepts the others accept.

Your extension owns its own user consent and budget; /typesafe enable applies only to this package's tool. See ../examples/decision-extension.ts and pi-warden for a full extension built this way.