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 +10 -9
- package/package.json +4 -3
- package/src/gateway.mjs +1 -1
- package/src/index.mjs +10 -15
- package/src/render.mjs +81 -1
- package/src/stream.mjs +1 -1
- package/src/tui-view.mjs +3 -3
- package/src/tui.mjs +12 -16
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# lobstack
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "The Lobstack
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
216
|
+
const r = spendReport(body, days);
|
|
217
217
|
process.stdout.write(
|
|
218
|
-
`${bold(
|
|
219
|
-
|
|
220
|
-
(
|
|
221
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
277
|
-
'
|
|
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
|
|
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
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
?
|
|
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() {
|