lobstack 0.1.1 → 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
@@ -1,6 +1,7 @@
1
1
  # lobstack
2
2
 
3
- One key, every model, and what each call cost.
3
+ The Lobstack API from your terminal: one key, every model, and what each call
4
+ cost.
4
5
 
5
6
  ```bash
6
7
  npx lobstack init
@@ -60,7 +61,7 @@ worse than not having it.
60
61
  | | |
61
62
  |---|---|
62
63
  | `/model [name]` | set the model, or open the picker |
63
- | `/models` | what the gateway will serve, with prices |
64
+ | `/models` | what the API will serve, with prices. `auto` — Nex 1, the router — is first, priced `—` because it costs whatever it picks |
64
65
  | `/spend [days]` | what you have actually spent, from the usage API |
65
66
  | `/receipt` | every field of the last receipt, verbatim, plus the session tally |
66
67
  | `/proxy [port]` | serve the OpenAI-compatible endpoint from this process |
@@ -101,11 +102,11 @@ your shell reading mouse packets as keystrokes. Nothing here needs a mouse.
101
102
  |---|---|
102
103
  | `lobstack init` | Save a key to `~/.lobstack/config.json`, mode 0600. Verifies it before writing. |
103
104
  | `lobstack chat "<prompt>"` | One call, streamed. Answer to stdout, receipt to stderr, so `> out.txt` gives you the answer alone. |
104
- | `lobstack models` | What the gateway will serve, with prices. |
105
- | `lobstack spend [--days 7]` | What you have spent. Needs a key with the `usage:read` scope. |
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: 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. |
106
107
  | `lobstack proxy [--port 8787]` | A local OpenAI-compatible endpoint. |
107
108
 
108
- Flags: `--model` (default `auto`), `--key`, `--base`, `--json`, `--force`.
109
+ Flags: `--model` (default `auto`, which is **Nex 1**, the router), `--key`, `--base`, `--json`, `--force`.
109
110
  `LOBSTACK_API_KEY` and `LOBSTACK_BASE_URL` win over the saved config.
110
111
 
111
112
  ## The proxy
@@ -157,12 +158,12 @@ window title. Escapes are removed, not rendered.
157
158
 
158
159
  ## Cost is read, never computed
159
160
 
160
- The gateway puts the price on the final SSE frame under `x_lobstack`, because on
161
+ The Lobstack API puts the price on the final SSE frame under `x_lobstack`, because on
161
162
  a streamed response the headers are written before the provider has counted a
162
163
  token. This CLI reads that number. It does not multiply token counts by a
163
164
  bundled rate card — our own desktop client did exactly that, and printed
164
165
  `$0.00` for three months next to a correct invoice, because its copy of the
165
- rate card knew six models and the gateway serves far more.
166
+ rate card knew six models and the API serves several times that.
166
167
 
167
168
  That is also why the UI shows no running dollar figure *during* a stream. Until
168
169
  the last frame lands there is no price to show, so it shows elapsed time and how
@@ -170,13 +171,13 @@ much text arrived, and says the price is still coming.
170
171
 
171
172
  Three rules follow, and they hold on every screen:
172
173
 
173
- - `cost_usd` is `null`, never `0`, when the gateway could not price a call. That
174
+ - `cost_usd` is `null`, never `0`, when the API could not price a call. That
174
175
  prints `unpriced`. A zero renders as "free", and writing off a real charge is
175
176
  the most expensive way to be wrong about money. A session whose calls were all
176
177
  unpriced shows `unpriced` as its total, not `$0.000000`; a session with some of
177
178
  each shows the priced total and counts the rest out loud — `+1 unpriced`.
178
179
  - `saved` means the router beat a model **you named**. `vs ceiling` means you
179
- sent `auto` and the gateway measured against the priciest model your plan
180
+ sent `auto` and the API measured against the priciest model your plan
180
181
  allows. `baseline_reason` says which, the receipt says which, and the two are
181
182
  separate running totals that are never added together.
182
183
  - No figure is ever truncated to fit.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lobstack",
3
- "version": "0.1.1",
4
- "description": "The Lobstack Gateway from your terminal: one key, every model, and what each call cost.",
3
+ "version": "0.1.3",
4
+ "description": "The Lobstack API from your terminal: one key, every model, and what each call cost.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "lobstack": "./src/index.mjs"
@@ -20,7 +20,8 @@
20
20
  "anthropic",
21
21
  "proxy",
22
22
  "cli",
23
- "lobstack"
23
+ "lobstack",
24
+ "lobstack-api"
24
25
  ],
25
26
  "license": "MIT",
26
27
  "repository": {
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() {