lobstack 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.mjs ADDED
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lobstack — the Gateway from a terminal.
4
+ *
5
+ * npx lobstack the interactive UI, when there is a
6
+ * terminal on both ends and a key to use
7
+ * npx lobstack tui the same thing, asked for by name
8
+ * npx lobstack init
9
+ * npx lobstack chat "say hello"
10
+ * npx lobstack models
11
+ * npx lobstack spend
12
+ * npx lobstack proxy
13
+ *
14
+ * Zero dependencies, on purpose. `fetch`, `node:http` and `node:readline` are
15
+ * all in the runtime, so `npx lobstack` starts immediately instead of resolving
16
+ * a tree first — and there is no supply chain between a user's key and us.
17
+ *
18
+ * That still holds with a full-screen UI in the box. `node:readline` already
19
+ * has the escape-sequence decoder, and the rest of a TUI is nine escape
20
+ * sequences written by hand in tty.mjs. A process that holds an `lsk_live_`
21
+ * credential does not get to pull a dependency tree to draw a box.
22
+ */
23
+
24
+ import { readConfig, writeConfig, resolveKey, resolveBase, gatewayUrl, CONFIG_PATH } from './config.mjs';
25
+ import { printReceipt, dim, bold, green, money, fail } from './render.mjs';
26
+ import { gwFetch, errorText, fetchModels, fetchUsage, streamCompletion } from './gateway.mjs';
27
+ import { startProxy } from './proxy.mjs';
28
+ import { detect } from './tty.mjs';
29
+ import { runTui, runLineMode, readAll } from './tui.mjs';
30
+ import { createInterface } from 'node:readline/promises';
31
+ import { readFileSync } from 'node:fs';
32
+
33
+ const HELP = `${bold('lobstack')} - one key, every model, and what each call cost.
34
+
35
+ ${bold('lobstack')} the interactive UI: watch the cost as it happens
36
+ ${bold('lobstack init')} save an API key to ${CONFIG_PATH}
37
+ ${bold('lobstack chat')} "<prompt>" one call, streamed, with the receipt
38
+ ${bold('lobstack models')} what the gateway will serve, and at what price
39
+ ${bold('lobstack spend')} [--days 7] what you have spent, from the usage API
40
+ ${bold('lobstack proxy')} [--port 8787] a local OpenAI-compatible endpoint
41
+
42
+ ${dim('Options')}
43
+ --model <key> default: auto (let the router choose)
44
+ --key <lsk_...> override the saved key for one command
45
+ --base <url> override the gateway host
46
+ --json machine-readable output where it makes sense
47
+ --force draw the UI even where the streams do not look like a terminal
48
+
49
+ ${dim('Environment')}
50
+ LOBSTACK_API_KEY, LOBSTACK_BASE_URL - both win over the saved config.
51
+ NO_COLOR, TERM=dumb, LOBSTACK_ASCII - all respected.
52
+ `;
53
+
54
+ /**
55
+ * Read from package.json rather than declared here.
56
+ *
57
+ * A version constant beside a version field is two places to bump and one
58
+ * place to forget; `npm version` writes the manifest and nothing else, so the
59
+ * manifest is the one that is always right.
60
+ */
61
+ const VERSION = (() => {
62
+ try {
63
+ return JSON.parse(
64
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
65
+ ).version;
66
+ } catch {
67
+ return '0.0.0';
68
+ }
69
+ })();
70
+
71
+
72
+ /**
73
+ * Flags that take no value. Everything else consumes the next argument.
74
+ *
75
+ * `help` and `version` have to be in here, and their absence was not a
76
+ * cosmetic bug. Anything not listed swallows the next argv item, so
77
+ * `lobstack chat --version "hi"` ate the prompt and answered "nothing to
78
+ * send", and a bare `--help` fell through to the no-command branch — which, in
79
+ * a terminal with a key saved, opens the chat UI instead of printing anything.
80
+ * `npx lobstack --help` is the first thing a new user types.
81
+ */
82
+ const BOOLEAN_FLAGS = new Set(['json', 'force', 'help', 'version']);
83
+
84
+ function parseArgs(argv) {
85
+ const out = { _: [], flags: {} };
86
+ for (let i = 0; i < argv.length; i++) {
87
+ const a = argv[i];
88
+ if (a.startsWith('--') && BOOLEAN_FLAGS.has(a.slice(2))) out.flags[a.slice(2)] = true;
89
+ else if (a.startsWith('--')) out.flags[a.slice(2)] = argv[++i];
90
+ else out._.push(a);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function requireKey(flags) {
96
+ const key = flags.key || resolveKey();
97
+ if (!key) {
98
+ fail(
99
+ 'no API key.',
100
+ 'Run `lobstack init`, or set LOBSTACK_API_KEY. Get a key at https://www.lobstack.ai/start',
101
+ );
102
+ }
103
+ return key;
104
+ }
105
+
106
+ function base(flags) {
107
+ const { base: b, corrected } = resolveBase(flags.base);
108
+ if (corrected) {
109
+ // Not a silent fix. The apex redirects to www, and every HTTP client drops
110
+ // Authorization when a redirect changes host, so honouring what was typed
111
+ // would answer a valid key with "missing credentials" - which is precisely
112
+ // the bug that made this gateway look broken for three months.
113
+ process.stderr.write(
114
+ dim(`- using ${b} - the bare domain redirects, and a redirect drops your Authorization header\n`),
115
+ );
116
+ }
117
+ return b;
118
+ }
119
+
120
+ /* ── init ──────────────────────────────────────────────────────────────── */
121
+
122
+ async function cmdInit(flags) {
123
+ let key = flags.key;
124
+ if (!key) {
125
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
126
+ process.stdout.write(
127
+ `Get a key at ${bold('https://www.lobstack.ai/start')} - it is free and takes a minute.\n\n`,
128
+ );
129
+ key = (await rl.question('Paste your key (lsk_live_...): ')).trim();
130
+ rl.close();
131
+ }
132
+ if (!/^lsk_(live|test)_[0-9a-f]{56}$/.test(key)) {
133
+ fail(
134
+ 'that does not look like a Lobstack key.',
135
+ 'A key is lsk_live_ or lsk_test_ followed by 56 hex characters.',
136
+ );
137
+ }
138
+
139
+ // Verified before saving. Writing an unusable key to disk just moves the
140
+ // failure to the next command, where it is harder to explain.
141
+ const b = base(flags);
142
+ process.stdout.write(dim('checking the key...\n'));
143
+ const res = await gwFetch(gatewayUrl(b, '/models'), key);
144
+ if (!res.ok) fail(`the gateway rejected that key: ${await errorText(res)}`);
145
+
146
+ writeConfig({ ...readConfig(), key, baseUrl: b });
147
+ process.stdout.write(
148
+ `\n${green('Saved')} to ${CONFIG_PATH} ${dim('(0600)')}\n\n` +
149
+ `Try it: ${bold('lobstack chat "say hello"')}\n`,
150
+ );
151
+ }
152
+
153
+ /* ── chat ──────────────────────────────────────────────────────────────── */
154
+
155
+ async function cmdChat(args, flags) {
156
+ const prompt = args.join(' ').trim();
157
+ if (!prompt) fail('nothing to send.', 'lobstack chat "your prompt here"');
158
+
159
+ const key = requireKey(flags);
160
+ const b = base(flags);
161
+ const model = flags.model || 'auto';
162
+
163
+ const { usage, receipt, model: served } = await streamCompletion(b, key, {
164
+ model,
165
+ messages: [{ role: 'user', content: prompt }],
166
+ onText: (t) => process.stdout.write(t),
167
+ });
168
+ process.stdout.write('\n');
169
+ if (flags.json) {
170
+ process.stdout.write(JSON.stringify({ usage, x_lobstack: receipt }, null, 2) + '\n');
171
+ } else {
172
+ printReceipt({ receipt, usage, model: served });
173
+ }
174
+ }
175
+
176
+ /* ── models ────────────────────────────────────────────────────────────── */
177
+
178
+ async function cmdModels(flags) {
179
+ const rows = await fetchModels(base(flags), requireKey(flags));
180
+
181
+ if (flags.json) {
182
+ process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
183
+ return;
184
+ }
185
+
186
+ const w = Math.max(...rows.map((m) => (m.id || '').length), 5);
187
+ process.stdout.write(
188
+ `${dim('MODEL'.padEnd(w))} ${dim('TIER'.padEnd(9))} ${dim('IN/M')} ${dim('OUT/M')}\n`,
189
+ );
190
+ for (const m of rows) {
191
+ const p = m.price_per_mtok || {};
192
+ const price = (v) => (typeof v === 'number' ? `$${v}` : dim('-'));
193
+ process.stdout.write(
194
+ `${(m.id || '').padEnd(w)} ${String(m.tier || '').padEnd(9)} ${price(p.input).padEnd(6)} ${price(p.output)}\n`,
195
+ );
196
+ }
197
+ process.stdout.write(
198
+ `\n${dim(`${rows.length} models. Send "auto" and the router picks one, then tells you which.`)}\n`,
199
+ );
200
+ }
201
+
202
+ /* ── spend ─────────────────────────────────────────────────────────────── */
203
+
204
+ async function cmdSpend(flags) {
205
+ const days = Number(flags.days || 7);
206
+ const body = await fetchUsage(base(flags), requireKey(flags), days);
207
+
208
+ if (flags.json) {
209
+ process.stdout.write(JSON.stringify(body, null, 2) + '\n');
210
+ return;
211
+ }
212
+ if (body.enabled === false) {
213
+ fail(body.message || 'usage reporting is not enabled on this deployment.');
214
+ }
215
+
216
+ const s = body.summary || {};
217
+ process.stdout.write(
218
+ `${bold(`Last ${days} days`)} ${dim('-')} ` +
219
+ `${s.requests ?? 0} requests ${dim('-')} ${money(Number(s.cost_usd ?? 0))}` +
220
+ (Number(s.savings_usd ?? 0) > 0 ? ` ${dim('-')} saved ${green(money(Number(s.savings_usd)))}` : '') +
221
+ '\n\n',
222
+ );
223
+ for (const g of body.groups ?? []) {
224
+ process.stdout.write(
225
+ ` ${String(g.key ?? '').padEnd(24)} ${String(g.requests ?? 0).padStart(6)} ${money(Number(g.cost_usd ?? 0))}\n`,
226
+ );
227
+ }
228
+ }
229
+
230
+ /* ── tui ───────────────────────────────────────────────────────────────── */
231
+
232
+ /**
233
+ * The interactive UI, and every way out of it.
234
+ *
235
+ * Four cases, in the order they have to be checked. The whole point is that
236
+ * `lobstack` in a pipeline behaves like a Unix program and `lobstack` at a
237
+ * keyboard behaves like an application, with no flag to remember either way.
238
+ *
239
+ * 1. stdout is not a terminal - redirected, piped, or a CI log. Never draw.
240
+ * If stdin is not a terminal either there is nothing interactive about
241
+ * this invocation at all, so whatever came in on stdin is the prompt and
242
+ * the one-shot `chat` path runs: `echo hi | lobstack > out.txt` puts the
243
+ * answer in the file and nothing else.
244
+ * 2. stdout is a terminal but stdin is a pipe. Same thing: the prompt arrived
245
+ * on stdin, there is no keyboard to run a UI with.
246
+ * 3. Both are terminals but TERM says `dumb` - no cursor addressing exists,
247
+ * so a full-screen frame is not a degraded experience, it is garbage. Line
248
+ * mode instead.
249
+ * 4. Draw.
250
+ */
251
+ async function cmdTui(args, flags) {
252
+ const key = requireKey(flags);
253
+ const b = base(flags);
254
+ const model = flags.model || 'auto';
255
+ const force = Boolean(flags.force) || process.env.LOBSTACK_TUI === '1';
256
+ const caps = detect({ force });
257
+
258
+ const pipedPrompt = async () => {
259
+ const typed = args.join(' ').trim();
260
+ if (typed) return typed;
261
+ return (await readAll(process.stdin)).trim();
262
+ };
263
+
264
+ if (!caps.outTTY) {
265
+ if (!process.stdin.isTTY) {
266
+ const prompt = await pipedPrompt();
267
+ if (!prompt) {
268
+ fail(
269
+ 'no terminal to draw on, and nothing on stdin.',
270
+ 'Run `lobstack` in a terminal, or pipe a prompt in: echo "hi" | lobstack',
271
+ );
272
+ }
273
+ return cmdChat([prompt], flags);
274
+ }
275
+ // A keyboard on stdin, a file on stdout. Drawing would fill the file with
276
+ // escape sequences, so this is the plain loop: answers to stdout, prompts
277
+ // and receipts to stderr.
278
+ process.stderr.write(dim('- stdout is not a terminal, so this is line mode\n'));
279
+ return runLineMode({ key, base: b, model });
280
+ }
281
+
282
+ if (!caps.inTTY) {
283
+ const prompt = await pipedPrompt();
284
+ if (prompt) return cmdChat([prompt], flags);
285
+ }
286
+
287
+ if (!caps.fullscreen) {
288
+ // Say which of the three reasons it was. "line mode" with no explanation
289
+ // reads as a bug, and the fix differs for each: set TERM, or stop piping.
290
+ const why = caps.dumb
291
+ ? `TERM=${process.env.TERM} cannot address a cursor`
292
+ : !caps.inTTY
293
+ ? 'stdin is not a terminal'
294
+ : 'stdout is not a terminal';
295
+ process.stderr.write(dim(`- ${why}, so this is line mode\n`));
296
+ return runLineMode({ key, base: b, model });
297
+ }
298
+
299
+ return runTui({ key, base: b, model, caps, initial: args.join(' ').trim() || null });
300
+ }
301
+
302
+ /* ── main ──────────────────────────────────────────────────────────────── */
303
+
304
+ const { _: positional, flags } = parseArgs(process.argv.slice(2));
305
+ const [command, ...rest] = positional;
306
+
307
+ // Answered before the switch, so they work with or without a command and
308
+ // cannot be captured by the bare-`lobstack` branch below.
309
+ if (flags.help) {
310
+ process.stdout.write(HELP);
311
+ process.exit(0);
312
+ }
313
+ if (flags.version) {
314
+ process.stdout.write(`${VERSION}\n`);
315
+ process.exit(0);
316
+ }
317
+
318
+ try {
319
+ switch (command) {
320
+ case 'init':
321
+ await cmdInit(flags);
322
+ break;
323
+ case 'chat':
324
+ await cmdChat(rest, flags);
325
+ break;
326
+ case 'models':
327
+ await cmdModels(flags);
328
+ break;
329
+ case 'spend':
330
+ await cmdSpend(flags);
331
+ break;
332
+ case 'proxy':
333
+ await startProxy({ key: requireKey(flags), base: base(flags), port: Number(flags.port || 8787) });
334
+ break;
335
+ case 'tui':
336
+ case 'ui':
337
+ await cmdTui(rest, flags);
338
+ break;
339
+ case undefined:
340
+ // Bare `lobstack` opens the UI, but only when opening it is obviously
341
+ // what was meant: a terminal on both ends and a key already available.
342
+ // In a pipeline, in CI, or on a first run with no key, it prints the same
343
+ // help it always printed - which is also the screen that tells you to run
344
+ // `init`, so the no-key case still lands somewhere useful.
345
+ if (
346
+ (flags.force || (process.stdout.isTTY && process.stdin.isTTY)) &&
347
+ (flags.key || resolveKey())
348
+ ) {
349
+ await cmdTui([], flags);
350
+ break;
351
+ }
352
+ process.stdout.write(HELP);
353
+ break;
354
+ case 'help':
355
+ process.stdout.write(HELP);
356
+ break;
357
+ default:
358
+ fail(`unknown command "${command}".`, 'Run `lobstack help`.');
359
+ }
360
+ } catch (err) {
361
+ // `hint` is how gateway.mjs carries the second line of an error message out
362
+ // of a throw. Without it a redirect would report "the gateway redirected"
363
+ // and lose the sentence explaining that a redirect strips your key.
364
+ fail(err instanceof Error ? err.message : String(err), err?.hint);
365
+ }
package/src/proxy.mjs ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A local OpenAI-compatible endpoint that forwards to the Gateway.
3
+ *
4
+ * lobstack proxy # http://127.0.0.1:8787/v1
5
+ *
6
+ * This is the shortest path from "I have a tool that speaks OpenAI" to routing
7
+ * and metering: change one base URL in Cursor, Aider, Continue, or anything
8
+ * else with an OpenAI-compatible setting, and every call it makes goes through
9
+ * the router and lands in your Console. Nothing else about the tool changes,
10
+ * and it never sees your Lobstack key.
11
+ *
12
+ * Three deliberate choices:
13
+ *
14
+ * · It binds 127.0.0.1, not 0.0.0.0. This process holds a credential and
15
+ * answers unauthenticated requests, so anything that can reach the port can
16
+ * spend your money. Loopback keeps that to this machine.
17
+ * · It streams by piping the upstream body straight through. Buffering a
18
+ * stream to inspect it would make every token wait for the last one, which
19
+ * is the one thing an interactive tool cannot tolerate.
20
+ * · It prints a one-line receipt per request to stderr. The point of routing
21
+ * through here is knowing what it cost; a proxy that silently forwards is
22
+ * just a slower base URL.
23
+ */
24
+
25
+ import { createServer } from 'node:http';
26
+ import { gatewayUrl } from './config.mjs';
27
+ import { dim, green, money, savingsLabel } from './render.mjs';
28
+
29
+ const HOST = '127.0.0.1';
30
+
31
+ /** Read a request body without assuming it fits in one chunk. */
32
+ function readBody(req) {
33
+ return new Promise((resolve, reject) => {
34
+ const chunks = [];
35
+ req.on('data', (c) => chunks.push(c));
36
+ req.on('end', () => resolve(Buffer.concat(chunks)));
37
+ req.on('error', reject);
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Pull the receipt out of a passing stream without holding it up.
43
+ *
44
+ * The bytes are forwarded the instant they arrive; a copy of the tail is kept
45
+ * so the final frame can be parsed after the client already has everything.
46
+ */
47
+ function receiptFromTail(tail) {
48
+ const frames = tail.split('\n\n');
49
+ for (let i = frames.length - 1; i >= 0; i--) {
50
+ const line = frames[i].split('\n').find((l) => l.startsWith('data:'));
51
+ if (!line) continue;
52
+ const payload = line.slice(5).trim();
53
+ if (!payload || payload === '[DONE]') continue;
54
+ try {
55
+ const frame = JSON.parse(payload);
56
+ if (frame.x_lobstack || frame.usage) return { receipt: frame.x_lobstack, usage: frame.usage };
57
+ } catch {
58
+ /* keep looking backwards */
59
+ }
60
+ }
61
+ return {};
62
+ }
63
+
64
+ function logReceipt(started, { receipt, usage }) {
65
+ const ms = Date.now() - started;
66
+ const parts = [`${dim('model')} ${receipt?.served_model ?? '?'}`];
67
+ if (usage) parts.push(`${dim('tokens')} ${usage.prompt_tokens}/${usage.completion_tokens}`);
68
+ parts.push(`${dim('cost')} ${money(receipt?.cost_usd)}`);
69
+ // See render.mjs: a plan-ceiling baseline is not a like-for-like saving and
70
+ // must not be printed as one. One rule, one implementation.
71
+ const saving = savingsLabel(receipt);
72
+ if (saving) parts.push(`${dim(saving.label)} ${green(money(saving.amount))}`);
73
+ parts.push(`${dim('in')} ${ms}ms`);
74
+ process.stderr.write(dim('- ') + parts.join(dim(' - ')) + '\n');
75
+ }
76
+
77
+ /**
78
+ * @param {object} o
79
+ * @param {string} o.key the Lobstack credential this process holds
80
+ * @param {string} o.base the gateway origin
81
+ * @param {number} o.port loopback port to listen on
82
+ * @param {boolean} [o.quiet] return the server instead of printing a banner and
83
+ * blocking forever. The TUI runs the proxy inside
84
+ * itself and owns the screen, so it cannot have a
85
+ * second writer on stderr or a call that never
86
+ * returns.
87
+ * @param {(r:{receipt:object|undefined,usage:object|undefined,ms:number,path:string}) => void} [o.onReceipt]
88
+ * called per request instead of the stderr line.
89
+ */
90
+ export async function startProxy({ key, base, port, quiet = false, onReceipt }) {
91
+ const server = createServer(async (req, res) => {
92
+ const started = Date.now();
93
+
94
+ // Strip the caller's /v1 prefix; the Gateway lives under /api/gateway/v1.
95
+ // Anything else 404s here rather than being forwarded, so a mistyped path
96
+ // is a local error instead of a confusing one from upstream.
97
+ const path = (req.url || '').replace(/^\/v1/, '');
98
+ if (!path.startsWith('/')) {
99
+ res.writeHead(404, { 'content-type': 'application/json' });
100
+ res.end(JSON.stringify({ error: { message: 'this proxy serves the OpenAI paths under /v1' } }));
101
+ return;
102
+ }
103
+
104
+ try {
105
+ const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : await readBody(req);
106
+ const upstream = await fetch(gatewayUrl(base, path), {
107
+ method: req.method,
108
+ redirect: 'manual',
109
+ headers: {
110
+ Authorization: `Bearer ${key}`,
111
+ 'Content-Type': req.headers['content-type'] || 'application/json',
112
+ 'x-lobstack-client': 'lobstack-cli-proxy',
113
+ },
114
+ body,
115
+ });
116
+
117
+ const headers = {};
118
+ upstream.headers.forEach((v, k) => {
119
+ // Hop-by-hop headers describe the upstream connection, not this one.
120
+ if (!['connection', 'keep-alive', 'transfer-encoding', 'content-encoding'].includes(k)) {
121
+ headers[k] = v;
122
+ }
123
+ });
124
+ res.writeHead(upstream.status, headers);
125
+
126
+ if (!upstream.body) {
127
+ res.end();
128
+ return;
129
+ }
130
+
131
+ // Forward first, inspect after. Keep only the tail: a long conversation
132
+ // is megabytes, and the receipt is always in the last frame.
133
+ const reader = upstream.body.getReader();
134
+ const decoder = new TextDecoder();
135
+ let tail = '';
136
+ for (;;) {
137
+ const { done, value } = await reader.read();
138
+ if (done) break;
139
+ res.write(Buffer.from(value));
140
+ tail = (tail + decoder.decode(value, { stream: true })).slice(-4096);
141
+ }
142
+ res.end();
143
+ const parsed = receiptFromTail(tail);
144
+ if (onReceipt) onReceipt({ ...parsed, ms: Date.now() - started, path });
145
+ else logReceipt(started, parsed);
146
+ } catch (err) {
147
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json' });
148
+ res.end(
149
+ JSON.stringify({ error: { message: err instanceof Error ? err.message : 'proxy failure' } }),
150
+ );
151
+ }
152
+ });
153
+
154
+ // `listen` reports failure as an 'error' event, and an unhandled one on a
155
+ // server is an uncaught exception - which inside the TUI would tear down the
156
+ // screen over something as ordinary as a port already being in use.
157
+ await new Promise((resolve, reject) => {
158
+ server.once('error', (err) =>
159
+ reject(
160
+ new Error(
161
+ err.code === 'EADDRINUSE'
162
+ ? `port ${port} is already in use - pass a different --port.`
163
+ : `could not listen on ${HOST}:${port}: ${err.message}`,
164
+ ),
165
+ ),
166
+ );
167
+ server.listen(port, HOST, resolve);
168
+ });
169
+ if (quiet) return server;
170
+
171
+ process.stderr.write(
172
+ `\n${green('Listening')} on http://${HOST}:${port}/v1 ${dim('-> ' + base)}\n\n` +
173
+ `${dim('Point any OpenAI-compatible tool at it:')}\n` +
174
+ ` OPENAI_BASE_URL=http://${HOST}:${port}/v1\n` +
175
+ ` OPENAI_API_KEY=anything\n\n` +
176
+ `${dim('Your Lobstack key stays in this process. Loopback only - anything that')}\n` +
177
+ `${dim('can reach this port can spend on your account.')}\n\n`,
178
+ );
179
+
180
+ await new Promise(() => {}); // run until interrupted
181
+ }
package/src/render.mjs ADDED
@@ -0,0 +1,87 @@
1
+ /** Terminal output. Colour only when the stream is a TTY and NO_COLOR is unset. */
2
+ const ESC = '[';
3
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
4
+ const wrap = (code) => (s) => (useColor ? `${ESC}${code}m${s}${ESC}0m` : s);
5
+
6
+ export const dim = wrap('2');
7
+ export const bold = wrap('1');
8
+ export const red = wrap('31');
9
+ export const green = wrap('32');
10
+
11
+ export const money = (n) =>
12
+ typeof n !== 'number' || !Number.isFinite(n)
13
+ ? 'unpriced'
14
+ : n >= 0.01
15
+ ? `$${n.toFixed(4)}`
16
+ : `$${n.toFixed(6)}`;
17
+
18
+ /**
19
+ * Whether a saving may be called a saving, in one place.
20
+ *
21
+ * `baseline_reason` decides. "named" means the caller asked for a model and got
22
+ * something cheaper -- a like-for-like comparison, and the only case that may
23
+ * be labelled `saved`. "plan_ceiling" means they sent `auto` and the gateway
24
+ * measured against the most expensive model their plan allows, which is a real
25
+ * comparison but not one they asked for.
26
+ *
27
+ * This lives here rather than in each renderer because there are now three of
28
+ * them -- the single-shot receipt, the proxy's per-request line and the TUI --
29
+ * and three copies of a rule about overstating savings is three chances to
30
+ * drift apart on the one thing this product is arguing about.
31
+ *
32
+ * @returns {{label:string, named:boolean, amount:number}|null}
33
+ */
34
+ export function savingsLabel(receipt) {
35
+ const amount = receipt?.savings_usd;
36
+ if (typeof amount !== 'number' || !(amount > 0)) return null;
37
+ const named = receipt.baseline_reason === 'named';
38
+ return { label: named ? 'saved' : 'vs ceiling', named, amount };
39
+ }
40
+
41
+ /**
42
+ * Print the receipt.
43
+ *
44
+ * `cost_usd` is null, never zero, when the Gateway could not price the call.
45
+ * Rendering that null as $0.00 would write off a real charge — which is the
46
+ * whole reason the field is nullable — so "unpriced" is printed instead.
47
+ *
48
+ * It goes to stderr, so `lobstack chat "..." > out.txt` gives you the answer
49
+ * and nothing else while you still see what it cost.
50
+ */
51
+ export function printReceipt({ receipt, usage, model }) {
52
+ const served = receipt?.served_model || model || 'unknown';
53
+ const asked = receipt?.requested_model;
54
+ const parts = [];
55
+
56
+ parts.push(`${dim('model')} ${served}`);
57
+ if (asked && receipt?.routed) parts.push(`${dim('asked')} ${asked}`);
58
+ if (usage) parts.push(`${dim('tokens')} ${usage.prompt_tokens}/${usage.completion_tokens}`);
59
+ parts.push(`${dim('cost')} ${money(receipt?.cost_usd)}`);
60
+
61
+ // Printing a plan-ceiling comparison as a like-for-like saving is the
62
+ // overstatement the receipt exists to prevent. `savingsLabel` decides.
63
+ const saving = savingsLabel(receipt);
64
+ if (saving) parts.push(`${dim(saving.label)} ${green(money(saving.amount))}`);
65
+
66
+ process.stderr.write('\n' + dim('- ') + parts.join(dim(' - ')) + '\n');
67
+
68
+ if (receipt?.baseline_reason === 'plan_ceiling' && receipt.baseline_model) {
69
+ process.stderr.write(
70
+ dim(` measured against ${receipt.baseline_model}, the priciest model your plan allows - you sent auto, not that model\n`),
71
+ );
72
+ }
73
+ if (receipt && receipt.priced === false) {
74
+ process.stderr.write(dim(' the gateway could not price this model, so no cost is claimed\n'));
75
+ }
76
+ if (!receipt) {
77
+ // An older Gateway, or a non-Lobstack base URL. Say so rather than
78
+ // silently showing nothing where a price belongs.
79
+ process.stderr.write(dim(' no receipt on this response - the endpoint did not send one\n'));
80
+ }
81
+ }
82
+
83
+ export function fail(message, hint) {
84
+ process.stderr.write(`${red('error')} ${message}\n`);
85
+ if (hint) process.stderr.write(dim(` ${hint}\n`));
86
+ process.exit(1);
87
+ }