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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lobstack
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# lobstack
|
|
2
|
+
|
|
3
|
+
One key, every model, and what each call cost.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx lobstack init
|
|
7
|
+
npx lobstack # the UI
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
lobstack auto 2 calls $0.002200 saved $0.008800
|
|
12
|
+
───────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
you explain a b-tree
|
|
14
|
+
|
|
15
|
+
lob A B-tree keeps sorted data in a shallow, wide tree, so a lookup touches
|
|
16
|
+
very few nodes even when the table is enormous. Each node holds many keys
|
|
17
|
+
and many child pointers, which is what keeps the height down to three or
|
|
18
|
+
four levels for tables with billions of rows.
|
|
19
|
+
───────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
model claude-haiku-4-5 asked claude-opus-5
|
|
21
|
+
tokens 400/140 cost $0.001100 saved $0.004400 in 123ms
|
|
22
|
+
against claude-opus-5, the model you named
|
|
23
|
+
›
|
|
24
|
+
^C quit Tab model PgUp scroll ^L redraw /help for the rest
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The bottom two panes are the reason this exists. A chat window on its own is
|
|
28
|
+
worth nothing — every tool has one. What you cannot get anywhere else is the
|
|
29
|
+
price of the call you just made, pinned under the conversation, and a running
|
|
30
|
+
total in the corner that moves while you work.
|
|
31
|
+
|
|
32
|
+
## The UI
|
|
33
|
+
|
|
34
|
+
`lobstack` with no arguments opens it, when — and only when — there is a
|
|
35
|
+
terminal on both ends and a key already available. In a pipeline, in CI, or on a
|
|
36
|
+
first run before `init`, bare `lobstack` prints the same help it always printed,
|
|
37
|
+
which is also the screen that tells you to run `init`. `lobstack tui` asks for it
|
|
38
|
+
by name and is the stable spelling for a script or a shortcut.
|
|
39
|
+
|
|
40
|
+
### Keys
|
|
41
|
+
|
|
42
|
+
| | |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `Enter` | send |
|
|
45
|
+
| `\` at end of line | keep typing on a new line. `Alt+Enter` too, where the terminal sends it |
|
|
46
|
+
| `Ctrl+C` | cancel a streaming answer; quit when nothing is streaming |
|
|
47
|
+
| `Ctrl+D` | quit on an empty line |
|
|
48
|
+
| `Tab` | complete a slash command, or open the model picker on an empty line |
|
|
49
|
+
| `Up` / `Down` | walk back through what you sent |
|
|
50
|
+
| `PgUp` / `PgDn` | scroll the transcript. `Esc` returns to the live tail |
|
|
51
|
+
| `Ctrl+L` | repaint, for when something else wrote over the screen |
|
|
52
|
+
| `Ctrl+A` `Ctrl+E` `Ctrl+U` `Ctrl+K` `Ctrl+W` | the readline edits you already know |
|
|
53
|
+
|
|
54
|
+
Shift+Enter is not bound: most terminals send nothing a program can tell apart
|
|
55
|
+
from a plain Enter, and promising a key that silently does the wrong thing is
|
|
56
|
+
worse than not having it.
|
|
57
|
+
|
|
58
|
+
### Commands
|
|
59
|
+
|
|
60
|
+
| | |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `/model [name]` | set the model, or open the picker |
|
|
63
|
+
| `/models` | what the gateway will serve, with prices |
|
|
64
|
+
| `/spend [days]` | what you have actually spent, from the usage API |
|
|
65
|
+
| `/receipt` | every field of the last receipt, verbatim, plus the session tally |
|
|
66
|
+
| `/proxy [port]` | serve the OpenAI-compatible endpoint from this process |
|
|
67
|
+
| `/new` | forget the conversation, keep the session totals |
|
|
68
|
+
| `/clear` | clear the screen, keep the conversation |
|
|
69
|
+
| `/help` `/quit` | |
|
|
70
|
+
|
|
71
|
+
`/proxy` is the one worth knowing about. Point Cursor or Aider at the port it
|
|
72
|
+
prints and every call those tools make appears in this transcript with its
|
|
73
|
+
price, in the same running total as what you type by hand.
|
|
74
|
+
|
|
75
|
+
## Every terminal, and every way out of one
|
|
76
|
+
|
|
77
|
+
The UI is the interesting case; the boring ones are where a TUI usually breaks.
|
|
78
|
+
|
|
79
|
+
| | |
|
|
80
|
+
|---|---|
|
|
81
|
+
| Piped or redirected | Never draws. `echo "hi" \| lobstack > out.txt` treats stdin as the prompt and puts the answer, and only the answer, in the file. |
|
|
82
|
+
| Keyboard in, file out | `lobstack tui > log.txt` runs a plain prompt loop: answers on stdout, prompts and receipts on stderr, so the file stays clean. |
|
|
83
|
+
| `TERM=dumb` | A dumb terminal has no cursor addressing, so a full-screen frame is not a degraded experience, it is garbage. Same plain loop, and it says why. |
|
|
84
|
+
| `NO_COLOR` | No escapes at all. The colour depth is a number — 0, 16, 256, or truecolour — and the frame is assembled the same way at every one of them. |
|
|
85
|
+
| No UTF-8 locale | Box drawing falls back to `-` and `>`. Force it with `LOBSTACK_ASCII=1`. |
|
|
86
|
+
| Resize | `SIGWINCH` drops the diff baseline and repaints whole, because every row's content depends on the width. |
|
|
87
|
+
| Narrow | 40 columns is the width it aims at. Below that the layout stacks instead of tabulating, and a figure is never truncated — `$0.004400` clipped to `$0.004` is not a shorter number, it is a wrong one, so labels and then whole fields drop first. |
|
|
88
|
+
| Short | Rows go to the receipt before the transcript. You can scroll back for history; you cannot scroll back for a price you never saw. |
|
|
89
|
+
| Ctrl+C, SIGTERM, SIGHUP, a crash | The terminal comes back. Every exit path — including `process.exit` from anywhere and an uncaught throw — runs the same restore, and a crash still prints its stack. |
|
|
90
|
+
|
|
91
|
+
`--force` draws the UI where `isTTY` says there is no terminal but a person is
|
|
92
|
+
watching anyway: a wrapper, `docker run` without `-t`, an unusual runner.
|
|
93
|
+
|
|
94
|
+
Mouse reporting is never switched on. It breaks click-to-select in JetBrains and
|
|
95
|
+
in some tmux configurations, and a process that dies before disabling it leaves
|
|
96
|
+
your shell reading mouse packets as keystrokes. Nothing here needs a mouse.
|
|
97
|
+
|
|
98
|
+
## Commands outside the UI
|
|
99
|
+
|
|
100
|
+
| | |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `lobstack init` | Save a key to `~/.lobstack/config.json`, mode 0600. Verifies it before writing. |
|
|
103
|
+
| `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. |
|
|
106
|
+
| `lobstack proxy [--port 8787]` | A local OpenAI-compatible endpoint. |
|
|
107
|
+
|
|
108
|
+
Flags: `--model` (default `auto`), `--key`, `--base`, `--json`, `--force`.
|
|
109
|
+
`LOBSTACK_API_KEY` and `LOBSTACK_BASE_URL` win over the saved config.
|
|
110
|
+
|
|
111
|
+
## The proxy
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
lobstack proxy
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
Listening on http://127.0.0.1:8787/v1 -> https://www.lobstack.ai
|
|
119
|
+
|
|
120
|
+
Point any OpenAI-compatible tool at it:
|
|
121
|
+
OPENAI_BASE_URL=http://127.0.0.1:8787/v1
|
|
122
|
+
OPENAI_API_KEY=anything
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Change one base URL in Cursor, Aider, Continue, or anything else with an
|
|
126
|
+
OpenAI-compatible setting, and every call it makes goes through the router and
|
|
127
|
+
lands in your Console — with a line per request telling you what it cost. The
|
|
128
|
+
tool never sees your Lobstack key; this process holds it.
|
|
129
|
+
|
|
130
|
+
It binds loopback only. This is a process that holds a credential and answers
|
|
131
|
+
unauthenticated requests, so anything that can reach the port can spend your
|
|
132
|
+
money.
|
|
133
|
+
|
|
134
|
+
## Notes on three things that look like details
|
|
135
|
+
|
|
136
|
+
**Zero dependencies, including the UI.** `fetch`, `node:http` and
|
|
137
|
+
`node:readline` are all in the runtime, so `npx lobstack` starts immediately
|
|
138
|
+
rather than resolving a tree first — and there is no supply chain between your
|
|
139
|
+
key and us. That did not change to add a full-screen interface: `node:readline`
|
|
140
|
+
already has the escape-sequence decoder, and the rest is nine escape sequences
|
|
141
|
+
written out by hand in `src/tty.mjs`, with a comment on each about why the
|
|
142
|
+
conservative one was chosen over the clever one. A process holding an
|
|
143
|
+
`lsk_live_` credential does not get to pull a dependency tree to draw a box.
|
|
144
|
+
|
|
145
|
+
**The bare domain is corrected, out loud.** `lobstack.ai` redirects to
|
|
146
|
+
`www.lobstack.ai`, and [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-redirection-3xx)
|
|
147
|
+
requires every HTTP client to drop `Authorization` when a redirect changes host.
|
|
148
|
+
Point `--base` at the apex and you would get "missing credentials" while holding
|
|
149
|
+
a perfectly good key. So the CLI rewrites it and prints a line saying it did,
|
|
150
|
+
because a silent fix teaches you nothing about why your own code will fail the
|
|
151
|
+
same way.
|
|
152
|
+
|
|
153
|
+
**Text from a model is sanitised before it is drawn.** A completion is
|
|
154
|
+
attacker-influenced text about to be pasted into a terminal. Left alone, an
|
|
155
|
+
`ESC[2J` inside an answer wipes the frame and an OSC sequence rewrites your
|
|
156
|
+
window title. Escapes are removed, not rendered.
|
|
157
|
+
|
|
158
|
+
## Cost is read, never computed
|
|
159
|
+
|
|
160
|
+
The gateway puts the price on the final SSE frame under `x_lobstack`, because on
|
|
161
|
+
a streamed response the headers are written before the provider has counted a
|
|
162
|
+
token. This CLI reads that number. It does not multiply token counts by a
|
|
163
|
+
bundled rate card — our own desktop client did exactly that, and printed
|
|
164
|
+
`$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
|
+
|
|
167
|
+
That is also why the UI shows no running dollar figure *during* a stream. Until
|
|
168
|
+
the last frame lands there is no price to show, so it shows elapsed time and how
|
|
169
|
+
much text arrived, and says the price is still coming.
|
|
170
|
+
|
|
171
|
+
Three rules follow, and they hold on every screen:
|
|
172
|
+
|
|
173
|
+
- `cost_usd` is `null`, never `0`, when the gateway could not price a call. That
|
|
174
|
+
prints `unpriced`. A zero renders as "free", and writing off a real charge is
|
|
175
|
+
the most expensive way to be wrong about money. A session whose calls were all
|
|
176
|
+
unpriced shows `unpriced` as its total, not `$0.000000`; a session with some of
|
|
177
|
+
each shows the priced total and counts the rest out loud — `+1 unpriced`.
|
|
178
|
+
- `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
|
+
allows. `baseline_reason` says which, the receipt says which, and the two are
|
|
181
|
+
separate running totals that are never added together.
|
|
182
|
+
- No figure is ever truncated to fit.
|
|
183
|
+
|
|
184
|
+
## Get a key
|
|
185
|
+
|
|
186
|
+
<https://www.lobstack.ai/start> — free, no card, about a minute.
|
|
187
|
+
|
|
188
|
+
MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lobstack": "./src/index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"llm",
|
|
18
|
+
"gateway",
|
|
19
|
+
"openai",
|
|
20
|
+
"anthropic",
|
|
21
|
+
"proxy",
|
|
22
|
+
"cli",
|
|
23
|
+
"lobstack"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/Lobstack-ai/lobstack-cli.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://www.lobstack.ai/docs/cli",
|
|
31
|
+
"author": "Lobstack",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/Lobstack-ai/lobstack-cli/issues"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test test/*.test.mjs",
|
|
37
|
+
"prepublishOnly": "node --test test/*.test.mjs"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the key lives, and why it lives there.
|
|
3
|
+
*
|
|
4
|
+
* `~/.lobstack/config.json`, mode 0600, created with mode 0700 on the
|
|
5
|
+
* directory. Not an environment variable in a dotfile the user has to remember
|
|
6
|
+
* to gitignore, and not a keychain — a keychain would be better and it would
|
|
7
|
+
* also mean a native dependency, which would mean `npx lobstack` stops being
|
|
8
|
+
* instant. LOBSTACK_API_KEY still wins when set, because CI has no home
|
|
9
|
+
* directory worth writing to.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const CONFIG_DIR = join(homedir(), '.lobstack');
|
|
16
|
+
export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
17
|
+
|
|
18
|
+
/** The host that answers without a redirect. See the note in `resolveBase`. */
|
|
19
|
+
export const DEFAULT_BASE = 'https://www.lobstack.ai';
|
|
20
|
+
|
|
21
|
+
export function readConfig() {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeConfig(next) {
|
|
30
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
31
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
32
|
+
// mkdir's mode is masked by umask, and writeFile's mode is ignored when the
|
|
33
|
+
// file already exists. Both are silent, and both leave a credential
|
|
34
|
+
// world-readable, so set them again explicitly.
|
|
35
|
+
try {
|
|
36
|
+
chmodSync(CONFIG_DIR, 0o700);
|
|
37
|
+
chmodSync(CONFIG_PATH, 0o600);
|
|
38
|
+
} catch {
|
|
39
|
+
/* Windows has no POSIX modes; the ACL default is per-user already */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveKey() {
|
|
44
|
+
return process.env.LOBSTACK_API_KEY || readConfig().key || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalise a base URL, and refuse the one that silently breaks auth.
|
|
49
|
+
*
|
|
50
|
+
* `lobstack.ai` 307s to `www.lobstack.ai`, and RFC 9110 requires a client to
|
|
51
|
+
* drop `Authorization` across a host change. A user who types the bare apex
|
|
52
|
+
* here gets "missing credentials" while holding a perfectly good key — which is
|
|
53
|
+
* exactly the failure that made the Gateway look broken for three months. So it
|
|
54
|
+
* is corrected, out loud, rather than honoured.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveBase(explicit) {
|
|
57
|
+
const raw = explicit || process.env.LOBSTACK_BASE_URL || readConfig().baseUrl || DEFAULT_BASE;
|
|
58
|
+
const url = new URL(raw);
|
|
59
|
+
if (url.hostname === 'lobstack.ai') {
|
|
60
|
+
url.hostname = 'www.lobstack.ai';
|
|
61
|
+
return { base: url.origin, corrected: true };
|
|
62
|
+
}
|
|
63
|
+
return { base: url.origin.replace(/\/+$/, ''), corrected: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const gatewayUrl = (base, path) => `${base}/api/gateway/v1${path}`;
|
package/src/gateway.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talking to the Gateway, in one place.
|
|
3
|
+
*
|
|
4
|
+
* These four calls used to live inline in `index.mjs`, which was fine while
|
|
5
|
+
* there was one caller per command. The TUI needs the same requests, the same
|
|
6
|
+
* refusal to follow a redirect and the same error text, and two copies of
|
|
7
|
+
* "never follow a redirect with a credential attached" is one copy too many.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here calls `process.exit`. A failure throws a `GatewayError` that
|
|
10
|
+
* carries the hint alongside the message, so the one-shot commands can print
|
|
11
|
+
* it and die exactly as they did before while the TUI puts the same words in
|
|
12
|
+
* the transcript and stays up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { gatewayUrl } from './config.mjs';
|
|
16
|
+
import { consume } from './stream.mjs';
|
|
17
|
+
|
|
18
|
+
export class GatewayError extends Error {
|
|
19
|
+
constructor(message, hint) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'GatewayError';
|
|
22
|
+
this.hint = hint;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function gwFetch(url, key, init = {}) {
|
|
27
|
+
const { client, headers, ...rest } = init;
|
|
28
|
+
const res = await fetch(url, {
|
|
29
|
+
...rest,
|
|
30
|
+
redirect: 'manual',
|
|
31
|
+
headers: {
|
|
32
|
+
Authorization: `Bearer ${key}`,
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
'x-lobstack-client': client || 'lobstack-cli',
|
|
35
|
+
...(headers || {}),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
if (res.status >= 300 && res.status < 400) {
|
|
39
|
+
// Not followed, and not quietly. A redirect that changes host makes every
|
|
40
|
+
// HTTP client drop Authorization, so the gateway would answer a perfectly
|
|
41
|
+
// good key with "missing credentials" - the failure that made this look
|
|
42
|
+
// broken for three months.
|
|
43
|
+
throw new GatewayError(
|
|
44
|
+
`the gateway redirected to ${res.headers.get('location') || 'somewhere else'}.`,
|
|
45
|
+
'A redirect strips your key. Point --base at the host that answers directly.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return res;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function errorText(res) {
|
|
52
|
+
const id = res.headers.get('x-lobstack-request-id');
|
|
53
|
+
let msg = `${res.status}`;
|
|
54
|
+
try {
|
|
55
|
+
const body = await res.json();
|
|
56
|
+
msg = body?.error?.message || body?.error || msg;
|
|
57
|
+
} catch {
|
|
58
|
+
/* not JSON */
|
|
59
|
+
}
|
|
60
|
+
return id ? `${msg} (request ${id})` : msg;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `/models`, as the array the gateway sends. */
|
|
64
|
+
export async function fetchModels(base, key, init) {
|
|
65
|
+
const res = await gwFetch(gatewayUrl(base, '/models'), key, init);
|
|
66
|
+
if (!res.ok) throw new GatewayError(await errorText(res));
|
|
67
|
+
return (await res.json()).data ?? [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `/api/v1/usage`, grouped by model. */
|
|
71
|
+
export async function fetchUsage(base, key, days, init) {
|
|
72
|
+
const res = await gwFetch(`${base}/api/v1/usage?range=${days}d&group_by=model`, key, init);
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw new GatewayError(
|
|
75
|
+
await errorText(res),
|
|
76
|
+
res.status === 403
|
|
77
|
+
? 'This key needs the "usage:read" scope. Mint one in Console > API keys.'
|
|
78
|
+
: undefined,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return res.json();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One streamed completion, and the receipt off its last frame.
|
|
86
|
+
*
|
|
87
|
+
* `include_usage` is not optional: without it the gateway has no frame to
|
|
88
|
+
* attach `x_lobstack` to, and the price never arrives.
|
|
89
|
+
*/
|
|
90
|
+
export async function streamCompletion(base, key, { model, messages, signal, onText, client }) {
|
|
91
|
+
const res = await gwFetch(gatewayUrl(base, '/chat/completions'), key, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
signal,
|
|
94
|
+
client,
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
model,
|
|
97
|
+
messages,
|
|
98
|
+
stream: true,
|
|
99
|
+
stream_options: { include_usage: true },
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok || !res.body) throw new GatewayError(await errorText(res));
|
|
103
|
+
return consume(res.body, onText);
|
|
104
|
+
}
|