howtofixcli 0.7.7 → 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.
Files changed (152) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-63ZCQDXH.js +6 -0
  5. package/dist/chunk-6FUWMLWO.js +1 -0
  6. package/dist/chunk-7CHDNTH6.js +1 -0
  7. package/dist/chunk-ACOMNDUL.js +185 -0
  8. package/dist/chunk-AEVPET7X.js +2 -0
  9. package/dist/chunk-AGGIYXN7.js +2 -0
  10. package/dist/chunk-BHVBO4SC.js +1 -0
  11. package/dist/chunk-FPXVDP32.js +8 -0
  12. package/dist/chunk-GIUYIKH3.js +109 -0
  13. package/dist/chunk-GQCAJ56I.js +5 -0
  14. package/dist/chunk-GXB7RZHH.js +1 -0
  15. package/dist/chunk-IGEY3AWF.js +1 -0
  16. package/dist/chunk-ILDDDLRC.js +1 -0
  17. package/dist/chunk-JFYVRWJI.js +2 -0
  18. package/dist/chunk-MVLCGU3G.js +1 -0
  19. package/dist/chunk-N345KWWK.js +1 -0
  20. package/dist/chunk-NBQFPCRT.js +1 -0
  21. package/dist/chunk-OIPSLIZE.js +5 -0
  22. package/dist/chunk-ORJ2QZBG.js +3 -0
  23. package/dist/chunk-PCALBZOO.js +2 -0
  24. package/dist/chunk-PDNBGUYP.js +2 -0
  25. package/dist/chunk-S656RE4A.js +1 -0
  26. package/dist/chunk-TSEKPGK4.js +1 -0
  27. package/dist/chunk-U6CI6KXN.js +34 -0
  28. package/dist/chunk-UTYFLLSM.js +1 -0
  29. package/dist/chunk-V3R4FYTJ.js +1 -0
  30. package/dist/chunk-V656XVXV.js +8 -0
  31. package/dist/chunk-WKUJ7CFO.js +1 -0
  32. package/dist/chunk-WOT6VMZA.js +1 -0
  33. package/dist/chunk-WUPU6LTD.js +4 -0
  34. package/dist/chunk-WX3V5P7U.js +5 -0
  35. package/dist/chunk-ZKZ6WCI3.js +2 -0
  36. package/dist/main.js +94 -536
  37. package/package.json +15 -62
  38. package/dist/account-credential.js +0 -136
  39. package/dist/account-credential.test.js +0 -38
  40. package/dist/activity.js +0 -124
  41. package/dist/agent-defs.js +0 -237
  42. package/dist/agent-limiter.js +0 -68
  43. package/dist/agents.js +0 -554
  44. package/dist/anim.js +0 -361
  45. package/dist/args.js +0 -228
  46. package/dist/at-file.js +0 -28
  47. package/dist/brand-extension.js +0 -87
  48. package/dist/brand.js +0 -76
  49. package/dist/browser.js +0 -58
  50. package/dist/cache-report.js +0 -139
  51. package/dist/checkpoint.js +0 -437
  52. package/dist/claims.js +0 -167
  53. package/dist/cli-auth.js +0 -142
  54. package/dist/cli-checkin.js +0 -116
  55. package/dist/cli-credentials.js +0 -254
  56. package/dist/clipboard.js +0 -149
  57. package/dist/collapse.js +0 -129
  58. package/dist/commands.js +0 -301
  59. package/dist/compaction-guard.js +0 -81
  60. package/dist/context-extension.js +0 -17
  61. package/dist/dcp/commands.js +0 -172
  62. package/dist/dcp/compress-state.js +0 -139
  63. package/dist/dcp/compress.js +0 -329
  64. package/dist/dcp/dcp-extension.js +0 -288
  65. package/dist/dcp/dedup.js +0 -93
  66. package/dist/dcp/ids.js +0 -189
  67. package/dist/dcp/index.js +0 -25
  68. package/dist/dcp/prompts.js +0 -68
  69. package/dist/dcp/protected-content.js +0 -221
  70. package/dist/dcp/protected.js +0 -161
  71. package/dist/dcp/prune.js +0 -418
  72. package/dist/dcp/state.js +0 -176
  73. package/dist/device-id.js +0 -90
  74. package/dist/device-id.test.js +0 -23
  75. package/dist/errors.js +0 -63
  76. package/dist/extensions.js +0 -48
  77. package/dist/fatal.js +0 -35
  78. package/dist/feed.js +0 -498
  79. package/dist/fs-atomic.js +0 -54
  80. package/dist/gateway-errors.js +0 -90
  81. package/dist/git.js +0 -230
  82. package/dist/goal-extension.js +0 -88
  83. package/dist/goal.js +0 -222
  84. package/dist/hooks-extension.js +0 -176
  85. package/dist/hooks.js +0 -443
  86. package/dist/howtofix-account.js +0 -715
  87. package/dist/hud-line.js +0 -153
  88. package/dist/hwid.js +0 -130
  89. package/dist/interrupt.js +0 -60
  90. package/dist/json-events.js +0 -50
  91. package/dist/level-style.js +0 -112
  92. package/dist/loop-guard.js +0 -95
  93. package/dist/lsp-tools.js +0 -32
  94. package/dist/lsp.js +0 -347
  95. package/dist/mcp-config.js +0 -85
  96. package/dist/mcp-lifecycle.js +0 -147
  97. package/dist/model-catalog.js +0 -217
  98. package/dist/model-resolve.js +0 -65
  99. package/dist/notes.js +0 -78
  100. package/dist/oauth-login.js +0 -568
  101. package/dist/paths.js +0 -61
  102. package/dist/permissions.js +0 -298
  103. package/dist/plan-mode.js +0 -363
  104. package/dist/preset-command.js +0 -227
  105. package/dist/preset-extension.js +0 -35
  106. package/dist/preset-mcp.js +0 -292
  107. package/dist/presets.js +0 -727
  108. package/dist/prompt-files.js +0 -103
  109. package/dist/prompt-text.js +0 -41
  110. package/dist/proxy.js +0 -137
  111. package/dist/quality/ast.js +0 -196
  112. package/dist/quality/cognitive.js +0 -83
  113. package/dist/quality/cyclomatic.js +0 -30
  114. package/dist/quality/halstead.js +0 -83
  115. package/dist/quality/index.js +0 -18
  116. package/dist/quality/length.js +0 -3
  117. package/dist/quality/maintainability.js +0 -15
  118. package/dist/quality/nesting.js +0 -57
  119. package/dist/quality/report.js +0 -803
  120. package/dist/quality/smells.js +0 -67
  121. package/dist/quality-tool.js +0 -63
  122. package/dist/read-only-commands.js +0 -597
  123. package/dist/repo-map.js +0 -292
  124. package/dist/rtk.js +0 -193
  125. package/dist/sandbox.js +0 -105
  126. package/dist/schema.js +0 -214
  127. package/dist/session-lifecycle.js +0 -19
  128. package/dist/session-retention.js +0 -111
  129. package/dist/session.js +0 -446
  130. package/dist/sessions.js +0 -100
  131. package/dist/settings.js +0 -148
  132. package/dist/skills.js +0 -35
  133. package/dist/slop-tool.js +0 -96
  134. package/dist/spend-cap.js +0 -43
  135. package/dist/subagent.js +0 -171
  136. package/dist/tar.js +0 -146
  137. package/dist/theme.js +0 -79
  138. package/dist/thinking.js +0 -45
  139. package/dist/todo.js +0 -114
  140. package/dist/tool-loadout.js +0 -97
  141. package/dist/tool-render.js +0 -769
  142. package/dist/tools.js +0 -176
  143. package/dist/ui.js +0 -4751
  144. package/dist/update.js +0 -533
  145. package/dist/usage-segment.js +0 -69
  146. package/dist/web-tools.js +0 -46
  147. package/dist/web.js +0 -265
  148. package/dist/welcome.js +0 -179
  149. package/dist/workflow-engine.js +0 -246
  150. package/dist/workflow-progress.js +0 -446
  151. package/dist/workflow-view.js +0 -195
  152. package/dist/workflow.js +0 -349
@@ -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
- }