UNPKG

@nytka/plugin-typesafe

Version:

TypeSafe connector for nytka projects. Asks Jev typed questions (noul, choice, score) about a file or an existing dataset and registers the judgements in datasets/ with the model, the questions hash and the usage.

653 lines (592 loc) • 29.3 kB
// TypeSafe client. The first connector in the line that *judges* rather than collects: it // sends material the project already holds to Jev, TypeSafe's System One model, and gets // back typed answers with a confidence — a choice from named options, a score against // named levels, or a yes/no probability. No text is generated, and nothing at TypeSafe is // written. Read-only by construction means something narrower here than for the six // collectors: there is one URL, one verb, and a plan step before anything that spends. // // Endpoint, request and response shapes, error statuses and the rate were read from // docs.typesafe.ai on 2026-09-19 (api.md, models.md, model-jaggedness/jev-1.13.md). The // retry defaults come from the official JavaScript SDK's RetryPolicy. Nothing here has // been verified against the live API from this repo — there is no TypeSafe key in it. import { createHash } from 'node:crypto' import { Buffer } from 'node:buffer' import { isoDate } from '@nytka/core' /** The one URL. A test asserts the hostname appears exactly once under src/ — the * read-only-by-construction guard for a connector that cannot write anyway. */ export const API_URL = 'https://api.typesafe.ai/v1/systemone' export const API_KEY_ENV = 'TYPESAFE_API_KEY' export const MODEL_ENV = 'TYPESAFE_MODEL' export const DEFAULT_MODEL = 'jev-latest' export const USER_AGENT = '@nytka/plugin-typesafe' /** $42 per billion input tokens, output free — models.md, read 2026-09-19. The API does * not return a cost, so every dollar figure this package prints is computed from usage * at this rate, and the rate goes stale. Both numbers travel together everywhere a cost * is written so a reader can tell how old the arithmetic is. */ export const RATE_USD_PER_MTOK = 0.042 export const RATE_AS_OF = '2026-09-19' /** 64k per request; 32k for the state plus the longest question — models.md. Checked * before sending, on a bytes/4 estimate, so an oversize item fails the run at plan time * rather than as a 422 on item 4,000. */ export const STATE_TOKEN_LIMIT = 32000 export const QUESTION_TYPES = Object.freeze(['noul', 'choice', 'score']) /** One inference call. A fresh deadline per attempt, never one across all six. */ export const REQUEST_TIMEOUT_MS = 60000 /** api.md: 429 rate-limited and 529 overloaded, both with "exponential backoff". 502 and * 503 are kept from @nytka/plugin-sanity's list for the same reason it had them — an * edge failing to reach its upstream is transient by definition. 422 is never here: a * request the API could not validate will not validate on the second try either. */ export const RETRIABLE_STATUS = Object.freeze([429, 502, 503, 529]) /** Connection failures worth retrying are everything EXCEPT these — @nytka/plugin-sanity's * list, which is get-it's via `is-retry-allowed`, trimmed to what Node's fetch surfaces. */ export const UNRETRIABLE_CODES = Object.freeze([ 'ENOTFOUND', 'ENETUNREACH', 'CERT_HAS_EXPIRED', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'CERT_REVOKED', 'HOSTNAME_MISMATCH', 'ERR_TLS_CERT_ALTNAME_INVALID', ]) export const MAX_RETRIES = 5 /** A `Retry-After` the server sends is honoured up to this. Past it the server is asking * for a wait no unattended run should take silently — the error surfaces instead. */ export const MAX_RETRY_AFTER_MS = 30000 export const DEFAULT_CONCURRENCY = 4 export const MAX_CONCURRENCY = 16 /** Errors written here, already actionable — the CLI prints the message and no stack. */ export class TypeSafeError extends Error { constructor (message) { super(message); this.name = 'TypeSafeError' } } /** An HTTP failure from the API, carrying `statusCode` for explain() and the request id * TypeSafe support would ask for. The response body's shape is undocumented, so the * message is built defensively from whichever of the usual fields is present. */ export class TypeSafeHttpError extends Error { constructor ({ statusCode, requestId = null, url, body }) { super(httpErrorMessage({ statusCode, url, body, requestId })) this.name = 'TypeSafeHttpError' this.statusCode = statusCode this.requestId = requestId this.responseBody = typeof body === 'string' ? body : JSON.stringify(body) this.url = url } } function httpErrorMessage ({ statusCode, url, body, requestId }) { const rid = requestId ? ` (request ${requestId})` : '' const error = body?.error if (typeof error === 'string') return `${error}${rid}` if (error && typeof error === 'object' && typeof error.message === 'string') return `${error.message}${rid}` if (typeof body?.message === 'string') return `${body.message}${rid}` if (typeof body?.detail === 'string') return `${body.detail}${rid}` const detail = typeof body === 'string' && body ? ` (${body.length > 100 ? `${body.slice(0, 100)}…` : body})` : '' return `POST ${url} resulted in HTTP ${statusCode}${detail}${rid}` } // --------------------------------------------------------------------------------- // Credentials // // One string from process.env. Like DataForSEO, there is no file to resolve and nothing // to put in private/ — the key is the credential. Unlike every connector before it, the // key grants nothing but the right to spend, so the separate-write-credential rule of // decisions/0008 has no second key to name: there is no write. // --------------------------------------------------------------------------------- export function credentials (env = process.env) { const apiKey = env[API_KEY_ENV] if (!apiKey) { throw new TypeSafeError( `${API_KEY_ENV} is not set in the project .env — nothing was sent.\n` + ` ${API_KEY_ENV} is the API key from your TypeSafe account (typesafe.ai). Every call\n` + ' is billed on input tokens, so there is no free smoke test; "check" costs about ten.', ) } return { apiKey } } /** The model to ask for. `--model` wins, then TYPESAFE_MODEL, then the alias. */ export function modelFromEnv (env = process.env) { return env[MODEL_ENV] || DEFAULT_MODEL } /** The official SDK's RetryPolicy defaults: 500ms doubled per attempt, capped at 5s, with * up to 25% jitter taken off. Not get-it's 100ms base — a 429 from an inference API * answered in 100ms burns a retry and nothing else. */ export function backoffMs (attempt) { const base = Math.min(500 * 2 ** attempt, 5000) return Math.round(base * (1 - Math.random() * 0.25)) } const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms)) /** Retry only a connection-level failure that could plausibly succeed next time. A * deadline that expired is not a blip and is not retried. */ function retriableConnectionError (err) { if (err?.name === 'TimeoutError' || err?.name === 'AbortError') return false const code = err?.cause?.code ?? err?.code if (!code) return false return !UNRETRIABLE_CODES.includes(code) } const headerOf = (res, name) => { const h = res?.headers if (!h) return undefined return typeof h.get === 'function' ? (h.get(name) ?? undefined) : (h[name] ?? h[name.toLowerCase()]) } /** `Retry-After` in milliseconds, or null. Seconds or an HTTP date, per RFC 9110; the * SDK also reads a `retry-after-ms` header and so does this. */ function retryAfterMs (res) { const ms = Number(headerOf(res, 'retry-after-ms')) if (Number.isFinite(ms) && ms > 0) return ms const raw = headerOf(res, 'retry-after') if (raw === undefined || raw === null || raw === '') return null const secs = Number(raw) if (Number.isFinite(secs) && secs >= 0) return secs * 1000 const at = Date.parse(raw) return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null } /** An authenticated caller. `systemOne` returns `{ model, answers, usage, requestId, * attempts }` — the whole answer set, because one call is one state and every question * about it, and the usage, because a metered API where the caller cannot see what a call * cost is a bad trade. * * `fetchImpl`, `retryDelay` and `sleepImpl` exist so the tests exercise the real request * and retry path with no network, no credential and no waiting. Built-in fetch, no HTTP * dependency — the only dependency stays @nytka/core. */ export function client (creds = credentials(), { fetchImpl = fetch, timeoutMs = REQUEST_TIMEOUT_MS, maxRetries = MAX_RETRIES, retryDelay = backoffMs, sleepImpl = defaultSleep, url = API_URL, } = {}) { const { apiKey } = creds const headers = { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', accept: 'application/json', 'user-agent': USER_AGENT, } async function systemOne (state, questions, { model = DEFAULT_MODEL } = {}) { const body = JSON.stringify({ state, model, questions }) for (let attempt = 0; ; attempt++) { let res try { res = await fetchImpl(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(timeoutMs) }) } catch (err) { if (attempt < maxRetries && retriableConnectionError(err)) { await sleepImpl(retryDelay(attempt)) continue } if (err?.name === 'TimeoutError' || err?.name === 'AbortError') { throw new TypeSafeError(`TypeSafe did not respond within ${Math.round(timeoutMs / 1000)}s. Nothing was recorded for this item; retry, or lower --concurrency.`) } const cause = err?.cause?.code ?? err?.code ?? err?.message ?? String(err) throw new TypeSafeError(`could not reach TypeSafe (${cause}). Check the network.`) } const text = await res.text() let parsed try { parsed = text ? JSON.parse(text) : undefined } catch { /* not JSON — handled below, differently for an error and for a 200 */ } if (res.status >= 400) { if (attempt < maxRetries && RETRIABLE_STATUS.includes(res.status)) { const asked = retryAfterMs(res) const wait = asked === null ? retryDelay(attempt) : Math.max(retryDelay(attempt), asked) if (wait > MAX_RETRY_AFTER_MS) { throw new TypeSafeError(`TypeSafe asked for a ${Math.round(wait / 1000)}s wait before retrying (HTTP ${res.status}) — longer than this connector will wait unattended. Retry later, or lower --concurrency.`) } await sleepImpl(wait) continue } throw new TypeSafeHttpError({ statusCode: res.status, requestId: headerOf(res, 'x-typesafe-request-id') ?? null, url, body: parsed ?? text, }) } if (!parsed || typeof parsed !== 'object' || !parsed.answers || typeof parsed.answers !== 'object') { throw new TypeSafeError(`TypeSafe returned HTTP ${res.status} and a body that is not an answer set: ${text.slice(0, 200) || '(empty)'}`) } return { model: parsed.model ?? model, answers: parsed.answers, usage: { input_tokens: Number(parsed.usage?.input_tokens ?? 0), output_tokens: Number(parsed.usage?.output_tokens ?? 0), }, requestId: headerOf(res, 'x-typesafe-request-id') ?? null, attempts: attempt + 1, } } } return Object.freeze({ systemOne }) } // --------------------------------------------------------------------------------- // Questions // // A questions file is the API's own `questions` map, verbatim, so what the docs show is // what a project commits. Validated before anything is sent: a 422 on item one of five // hundred is the same information as a sentence here, minus the call. // --------------------------------------------------------------------------------- const isPlainObject = v => v !== null && typeof v === 'object' && !Array.isArray(v) export function validateQuestions (questions) { if (!isPlainObject(questions)) { throw new TypeSafeError('the questions file must be a JSON object mapping question ids to questions — the API\'s own `questions` shape.') } const ids = Object.keys(questions) if (!ids.length) throw new TypeSafeError('the questions file has no questions in it.') if (ids.includes('questions') && isPlainObject(questions.questions) && !('type' in questions.questions)) { throw new TypeSafeError('the questions file looks like a whole request ({ state, model, questions }). Pass the bare `questions` map — this connector supplies the state and the model.') } for (const id of ids) { const q = questions[id] if (!isPlainObject(q)) throw new TypeSafeError(`question "${id}" is not an object.`) if (!QUESTION_TYPES.includes(q.type)) { throw new TypeSafeError(`question "${id}" has type "${q.type}" — expected one of ${QUESTION_TYPES.join(', ')}.`) } if (q.type === 'choice') { if (!isPlainObject(q.criteria) || Object.keys(q.criteria).length < 2) { throw new TypeSafeError(`choice question "${id}" needs \`criteria\` as an object with at least two options.`) } } if (q.type === 'score') { if (!Array.isArray(q.criteria) || q.criteria.length < 2) { throw new TypeSafeError(`score question "${id}" needs \`criteria\` as an array of at least two levels.`) } } } return questions } /** Question ids and their types, for a registry entry that describes the payload's inner * shape without anyone opening the payload. */ export function questionShape (questions) { const ids = Object.keys(questions) const types = {} for (const id of ids) types[id] = questions[id].type return { ids, types } } // --------------------------------------------------------------------------------- // Estimates // --------------------------------------------------------------------------------- /** Bytes over four. Rough on purpose: the plan needs an order of magnitude and a limit * check, not a tokenizer, and shipping one would be the package's second dependency. * Under-counts dense scripts (CJK) and over-counts whitespace-heavy JSON — both are noted * in the README, and the actual usage the API reports is what gets recorded. */ export function estimateTokens (value) { const str = typeof value === 'string' ? value : JSON.stringify(value) ?? '' return Math.ceil(Buffer.byteLength(str, 'utf8') / 4) } /** USD, six decimals, so a summed cost across five hundred calls does not read as * 0.030000000000000002. */ export function estimateCost (inputTokens, rate = RATE_USD_PER_MTOK) { return round(inputTokens / 1e6 * rate) } function round (n) { return Number.isFinite(n) ? Math.round(n * 1e6) / 1e6 : 0 } /** The state each item contributes, before a call is made. Validates the questions, the * item list and `field`, and names any item too large to send — everything that would * otherwise fail as a 422 mid-run. Shared by plan() and classify() so the two cannot * disagree about what would be sent. */ export function prepare ({ items, questions, field = null, limit = null }) { validateQuestions(questions) if (!Array.isArray(items) || !items.length) throw new TypeSafeError('the input has no items — nothing to classify.') if (limit !== null && (!Number.isInteger(limit) || limit < 1)) throw new TypeSafeError('--limit must be a whole number of at least 1.') const slice = limit ? items.slice(0, limit) : items const states = slice.map((item, index) => { if (field === null) return item if (!isPlainObject(item) || !(field in item)) { throw new TypeSafeError(`item ${index} has no "${field}" field — --field must name a key every item carries.`) } return item[field] }) const questionTokens = estimateTokens(questions) const oversize = [] let estimatedInputTokens = 0 states.forEach((s, i) => { const t = estimateTokens(s) if (t > STATE_TOKEN_LIMIT) oversize.push(i) estimatedInputTokens += t + questionTokens }) return { items: slice, states, oversize, estimatedInputTokens } } /** What classify() would do, priced. Sends nothing and constructs no client — the CLI * prints this and stops unless told --yes, which is decisions/0008 §4 applied to money * rather than to a mutation. */ export function plan ({ items, questions, model = DEFAULT_MODEL, field = null, limit = null }) { const p = prepare({ items, questions, field, limit }) const { ids, types } = questionShape(questions) return { items: p.items.length, calls: p.items.length, model, questionIds: ids, questionTypes: types, estimatedInputTokens: p.estimatedInputTokens, estimatedCost: estimateCost(p.estimatedInputTokens), rate: { usdPerMillionInputTokens: RATE_USD_PER_MTOK, asOf: RATE_AS_OF }, oversize: p.oversize, } } // --------------------------------------------------------------------------------- // ask — one state, every question. The "hand": small enough to read. // --------------------------------------------------------------------------------- export async function ask ({ ts = client(), state, questions, model = DEFAULT_MODEL }) { validateQuestions(questions) if (state === undefined) throw new TypeSafeError('ask needs a state — the thing the questions are about.') if (estimateTokens(state) > STATE_TOKEN_LIMIT) { throw new TypeSafeError(`the state is about ${estimateTokens(state)} tokens; TypeSafe takes ${STATE_TOKEN_LIMIT} for the state plus the longest question. Send less of it.`) } const res = await ts.systemOne(state, questions, { model }) return { model: res.model, answers: res.answers, usage: res.usage, estimatedCost: estimateCost(res.usage.input_tokens), rate: { usdPerMillionInputTokens: RATE_USD_PER_MTOK, asOf: RATE_AS_OF }, requestId: res.requestId, } } // --------------------------------------------------------------------------------- // classify — the same questions over every item. The "memory": a dataset. // --------------------------------------------------------------------------------- const KEY_CANDIDATES = ['_id', 'id', 'key', 'keyword', 'url', 'slug', 'name'] /** Which field of an item identifies it in a row. `keyField` if given; otherwise the * first conventional id-like key the first item carries; otherwise none. The row's * `index` is always there, so a key is a convenience for the script that reads the * payload, not the identity. */ export function detectKeyField (items, keyField = null) { if (keyField) return keyField const first = items[0] if (!isPlainObject(first)) return null return KEY_CANDIDATES.find(k => k in first) ?? null } /** The smallest confidence among an item's answers — the number a reader filters on * when they want "the rows Jev was sure about on every question". Per-answer values * stay inside `answers`, untouched. */ function rowConfidence (answers) { const values = Object.values(answers).map(a => Number(a?.confidence)).filter(Number.isFinite) return values.length ? Math.min(...values) : null } /** Run the questions over every item and return the payload that will be written. * * Order of operations, and why: * * 1. prepare() — validates and refuses oversize items before any call, so a bad * questions file or a too-large item costs nothing. * 2. A canary: item 0 alone. A questions file the API rejects (422) costs one call, not * `concurrency` of them. * 3. A worker pool over the rest. Results land by index, so the payload's rows are in * input order whatever the network did. On the first failure no further item is * scheduled and the run rejects — DataForSEO's fail-whole rule. Paid answers from * items already done are lost with it; the README says so, and --limit is the trial. * * Answers are stored verbatim as the API returned them. There is no normalised form, * so there is no --raw. */ export async function classify ({ ts = client(), items, questions, model = DEFAULT_MODEL, field = null, keyField = null, limit = null, concurrency = DEFAULT_CONCURRENCY, input = { ref: '(items)', kind: 'items', rawPath: null, itemsKey: null }, questionsMeta = { path: null, name: 'questions' }, at = new Date(), }) { const p = prepare({ items, questions, field, limit }) if (p.oversize.length) { throw new TypeSafeError( `${p.oversize.length} item(s) exceed roughly ${STATE_TOKEN_LIMIT} tokens of state (indices ${p.oversize.slice(0, 10).join(', ')}${p.oversize.length > 10 ? ', …' : ''}). ` + 'Nothing was sent. Use --field to send one field of each item, or trim the input.', ) } const workers = Math.max(1, Math.min(MAX_CONCURRENCY, Number(concurrency) || DEFAULT_CONCURRENCY)) const key = detectKeyField(p.items, keyField) const meta = { calls: 0, retries: 0, usage: { input_tokens: 0, output_tokens: 0 }, model: null } const rows = new Array(p.items.length) const one = async index => { const res = await ts.systemOne(p.states[index], questions, { model }) meta.calls++ meta.retries += Math.max(0, (res.attempts ?? 1) - 1) meta.usage.input_tokens += res.usage?.input_tokens ?? 0 meta.usage.output_tokens += res.usage?.output_tokens ?? 0 meta.model ??= res.model ?? null const item = p.items[index] rows[index] = { index, key: key && isPlainObject(item) ? (item[key] ?? null) : null, answers: res.answers, confidence: rowConfidence(res.answers), } } await one(0) let next = 1 let failed = null const worker = async () => { while (failed === null) { const index = next++ if (index >= p.items.length) return try { await one(index) } catch (err) { failed ??= err return } } } await Promise.all(Array.from({ length: Math.min(workers, Math.max(0, p.items.length - 1)) }, worker)) if (failed) throw failed const { ids, types } = questionShape(questions) const resolved = meta.model ?? model const questionsHash = shortHash(canonical(questions)) const id = datasetId({ input, questions, questionsName: questionsMeta.name, model, collectedOn: null }) const result = { id, command: 'classify', model: { requested: model, resolved }, input: { ref: input.ref, kind: input.kind, rawPath: input.rawPath ?? null, itemsKey: input.itemsKey ?? null, items: p.items.length, field, keyField: key, inputHash: createHash('sha256').update(canonical(p.items), 'utf8').digest('hex').slice(0, 16), }, questions: { path: questionsMeta.path ?? null, name: questionsMeta.name, hash: questionsHash, ids, types, spec: questions, }, dateRange: null, collectedAt: isoDate(at), limit, concurrency: workers, schema: ['index', 'key', 'answers', 'confidence'], rowCount: rows.length, apiMetadata: { calls: meta.calls, retries: meta.retries, usage: meta.usage, estimatedInputTokens: p.estimatedInputTokens, // Computed here, NOT returned by the API — hence the name. estimatedCost: estimateCost(meta.usage.input_tokens), rate: { usdPerMillionInputTokens: RATE_USD_PER_MTOK, asOf: RATE_AS_OF }, }, rows, } result.summary = `${ids.length} question${ids.length === 1 ? '' : 's'} (${ids.join(', ')}) answered for ${rows.length} item${rows.length === 1 ? '' : 's'} ` + `of ${input.ref} by TypeSafe ${resolved}. Typed judgements with confidence — no time dimension.` return result } // --------------------------------------------------------------------------------- // Dataset ids // // typesafe-classify-<inputSlug>-<inputSha8>-<questionsSlug>-<questionsSha8>-<model>[-<date>] // // The same two forces as every connector: a re-run of one question must REPLACE, a // different question must never collide. What the question is here: // // the input, keyed on its reference (a dataset id or a project-relative path), not its // content — "the id names the question, never the answer", @nytka/plugin-sanity's rule. // Whether the content changed under the same reference is `inputHash` in the entry. // // the questions, keyed on their canonical CONTENT. Editing one criterion is a // different question, and a re-run after the edit must not overwrite the answers to // the old one. The readable prefix is the file's name, for a human's benefit. // // the requested model, for the reason market is in DataForSEO's id: the same items // under two judges are two answers. The alias is what was asked for; the resolved // version is recorded in the entry. // // Date-free by default, so the default is idempotent. --snapshot appends the local date // and makes a series, which is sanity's opt-in and for the same reason: most judgements // are "the current answer", and the rare project that wants to watch drift across model // releases asks for it. // --------------------------------------------------------------------------------- /** Stable, filename-safe id fragment — @nytka/plugin-dataforseo's, verbatim. */ export function slugify (value, max = 40) { const s = String(value ?? '') .replace(/^https?:\/\//, '') .replace(/\/+$/, '') .replace(/[^a-zA-Z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .toLowerCase() return s.length > max ? s.slice(0, max).replace(/-+$/, '') : s } /** A readable prefix plus a digest of the exact value — DataForSEO's subjectKey. The * digest keeps two references distinct however they slugify. */ export function subjectKey (parts) { const list = Array.isArray(parts) ? parts : [parts] const digest = createHash('sha256').update(list.join('\n'), 'utf8').digest('hex').slice(0, 8) const readable = slugify(list[0]) return readable ? `${readable}-${digest}` : digest } /** sha256 truncated to 8 hex characters — an id fragment, not a security boundary. */ export function shortHash (value) { return createHash('sha256').update(value).digest('hex').slice(0, 8) } /** Canonical JSON with object keys sorted, so `{a, b}` and `{b, a}` hash the same. */ export function canonical (value) { if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]` return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}` } export function questionsKey (questions, name = 'questions') { const readable = slugify(name, 24) const digest = shortHash(canonical(questions)) return readable ? `${readable}-${digest}` : digest } export function datasetId ({ input, questions, questionsName = 'questions', model = DEFAULT_MODEL, collectedOn = null }) { const ref = typeof input === 'string' ? input : input.ref const base = ['typesafe', 'classify', subjectKey(ref), questionsKey(questions, questionsName), slugify(model, 24)].join('-') return collectedOn ? `${base}-${collectedOn}` : base } // --------------------------------------------------------------------------------- // explain — an error as a sentence and a hint, never a stack. // --------------------------------------------------------------------------------- export function explain (err) { if (err instanceof TypeSafeError) return { message: err.message, hints: [] } const status = err?.statusCode ?? null const body = err?.responseBody let detail = err?.message ?? String(err) if (typeof body === 'string' && body.length < 400) { try { const parsed = JSON.parse(body) detail = parsed?.error?.message ?? parsed?.message ?? parsed?.detail ?? (typeof parsed?.error === 'string' ? parsed.error : detail) } catch { /* not JSON — the message already carries what came back */ } } if (status === 401 || status === 403) { return { message: `${detail} (HTTP ${status})`, hints: [ `${API_KEY_ENV} is set but TypeSafe rejected it — missing, revoked, or pasted with a stray character.`, 'Generate a key in the TypeSafe dashboard at typesafe.ai and put it in the project .env.', ], } } if (status === 422) { return { message: `${detail} (HTTP 422)`, hints: [ 'The request did not validate. That is the questions file or the state, not the key.', 'Check each question has a `type` of noul, choice or score, choice has a `criteria` object, score has a `criteria` array — the body above says which.', ], } } if (status === 429) { return { message: `${detail} (HTTP 429)`, hints: [`Rate-limited, and already retried ${MAX_RETRIES} times with backoff. Lower --concurrency, or wait.`], } } if (status === 529) { return { message: `${detail} (HTTP 529)`, hints: [`TypeSafe is overloaded, and this was already retried ${MAX_RETRIES} times. Try again later.`], } } if (status && status >= 500) { return { message: `${detail} (HTTP ${status})`, hints: ['TypeSafe-side. Retry; if it persists, check with TypeSafe.'] } } if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|fetch failed/i.test(String(err?.cause?.code ?? err?.code ?? detail))) { return { message: detail, hints: ['Could not reach the TypeSafe API. Check the network.'] } } if (status) return { message: `${detail} (HTTP ${status})`, hints: [] } return { message: detail, hints: [] } }