@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
JavaScript
// 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: [] }
}