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.

170 lines (154 loc) • 8.99 kB
#!/usr/bin/env node import { API_KEY_ENV, DEFAULT_CONCURRENCY, MAX_CONCURRENCY, TypeSafeError, TypeSafeHttpError, ask, client, credentials, explain, modelFromEnv, } from './typesafe.mjs' import { loadQuestions, loadState } from './inputs.mjs' import { findProjectRoot, loadEnv } from '@nytka/core' import { plan, run } from './index.mjs' const USAGE = ` nytka-typesafe — TypeSafe connector. Asks Jev typed questions about material the project already holds. It generates no text and writes nothing at TypeSafe; it spends. nytka-typesafe check One ten-token question. The auth smoke test: prints the resolved model and the usage. nytka-typesafe ask --questions <file> (--state <file> | --state-json '<json>') [--model M] One state, every question in the file. Answers as JSON on stdout, usage on stderr. Registers nothing. Small enough to read — the "hand". nytka-typesafe classify --input <dataset-id | file.json | file.jsonl> --questions <file> [--field name] [--key name] [--items key] [--limit N] [--concurrency N] [--model M] [--snapshot] [--no-register] [--yes] The same questions over every item. WITHOUT --yes it prints what it would send and what that would cost, and stops. With --yes it runs, writes the answers to datasets/payloads/ and registers them in datasets/index.json — the "memory". --input a dataset id from datasets/index.json (its payload is read by this connector, never by you), or a .json array / .jsonl file of items --field send one field of each item as the state instead of the whole item --key which field names an item in the rows (auto-detected: _id, id, key, …) --items which key of a payload holds the items (auto: rows, documents, items, …) --limit N first N items only. --limit 5 --yes is the cheap trial --concurrency parallel calls, 1..${MAX_CONCURRENCY}, default ${DEFAULT_CONCURRENCY} --snapshot append today's date to the id, so re-runs accumulate instead of replace The questions file is the API's own \`questions\` map, verbatim: { "intent": { "type": "choice", "instructions": "...", "criteria": { "buy": null, "learn": null } }, "urgent": { "type": "noul", "instructions": "..." } } Credentials come from .env at the project root: ${API_KEY_ENV}. Optional: TYPESAFE_MODEL pins a model (default jev-latest); --model wins over both. Every call is billed on input tokens; the rate and the date it was checked are printed with every estimate. ` function parseArgs (argv) { const out = { _: [] } for (let i = 0; i < argv.length; i++) { const a = argv[i] if (!a.startsWith('--')) { out._.push(a); continue } const key = a.slice(2) const next = argv[i + 1] if (next === undefined || next.startsWith('--')) out[key] = true else { out[key] = next; i++ } } return out } const str = v => (typeof v === 'string' ? v : undefined) const num = v => (v === undefined || v === true ? null : Number(v)) const money = n => `$${Number(n).toFixed(n < 0.01 ? 6 : 4)}` const args = parseArgs(process.argv.slice(2)) const cmd = args._[0] const COMMANDS = ['check', 'ask', 'classify'] try { if (!cmd || !COMMANDS.includes(cmd)) { console.log(USAGE) process.exit(cmd ? 1 : 0) } const root = findProjectRoot() if (!root) { console.error('error: no project.yaml found walking up from cwd — run inside a nytka project') process.exit(1) } loadEnv(root) // Capability is configuration presence (decisions/0008 §0): an unset key means this // project has not adopted TypeSafe, not that it is broken. The one command that needs no // key is classify WITHOUT --yes — a plan is free and must stay free, so it is exempt. const planning = cmd === 'classify' && args.yes !== true if (!planning && !process.env[API_KEY_ENV]) { console.error(`${API_KEY_ENV} is not set in the project .env — this project has not configured TypeSafe.`) console.error(` Set ${API_KEY_ENV} to your TypeSafe API key to enable "check", "ask" and "classify --yes".`) console.error(' "classify" without --yes still works: it plans and prices a run without sending anything.') process.exit(0) } const model = str(args.model) ?? modelFromEnv() if (cmd === 'check') { const res = await ask({ ts: client(credentials()), state: { check: 'nytka-typesafe check' }, questions: { ok: { type: 'noul', instructions: 'Is the state a connectivity check from a tool called nytka-typesafe?' } }, model, }) const a = res.answers.ok ?? {} console.log(`\n TypeSafe answered. model ${res.model}`) console.log(` noul ${a.noul ?? '?'} confidence ${a.confidence ?? '?'}`) console.log(` usage ${res.usage.input_tokens} in / ${res.usage.output_tokens} out — about ${money(res.estimatedCost)} at $${res.rate.usdPerMillionInputTokens}/Mtok (rate as of ${res.rate.asOf})`) } else if (cmd === 'ask') { const q = await loadQuestions(root, str(args.questions)) let state if (str(args['state-json']) !== undefined) { try { state = JSON.parse(args['state-json']) } catch (err) { throw new TypeSafeError(`--state-json is not valid JSON: ${err.message}`) } } else if (str(args.state) !== undefined) { state = await loadState(root, args.state) } else { throw new TypeSafeError('ask needs --state <file> or --state-json \'<json>\' — the thing the questions are about.') } const res = await ask({ ts: client(credentials()), state, questions: q.questions, model }) // Answers to stdout and nothing else, so a pipe stays pure JSON; usage to stderr. console.log(JSON.stringify({ model: res.model, answers: res.answers }, null, 2)) console.error(`usage ${res.usage.input_tokens} in / ${res.usage.output_tokens} out — about ${money(res.estimatedCost)} at $${res.rate.usdPerMillionInputTokens}/Mtok (rate as of ${res.rate.asOf})`) } else if (cmd === 'classify') { const common = { root, input: str(args.input), questions: str(args.questions), field: str(args.field) ?? null, itemsKey: str(args.items) ?? null, limit: num(args.limit), model, } if (planning) { const p = await plan(common) console.log(`\n classify ${p.questions.name} over ${p.input.ref}`) console.log(` input ${p.input.kind}${p.input.itemsKey ? ` (${p.input.itemsKey})` : ''}: ${p.items} item${p.items === 1 ? '' : 's'}${p.input.available > p.items ? ` of ${p.input.available} (--limit)` : ''}`) console.log(` questions ${p.questionIds.map(id => `${id}:${p.questionTypes[id]}`).join(', ')} [${p.questions.hash}]`) console.log(` model ${p.model}`) console.log(` would send ${p.calls} call${p.calls === 1 ? '' : 's'}, about ${p.estimatedInputTokens} input tokens`) console.log(` estimated ${money(p.estimatedCost)} at $${p.rate.usdPerMillionInputTokens}/Mtok (rate as of ${p.rate.asOf}; output tokens are free)`) console.log(` dataset id ${p.id}`) if (p.oversize.length) { console.log(` REFUSES ${p.oversize.length} item(s) too large for one call (indices ${p.oversize.slice(0, 10).join(', ')}${p.oversize.length > 10 ? ', …' : ''}) — use --field or trim the input`) } else { console.log('\n Nothing was sent. Add --yes to run it.') } } else { const res = await run({ ...common, keyField: str(args.key) ?? null, concurrency: num(args.concurrency) ?? DEFAULT_CONCURRENCY, snapshot: args.snapshot === true, register: args['no-register'] !== true, }) // Counts, paths and cost only — payloads must not enter agent context. console.log(`\n classify model ${res.model.resolved}${res.model.requested !== res.model.resolved ? ` (asked for ${res.model.requested})` : ''}`) console.log(` ${res.rowCount} rows -> ${res.rawPath}`) console.log(res.registered ? ` registered as "${res.id}" in datasets/index.json` : ' not registered (--no-register)') // A metered API. A run whose cost is invisible is one nobody budgets for. console.log(` usage ${res.usage.input_tokens} in / ${res.usage.output_tokens} out over ${res.calls} call${res.calls === 1 ? '' : 's'}`) console.log(` estimated cost ${money(res.estimatedCost)} at $${res.rate.usdPerMillionInputTokens}/Mtok (rate as of ${res.rate.asOf})`) } } } catch (err) { const { message, hints } = explain(err) console.error(`error: ${message}`) for (const line of hints) console.error(` ${line}`) // No stack trace, ever, for an error this package or the API produced — both are // already sentences. Anything else is a bug, and the stack is the useful part of it. if (!(err instanceof TypeSafeError) && !(err instanceof TypeSafeHttpError) && err?.stack) console.error(err.stack) process.exit(1) }