Repository Public Research preview
1#!/usr/bin/env node2/**3 * Offline companion for the openai-api-toolkit skill. No network, no API key, no dependencies.4 *5 * node openai-api.mjs models [model-id] model table, or everything known about one model6 * node openai-api.mjs lint <request.json|-> findings for a request (exit 2 if the API would reject it)7 * node openai-api.mjs fix <request.json|-> corrected request + list of changes8 * node openai-api.mjs convert <chat-request.json|-> Chat Completions -> Responses API9 * node openai-api.mjs migrate <request.json|-> <target-model>10 * node openai-api.mjs cost <model> <input_tokens> <output_tokens> [--cached N] [--batch] [--fast] [--requests N]11 * node openai-api.mjs compare <input_tokens> <output_tokens> [--requests N]12 * node openai-api.mjs scaffold <approach> [--language typescript|python] [--model ID] [--out FILE]13 * node openai-api.mjs guide [topic]14 * The API (responses or chat) is detected from the body; force it with --api responses|chat. Output is JSON unless noted.15 */16import { readFileSync, writeFileSync, existsSync } from "node:fs";17import { compareCost, estimateCost } from "./lib/cost.mjs";18import { GUIDES } from "./lib/guides.mjs";19import { KB, MODELS, RECOMMENDED, modelSummary, resolveModel } from "./lib/knowledge.mjs";20import { chatToResponses, detectApi, fixRequest, lintRequest } from "./lib/lint.mjs";21import { APPROACHES, scaffold } from "./lib/scaffold.mjs";2223const [cmd, ...rest] = process.argv.slice(2);24const flags = {}, args = [];25for (let i = 0; i < rest.length; i++) {26 if (rest[i].startsWith("--")) { const k = rest[i].slice(2); const v = rest[i + 1]; if (v === undefined || v.startsWith("--")) flags[k] = true; else { flags[k] = v; i++; } }27 else args.push(rest[i]);28}29const print = (x) => process.stdout.write(JSON.stringify(x, null, 2) + "\n");30const die = (msg, code = 1) => { process.stderr.write(`error: ${msg}\n`); process.exit(code); };31function readRequest(src) {32 if (!src) die("give a request file, or - for stdin");33 let text;34 try { text = src === "-" ? readFileSync(0, "utf8") : readFileSync(src, "utf8"); } catch { die(`cannot read ${src}`); }35 try { return JSON.parse(text); } catch (e) { die(`${src} is not valid JSON: ${e.message}`); }36}37const opts = { api: flags.api === "responses" || flags.api === "chat" ? flags.api : undefined };38const int = (v, name) => { const n = Number(v); if (!Number.isInteger(n) || n < 0) die(`${name} must be a non-negative integer`); return n; };3940switch (cmd) {41 case "models": {42 if (args[0]) { const m = resolveModel(args[0]); if (!m) die(`unknown model "${args[0]}". Known: ${Object.keys(MODELS).join(", ")}`); print({ observed_at: KB.observed_at, ...m }); }43 else print({ observed_at: KB.observed_at, recommended: RECOMMENDED, models: Object.values(MODELS).map(modelSummary) });44 break;45 }46 case "lint": { const r = lintRequest(readRequest(args[0]), opts); print(r); process.exit(r.valid ? 0 : 2); }47 case "fix": { print(fixRequest(readRequest(args[0]), opts)); break; }48 case "convert": { print(chatToResponses(readRequest(args[0]))); break; }49 case "migrate": {50 const target = resolveModel(args[1]);51 if (!target) die(`unknown target model "${args[1]}"`);52 const req = { ...readRequest(args[0]), model: target.id };53 // A Chat Completions request cannot run on a Responses-only model: convert it instead of leaving it invalid.54 const chatOnly = (opts.api ?? detectApi(req)) === "chat" && !target.chat_completions.supported;55 print(chatOnly ? { to: target.id, converted_to: "responses", ...chatToResponses(req) } : { to: target.id, ...fixRequest(req, opts) });56 break;57 }58 case "cost": {59 if (args.length < 3) die("usage: cost <model> <input_tokens> <output_tokens>");60 const r = estimateCost({ model: args[0], input_tokens: int(args[1], "input_tokens"), output_tokens: int(args[2], "output_tokens"), cached_input_tokens: flags.cached ? int(flags.cached, "--cached") : 0, batch: !!flags.batch, fast: !!flags.fast, requests: flags.requests ? int(flags.requests, "--requests") : 1 });61 if (r.error) die(r.error);62 print(r);63 break;64 }65 case "compare": {66 if (args.length < 2) die("usage: compare <input_tokens> <output_tokens>");67 print(compareCost({ input_tokens: int(args[0], "input_tokens"), output_tokens: int(args[1], "output_tokens"), requests: flags.requests ? int(flags.requests, "--requests") : 1, batch: !!flags.batch }, Object.keys(MODELS).filter((id) => MODELS[id].pricing_usd_per_mtok)));68 break;69 }70 case "scaffold": {71 const r = scaffold({ approach: args[0], language: flags.language ?? "typescript", model: flags.model, effort: flags.effort });72 if (r.error) die(`${r.error}\napproaches: ${Object.entries(APPROACHES).map(([k, v]) => `\n ${k} — ${v}`).join("")}`);73 if (flags.out) { if (existsSync(flags.out)) die(`${flags.out} already exists; refusing to overwrite`); writeFileSync(flags.out, r.code); process.stderr.write(`wrote ${flags.out}\ninstall: ${r.install}\nrun: ${r.run}\n`); }74 else process.stdout.write(r.code);75 break;76 }77 case "guide": {78 if (!args[0]) print({ topics: Object.keys(GUIDES) });79 else if (!GUIDES[args[0]]) die(`unknown topic "${args[0]}". Topics: ${Object.keys(GUIDES).join(", ")}`);80 else process.stdout.write(GUIDES[args[0]] + "\n");81 break;82 }83 default:84 process.stderr.write(readFileSync(new URL(import.meta.url), "utf8").split("*/")[0].split("\n").slice(2).map((l) => l.replace(/^ \* ?/, "")).join("\n") + "\n");85 process.exit(cmd ? 1 : 0);86}87