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/LICENSE +21 -0
- package/README.md +188 -0
- package/package.json +39 -0
- package/src/config.mjs +66 -0
- package/src/gateway.mjs +104 -0
- package/src/index.mjs +365 -0
- package/src/proxy.mjs +181 -0
- package/src/render.mjs +87 -0
- package/src/stream.mjs +70 -0
- package/src/tty.mjs +490 -0
- package/src/tui-view.mjs +621 -0
- package/src/tui.mjs +710 -0
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
|
+
}
|