howtofixcli 0.7.24 → 0.7.26

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