howtofixcli 0.7.13 → 0.7.15

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