lobstack 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -103,7 +103,7 @@ your shell reading mouse packets as keystrokes. Nothing here needs a mouse.
103
103
  | `lobstack init` | Save a key to `~/.lobstack/config.json`, mode 0600. Verifies it before writing. |
104
104
  | `lobstack chat "<prompt>"` | One call, streamed. Answer to stdout, receipt to stderr, so `> out.txt` gives you the answer alone. |
105
105
  | `lobstack models` | What the API will serve, with prices. `auto` — Nex 1 — leads the list with no price of its own. Also public at <https://www.lobstack.ai/models>. |
106
- | `lobstack spend [--days 7]` | What you have spent. Needs a key with the `usage:read` scope. |
106
+ | `lobstack spend [--days 7]` | What you have spent: the billing ledger's total, the same figure the Console shows, with routing savings as two separate figures. Needs a key with the `usage:read` scope. |
107
107
  | `lobstack proxy [--port 8787]` | A local OpenAI-compatible endpoint. |
108
108
 
109
109
  Flags: `--model` (default `auto`, which is **Nex 1**, the router), `--key`, `--base`, `--json`, `--force`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobstack",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "The Lobstack API from your terminal: one key, every model, and what each call cost.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/gateway.mjs CHANGED
@@ -41,7 +41,7 @@ export async function gwFetch(url, key, init = {}) {
41
41
  // good key with "missing credentials" - the failure that made this look
42
42
  // broken for three months.
43
43
  throw new GatewayError(
44
- `the gateway redirected to ${res.headers.get('location') || 'somewhere else'}.`,
44
+ `the Lobstack API redirected to ${res.headers.get('location') || 'somewhere else'}.`,
45
45
  'A redirect strips your key. Point --base at the host that answers directly.',
46
46
  );
47
47
  }
package/src/index.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { readConfig, writeConfig, resolveKey, resolveBase, gatewayUrl, CONFIG_PATH } from './config.mjs';
25
- import { printReceipt, dim, bold, green, money, fail } from './render.mjs';
25
+ import { printReceipt, dim, bold, green, money, fail, spendReport } from './render.mjs';
26
26
  import { gwFetch, errorText, fetchModels, fetchUsage, streamCompletion } from './gateway.mjs';
27
27
  import { startProxy } from './proxy.mjs';
28
28
  import { detect } from './tty.mjs';
@@ -35,14 +35,14 @@ const HELP = `${bold('lobstack')} - one key, every model, and what each call cos
35
35
  ${bold('lobstack')} the interactive UI: watch the cost as it happens
36
36
  ${bold('lobstack init')} save an API key to ${CONFIG_PATH}
37
37
  ${bold('lobstack chat')} "<prompt>" one call, streamed, with the receipt
38
- ${bold('lobstack models')} what the gateway will serve, and at what price
38
+ ${bold('lobstack models')} what the Lobstack API will serve, and at what price
39
39
  ${bold('lobstack spend')} [--days 7] what you have spent, from the usage API
40
40
  ${bold('lobstack proxy')} [--port 8787] a local OpenAI-compatible endpoint
41
41
 
42
42
  ${dim('Options')}
43
43
  --model <key> default: auto (let the router choose)
44
44
  --key <lsk_...> override the saved key for one command
45
- --base <url> override the gateway host
45
+ --base <url> override the Lobstack API host
46
46
  --json machine-readable output where it makes sense
47
47
  --force draw the UI even where the streams do not look like a terminal
48
48
 
@@ -141,7 +141,7 @@ async function cmdInit(flags) {
141
141
  const b = base(flags);
142
142
  process.stdout.write(dim('checking the key...\n'));
143
143
  const res = await gwFetch(gatewayUrl(b, '/models'), key);
144
- if (!res.ok) fail(`the gateway rejected that key: ${await errorText(res)}`);
144
+ if (!res.ok) fail(`the Lobstack API rejected that key: ${await errorText(res)}`);
145
145
 
146
146
  writeConfig({ ...readConfig(), key, baseUrl: b });
147
147
  process.stdout.write(
@@ -213,18 +213,13 @@ async function cmdSpend(flags) {
213
213
  fail(body.message || 'usage reporting is not enabled on this deployment.');
214
214
  }
215
215
 
216
- const s = body.summary || {};
216
+ const r = spendReport(body, days);
217
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',
218
+ `${bold(r.head.charAt(0).toUpperCase() + r.head.slice(1))}\n\n` +
219
+ r.rows.map((row) => `${row}\n`).join('') +
220
+ (r.savings.length ? `\n${r.savings.map((l) => ` ${l.startsWith('saved') ? green(l) : l}\n`).join('')}` : '') +
221
+ (r.notes.length ? `\n${r.notes.map((l) => dim(` ${l}\n`)).join('')}` : ''),
222
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
223
  }
229
224
 
230
225
  /* ── tui ───────────────────────────────────────────────────────────────── */
@@ -359,7 +354,7 @@ try {
359
354
  }
360
355
  } catch (err) {
361
356
  // `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"
357
+ // of a throw. Without it a redirect would report "the Lobstack API redirected"
363
358
  // and lose the sentence explaining that a redirect strips your key.
364
359
  fail(err instanceof Error ? err.message : String(err), err?.hint);
365
360
  }
package/src/render.mjs CHANGED
@@ -38,6 +38,86 @@ export function savingsLabel(receipt) {
38
38
  return { label: named ? 'saved' : 'vs ceiling', named, amount };
39
39
  }
40
40
 
41
+ /**
42
+ * `/api/v1/usage`, as lines of text, for `lobstack spend` and the TUI's /spend.
43
+ *
44
+ * THE TOTAL IS THE CONSOLE'S. The endpoint returns two totals of one quantity:
45
+ * `spend.cost_usd`, from the billing ledger the Console's Spend shows and
46
+ * invoices are cut from, and `summary.cost_usd`, the request trace's own copy
47
+ * of each price, kept for older callers. They are written separately and can
48
+ * disagree. The ledger's figure is used, and each group's `ledger_cost_usd`;
49
+ * the trace's copy only when the ledger figure is null (it could not be read)
50
+ * or absent (an older deployment), and a note says so.
51
+ *
52
+ * SAVINGS ARE TWO FIGURES. `savings.named` is measured against models the
53
+ * caller asked for; `savings.plan_ceiling` is what `auto` requests would have
54
+ * cost on the priciest model the plan allows, which nobody asked for. They are
55
+ * printed on separate lines and never added together.
56
+ *
57
+ * Plain strings, no colour, so both renderers can use them.
58
+ *
59
+ * @returns {{head: string, rows: string[], savings: string[], notes: string[]}}
60
+ */
61
+ export function spendReport(body, days) {
62
+ const s = body?.summary || {};
63
+ const ledger = body?.spend && typeof body.spend === 'object' ? body.spend : null;
64
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
65
+ const fromLedger = ledger !== null && isNum(ledger.cost_usd);
66
+ const cost = fromLedger ? ledger.cost_usd : isNum(s.cost_usd) ? s.cost_usd : null;
67
+ const unpriced = fromLedger ? ledger.unpriced_rows ?? 0 : s.unpriced_requests ?? 0;
68
+ const priceable = fromLedger ? ledger.rows ?? 0 : s.requests ?? 0;
69
+ const costText = priceable > 0 && unpriced >= priceable ? 'unpriced' : money(cost);
70
+ const n = (count, one) => `${count} ${one}${count === 1 ? '' : 's'}`;
71
+
72
+ // The window the server actually summed. It answers an unknown range with
73
+ // 7d, so `--days 5` is labelled for what came back, not what was asked.
74
+ const served = typeof body?.range === 'string' ? body.range : `${days}d`;
75
+ const label = served === 'month' ? 'this month' : /^\d+d$/.test(served) ? `last ${parseInt(served, 10)} days` : `last ${days} days`;
76
+ const head = `${label}: ${n(s.requests ?? 0, 'request')}, ${costText}`;
77
+ const rows = (body?.groups ?? []).map((g) => {
78
+ const gCost = isNum(g.ledger_cost_usd) ? g.ledger_cost_usd : isNum(g.cost_usd) ? g.cost_usd : null;
79
+ const gUnpriced = isNum(g.ledger_cost_usd) ? g.ledger_unpriced_rows ?? 0 : g.unpriced_requests ?? 0;
80
+ return (
81
+ ` ${String(g.key ?? '').padEnd(24)} ${String(g.requests ?? 0).padStart(6)} ${money(gCost)}` +
82
+ (gUnpriced ? ` (${gUnpriced} unpriced)` : '')
83
+ );
84
+ });
85
+
86
+ const savings = [];
87
+ const named = body?.savings?.named;
88
+ const ceiling = body?.savings?.plan_ceiling;
89
+ if (named && named.requests > 0 && isNum(named.difference_usd)) {
90
+ savings.push(
91
+ named.difference_usd < 0
92
+ ? `routing cost ${money(-named.difference_usd)} more than the models you named, on ${n(named.requests, 'request')}`
93
+ : `saved ${money(named.difference_usd)} on models you named, on ${n(named.requests, 'request')}`,
94
+ );
95
+ }
96
+ if (ceiling && ceiling.requests > 0 && isNum(ceiling.difference_usd)) {
97
+ savings.push(
98
+ `vs ceiling ${money(ceiling.difference_usd)} on ${n(ceiling.requests, 'auto request')} - ` +
99
+ 'compared with the best model your plan allows, which you did not ask for; not a saving',
100
+ );
101
+ }
102
+
103
+ const notes = [];
104
+ if (!fromLedger) {
105
+ notes.push(
106
+ (ledger === null && body && 'spend' in body
107
+ ? 'the billing ledger could not be read'
108
+ : 'this deployment does not report the billing ledger') +
109
+ ", so this total is the request trace's copy of each price (the legacy summary.cost_usd) and can differ from the Console",
110
+ );
111
+ }
112
+ if (unpriced > 0) {
113
+ notes.push(`${n(unpriced, fromLedger ? 'row' : 'request')} could not be priced, so the total is a floor, not a total`);
114
+ }
115
+ if (body?.truncated || (fromLedger && ledger.truncated)) {
116
+ notes.push('the row cap bound on this range, so older requests are not counted');
117
+ }
118
+ return { head, rows, savings, notes };
119
+ }
120
+
41
121
  /**
42
122
  * Print the receipt.
43
123
  *
@@ -71,7 +151,7 @@ export function printReceipt({ receipt, usage, model }) {
71
151
  );
72
152
  }
73
153
  if (receipt && receipt.priced === false) {
74
- process.stderr.write(dim(' the gateway could not price this model, so no cost is claimed\n'));
154
+ process.stderr.write(dim(' the Lobstack API could not price this model, so no cost is claimed\n'));
75
155
  }
76
156
  if (!receipt) {
77
157
  // An older Gateway, or a non-Lobstack base URL. Say so rather than
package/src/stream.mjs CHANGED
@@ -54,7 +54,7 @@ export async function consume(body, onText) {
54
54
  continue; // a half-frame is not worth ending a turn over
55
55
  }
56
56
  if (frame.error) {
57
- throw new Error(frame.error.message || 'the gateway reported an error mid-stream');
57
+ throw new Error(frame.error.message || 'the Lobstack API reported an error mid-stream');
58
58
  }
59
59
  if (frame.model) model = frame.model;
60
60
  if (frame.usage) usage = frame.usage;
package/src/tui-view.mjs CHANGED
@@ -273,8 +273,8 @@ function caveatRow(r, saving, w) {
273
273
  }
274
274
  if (r && r.priced === false) {
275
275
  return pick([
276
- ' the gateway could not price this model, so no cost is claimed',
277
- ' gateway could not price this model',
276
+ ' the Lobstack API could not price this model, so no cost is claimed',
277
+ ' the API could not price this model',
278
278
  ]);
279
279
  }
280
280
  if (!r) {
@@ -362,7 +362,7 @@ const KEYS = [
362
362
 
363
363
  const COMMANDS = [
364
364
  ['/model [name]', 'set the model, or open the picker'],
365
- ['/models', 'what the gateway will serve, with prices'],
365
+ ['/models', 'what the Lobstack API will serve, with prices'],
366
366
  ['/spend [days]', 'what you have actually spent, from the usage API'],
367
367
  ['/receipt', 'every field of the last receipt, verbatim'],
368
368
  ['/proxy [port]', 'serve the OpenAI-compatible endpoint here, and watch it bill'],
package/src/tui.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { createInterface } from 'node:readline/promises';
21
21
  import { streamCompletion, fetchModels, fetchUsage, GatewayError } from './gateway.mjs';
22
- import { printReceipt, money, savingsLabel } from './render.mjs';
22
+ import { printReceipt, money, savingsLabel, spendReport } from './render.mjs';
23
23
  import { startProxy } from './proxy.mjs';
24
24
  import { Terminal, detect, styler, MIN_WIDTH } from './tty.mjs';
25
25
  import { frame, newSession, accrue } from './tui-view.mjs';
@@ -55,7 +55,7 @@ class Tui {
55
55
  {
56
56
  role: 'system',
57
57
  text:
58
- 'Every answer here comes back with a receipt: what the gateway served, what it ' +
58
+ 'Every answer here comes back with a receipt: what the Lobstack API served, what it ' +
59
59
  'cost, and what the routing decision saved. Type a prompt, or /help.',
60
60
  },
61
61
  ],
@@ -494,21 +494,17 @@ class Tui {
494
494
  this.say('error', body.message || 'usage reporting is not enabled on this deployment.');
495
495
  return;
496
496
  }
497
- const sum = body.summary || {};
498
- // Same rule as everywhere else: a saving the gateway measured against a
499
- // plan ceiling is not the same claim as one against a model you named. The
500
- // usage API reports a single figure, so it is labelled for what it is
501
- // rather than being called a saving outright.
502
- const head =
503
- `last ${days} days: ${sum.requests ?? 0} requests, ${money(Number(sum.cost_usd ?? 0))}` +
504
- (Number(sum.savings_usd ?? 0) > 0
505
- ? `, routing saved ${money(Number(sum.savings_usd))} against the baselines the gateway recorded`
506
- : '');
507
- const rows = (body.groups ?? []).map(
508
- (g) =>
509
- ` ${String(g.key ?? '').padEnd(24)} ${String(g.requests ?? 0).padStart(6)} ${money(Number(g.cost_usd ?? 0))}`,
497
+ // The Console's figure, and the two savings figures kept apart. See spendReport.
498
+ const r = spendReport(body, days);
499
+ this.say(
500
+ 'system',
501
+ [
502
+ r.head,
503
+ ...r.rows,
504
+ ...(r.savings.length ? ['', ...r.savings.map((l) => ` ${l}`)] : []),
505
+ ...(r.notes.length ? ['', ...r.notes.map((l) => ` ${l}`)] : []),
506
+ ].join('\n'),
510
507
  );
511
- this.say('system', [head, ...rows].join('\n'));
512
508
  }
513
509
 
514
510
  showReceipt() {