howtofixcli 0.7.8 → 0.7.9
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/dist/chunk-27TRKD65.js +2 -0
- package/dist/chunk-3FYCFLNK.js +1 -0
- package/dist/chunk-56SEAGAQ.js +1 -0
- package/dist/chunk-63ZCQDXH.js +6 -0
- package/dist/chunk-6FUWMLWO.js +1 -0
- package/dist/chunk-7CHDNTH6.js +1 -0
- package/dist/chunk-ACOMNDUL.js +185 -0
- package/dist/chunk-AEVPET7X.js +2 -0
- package/dist/chunk-AGGIYXN7.js +2 -0
- package/dist/chunk-BHVBO4SC.js +1 -0
- package/dist/chunk-FPXVDP32.js +8 -0
- package/dist/chunk-GIUYIKH3.js +109 -0
- package/dist/chunk-GQCAJ56I.js +5 -0
- package/dist/chunk-GXB7RZHH.js +1 -0
- package/dist/chunk-IGEY3AWF.js +1 -0
- package/dist/chunk-ILDDDLRC.js +1 -0
- package/dist/chunk-JFYVRWJI.js +2 -0
- package/dist/chunk-MVLCGU3G.js +1 -0
- package/dist/chunk-N345KWWK.js +1 -0
- package/dist/chunk-NBQFPCRT.js +1 -0
- package/dist/chunk-OIPSLIZE.js +5 -0
- package/dist/chunk-ORJ2QZBG.js +3 -0
- package/dist/chunk-PCALBZOO.js +2 -0
- package/dist/chunk-PDNBGUYP.js +2 -0
- package/dist/chunk-S656RE4A.js +1 -0
- package/dist/chunk-TSEKPGK4.js +1 -0
- package/dist/chunk-U6CI6KXN.js +34 -0
- package/dist/chunk-UTYFLLSM.js +1 -0
- package/dist/chunk-V3R4FYTJ.js +1 -0
- package/dist/chunk-V656XVXV.js +8 -0
- package/dist/chunk-WKUJ7CFO.js +1 -0
- package/dist/chunk-WOT6VMZA.js +1 -0
- package/dist/chunk-WUPU6LTD.js +4 -0
- package/dist/chunk-WX3V5P7U.js +5 -0
- package/dist/chunk-ZKZ6WCI3.js +2 -0
- package/dist/main.js +94 -536
- package/package.json +15 -62
- package/dist/account-credential.js +0 -136
- package/dist/account-credential.test.js +0 -38
- package/dist/activity.js +0 -124
- package/dist/agent-defs.js +0 -237
- package/dist/agent-limiter.js +0 -68
- package/dist/agents.js +0 -554
- package/dist/anim.js +0 -361
- package/dist/args.js +0 -228
- package/dist/at-file.js +0 -28
- package/dist/brand-extension.js +0 -87
- package/dist/brand.js +0 -76
- package/dist/browser.js +0 -58
- package/dist/cache-report.js +0 -139
- package/dist/checkpoint.js +0 -437
- package/dist/claims.js +0 -167
- package/dist/cli-auth.js +0 -142
- package/dist/cli-checkin.js +0 -120
- package/dist/cli-credentials.js +0 -254
- package/dist/clipboard.js +0 -149
- package/dist/collapse.js +0 -129
- package/dist/commands.js +0 -301
- package/dist/compaction-guard.js +0 -81
- package/dist/context-extension.js +0 -17
- package/dist/dcp/commands.js +0 -172
- package/dist/dcp/compress-state.js +0 -139
- package/dist/dcp/compress.js +0 -329
- package/dist/dcp/dcp-extension.js +0 -288
- package/dist/dcp/dedup.js +0 -93
- package/dist/dcp/ids.js +0 -189
- package/dist/dcp/index.js +0 -25
- package/dist/dcp/prompts.js +0 -68
- package/dist/dcp/protected-content.js +0 -221
- package/dist/dcp/protected.js +0 -161
- package/dist/dcp/prune.js +0 -418
- package/dist/dcp/state.js +0 -176
- package/dist/device-id.js +0 -90
- package/dist/device-id.test.js +0 -23
- package/dist/errors.js +0 -63
- package/dist/extensions.js +0 -48
- package/dist/fatal.js +0 -35
- package/dist/feed.js +0 -498
- package/dist/fs-atomic.js +0 -54
- package/dist/gateway-errors.js +0 -90
- package/dist/git.js +0 -230
- package/dist/goal-extension.js +0 -88
- package/dist/goal.js +0 -222
- package/dist/hooks-extension.js +0 -176
- package/dist/hooks.js +0 -443
- package/dist/howtofix-account.js +0 -715
- package/dist/hud-line.js +0 -153
- package/dist/hwid.js +0 -130
- package/dist/interrupt.js +0 -60
- package/dist/json-events.js +0 -50
- package/dist/level-style.js +0 -112
- package/dist/loop-guard.js +0 -95
- package/dist/lsp-tools.js +0 -32
- package/dist/lsp.js +0 -347
- package/dist/mcp-config.js +0 -85
- package/dist/mcp-lifecycle.js +0 -147
- package/dist/model-catalog.js +0 -217
- package/dist/model-resolve.js +0 -65
- package/dist/notes.js +0 -78
- package/dist/oauth-login.js +0 -568
- package/dist/paths.js +0 -61
- package/dist/permissions.js +0 -298
- package/dist/plan-mode.js +0 -363
- package/dist/preset-command.js +0 -227
- package/dist/preset-extension.js +0 -35
- package/dist/preset-mcp.js +0 -292
- package/dist/presets.js +0 -727
- package/dist/prompt-files.js +0 -103
- package/dist/prompt-text.js +0 -41
- package/dist/proxy.js +0 -137
- package/dist/quality/ast.js +0 -196
- package/dist/quality/cognitive.js +0 -83
- package/dist/quality/cyclomatic.js +0 -30
- package/dist/quality/halstead.js +0 -83
- package/dist/quality/index.js +0 -18
- package/dist/quality/length.js +0 -3
- package/dist/quality/maintainability.js +0 -15
- package/dist/quality/nesting.js +0 -57
- package/dist/quality/report.js +0 -803
- package/dist/quality/smells.js +0 -67
- package/dist/quality-tool.js +0 -63
- package/dist/read-only-commands.js +0 -597
- package/dist/repo-map.js +0 -292
- package/dist/rtk.js +0 -193
- package/dist/sandbox.js +0 -105
- package/dist/schema.js +0 -214
- package/dist/session-lifecycle.js +0 -19
- package/dist/session-retention.js +0 -111
- package/dist/session.js +0 -446
- package/dist/sessions.js +0 -100
- package/dist/settings.js +0 -148
- package/dist/skills.js +0 -35
- package/dist/slop-tool.js +0 -96
- package/dist/spend-cap.js +0 -43
- package/dist/subagent.js +0 -171
- package/dist/tar.js +0 -146
- package/dist/theme.js +0 -79
- package/dist/thinking.js +0 -45
- package/dist/todo.js +0 -114
- package/dist/tool-loadout.js +0 -97
- package/dist/tool-render.js +0 -769
- package/dist/tools.js +0 -176
- package/dist/ui.js +0 -4757
- package/dist/update.js +0 -533
- package/dist/usage-segment.js +0 -69
- package/dist/web-tools.js +0 -46
- package/dist/web.js +0 -265
- package/dist/welcome.js +0 -179
- package/dist/workflow-engine.js +0 -246
- package/dist/workflow-progress.js +0 -446
- package/dist/workflow-view.js +0 -195
- package/dist/workflow.js +0 -349
package/dist/howtofix-account.js
DELETED
|
@@ -1,715 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Connecting a howtofix.id account to the CLI.
|
|
3
|
-
*
|
|
4
|
-
* `howtofix.id` is the product's own gateway: one token, one base URL
|
|
5
|
-
* (`https://api.howtofix.id/v1`), OpenAI-compatible, and 50-odd models behind it
|
|
6
|
-
* (Claude, GPT, Gemini, Qwen, DeepSeek, ...). Two facts drive this module, both
|
|
7
|
-
* verified against the live service rather than assumed:
|
|
8
|
-
*
|
|
9
|
-
* 1. **There is no OAuth.** `/.well-known/openid-configuration` returns the SPA's
|
|
10
|
-
* HTML, `/api/device/code` and `/v1/oauth/device` are 404, and the SDK's only
|
|
11
|
-
* provider-level OAuth is `oauth: "radius"`. The documented flow is: sign up
|
|
12
|
-
* on howtofix.id, create a token in the Console, paste it. So "connect your
|
|
13
|
-
* account" means *token*, and this module makes that flow verify the token
|
|
14
|
-
* instead of storing it blind.
|
|
15
|
-
* 2. **`models.json` is a static file.** A provider entry lists the models the
|
|
16
|
-
* CLI may use, and the gateway exposes `GET /v1/models` (50 ids). Without a
|
|
17
|
-
* sync the user pays for one model and can reach one model, which is the whole
|
|
18
|
-
* point of the product wasted.
|
|
19
|
-
*
|
|
20
|
-
* The policy is pure and the network call is injected, so both the merge rules
|
|
21
|
-
* and the failure messages are testable without a network or a session.
|
|
22
|
-
*/
|
|
23
|
-
/** The gateway the product is built on. */
|
|
24
|
-
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
25
|
-
import { join } from "node:path";
|
|
26
|
-
import { writeFileAtomicSync } from "./fs-atomic.js";
|
|
27
|
-
import { CLI_CLIENT_HEADER, cliClientHeader } from "./cli-auth.js";
|
|
28
|
-
import { catalogCachePath, deserializeCatalog, fetchCatalog, lookupModel, normalizeModelId, serializeCatalog } from "./model-catalog.js";
|
|
29
|
-
export const HOWTOFIX_BASE_URL = "https://api.howtofix.id/v1";
|
|
30
|
-
/**
|
|
31
|
-
* Where a user creates a token.
|
|
32
|
-
*
|
|
33
|
-
* This is the dashboard's API-keys page. It used to point at
|
|
34
|
-
* `https://howtofix.id/console/token`, which is **not a route in the panel**:
|
|
35
|
-
* the dashboard is new-api's, its file-based route tree contains no `/console`
|
|
36
|
-
* entry at all, and the keys page is `/keys`. Signed out, the router's auth
|
|
37
|
-
* guard answers with the sign-in page for any unknown path, so the dead link
|
|
38
|
-
* looked fine from the outside — a user only found out after signing in.
|
|
39
|
-
*
|
|
40
|
-
* There is still no OAuth callback a local client can receive: the panel's
|
|
41
|
-
* callback completes into a *browser session*, and its redirect sanitizer
|
|
42
|
-
* refuses anything that is not the panel's own origin, so a loopback callback
|
|
43
|
-
* would need a change on the panel, not in this CLI. The token is the supported
|
|
44
|
-
* sign-in.
|
|
45
|
-
*/
|
|
46
|
-
export const HOWTOFIX_CONSOLE_URL = "https://howtofix.id/keys";
|
|
47
|
-
/** The provider ids in `models.json` that point at that gateway. */
|
|
48
|
-
export const HOWTOFIX_PROVIDERS = ["howtofix", "howtofix-free"];
|
|
49
|
-
/**
|
|
50
|
-
* The provider entry for a gateway account, created when the file has none.
|
|
51
|
-
*
|
|
52
|
-
* A fresh install has no `models.json` at all, so `/connect` used to verify the
|
|
53
|
-
* token, report "connected", and then store nothing — the first thing a new user
|
|
54
|
-
* does left them with zero models.
|
|
55
|
-
*
|
|
56
|
-
* There is deliberately **no `apiKey` field**. An empty-string `apiKey` makes the
|
|
57
|
-
* SDK drop the whole provider: measured against the SDK, `apiKey: ""` yields zero
|
|
58
|
-
* models while omitting the field, `"$SOME_VAR"`, or a literal key all yield the
|
|
59
|
-
* full list. The credential is supplied at runtime by `setRuntimeApiKey`, which is
|
|
60
|
-
* what `/connect` calls, so the entry must leave the field out rather than blank.
|
|
61
|
-
*/
|
|
62
|
-
export function newProviderEntry() {
|
|
63
|
-
return {
|
|
64
|
-
baseUrl: HOWTOFIX_BASE_URL,
|
|
65
|
-
api: "openai-completions",
|
|
66
|
-
// Names the CLI on every model call, so the gateway's usage log records the
|
|
67
|
-
// run as "CLI (0.4.92)" instead of the token's generic name.
|
|
68
|
-
headers: { [CLI_CLIENT_HEADER]: cliClientHeader() },
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* Repair the gateway's models in `models.json` in place, and say whether the file
|
|
73
|
-
* changed.
|
|
74
|
-
*
|
|
75
|
-
* The gateway refuses the `developer` role (`code 11128`, "Illegal API invocation
|
|
76
|
-
* from an unapproved channel") for every model that declares `reasoning: true`,
|
|
77
|
-
* and the SDK picks `developer` over `system` exactly for those models — so an
|
|
78
|
-
* entry without `compat.supportsDeveloperRole: false` fails every prompt. The
|
|
79
|
-
* symptom is the worst kind: the spinner turns, the context meter barely moves,
|
|
80
|
-
* and nothing is ever reported, because the failure happens before a response.
|
|
81
|
-
*
|
|
82
|
-
* `/connect` writes the flag for models it adds, but that only helps an account
|
|
83
|
-
* connected *after* the flag existed. Anyone who connected earlier keeps a
|
|
84
|
-
* `models.json` that is entirely broken, and no command they run repairs it. So
|
|
85
|
-
* the check runs at every start, over the howtofix entries only — another
|
|
86
|
-
* provider's compat is its own business.
|
|
87
|
-
*
|
|
88
|
-
* Returns the number of models repaired. Never throws: an unreadable or
|
|
89
|
-
* unwritable file must not stop the session, and the caller reports the count
|
|
90
|
-
* rather than failing.
|
|
91
|
-
*/
|
|
92
|
-
export function repairGatewayModels(agentDir) {
|
|
93
|
-
const path = join(agentDir, "models.json");
|
|
94
|
-
let parsed;
|
|
95
|
-
try {
|
|
96
|
-
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return 0;
|
|
100
|
-
}
|
|
101
|
-
const providers = parsed.providers;
|
|
102
|
-
if (!providers || typeof providers !== "object")
|
|
103
|
-
return 0;
|
|
104
|
-
let changed = 0;
|
|
105
|
-
for (const id of HOWTOFIX_PROVIDERS) {
|
|
106
|
-
const entry = providers[id];
|
|
107
|
-
if (!entry || !Array.isArray(entry.models))
|
|
108
|
-
continue;
|
|
109
|
-
const repaired = applyCompat(entry.models);
|
|
110
|
-
// A machine that connected before the client header existed still names
|
|
111
|
-
// itself on the next call: the entry is written here rather than only at
|
|
112
|
-
// /connect, so an update is enough and nobody has to reconnect.
|
|
113
|
-
const wanted = { ...(entry.headers ?? {}), [CLI_CLIENT_HEADER]: cliClientHeader() };
|
|
114
|
-
const headerMissing = entry.headers?.[CLI_CLIENT_HEADER] !== wanted[CLI_CLIENT_HEADER];
|
|
115
|
-
if (repaired.changed === 0 && !headerMissing)
|
|
116
|
-
continue;
|
|
117
|
-
entry.models = repaired.models;
|
|
118
|
-
entry.headers = wanted;
|
|
119
|
-
changed += repaired.changed === 0 ? 1 : repaired.changed;
|
|
120
|
-
}
|
|
121
|
-
if (changed === 0)
|
|
122
|
-
return 0;
|
|
123
|
-
try {
|
|
124
|
-
writeFileAtomicSync(path, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
return 0;
|
|
128
|
-
}
|
|
129
|
-
return changed;
|
|
130
|
-
}
|
|
131
|
-
/** How long a cached catalogue is trusted before it is refreshed. */
|
|
132
|
-
export const CATALOG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
133
|
-
/**
|
|
134
|
-
* Load the model catalogue, from the cache when it is fresh and from the network
|
|
135
|
-
* otherwise, and write the cache back.
|
|
136
|
-
*
|
|
137
|
-
* The cache is what makes this affordable on every start: the catalogue is ~5 MB
|
|
138
|
-
* and eight thousand models, and a start that waited for it would be a start the
|
|
139
|
-
* user notices. A stale or missing cache fetches; a fetch that fails falls back
|
|
140
|
-
* to whatever the cache holds, so a machine that is offline keeps the numbers it
|
|
141
|
-
* already learned rather than losing them.
|
|
142
|
-
*/
|
|
143
|
-
export async function loadCatalog(agentDir, options) {
|
|
144
|
-
const path = catalogCachePath(agentDir);
|
|
145
|
-
const now = options?.now ?? Date.now();
|
|
146
|
-
let cached;
|
|
147
|
-
let cachedAt = 0;
|
|
148
|
-
try {
|
|
149
|
-
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
150
|
-
if (typeof raw?.entries === "string") {
|
|
151
|
-
cached = deserializeCatalog(raw.entries);
|
|
152
|
-
cachedAt = Number(raw.at) || 0;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
cached = undefined;
|
|
157
|
-
}
|
|
158
|
-
const fresh = cached && cached.size > 0 && now - cachedAt < CATALOG_CACHE_TTL_MS;
|
|
159
|
-
if (fresh && !options?.force)
|
|
160
|
-
return { index: cached };
|
|
161
|
-
const fetched = await fetchCatalog({ fetchImpl: options?.fetchImpl });
|
|
162
|
-
if (!fetched.ok) {
|
|
163
|
-
if (cached && cached.size > 0)
|
|
164
|
-
return { index: cached, message: fetched.message };
|
|
165
|
-
return { index: new Map(), message: fetched.message };
|
|
166
|
-
}
|
|
167
|
-
try {
|
|
168
|
-
mkdirSync(agentDir, { recursive: true });
|
|
169
|
-
writeFileAtomicSync(path, `${JSON.stringify({ at: now, entries: serializeCatalog(fetched.index) })}\n`);
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
// A cache that cannot be written is not a reason to lose the answer.
|
|
173
|
-
}
|
|
174
|
-
return { index: fetched.index };
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Give the gateway's models their real context window and reasoning flag.
|
|
178
|
-
*
|
|
179
|
-
* Runs on every start, over the howtofix providers only. It is the repair half of
|
|
180
|
-
* the same problem {@link repairGatewayModels} handles: an account connected
|
|
181
|
-
* before the catalogue existed has 128k stamped on every model and no `reasoning`
|
|
182
|
-
* field anywhere, and no command they can run fixes it. Existing entries are
|
|
183
|
-
* corrected only where this module's own default was left in place, so a value
|
|
184
|
-
* the user chose survives.
|
|
185
|
-
*
|
|
186
|
-
* Never throws: an unreachable catalogue leaves the file as it is and reports why.
|
|
187
|
-
*/
|
|
188
|
-
export async function repairModelFacts(agentDir, options) {
|
|
189
|
-
const path = join(agentDir, "models.json");
|
|
190
|
-
let parsed;
|
|
191
|
-
try {
|
|
192
|
-
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
193
|
-
}
|
|
194
|
-
catch {
|
|
195
|
-
return { changed: 0 };
|
|
196
|
-
}
|
|
197
|
-
const providers = parsed.providers;
|
|
198
|
-
if (!providers || typeof providers !== "object")
|
|
199
|
-
return { changed: 0 };
|
|
200
|
-
const { index, message } = await loadCatalog(agentDir, options);
|
|
201
|
-
if (index.size === 0)
|
|
202
|
-
return { changed: 0, message };
|
|
203
|
-
let changed = 0;
|
|
204
|
-
for (const id of HOWTOFIX_PROVIDERS) {
|
|
205
|
-
const entry = providers[id];
|
|
206
|
-
if (!entry || !Array.isArray(entry.models))
|
|
207
|
-
continue;
|
|
208
|
-
const repaired = applyCatalog(entry.models, index);
|
|
209
|
-
if (repaired.changed === 0)
|
|
210
|
-
continue;
|
|
211
|
-
entry.models = repaired.models;
|
|
212
|
-
changed += repaired.changed;
|
|
213
|
-
}
|
|
214
|
-
if (changed > 0) {
|
|
215
|
-
try {
|
|
216
|
-
writeFileAtomicSync(path, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
return { changed: 0, message: "the models file could not be written" };
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return { changed, message };
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* A stalled provider must not look like a hang.
|
|
226
|
-
*
|
|
227
|
-
* The SDK's HTTP idle timeout defaults to 300000 ms (5 minutes) and it is the
|
|
228
|
-
* per-provider request timeout, so a gateway that accepts the connection and then
|
|
229
|
-
* goes quiet leaves the user staring at a spinner for that long with no answer
|
|
230
|
-
* and no error.
|
|
231
|
-
*
|
|
232
|
-
* This was 90s, chosen to keep that wait short. It is wrong for the case the
|
|
233
|
-
* gateway is built to serve: a reasoning model on a long calculation can think
|
|
234
|
-
* for minutes, and the gateway now emits an SSE keepalive comment every 15s
|
|
235
|
-
* (`PingData` in the relay, `general_setting.ping_interval_enabled`) precisely so
|
|
236
|
-
* that a thinking model does not look stalled. Measured with a stub upstream that
|
|
237
|
-
* sent only keepalive comments for 75s and then a real chunk, a client with a raw
|
|
238
|
-
* 20s socket timeout received every ping and finished normally — so a ping resets
|
|
239
|
-
* this clock. 90s therefore cut real work at exactly 90s while the model was
|
|
240
|
-
* still drawing its first token, which is what users reported as "thinking is cut
|
|
241
|
-
* mid stream".
|
|
242
|
-
*
|
|
243
|
-
* Matching the SDK's own 5 minutes keeps the stalled-provider protection (the
|
|
244
|
-
* reason the key is written at all) while leaving room for a think the keepalive
|
|
245
|
-
* is already covering.
|
|
246
|
-
*/
|
|
247
|
-
export const HTTP_IDLE_TIMEOUT_MS = 300_000;
|
|
248
|
-
/**
|
|
249
|
-
* Write the network settings the gateway needs, if they are not already there.
|
|
250
|
-
*
|
|
251
|
-
* Only `httpIdleTimeoutMs` is set, and only when the key is absent: a value the
|
|
252
|
-
* user chose is theirs to keep. The rest of `settings.json` is left exactly as it
|
|
253
|
-
* was, and a file that cannot be parsed is never overwritten.
|
|
254
|
-
*
|
|
255
|
-
* Returns true when the file changed.
|
|
256
|
-
*/
|
|
257
|
-
export function ensureGatewaySettings(agentDir) {
|
|
258
|
-
const path = join(agentDir, "settings.json");
|
|
259
|
-
let parsed = {};
|
|
260
|
-
try {
|
|
261
|
-
const raw = readFileSync(path, "utf8");
|
|
262
|
-
const candidate = JSON.parse(raw);
|
|
263
|
-
if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
|
|
264
|
-
parsed = candidate;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
catch {
|
|
268
|
-
// Missing file (the normal first run) or unreadable JSON: start from an empty
|
|
269
|
-
// object. An existing but invalid file is left alone below, not clobbered.
|
|
270
|
-
if (existsSync(path))
|
|
271
|
-
return false;
|
|
272
|
-
}
|
|
273
|
-
if ("httpIdleTimeoutMs" in parsed)
|
|
274
|
-
return false;
|
|
275
|
-
parsed.httpIdleTimeoutMs = HTTP_IDLE_TIMEOUT_MS;
|
|
276
|
-
try {
|
|
277
|
-
mkdirSync(agentDir, { recursive: true });
|
|
278
|
-
writeFileAtomicSync(path, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
279
|
-
}
|
|
280
|
-
catch {
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
return true;
|
|
284
|
-
}
|
|
285
|
-
/**
|
|
286
|
-
* What every model behind the howtofix.id gateway needs.
|
|
287
|
-
*
|
|
288
|
-
* The gateway accepts `system` and `user` instructions and refuses `developer`,
|
|
289
|
-
* so the adapter must never choose the `developer` role for a reasoning model.
|
|
290
|
-
*/
|
|
291
|
-
export const HOWTOFIX_MODEL_COMPAT = { supportsDeveloperRole: false };
|
|
292
|
-
/**
|
|
293
|
-
* A conservative default context window for a model the gateway does not
|
|
294
|
-
* describe.
|
|
295
|
-
*
|
|
296
|
-
* `GET /v1/models` returns only ids, so a synced model has no declared window.
|
|
297
|
-
* Guessing high would let the CLI overflow a model's real limit; 128k is the
|
|
298
|
-
* common floor for the families on offer and the user can edit the entry.
|
|
299
|
-
*/
|
|
300
|
-
export const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
301
|
-
/** A short, non-reversible hint for a token, safe to show in the feed. */
|
|
302
|
-
export function maskToken(token) {
|
|
303
|
-
const trimmed = token.trim();
|
|
304
|
-
if (trimmed.length <= 10)
|
|
305
|
-
return "***";
|
|
306
|
-
return `${trimmed.slice(0, 6)}…${trimmed.slice(-4)}`;
|
|
307
|
-
}
|
|
308
|
-
/** Whether a token looks like the ones the Console issues. */
|
|
309
|
-
export function looksLikeToken(token) {
|
|
310
|
-
return /^sk-[A-Za-z0-9_-]{8,}$/.test(token.trim());
|
|
311
|
-
}
|
|
312
|
-
/**
|
|
313
|
-
* Read the model ids out of a `GET /v1/models` payload.
|
|
314
|
-
*
|
|
315
|
-
* Tolerant on purpose: gateways wrap the list differently (`data`, `models`,
|
|
316
|
-
* or a bare array), and a strict reader would turn a working token into
|
|
317
|
-
* "connection failed".
|
|
318
|
-
*/
|
|
319
|
-
export function parseModelIds(payload) {
|
|
320
|
-
const list = (() => {
|
|
321
|
-
if (Array.isArray(payload))
|
|
322
|
-
return payload;
|
|
323
|
-
if (payload && typeof payload === "object") {
|
|
324
|
-
const record = payload;
|
|
325
|
-
if (Array.isArray(record.data))
|
|
326
|
-
return record.data;
|
|
327
|
-
if (Array.isArray(record.models))
|
|
328
|
-
return record.models;
|
|
329
|
-
}
|
|
330
|
-
return [];
|
|
331
|
-
})();
|
|
332
|
-
const ids = [];
|
|
333
|
-
for (const entry of list) {
|
|
334
|
-
const id = typeof entry === "string" ? entry : entry && typeof entry === "object" && typeof entry.id === "string" ? entry.id : undefined;
|
|
335
|
-
if (id && !ids.includes(id))
|
|
336
|
-
ids.push(id);
|
|
337
|
-
}
|
|
338
|
-
return ids;
|
|
339
|
-
}
|
|
340
|
-
/** Whether a provider id points at the howtofix.id gateway. */
|
|
341
|
-
export function isHowtofixProvider(providerId) {
|
|
342
|
-
return HOWTOFIX_PROVIDERS.includes(providerId);
|
|
343
|
-
}
|
|
344
|
-
/**
|
|
345
|
-
* The value the gateway returns for an unlimited token.
|
|
346
|
-
*
|
|
347
|
-
* `GetSubscription` computes `(remainQuota + usedQuota) / QuotaPerUnit`, and
|
|
348
|
-
* then overwrites it with this literal when the token has `UnlimitedQuota` — so
|
|
349
|
-
* a huge round number is not a balance, it is the absence of one. Presenting it
|
|
350
|
-
* as money would tell an unlimited account it has 100,000,000 of something.
|
|
351
|
-
*/
|
|
352
|
-
export const UNLIMITED_SENTINEL = 100_000_000;
|
|
353
|
-
/**
|
|
354
|
-
* Read the unit the gateway displays balances in.
|
|
355
|
-
*
|
|
356
|
-
* `GET /api/status` carries `quota_display_type` ("USD", "CNY" or "TOKENS"),
|
|
357
|
-
* and it is public, so this needs no credential. A failure is not an error: the
|
|
358
|
-
* caller falls back to USD rather than refusing to show a balance.
|
|
359
|
-
*/
|
|
360
|
-
async function readDisplayUnit(doFetch, statusUrl) {
|
|
361
|
-
const response = await doFetch(statusUrl, { headers: { Accept: "application/json" } });
|
|
362
|
-
if (!response.ok)
|
|
363
|
-
return "USD";
|
|
364
|
-
const payload = (await response.json());
|
|
365
|
-
const raw = payload?.data?.quota_display_type;
|
|
366
|
-
return typeof raw === "string" && raw.trim() ? raw.trim().toUpperCase() : "USD";
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* Ask the gateway what an account has left.
|
|
370
|
-
*
|
|
371
|
-
* The balance is real and reachable with the API key alone — measured on the
|
|
372
|
-
* live gateway, `GET /v1/dashboard/billing/subscription` and
|
|
373
|
-
* `GET /v1/dashboard/billing/usage` both answer 200 to a plain `sk-` token,
|
|
374
|
-
* while `/v1/me` and `/api/user/self` are 404/401. So no dashboard session and
|
|
375
|
-
* no OAuth are needed to sync it.
|
|
376
|
-
*
|
|
377
|
-
* Two conventions have to be undone to read it:
|
|
378
|
-
*
|
|
379
|
-
* - `soft_limit_usd` is *total granted* (remaining + used), not remaining, so
|
|
380
|
-
* what is left is `limit - used`;
|
|
381
|
-
* - `GetUsage` multiplies by 100, so its `total_usage` is in hundredths.
|
|
382
|
-
*
|
|
383
|
-
* Never throws: any failure is a sentence, because this runs from a command.
|
|
384
|
-
*/
|
|
385
|
-
export async function checkUsage(token, options) {
|
|
386
|
-
const baseUrl = (options?.baseUrl ?? HOWTOFIX_BASE_URL).replace(/\/+$/, "");
|
|
387
|
-
const doFetch = options?.fetchImpl ?? fetch;
|
|
388
|
-
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
389
|
-
const trimmed = token.trim();
|
|
390
|
-
if (!trimmed)
|
|
391
|
-
return { ok: false, unlimited: false, unit: "USD", message: "no token given" };
|
|
392
|
-
// The gateway reports which unit it displays balances in. Reading it beats
|
|
393
|
-
// assuming USD: a CNY instance would otherwise print a dollar sign on yuan.
|
|
394
|
-
const statusUrl = `${baseUrl.replace(/\/v1$/, "")}/api/status`;
|
|
395
|
-
const unit = await readDisplayUnit(doFetch, statusUrl).catch(() => "USD");
|
|
396
|
-
const controller = new AbortController();
|
|
397
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
398
|
-
const headers = { Authorization: `Bearer ${trimmed}` };
|
|
399
|
-
try {
|
|
400
|
-
const [subscriptionResponse, usageResponse] = await Promise.all([doFetch(`${baseUrl}/dashboard/billing/subscription`, { headers, signal: controller.signal }), doFetch(`${baseUrl}/dashboard/billing/usage`, { headers, signal: controller.signal })]);
|
|
401
|
-
if (subscriptionResponse.status === 401 || subscriptionResponse.status === 403) {
|
|
402
|
-
return { ok: false, unlimited: false, unit, message: "the gateway rejected that token when asking for the balance" };
|
|
403
|
-
}
|
|
404
|
-
if (!subscriptionResponse.ok) {
|
|
405
|
-
return { ok: false, unlimited: false, unit, message: `the balance endpoint answered ${subscriptionResponse.status}` };
|
|
406
|
-
}
|
|
407
|
-
const subscription = (await subscriptionResponse.json());
|
|
408
|
-
if (subscription.error?.message) {
|
|
409
|
-
return { ok: false, unlimited: false, unit, message: subscription.error.message };
|
|
410
|
-
}
|
|
411
|
-
const limit = Number(subscription.hard_limit_usd ?? subscription.soft_limit_usd ?? 0);
|
|
412
|
-
if (limit >= UNLIMITED_SENTINEL) {
|
|
413
|
-
return { ok: true, unlimited: true, unit, message: "this token has no quota limit" };
|
|
414
|
-
}
|
|
415
|
-
let used = 0;
|
|
416
|
-
if (usageResponse.ok) {
|
|
417
|
-
const usage = (await usageResponse.json());
|
|
418
|
-
// The gateway multiplies by 100 on the way out; undo it.
|
|
419
|
-
used = Number(usage.total_usage ?? 0) / 100;
|
|
420
|
-
}
|
|
421
|
-
const remaining = Math.max(0, limit - used);
|
|
422
|
-
return {
|
|
423
|
-
ok: true,
|
|
424
|
-
unlimited: false,
|
|
425
|
-
limit,
|
|
426
|
-
used,
|
|
427
|
-
remaining,
|
|
428
|
-
unit,
|
|
429
|
-
message: `${remaining.toFixed(2)} ${unit} left of ${limit.toFixed(2)} (${used.toFixed(2)} used)`,
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
catch (cause) {
|
|
433
|
-
const aborted = cause instanceof Error && cause.name === "AbortError";
|
|
434
|
-
return {
|
|
435
|
-
ok: false,
|
|
436
|
-
unlimited: false,
|
|
437
|
-
unit,
|
|
438
|
-
message: aborted ? `the gateway did not answer within ${Math.round(timeoutMs / 1000)}s` : "could not reach the gateway",
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
finally {
|
|
442
|
-
clearTimeout(timer);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
/**
|
|
446
|
-
* Add the account's current model ids to every howtofix entry in `models.json`,
|
|
447
|
-
* silently and without touching the runtime.
|
|
448
|
-
*
|
|
449
|
-
* This is the start-time half of `/sync`: the gateway list follows the
|
|
450
|
-
* account's tier, and a snapshot taken weeks ago keeps showing what the
|
|
451
|
-
* account could reach then. Running this on every start means a downgrade
|
|
452
|
-
* cannot leave a VIP model offered to a free account on the next launch, and
|
|
453
|
-
* an upgrade puts the new models in reach without anyone re-pasting a
|
|
454
|
-
* credential. Only ids missing from the file are added; entries the user
|
|
455
|
-
* wrote by hand are kept, because this function cannot tell a hand-written
|
|
456
|
-
* model from a synced one and deleting a model the user chose is worse than
|
|
457
|
-
* leaving a stale one for `/sync` to refresh.
|
|
458
|
-
*
|
|
459
|
-
* Never throws: a start must not die over a catalogue it could not read.
|
|
460
|
-
*/
|
|
461
|
-
export async function syncGatewayModelsOnStart(agentDir, ids) {
|
|
462
|
-
const path = join(agentDir, "models.json");
|
|
463
|
-
let parsed;
|
|
464
|
-
try {
|
|
465
|
-
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
466
|
-
}
|
|
467
|
-
catch {
|
|
468
|
-
return { added: 0, touched: 0 };
|
|
469
|
-
}
|
|
470
|
-
const providers = parsed.providers;
|
|
471
|
-
if (!providers || typeof providers !== "object")
|
|
472
|
-
return { added: 0, touched: 0 };
|
|
473
|
-
const { index } = await loadCatalog(agentDir).catch(() => ({ index: new Map() }));
|
|
474
|
-
let added = 0;
|
|
475
|
-
let touched = 0;
|
|
476
|
-
for (const id of HOWTOFIX_PROVIDERS) {
|
|
477
|
-
const entry = providers[id];
|
|
478
|
-
if (!entry || !Array.isArray(entry.models))
|
|
479
|
-
continue;
|
|
480
|
-
const before = entry.models.length;
|
|
481
|
-
entry.models = mergeModels(entry.models, ids, index);
|
|
482
|
-
added += entry.models.length - before;
|
|
483
|
-
touched++;
|
|
484
|
-
}
|
|
485
|
-
if (added === 0)
|
|
486
|
-
return { added: 0, touched };
|
|
487
|
-
try {
|
|
488
|
-
writeFileAtomicSync(path, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
489
|
-
}
|
|
490
|
-
catch {
|
|
491
|
-
return { added: 0, touched };
|
|
492
|
-
}
|
|
493
|
-
return { added, touched };
|
|
494
|
-
}
|
|
495
|
-
/**
|
|
496
|
-
* Merge gateway model ids into a provider's existing model list.
|
|
497
|
-
*
|
|
498
|
-
* Existing entries are kept **as they are** — a user who set `contextWindow` or
|
|
499
|
-
* `maxTokens` for a model must not have it overwritten by a sync — and only ids
|
|
500
|
-
* that are missing are appended. The order is preserved: hand-written models
|
|
501
|
-
* first, then whatever the gateway added, so the list stays readable.
|
|
502
|
-
*
|
|
503
|
-
* New entries take their numbers from the model catalogue when it knows the
|
|
504
|
-
* model, and only fall back to {@link DEFAULT_CONTEXT_WINDOW} when it does not.
|
|
505
|
-
* The default is a floor for an unknown model, not a description of every model:
|
|
506
|
-
* the gateway serves 1,000,000-token windows, and stamping 128k on one of those
|
|
507
|
-
* made the context meter read many times too full and compact a conversation
|
|
508
|
-
* that had barely started.
|
|
509
|
-
*/
|
|
510
|
-
export function mergeModels(existing, ids, catalog) {
|
|
511
|
-
const known = new Set(existing.map((model) => model.id));
|
|
512
|
-
const merged = [...existing];
|
|
513
|
-
for (const id of ids) {
|
|
514
|
-
if (known.has(id))
|
|
515
|
-
continue;
|
|
516
|
-
known.add(id);
|
|
517
|
-
const entry = catalog?.get(normalizeModelId(id)) ?? (catalog ? lookupModel(catalog, id) : undefined);
|
|
518
|
-
merged.push({
|
|
519
|
-
id,
|
|
520
|
-
contextWindow: entry?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
521
|
-
...(entry?.maxTokens ? { maxTokens: entry.maxTokens } : {}),
|
|
522
|
-
...(entry ? { reasoning: entry.reasoning } : {}),
|
|
523
|
-
// Written only when the model takes an image. The SDK's own default for a
|
|
524
|
-
// model with no `input` is `["text"]`, so stating it for every text model
|
|
525
|
-
// would add noise to the file and to the "N models updated" count without
|
|
526
|
-
// changing behaviour; naming it for a vision model is the part that matters.
|
|
527
|
-
...(entry?.vision ? { input: ["text", "image"] } : {}),
|
|
528
|
-
compat: { ...HOWTOFIX_MODEL_COMPAT },
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
return merged;
|
|
532
|
-
}
|
|
533
|
-
/**
|
|
534
|
-
* Fill in the numbers a synced model never got, without touching a user's own.
|
|
535
|
-
*
|
|
536
|
-
* The repair runs over every gateway model on each start, for the same reason
|
|
537
|
-
* {@link applyCompat} does: an account connected before the catalogue existed has
|
|
538
|
-
* a `models.json` full of 128k entries and no `reasoning` flag, and no command
|
|
539
|
-
* they can run fixes it. Only the fields this module owns are written — a
|
|
540
|
-
* `contextWindow` the user edited by hand is left alone, which is why the check
|
|
541
|
-
* is "is it exactly the old default" rather than "is it present".
|
|
542
|
-
*
|
|
543
|
-
* Returns how many entries were changed. Never throws.
|
|
544
|
-
*/
|
|
545
|
-
export function applyCatalog(models, catalog) {
|
|
546
|
-
let changed = 0;
|
|
547
|
-
const repaired = models.map((model) => {
|
|
548
|
-
const entry = lookupModel(catalog, model.id);
|
|
549
|
-
if (!entry)
|
|
550
|
-
return model;
|
|
551
|
-
const next = { ...model };
|
|
552
|
-
let touched = false;
|
|
553
|
-
// The old default is the marker of "never given a real number". A user's own
|
|
554
|
-
// value is anything else, and is kept.
|
|
555
|
-
if (entry.contextWindow && model.contextWindow === DEFAULT_CONTEXT_WINDOW) {
|
|
556
|
-
next.contextWindow = entry.contextWindow;
|
|
557
|
-
touched = true;
|
|
558
|
-
}
|
|
559
|
-
if (entry.maxTokens && !model.maxTokens) {
|
|
560
|
-
next.maxTokens = entry.maxTokens;
|
|
561
|
-
touched = true;
|
|
562
|
-
}
|
|
563
|
-
if (model.reasoning === undefined) {
|
|
564
|
-
next.reasoning = entry.reasoning;
|
|
565
|
-
touched = true;
|
|
566
|
-
}
|
|
567
|
-
// A synced model never got an `input`, so a vision model stayed blind. Add
|
|
568
|
-
// the image capability where the catalogue states it. Only ever *adds*: a
|
|
569
|
-
// model the user marked as image-capable keeps that, and one the catalogue
|
|
570
|
-
// knows to be text-only is not downgraded — an unlisted model is the
|
|
571
|
-
// conservative default, and silently removing a capability a user set is
|
|
572
|
-
// worse than leaving one the catalogue disagrees with.
|
|
573
|
-
if (entry.vision && !(model.input ?? []).includes("image")) {
|
|
574
|
-
next.input = [...(model.input ?? ["text"]).filter((v) => v !== "image"), "image"];
|
|
575
|
-
touched = true;
|
|
576
|
-
}
|
|
577
|
-
if (!touched)
|
|
578
|
-
return model;
|
|
579
|
-
changed++;
|
|
580
|
-
return next;
|
|
581
|
-
});
|
|
582
|
-
return { models: repaired, changed };
|
|
583
|
-
}
|
|
584
|
-
/**
|
|
585
|
-
* Give every model the gateway's compatibility flags, and say how many changed.
|
|
586
|
-
*
|
|
587
|
-
* A sync that only stamped new entries would leave every model already in the
|
|
588
|
-
* file broken: a user who connected before this flag existed has 50 models that
|
|
589
|
-
* all send the `developer` role and all fail. So the whole list is repaired, not
|
|
590
|
-
* just the new part. Entries that already carry the flag are left untouched.
|
|
591
|
-
*/
|
|
592
|
-
export function applyCompat(models) {
|
|
593
|
-
let changed = 0;
|
|
594
|
-
const repaired = models.map((model) => {
|
|
595
|
-
if (model.compat?.supportsDeveloperRole === false)
|
|
596
|
-
return model;
|
|
597
|
-
changed++;
|
|
598
|
-
return { ...model, compat: { ...model.compat, ...HOWTOFIX_MODEL_COMPAT } };
|
|
599
|
-
});
|
|
600
|
-
return { models: repaired, changed };
|
|
601
|
-
}
|
|
602
|
-
/** How many models a merge would add, for a message that says what happened. */
|
|
603
|
-
export function countNewModels(existing, ids) {
|
|
604
|
-
const known = new Set(existing.map((model) => model.id));
|
|
605
|
-
return ids.filter((id) => !known.has(id)).length;
|
|
606
|
-
}
|
|
607
|
-
/**
|
|
608
|
-
* Ask the gateway whether a model actually answers.
|
|
609
|
-
*
|
|
610
|
-
* `/v1/models` lists what an account *may* call, which is not the same as what
|
|
611
|
-
* works right now: measured on the live gateway, one listed model returned 500
|
|
612
|
-
* (its channel is down) and another returned 401 (the token is not allowed to
|
|
613
|
-
* call it). Selecting the first listed model therefore hands a new user a model
|
|
614
|
-
* that fails on their first prompt, which reads as a broken install.
|
|
615
|
-
*
|
|
616
|
-
* A one-token request is enough to tell the difference. Never throws: any failure
|
|
617
|
-
* is reported as not-ok with the reason.
|
|
618
|
-
*/
|
|
619
|
-
export async function probeModel(model, token, options) {
|
|
620
|
-
const baseUrl = (options?.baseUrl ?? HOWTOFIX_BASE_URL).replace(/\/+$/, "");
|
|
621
|
-
const doFetch = options?.fetchImpl ?? fetch;
|
|
622
|
-
const timeoutMs = options?.timeoutMs ?? 10_000;
|
|
623
|
-
const controller = new AbortController();
|
|
624
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
625
|
-
try {
|
|
626
|
-
const response = await doFetch(`${baseUrl}/chat/completions`, {
|
|
627
|
-
method: "POST",
|
|
628
|
-
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
629
|
-
body: JSON.stringify({ model, messages: [{ role: "user", content: "hi" }], max_tokens: 1 }),
|
|
630
|
-
signal: controller.signal,
|
|
631
|
-
});
|
|
632
|
-
if (response.ok)
|
|
633
|
-
return { ok: true, detail: "ok" };
|
|
634
|
-
// The gateway reports the real reason in the body even when the status is 200.
|
|
635
|
-
const text = await response.text().catch(() => "");
|
|
636
|
-
const message = /"message"\s*:\s*"([^"]{0,80})/.exec(text)?.[1] ?? `HTTP ${response.status}`;
|
|
637
|
-
return { ok: false, detail: message };
|
|
638
|
-
}
|
|
639
|
-
catch (cause) {
|
|
640
|
-
const aborted = cause instanceof Error && cause.name === "AbortError";
|
|
641
|
-
return { ok: false, detail: aborted ? "timed out" : "unreachable" };
|
|
642
|
-
}
|
|
643
|
-
finally {
|
|
644
|
-
clearTimeout(timer);
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
/**
|
|
648
|
-
* Which of these candidates answer right now, probed at most `limit` at a time.
|
|
649
|
-
*
|
|
650
|
-
* The channel behind one id being down says nothing about its neighbours —
|
|
651
|
-
* measured on the live gateway, one listed model answered 500 while another
|
|
652
|
-
* answered 401 and a third was fine. So when a run dies with "no available
|
|
653
|
-
* channel", the useful next step is a handful of one-token probes against the
|
|
654
|
-
* *other* candidates: cheap enough to run inline, and the answer is what
|
|
655
|
-
* decides whether opening the model picker is worth the user's attention.
|
|
656
|
-
*
|
|
657
|
-
* `exclude` drops the id that just failed: probing it again spends one of the
|
|
658
|
-
* `limit` probes on an answer we already have. Probes run in parallel (each
|
|
659
|
-
* carries its own timeout), so the whole batch costs one probe's latency, not
|
|
660
|
-
* `limit` of them. Never throws: `probeModel` reports every failure as not-ok.
|
|
661
|
-
*/
|
|
662
|
-
export async function findLiveModels(candidates, token, options) {
|
|
663
|
-
const limit = Math.max(0, options?.limit ?? 4);
|
|
664
|
-
const probed = [...new Set(candidates)].filter((id) => id !== options?.exclude).slice(0, limit);
|
|
665
|
-
const results = await Promise.all(probed.map((id) => probeModel(id, token, options)));
|
|
666
|
-
return { live: probed.filter((_, index) => results[index]?.ok), probed };
|
|
667
|
-
}
|
|
668
|
-
/**
|
|
669
|
-
* Ask the gateway which models a token may call.
|
|
670
|
-
*
|
|
671
|
-
* One request, a bounded timeout, and every failure turned into a sentence —
|
|
672
|
-
* a 401 is "the token was rejected", not an exception the TUI has to catch.
|
|
673
|
-
* The token is only ever placed in the `Authorization` header.
|
|
674
|
-
*/
|
|
675
|
-
export async function checkAccount(token, options) {
|
|
676
|
-
const baseUrl = (options?.baseUrl ?? HOWTOFIX_BASE_URL).replace(/\/+$/, "");
|
|
677
|
-
const doFetch = options?.fetchImpl ?? fetch;
|
|
678
|
-
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
679
|
-
const trimmed = token.trim();
|
|
680
|
-
if (!trimmed)
|
|
681
|
-
return { ok: false, models: [], message: "no token given" };
|
|
682
|
-
const controller = new AbortController();
|
|
683
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
684
|
-
try {
|
|
685
|
-
const response = await doFetch(`${baseUrl}/models`, {
|
|
686
|
-
headers: { Authorization: `Bearer ${trimmed}` },
|
|
687
|
-
signal: controller.signal,
|
|
688
|
-
});
|
|
689
|
-
if (response.status === 401) {
|
|
690
|
-
return { ok: false, models: [], message: "the gateway rejected that token (401). Check it in the Console, or make a new one." };
|
|
691
|
-
}
|
|
692
|
-
if (response.status === 403) {
|
|
693
|
-
return { ok: false, models: [], message: "that token is not allowed to list models (403). The Console shows what a token may call." };
|
|
694
|
-
}
|
|
695
|
-
if (!response.ok) {
|
|
696
|
-
return { ok: false, models: [], message: `the gateway answered ${response.status}` };
|
|
697
|
-
}
|
|
698
|
-
const ids = parseModelIds(await response.json());
|
|
699
|
-
if (ids.length === 0) {
|
|
700
|
-
return { ok: false, models: [], message: "the token works but the gateway listed no models" };
|
|
701
|
-
}
|
|
702
|
-
return { ok: true, models: ids, message: `${ids.length} models available to this token` };
|
|
703
|
-
}
|
|
704
|
-
catch (cause) {
|
|
705
|
-
const aborted = cause instanceof Error && cause.name === "AbortError";
|
|
706
|
-
return {
|
|
707
|
-
ok: false,
|
|
708
|
-
models: [],
|
|
709
|
-
message: aborted ? `the gateway did not answer within ${Math.round(timeoutMs / 1000)}s` : "could not reach the gateway",
|
|
710
|
-
};
|
|
711
|
-
}
|
|
712
|
-
finally {
|
|
713
|
-
clearTimeout(timer);
|
|
714
|
-
}
|
|
715
|
-
}
|