@inferall/cli 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -15
- package/bin/build-info.json +1 -0
- package/bin/inferall.js +2734 -153
- package/package.json +5 -2
package/bin/inferall.js
CHANGED
|
@@ -23,15 +23,222 @@ import {
|
|
|
23
23
|
existsSync,
|
|
24
24
|
readdirSync,
|
|
25
25
|
statSync,
|
|
26
|
+
realpathSync,
|
|
27
|
+
accessSync,
|
|
28
|
+
constants as fsConstants,
|
|
26
29
|
} from "node:fs";
|
|
27
|
-
import { join, relative, extname } from "node:path";
|
|
30
|
+
import { join, relative, extname, basename, dirname } from "node:path";
|
|
31
|
+
import { pathToFileURL } from "node:url";
|
|
32
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
28
33
|
|
|
29
34
|
const API_BASE = env.INFERALL_API_BASE || "https://api.inferall.ai/v1";
|
|
30
35
|
// The Anthropic SDK appends /v1/messages itself, so its baseURL is the host root.
|
|
31
36
|
const ANTHROPIC_BASE = API_BASE.replace(/\/v1$/, "");
|
|
32
37
|
const KEYS_URL = "https://inferall.ai/keys";
|
|
33
38
|
const BILLING_URL = "https://inferall.ai/billing?src=cli_init";
|
|
34
|
-
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Did we fail to REACH the gateway, as opposed to reaching it and disliking the
|
|
42
|
+
* answer? 2026-07-27 tick 832.
|
|
43
|
+
*
|
|
44
|
+
* With the gateway unreachable, `doctor` printed
|
|
45
|
+
*
|
|
46
|
+
* ✗ key rejected by gateway (network error). Check https://inferall.ai/keys
|
|
47
|
+
*
|
|
48
|
+
* The key was never checked. That sentence names a cause that did not happen
|
|
49
|
+
* and sends the user to mint a replacement key, which cannot fix an unreachable
|
|
50
|
+
* host. `models` was worse: the throw reached the top-level handler and printed
|
|
51
|
+
* a bare `✗ fetch failed`, which is Node's words, not ours.
|
|
52
|
+
*
|
|
53
|
+
* One predicate so every caller classifies it the same way. Node/undici phrases
|
|
54
|
+
* the same condition several ways depending on how it fails, so match on all of
|
|
55
|
+
* them rather than the one seen first.
|
|
56
|
+
*/
|
|
57
|
+
/**
|
|
58
|
+
* Every request this CLI makes is bounded.
|
|
59
|
+
*
|
|
60
|
+
* WHY THIS EXISTS (2026-07-31, tick 883)
|
|
61
|
+
* -------------------------------------
|
|
62
|
+
* Node's fetch applies no request timeout, and none of the call sites set one.
|
|
63
|
+
* Measured against a stub that accepts the connection and never replies:
|
|
64
|
+
*
|
|
65
|
+
* inferall status -> 45s+, ZERO bytes of output, no exit
|
|
66
|
+
*
|
|
67
|
+
* Not a hang forever (undici gives up on headers around 300s) but five minutes
|
|
68
|
+
* of a completely blank terminal, with nothing printed to say a request is even
|
|
69
|
+
* in flight. That is the worst shape a failure can take: there is nothing to
|
|
70
|
+
* read, nothing to act on, and no error to search for. A blackholing firewall
|
|
71
|
+
* or a proxy that swallows outbound traffic produces exactly this.
|
|
72
|
+
*/
|
|
73
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
74
|
+
const timeoutSignal = () => AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
75
|
+
|
|
76
|
+
function isNetworkError(err) {
|
|
77
|
+
// A timeout arrives as a DOMException named TimeoutError carrying no code,
|
|
78
|
+
// so matching on codes alone would drop it into the generic catch and print
|
|
79
|
+
// Node's words instead of ours.
|
|
80
|
+
if (err?.name === "TimeoutError" || err?.cause?.name === "TimeoutError") return true;
|
|
81
|
+
const m = `${err?.cause?.code || ""} ${err?.code || ""} ${err?.message || err || ""}`;
|
|
82
|
+
return /fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNRESET|socket hang up|UND_ERR|network/i.test(m);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What to say when we could not reach the gateway. Never blames the key. */
|
|
86
|
+
/**
|
|
87
|
+
* Name WHY the gateway could not be reached, from the cause the error already
|
|
88
|
+
* carries.
|
|
89
|
+
*
|
|
90
|
+
* 2026-07-29 tick 852. `err.message` is always the string "fetch failed",
|
|
91
|
+
* whatever went wrong, so the old single remedy ("check your connection") was
|
|
92
|
+
* printed for every cause. MEASURED against real failures:
|
|
93
|
+
*
|
|
94
|
+
* self-signed TLS cause.code = DEPTH_ZERO_SELF_SIGNED_CERT
|
|
95
|
+
* DNS failure cause.code = ENOTFOUND
|
|
96
|
+
* nothing listening cause.code = ECONNREFUSED
|
|
97
|
+
*
|
|
98
|
+
* These need completely different fixes, and the one that matters most is the
|
|
99
|
+
* TLS case: behind a corporate proxy that re-signs traffic, the user's
|
|
100
|
+
* connection is fine and "check your connection" sends them looking in the
|
|
101
|
+
* wrong place entirely. The remedy is a trust store, which they will not guess.
|
|
102
|
+
*
|
|
103
|
+
* Returns null for a cause we cannot name, and the caller then prints the raw
|
|
104
|
+
* code. Inventing a confident explanation for an unrecognised failure is how a
|
|
105
|
+
* diagnostic starts lying, and this file has paid for that twice already.
|
|
106
|
+
*/
|
|
107
|
+
export function describeNetworkCause(err) {
|
|
108
|
+
const code = err?.cause?.code || err?.code || "";
|
|
109
|
+
if (err?.name === "TimeoutError" || err?.cause?.name === "TimeoutError") {
|
|
110
|
+
return {
|
|
111
|
+
code: "TIMEOUT",
|
|
112
|
+
why: `the gateway accepted the connection but sent nothing back within ${REQUEST_TIMEOUT_MS / 1000}s`,
|
|
113
|
+
fix: "Often a firewall or proxy that swallows outbound traffic. Retry, or check INFERALL_API_BASE.",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (!code) return null;
|
|
117
|
+
if (/SELF_SIGNED|UNABLE_TO_VERIFY|CERT_|_CERT$|ERR_TLS/i.test(code)) {
|
|
118
|
+
return {
|
|
119
|
+
code,
|
|
120
|
+
why: "the TLS certificate could not be verified, which usually means a proxy is re-signing traffic",
|
|
121
|
+
fix: `Point Node at your organisation's CA: ${c.cyan("export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem")}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (/ENOTFOUND|EAI_AGAIN/i.test(code)) {
|
|
125
|
+
return { code, why: "the hostname did not resolve", fix: "Check DNS, a VPN, or a typo in the host name." };
|
|
126
|
+
}
|
|
127
|
+
if (/ECONNREFUSED/i.test(code)) {
|
|
128
|
+
return {
|
|
129
|
+
code,
|
|
130
|
+
why: "nothing is listening there",
|
|
131
|
+
fix: `Usually a stale ${c.cyan("INFERALL_API_BASE")} pointing at a local port that is not running.`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (/ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|ECONNRESET|EPIPE/i.test(code)) {
|
|
135
|
+
return { code, why: "the connection timed out or was dropped", fix: "Often a firewall or a proxy that blocks outbound HTTPS." };
|
|
136
|
+
}
|
|
137
|
+
return { code, why: null, fix: null };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Explain a NON-OK HTTP response without guessing at the cause.
|
|
142
|
+
*
|
|
143
|
+
* WHY THIS EXISTS (2026-07-31, tick 882)
|
|
144
|
+
* -------------------------------------
|
|
145
|
+
* Tick 832 established that an unreachable gateway must not be reported as a
|
|
146
|
+
* rejected key, "so users went and regenerated a key that was fine". That fix
|
|
147
|
+
* covered NETWORK errors, which arrive as a thrown exception. A 500 is not a
|
|
148
|
+
* network error: it is a completed HTTP exchange with a failure status, so it
|
|
149
|
+
* never entered that path and the old wording survived there untouched.
|
|
150
|
+
*
|
|
151
|
+
* Measured this tick against a stub forced to 500:
|
|
152
|
+
*
|
|
153
|
+
* status -> "status check failed (HTTP 500). Check the key at <keys page>"
|
|
154
|
+
* doctor -> "key rejected by gateway (HTTP 500)"
|
|
155
|
+
*
|
|
156
|
+
* Both blame the key for a server fault. During a real gateway incident every
|
|
157
|
+
* user would be told their key was rejected, and the remedy on offer, minting a
|
|
158
|
+
* new key, cannot fix a 500. doctor carries a comment two lines above saying a
|
|
159
|
+
* confident wrong cause is the failure that costs the most there.
|
|
160
|
+
*
|
|
161
|
+
* Only 401/403 are actually a verdict on the key.
|
|
162
|
+
*/
|
|
163
|
+
export function httpFailureLines(status) {
|
|
164
|
+
if (status === 401 || status === 403) {
|
|
165
|
+
return [
|
|
166
|
+
`${c.red("✗")} key rejected by gateway (HTTP ${status})`,
|
|
167
|
+
` The gateway does not recognise this key. Mint one at ${KEYS_URL}`,
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
if (status === 429) {
|
|
171
|
+
return [
|
|
172
|
+
`${c.red("✗")} rate limited by the gateway (HTTP 429)`,
|
|
173
|
+
` Your key is fine. Wait a moment and retry.`,
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
if (status >= 500) {
|
|
177
|
+
return [
|
|
178
|
+
`${c.red("✗")} the gateway is failing (HTTP ${status})`,
|
|
179
|
+
` This is NOT your key and there is nothing to fix on your side.`,
|
|
180
|
+
` Retry shortly. A new key will not help.`,
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
return [
|
|
184
|
+
`${c.red("✗")} unexpected response from the gateway (HTTP ${status})`,
|
|
185
|
+
` This is not a verdict on your key. Retry, and report it if it persists.`,
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function unreachableLines(err) {
|
|
190
|
+
const cause = describeNetworkCause(err);
|
|
191
|
+
return [
|
|
192
|
+
`${c.red("✗")} cannot reach the InferAll gateway at ${c.bold(API_BASE)}`,
|
|
193
|
+
// The cause is printed even when unrecognised: a bare code is still a
|
|
194
|
+
// search term, and hiding it leaves the user with nothing.
|
|
195
|
+
...(cause
|
|
196
|
+
? [` cause: ${c.bold(cause.code)}${cause.why ? `, ${cause.why}` : ""}`, ...(cause.fix ? [` ${cause.fix}`] : [])]
|
|
197
|
+
: []),
|
|
198
|
+
// Phrased to be exactly true from every caller: `models` never sends a key
|
|
199
|
+
// at all, so "your key was not checked" would be true but beside the point
|
|
200
|
+
// there. What every caller needs is the same: do not read this as a verdict
|
|
201
|
+
// on your setup.
|
|
202
|
+
` ${c.bold("Nothing")} was verified, so this says nothing about your key or your setup.`,
|
|
203
|
+
` Check your connection, or whether ${c.cyan("INFERALL_API_BASE")} points somewhere unexpected.`,
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
// 2026-10-03: the previous default was end-of-lifed by NVIDIA; ultra is the default free model.
|
|
207
|
+
const FREE_MODEL = "nvidia/nemotron-3-ultra-550b-a55b";
|
|
208
|
+
const CLI_VERSION = (() => {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
211
|
+
} catch {
|
|
212
|
+
return "0.0.0";
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
|
|
216
|
+
// Written by scripts/publish-gate.sh at publish time, and never committed.
|
|
217
|
+
// npm publishes the working tree rather than a commit, so without this the
|
|
218
|
+
// version string is the only identity the artifact has - and one version string
|
|
219
|
+
// has covered 34 different trees so far.
|
|
220
|
+
//
|
|
221
|
+
// ABSENT MEANS "unstamped", NOT "broken". A dev checkout and an `npm link` are
|
|
222
|
+
// legitimate states; neither should be reported as a fault. Only a stamp that
|
|
223
|
+
// is present AND carries a sha counts.
|
|
224
|
+
const BUILD_INFO = (() => {
|
|
225
|
+
try {
|
|
226
|
+
const j = JSON.parse(readFileSync(new URL("./build-info.json", import.meta.url), "utf8"));
|
|
227
|
+
return j && typeof j.sha === "string" && j.sha ? j : null;
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
})();
|
|
232
|
+
|
|
233
|
+
// An off-main publish is surfaced to the user rather than buried in a CI log,
|
|
234
|
+
// because the CI log is exactly what nobody reads on a green run.
|
|
235
|
+
export function versionLine(version, build) {
|
|
236
|
+
if (!build || typeof build.sha !== "string" || !build.sha) return version;
|
|
237
|
+
const offMain = typeof build.offMain === "string" && build.offMain.trim() ? build.offMain.trim() : null;
|
|
238
|
+
return offMain
|
|
239
|
+
? `${version} (${build.sha}, published off-main: ${offMain})`
|
|
240
|
+
: `${version} (${build.sha})`;
|
|
241
|
+
}
|
|
35
242
|
|
|
36
243
|
const c = {
|
|
37
244
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
@@ -43,8 +250,37 @@ const c = {
|
|
|
43
250
|
};
|
|
44
251
|
|
|
45
252
|
const args = argv.slice(2);
|
|
46
|
-
|
|
47
|
-
|
|
253
|
+
// 2026-07-31 tick 876. `|| "init"` swallowed every flag-only invocation.
|
|
254
|
+
// `inferall --help` has no non-flag argument, so cmd fell through to init and
|
|
255
|
+
// the user got an interactive setup wizard prompting "Paste your InferAll API
|
|
256
|
+
// key" instead of help. Measured on the packed artifact: --help, -h and -v all
|
|
257
|
+
// ran the wizard; only the bare word `help` and the long `--version` worked.
|
|
258
|
+
//
|
|
259
|
+
// --help is the first thing anyone types on an unfamiliar CLI, so the default
|
|
260
|
+
// has to run AFTER the help and version flags are considered, not before.
|
|
261
|
+
const HELP_FLAGS = new Set(["--help", "-h"]);
|
|
262
|
+
const VERSION_FLAGS = new Set(["--version", "-v", "-V"]);
|
|
263
|
+
const cmd =
|
|
264
|
+
args.find((a) => !a.startsWith("-")) ||
|
|
265
|
+
(args.some((a) => HELP_FLAGS.has(a))
|
|
266
|
+
? "help"
|
|
267
|
+
: args.some((a) => VERSION_FLAGS.has(a))
|
|
268
|
+
? "version"
|
|
269
|
+
: "init");
|
|
270
|
+
// Single-character names also accept the conventional single-dash form.
|
|
271
|
+
//
|
|
272
|
+
// 2026-08-01 tick 899. `const YES = flag("yes") || flag("y")` on the line below
|
|
273
|
+
// reads as a `-y` alias and was clearly written to be one, but flag() only ever
|
|
274
|
+
// built `--${name}`, so it enabled `--y` (which nobody types and no doc
|
|
275
|
+
// mentions) and NOT `-y`. Meanwhile README.md documents `--yes` / `-y` in its
|
|
276
|
+
// flag table. MEASURED end to end against a stub: `init --key ... --yes` exits
|
|
277
|
+
// 0 and wires the project; the identical run with `-y` exits 1 and prints the
|
|
278
|
+
// non-interactive help. So the landing page documented a flag that failed, and
|
|
279
|
+
// a user copying it into CI got a broken step.
|
|
280
|
+
//
|
|
281
|
+
// Restricted to length 1 so this cannot start accepting `-yes` or `-no-ai`,
|
|
282
|
+
// which are not real forms and would be a new way to be wrong.
|
|
283
|
+
const flag = (name) => args.includes(`--${name}`) || (name.length === 1 && args.includes(`-${name}`));
|
|
48
284
|
const flagValue = (name) => {
|
|
49
285
|
const i = args.indexOf(`--${name}`);
|
|
50
286
|
return i >= 0 && args[i + 1] && !args[i + 1].startsWith("-") ? args[i + 1] : undefined;
|
|
@@ -52,12 +288,114 @@ const flagValue = (name) => {
|
|
|
52
288
|
const YES = flag("yes") || flag("y");
|
|
53
289
|
const NO_AI = flag("no-ai");
|
|
54
290
|
|
|
55
|
-
|
|
291
|
+
// Lazily created. At module scope this held stdin open, so merely IMPORTING
|
|
292
|
+
// this file kept a Node process alive forever — which is why `node --test`
|
|
293
|
+
// hung the first time the codemod was given tests. Interactive state must not
|
|
294
|
+
// be a side effect of loading the module.
|
|
295
|
+
let _rl = null;
|
|
296
|
+
const rlHandle = () => (_rl ??= createInterface({ input: stdin, output: stdout }));
|
|
297
|
+
const rl = {
|
|
298
|
+
question: (q) => rlHandle().question(q),
|
|
299
|
+
close: () => { _rl?.close(); _rl = null; },
|
|
300
|
+
};
|
|
301
|
+
/**
|
|
302
|
+
* Raised when a prompt is reached but there is no more input to answer it.
|
|
303
|
+
*
|
|
304
|
+
* 2026-07-31 tick 879: piping answers into `init` crashed with a bare
|
|
305
|
+
* `✗ readline was closed`, and Ctrl+D produced `✗ Aborted with Ctrl+D`.
|
|
306
|
+
* Neither string is ours; both are Node internals reaching the user through
|
|
307
|
+
* the generic catch. The crash landed AFTER the key was written and after we
|
|
308
|
+
* had printed that it was saved, so the last thing a user saw was an internal
|
|
309
|
+
* error with no way to tell whether setup had finished.
|
|
310
|
+
*/
|
|
311
|
+
class InputEndedError extends Error {
|
|
312
|
+
constructor(cause) {
|
|
313
|
+
super("input ended before setup finished");
|
|
314
|
+
this.name = "InputEndedError";
|
|
315
|
+
this.cause = cause;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
56
319
|
async function ask(q, fallback) {
|
|
57
320
|
if (YES) return fallback ?? "y";
|
|
58
|
-
|
|
321
|
+
// Ending stdin fails in TWO different ways and both had to be caught.
|
|
322
|
+
//
|
|
323
|
+
// 1. At a LATER prompt, after a close/reopen, question() REJECTS with
|
|
324
|
+
// "readline was closed". Closing a readline over a pipe ends stdin for
|
|
325
|
+
// good, so queued answers are discarded; feeding eight answers failed
|
|
326
|
+
// exactly like feeding one.
|
|
327
|
+
// 2. At the FIRST prompt, when stdin is already ended, question() simply
|
|
328
|
+
// NEVER SETTLES. The event loop empties and the process exits with no
|
|
329
|
+
// message at all, which is worse than a bad message.
|
|
330
|
+
//
|
|
331
|
+
// Racing the readline's own close event covers both, so this cannot depend
|
|
332
|
+
// on which prompt happened to be reached first.
|
|
333
|
+
const handle = rlHandle();
|
|
334
|
+
let a = await new Promise((resolve, reject) => {
|
|
335
|
+
const onClose = () => reject(new InputEndedError());
|
|
336
|
+
handle.once("close", onClose);
|
|
337
|
+
handle.question(q).then(
|
|
338
|
+
(v) => { handle.off("close", onClose); resolve(v); },
|
|
339
|
+
(err) => { handle.off("close", onClose); reject(new InputEndedError(err)); },
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
a = a.trim();
|
|
59
343
|
return a === "" ? (fallback ?? "") : a;
|
|
60
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Open a URL in the user's default browser. Returns true if the command was
|
|
347
|
+
* dispatched, false if we could not.
|
|
348
|
+
*
|
|
349
|
+
* Deliberately fire-and-forget and never throws: this is a convenience on the
|
|
350
|
+
* signup path, and a headless box, a locked-down container, or a missing
|
|
351
|
+
* xdg-open must degrade to "here is the URL" rather than taking down a wizard
|
|
352
|
+
* that was about to work fine.
|
|
353
|
+
*
|
|
354
|
+
* The URL is a module constant, never user input, so there is no injection
|
|
355
|
+
* surface here — but it is passed as an argv element rather than interpolated
|
|
356
|
+
* into a shell string, so that stays true if someone later makes it dynamic.
|
|
357
|
+
*/
|
|
358
|
+
function openBrowser(url) {
|
|
359
|
+
try {
|
|
360
|
+
const cmd =
|
|
361
|
+
process.platform === "darwin" ? "open" :
|
|
362
|
+
process.platform === "win32" ? "cmd" : "xdg-open";
|
|
363
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
364
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
365
|
+
child.on("error", () => {}); // ENOENT on a box with no opener — already handled by the return
|
|
366
|
+
child.unref();
|
|
367
|
+
return true;
|
|
368
|
+
} catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Keep the patched file's trailing-newline state identical to the original.
|
|
375
|
+
*
|
|
376
|
+
* 2026-07-26 tick 801: the wizard was STRIPPING the final newline from every
|
|
377
|
+
* source file it rewrote. Found by running `init` on a Python project and
|
|
378
|
+
* noticing the next shell prompt ran onto the last line of the patched file.
|
|
379
|
+
*
|
|
380
|
+
* Small, but this is someone else's repository. A missing final newline is
|
|
381
|
+
* `W292` to flake8, `eol-last` to eslint, and renders as
|
|
382
|
+
* "" in the first git diff a user sees after
|
|
383
|
+
* running our installer. Introducing lint errors into a project we were
|
|
384
|
+
* invited to configure is exactly the kind of thing that makes a tool feel
|
|
385
|
+
* unsafe to run twice.
|
|
386
|
+
*
|
|
387
|
+
* Symmetric on purpose: a file that never ended with a newline does not get
|
|
388
|
+
* one added. The rule is "leave it as you found it", not "normalise it" — this
|
|
389
|
+
* tool has no business having an opinion about a convention it did not set.
|
|
390
|
+
*/
|
|
391
|
+
export function preserveTrailingNewline(original, patched) {
|
|
392
|
+
const had = original.endsWith("\n");
|
|
393
|
+
const has = patched.endsWith("\n");
|
|
394
|
+
if (had && !has) return patched + "\n";
|
|
395
|
+
if (!had && has) return patched.replace(/\n+$/, "");
|
|
396
|
+
return patched;
|
|
397
|
+
}
|
|
398
|
+
|
|
61
399
|
async function confirm(q, defaultYes = true) {
|
|
62
400
|
const hint = defaultYes ? "Y/n" : "y/N";
|
|
63
401
|
const a = (await ask(`${q} ${c.dim(`[${hint}]`)} `, defaultYes ? "y" : "n")).toLowerCase();
|
|
@@ -66,89 +404,739 @@ async function confirm(q, defaultYes = true) {
|
|
|
66
404
|
|
|
67
405
|
// ---------- project detection ----------
|
|
68
406
|
|
|
69
|
-
|
|
70
|
-
|
|
407
|
+
const NODE_SDKS = ["openai", "@anthropic-ai/sdk", "@google/generative-ai", "@google/genai", "ai", "@inferall/sdk"];
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Every package.json at or below `root`, skipping node_modules.
|
|
411
|
+
*
|
|
412
|
+
* 2026-07-26 tick 803: detection used to read ONLY the root package.json. In a
|
|
413
|
+
* workspace monorepo the root declares `workspaces` and no dependencies, so
|
|
414
|
+
* `sdks` came back empty, the wizard took its greenfield branch, wrote a
|
|
415
|
+
* hello-world example, and printed "Done. Your project is wired to InferAll"
|
|
416
|
+
* while packages/api/src/service.ts still pointed at OpenAI with
|
|
417
|
+
* OPENAI_API_KEY. Reported success, migrated nothing. Same failure family as
|
|
418
|
+
* the tick-800 codemod bug, and monorepos are not an edge case in this
|
|
419
|
+
* ecosystem.
|
|
420
|
+
*
|
|
421
|
+
* Deliberately a filesystem walk rather than parsing the `workspaces` globs:
|
|
422
|
+
* it also catches pnpm/nx/turbo layouts and plain nested packages that never
|
|
423
|
+
* declare workspaces at the root. The call-site scanner already walks to depth
|
|
424
|
+
* 6, so detection was the only thing looking in one place.
|
|
425
|
+
*
|
|
426
|
+
* Depth 3 covers packages/<name>/package.json and apps/<name>/package.json,
|
|
427
|
+
* which is where these live in practice.
|
|
428
|
+
*/
|
|
429
|
+
function findPackageJsons(root, depth = 0, acc = []) {
|
|
430
|
+
if (depth > 3) return acc;
|
|
431
|
+
const here = join(root, "package.json");
|
|
432
|
+
if (existsSync(here)) acc.push(here);
|
|
433
|
+
let entries = [];
|
|
434
|
+
try { entries = readdirSync(root); } catch { return acc; }
|
|
435
|
+
for (const name of entries) {
|
|
436
|
+
if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
|
|
437
|
+
const full = join(root, name);
|
|
438
|
+
try { if (!statSync(full).isDirectory()) continue; } catch { continue; }
|
|
439
|
+
findPackageJsons(full, depth + 1, acc);
|
|
440
|
+
}
|
|
441
|
+
return acc;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Does .gitignore list .env?
|
|
446
|
+
*
|
|
447
|
+
* TRI-STATE ON PURPOSE, and `covers: null` is the whole reason this exists.
|
|
448
|
+
*
|
|
449
|
+
* 2026-07-28 tick 844: `doctor` did `existsSync(giPath) && readFileSync(giPath)`
|
|
450
|
+
* with nothing around it. existsSync answers TRUE for a directory, so a
|
|
451
|
+
* .gitignore that was a directory threw EISDIR and an unreadable one threw
|
|
452
|
+
* EACCES -- from step 2 of five, which meant the hygiene verdict AND the key
|
|
453
|
+
* check never ran at all. MEASURED, both spellings:
|
|
454
|
+
*
|
|
455
|
+
* ✗ EISDIR: illegal operation on a directory, read
|
|
456
|
+
* ✗ EACCES: permission denied, open '/private/tmp/.../giperm/.gitignore'
|
|
457
|
+
*
|
|
458
|
+
* People run `doctor` precisely because something is already wrong, so handing
|
|
459
|
+
* them a bare errno, the absolute path of their own machine, and no diagnosis
|
|
460
|
+
* is the worst moment to fail. This is the tick 835 unreadable-.env bug in the
|
|
461
|
+
* one reader I never enumerated: 835 fixed the .env READ, 836 fixed the WRITES
|
|
462
|
+
* including .gitignore, and this READ sat between them untouched.
|
|
463
|
+
*
|
|
464
|
+
* Returning `false` on failure would have been the smaller change and the wrong
|
|
465
|
+
* one. false means "not ignored" and would print a confident warning about a
|
|
466
|
+
* file we could not open; the tick-839 lesson is that a boolean meaning both
|
|
467
|
+
* "no" and "could not tell" is two facts wearing one type.
|
|
468
|
+
*/
|
|
469
|
+
export function gitignoreCoversEnv(root) {
|
|
470
|
+
const giPath = join(root, ".gitignore");
|
|
471
|
+
if (!existsSync(giPath)) return { covers: false, problem: null };
|
|
472
|
+
try {
|
|
473
|
+
const gi = readFileSync(giPath, "utf8");
|
|
474
|
+
return {
|
|
475
|
+
covers: gi.split("\n").some((l) => [".env", ".env*", "*.env"].includes(l.trim())),
|
|
476
|
+
problem: null,
|
|
477
|
+
};
|
|
478
|
+
} catch (err) {
|
|
479
|
+
// describeFsError basenames the path, so this stops leaking the absolute
|
|
480
|
+
// path of the user's machine along with the errno.
|
|
481
|
+
return { covers: null, problem: describeFsError(err, giPath, "read") };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function detectProject(root) {
|
|
486
|
+
const p = { root, kind: null, sdks: [], pkg: null, readProblems: [] };
|
|
71
487
|
const pkgPath = join(root, "package.json");
|
|
72
488
|
if (existsSync(pkgPath)) {
|
|
73
489
|
p.kind = "node";
|
|
74
490
|
try {
|
|
75
491
|
p.pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
76
|
-
const deps = { ...p.pkg.dependencies, ...p.pkg.devDependencies };
|
|
77
|
-
for (const name of ["openai", "@anthropic-ai/sdk", "@google/generative-ai", "@google/genai", "ai", "@inferall/sdk"]) {
|
|
78
|
-
if (deps?.[name]) p.sdks.push(name);
|
|
79
|
-
}
|
|
80
492
|
} catch {
|
|
81
493
|
/* unparseable package.json — treat as bare node */
|
|
82
494
|
}
|
|
495
|
+
const found = new Set();
|
|
496
|
+
for (const f of findPackageJsons(root)) {
|
|
497
|
+
try {
|
|
498
|
+
const pkg = JSON.parse(readFileSync(f, "utf8"));
|
|
499
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
500
|
+
for (const name of NODE_SDKS) if (deps?.[name]) found.add(name);
|
|
501
|
+
} catch { /* skip unparseable */ }
|
|
502
|
+
}
|
|
503
|
+
p.sdks = [...found];
|
|
83
504
|
} else if (existsSync(join(root, "pyproject.toml")) || existsSync(join(root, "requirements.txt"))) {
|
|
84
505
|
p.kind = "python";
|
|
506
|
+
// 2026-07-28 tick 844: same unguarded existsSync-then-readFileSync as the
|
|
507
|
+
// .gitignore reader in `doctor`. MEASURED: a pyproject.toml that is a
|
|
508
|
+
// DIRECTORY killed `init` with "EISDIR: illegal operation on a directory,
|
|
509
|
+
// read", and an unreadable requirements.txt with EACCES plus the absolute
|
|
510
|
+
// path. Found by enumerating every read in this file rather than by fixing
|
|
511
|
+
// only the one that was reported, which is how the first copy survived.
|
|
512
|
+
//
|
|
513
|
+
// Swallowing the error to "" would be the tempting one-liner and it
|
|
514
|
+
// recreates the tick 805 bug documented immediately below: with no manifest
|
|
515
|
+
// text, detection finds no SDK, the wizard takes its greenfield branch and
|
|
516
|
+
// announces "Done. Your project is wired to InferAll" over a project that
|
|
517
|
+
// still calls OpenAI directly. So the failure is recorded and surfaced.
|
|
85
518
|
const reqs = ["pyproject.toml", "requirements.txt"]
|
|
86
519
|
.filter((f) => existsSync(join(root, f)))
|
|
87
|
-
.map((f) =>
|
|
520
|
+
.map((f) => {
|
|
521
|
+
try {
|
|
522
|
+
return readFileSync(join(root, f), "utf8");
|
|
523
|
+
} catch (err) {
|
|
524
|
+
p.readProblems.push(describeFsError(err, join(root, f), "read"));
|
|
525
|
+
return "";
|
|
526
|
+
}
|
|
527
|
+
})
|
|
88
528
|
.join("\n");
|
|
89
|
-
|
|
529
|
+
// 2026-07-26 tick 805: langchain-openai added. A LangChain project declares
|
|
530
|
+
// `langchain-openai`, never `openai`, so detection found nothing, the wizard
|
|
531
|
+
// took its greenfield branch and printed "Done. Your project is wired to
|
|
532
|
+
// InferAll" while app/chain.py still called OpenAI directly. Third variant
|
|
533
|
+
// of the detected-reported-success-migrated-nothing family (t803 monorepo,
|
|
534
|
+
// t804 Vercel AI). LangChain is one of the most common Python AI stacks.
|
|
535
|
+
for (const name of ["openai", "anthropic", "google-generativeai", "google-genai", "inferall-ai",
|
|
536
|
+
"langchain-openai", "langchain_openai",
|
|
537
|
+
"langchain-anthropic", "langchain_anthropic"]) {
|
|
90
538
|
if (new RegExp(`^\\s*"?${name}`, "m").test(reqs)) p.sdks.push(name);
|
|
91
539
|
}
|
|
92
540
|
}
|
|
541
|
+
|
|
542
|
+
// 2026-07-27 tick 828: a Python project with NO manifest.
|
|
543
|
+
//
|
|
544
|
+
// pip install without a committed requirements.txt is ordinary — scripts,
|
|
545
|
+
// notebooks-turned-scripts, anything using a venv or conda or uv where the
|
|
546
|
+
// lockfile is not in the repo. Neither branch above matched, so kind stayed
|
|
547
|
+
// null and the wizard said:
|
|
548
|
+
//
|
|
549
|
+
// No package.json / pyproject.toml / requirements.txt found here.
|
|
550
|
+
// Run this from your project root, or continue for a bare .env setup.
|
|
551
|
+
//
|
|
552
|
+
// while standing IN the project root, with .py files calling the exact SDKs
|
|
553
|
+
// it migrates. The advice was not merely unhelpful, it was impossible to
|
|
554
|
+
// follow: there is no other directory to run it from. Then it labelled the
|
|
555
|
+
// project "node" and offered a bare .env, so the Python code was never
|
|
556
|
+
// migrated and the user was told to go somewhere that does not exist.
|
|
557
|
+
//
|
|
558
|
+
// The manifest is evidence, not the definition. Imports are the better
|
|
559
|
+
// evidence, and the codemod handles these files perfectly once detection
|
|
560
|
+
// stops filtering them out (verified: both an OpenAI and an Anthropic site
|
|
561
|
+
// patch with the correct per-SDK base). So when no manifest is found, look
|
|
562
|
+
// for actual SDK imports in the source.
|
|
563
|
+
if (!p.kind) {
|
|
564
|
+
const imported = findPythonSdkImports(root);
|
|
565
|
+
if (imported.length) {
|
|
566
|
+
p.kind = "python";
|
|
567
|
+
p.sdks = imported;
|
|
568
|
+
p.inferredFrom = "imports";
|
|
569
|
+
} else {
|
|
570
|
+
// 2026-07-27 tick 829: the same hole, one surface over.
|
|
571
|
+
//
|
|
572
|
+
// A notebook-only project (the ordinary data-science shape: one .ipynb,
|
|
573
|
+
// pip install, nothing committed) still had no manifest and no .py, so
|
|
574
|
+
// the tick-828 fallback missed it and we printed
|
|
575
|
+
//
|
|
576
|
+
// No project detected here: ... and no Python file importing an AI SDK.
|
|
577
|
+
// If your code lives elsewhere, cd there and re-run.
|
|
578
|
+
//
|
|
579
|
+
// while findNotebookSites finds that exact notebook in under a
|
|
580
|
+
// millisecond. The assertion was false and the advice was again
|
|
581
|
+
// impossible to follow.
|
|
582
|
+
//
|
|
583
|
+
// Notebooks are deliberately NOT patched -- handing JSON to the text
|
|
584
|
+
// codemod corrupts the file (t816) -- but they ARE reported, and being
|
|
585
|
+
// reported requires getting past detection first. Detecting them sets up
|
|
586
|
+
// the honest path: .env written, notebook guidance printed, and the
|
|
587
|
+
// "still need the one-line change" count includes them.
|
|
588
|
+
//
|
|
589
|
+
// Reuses findNotebookSites and CLIENT_CLASSES rather than parsing cells
|
|
590
|
+
// again here. A parallel copy is how these two would drift apart.
|
|
591
|
+
const nbSites = findNotebookSites(root);
|
|
592
|
+
if (nbSites.length) {
|
|
593
|
+
p.kind = "python";
|
|
594
|
+
p.sdks = [...new Set(nbSites.flatMap((s) => s.hits.map((h) => CLIENT_CLASSES[h.sdk])))];
|
|
595
|
+
p.inferredFrom = "notebooks";
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
93
599
|
return p;
|
|
94
600
|
}
|
|
95
601
|
|
|
602
|
+
/**
|
|
603
|
+
* Which Python SDKs does this tree actually import? Used only when there is no
|
|
604
|
+
* manifest to read. Reuses the same walker as the codemod so the two can never
|
|
605
|
+
* disagree about which files count -- a mismatch there would recreate the
|
|
606
|
+
* detected-but-not-migrated family of bugs (t803/t804/t805) from the other end.
|
|
607
|
+
*/
|
|
608
|
+
const PY_IMPORT_SDKS = [
|
|
609
|
+
["openai", /^\s*(?:from\s+openai\b|import\s+openai\b)/m],
|
|
610
|
+
["anthropic", /^\s*(?:from\s+anthropic\b|import\s+anthropic\b)/m],
|
|
611
|
+
["langchain-openai", /^\s*(?:from\s+langchain_openai\b|import\s+langchain_openai\b)/m],
|
|
612
|
+
["langchain-anthropic", /^\s*(?:from\s+langchain_anthropic\b|import\s+langchain_anthropic\b)/m],
|
|
613
|
+
["google-generativeai", /^\s*(?:from\s+google\.generativeai\b|import\s+google\.generativeai\b)/m],
|
|
614
|
+
["google-genai", /^\s*(?:from\s+google\s+import\s+genai\b|from\s+google\.genai\b)/m],
|
|
615
|
+
];
|
|
616
|
+
|
|
617
|
+
export function findPythonSdkImports(root) {
|
|
618
|
+
const found = new Set();
|
|
619
|
+
for (const file of walk(root)) {
|
|
620
|
+
if (extname(file) !== ".py") continue;
|
|
621
|
+
let body;
|
|
622
|
+
try {
|
|
623
|
+
body = readFileSync(file, "utf8");
|
|
624
|
+
} catch {
|
|
625
|
+
continue; // unreadable file is not a detection failure (t824)
|
|
626
|
+
}
|
|
627
|
+
for (const [name, re] of PY_IMPORT_SDKS) if (re.test(body)) found.add(name);
|
|
628
|
+
}
|
|
629
|
+
return [...found];
|
|
630
|
+
}
|
|
631
|
+
|
|
96
632
|
// ---------- env / key setup ----------
|
|
97
633
|
|
|
98
|
-
|
|
634
|
+
/**
|
|
635
|
+
* The INFERALL_API_KEY currently in .env, or null.
|
|
636
|
+
*
|
|
637
|
+
* 2026-07-27 tick 820: added because upsertEnvFile replaced any existing key
|
|
638
|
+
* line unconditionally and the caller announced it as "+ written". A user with
|
|
639
|
+
* a working key in .env who re-ran init had it silently swapped for whatever
|
|
640
|
+
* the wizard resolved, and the only report was a plus sign. Measured:
|
|
641
|
+
*
|
|
642
|
+
* before INFERALL_API_KEY=ifu_MY_EXISTING_WORKING_KEY
|
|
643
|
+
* after INFERALL_API_KEY=ifu_NEW_DIFFERENT_KEY
|
|
644
|
+
* printed + INFERALL_API_KEY written to .env
|
|
645
|
+
*
|
|
646
|
+
* Destroying somebody's credential is bad; describing it as an addition is
|
|
647
|
+
* what makes it undiscoverable.
|
|
648
|
+
*/
|
|
649
|
+
/**
|
|
650
|
+
* Pull INFERALL_API_KEY out of .env text, the way dotenv would.
|
|
651
|
+
*
|
|
652
|
+
* 2026-07-27 tick 834. Three copies of `/^INFERALL_API_KEY=(.+)$/m` lived in
|
|
653
|
+
* this file and none of them understood ordinary .env syntax. MEASURED against
|
|
654
|
+
* a stub that echoes the Authorization header it received:
|
|
655
|
+
*
|
|
656
|
+
* INFERALL_API_KEY=ifu_x -> Bearer ifu_x ok
|
|
657
|
+
* INFERALL_API_KEY="ifu_x" -> Bearer "ifu_x" 401
|
|
658
|
+
* INFERALL_API_KEY='ifu_x' -> Bearer 'ifu_x' 401
|
|
659
|
+
* INFERALL_API_KEY=ifu_x # my key -> Bearer ifu_x # my key 401
|
|
660
|
+
* export INFERALL_API_KEY=ifu_x -> not found at all
|
|
661
|
+
* INFERALL_API_KEY = ifu_x -> not found at all
|
|
662
|
+
*
|
|
663
|
+
* Every one of those is valid dotenv. The first three produce a 401, and
|
|
664
|
+
* `doctor` then reports "key rejected by gateway. Check <keys page>" -- a
|
|
665
|
+
* confident wrong cause about a key that is fine, sending the user to mint a
|
|
666
|
+
* replacement that will fail in exactly the same way. The last two produce
|
|
667
|
+
* "No key in INFERALL_API_KEY or .env. Run init" while the key is sitting in
|
|
668
|
+
* the file being read.
|
|
669
|
+
*
|
|
670
|
+
* Quoting rules follow dotenv rather than anything invented here: quotes are
|
|
671
|
+
* stripped only when the same quote opens and closes the value, and an inline
|
|
672
|
+
* `#` comment is stripped only from an UNQUOTED value, because a quoted one may
|
|
673
|
+
* legitimately contain a hash.
|
|
674
|
+
*/
|
|
675
|
+
export function parseEnvKey(text) {
|
|
676
|
+
if (!text) return null;
|
|
677
|
+
const line = text.split(/\r?\n/).find((l) => /^\s*(?:export\s+)?INFERALL_API_KEY\s*=/.test(l));
|
|
678
|
+
if (line === undefined) return null;
|
|
679
|
+
let v = line.replace(/^\s*(?:export\s+)?INFERALL_API_KEY\s*=/, "").trim();
|
|
680
|
+
const quote = v[0];
|
|
681
|
+
if (quote === '"' || quote === "'") {
|
|
682
|
+
// 2026-07-28 tick 840: find the CLOSING quote rather than requiring it to
|
|
683
|
+
// be the last character of the line. Requiring last-char meant
|
|
684
|
+
//
|
|
685
|
+
// INFERALL_API_KEY="ifu_x" # note
|
|
686
|
+
//
|
|
687
|
+
// failed the pair test (last char is "e"), fell through to the unquoted
|
|
688
|
+
// branch, and returned "ifu_x" WITH the quotes -- the tick 834 bug alive in
|
|
689
|
+
// the one spelling that has both features at once. Tick 834 tested quotes
|
|
690
|
+
// and comments separately and never together, which is exactly what the
|
|
691
|
+
// build-the-awkward-combination rule exists to prevent.
|
|
692
|
+
const close = v.indexOf(quote, 1);
|
|
693
|
+
if (close === -1) return v || null; // unterminated: leave exactly as written
|
|
694
|
+
v = v.slice(1, close); // trailing whitespace/comment discarded
|
|
695
|
+
} else {
|
|
696
|
+
v = v.replace(/\s+#.*$/, "").trim(); // inline comment, unquoted values only
|
|
697
|
+
}
|
|
698
|
+
return v || null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Read .env as text, reporting WHY it could not be read rather than throwing.
|
|
703
|
+
*
|
|
704
|
+
* 2026-07-27 tick 835. Tick 834 unified how the key is PARSED across three call
|
|
705
|
+
* sites and left how the file is READ alone. Two of those three sites called
|
|
706
|
+
* readFileSync bare, so an ordinary filesystem state escaped as a Node errno:
|
|
707
|
+
*
|
|
708
|
+
* .env is a directory -> "✗ EISDIR: illegal operation on a directory, read"
|
|
709
|
+
* .env is chmod 000 -> "✗ EACCES: permission denied, open '/long/path/.env'"
|
|
710
|
+
*
|
|
711
|
+
* Neither names `.env` as the thing at fault or says what to do, and the first
|
|
712
|
+
* does not even name a file. Same leak as the bare "fetch failed" fixed in tick
|
|
713
|
+
* 832: Node's words reaching a user who asked us a question.
|
|
714
|
+
*
|
|
715
|
+
* Returns { text, code }: code is null when the file was read, "ENOENT" when
|
|
716
|
+
* there is no .env at all (not an error, just absent), or the errno.
|
|
717
|
+
*/
|
|
718
|
+
/**
|
|
719
|
+
* Is there a .env holding a key in an ANCESTOR directory?
|
|
720
|
+
*
|
|
721
|
+
* 2026-07-29 tick 850, measured in a monorepo whose key sits at the repo root
|
|
722
|
+
* with the code in packages/api:
|
|
723
|
+
*
|
|
724
|
+
* doctor (from packages/api) -> "INFERALL_API_KEY not found in environment
|
|
725
|
+
* or .env. Run npx @inferall/cli init"
|
|
726
|
+
* init (from packages/api) -> writes packages/api/.env with a SECOND key
|
|
727
|
+
*
|
|
728
|
+
* Neither step is wrong on its own. `.env` is conventionally read from the
|
|
729
|
+
* working directory and this deliberately does NOT change that. The damage is
|
|
730
|
+
* the CHAIN: doctor reports absence without saying where it looked, tells the
|
|
731
|
+
* user to run init, and init then creates a second credential for one repo
|
|
732
|
+
* without mentioning the one already there. Which key the app uses afterwards
|
|
733
|
+
* depends on the directory it happens to start in.
|
|
734
|
+
*
|
|
735
|
+
* So this reports LOCATION ONLY, never the key itself, and both commands use it
|
|
736
|
+
* to say what they checked rather than to change what they read.
|
|
737
|
+
*
|
|
738
|
+
* Stops at the repository root: a .env above someone's checkout is not ours to
|
|
739
|
+
* reason about. Bounded rather than while(true) because a symlink cycle in a
|
|
740
|
+
* path walk is a hang, and a hang in `doctor` is the worst failure it has.
|
|
741
|
+
*/
|
|
742
|
+
export function findAncestorEnvKey(startDir) {
|
|
743
|
+
let dir = startDir;
|
|
744
|
+
for (let hops = 0; hops < 32; hops++) {
|
|
745
|
+
const parent = dirname(dir);
|
|
746
|
+
if (!parent || parent === dir) return null; // filesystem root
|
|
747
|
+
dir = parent;
|
|
748
|
+
// Check the file BEFORE the .git stop, or a key sitting at the repo root
|
|
749
|
+
// (the single most likely place for it) would be missed by one step.
|
|
750
|
+
const { text } = readEnvText(dir);
|
|
751
|
+
if (text && parseEnvKey(text)) return { dir };
|
|
752
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
753
|
+
}
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export function readEnvText(root) {
|
|
758
|
+
const envPath = join(root, ".env");
|
|
759
|
+
if (!existsSync(envPath)) return { text: null, code: "ENOENT" };
|
|
760
|
+
try {
|
|
761
|
+
return { text: readFileSync(envPath, "utf8"), code: null };
|
|
762
|
+
} catch (err) {
|
|
763
|
+
return { text: null, code: err?.code || "EUNKNOWN" };
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** One line explaining an unreadable .env, or null when it read fine. */
|
|
768
|
+
export function envReadProblem(code) {
|
|
769
|
+
if (!code || code === "ENOENT") return null;
|
|
770
|
+
if (code === "EISDIR") return ".env is a directory, not a file";
|
|
771
|
+
if (code === "EACCES" || code === "EPERM") return ".env exists but cannot be read (permission denied)";
|
|
772
|
+
return `.env could not be read (${code})`;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export function readEnvKey(root) {
|
|
776
|
+
// Contract unchanged for existing callers: a key, or null for any reason.
|
|
777
|
+
return parseEnvKey(readEnvText(root).text);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Mask a key for display. Never print a whole credential. */
|
|
781
|
+
export function maskKey(k) {
|
|
782
|
+
return k && k.length > 12 ? `${k.slice(0, 8)}...${k.slice(-4)}` : "(set)";
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Why a filesystem operation failed, in words rather than an errno.
|
|
787
|
+
*
|
|
788
|
+
* 2026-07-27 tick 836. Companion to envReadProblem (tick 835), which covered
|
|
789
|
+
* READING .env. This covers the WRITES, which is where it mattered more.
|
|
790
|
+
*/
|
|
791
|
+
export function describeFsError(err, path, action) {
|
|
792
|
+
const code = err?.code;
|
|
793
|
+
const why =
|
|
794
|
+
code === "EISDIR" ? "it is a directory, not a file" :
|
|
795
|
+
code === "EACCES" || code === "EPERM" ? "permission denied" :
|
|
796
|
+
code === "EROFS" ? "the filesystem is read-only" :
|
|
797
|
+
code === "ENOSPC" ? "there is no space left on the device" :
|
|
798
|
+
code === "ENOTDIR" ? "part of the path is not a directory" :
|
|
799
|
+
code ? `of ${code}` : "of an unknown error";
|
|
800
|
+
return `could not ${action} ${basename(path)}: ${why}`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// writeKey=false leaves the .env key line alone (the user declined a replace)
|
|
804
|
+
// while still ensuring .env.example and .gitignore are correct.
|
|
805
|
+
//
|
|
806
|
+
// 2026-07-27 tick 836: every step here used to be a bare filesystem call. With
|
|
807
|
+
// .gitignore as a directory, MEASURED:
|
|
808
|
+
//
|
|
809
|
+
// verifying key against … ok
|
|
810
|
+
// ✗ EISDIR: illegal operation on a directory, read
|
|
811
|
+
//
|
|
812
|
+
// and `.env` on disk containing the key. So the wizard wrote a live credential,
|
|
813
|
+
// failed to protect it, crashed with a Node errno that names no file, and said
|
|
814
|
+
// nothing about either. That is worse than the tick-835 read case: this is a
|
|
815
|
+
// crash MID-MUTATION that leaves a secret exposed.
|
|
816
|
+
//
|
|
817
|
+
// Each step now degrades on its own (the per-file rule from tick 824) and the
|
|
818
|
+
// caller is told what actually happened, in particular whether the key reached
|
|
819
|
+
// disk without gitignore protection.
|
|
820
|
+
function upsertEnvFile(root, key, { writeKey = true } = {}) {
|
|
821
|
+
const result = { keyWritten: false, problems: [], gitignoreOk: false };
|
|
99
822
|
const envPath = join(root, ".env");
|
|
100
823
|
const line = `INFERALL_API_KEY=${key}`;
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
824
|
+
if (writeKey) {
|
|
825
|
+
try {
|
|
826
|
+
if (existsSync(envPath)) {
|
|
827
|
+
const body = readFileSync(envPath, "utf8");
|
|
828
|
+
if (/^INFERALL_API_KEY=/m.test(body)) {
|
|
829
|
+
writeFileSync(envPath, body.replace(/^INFERALL_API_KEY=.*$/m, line));
|
|
830
|
+
} else {
|
|
831
|
+
appendFileSync(envPath, `${body.endsWith("\n") || body === "" ? "" : "\n"}${line}\n`);
|
|
832
|
+
}
|
|
833
|
+
} else {
|
|
834
|
+
writeFileSync(envPath, `${line}\n`);
|
|
835
|
+
}
|
|
836
|
+
result.keyWritten = true;
|
|
837
|
+
} catch (err) {
|
|
838
|
+
result.problems.push(describeFsError(err, envPath, "write"));
|
|
107
839
|
}
|
|
108
|
-
} else {
|
|
109
|
-
writeFileSync(envPath, `${line}\n`);
|
|
110
840
|
}
|
|
111
841
|
|
|
112
842
|
const examplePath = join(root, ".env.example");
|
|
113
843
|
const exampleLine = "INFERALL_API_KEY=ifu_your_key_here";
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
844
|
+
try {
|
|
845
|
+
if (existsSync(examplePath)) {
|
|
846
|
+
const body = readFileSync(examplePath, "utf8");
|
|
847
|
+
if (!/^INFERALL_API_KEY=/m.test(body)) {
|
|
848
|
+
appendFileSync(examplePath, `${body.endsWith("\n") || body === "" ? "" : "\n"}${exampleLine}\n`);
|
|
849
|
+
}
|
|
850
|
+
} else {
|
|
851
|
+
writeFileSync(examplePath, `${exampleLine}\n`);
|
|
118
852
|
}
|
|
119
|
-
}
|
|
120
|
-
|
|
853
|
+
} catch (err) {
|
|
854
|
+
result.problems.push(describeFsError(err, examplePath, "write"));
|
|
121
855
|
}
|
|
122
856
|
|
|
123
857
|
const giPath = join(root, ".gitignore");
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
858
|
+
try {
|
|
859
|
+
if (existsSync(giPath)) {
|
|
860
|
+
const gi = readFileSync(giPath, "utf8");
|
|
861
|
+
if (!gi.split("\n").some((l) => l.trim() === ".env" || l.trim() === ".env*" || l.trim() === "*.env")) {
|
|
862
|
+
appendFileSync(giPath, `${gi.endsWith("\n") ? "" : "\n"}.env\n`);
|
|
863
|
+
console.log(` ${c.green("+")} added ${c.bold(".env")} to .gitignore`);
|
|
864
|
+
}
|
|
865
|
+
result.gitignoreOk = true;
|
|
866
|
+
} else {
|
|
867
|
+
writeFileSync(giPath, ".env\n");
|
|
868
|
+
console.log(` ${c.green("+")} created .gitignore with .env`);
|
|
869
|
+
result.gitignoreOk = true;
|
|
129
870
|
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
871
|
+
} catch (err) {
|
|
872
|
+
result.problems.push(describeFsError(err, giPath, "update"));
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// 2026-07-27 tick 821. .gitignore does NOTHING for a file git is ALREADY
|
|
876
|
+
// tracking, and we have just written an API key into that file.
|
|
877
|
+
//
|
|
878
|
+
// Measured: on a repo with .env committed, init printed
|
|
879
|
+
// + created .gitignore with .env
|
|
880
|
+
// + INFERALL_API_KEY written to .env
|
|
881
|
+
// and left `git status` showing " M .env". The next `git add .` commits a
|
|
882
|
+
// live credential, and our own output had just implied it was protected.
|
|
883
|
+
//
|
|
884
|
+
// ls-files reads the INDEX, which is the question being asked. check-ignore
|
|
885
|
+
// reads the working tree and would answer a different one (that scar is why
|
|
886
|
+
// this is spelled out).
|
|
887
|
+
// 2026-07-28 tick 839: tri-state. We have just written a credential, so
|
|
888
|
+
// "could not check" is worth a line of its own here rather than silence.
|
|
889
|
+
const track = result.keyWritten ? gitTrackStatus(root) : "untracked";
|
|
890
|
+
if (track === "tracked") {
|
|
891
|
+
console.log(` ${c.red("!")} ${c.bold(".env is tracked by git")} and now contains your API key.`);
|
|
892
|
+
console.log(` .gitignore does not apply to already-tracked files. Untrack it before committing:`);
|
|
893
|
+
console.log(c.cyan(` git rm --cached .env`));
|
|
894
|
+
} else if (track === "unknown") {
|
|
895
|
+
console.log(` ${c.yellow("~")} could not check whether ${c.bold(".env")} is tracked by git (git is not available here).`);
|
|
896
|
+
console.log(` If it is already committed, .gitignore will not protect the key you just saved.`);
|
|
897
|
+
}
|
|
898
|
+
return result;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Is .env in the git index? Returns false when there is no repo, no git on
|
|
903
|
+
* PATH, or the file is untracked. Never throws: this is advisory, and a wizard
|
|
904
|
+
* that dies because git is missing is worse than one that stays quiet.
|
|
905
|
+
*/
|
|
906
|
+
/**
|
|
907
|
+
* Is .env in git's index? "tracked" | "untracked" | "unknown".
|
|
908
|
+
*
|
|
909
|
+
* 2026-07-28 tick 839. This used to be a bare boolean, and it answered FALSE in
|
|
910
|
+
* two very different situations: git said no, and git could not be asked.
|
|
911
|
+
* MEASURED on a repo whose .env IS committed, with git removed from PATH:
|
|
912
|
+
*
|
|
913
|
+
* git present -> true
|
|
914
|
+
* git absent -> false <-- same answer as "your .env is safe"
|
|
915
|
+
*
|
|
916
|
+
* doctor then prints "✓ .env hygiene ok" over a committed credential, which is
|
|
917
|
+
* precisely the bug fixed in tick 825 coming back through a different door
|
|
918
|
+
* whenever git is not installed (slim containers, minimal CI images).
|
|
919
|
+
*
|
|
920
|
+
* A guard that cannot run must not report a pass. "unknown" is only for a git
|
|
921
|
+
* that could not be executed: a directory that is not a repository at all
|
|
922
|
+
* genuinely has no tracked files, so that stays "untracked" and the green tick
|
|
923
|
+
* there is honest.
|
|
924
|
+
*/
|
|
925
|
+
export function gitTrackStatus(root) {
|
|
926
|
+
let r;
|
|
927
|
+
try {
|
|
928
|
+
r = spawnSync("git", ["ls-files", "--error-unmatch", ".env"], {
|
|
929
|
+
cwd: root, stdio: "ignore", timeout: 5000,
|
|
930
|
+
});
|
|
931
|
+
} catch {
|
|
932
|
+
return "unknown";
|
|
133
933
|
}
|
|
934
|
+
if (r.error || r.status === null) return "unknown"; // git missing, or killed
|
|
935
|
+
if (r.status === 0) return "tracked";
|
|
936
|
+
return "untracked"; // includes "not a repo"
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Boolean contract preserved for callers that only care about the yes case. */
|
|
940
|
+
export function envIsGitTracked(root) {
|
|
941
|
+
return gitTrackStatus(root) === "tracked";
|
|
134
942
|
}
|
|
135
943
|
|
|
136
944
|
// ---------- gateway API helpers ----------
|
|
137
945
|
|
|
946
|
+
// Distinctive User-Agent so gateway-side ai_usage_log.client_ua attributes
|
|
947
|
+
// wizard-originated calls to the CLI (and which command) — lets us measure
|
|
948
|
+
// how many people actually run `init` without any gateway change or new
|
|
949
|
+
// telemetry column. `cmd` is the subcommand (init/doctor/models/status).
|
|
950
|
+
// The gateway records client_ua on every call, so the sha rides along and
|
|
951
|
+
// telemetry can tell two builds of one version apart. Semver build-metadata
|
|
952
|
+
// spelling (+sha) so anything already parsing the version keeps working.
|
|
953
|
+
const CLI_UA = `@inferall/cli/${CLI_VERSION}${BUILD_INFO?.sha ? `+${BUILD_INFO.sha}` : ""} (${cmd})`;
|
|
954
|
+
|
|
138
955
|
async function apiGet(path, key) {
|
|
139
956
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
140
|
-
|
|
957
|
+
signal: timeoutSignal(),
|
|
958
|
+
headers: { Authorization: `Bearer ${key}`, "User-Agent": CLI_UA },
|
|
141
959
|
});
|
|
142
960
|
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
143
961
|
}
|
|
144
962
|
|
|
145
|
-
|
|
963
|
+
// maxTokens is a parameter rather than a constant as of tick 817: the chat
|
|
964
|
+
// command now tells a user whose reasoning model ran out of budget to retry
|
|
965
|
+
// with a larger --max-tokens, and a message that names a flag which does not
|
|
966
|
+
// exist is worse than no message. Default is the previous hardcoded 512, so
|
|
967
|
+
// every other caller is unchanged.
|
|
968
|
+
async function apiChat(key, messages, model = FREE_MODEL, maxTokens = 512) {
|
|
146
969
|
const res = await fetch(`${API_BASE}/chat/completions`, {
|
|
970
|
+
signal: timeoutSignal(),
|
|
147
971
|
method: "POST",
|
|
148
|
-
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
149
|
-
body: JSON.stringify({ model, messages, max_tokens:
|
|
972
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "User-Agent": CLI_UA },
|
|
973
|
+
body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
|
|
150
974
|
});
|
|
151
|
-
|
|
975
|
+
// 2026-07-27 tick 833: this used to be `await res.json().catch(() => ({}))`,
|
|
976
|
+
// which collapsed two different facts into one empty object: "the server sent
|
|
977
|
+
// JSON with nothing useful in it" and "the server sent something that is not
|
|
978
|
+
// JSON at all". A proxy 502 is an HTML page, and the caller printed
|
|
979
|
+
//
|
|
980
|
+
// HTTP 502: {}
|
|
981
|
+
//
|
|
982
|
+
// Reading the body as text first keeps the distinction, and keeps a snippet
|
|
983
|
+
// so we can quote what actually arrived instead of an empty object.
|
|
984
|
+
const raw = await res.text().catch(() => "");
|
|
985
|
+
let body = {};
|
|
986
|
+
let parseFailed = false;
|
|
987
|
+
if (raw) {
|
|
988
|
+
try {
|
|
989
|
+
body = JSON.parse(raw);
|
|
990
|
+
} catch {
|
|
991
|
+
parseFailed = true;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return {
|
|
995
|
+
status: res.status,
|
|
996
|
+
body,
|
|
997
|
+
parseFailed,
|
|
998
|
+
raw,
|
|
999
|
+
contentType: res.headers.get("content-type") || "",
|
|
1000
|
+
retryAfter: res.headers.get("retry-after"),
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Read a response as JSON, or say plainly that it was not JSON.
|
|
1007
|
+
*
|
|
1008
|
+
* WHY THIS EXISTS (2026-07-31, tick 886)
|
|
1009
|
+
* -------------------------------------
|
|
1010
|
+
* `chat` has handled this correctly since tick 833: a body it cannot parse is a
|
|
1011
|
+
* FAILED call whatever the status line said. That fix was never carried to the
|
|
1012
|
+
* other three commands, and they each failed differently against a stub serving
|
|
1013
|
+
* a corporate proxy login page with HTTP 200:
|
|
1014
|
+
*
|
|
1015
|
+
* models raw Node text: Unexpected token '<', "<!DOCTYPE "...
|
|
1016
|
+
* status the same raw text
|
|
1017
|
+
* doctor rc=0 and "key accepted by gateway" <-- the bad one
|
|
1018
|
+
*
|
|
1019
|
+
* doctor is the DIAGNOSTIC command. Behind a captive portal or a proxy that
|
|
1020
|
+
* intercepts HTTPS it told the user their setup was healthy, because it looked
|
|
1021
|
+
* only at r.status and a login page is served with 200.
|
|
1022
|
+
*
|
|
1023
|
+
* Returns the same shape apiChat already returns, so nonJsonSummary works on it.
|
|
1024
|
+
*/
|
|
1025
|
+
async function readJsonBody(res) {
|
|
1026
|
+
const raw = await res.text().catch(() => "");
|
|
1027
|
+
let body = {};
|
|
1028
|
+
let parseFailed = false;
|
|
1029
|
+
if (raw) {
|
|
1030
|
+
try { body = JSON.parse(raw); } catch { parseFailed = true; }
|
|
1031
|
+
}
|
|
1032
|
+
/*
|
|
1033
|
+
* TRIAL HEADERS, 2026-08-03 tick 929.
|
|
1034
|
+
*
|
|
1035
|
+
* The gateway emits X-InferAll-Trial-Usage ("N/cap") and
|
|
1036
|
+
* X-InferAll-Trial-Status on every SUCCESSFUL call from a trial key. We were
|
|
1037
|
+
* reading content-type off this response and throwing the rest away, so the
|
|
1038
|
+
* countdown was on the wire for every call the CLI ever made and never once
|
|
1039
|
+
* reached the person spending it.
|
|
1040
|
+
*
|
|
1041
|
+
* Read here rather than at the call site because every caller goes through
|
|
1042
|
+
* this function, so a future command gets the counter for free instead of
|
|
1043
|
+
* rediscovering the header.
|
|
1044
|
+
*/
|
|
1045
|
+
return {
|
|
1046
|
+
status: res.status,
|
|
1047
|
+
body,
|
|
1048
|
+
parseFailed,
|
|
1049
|
+
raw,
|
|
1050
|
+
contentType: res.headers.get("content-type") || "",
|
|
1051
|
+
trialUsage: res.headers.get("x-inferall-trial-usage") || "",
|
|
1052
|
+
trialStatus: res.headers.get("x-inferall-trial-status") || "",
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Render "N/cap" from the gateway into a line worth printing, or null.
|
|
1058
|
+
*
|
|
1059
|
+
* Returns null when the header is absent (a paid key sends none, and a paid
|
|
1060
|
+
* user must not be shown a trial counter) or unparseable. Exported shape kept
|
|
1061
|
+
* pure so the thresholds below are testable without a network call.
|
|
1062
|
+
*
|
|
1063
|
+
* WHY IT ESCALATES RATHER THAN ALWAYS PRINTING THE SAME LINE. Measured since
|
|
1064
|
+
* the micro-trial: 9 real users hit the ceiling, and SEVEN OF THE NINE spent
|
|
1065
|
+
* all five calls within 0.0 to 0.3 hours of their first one, several through
|
|
1066
|
+
* agent tooling that burns the whole allowance inside a single task. A line
|
|
1067
|
+
* that reads identically at 4 remaining and at 1 remaining is wallpaper by the
|
|
1068
|
+
* time it matters, so the last call before the wall says so plainly.
|
|
1069
|
+
*/
|
|
1070
|
+
function trialCountdownLine(trialUsage) {
|
|
1071
|
+
const m = /^(\d+)\s*\/\s*(\d+)$/.exec((trialUsage || "").trim());
|
|
1072
|
+
if (!m) return null;
|
|
1073
|
+
const used = Number(m[1]);
|
|
1074
|
+
const cap = Number(m[2]);
|
|
1075
|
+
if (!Number.isFinite(used) || !Number.isFinite(cap) || cap <= 0) return null;
|
|
1076
|
+
const left = Math.max(0, cap - used);
|
|
1077
|
+
if (left === 0) {
|
|
1078
|
+
return `${c.yellow("~")} that was your last free call. ${c.dim(`Activate at https://inferall.ai/billing to keep going.`)}`;
|
|
1079
|
+
}
|
|
1080
|
+
if (left === 1) {
|
|
1081
|
+
return `${c.yellow("~")} 1 free call left of ${cap}. ${c.dim(`Run ${"npx @inferall/cli status"} for details.`)}`;
|
|
1082
|
+
}
|
|
1083
|
+
return `${c.dim(`· ${left} free calls left of ${cap}`)}`;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** The lines every caller prints when the gateway sent something unparseable. */
|
|
1087
|
+
function nonJsonLines(r, indent = "") {
|
|
1088
|
+
return [
|
|
1089
|
+
`${indent}${c.red("✗")} ${nonJsonSummary(r)}`,
|
|
1090
|
+
`${indent} HTTP ${r.status}, but the body is not JSON, so nothing was verified.`,
|
|
1091
|
+
`${indent} A proxy or captive portal intercepting HTTPS produces exactly this.`,
|
|
1092
|
+
];
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* When a write fails, is the DIRECTORY the problem rather than the file?
|
|
1097
|
+
*
|
|
1098
|
+
* WHY THIS EXISTS (2026-07-31, tick 887)
|
|
1099
|
+
* -------------------------------------
|
|
1100
|
+
* Measured with `init` in a directory at mode 555: every write failed with
|
|
1101
|
+
* "permission denied" and the wizard then said "fix the file above" and "until
|
|
1102
|
+
* the file above is fixed". There IS no file to fix. .env was never created,
|
|
1103
|
+
* so `chmod +w .env` cannot work, and the user is sent to look for something
|
|
1104
|
+
* that does not exist.
|
|
1105
|
+
*
|
|
1106
|
+
* That is the same defect this changelog keeps recording in a new place: the
|
|
1107
|
+
* message names a cause confidently and names the wrong one. The remedy for an
|
|
1108
|
+
* unwritable directory is different from the remedy for an unwritable file, so
|
|
1109
|
+
* the two have to be told apart before either is recommended.
|
|
1110
|
+
*/
|
|
1111
|
+
function dirIsUnwritable(root) {
|
|
1112
|
+
try {
|
|
1113
|
+
accessSync(root, fsConstants.W_OK);
|
|
1114
|
+
return false;
|
|
1115
|
+
} catch {
|
|
1116
|
+
return true;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Is this a model catalog, an object of {id: pricingObject}?
|
|
1122
|
+
*
|
|
1123
|
+
* An empty object is a legitimate catalog; emptiness is a different complaint
|
|
1124
|
+
* and check-pricing-coverage already owns it.
|
|
1125
|
+
*/
|
|
1126
|
+
export function isCatalogShape(map) {
|
|
1127
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) return false;
|
|
1128
|
+
return Object.values(map).every((v) => v && typeof v === "object" && !Array.isArray(v));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* One line describing a body we could not parse. Used wherever a caller has to
|
|
1133
|
+
* explain a non-JSON response without pasting an HTML page into a terminal.
|
|
1134
|
+
*/
|
|
1135
|
+
export function nonJsonSummary({ contentType, raw }) {
|
|
1136
|
+
const kind = /html/i.test(contentType) ? "an HTML page" : contentType ? `content-type ${contentType}` : "a non-JSON body";
|
|
1137
|
+
const title = /<title>([^<]{1,80})<\/title>/i.exec(raw || "")?.[1]?.trim();
|
|
1138
|
+
const snippet = title || (raw || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
1139
|
+
return `the gateway returned ${kind}${snippet ? `: ${c.dim(snippet)}` : ""}`;
|
|
152
1140
|
}
|
|
153
1141
|
|
|
154
1142
|
// ---------- codemod: point OpenAI SDK at InferAll ----------
|
|
@@ -156,8 +1144,43 @@ async function apiChat(key, messages, model = FREE_MODEL) {
|
|
|
156
1144
|
const SOURCE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py"]);
|
|
157
1145
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "venv", ".venv", "__pycache__", ".turbo"]);
|
|
158
1146
|
|
|
159
|
-
|
|
160
|
-
|
|
1147
|
+
// Notebooks are deliberately NOT in SOURCE_EXTS. A .ipynb is JSON, so handing
|
|
1148
|
+
// it to the text codemod would rewrite escaped source inside a JSON string and
|
|
1149
|
+
// corrupt the file. They get their own walk and their own honest report.
|
|
1150
|
+
const NOTEBOOK_EXTS = new Set([".ipynb"]);
|
|
1151
|
+
// Notebooks carry base64 image outputs, so the source-file cap is far too low
|
|
1152
|
+
// for them. Only cell.source is ever read; the outputs are skipped.
|
|
1153
|
+
const NOTEBOOK_MAX_BYTES = 8 * 1024 * 1024;
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Walk the project for source files.
|
|
1157
|
+
*
|
|
1158
|
+
* 2026-07-30 tick 854. Two of the limits here used to stop the walk SILENTLY,
|
|
1159
|
+
* and the caller then printed a count and declared success over it. MEASURED,
|
|
1160
|
+
* with three identical `new OpenAI({...})` sites in one project:
|
|
1161
|
+
*
|
|
1162
|
+
* Found 1 client constructor site(s):
|
|
1163
|
+
* Done. Your project is wired to InferAll.
|
|
1164
|
+
*
|
|
1165
|
+
* while `packages/api/src/lib/clients/openai/v1/deep.js` (one level past the
|
|
1166
|
+
* depth cap) and a 600KB file were never opened, and both still called OpenAI
|
|
1167
|
+
* directly. That is the tick 805 failure verbatim: a wizard that reports a wired
|
|
1168
|
+
* project and hands back one that throws "OPENAI_API_KEY is missing or empty" on
|
|
1169
|
+
* its first run. The codemod suite exists because of that exact outcome.
|
|
1170
|
+
*
|
|
1171
|
+
* THE LIMITS STAY. A depth bound and a size bound are correct: they keep `init`
|
|
1172
|
+
* from walking a vendored tree or reading a bundle, and raising them trades one
|
|
1173
|
+
* failure for a slower one. What was wrong is that stopping early was invisible.
|
|
1174
|
+
*
|
|
1175
|
+
* `capped` collects ONLY the limit-driven skips. SKIP_DIRS, dotfiles and
|
|
1176
|
+
* unreadable entries are deliberate and universal, and reporting them would bury
|
|
1177
|
+
* the two that actually hide a user's code in noise nobody reads.
|
|
1178
|
+
*/
|
|
1179
|
+
function* walk(dir, depth = 0, exts = SOURCE_EXTS, maxBytes = 512 * 1024, capped = null) {
|
|
1180
|
+
if (depth > 6) {
|
|
1181
|
+
capped?.push({ path: dir, why: "deeper than 6 directory levels" });
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
161
1184
|
let entries = [];
|
|
162
1185
|
try {
|
|
163
1186
|
entries = readdirSync(dir);
|
|
@@ -173,14 +1196,111 @@ function* walk(dir, depth = 0) {
|
|
|
173
1196
|
} catch {
|
|
174
1197
|
continue;
|
|
175
1198
|
}
|
|
176
|
-
if (st.isDirectory()) yield* walk(full, depth + 1);
|
|
177
|
-
else if (st.isFile() &&
|
|
1199
|
+
if (st.isDirectory()) yield* walk(full, depth + 1, exts, maxBytes, capped);
|
|
1200
|
+
else if (st.isFile() && exts.has(extname(name))) {
|
|
1201
|
+
if (st.size < maxBytes) yield full;
|
|
1202
|
+
else capped?.push({ path: full, why: `larger than ${Math.round(maxBytes / 1024)}KB` });
|
|
1203
|
+
}
|
|
178
1204
|
}
|
|
179
1205
|
}
|
|
180
1206
|
|
|
181
|
-
|
|
1207
|
+
/**
|
|
1208
|
+
* One line naming what the walk never opened, or null when it opened everything.
|
|
1209
|
+
* Separate from walk() so both the scan and its reporting read the same list.
|
|
1210
|
+
*/
|
|
1211
|
+
export function cappedSummary(capped) {
|
|
1212
|
+
if (!capped?.length) return null;
|
|
1213
|
+
const dirs = capped.filter((c) => c.why.startsWith("deeper")).length;
|
|
1214
|
+
const big = capped.length - dirs;
|
|
1215
|
+
const parts = [];
|
|
1216
|
+
if (dirs) parts.push(`${dirs} director${dirs === 1 ? "y" : "ies"} deeper than 6 levels`);
|
|
1217
|
+
if (big) parts.push(`${big} file${big === 1 ? "" : "s"} over the size limit`);
|
|
1218
|
+
return `${parts.join(" and ")} were not scanned`;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Jupyter notebooks that construct an AI client.
|
|
1223
|
+
*
|
|
1224
|
+
* 2026-07-26 tick 816. `.ipynb` was not in SOURCE_EXTS, so findCallSites never
|
|
1225
|
+
* looked inside one. But requirements.txt still lists `openai`, so `usesSdk`
|
|
1226
|
+
* was true, the wizard called findCallSites, got ZERO sites back and printed:
|
|
1227
|
+
*
|
|
1228
|
+
* ✓ AI SDK present but no un-migrated client constructors found.
|
|
1229
|
+
*
|
|
1230
|
+
* over a notebook still calling OpenAI directly. That is the same false green
|
|
1231
|
+
* tick as t811's async clients, and notebooks are where a large share of Python
|
|
1232
|
+
* AI code actually lives.
|
|
1233
|
+
*
|
|
1234
|
+
* REPORTED, NOT PATCHED, and that is a deliberate scope choice rather than an
|
|
1235
|
+
* oversight. A notebook is JSON carrying execution counts and output blobs;
|
|
1236
|
+
* round-tripping it through parse/stringify rewrites the formatting of every
|
|
1237
|
+
* line in the file, and this tool promises "your code otherwise untouched".
|
|
1238
|
+
* The existing Gemini path makes the same call for the same reason. A user
|
|
1239
|
+
* pastes one line into a cell; a mangled notebook diff is much worse than that.
|
|
1240
|
+
*
|
|
1241
|
+
* Class list comes from CLIENT_CLASSES so notebooks cannot silently support a
|
|
1242
|
+
* different set of SDKs than plain .py files do.
|
|
1243
|
+
*/
|
|
1244
|
+
/**
|
|
1245
|
+
* Does this source text already send its traffic to InferAll?
|
|
1246
|
+
*
|
|
1247
|
+
* 2026-07-27 tick 830. Extracted so `findCallSites` and `findNotebookSites`
|
|
1248
|
+
* cannot answer it differently. `doctor` needed the notebook answer and there
|
|
1249
|
+
* was no way to get one, which is how it ended up reporting "no client
|
|
1250
|
+
* constructors found" over a notebook holding a live OpenAI key.
|
|
1251
|
+
*/
|
|
1252
|
+
export function pointsAtInferAll(text) {
|
|
1253
|
+
return text.includes(new URL(API_BASE).host);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
export function findNotebookSites(root) {
|
|
182
1257
|
const sites = [];
|
|
183
|
-
for (const file of walk(root)) {
|
|
1258
|
+
for (const file of walk(root, 0, NOTEBOOK_EXTS, NOTEBOOK_MAX_BYTES)) {
|
|
1259
|
+
let nb;
|
|
1260
|
+
try {
|
|
1261
|
+
nb = JSON.parse(readFileSync(file, "utf8"));
|
|
1262
|
+
} catch {
|
|
1263
|
+
continue; // not valid JSON, or not a notebook we understand
|
|
1264
|
+
}
|
|
1265
|
+
if (!Array.isArray(nb.cells)) continue;
|
|
1266
|
+
const hits = [];
|
|
1267
|
+
nb.cells.forEach((cell, index) => {
|
|
1268
|
+
if (cell.cell_type !== "code") return;
|
|
1269
|
+
// nbformat allows source as either a string or an array of line strings.
|
|
1270
|
+
// Both shapes occur in the wild; JupyterLab writes the array form.
|
|
1271
|
+
const src = Array.isArray(cell.source) ? cell.source.join("") : (cell.source || "");
|
|
1272
|
+
if (typeof src !== "string" || !src) return;
|
|
1273
|
+
for (const sdk of Object.keys(CLIENT_CLASSES)) {
|
|
1274
|
+
// alreadyPointed is per CELL, not per file: a notebook routinely holds
|
|
1275
|
+
// one migrated client and one somebody forgot.
|
|
1276
|
+
if (new RegExp(`\\b${sdk}\\s*\\(`).test(src)) {
|
|
1277
|
+
hits.push({ index, sdk, alreadyPointed: pointsAtInferAll(src) });
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
if (hits.length) sites.push({ file, hits });
|
|
1282
|
+
}
|
|
1283
|
+
return sites;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function printNotebookGuidance(root, sites) {
|
|
1287
|
+
const total = sites.reduce((n, s) => n + s.hits.length, 0);
|
|
1288
|
+
console.log(`\n ${c.bold("Jupyter notebook")} client(s) found in ${sites.length} file(s), ${total} cell(s):`);
|
|
1289
|
+
for (const s of sites) {
|
|
1290
|
+
for (const h of s.hits) {
|
|
1291
|
+
console.log(` - ${relative(root, s.file)} ${c.dim(`cell ${h.index} (${h.sdk})`)}`);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
console.log(` These were ${c.bold("NOT changed")}. Rewriting a notebook would reformat the whole`);
|
|
1295
|
+
console.log(` file, so edit the cell yourself. Add these two arguments to the client:`);
|
|
1296
|
+
const anySdk = sites[0].hits[0].sdk;
|
|
1297
|
+
console.log(c.cyan(` ${anySdk}(base_url="${baseForSdk(anySdk)}", api_key=os.environ["INFERALL_API_KEY"])`));
|
|
1298
|
+
console.log(` ${c.dim(`(and \`import os\` in that cell if it is not there already)`)}`);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
export function findCallSites(root, capped = null) {
|
|
1302
|
+
const sites = [];
|
|
1303
|
+
for (const file of walk(root, 0, SOURCE_EXTS, 512 * 1024, capped)) {
|
|
184
1304
|
let body;
|
|
185
1305
|
try {
|
|
186
1306
|
body = readFileSync(file, "utf8");
|
|
@@ -189,7 +1309,35 @@ function findCallSites(root) {
|
|
|
189
1309
|
}
|
|
190
1310
|
const isPy = file.endsWith(".py");
|
|
191
1311
|
const baseKey = isPy ? "base_url" : "baseURL";
|
|
192
|
-
|
|
1312
|
+
// LangChain chat classes. \b does not match inside "ChatOpenAI" (t and O
|
|
1313
|
+
// are both word characters), so the plain "OpenAI" pattern never caught
|
|
1314
|
+
// these.
|
|
1315
|
+
//
|
|
1316
|
+
// 2026-07-26 tick 807 -- CORRECTING MYSELF. At tick 805 I excluded
|
|
1317
|
+
// ChatAnthropic and wrote that langchain-anthropic "uses a different
|
|
1318
|
+
// parameter name, and emitting base_url= there would produce a constructor
|
|
1319
|
+
// that raises at import". That was an assumption, and it was WRONG.
|
|
1320
|
+
// Measured by installing the package and introspecting it:
|
|
1321
|
+
//
|
|
1322
|
+
// ChatAnthropic.model_fields["anthropic_api_url"].alias == "base_url"
|
|
1323
|
+
// ChatAnthropic.model_fields["anthropic_api_key"].alias == "api_key"
|
|
1324
|
+
//
|
|
1325
|
+
// and both forms construct successfully. `base_url=` is a supported alias,
|
|
1326
|
+
// not a foreign parameter. Deferring it was right (I had not checked);
|
|
1327
|
+
// the REASON I recorded was not.
|
|
1328
|
+
// 2026-07-26 tick 811: AsyncOpenAI and AsyncAnthropic were invisible here.
|
|
1329
|
+
// \b does not match inside "AsyncOpenAI" (c and O are both word
|
|
1330
|
+
// characters), the same reason ChatOpenAI was missed at t805 -- but this
|
|
1331
|
+
// one failed WORSE. `openai` is in CODEMODDABLE, so usesSdk was true, the
|
|
1332
|
+
// wizard called findCallSites, got zero sites back, and printed
|
|
1333
|
+
//
|
|
1334
|
+
// ✓ AI SDK present but no un-migrated client constructors found.
|
|
1335
|
+
//
|
|
1336
|
+
// over an async FastAPI service that was still calling OpenAI directly.
|
|
1337
|
+
// A confident green tick, not the honest "couldn't patch" fallback. Async
|
|
1338
|
+
// clients are the norm for Python AI backends, so this is not a corner.
|
|
1339
|
+
// Both classes verified against the installed SDKs to accept base_url.
|
|
1340
|
+
for (const sdk of Object.keys(CLIENT_CLASSES)) {
|
|
193
1341
|
const ctorRe = isPy ? new RegExp(`\\b${sdk}\\s*\\(`) : new RegExp(`new\\s+${sdk}\\s*\\(`);
|
|
194
1342
|
if (!ctorRe.test(body)) continue;
|
|
195
1343
|
sites.push({
|
|
@@ -197,7 +1345,7 @@ function findCallSites(root) {
|
|
|
197
1345
|
body,
|
|
198
1346
|
isPy,
|
|
199
1347
|
sdk,
|
|
200
|
-
alreadyPointed: body
|
|
1348
|
+
alreadyPointed: pointsAtInferAll(body),
|
|
201
1349
|
hasBaseUrl: new RegExp(`${baseKey}\\s*[:=]`).test(body),
|
|
202
1350
|
});
|
|
203
1351
|
}
|
|
@@ -205,11 +1353,151 @@ function findCallSites(root) {
|
|
|
205
1353
|
return sites;
|
|
206
1354
|
}
|
|
207
1355
|
|
|
1356
|
+
/**
|
|
1357
|
+
* Every client class we can rewrite, and which base URL each one needs.
|
|
1358
|
+
*
|
|
1359
|
+
* ONE SOURCE OF TRUTH, deliberately. Before tick 811 this knowledge lived in
|
|
1360
|
+
* three places: the class list in findCallSites, and two separate
|
|
1361
|
+
* `sdk === "Anthropic" || sdk === "ChatAnthropic"` chains. The third copy had
|
|
1362
|
+
* ALREADY drifted: the manual-fallback message tested only `=== "Anthropic"`,
|
|
1363
|
+
* so a ChatAnthropic site we could not patch told the user to set the /v1 base
|
|
1364
|
+
* when LangChain's Anthropic client needs the host root. Wrong instructions to
|
|
1365
|
+
* the one user we had just failed to help automatically.
|
|
1366
|
+
*
|
|
1367
|
+
* That is the same shape as the tick-805 "third hardcoded list" bug. Adding
|
|
1368
|
+
* AsyncOpenAI and AsyncAnthropic to a fourth chain would have been asking for
|
|
1369
|
+
* it a third time, so the chains are gone.
|
|
1370
|
+
*
|
|
1371
|
+
* "anthropic" here means the SDK appends /v1 itself, so it gets the host root.
|
|
1372
|
+
*
|
|
1373
|
+
* NOT IN THIS TABLE, ON PURPOSE: llama_index.llms.openai.OpenAI. It collides
|
|
1374
|
+
* with the plain OpenAI class name, and it is TEMPTING to assume it takes
|
|
1375
|
+
* base_url. It does not. Measured against the installed package at tick 811:
|
|
1376
|
+
*
|
|
1377
|
+
* OpenAI.model_fields has api_base, and has NO base_url
|
|
1378
|
+
* OpenAI(base_url="https://api.inferall.ai/v1", ...) does NOT raise
|
|
1379
|
+
* ...and the resolved api_base stays https://api.openai.com/v1
|
|
1380
|
+
*
|
|
1381
|
+
* So patching it with base_url= would leave the project calling OpenAI
|
|
1382
|
+
* directly while we printed a green tick over it. Silently ignored is worse
|
|
1383
|
+
* than rejected. It needs an api_base= branch before it can be added.
|
|
1384
|
+
*/
|
|
1385
|
+
const CLIENT_CLASSES = {
|
|
1386
|
+
OpenAI: "openai",
|
|
1387
|
+
AsyncOpenAI: "openai",
|
|
1388
|
+
Anthropic: "anthropic",
|
|
1389
|
+
AsyncAnthropic: "anthropic",
|
|
1390
|
+
ChatOpenAI: "openai",
|
|
1391
|
+
ChatAnthropic: "anthropic",
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
// The base URL a given client class must be given. Derived, never re-typed.
|
|
1395
|
+
export function baseForSdk(sdk) {
|
|
1396
|
+
return CLIENT_CLASSES[sdk] === "anthropic" ? ANTHROPIC_BASE : API_BASE;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Repoint the CREDENTIAL inside a constructor we have just pointed at InferAll.
|
|
1400
|
+
//
|
|
1401
|
+
// 2026-07-25: the object-form branch below used to inject only `baseURL`,
|
|
1402
|
+
// leaving `apiKey: process.env.OPENAI_API_KEY` untouched while `init` wrote the
|
|
1403
|
+
// key to .env as INFERALL_API_KEY. The wizard then printed
|
|
1404
|
+
// "Done. Your project is wired to InferAll." and the user's very next run threw
|
|
1405
|
+
// OpenAIError: The OPENAI_API_KEY environment variable is missing or empty
|
|
1406
|
+
// i.e. the one-command installer reported success and left a broken project.
|
|
1407
|
+
// That is the worst possible outcome for onboarding, and it plausibly accounts
|
|
1408
|
+
// for part of the mint -> never-called gap.
|
|
1409
|
+
//
|
|
1410
|
+
// It survived because the wizard's own "test call ✓" uses the key DIRECTLY
|
|
1411
|
+
// rather than the code it just wrote; it verified a convenient proxy instead
|
|
1412
|
+
// of the path the user takes. Caught by running the generated project.
|
|
1413
|
+
//
|
|
1414
|
+
// The rewrite is scoped to the constructor's argument list (brace/paren depth)
|
|
1415
|
+
// so an unrelated OPENAI_API_KEY elsewhere in the file is left alone.
|
|
1416
|
+
export function repointApiKey(src, sdk, isPy) {
|
|
1417
|
+
const ctor = isPy
|
|
1418
|
+
? new RegExp(`\\b${sdk}\\s*\\(`)
|
|
1419
|
+
: new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\{`);
|
|
1420
|
+
const m = ctor.exec(src);
|
|
1421
|
+
if (!m) return src;
|
|
1422
|
+
const start = m.index + m[0].length;
|
|
1423
|
+
const open = isPy ? "(" : "{";
|
|
1424
|
+
const close = isPy ? ")" : "}";
|
|
1425
|
+
let depth = 1;
|
|
1426
|
+
let end = start;
|
|
1427
|
+
while (end < src.length && depth > 0) {
|
|
1428
|
+
const ch = src[end];
|
|
1429
|
+
if (ch === open) depth++;
|
|
1430
|
+
else if (ch === close) depth--;
|
|
1431
|
+
end++;
|
|
1432
|
+
}
|
|
1433
|
+
const head = src.slice(0, start);
|
|
1434
|
+
const region = src.slice(start, end);
|
|
1435
|
+
const tail = src.slice(end);
|
|
1436
|
+
|
|
1437
|
+
const want = isPy ? `os.environ["INFERALL_API_KEY"]` : `process.env.INFERALL_API_KEY`;
|
|
1438
|
+
const keyRe = isPy
|
|
1439
|
+
? /os\.(?:environ\[\s*["'](?:OPENAI|ANTHROPIC|GOOGLE|GEMINI|OPENROUTER)_API_KEY["']\s*\]|getenv\(\s*["'](?:OPENAI|ANTHROPIC|GOOGLE|GEMINI|OPENROUTER)_API_KEY["']\s*\))/g
|
|
1440
|
+
: /process\.env\.(?:OPENAI|ANTHROPIC|GOOGLE|GEMINI|OPENROUTER)_API_KEY/g;
|
|
1441
|
+
|
|
1442
|
+
if (keyRe.test(region)) {
|
|
1443
|
+
keyRe.lastIndex = 0;
|
|
1444
|
+
return head + region.replace(keyRe, want) + tail;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// 2026-07-26 tick 810: the HARDCODED-LITERAL case. Until now this function
|
|
1448
|
+
// only understood a credential read from an env var. A constructor written
|
|
1449
|
+
//
|
|
1450
|
+
// new OpenAI({ apiKey: "sk-live-abc123" })
|
|
1451
|
+
// OpenAI(api_key="sk-live-abc123")
|
|
1452
|
+
//
|
|
1453
|
+
// fell through both branches (`hasKey` is true, so we returned the region
|
|
1454
|
+
// untouched) and the caller then pointed baseURL at InferAll anyway. The
|
|
1455
|
+
// user ends up sending a foreign provider key to our gateway: the exact
|
|
1456
|
+
// "wired to InferAll, fails on first call" shape fixed at tick 800 for env
|
|
1457
|
+
// vars, still open for literals. Pre-existing in BOTH languages; found by
|
|
1458
|
+
// running the patcher over a module-qualified `openai.OpenAI(api_key="…")`.
|
|
1459
|
+
//
|
|
1460
|
+
// Deliberately keyed on the PROVIDER KEY SHAPE, not on "any string literal".
|
|
1461
|
+
// Local-inference users legitimately write api_key="EMPTY" or "ollama" for
|
|
1462
|
+
// vLLM/Ollama, and silently rewriting those would be us editing something we
|
|
1463
|
+
// do not understand. Those are left alone and reported by the post-patch
|
|
1464
|
+
// check instead. sk- covers OpenAI, Anthropic and OpenRouter; AIza is Google.
|
|
1465
|
+
const literalRe = isPy
|
|
1466
|
+
? /(\bapi_key\s*=\s*)(["'])(?:sk-[A-Za-z0-9_-]{8,}|AIza[A-Za-z0-9_-]{10,})\2/
|
|
1467
|
+
: /(\bapiKey\s*:\s*)(["'])(?:sk-[A-Za-z0-9_-]{8,}|AIza[A-Za-z0-9_-]{10,})\2/;
|
|
1468
|
+
if (literalRe.test(region)) {
|
|
1469
|
+
return head + region.replace(literalRe, `$1${want}`) + tail;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// No credential passed at all: the SDK falls back to the PROVIDER's env var,
|
|
1473
|
+
// which is just as broken once baseURL points at us. Supply ours.
|
|
1474
|
+
const hasKey = isPy ? /\bapi_key\s*=/.test(region) : /\bapiKey\s*:/.test(region);
|
|
1475
|
+
if (!hasKey) {
|
|
1476
|
+
return head + (isPy ? `api_key=${want}, ` : `apiKey: ${want}, `) + region + tail;
|
|
1477
|
+
}
|
|
1478
|
+
return head + region + tail;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// `import os` is required by every Python rewrite we emit (all of them read
|
|
1482
|
+
// os.environ["INFERALL_API_KEY"]). Skipped when the module already imports it.
|
|
1483
|
+
// The shebang must stay on line 1 to keep working, so insert below it.
|
|
1484
|
+
export function ensureImportOs(src) {
|
|
1485
|
+
// `import os` and `import os.path` both bind the name `os`. `from os import
|
|
1486
|
+
// environ` does NOT — a file with only that line still needs the import, or
|
|
1487
|
+
// the os.environ[...] we emit raises NameError. Adding a redundant import is
|
|
1488
|
+
// harmless; omitting a required one is not.
|
|
1489
|
+
if (/^\s*import os(\b|\.)/m.test(src)) return src;
|
|
1490
|
+
const lines = src.split("\n");
|
|
1491
|
+
const at = lines[0]?.startsWith("#!") ? 1 : 0;
|
|
1492
|
+
lines.splice(at, 0, "import os");
|
|
1493
|
+
return lines.join("\n");
|
|
1494
|
+
}
|
|
1495
|
+
|
|
208
1496
|
// Deterministic rewrite for the common zero-arg / simple-config constructor.
|
|
209
1497
|
// The OpenAI SDK's baseURL includes /v1; the Anthropic SDK appends /v1 itself.
|
|
210
|
-
function patchSite(site) {
|
|
1498
|
+
export function patchSite(site) {
|
|
211
1499
|
const { body, isPy, sdk } = site;
|
|
212
|
-
const base = sdk
|
|
1500
|
+
const base = baseForSdk(sdk);
|
|
213
1501
|
const baseLine = isPy
|
|
214
1502
|
? `base_url="${base}", api_key=os.environ["INFERALL_API_KEY"]`
|
|
215
1503
|
: `baseURL: "${base}", apiKey: process.env.INFERALL_API_KEY`;
|
|
@@ -219,15 +1507,80 @@ function patchSite(site) {
|
|
|
219
1507
|
const bare = new RegExp(`\\b${sdk}\\s*\\(\\s*\\)`);
|
|
220
1508
|
if (bare.test(body)) {
|
|
221
1509
|
patched = body.replace(bare, `${sdk}(${baseLine})`);
|
|
222
|
-
|
|
1510
|
+
} else {
|
|
1511
|
+
// 2026-07-26 tick 810: the keyword-argument form. Until now the Python
|
|
1512
|
+
// codemod handled ONLY the bare `OpenAI()` constructor, while the
|
|
1513
|
+
// JavaScript branch below handled both bare AND config-object form. That
|
|
1514
|
+
// asymmetry was not a decision — the branch was simply never written.
|
|
1515
|
+
//
|
|
1516
|
+
// Measured before writing this, by running the real patcher over six
|
|
1517
|
+
// realistic Python files: 5 of 6 returned null. Every one of these is a
|
|
1518
|
+
// form you would find in an actual project:
|
|
1519
|
+
//
|
|
1520
|
+
// OpenAI(api_key=os.environ["OPENAI_API_KEY"]) -> null
|
|
1521
|
+
// OpenAI(api_key=os.getenv("OPENAI_API_KEY")) -> null
|
|
1522
|
+
// self.client = OpenAI(api_key=...) (in a class) -> null
|
|
1523
|
+
// return OpenAI(\n api_key=...,\n timeout=30,\n) -> null
|
|
1524
|
+
// Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])-> null
|
|
1525
|
+
//
|
|
1526
|
+
// Those users were not silently broken (the t805 fallback prints an
|
|
1527
|
+
// honest "couldn't patch automatically"), but they were pushed onto the
|
|
1528
|
+
// AI-assist path — which costs a call and is skipped entirely on a 402 —
|
|
1529
|
+
// to do something the deterministic codemod can do exactly.
|
|
1530
|
+
//
|
|
1531
|
+
// Guards, in order of how badly each would break a file:
|
|
1532
|
+
// (?<!class\s)(?<!def\s) — `class OpenAI(Base):` and `def OpenAI(x):`
|
|
1533
|
+
// both match a bare `\bOpenAI\s*\(`, and injecting base_url= into a
|
|
1534
|
+
// class or def header produces a file that will not import.
|
|
1535
|
+
// \b — keeps `OpenAI` from matching inside `ChatOpenAI` (t805), while
|
|
1536
|
+
// still allowing the `openai.OpenAI(...)` module-qualified form.
|
|
1537
|
+
// !site.hasBaseUrl — never touch a client already given a base_url.
|
|
1538
|
+
const kw = new RegExp(`(?<!class\\s)(?<!def\\s)\\b${sdk}\\s*\\([ \\t]*`);
|
|
1539
|
+
if (kw.test(body) && !site.hasBaseUrl) {
|
|
1540
|
+
// Same separator rule as the JS branch: emit the space only when
|
|
1541
|
+
// something follows on the SAME line, so a multiline constructor keeps
|
|
1542
|
+
// its newline and does not gain trailing whitespace.
|
|
1543
|
+
patched = body.replace(kw, (match, offset, whole) => {
|
|
1544
|
+
const nextCh = whole[offset + match.length];
|
|
1545
|
+
const sep = nextCh === "\n" || nextCh === "\r" ? "" : " ";
|
|
1546
|
+
return `${sdk}(base_url="${base}",${sep}`;
|
|
1547
|
+
});
|
|
1548
|
+
patched = repointApiKey(patched, sdk, true);
|
|
1549
|
+
}
|
|
223
1550
|
}
|
|
1551
|
+
// Hoisted out of the bare branch so BOTH paths get the import they need —
|
|
1552
|
+
// every Python rewrite here emits os.environ[...]. Inserted after a shebang
|
|
1553
|
+
// rather than above it: `#!/usr/bin/env python3` only works on line 1, and
|
|
1554
|
+
// the old unconditional prepend would have silently disarmed it.
|
|
1555
|
+
if (patched) patched = ensureImportOs(patched);
|
|
224
1556
|
} else {
|
|
225
1557
|
const bare = new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\)`);
|
|
226
|
-
|
|
1558
|
+
// 2026-07-26 tick 802: consume the spaces/tabs AFTER the brace so the
|
|
1559
|
+
// injected `baseURL: ...,` does not collide with the original's leading
|
|
1560
|
+
// space and produce `, apiKey` (two spaces). Found by running init on an
|
|
1561
|
+
// Anthropic project and reading the patched line, not the code.
|
|
1562
|
+
//
|
|
1563
|
+
// [ \t]* deliberately, NOT \s*: a multiline constructor
|
|
1564
|
+
// new OpenAI({
|
|
1565
|
+
// apiKey: ...
|
|
1566
|
+
// })
|
|
1567
|
+
// must keep its newline and indentation. Collapsing that onto one line
|
|
1568
|
+
// would be real reformatting, which is a bigger sin than a double space --
|
|
1569
|
+
// the README promises "your code otherwise untouched".
|
|
1570
|
+
const obj = new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\{[ \\t]*`);
|
|
227
1571
|
if (bare.test(body)) {
|
|
228
1572
|
patched = body.replace(bare, `new ${sdk}({ ${baseLine} })`);
|
|
229
1573
|
} else if (obj.test(body) && !site.hasBaseUrl) {
|
|
230
|
-
|
|
1574
|
+
// Emit the separating space only when something follows on the SAME
|
|
1575
|
+
// line. On a multiline constructor the next character is a newline, and
|
|
1576
|
+
// a space before it is trailing whitespace (eslint no-trailing-spaces,
|
|
1577
|
+
// and a red block in the diff). Two artifacts, one decision point.
|
|
1578
|
+
patched = body.replace(obj, (match, offset, whole) => {
|
|
1579
|
+
const nextCh = whole[offset + match.length];
|
|
1580
|
+
const sep = nextCh === "\n" || nextCh === "\r" ? "" : " ";
|
|
1581
|
+
return `new ${sdk}({ baseURL: "${base}",${sep}`;
|
|
1582
|
+
});
|
|
1583
|
+
patched = repointApiKey(patched, sdk, false);
|
|
231
1584
|
}
|
|
232
1585
|
}
|
|
233
1586
|
return patched;
|
|
@@ -257,6 +1610,115 @@ function findGeminiSites(root) {
|
|
|
257
1610
|
return sites;
|
|
258
1611
|
}
|
|
259
1612
|
|
|
1613
|
+
/**
|
|
1614
|
+
* Vercel AI SDK call sites (`ai` + `@ai-sdk/<provider>`).
|
|
1615
|
+
*
|
|
1616
|
+
* 2026-07-26 tick 804: `ai` is in NODE_SDKS, so a Vercel-AI project was
|
|
1617
|
+
* DETECTED, but its code has no `new OpenAI({...})` constructor to rewrite --
|
|
1618
|
+
* the provider is `openai("gpt-4o-mini")` from `@ai-sdk/openai`. The codemod
|
|
1619
|
+
* found nothing, the wizard fell through to its greenfield branch, wrote a
|
|
1620
|
+
* hello-world, and printed "Done. Your project is wired to InferAll" while
|
|
1621
|
+
* src/gen.js still routed to OpenAI directly.
|
|
1622
|
+
*
|
|
1623
|
+
* Same failure family as the tick-803 monorepo bug: detected the project and
|
|
1624
|
+
* reported success while migrating none of it. Detection alone is not the
|
|
1625
|
+
* promise; the promise is that the code now points here.
|
|
1626
|
+
*
|
|
1627
|
+
* Unlike Gemini, this one IS re-pointable -- `createOpenAI({ baseURL, apiKey })`
|
|
1628
|
+
* is a supported factory in @ai-sdk/openai. It is left as guidance rather than
|
|
1629
|
+
* a codemod because the change is one line the user makes deliberately, and a
|
|
1630
|
+
* wrong automatic rewrite of a provider factory is worse than an accurate
|
|
1631
|
+
* instruction.
|
|
1632
|
+
*/
|
|
1633
|
+
/**
|
|
1634
|
+
* Vercel AI SDK call sites, TAGGED WITH WHICH PROVIDER PACKAGE each file uses.
|
|
1635
|
+
*
|
|
1636
|
+
* 2026-07-27 tick 819: the tag is new. The guidance printed for these files was
|
|
1637
|
+
* hardcoded to @ai-sdk/openai, so a project importing @ai-sdk/anthropic was told
|
|
1638
|
+
* to install and rewrite against a package it does not have.
|
|
1639
|
+
*/
|
|
1640
|
+
export function findVercelAiSites(root) {
|
|
1641
|
+
const sites = [];
|
|
1642
|
+
for (const file of walk(root)) {
|
|
1643
|
+
if (extname(file) === ".py") continue;
|
|
1644
|
+
let body;
|
|
1645
|
+
try { body = readFileSync(file, "utf8"); } catch { continue; }
|
|
1646
|
+
if (!/@ai-sdk\//.test(body)) continue;
|
|
1647
|
+
// 2026-07-27 tick 831: factory files used to be dropped here with the
|
|
1648
|
+
// comment "already re-pointable". Re-pointABLE is not re-pointED. The file
|
|
1649
|
+
// is now kept and tagged, because `doctor` needs to see a createOpenAI call
|
|
1650
|
+
// that never got a baseURL -- exactly what somebody who half-applied our
|
|
1651
|
+
// own snippet ends up with. `init` filters on usesFactory below and so
|
|
1652
|
+
// behaves as before.
|
|
1653
|
+
const usesFactory = /createOpenAI|createAnthropic/.test(body);
|
|
1654
|
+
const providers = [];
|
|
1655
|
+
if (/@ai-sdk\/anthropic/.test(body)) providers.push("anthropic");
|
|
1656
|
+
if (/@ai-sdk\/openai/.test(body)) providers.push("openai");
|
|
1657
|
+
// A file importing some other @ai-sdk/* provider still gets the generic
|
|
1658
|
+
// openai-compatible route, which is the honest fallback rather than
|
|
1659
|
+
// silently naming a package the file never mentions.
|
|
1660
|
+
sites.push({
|
|
1661
|
+
file,
|
|
1662
|
+
body,
|
|
1663
|
+
providers: providers.length ? providers : ["openai"],
|
|
1664
|
+
usesFactory,
|
|
1665
|
+
alreadyPointed: pointsAtInferAll(body),
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
return sites;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* 2026-07-27 tick 819. Two defects, both MEASURED against the real packages
|
|
1673
|
+
* rather than assumed, because a snippet we print is a claim.
|
|
1674
|
+
*
|
|
1675
|
+
* 1. THE SNIPPET POINTED AT AN ENDPOINT WE DO NOT SERVE. In @ai-sdk/openai v4,
|
|
1676
|
+
* `openai("gpt-4o")` targets the Responses API. Measured by pointing a
|
|
1677
|
+
* factory at a recording server:
|
|
1678
|
+
*
|
|
1679
|
+
* openai("gpt-4o") -> POST /responses
|
|
1680
|
+
* openai.chat("gpt-4o") -> POST /chat/completions
|
|
1681
|
+
*
|
|
1682
|
+
* and against production: /v1/responses is 404, /v1/chat/completions is 401.
|
|
1683
|
+
* So anybody who followed the old snippet literally got a 404 from us. The
|
|
1684
|
+
* fix is `.chat(...)`, which is a CLI-side change; adding a /v1/responses
|
|
1685
|
+
* route would be a gateway feature and a much larger decision.
|
|
1686
|
+
*
|
|
1687
|
+
* 2. IT NAMED THE WRONG PACKAGE. Every file got @ai-sdk/openai advice, so an
|
|
1688
|
+
* @ai-sdk/anthropic project was told to import a package it does not have.
|
|
1689
|
+
* createAnthropic exists and honours baseURL (verified: it reached the
|
|
1690
|
+
* recording server rather than silently ignoring the option, which is the
|
|
1691
|
+
* llamaindex api_base trap from tick 811).
|
|
1692
|
+
*
|
|
1693
|
+
* BOTH providers take API_BASE, the /v1 URL. I had assumed anthropic would need
|
|
1694
|
+
* the host root, as the raw @anthropic-ai/sdk does. It does not: this provider
|
|
1695
|
+
* appends only "/messages", so the /v1 has to already be there. Measured, and
|
|
1696
|
+
* the assumption was wrong.
|
|
1697
|
+
*/
|
|
1698
|
+
function printVercelAiGuidance(root, sites) {
|
|
1699
|
+
console.log(`\n ${c.bold("Vercel AI SDK usage")} found in ${sites.length} file(s):`);
|
|
1700
|
+
for (const s of sites) {
|
|
1701
|
+
console.log(` - ${relative(root, s.file)} ${c.dim(`(${s.providers.join(" + ")})`)}`);
|
|
1702
|
+
}
|
|
1703
|
+
console.log(` These were ${c.bold("NOT changed")}. The provider helper builds its own URL, so there`);
|
|
1704
|
+
console.log(` is no client constructor to re-point. Swap the helper for its factory form:`);
|
|
1705
|
+
const wanted = [...new Set(sites.flatMap((s) => s.providers))];
|
|
1706
|
+
for (const prov of wanted) {
|
|
1707
|
+
const isAnthropic = prov === "anthropic";
|
|
1708
|
+
const pkg = isAnthropic ? "@ai-sdk/anthropic" : "@ai-sdk/openai";
|
|
1709
|
+
const factory = isAnthropic ? "createAnthropic" : "createOpenAI";
|
|
1710
|
+
const local = isAnthropic ? "anthropic" : "openai";
|
|
1711
|
+
// .chat() on the openai provider: the default export targets /responses,
|
|
1712
|
+
// which this gateway does not serve. See the note above.
|
|
1713
|
+
const call = isAnthropic ? `${local}("${FREE_MODEL}")` : `${local}.chat("${FREE_MODEL}")`;
|
|
1714
|
+
console.log(c.dim(` // import { ${local} } from "${pkg}";`));
|
|
1715
|
+
console.log(c.cyan(` import { ${factory} } from "${pkg}";`));
|
|
1716
|
+
console.log(c.cyan(` const ${local} = ${factory}({ baseURL: "${API_BASE}", apiKey: process.env.INFERALL_API_KEY });`));
|
|
1717
|
+
console.log(c.cyan(` model: ${call} ${c.dim("// $0 in/out")}`));
|
|
1718
|
+
}
|
|
1719
|
+
console.log(` Everything downstream keeps working, and you can now name any InferAll model.`);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
260
1722
|
function printGeminiGuidance(root, sites) {
|
|
261
1723
|
console.log(`\n ${c.bold("Gemini SDK usage")} found in ${sites.length} file(s):`);
|
|
262
1724
|
for (const s of sites) console.log(` - ${relative(root, s.file)}`);
|
|
@@ -271,14 +1733,32 @@ function printGeminiGuidance(root, sites) {
|
|
|
271
1733
|
console.log(c.cyan(` const ai = new OpenAI({ baseURL: "${API_BASE}", apiKey: process.env.INFERALL_API_KEY });`));
|
|
272
1734
|
console.log(c.cyan(` await ai.chat.completions.create({ model: "gemini/gemini-2.5-flash", messages });`));
|
|
273
1735
|
}
|
|
274
|
-
|
|
1736
|
+
// ⛔ "$0 markup" CORRECTED 2026-09-03. Premium provider tokens (OpenAI,
|
|
1737
|
+
// Anthropic, Google) are NOT billed at the provider's rate: InferAll bills its
|
|
1738
|
+
// own published per-token rates, and applied-margin measures 1.30x across
|
|
1739
|
+
// those providers. The site published this correction on 2026-08-26 and said
|
|
1740
|
+
// "every live surface has been corrected" — the CLI is a live surface and was
|
|
1741
|
+
// missed, so the claim shipped on npm for another week.
|
|
1742
|
+
console.log(` Same Gemini models, one ifu_ key, one bill. Premium tokens bill at InferAll's published per-token rate; see ${BILLING_URL}. Migrate at your own pace.`);
|
|
1743
|
+
// ⛔ "IT BILLS" AND "YOU CANNOT USE IT" ARE DIFFERENT FACTS, and only the second
|
|
1744
|
+
// one is actionable for a caller who has not paid. Gemini is a PREMIUM route
|
|
1745
|
+
// ($0.15/$0.60 per M), so a no-card trial key is REFUSED here rather than
|
|
1746
|
+
// charged — it routes to free NIM models only. Following this guidance on a
|
|
1747
|
+
// new key turns a working Gemini SDK into a 402.
|
|
1748
|
+
//
|
|
1749
|
+
// ⭐ THE CAVEAT ALREADY EXISTED ONE SURFACE OVER. content/blog/
|
|
1750
|
+
// gemini-2-5-flash-api-free.mdx carries "Using a new key? A no-card trial key
|
|
1751
|
+
// routes to the free NIM models only" — the site said it, the CLI did not.
|
|
1752
|
+
// Same shape as the "$0 markup" correction directly above, which the site
|
|
1753
|
+
// shipped on 2026-08-26 while this file kept the wrong claim for another week.
|
|
1754
|
+
console.log(` ${c.yellow("~")} On a no-card trial key these premium routes are REFUSED, not billed: the trial covers the free NIM models only. Add $5 at ${BILLING_URL}, or start with model="nvidia/nemotron-3-ultra-550b-a55b" which is $0 and routes as written.`);
|
|
275
1755
|
}
|
|
276
1756
|
|
|
277
1757
|
// AI-assisted patch for call sites the deterministic codemod can't handle.
|
|
278
1758
|
// Uses the user's own key against a $0 NIM model — the wizard dogfoods the
|
|
279
1759
|
// gateway. Returns the suggested full-file replacement, or null.
|
|
280
1760
|
async function aiSuggestPatch(key, site) {
|
|
281
|
-
const base = site.sdk
|
|
1761
|
+
const base = baseForSdk(site.sdk);
|
|
282
1762
|
const prompt = [
|
|
283
1763
|
{
|
|
284
1764
|
role: "user",
|
|
@@ -294,29 +1774,284 @@ async function aiSuggestPatch(key, site) {
|
|
|
294
1774
|
const { status, body } = await apiChat(key, prompt);
|
|
295
1775
|
if (status !== 200) return { status, patched: null };
|
|
296
1776
|
const text = body?.choices?.[0]?.message?.content;
|
|
297
|
-
|
|
298
|
-
|
|
1777
|
+
// Strip fences BEFORE judging the text. The old order measured the fenced
|
|
1778
|
+
// string and then wrote the unfenced one, so the thing checked was never the
|
|
1779
|
+
// thing saved.
|
|
1780
|
+
const cleaned = (text ?? "").replace(/^```[a-z]*\n?/, "").replace(/\n?```\s*$/, "");
|
|
1781
|
+
if (!isPlausiblePatch(cleaned, site, base)) return { status, patched: null };
|
|
1782
|
+
return { status, patched: cleaned };
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Would this model output be a sane replacement for the whole file?
|
|
1787
|
+
*
|
|
1788
|
+
* WHY THIS IS STRICT (2026-07-31, tick 880)
|
|
1789
|
+
* ----------------------------------------
|
|
1790
|
+
* The previous check was:
|
|
1791
|
+
*
|
|
1792
|
+
* Math.abs(text.length - site.body.length) > site.body.length
|
|
1793
|
+
*
|
|
1794
|
+
* For an original of length L that accepts ANY reply from 1 to 2L characters.
|
|
1795
|
+
* Its failing set is empty in the only direction that destroys data. Measured:
|
|
1796
|
+
* a two-character reply overwrote a 250-character main.py, and the wizard then
|
|
1797
|
+
* printed a tick and "Done. Your project is wired to InferAll."
|
|
1798
|
+
*
|
|
1799
|
+
* The replies that actually occur in the short direction are refusals, one-line
|
|
1800
|
+
* apologies and responses truncated by max_tokens. All of them passed. This
|
|
1801
|
+
* function exists so those fail CLOSED: the site is reported as unchanged,
|
|
1802
|
+
* which the caller already accounts for and which suppresses the "wired"
|
|
1803
|
+
* closing line.
|
|
1804
|
+
*
|
|
1805
|
+
* A patch is only accepted if it did the job it was asked to do, which is a
|
|
1806
|
+
* stronger and more honest test than any length band on its own.
|
|
1807
|
+
*/
|
|
1808
|
+
export function isPlausiblePatch(text, site, base) {
|
|
1809
|
+
if (!text || !text.trim()) return false;
|
|
1810
|
+
const orig = site.body ?? "";
|
|
1811
|
+
// A whole-file rewrite should be about the same size. Anything much shorter
|
|
1812
|
+
// is a refusal or a truncation, not a rewrite.
|
|
1813
|
+
if (orig.length > 0 && text.length < orig.length * 0.6) return false;
|
|
1814
|
+
if (orig.length > 0 && text.length > orig.length * 2) return false;
|
|
1815
|
+
// 2026-07-31 tick 881: a CHARACTER ratio cannot tell a whole file from a
|
|
1816
|
+
// single line once the file is short. Measured end to end: a one-line reply
|
|
1817
|
+
// containing every marker replaced a four-line file, because 105 chars is 80%
|
|
1818
|
+
// of 130 and so cleared the 0.6 bound. The tick 880 test for this case passed
|
|
1819
|
+
// only because it used a longer original, where the same fragment fell below
|
|
1820
|
+
// the ratio. Line count does not decay with file size, so it catches the
|
|
1821
|
+
// fragment at any scale. Allow one line of slack for a dropped blank.
|
|
1822
|
+
const lines = (s) => s.split("\n").filter((l) => l.trim() !== "").length;
|
|
1823
|
+
const origLines = lines(orig);
|
|
1824
|
+
if (origLines > 0 && lines(text) < origLines - 1) return false;
|
|
1825
|
+
// 2026-07-31 tick 884: the line floor above has its OWN degenerate region,
|
|
1826
|
+
// and it is small by construction, which is exactly where a casual test never
|
|
1827
|
+
// looks. At origLines = 2 the floor is 1, so a one-line reply satisfies it,
|
|
1828
|
+
// and the character ratio cannot help because the fragment is often LONGER
|
|
1829
|
+
// than the two lines it replaces (measured: ratio 1.12). A two-line client
|
|
1830
|
+
// module is an ordinary shape:
|
|
1831
|
+
//
|
|
1832
|
+
// import OpenAI from "openai";
|
|
1833
|
+
// export default new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
1834
|
+
//
|
|
1835
|
+
// and the fragment silently deletes the import.
|
|
1836
|
+
//
|
|
1837
|
+
// So stop sampling the quantity and test the INVARIANT instead. The model is
|
|
1838
|
+
// told to change the client constructor and NOTHING else, therefore every
|
|
1839
|
+
// import the original had must survive. That holds at any file size and it
|
|
1840
|
+
// disagrees the moment it is wrong, where a size comparison agrees by luck
|
|
1841
|
+
// most of the time.
|
|
1842
|
+
const importsOf = (s) =>
|
|
1843
|
+
s.split("\n").map((l) => l.trim()).filter((l) => /^(import|from)\s/.test(l));
|
|
1844
|
+
for (const line of importsOf(orig)) {
|
|
1845
|
+
if (!text.includes(line)) return false;
|
|
1846
|
+
}
|
|
1847
|
+
// It must contain what it was told to add, or it changed nothing that matters
|
|
1848
|
+
// and there is no reason to overwrite the file with it.
|
|
1849
|
+
if (!text.includes("INFERALL_API_KEY")) return false;
|
|
1850
|
+
if (base && !text.includes(base)) return false;
|
|
1851
|
+
// And it must still be recognisably the same file: the SDK it was built from
|
|
1852
|
+
// has to survive. Catches "here is the rewritten file:" style prose.
|
|
1853
|
+
if (site.sdk && !text.includes(site.sdk)) return false;
|
|
1854
|
+
return true;
|
|
299
1855
|
}
|
|
300
1856
|
|
|
301
1857
|
// ---------- fresh-install example ----------
|
|
302
1858
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
1859
|
+
/**
|
|
1860
|
+
* 2026-07-28 tick 837: these two writes were the last bare ones in the wizard.
|
|
1861
|
+
*
|
|
1862
|
+
* Tick 836 guarded .env, .env.example and .gitignore, and MEASURED against a
|
|
1863
|
+
* read-only working directory those three now degrade correctly -- and then
|
|
1864
|
+
* this function threw one line later:
|
|
1865
|
+
*
|
|
1866
|
+
* ✗ could not write .env: permission denied
|
|
1867
|
+
* ✗ could not write .env.example: permission denied
|
|
1868
|
+
* ✗ could not update .gitignore: permission denied
|
|
1869
|
+
* ✗ EACCES: permission denied, open '/…/inferall-hello.mjs'
|
|
1870
|
+
*
|
|
1871
|
+
* Three honest lines followed by the exact raw errno they replaced. I guarded
|
|
1872
|
+
* the writers I happened to be looking at rather than enumerating them, which
|
|
1873
|
+
* is the same mistake as tick 835 (parse vs read) one surface further on.
|
|
1874
|
+
*
|
|
1875
|
+
* Returns { path, problem }: path when written, problem when it could not be.
|
|
1876
|
+
*/
|
|
1877
|
+
export function writeExample(root, kind) {
|
|
1878
|
+
const p = join(root, kind === "python" ? "inferall_hello.py" : "inferall-hello.mjs");
|
|
1879
|
+
if (existsSync(p)) return { path: null, problem: null }; // never clobber theirs
|
|
1880
|
+
// The examples are dependency-free (Node fetch, Python urllib) and read the
|
|
1881
|
+
// key from the environment, else from the .env next to the file, so a plain
|
|
1882
|
+
// `node`/`python3` run works right after init. String.raw keeps the regex
|
|
1883
|
+
// backslashes; the generated code avoids backticks and "${" for that reason.
|
|
1884
|
+
const body = kind === "python"
|
|
1885
|
+
? String.raw`"""InferAll hello. Run: python3 inferall_hello.py (no packages needed)"""
|
|
1886
|
+
import json
|
|
1887
|
+
import http.client
|
|
1888
|
+
import os
|
|
1889
|
+
import re
|
|
1890
|
+
import sys
|
|
1891
|
+
import urllib.error
|
|
1892
|
+
import urllib.request
|
|
1893
|
+
from pathlib import Path
|
|
1894
|
+
|
|
1895
|
+
API_BASE = ${JSON.stringify(API_BASE)}
|
|
1896
|
+
MODEL = ${JSON.stringify(FREE_MODEL)}
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
def fail(message):
|
|
1900
|
+
print("inferall: " + message, file=sys.stderr)
|
|
1901
|
+
sys.exit(1)
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def read_key():
|
|
1905
|
+
# INFERALL_API_KEY from the environment first, else the .env beside this file.
|
|
1906
|
+
key = os.environ.get("INFERALL_API_KEY", "").strip()
|
|
1907
|
+
if key:
|
|
1908
|
+
return key
|
|
1909
|
+
try:
|
|
1910
|
+
text = (Path(__file__).resolve().parent / ".env").read_text(encoding="utf-8-sig")
|
|
1911
|
+
except FileNotFoundError:
|
|
1912
|
+
return ""
|
|
1913
|
+
except OSError as e:
|
|
1914
|
+
fail("could not read .env: %s" % e)
|
|
1915
|
+
pattern = re.compile(r"^\s*(?:export\s+)?INFERALL_API_KEY\s*=(.*)$")
|
|
1916
|
+
for line in text.splitlines():
|
|
1917
|
+
m = pattern.match(line)
|
|
1918
|
+
if m:
|
|
1919
|
+
value = m.group(1).strip()
|
|
1920
|
+
if value[:1] in ('"', "'"):
|
|
1921
|
+
close = value.find(value[0], 1)
|
|
1922
|
+
return value[1:close] if close != -1 else value
|
|
1923
|
+
return re.sub(r"\s+#.*$", "", value).strip()
|
|
1924
|
+
return ""
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
key = read_key()
|
|
1928
|
+
if not key:
|
|
1929
|
+
fail("INFERALL_API_KEY is not set. Export it or put it in the .env next to this file.")
|
|
1930
|
+
|
|
1931
|
+
request = urllib.request.Request(
|
|
1932
|
+
API_BASE + "/chat/completions",
|
|
1933
|
+
data=json.dumps({
|
|
1934
|
+
"model": MODEL,
|
|
1935
|
+
"messages": [{"role": "user", "content": "Explain InferAll in one sentence."}],
|
|
1936
|
+
}).encode(),
|
|
1937
|
+
headers={
|
|
1938
|
+
"Authorization": "Bearer " + key,
|
|
1939
|
+
"Content-Type": "application/json",
|
|
1940
|
+
"User-Agent": "inferall-hello-example",
|
|
1941
|
+
},
|
|
1942
|
+
method="POST",
|
|
1943
|
+
)
|
|
1944
|
+
try:
|
|
1945
|
+
try:
|
|
1946
|
+
res = urllib.request.urlopen(request, timeout=120)
|
|
1947
|
+
except urllib.error.HTTPError as e:
|
|
1948
|
+
res = e
|
|
1949
|
+
with res:
|
|
1950
|
+
status, raw = res.status, res.read()
|
|
1951
|
+
except (OSError, http.client.HTTPException) as e:
|
|
1952
|
+
fail("request failed: %s" % e)
|
|
1953
|
+
|
|
1954
|
+
text = raw.decode("utf-8", "replace")
|
|
1955
|
+
try:
|
|
1956
|
+
data = json.loads(text)
|
|
1957
|
+
except ValueError:
|
|
1958
|
+
data = None
|
|
1959
|
+
if not isinstance(data, dict):
|
|
1960
|
+
data = {}
|
|
1961
|
+
|
|
1962
|
+
if not 200 <= status < 300:
|
|
1963
|
+
err = data.get("error")
|
|
1964
|
+
detail = err.get("message") if isinstance(err, dict) else err
|
|
1965
|
+
detail = detail or data.get("message") or text.strip()[:300] or "no response body"
|
|
1966
|
+
fail("HTTP %d: %s" % (status, detail))
|
|
1967
|
+
|
|
1968
|
+
try:
|
|
1969
|
+
answer = data["choices"][0]["message"]["content"]
|
|
1970
|
+
except (KeyError, IndexError, TypeError):
|
|
1971
|
+
answer = None
|
|
1972
|
+
if not isinstance(answer, str) or not answer.strip():
|
|
1973
|
+
fail("the gateway answered HTTP %d but the response had no answer text" % status)
|
|
1974
|
+
print(answer)
|
|
1975
|
+
`
|
|
1976
|
+
: String.raw`// InferAll hello. Run: node inferall-hello.mjs (Node 18+, no packages needed)
|
|
1977
|
+
import { readFileSync } from "node:fs";
|
|
1978
|
+
|
|
1979
|
+
const API_BASE = ${JSON.stringify(API_BASE)};
|
|
1980
|
+
const MODEL = ${JSON.stringify(FREE_MODEL)};
|
|
1981
|
+
|
|
1982
|
+
function fail(message) {
|
|
1983
|
+
console.error("inferall: " + message);
|
|
1984
|
+
process.exit(1);
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// INFERALL_API_KEY from the environment first, else the .env beside this file.
|
|
1988
|
+
function readKey() {
|
|
1989
|
+
const fromEnv = (process.env.INFERALL_API_KEY || "").trim();
|
|
1990
|
+
if (fromEnv) return fromEnv;
|
|
1991
|
+
let text;
|
|
1992
|
+
try {
|
|
1993
|
+
text = readFileSync(new URL(".env", import.meta.url), "utf8");
|
|
1994
|
+
} catch (err) {
|
|
1995
|
+
if (err.code === "ENOENT") return "";
|
|
1996
|
+
fail("could not read .env: " + err.message);
|
|
1997
|
+
}
|
|
1998
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1999
|
+
const m = line.match(/^\s*(?:export\s+)?INFERALL_API_KEY\s*=(.*)$/);
|
|
2000
|
+
if (m) {
|
|
2001
|
+
const value = m[1].trim();
|
|
2002
|
+
if (value[0] === '"' || value[0] === "'") {
|
|
2003
|
+
const close = value.indexOf(value[0], 1);
|
|
2004
|
+
return close === -1 ? value : value.slice(1, close);
|
|
2005
|
+
}
|
|
2006
|
+
return value.replace(/\s+#.*$/, "").trim();
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
return "";
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
const key = readKey();
|
|
2013
|
+
if (!key) fail("INFERALL_API_KEY is not set. Export it or put it in the .env next to this file.");
|
|
2014
|
+
|
|
2015
|
+
let res;
|
|
2016
|
+
let raw;
|
|
2017
|
+
try {
|
|
2018
|
+
res = await fetch(API_BASE + "/chat/completions", {
|
|
2019
|
+
method: "POST",
|
|
2020
|
+
headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
|
|
2021
|
+
body: JSON.stringify({
|
|
2022
|
+
model: MODEL,
|
|
2023
|
+
messages: [{ role: "user", content: "Explain InferAll in one sentence." }],
|
|
2024
|
+
}),
|
|
2025
|
+
signal: AbortSignal.timeout(120_000),
|
|
2026
|
+
});
|
|
2027
|
+
raw = await res.text();
|
|
2028
|
+
} catch (err) {
|
|
2029
|
+
fail("request failed: " + (err.name === "TimeoutError" ? "timed out after 120s" : err.message));
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
let data;
|
|
2033
|
+
try {
|
|
2034
|
+
data = JSON.parse(raw);
|
|
2035
|
+
} catch {}
|
|
2036
|
+
|
|
2037
|
+
if (!res.ok) {
|
|
2038
|
+
const e = data?.error;
|
|
2039
|
+
const detail = (typeof e === "string" ? e : e?.message) || data?.message || raw.trim().slice(0, 300) || res.statusText;
|
|
2040
|
+
fail("HTTP " + res.status + ": " + detail);
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const answer = data?.choices?.[0]?.message?.content;
|
|
2044
|
+
if (typeof answer !== "string" || !answer.trim()) {
|
|
2045
|
+
fail("the gateway answered HTTP " + res.status + " but the response had no answer text");
|
|
2046
|
+
}
|
|
2047
|
+
console.log(answer);
|
|
2048
|
+
`;
|
|
2049
|
+
try {
|
|
2050
|
+
writeFileSync(p, body);
|
|
2051
|
+
return { path: p, problem: null };
|
|
2052
|
+
} catch (err) {
|
|
2053
|
+
return { path: null, problem: describeFsError(err, p, "write") };
|
|
2054
|
+
}
|
|
320
2055
|
}
|
|
321
2056
|
|
|
322
2057
|
// ---------- models ----------
|
|
@@ -334,13 +2069,32 @@ function priceLabel(p) {
|
|
|
334
2069
|
}
|
|
335
2070
|
|
|
336
2071
|
async function modelsCmd() {
|
|
337
|
-
const res = await fetch(`${ANTHROPIC_BASE}/ai/v1/models
|
|
2072
|
+
const res = await fetch(`${ANTHROPIC_BASE}/ai/v1/models`, { signal: timeoutSignal() });
|
|
338
2073
|
if (!res.ok) {
|
|
339
|
-
|
|
2074
|
+
for (const line of httpFailureLines(res.status)) console.log(line);
|
|
2075
|
+
rl.close();
|
|
2076
|
+
exit(1);
|
|
2077
|
+
}
|
|
2078
|
+
const parsed = await readJsonBody(res);
|
|
2079
|
+
if (parsed.parseFailed) {
|
|
2080
|
+
for (const line of nonJsonLines(parsed)) console.log(line);
|
|
2081
|
+
rl.close();
|
|
2082
|
+
exit(1);
|
|
2083
|
+
}
|
|
2084
|
+
const map = parsed.body;
|
|
2085
|
+
// 2026-07-31 tick 886: valid JSON of the WRONG SHAPE. Measured against a stub
|
|
2086
|
+
// returning an ARRAY: Object.entries gives ["0", "gpt-4o"], p is a string, and
|
|
2087
|
+
// the renderer died on `Cannot read properties of undefined (reading
|
|
2088
|
+
// 'padEnd')`. The catalog is an object of {id: pricing}; anything else is a
|
|
2089
|
+
// different service answering at this URL, which is what a mistyped
|
|
2090
|
+
// INFERALL_API_BASE looks like.
|
|
2091
|
+
if (!isCatalogShape(map)) {
|
|
2092
|
+
console.log(`${c.red("✗")} the gateway returned JSON, but not a model catalog`);
|
|
2093
|
+
console.log(` Expected an object of model ids to pricing. Got ${Array.isArray(map) ? "an array" : typeof map}.`);
|
|
2094
|
+
console.log(` Check ${c.cyan("INFERALL_API_BASE")}: something else is answering at that address.`);
|
|
340
2095
|
rl.close();
|
|
341
2096
|
exit(1);
|
|
342
2097
|
}
|
|
343
|
-
const map = await res.json();
|
|
344
2098
|
let rows = Object.entries(map).map(([id, p]) => ({
|
|
345
2099
|
id,
|
|
346
2100
|
type: p.type,
|
|
@@ -362,53 +2116,204 @@ async function modelsCmd() {
|
|
|
362
2116
|
console.log(`${r.id.padEnd(w)}${r.type.padEnd(12)}${price}`);
|
|
363
2117
|
}
|
|
364
2118
|
const freeCount = Object.values(map).filter((p) => p.type === "token" && !p.inputPerM && !p.outputPerM).length;
|
|
365
|
-
console.log(`\n${Object.keys(map).length} models
|
|
2119
|
+
console.log(`\n${Object.keys(map).length} models, ${c.green(`${freeCount} at $0`)} (NVIDIA NIM). Use any with your InferAll key.\n`);
|
|
366
2120
|
rl.close();
|
|
367
2121
|
}
|
|
368
2122
|
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
if (
|
|
2123
|
+
// Resolve the API key from --key, env, or .env (in that order); exit if none.
|
|
2124
|
+
function resolveKey() {
|
|
2125
|
+
let key = flagValue("key") || env.INFERALL_API_KEY || "";
|
|
2126
|
+
if (!key) {
|
|
2127
|
+
// 2026-07-27 tick 834: shared parser (quotes, `export`, spaces, comments).
|
|
2128
|
+
// 2026-07-27 tick 835: shared READER too, so an unreadable .env is named
|
|
2129
|
+
// rather than escaping as a Node errno.
|
|
2130
|
+
const { text, code } = readEnvText(cwd());
|
|
2131
|
+
const problem = envReadProblem(code);
|
|
2132
|
+
if (problem) {
|
|
2133
|
+
console.log(`${c.red("✗")} ${problem}`);
|
|
2134
|
+
console.log(` ${c.dim(join(cwd(), ".env"))}`);
|
|
2135
|
+
console.log(` Fix it, or pass the key directly: ${c.bold("--key ifu_...")}`);
|
|
2136
|
+
rl.close();
|
|
2137
|
+
exit(1);
|
|
2138
|
+
}
|
|
2139
|
+
key = parseEnvKey(text) || "";
|
|
379
2140
|
}
|
|
380
2141
|
if (!key) {
|
|
381
|
-
console.log(`${c.red("✗")} No key in INFERALL_API_KEY or .env
|
|
2142
|
+
console.log(`${c.red("✗")} No key in INFERALL_API_KEY or .env. Run ${c.bold("npx @inferall/cli init")}`);
|
|
2143
|
+
rl.close();
|
|
2144
|
+
exit(1);
|
|
2145
|
+
}
|
|
2146
|
+
return key;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// ---------- chat ----------
|
|
2150
|
+
|
|
2151
|
+
// One-shot chat: `inferall chat "prompt"` (or piped stdin). The fastest way to
|
|
2152
|
+
// confirm a key works and see a real response — dogfoods the gateway on a $0
|
|
2153
|
+
// NIM model by default (a taster's first "it works" moment without writing code).
|
|
2154
|
+
async function chatCmd() {
|
|
2155
|
+
const key = resolveKey();
|
|
2156
|
+
const model = flagValue("model") || FREE_MODEL;
|
|
2157
|
+
// Every flag that takes a VALUE must be listed here, or its value is
|
|
2158
|
+
// silently treated as part of the prompt. Adding --max-tokens without this
|
|
2159
|
+
// would have made `chat "hi" --max-tokens 900` send the prompt "hi 900".
|
|
2160
|
+
const VALUE_FLAGS = ["--key", "--model", "--max-tokens"];
|
|
2161
|
+
let prompt = args.filter((a, idx) => idx > 0 && !a.startsWith("-") && !VALUE_FLAGS.includes(args[idx - 1])).join(" ").trim();
|
|
2162
|
+
if (!prompt && !stdin.isTTY) {
|
|
2163
|
+
prompt = readFileSync(0, "utf8").trim(); // piped stdin
|
|
2164
|
+
}
|
|
2165
|
+
if (!prompt) {
|
|
2166
|
+
console.log(`Usage: npx @inferall/cli chat "your prompt" ${c.dim("(or pipe: echo \"hi\" | npx @inferall/cli chat)")}`);
|
|
2167
|
+
rl.close();
|
|
2168
|
+
exit(1);
|
|
2169
|
+
}
|
|
2170
|
+
// Parsed here rather than inside apiChat so the flag is visible where the
|
|
2171
|
+
// error message that recommends it also lives.
|
|
2172
|
+
const rawMax = flagValue("max-tokens");
|
|
2173
|
+
const parsedMax = rawMax === undefined ? 512 : parseInt(rawMax, 10);
|
|
2174
|
+
const maxTokens = Number.isFinite(parsedMax) && parsedMax > 0 ? parsedMax : 512;
|
|
2175
|
+
const reply = await apiChat(key, [{ role: "user", content: prompt }], model, maxTokens);
|
|
2176
|
+
const { status, body } = reply;
|
|
2177
|
+
// 2026-07-27 tick 833: a body we could not parse is a FAILED call whatever
|
|
2178
|
+
// the status line said. Measured: a 200 whose JSON was truncated mid-stream
|
|
2179
|
+
// (a proxy cutting the response) printed `{}` and exited 0, so a script
|
|
2180
|
+
// wrapping this command saw success and an empty answer. Reporting success
|
|
2181
|
+
// for a call that produced nothing is the rung of the ladder this product
|
|
2182
|
+
// keeps landing on, and it is worth its own branch before the status checks.
|
|
2183
|
+
if (reply.parseFailed) {
|
|
2184
|
+
console.log(`${c.red("✗")} ${nonJsonSummary(reply)}`);
|
|
2185
|
+
console.log(` ${c.dim(`HTTP ${status}. Nothing usable came back, so this call did not succeed.`)}`);
|
|
2186
|
+
if (status >= 500 || status === 502 || status === 503) {
|
|
2187
|
+
console.log(` That is an infrastructure error, not a problem with your key. Retry shortly.`);
|
|
2188
|
+
}
|
|
2189
|
+
rl.close();
|
|
2190
|
+
exit(1);
|
|
2191
|
+
}
|
|
2192
|
+
if (status === 200) {
|
|
2193
|
+
const choice = body?.choices?.[0];
|
|
2194
|
+
const text = choice?.message?.content;
|
|
2195
|
+
if (text) {
|
|
2196
|
+
console.log(text);
|
|
2197
|
+
/*
|
|
2198
|
+
* Printed to STDERR, deliberately. `chat` is piped: the usage line above
|
|
2199
|
+
* documents `echo "hi" | npx @inferall/cli chat`, and people redirect the
|
|
2200
|
+
* answer into files and other commands. A counter on stdout would corrupt
|
|
2201
|
+
* every one of those, which is a worse bug than the blindness it fixes.
|
|
2202
|
+
* stderr reaches a human at a terminal and stays out of the pipe.
|
|
2203
|
+
*/
|
|
2204
|
+
const countdown = trialCountdownLine(reply.trialUsage);
|
|
2205
|
+
if (countdown) process.stderr.write(`${countdown}\n`);
|
|
2206
|
+
} else if (choice?.finish_reason === "length") {
|
|
2207
|
+
// 2026-07-26 tick 817. Reasoning models (nemotron and friends) spend the
|
|
2208
|
+
// token budget on reasoning_content and return content: null with
|
|
2209
|
+
// finish_reason "length". Measured at tick 811: nemotron at max_tokens=64
|
|
2210
|
+
// returns null, at 256 it answers. The old code fell through to
|
|
2211
|
+
// JSON.stringify(body), so a user who asked a question got a JSON blob
|
|
2212
|
+
// back with no hint that the fix is simply a bigger budget.
|
|
2213
|
+
console.log(c.yellow("The model used its whole token budget before answering."));
|
|
2214
|
+
console.log(` ${c.dim("Reasoning models spend the budget thinking first. Retry with a larger")} ${c.bold("--max-tokens")}${c.dim(", or use a non-reasoning model such as")} ${c.bold(FREE_MODEL)}${c.dim(".")}`);
|
|
2215
|
+
rl.close();
|
|
2216
|
+
exit(1);
|
|
2217
|
+
} else {
|
|
2218
|
+
// 2026-07-27 tick 833: this printed a JSON blob and then fell out of the
|
|
2219
|
+
// function, so the process exited 0. A 200 carrying no answer is still a
|
|
2220
|
+
// call that produced nothing, and a caller piping this into anything had
|
|
2221
|
+
// no way to tell. The blob is kept -- it is the only diagnostic there is
|
|
2222
|
+
// -- but it is labelled and the exit code now says what happened.
|
|
2223
|
+
console.log(`${c.yellow("~")} the gateway returned 200 with no answer in it:`);
|
|
2224
|
+
console.log(` ${JSON.stringify(body).slice(0, 400)}`);
|
|
2225
|
+
rl.close();
|
|
2226
|
+
exit(1);
|
|
2227
|
+
}
|
|
2228
|
+
} else {
|
|
2229
|
+
// 2026-07-26 tick 817: SHOW THE SERVER'S OWN MESSAGE.
|
|
2230
|
+
//
|
|
2231
|
+
// The gateway writes careful, model-aware 402 and 429 copy (ticks 812 and
|
|
2232
|
+
// 815 were spent making it accurate) and this command threw it away. On 402
|
|
2233
|
+
// it printed "your free allowance is used up or the account isn't activated
|
|
2234
|
+
// yet", which conflates two different states and is simply WRONG for a user
|
|
2235
|
+
// who was never activated: nothing of theirs is used up. The accurate
|
|
2236
|
+
// sentence was in the response we discarded. On 429 it dumped raw JSON at
|
|
2237
|
+
// the user, so the gateway's readable "retry in 42s" arrived wrapped in
|
|
2238
|
+
// braces.
|
|
2239
|
+
//
|
|
2240
|
+
// Both are the same mistake: the client second-guessing a server that knows
|
|
2241
|
+
// more than it does.
|
|
2242
|
+
const serverMsg = body?.error?.message || body?.message;
|
|
2243
|
+
console.log(`${status === 402 ? c.yellow("402") : c.red(`HTTP ${status}`)}: ${serverMsg || JSON.stringify(body).slice(0, 300)}`);
|
|
2244
|
+
// Keep the pointer to `status`, which is genuinely useful and not something
|
|
2245
|
+
// the server can know to suggest. Only for billing states, where "where do
|
|
2246
|
+
// I stand" is the actual next question.
|
|
2247
|
+
if (status === 402) {
|
|
2248
|
+
console.log(` ${c.dim("check your account:")} ${c.bold("npx @inferall/cli status")}`);
|
|
2249
|
+
}
|
|
382
2250
|
rl.close();
|
|
383
2251
|
exit(1);
|
|
384
2252
|
}
|
|
2253
|
+
rl.close();
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// ---------- status ----------
|
|
2257
|
+
|
|
2258
|
+
// GET /ai/v1/key/status (host root, API-key auth) — tier, trial counter,
|
|
2259
|
+
// balance, savings. Answers "did my payment land?" without opening a browser.
|
|
2260
|
+
async function statusCmd() {
|
|
2261
|
+
const key = resolveKey();
|
|
385
2262
|
const res = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
|
|
2263
|
+
signal: timeoutSignal(),
|
|
386
2264
|
headers: { Authorization: `Bearer ${key}` },
|
|
387
2265
|
});
|
|
388
2266
|
if (!res.ok) {
|
|
389
|
-
|
|
2267
|
+
for (const line of httpFailureLines(res.status)) console.log(line);
|
|
2268
|
+
rl.close();
|
|
2269
|
+
exit(1);
|
|
2270
|
+
}
|
|
2271
|
+
const parsedStatus = await readJsonBody(res);
|
|
2272
|
+
if (parsedStatus.parseFailed) {
|
|
2273
|
+
for (const line of nonJsonLines(parsedStatus)) console.log(line);
|
|
390
2274
|
rl.close();
|
|
391
2275
|
exit(1);
|
|
392
2276
|
}
|
|
393
|
-
const s =
|
|
2277
|
+
const s = parsedStatus.body;
|
|
394
2278
|
console.log(`\n${c.bold("InferAll account status")} ${c.dim(`(key ${key.slice(0, 8)}…)`)}\n`);
|
|
395
2279
|
if (s.project) {
|
|
396
|
-
console.log(` tier: ${c.cyan(s.tier)} ${c.dim("(project key
|
|
2280
|
+
console.log(` tier: ${c.cyan(s.tier)} ${c.dim("(project key, billed separately, no trial counter)")}\n`);
|
|
397
2281
|
rl.close();
|
|
398
2282
|
return;
|
|
399
2283
|
}
|
|
400
2284
|
const active = s.has_paid_successfully;
|
|
401
2285
|
console.log(` tier: ${c.cyan(s.tier)}`);
|
|
402
2286
|
console.log(` activated: ${active ? c.green("yes") : c.yellow("not yet")}${!active && s.has_card ? c.dim(" (checkout started, not settled)") : ""}`);
|
|
403
|
-
|
|
2287
|
+
// 2026-07-31 tick 882: this was a bare s.balance_usd.toFixed(2), so a
|
|
2288
|
+
// response missing the field killed the command with a raw
|
|
2289
|
+
// "Cannot read properties of undefined (reading 'toFixed')". The CLI is
|
|
2290
|
+
// published independently of the gateway, so an older client meeting a
|
|
2291
|
+
// renamed or dropped field is a version-skew case, not a hypothetical. Say
|
|
2292
|
+
// the number is unavailable rather than throwing Node's words at the user.
|
|
2293
|
+
const bal = typeof s.balance_usd === "number" && Number.isFinite(s.balance_usd) ? s.balance_usd : null;
|
|
2294
|
+
console.log(` balance: ${bal === null ? c.yellow("unavailable (gateway did not report it)") : bal > 0 ? c.green(`$${bal.toFixed(2)}`) : `$${bal.toFixed(2)}`}`);
|
|
404
2295
|
if (s.trial) console.log(` trial: ${s.trial.remaining}/${s.trial.cap} calls remaining`);
|
|
405
2296
|
const sv = s.savings_7d;
|
|
406
2297
|
if (sv && (sv.vs_gpt4o_mini_usd > 0 || sv.vs_claude_sonnet_usd > 0)) {
|
|
407
2298
|
console.log(` saved 7d: $${sv.vs_gpt4o_mini_usd.toFixed(2)} vs GPT-4o-mini · $${sv.vs_claude_sonnet_usd.toFixed(2)} vs Claude Sonnet`);
|
|
408
2299
|
}
|
|
409
2300
|
if (!active) {
|
|
410
|
-
|
|
411
|
-
|
|
2301
|
+
// 2026-07-25: `active` is has_paid_successfully, so before this branch was
|
|
2302
|
+
// split EVERY unpaid user got told to buy the $5 pack -- including someone
|
|
2303
|
+
// holding 5 unused free calls. That is the same mistake the /keys page made
|
|
2304
|
+
// ("your calls will 402 until you pay") at the moment activation is decided.
|
|
2305
|
+
// trial.active comes from the gateway and means "this key can call RIGHT
|
|
2306
|
+
// NOW", so the CTA no longer has to be inferred from payment state.
|
|
2307
|
+
if (s.trial?.active) {
|
|
2308
|
+
const n = s.trial.remaining;
|
|
2309
|
+
console.log(`\n ${c.green("You can call right now")}: ${c.bold(`${n} free request${n === 1 ? "" : "s"}`)} on 10+ open models, no card needed:`);
|
|
2310
|
+
console.log(` ${c.cyan('npx @inferall/cli chat "hello"')}`);
|
|
2311
|
+
console.log(c.dim(` When those run out, the one-time $5 starter pack keeps you going (it becomes`));
|
|
2312
|
+
console.log(c.dim(` spendable balance, not a fee): ${BILLING_URL}`));
|
|
2313
|
+
} else {
|
|
2314
|
+
console.log(`\n Activate with the one-time ${c.bold("$5 starter pack")} (becomes spendable balance):`);
|
|
2315
|
+
console.log(` ${c.cyan(BILLING_URL)}`);
|
|
2316
|
+
}
|
|
412
2317
|
}
|
|
413
2318
|
console.log("");
|
|
414
2319
|
rl.close();
|
|
@@ -417,51 +2322,189 @@ async function statusCmd() {
|
|
|
417
2322
|
// ---------- doctor ----------
|
|
418
2323
|
|
|
419
2324
|
async function doctor() {
|
|
420
|
-
console.log(`\n${c.bold("InferAll doctor")} ${c.dim(
|
|
2325
|
+
console.log(`\n${c.bold("InferAll doctor")} ${c.dim(`checks your setup against ${API_BASE}`)}\n`);
|
|
421
2326
|
const root = cwd();
|
|
422
2327
|
let ok = true;
|
|
423
2328
|
|
|
424
2329
|
// 1. key present
|
|
425
2330
|
let key = env.INFERALL_API_KEY || "";
|
|
426
2331
|
const envPath = join(root, ".env");
|
|
427
|
-
if (!key
|
|
428
|
-
|
|
429
|
-
|
|
2332
|
+
if (!key) {
|
|
2333
|
+
// 2026-07-27 tick 834: shared parser (quotes, `export`, spaces, comments).
|
|
2334
|
+
// 2026-07-27 tick 835: shared reader. doctor is the command people run to
|
|
2335
|
+
// find out what is wrong, so "EISDIR: illegal operation on a directory,
|
|
2336
|
+
// read" was the least useful thing it could have said: it names no file, no
|
|
2337
|
+
// cause and no next step.
|
|
2338
|
+
const { text, code } = readEnvText(root);
|
|
2339
|
+
const problem = envReadProblem(code);
|
|
2340
|
+
if (problem) {
|
|
2341
|
+
console.log(` ${c.red("✗")} ${problem}`);
|
|
2342
|
+
console.log(` ${c.dim(envPath)}`);
|
|
2343
|
+
console.log(` Fix that file, or set ${c.cyan("INFERALL_API_KEY")} in your environment.`);
|
|
2344
|
+
rl.close();
|
|
2345
|
+
exit(1);
|
|
2346
|
+
}
|
|
2347
|
+
key = parseEnvKey(text) || "";
|
|
430
2348
|
}
|
|
431
2349
|
if (!key) {
|
|
432
|
-
|
|
2350
|
+
// 2026-07-29 tick 850: say WHERE we looked, and whether a parent directory
|
|
2351
|
+
// already has one. The old line reported absence with no scope, then sent
|
|
2352
|
+
// the user to `init`, which in a monorepo writes a second key beside the
|
|
2353
|
+
// first. Naming the location turns a wrong instruction into a correct one.
|
|
2354
|
+
const ancestor = findAncestorEnvKey(root);
|
|
2355
|
+
console.log(` ${c.red("✗")} INFERALL_API_KEY not in the environment, and no key in ${c.bold(".env")} here`);
|
|
2356
|
+
console.log(` ${c.dim(`looked in ${root}`)}`);
|
|
2357
|
+
if (ancestor) {
|
|
2358
|
+
const rel = relative(root, ancestor.dir) || ".";
|
|
2359
|
+
console.log(` ${c.yellow("~")} a parent directory already has one: ${c.bold(join(rel, ".env"))}`);
|
|
2360
|
+
console.log(` Run this from there, or copy that key into this package.`);
|
|
2361
|
+
console.log(` ${c.bold("Do not run init here")} unless you want a second key for the same repo.`);
|
|
2362
|
+
} else {
|
|
2363
|
+
console.log(` Run ${c.bold("npx @inferall/cli init")}`);
|
|
2364
|
+
}
|
|
433
2365
|
rl.close();
|
|
434
2366
|
exit(1);
|
|
435
2367
|
}
|
|
436
2368
|
console.log(` ${c.green("✓")} key found (${key.slice(0, 8)}…)`);
|
|
437
|
-
|
|
438
|
-
|
|
2369
|
+
// 2026-07-25: this used to warn (and set ok=false) for ANY key without an
|
|
2370
|
+
// ifu_ prefix. The gateway mints ifu_ but auth.ts also accepts ifa_,
|
|
2371
|
+
// kr_user_ and kr_proj_ ("Legacy kr_user_... keys remain valid"), so a user
|
|
2372
|
+
// with a working key was told their setup "needs attention" — and then, two
|
|
2373
|
+
// lines later, told "key accepted by gateway". doctor contradicted itself.
|
|
2374
|
+
//
|
|
2375
|
+
// The gateway check below is authoritative; a client-side prefix guess is a
|
|
2376
|
+
// second copy of a rule enforced elsewhere. Only flag keys that are
|
|
2377
|
+
// recognisably ANOTHER provider's, where a bare 401 would not explain why.
|
|
2378
|
+
const foreign =
|
|
2379
|
+
key.startsWith("sk-ant-") ? "an Anthropic key (sk-ant-…)" :
|
|
2380
|
+
key.startsWith("sk-or-") ? "an OpenRouter key (sk-or-…)" :
|
|
2381
|
+
key.startsWith("sk-") ? "an OpenAI key (sk-…)" :
|
|
2382
|
+
key.startsWith("AIzaSy") ? "a Google AI Studio key (AIzaSy…)" : null;
|
|
2383
|
+
if (foreign) {
|
|
2384
|
+
console.log(` ${c.red("✗")} that looks like ${foreign}, not an InferAll key. Mint one at ${KEYS_URL}`);
|
|
439
2385
|
ok = false;
|
|
440
2386
|
}
|
|
441
2387
|
|
|
442
2388
|
// 2. .env hygiene
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
2389
|
+
//
|
|
2390
|
+
// 2026-07-27 tick 825: this asked ONE question (is .env gitignored?) and
|
|
2391
|
+
// then reported on a broader one (".env hygiene ok"). Measured: a .env that
|
|
2392
|
+
// is COMMITTED TO GIT, with a live key in it, got
|
|
2393
|
+
//
|
|
2394
|
+
// ✓ .env hygiene ok
|
|
2395
|
+
//
|
|
2396
|
+
// because a tracked file is usually also listed in .gitignore, and gitignore
|
|
2397
|
+
// has no effect on tracked files. tick 821 built envIsGitTracked() for the
|
|
2398
|
+
// same check in `init` and this copy never got it: the eighth time in a week
|
|
2399
|
+
// one fact lived in two places and went stale in one of them.
|
|
2400
|
+
//
|
|
2401
|
+
// It is worse here than in init. People run `doctor` to ASK whether their
|
|
2402
|
+
// setup is safe, so a green tick from it is the strongest false assurance
|
|
2403
|
+
// the tool can give.
|
|
2404
|
+
const gi = gitignoreCoversEnv(root);
|
|
2405
|
+
const giCovers = gi.covers === true;
|
|
2406
|
+
const envExists = existsSync(envPath);
|
|
2407
|
+
const trackStatus = envExists ? gitTrackStatus(root) : "untracked";
|
|
2408
|
+
if (trackStatus === "tracked") {
|
|
2409
|
+
// Checked FIRST: for a tracked file the gitignore answer is irrelevant,
|
|
2410
|
+
// and reporting the ignore status here would soften a live exposure.
|
|
2411
|
+
console.log(` ${c.red("✗")} .env is ${c.bold("tracked by git")} and holds your key. Untrack it: ${c.cyan("git rm --cached .env")}`);
|
|
2412
|
+
ok = false;
|
|
2413
|
+
} else if (trackStatus === "unknown") {
|
|
2414
|
+
// 2026-07-28 tick 839: git could not be run, so this check did not happen.
|
|
2415
|
+
// Reporting "hygiene ok" here would be the tick-825 false all-clear again,
|
|
2416
|
+
// reached through a different door: a committed .env in a container without
|
|
2417
|
+
// git answered exactly the same as a safe one.
|
|
2418
|
+
console.log(` ${c.yellow("~")} could not check whether .env is tracked by git (git is not available here)`);
|
|
2419
|
+
console.log(` If this project is a git repository, confirm yourself: ${c.cyan("git ls-files --error-unmatch .env")}`);
|
|
2420
|
+
ok = false;
|
|
2421
|
+
} else if (envExists && gi.covers === null) {
|
|
2422
|
+
// Ordered AFTER the tracked check and BEFORE the not-ignored one, because
|
|
2423
|
+
// "could not read .gitignore" must not be reported as "not ignored". The
|
|
2424
|
+
// key may well be protected; we cannot say. Saying either would be a
|
|
2425
|
+
// confident answer to a question this run did not get to ask.
|
|
2426
|
+
console.log(` ${c.yellow("~")} ${gi.problem}, so .env protection could not be checked`);
|
|
2427
|
+
console.log(` Check it yourself: ${c.cyan("git check-ignore -v .env")}`);
|
|
2428
|
+
ok = false;
|
|
2429
|
+
} else if (envExists && !giCovers) {
|
|
2430
|
+
console.log(` ${c.yellow("~")} .env exists but is NOT gitignored, so your key could be committed`);
|
|
449
2431
|
ok = false;
|
|
450
2432
|
} else {
|
|
451
2433
|
console.log(` ${c.green("✓")} .env hygiene ok`);
|
|
452
2434
|
}
|
|
453
2435
|
|
|
454
|
-
// 3. key valid against the gateway
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
2436
|
+
// 3. key valid against the gateway.
|
|
2437
|
+
// MUST hit an authenticated endpoint. /v1/models is public — it answers 200
|
|
2438
|
+
// with no key at all — so checking it made this line ("key accepted by
|
|
2439
|
+
// gateway") true for any string, in the very tool people run BECAUSE their
|
|
2440
|
+
// key isn't working.
|
|
2441
|
+
const models = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
|
|
2442
|
+
signal: timeoutSignal(),
|
|
2443
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
2444
|
+
}).then((r) => readJsonBody(r)).catch((e) => ({ netErr: e }));
|
|
2445
|
+
// 2026-07-27 tick 832: an unreachable gateway used to be reported as
|
|
2446
|
+
// "key rejected by gateway (network error). Check <keys page>". The key was
|
|
2447
|
+
// never checked, and minting a replacement cannot reach a host that is down.
|
|
2448
|
+
// doctor exists to diagnose, so a confident wrong cause is the failure that
|
|
2449
|
+
// costs the most here.
|
|
2450
|
+
if (models.netErr) {
|
|
2451
|
+
for (const line of unreachableLines(models.netErr)) console.log(` ${line}`);
|
|
2452
|
+
rl.close();
|
|
2453
|
+
exit(1);
|
|
2454
|
+
}
|
|
2455
|
+
if (models.status !== 200 && models.status !== 402) {
|
|
2456
|
+
for (const line of httpFailureLines(models.status)) console.log(` ${line}`);
|
|
2457
|
+
rl.close();
|
|
2458
|
+
exit(1);
|
|
2459
|
+
}
|
|
2460
|
+
if (models.parseFailed) {
|
|
2461
|
+
// A login page is served with 200, so the status check above passes it.
|
|
2462
|
+
for (const line of nonJsonLines(models, " ")) console.log(line);
|
|
458
2463
|
rl.close();
|
|
459
2464
|
exit(1);
|
|
460
2465
|
}
|
|
461
2466
|
console.log(` ${c.green("✓")} key accepted by gateway`);
|
|
462
2467
|
|
|
2468
|
+
/*
|
|
2469
|
+
* TRIAL BALANCE, 2026-08-03 tick 927.
|
|
2470
|
+
*
|
|
2471
|
+
* `status` has always printed this; `doctor` never did, even though it
|
|
2472
|
+
* already fetches the very response that carries it (the /ai/v1/key/status
|
|
2473
|
+
* call above). So the command people run BECAUSE something is wrong was the
|
|
2474
|
+
* one command that could not tell them the most common reason it is wrong.
|
|
2475
|
+
*
|
|
2476
|
+
* MEASURED, server-side and synthetic-filtered, since the micro-trial:
|
|
2477
|
+
* 9 real users have hit the trial ceiling, 9 of 9 had made successful calls
|
|
2478
|
+
* first (the cap only fires after five), and SEVEN OF THE NINE burned all
|
|
2479
|
+
* five within 0.0 to 0.3 hours of their first call. Six of them arrived
|
|
2480
|
+
* through real tooling, including claude-cli and opencode, which consume
|
|
2481
|
+
* five calls inside a single task. Nobody saw a counter on the way down.
|
|
2482
|
+
*
|
|
2483
|
+
* A used-up trial is not a fault, so it prints as information rather than as
|
|
2484
|
+
* a failure and deliberately does NOT set ok = false. doctor exits non-zero
|
|
2485
|
+
* for things the user can fix by editing something; "you spent your trial"
|
|
2486
|
+
* is a true, correct state of a working key.
|
|
2487
|
+
*
|
|
2488
|
+
* The zero case gets its own line because that is the population this exists
|
|
2489
|
+
* for, and because "0/5 remaining" scans as a number rather than as a cause.
|
|
2490
|
+
*/
|
|
2491
|
+
const trial = models.body?.trial;
|
|
2492
|
+
if (trial && typeof trial.remaining === "number" && typeof trial.cap === "number") {
|
|
2493
|
+
if (trial.remaining > 0) {
|
|
2494
|
+
console.log(` ${c.dim("·")} trial: ${trial.remaining}/${trial.cap} free calls remaining`);
|
|
2495
|
+
} else {
|
|
2496
|
+
console.log(` ${c.yellow("~")} trial: 0/${trial.cap} free calls remaining. This key works; the free allowance is spent.`);
|
|
2497
|
+
console.log(` ${c.dim("Activate at https://inferall.ai/billing to keep calling.")}`);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
463
2501
|
// 4. call-site wiring
|
|
464
|
-
|
|
2502
|
+
// 2026-07-30 tick 854: the walk can stop early (depth and size caps), and
|
|
2503
|
+
// both claims below are about the WHOLE project. Saying "no constructors
|
|
2504
|
+
// found" over a tree we did not finish reading is the tick 830 false
|
|
2505
|
+
// all-clear in a new place.
|
|
2506
|
+
const cappedDoc = [];
|
|
2507
|
+
const sites = findCallSites(root, cappedDoc);
|
|
465
2508
|
const pointed = sites.filter((s) => s.alreadyPointed).length;
|
|
466
2509
|
const unpointed = sites.length - pointed;
|
|
467
2510
|
if (sites.length === 0) {
|
|
@@ -469,14 +2512,81 @@ async function doctor() {
|
|
|
469
2512
|
} else if (unpointed === 0) {
|
|
470
2513
|
console.log(` ${c.green("✓")} all ${sites.length} client constructor site(s) point at InferAll`);
|
|
471
2514
|
} else {
|
|
472
|
-
console.log(` ${c.yellow("~")} ${unpointed} of ${sites.length} client site(s) NOT pointed at InferAll
|
|
2515
|
+
console.log(` ${c.yellow("~")} ${unpointed} of ${sites.length} client site(s) NOT pointed at InferAll. Run ${c.bold("npx @inferall/cli init")}`);
|
|
2516
|
+
ok = false;
|
|
2517
|
+
}
|
|
2518
|
+
// A count is only as good as the scan behind it. Printed AFTER the verdict so
|
|
2519
|
+
// it qualifies whatever was just claimed, including the "none found" case.
|
|
2520
|
+
const capDoc = cappedSummary(cappedDoc);
|
|
2521
|
+
if (capDoc) {
|
|
2522
|
+
console.log(` ${c.yellow("~")} ${capDoc}, so this count may be incomplete`);
|
|
2523
|
+
ok = false;
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// 4a. Notebook cells.
|
|
2527
|
+
//
|
|
2528
|
+
// 2026-07-27 tick 830: doctor never looked inside a .ipynb. On a
|
|
2529
|
+
// notebook-only project it printed
|
|
2530
|
+
//
|
|
2531
|
+
// · no OpenAI/Anthropic client constructors found in this project
|
|
2532
|
+
// All checks passed.
|
|
2533
|
+
//
|
|
2534
|
+
// and exited 0, over a notebook calling OpenAI directly with a live key. The
|
|
2535
|
+
// user's traffic was going to OpenAI, billed by OpenAI, and the tool they ran
|
|
2536
|
+
// to check their InferAll setup told them it was fine.
|
|
2537
|
+
//
|
|
2538
|
+
// This is tick 816 reproduced one command over: t816 taught `init` about
|
|
2539
|
+
// notebooks and `doctor` never got it. Twelfth time one fact lived in two
|
|
2540
|
+
// places and went stale in one. Same severity argument as t825 -- doctor is
|
|
2541
|
+
// the tool people run to ASK whether they are set up, so its green tick is
|
|
2542
|
+
// the strongest false assurance we can hand out.
|
|
2543
|
+
//
|
|
2544
|
+
// Reported, never patched: rewriting a notebook reformats the whole file
|
|
2545
|
+
// (t816). doctor's job here is to say the cell is not wired, not to fix it.
|
|
2546
|
+
const nbSites = findNotebookSites(root);
|
|
2547
|
+
const nbUnpointed = nbSites.flatMap((s) => s.hits.filter((h) => !h.alreadyPointed).map((h) => ({ file: s.file, ...h })));
|
|
2548
|
+
if (nbUnpointed.length) {
|
|
2549
|
+
console.log(` ${c.yellow("~")} ${nbUnpointed.length} notebook cell(s) still call the provider directly:`);
|
|
2550
|
+
for (const h of nbUnpointed) {
|
|
2551
|
+
console.log(` ${relative(root, h.file)} ${c.dim(`cell ${h.index} (${h.sdk})`)}`);
|
|
2552
|
+
}
|
|
2553
|
+
console.log(` Notebooks are never rewritten automatically. Add to the client: ${c.cyan(`base_url="${API_BASE}"`)}`);
|
|
2554
|
+
ok = false;
|
|
2555
|
+
} else if (nbSites.length) {
|
|
2556
|
+
const cells = nbSites.reduce((n, s) => n + s.hits.length, 0);
|
|
2557
|
+
console.log(` ${c.green("✓")} all ${cells} notebook cell(s) point at InferAll`);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// 4c. Vercel AI SDK sites.
|
|
2561
|
+
//
|
|
2562
|
+
// 2026-07-27 tick 831: doctor never called findVercelAiSites, so a Next.js
|
|
2563
|
+
// route doing `openai("gpt-4o")` from @ai-sdk/openai produced
|
|
2564
|
+
//
|
|
2565
|
+
// · no OpenAI/Anthropic client constructors found in this project
|
|
2566
|
+
// All checks passed. (exit 0)
|
|
2567
|
+
//
|
|
2568
|
+
// while every request went to OpenAI and was billed by OpenAI. `init` has
|
|
2569
|
+
// handled this surface since tick 804 and doctor never got it. Same shape as
|
|
2570
|
+
// the notebook hole fixed yesterday in tick 830, one surface over, and the
|
|
2571
|
+
// thirteenth time one fact lived in two places.
|
|
2572
|
+
//
|
|
2573
|
+
// Found by the command-x-surface sweep rather than by stumbling into it,
|
|
2574
|
+
// which is the whole reason that sweep is now a standing step.
|
|
2575
|
+
const vSites = findVercelAiSites(root).filter((s) => !s.alreadyPointed);
|
|
2576
|
+
if (vSites.length) {
|
|
2577
|
+
console.log(` ${c.yellow("~")} ${vSites.length} Vercel AI SDK file(s) still route to the provider directly:`);
|
|
2578
|
+
for (const s of vSites) {
|
|
2579
|
+
const why = s.usesFactory ? "factory without an InferAll baseURL" : `bare ${s.providers[0]}() import`;
|
|
2580
|
+
console.log(` ${relative(root, s.file)} ${c.dim(`(${why})`)}`);
|
|
2581
|
+
}
|
|
2582
|
+
console.log(` Run ${c.bold("npx @inferall/cli init")} for the exact snippet.`);
|
|
473
2583
|
ok = false;
|
|
474
2584
|
}
|
|
475
2585
|
|
|
476
2586
|
// 4b. Gemini SDK sites — different protocol, can't be re-pointed
|
|
477
2587
|
const gSites = findGeminiSites(root);
|
|
478
2588
|
if (gSites.length) {
|
|
479
|
-
console.log(` ${c.yellow("~")} ${gSites.length} Gemini SDK site(s) bypass InferAll (different protocol)
|
|
2589
|
+
console.log(` ${c.yellow("~")} ${gSites.length} Gemini SDK site(s) bypass InferAll (different protocol). Use the OpenAI-compat endpoint with model ${c.bold("gemini/gemini-2.5-flash")}; run ${c.bold("npx @inferall/cli init")} for the exact snippet`);
|
|
480
2590
|
ok = false;
|
|
481
2591
|
}
|
|
482
2592
|
|
|
@@ -503,26 +2613,44 @@ async function doctor() {
|
|
|
503
2613
|
|
|
504
2614
|
async function main() {
|
|
505
2615
|
if (flag("version") || cmd === "version") {
|
|
506
|
-
|
|
507
|
-
console.log(pkg.version);
|
|
2616
|
+
console.log(versionLine(CLI_VERSION, BUILD_INFO));
|
|
508
2617
|
rl.close();
|
|
509
2618
|
return;
|
|
510
2619
|
}
|
|
511
2620
|
if (cmd === "doctor") return doctor();
|
|
512
2621
|
if (cmd === "models") return modelsCmd();
|
|
513
2622
|
if (cmd === "status") return statusCmd();
|
|
2623
|
+
if (cmd === "chat") return chatCmd();
|
|
514
2624
|
if (cmd !== "init") {
|
|
515
|
-
console.log(`Usage: npx @inferall/cli <init|doctor|models|status|version> [--key ifu_...] [--yes] [--no-ai] [--free] [--json]`);
|
|
2625
|
+
console.log(`Usage: npx @inferall/cli <init|doctor|models|status|chat|version> [--key ifu_...] [--model id] [--yes] [--no-ai] [--free] [--json]`);
|
|
516
2626
|
exit(cmd === "help" || flag("help") ? 0 : 1);
|
|
517
2627
|
}
|
|
518
2628
|
|
|
519
|
-
console.log(`\n${c.bold("InferAll setup")} ${c.dim("
|
|
2629
|
+
console.log(`\n${c.bold("InferAll setup")} ${c.dim("one API for OpenAI, Anthropic, Google, and 10+ $0 NIM models")}\n`);
|
|
520
2630
|
|
|
521
2631
|
const root = cwd();
|
|
522
2632
|
const project = detectProject(root);
|
|
2633
|
+
// 2026-07-28 tick 844. Deliberately NOT folded into envResult.problems: that
|
|
2634
|
+
// channel's closing message says either "Your key was not saved" or "your key
|
|
2635
|
+
// is in .env but .gitignore was not updated", and both would be false here.
|
|
2636
|
+
// The key saves fine; what failed is reading a manifest, so what is at risk
|
|
2637
|
+
// is DETECTION, and the honest warning names that instead.
|
|
2638
|
+
for (const problem of project.readProblems) {
|
|
2639
|
+
console.log(` ${c.yellow("~")} ${problem}`);
|
|
2640
|
+
console.log(` Your dependencies could not be read, so existing SDK calls may not be rewritten.`);
|
|
2641
|
+
}
|
|
523
2642
|
if (!project.kind) {
|
|
524
|
-
|
|
525
|
-
|
|
2643
|
+
// 2026-07-27 tick 828: this used to say "Run this from your project root",
|
|
2644
|
+
// which was impossible advice for someone already standing in it -- see
|
|
2645
|
+
// detectProject. Import-based detection now covers the manifest-less Python
|
|
2646
|
+
// case, so reaching here means we found no manifest AND no SDK import, and
|
|
2647
|
+
// the honest statement is that there is nothing here to migrate rather than
|
|
2648
|
+
// that the user is in the wrong directory.
|
|
2649
|
+
console.log(`${c.yellow("!")} No project detected here: no package.json, pyproject.toml or requirements.txt,`);
|
|
2650
|
+
console.log(` and no Python file or notebook using an AI SDK.`);
|
|
2651
|
+
console.log(` ${c.dim(`(looked in ${root})`)}`);
|
|
2652
|
+
console.log(` If your code lives elsewhere, cd there and re-run. Otherwise continue for a`);
|
|
2653
|
+
console.log(` bare .env + example setup you can point at your own code.`);
|
|
526
2654
|
if (!(await confirm("Continue anyway?", false))) exit(0);
|
|
527
2655
|
project.kind = "node";
|
|
528
2656
|
}
|
|
@@ -531,36 +2659,276 @@ async function main() {
|
|
|
531
2659
|
// --- key ---
|
|
532
2660
|
let key = flagValue("key") || env.INFERALL_API_KEY || "";
|
|
533
2661
|
if (!key) {
|
|
2662
|
+
// 2026-07-26 tick 800 — the wizard used to dead-end the ONE person it
|
|
2663
|
+
// exists for: someone with no key yet.
|
|
2664
|
+
//
|
|
2665
|
+
// Pressing Enter with nothing typed sent "" to the gateway, which 401'd,
|
|
2666
|
+
// and we printed "The gateway does not recognise that key." That is the
|
|
2667
|
+
// worst available message — it tells a user their key is wrong when their
|
|
2668
|
+
// actual state is "I don't have one." Then it exited, so the fix was
|
|
2669
|
+
// re-running the whole wizard.
|
|
2670
|
+
//
|
|
2671
|
+
// Two changes: empty input is now its own case (re-prompt, never verified),
|
|
2672
|
+
// and we offer to open the signup page so getting a key is a keystroke
|
|
2673
|
+
// instead of a context switch out of the terminal and back. This is the
|
|
2674
|
+
// widest measured gap in the funnel — 114 arrived, 97 minted, 52 called.
|
|
534
2675
|
console.log(`\n Grab a key at ${c.cyan(KEYS_URL)} (free account, key starts with ${c.bold("ifu_")})`);
|
|
535
|
-
|
|
2676
|
+
console.log(` ${c.dim("It's free, takes about 20 seconds, and includes 5 calls with no card.")}`);
|
|
2677
|
+
|
|
2678
|
+
const interactive = Boolean(process.stdin.isTTY) && !YES;
|
|
2679
|
+
for (let attempt = 1; !key; attempt++) {
|
|
2680
|
+
if (attempt === 1 && interactive && (await confirm(` Open ${KEYS_URL} in your browser now?`, true))) {
|
|
2681
|
+
if (openBrowser(KEYS_URL)) {
|
|
2682
|
+
console.log(` ${c.green("→")} opened your browser. Create a key, then paste it below.`);
|
|
2683
|
+
} else {
|
|
2684
|
+
console.log(` ${c.yellow("!")} couldn't open a browser; visit ${c.cyan(KEYS_URL)} manually.`);
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
key = await ask(` Paste your InferAll API key: `);
|
|
2688
|
+
if (key) break;
|
|
2689
|
+
|
|
2690
|
+
// Non-interactive (CI, piped stdin): looping would spin forever on EOF.
|
|
2691
|
+
// Say what to do and leave a non-zero exit for the caller.
|
|
2692
|
+
if (!interactive) {
|
|
2693
|
+
console.log(`${c.red("✗")} No key provided.`);
|
|
2694
|
+
console.log(` Pass one non-interactively: ${c.bold("npx @inferall/cli init --key ifu_…")}`);
|
|
2695
|
+
console.log(` or set ${c.bold("INFERALL_API_KEY")} in the environment. Get one at ${c.cyan(KEYS_URL)}.`);
|
|
2696
|
+
exit(1);
|
|
2697
|
+
}
|
|
2698
|
+
if (attempt >= 3) {
|
|
2699
|
+
console.log(`${c.yellow("!")} No key entered, so nothing was changed.`);
|
|
2700
|
+
console.log(` Re-run ${c.bold("npx @inferall/cli init")} once you have one from ${c.cyan(KEYS_URL)}.`);
|
|
2701
|
+
exit(0); // 0: the user chose to stop, that is not a failure
|
|
2702
|
+
}
|
|
2703
|
+
console.log(` ${c.dim("Nothing entered. Paste the key (starts with ifu_), or Ctrl-C to stop.")}`);
|
|
2704
|
+
}
|
|
536
2705
|
}
|
|
537
|
-
|
|
538
|
-
|
|
2706
|
+
// 2026-07-25: this used to HARD-EXIT on anything without an ifu_ prefix, which
|
|
2707
|
+
// was both wrong and redundant.
|
|
2708
|
+
//
|
|
2709
|
+
// Wrong: the gateway mints ifu_ today but auth.ts explicitly accepts ifu_,
|
|
2710
|
+
// ifa_, kr_user_ and kr_proj_ ("Legacy kr_user_... keys remain valid"). So a
|
|
2711
|
+
// legacy or project key — which works perfectly against the API — was told
|
|
2712
|
+
// "that doesn't look like an InferAll key" and the wizard quit. `doctor` only
|
|
2713
|
+
// warned on the same input, so the two commands disagreed about what a valid
|
|
2714
|
+
// key is, and `init` was stricter than the system it configures.
|
|
2715
|
+
//
|
|
2716
|
+
// Redundant: the very next line verifies the key against the gateway, which
|
|
2717
|
+
// is the actual authority. A client-side prefix test is a second copy of a
|
|
2718
|
+
// rule enforced elsewhere, and second copies drift.
|
|
2719
|
+
//
|
|
2720
|
+
// Kept: a specific, useful hint when the key is recognisably ANOTHER
|
|
2721
|
+
// provider's, because a bare 401 would not explain that.
|
|
2722
|
+
const foreignKey =
|
|
2723
|
+
key.startsWith("sk-ant-") ? "an Anthropic key (sk-ant-…)" :
|
|
2724
|
+
key.startsWith("sk-or-") ? "an OpenRouter key (sk-or-…)" :
|
|
2725
|
+
key.startsWith("sk-") ? "an OpenAI key (sk-…)" :
|
|
2726
|
+
key.startsWith("AIzaSy") ? "a Google AI Studio key (AIzaSy…)" : null;
|
|
2727
|
+
if (foreignKey) {
|
|
2728
|
+
console.log(`${c.red("✗")} That looks like ${foreignKey}, not an InferAll key.`);
|
|
2729
|
+
console.log(` Mint one at ${c.cyan(KEYS_URL)}. InferAll keys start with ${c.bold("ifu_")}.`);
|
|
539
2730
|
exit(1);
|
|
540
2731
|
}
|
|
541
2732
|
|
|
2733
|
+
// 2026-07-25: this used to verify against /v1/models — which is PUBLIC.
|
|
2734
|
+
// It returns 200 with a garbage key and 200 with no Authorization header at
|
|
2735
|
+
// all, so "verifying key … ok" passed for literally any string and this step
|
|
2736
|
+
// proved nothing. That is the vacuous-guard failure mode, in the one place a
|
|
2737
|
+
// developer trusts us to tell them their key works before we rewrite their
|
|
2738
|
+
// source files.
|
|
2739
|
+
//
|
|
2740
|
+
// /ai/v1/key/status runs authenticateRequest: 401 for a bad key, 200 for a
|
|
2741
|
+
// good one (verified against production, including a fake ifu_-prefixed key).
|
|
542
2742
|
process.stdout.write(` verifying key against ${API_BASE} … `);
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
2743
|
+
// 2026-07-30 tick 854: this used to `.catch(() => null)`, discarding the error,
|
|
2744
|
+
// and then print "Check the key at <keys page> and re-run" for a NETWORK
|
|
2745
|
+
// failure. Minting a new key cannot fix DNS, a closed port or a proxy that
|
|
2746
|
+
// re-signs TLS. That is the tick 832 conflation, which was fixed in `doctor`
|
|
2747
|
+
// and never applied here, one screen away, and the tick 852 cause-naming was
|
|
2748
|
+
// built for `doctor` alone for the same reason. Both commands now share it.
|
|
2749
|
+
const verify = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
|
|
2750
|
+
signal: timeoutSignal(),
|
|
2751
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
2752
|
+
}).then((res) => ({ res })).catch((err) => ({ err }));
|
|
2753
|
+
if (verify.res && verify.res.status !== 401) {
|
|
2754
|
+
// Same trap as doctor: a captive portal answers 200 with HTML, and the
|
|
2755
|
+
// wizard would otherwise print "ok" and write the key as verified.
|
|
2756
|
+
const parsedVerify = await readJsonBody(verify.res.clone());
|
|
2757
|
+
if (parsedVerify.parseFailed) {
|
|
2758
|
+
console.log(c.red("failed"));
|
|
2759
|
+
for (const line of nonJsonLines(parsedVerify, " ")) console.log(line);
|
|
2760
|
+
exit(1);
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
if (verify.res?.status === 401) {
|
|
2764
|
+
console.log(c.red("rejected"));
|
|
2765
|
+
console.log(` The gateway does not recognise that key. Mint one at ${KEYS_URL} and re-run.`);
|
|
2766
|
+
exit(1);
|
|
2767
|
+
}
|
|
2768
|
+
if (verify.err) {
|
|
2769
|
+
console.log(c.red("failed"));
|
|
2770
|
+
// Same lines doctor prints, so the cause and its remedy are stated once.
|
|
2771
|
+
for (const line of unreachableLines(verify.err)) console.log(` ${line}`);
|
|
2772
|
+
console.log(` ${c.bold("Nothing was written.")} Your key was never checked, so this says nothing about it.`);
|
|
2773
|
+
exit(1);
|
|
2774
|
+
}
|
|
2775
|
+
if (!verify.res.ok) {
|
|
2776
|
+
console.log(c.red(`failed (HTTP ${verify.res.status})`));
|
|
546
2777
|
console.log(` Check the key at ${KEYS_URL} and re-run.`);
|
|
547
2778
|
exit(1);
|
|
548
2779
|
}
|
|
549
2780
|
console.log(c.green("ok"));
|
|
550
2781
|
|
|
551
2782
|
// --- env files ---
|
|
552
|
-
|
|
553
|
-
|
|
2783
|
+
// tick 820: never silently replace a credential, and never call a
|
|
2784
|
+
// replacement an addition. --yes still replaces (the user opted in), but the
|
|
2785
|
+
// message says so.
|
|
2786
|
+
const existingKey = readEnvKey(root);
|
|
2787
|
+
let writeKey = true;
|
|
2788
|
+
if (existingKey && existingKey !== key) {
|
|
2789
|
+
writeKey = await confirm(` .env already has a DIFFERENT INFERALL_API_KEY (${maskKey(existingKey)}). Replace it?`);
|
|
2790
|
+
}
|
|
2791
|
+
// 2026-07-29 tick 850. Measured in a monorepo with the key at the repo root
|
|
2792
|
+
// and the code in packages/api: running init from the sub-package wrote a
|
|
2793
|
+
// SECOND .env with a different key and said nothing about the first. Two
|
|
2794
|
+
// credentials for one repo, and which one the app uses depends on the
|
|
2795
|
+
// directory it starts in.
|
|
2796
|
+
//
|
|
2797
|
+
// Deliberately a warning and not a refusal. A monorepo where each package
|
|
2798
|
+
// genuinely has its own key is a real setup, and this command was given an
|
|
2799
|
+
// explicit --key, so blocking it would break a legitimate case to prevent a
|
|
2800
|
+
// confusing one. Naming the other file is enough to make the choice informed.
|
|
2801
|
+
// Printed BEFORE the write so the ordering matches what happens on disk.
|
|
2802
|
+
if (writeKey) {
|
|
2803
|
+
const ancestor = findAncestorEnvKey(root);
|
|
2804
|
+
if (ancestor) {
|
|
2805
|
+
const rel = relative(root, ancestor.dir) || ".";
|
|
2806
|
+
console.log(` ${c.yellow("~")} ${c.bold(join(rel, ".env"))} already holds a key for this repo`);
|
|
2807
|
+
console.log(` Writing another one here. Your app will use whichever .env matches the`);
|
|
2808
|
+
console.log(` directory it runs from, so delete one if that is not what you intended.`);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
const envResult = upsertEnvFile(root, key, { writeKey });
|
|
2812
|
+
// 2026-07-27 tick 836: report what actually happened on disk. Previously a
|
|
2813
|
+
// failing step threw a bare errno from inside upsertEnvFile, after the key had
|
|
2814
|
+
// already been written, so the user got no file name, no cause, and no notice
|
|
2815
|
+
// that a live credential was sitting unprotected.
|
|
2816
|
+
for (const p of envResult.problems) {
|
|
2817
|
+
console.log(` ${c.red("✗")} ${p}`);
|
|
2818
|
+
}
|
|
2819
|
+
if (envResult.keyWritten && !envResult.gitignoreOk) {
|
|
2820
|
+
// The security-relevant combination, and the reason this is not merely a
|
|
2821
|
+
// tidier error message: the secret is on disk and nothing is ignoring it.
|
|
2822
|
+
console.log(` ${c.red("!")} your key is in ${c.bold(".env")} but ${c.bold(".gitignore")} could not be updated.`);
|
|
2823
|
+
console.log(` Add ${c.cyan(".env")} to .gitignore yourself before committing.`);
|
|
2824
|
+
}
|
|
2825
|
+
if (!envResult.keyWritten && writeKey) {
|
|
2826
|
+
const dirBad = dirIsUnwritable(root);
|
|
2827
|
+
console.log(
|
|
2828
|
+
` ${c.dim("The key was not saved. Pass it per run with")} ${c.bold("--key")}${c.dim(
|
|
2829
|
+
dirBad ? ", or make this DIRECTORY writable: nothing can be created here." : ", or fix the file above.",
|
|
2830
|
+
)}`,
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
if (existingKey && existingKey === key) {
|
|
2834
|
+
console.log(` ${c.green("=")} INFERALL_API_KEY in ${c.bold(".env")} already matches (unchanged)`);
|
|
2835
|
+
} else if (existingKey && writeKey) {
|
|
2836
|
+
console.log(` ${c.yellow("!")} REPLACED a different INFERALL_API_KEY in ${c.bold(".env")} ${c.dim(`(was ${maskKey(existingKey)})`)}`);
|
|
2837
|
+
} else if (existingKey) {
|
|
2838
|
+
console.log(` ${c.yellow("~")} kept your existing INFERALL_API_KEY in ${c.bold(".env")} ${c.dim("(this run used the key you supplied)")}`);
|
|
2839
|
+
} else if (envResult.keyWritten) {
|
|
2840
|
+
// tick 836: guarded. This line used to print unconditionally, so a failed
|
|
2841
|
+
// write was announced as a success on the very next line after the error.
|
|
2842
|
+
console.log(` ${c.green("+")} INFERALL_API_KEY written to ${c.bold(".env")} (and .env.example placeholder)`);
|
|
2843
|
+
}
|
|
554
2844
|
|
|
555
2845
|
// --- integration ---
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
2846
|
+
// Computed before the branch: a Vercel-AI project has no client constructor
|
|
2847
|
+
// to rewrite, so without this the wizard would treat it as greenfield.
|
|
2848
|
+
// .filter(!usesFactory) preserves the pre-tick-831 behaviour exactly: this
|
|
2849
|
+
// list drives the "paste this snippet" guidance, and a file that already
|
|
2850
|
+
// calls createOpenAI does not need the snippet, only a baseURL. doctor reads
|
|
2851
|
+
// the same finder with a different filter (alreadyPointed).
|
|
2852
|
+
const allVercelSites = project.kind === "node" ? findVercelAiSites(root) : [];
|
|
2853
|
+
const vercelAiSites = allVercelSites.filter((s) => !s.usesFactory);
|
|
2854
|
+
// Factory files that never got our baseURL. Separate list because the remedy
|
|
2855
|
+
// is one argument, not the snippet, but they count the same in the sum below.
|
|
2856
|
+
const vercelFactoryUnpointed = allVercelSites.filter((s) => s.usesFactory && !s.alreadyPointed);
|
|
2857
|
+
// 2026-07-26 tick 805: this was a THIRD hardcoded list, narrower than the two
|
|
2858
|
+
// detection lists above it. Fixing detection to recognise langchain-openai
|
|
2859
|
+
// was not enough -- this gate still said "no SDK", the wizard still took the
|
|
2860
|
+
// greenfield branch, and it still printed "Done. Your project is wired to
|
|
2861
|
+
// InferAll" over an unmigrated file.
|
|
2862
|
+
//
|
|
2863
|
+
// That is the lesson from the whole t803-t805 run in one line: a claim can
|
|
2864
|
+
// stay false after you fix the thing you thought was making it false. I
|
|
2865
|
+
// verified detection (the step) and only caught this by running the flow end
|
|
2866
|
+
// to end and reading the file afterwards.
|
|
2867
|
+
//
|
|
2868
|
+
// Derived from one place now. Anything whose constructors findCallSites can
|
|
2869
|
+
// actually rewrite belongs here, so the two cannot drift apart again.
|
|
2870
|
+
const CODEMODDABLE = ["openai", "@anthropic-ai/sdk", "anthropic",
|
|
2871
|
+
"langchain-openai", "langchain_openai", "langchain-anthropic", "langchain_anthropic"];
|
|
2872
|
+
const usesSdk = project.sdks.some((s) => CODEMODDABLE.includes(s));
|
|
2873
|
+
// 2026-07-26 tick 816: computed BEFORE the green tick below, because the tick
|
|
2874
|
+
// is exactly what was wrong. A notebook project lists `openai` in
|
|
2875
|
+
// requirements.txt, so usesSdk is true, but findCallSites never opens .ipynb
|
|
2876
|
+
// and returned zero, so the wizard reported "nothing to migrate" over a
|
|
2877
|
+
// notebook still calling OpenAI directly.
|
|
2878
|
+
// 2026-07-27 tick 830: filtered to cells that are NOT already pointed, which
|
|
2879
|
+
// is what the findCallSites line below has always done. Without it, a user
|
|
2880
|
+
// who had already made the manual edit was told on their next run to "add
|
|
2881
|
+
// these two arguments" that were already there, and the cell still counted
|
|
2882
|
+
// toward the sum that suppresses "Done. Your project is wired to InferAll" --
|
|
2883
|
+
// so a fully wired project was reported as unfinished. Mirror image of the
|
|
2884
|
+
// doctor bug fixed in the same tick: doctor could not see notebooks at all,
|
|
2885
|
+
// init could not see that they were already done.
|
|
2886
|
+
const notebookSites = findNotebookSites(root)
|
|
2887
|
+
.map((s) => ({ ...s, hits: s.hits.filter((h) => !h.alreadyPointed) }))
|
|
2888
|
+
.filter((s) => s.hits.length);
|
|
2889
|
+
// Populated inside the codemod branch below; read by the closing line.
|
|
2890
|
+
let unwritableCount = 0;
|
|
2891
|
+
const cappedInit = [];
|
|
2892
|
+
// 2026-08-01 tick 896. `usesSdk` reads the MANIFEST, and it used to gate the
|
|
2893
|
+
// source scan entirely. Every earlier fix in this family (t803 monorepo, t804
|
|
2894
|
+
// Vercel AI, t805 LangChain) added another NAME to the manifest scan, which by
|
|
2895
|
+
// construction cannot reach the case where the SDK is not declared at all.
|
|
2896
|
+
//
|
|
2897
|
+
// MEASURED against the installed 0.2.1 artifact, on a project whose
|
|
2898
|
+
// package.json had no dependencies and whose src/index.js read
|
|
2899
|
+
// `new OpenAI({ apiKey: process.env.OPENAI_API_KEY })`:
|
|
2900
|
+
//
|
|
2901
|
+
// detected: node project
|
|
2902
|
+
// Done. Your project is wired to InferAll.
|
|
2903
|
+
//
|
|
2904
|
+
// Nothing was rewritten and it reported success. Running `inferall init`
|
|
2905
|
+
// before `npm install openai` is an ordinary ordering, so this is reachable
|
|
2906
|
+
// rather than a corner.
|
|
2907
|
+
//
|
|
2908
|
+
// findCallSites reads the SOURCE, so it is the authoritative answer to "is
|
|
2909
|
+
// there anything to rewrite" and it now decides. The manifest survives as a
|
|
2910
|
+
// hint: an SDK that IS declared but has no constructors still gets its honest
|
|
2911
|
+
// "no un-migrated client constructors found" line, which is why usesSdk stays
|
|
2912
|
+
// in the condition. The tree is already walked unconditionally for notebooks
|
|
2913
|
+
// a few lines above, so this adds no new cost class.
|
|
2914
|
+
const sites = findCallSites(root, cappedInit).filter((s) => !s.alreadyPointed);
|
|
2915
|
+
if (usesSdk || sites.length > 0) {
|
|
559
2916
|
if (sites.length === 0) {
|
|
560
|
-
|
|
2917
|
+
// Only claim there is nothing to do when there is genuinely nothing to
|
|
2918
|
+
// do. Notebooks are un-migrated code we simply cannot rewrite safely.
|
|
2919
|
+
if (notebookSites.length === 0) {
|
|
2920
|
+
console.log(` ${c.green("✓")} AI SDK present but no un-migrated client constructors found.`);
|
|
2921
|
+
} else {
|
|
2922
|
+
console.log(` ${c.yellow("~")} No rewritable client constructors, but notebooks below still call the provider directly.`);
|
|
2923
|
+
}
|
|
561
2924
|
} else {
|
|
562
2925
|
console.log(`\n Found ${c.bold(sites.length)} client constructor site(s):`);
|
|
563
2926
|
for (const s of sites) console.log(` - ${relative(root, s.file)} ${c.dim(`(${s.sdk})`)}`);
|
|
2927
|
+
// Files we detected, tried to rewrite, and could NOT write. They belong
|
|
2928
|
+
// in the same bucket as Vercel/Gemini/notebook sites: detected, not
|
|
2929
|
+
// changed, and therefore must suppress the "your project is wired"
|
|
2930
|
+
// closing line (tick 816).
|
|
2931
|
+
let unwritable = 0;
|
|
564
2932
|
if (await confirm(` Point them at the InferAll gateway?`)) {
|
|
565
2933
|
for (const s of sites) {
|
|
566
2934
|
let patched = patchSite(s);
|
|
@@ -569,7 +2937,7 @@ async function main() {
|
|
|
569
2937
|
process.stdout.write(` ${c.dim("AI-assist")} ${relative(root, s.file)} … `);
|
|
570
2938
|
const r = await aiSuggestPatch(key, s);
|
|
571
2939
|
if (r.status === 402) {
|
|
572
|
-
console.log(c.yellow("skipped (account not activated yet
|
|
2940
|
+
console.log(c.yellow("skipped (account not activated yet, see verify step below)"));
|
|
573
2941
|
continue;
|
|
574
2942
|
}
|
|
575
2943
|
patched = r.patched;
|
|
@@ -577,25 +2945,109 @@ async function main() {
|
|
|
577
2945
|
console.log(patched ? c.green("patch ready") : c.yellow("no confident patch"));
|
|
578
2946
|
}
|
|
579
2947
|
if (!patched) {
|
|
580
|
-
const manualBase = s.sdk
|
|
581
|
-
console.log(` ${c.yellow("~")} ${relative(root, s.file)}: couldn't patch automatically
|
|
2948
|
+
const manualBase = baseForSdk(s.sdk);
|
|
2949
|
+
console.log(` ${c.yellow("~")} ${relative(root, s.file)}: couldn't patch automatically. Set ${s.isPy ? `base_url="${manualBase}"` : `baseURL: "${manualBase}"`} on your ${s.sdk} client manually.`);
|
|
2950
|
+
// 2026-07-31 tick 880: this `continue` did not count the site, so a
|
|
2951
|
+
// file we detected and left untouched still ended with "Done. Your
|
|
2952
|
+
// project is wired to InferAll." Only the EACCES branch below was
|
|
2953
|
+
// counted, though the comment above this block already says
|
|
2954
|
+
// detected-but-not-changed belongs in the same bucket. Tightening
|
|
2955
|
+
// the AI patch check made this path common rather than rare.
|
|
2956
|
+
unwritable++;
|
|
2957
|
+
continue;
|
|
2958
|
+
}
|
|
2959
|
+
// Declining the rewrite also leaves the site unchanged, so it counts
|
|
2960
|
+
// for the same reason.
|
|
2961
|
+
if (via === "AI" && !(await confirm(` Apply AI-suggested rewrite to ${relative(root, s.file)}?`))) {
|
|
2962
|
+
unwritable++;
|
|
2963
|
+
continue;
|
|
2964
|
+
}
|
|
2965
|
+
// 2026-07-27 tick 824: guarded. This write was bare, so a single
|
|
2966
|
+
// unwritable file killed the whole wizard with a raw Node exception:
|
|
2967
|
+
//
|
|
2968
|
+
// ✗ EACCES: permission denied, open '/abs/path/svc.ts'
|
|
2969
|
+
//
|
|
2970
|
+
// and it did so AFTER .env and .gitignore had been written, leaving a
|
|
2971
|
+
// half-configured project, no summary, and no test call. One vendored
|
|
2972
|
+
// read-only file in a monorepo cost the user the entire setup.
|
|
2973
|
+
//
|
|
2974
|
+
// To its credit it never claimed success, which is the failure this
|
|
2975
|
+
// codebase keeps making. But "abort everything" is the wrong response
|
|
2976
|
+
// to a per-file problem when a per-file fallback already exists three
|
|
2977
|
+
// lines above. Route into it: report this file honestly, keep going.
|
|
2978
|
+
try {
|
|
2979
|
+
writeFileSync(s.file, preserveTrailingNewline(s.body ?? "", patched));
|
|
2980
|
+
} catch (err) {
|
|
2981
|
+
const why = err && err.code === "EACCES" ? "no write permission" : (err?.code || "write failed");
|
|
2982
|
+
console.log(` ${c.yellow("~")} ${relative(root, s.file)}: ${c.bold(why)}, left unchanged. Make it writable and re-run, or set ${s.isPy ? `base_url="${baseForSdk(s.sdk)}"` : `baseURL: "${baseForSdk(s.sdk)}"`} by hand.`);
|
|
2983
|
+
unwritable++;
|
|
582
2984
|
continue;
|
|
583
2985
|
}
|
|
584
|
-
|
|
585
|
-
|
|
2986
|
+
// Post-patch self-check. A rewrite that points the client at InferAll
|
|
2987
|
+
// but leaves a FOREIGN provider credential produces a project that
|
|
2988
|
+
// fails on its first run — while this wizard prints "Done." That
|
|
2989
|
+
// exact combination shipped until 2026-07-25 and is invisible to the
|
|
2990
|
+
// verify step below, which calls the API with the key directly rather
|
|
2991
|
+
// than through the code we just wrote. Never claim success on a file
|
|
2992
|
+
// we can see is still mis-wired.
|
|
2993
|
+
const stillForeign = /(?:process\.env\.|os\.environ\[\s*["']|os\.getenv\(\s*["'])(?:OPENAI|ANTHROPIC|GOOGLE|GEMINI|OPENROUTER)_API_KEY/.test(patched);
|
|
2994
|
+
// tick 810: the same check, for a credential written inline. The env-var
|
|
2995
|
+
// form above was the only one this warned about, so a client left
|
|
2996
|
+
// holding apiKey: "sk-…", or a local-inference placeholder like
|
|
2997
|
+
// "EMPTY" that we deliberately do not rewrite, printed a bare ✓ over
|
|
2998
|
+
// a project that cannot authenticate.
|
|
2999
|
+
const stillLiteral = /\b(?:api_key\s*=|apiKey\s*:)\s*(["'])(?!\s*\1)[^"']*\1/.test(patched)
|
|
3000
|
+
&& !/(?:os\.environ\[|os\.getenv\(|process\.env\.)\s*["']?INFERALL_API_KEY/.test(patched);
|
|
586
3001
|
console.log(` ${c.green("✓")} ${relative(root, s.file)} ${c.dim(`(${via})`)}`);
|
|
3002
|
+
if (stillForeign) {
|
|
3003
|
+
console.log(` ${c.yellow("!")} ${relative(root, s.file)} still reads another provider's API key env var.`);
|
|
3004
|
+
console.log(` Point it at ${c.bold(s.isPy ? 'os.environ["INFERALL_API_KEY"]' : "process.env.INFERALL_API_KEY")} or the first run will fail.`);
|
|
3005
|
+
} else if (stillLiteral) {
|
|
3006
|
+
console.log(` ${c.yellow("!")} ${relative(root, s.file)} still passes a hardcoded API key literal.`);
|
|
3007
|
+
console.log(` Replace it with ${c.bold(s.isPy ? 'os.environ["INFERALL_API_KEY"]' : "process.env.INFERALL_API_KEY")} or the first run will fail.`);
|
|
3008
|
+
}
|
|
587
3009
|
}
|
|
3010
|
+
unwritableCount = unwritable;
|
|
588
3011
|
}
|
|
589
3012
|
}
|
|
590
|
-
} else {
|
|
3013
|
+
} else if (vercelAiSites.length === 0 && notebookSites.length === 0) {
|
|
3014
|
+
// Only treat this as a greenfield project when there is genuinely no AI
|
|
3015
|
+
// code we recognised. Writing a hello-world next to un-migrated calls is
|
|
3016
|
+
// what made the tick-804 report misleading. Notebooks count as recognised
|
|
3017
|
+
// AI code (tick 816): a .ipynb calling OpenAI is not a blank slate.
|
|
591
3018
|
const example = writeExample(root, project.kind);
|
|
592
|
-
if (example) console.log(` ${c.green("+")} example written: ${c.bold(relative(root, example))}`);
|
|
3019
|
+
if (example.path) console.log(` ${c.green("+")} example written: ${c.bold(relative(root, example.path))}`);
|
|
3020
|
+
// tick 837: an unwritable example is a per-file degradation, not a crash.
|
|
3021
|
+
// It also counts toward envResult.problems so the closing line cannot claim
|
|
3022
|
+
// a clean setup while a step it announced never happened.
|
|
3023
|
+
if (example.problem) {
|
|
3024
|
+
console.log(` ${c.red("✗")} ${example.problem}`);
|
|
3025
|
+
envResult.problems.push(example.problem);
|
|
3026
|
+
}
|
|
3027
|
+
if (example.path) {
|
|
3028
|
+
const run = project.kind === "node" ? "node inferall-hello.mjs" : "python3 inferall_hello.py";
|
|
3029
|
+
console.log(` ${c.dim("run:")} ${c.bold(run)} ${c.dim("(reads INFERALL_API_KEY from the environment or .env)")}`);
|
|
3030
|
+
}
|
|
593
3031
|
if (project.kind === "node") console.log(` ${c.dim("optional:")} npm install @inferall/sdk`);
|
|
594
3032
|
else console.log(` ${c.dim("optional:")} pip install inferall-ai`);
|
|
595
3033
|
}
|
|
596
3034
|
|
|
3035
|
+
if (vercelAiSites.length) printVercelAiGuidance(root, vercelAiSites);
|
|
3036
|
+
// 2026-07-27 tick 831: a file that calls createOpenAI WITHOUT an InferAll
|
|
3037
|
+
// baseURL routes to the provider, and init used to say nothing about it and
|
|
3038
|
+
// then print "Done. Your project is wired to InferAll". Measured: a one-file
|
|
3039
|
+
// project in exactly that state got a clean Done while every request went to
|
|
3040
|
+
// OpenAI. These need one argument, not the whole snippet, so they get their
|
|
3041
|
+
// own line rather than being folded into the guidance above.
|
|
3042
|
+
if (vercelFactoryUnpointed.length) {
|
|
3043
|
+
console.log(`\n ${c.bold("Vercel AI SDK factory")} without an InferAll baseURL in ${vercelFactoryUnpointed.length} file(s):`);
|
|
3044
|
+
for (const s of vercelFactoryUnpointed) console.log(` - ${relative(root, s.file)}`);
|
|
3045
|
+
console.log(` These were ${c.bold("NOT changed")}. Add one argument to the factory call:`);
|
|
3046
|
+
console.log(c.cyan(` baseURL: "${API_BASE}"`));
|
|
3047
|
+
}
|
|
597
3048
|
const geminiSites = findGeminiSites(root);
|
|
598
3049
|
if (geminiSites.length) printGeminiGuidance(root, geminiSites);
|
|
3050
|
+
if (notebookSites.length) printNotebookGuidance(root, notebookSites);
|
|
599
3051
|
|
|
600
3052
|
// --- verify with a live call ---
|
|
601
3053
|
const testModel = flagValue("model") || FREE_MODEL;
|
|
@@ -603,33 +3055,162 @@ async function main() {
|
|
|
603
3055
|
const test = await apiChat(key, [{ role: "user", content: "Reply with the single word: ready" }], testModel);
|
|
604
3056
|
if (test.status === 200) {
|
|
605
3057
|
console.log(c.green("success ✓"));
|
|
606
|
-
|
|
3058
|
+
// 2026-07-26 tick 804: do not claim the project is wired when we have just
|
|
3059
|
+
// printed guidance saying specific files were NOT changed. Saying "Done.
|
|
3060
|
+
// Your project is wired to InferAll" directly under a list of un-migrated
|
|
3061
|
+
// call sites is the same false-success the monorepo bug produced, only
|
|
3062
|
+
// this time contradicted by our own output three lines earlier.
|
|
3063
|
+
// 2026-07-26 tick 816: notebooks were missing from this sum, so the wizard
|
|
3064
|
+
// printed "Done. Your project is wired to InferAll" three lines under its
|
|
3065
|
+
// own "These were NOT changed" notebook list. The t804 fix was correct and
|
|
3066
|
+
// simply did not know about a surface added later.
|
|
3067
|
+
//
|
|
3068
|
+
// THIS SUM MUST INCLUDE EVERY DETECTED-BUT-NOT-REWRITTEN SURFACE. It has
|
|
3069
|
+
// now drifted once already, and it is the fourth time in this codebase that
|
|
3070
|
+
// one fact spread across several places went stale in one of them
|
|
3071
|
+
// (t811 base URLs, t813 the metrics filter, t815 three model call sites).
|
|
3072
|
+
// Anything that prints "NOT changed" guidance belongs here, or the closing
|
|
3073
|
+
// line contradicts it.
|
|
3074
|
+
// 2026-07-30 tick 854. MEASURED: three identical call sites, one past the
|
|
3075
|
+
// depth cap and one over the size cap, produced "Found 1 client constructor
|
|
3076
|
+
// site(s)" and then "Done. Your project is wired to InferAll." while two
|
|
3077
|
+
// files still called OpenAI directly. That is the tick 805 outcome exactly.
|
|
3078
|
+
// A cap the user cannot see becomes a claim the tool cannot support, so an
|
|
3079
|
+
// incomplete scan counts as outstanding work like any unwritable file.
|
|
3080
|
+
const capInit = cappedSummary(cappedInit);
|
|
3081
|
+
if (capInit) {
|
|
3082
|
+
console.log(` ${c.yellow("~")} ${capInit}, so there may be client code this did not see.`);
|
|
3083
|
+
console.log(` Wire any client in those paths by hand, or move it within 6 directory levels and re-run.`);
|
|
3084
|
+
}
|
|
3085
|
+
const manual = vercelAiSites.length + geminiSites.length + notebookSites.length + unwritableCount
|
|
3086
|
+
+ vercelFactoryUnpointed.length + (capInit ? 1 : 0); // tick 831, see the note above this sum
|
|
3087
|
+
// 2026-07-27 tick 836: a filesystem problem outranks the file counts. With
|
|
3088
|
+
// .env unwritable the key never reached disk, so "Done. Your project is
|
|
3089
|
+
// wired to InferAll" was flatly false -- the next run finds no key at all.
|
|
3090
|
+
// With .gitignore unwritable the key IS on disk and unprotected, which is
|
|
3091
|
+
// not "done" either. Both used to print Done and exit 0.
|
|
3092
|
+
if (envResult.problems.length) {
|
|
3093
|
+
const what = envResult.keyWritten
|
|
3094
|
+
? `Your key is in .env, but ${c.bold(".gitignore")} was not updated.`
|
|
3095
|
+
: `${c.bold("Your key was not saved.")} Pass it with ${c.bold("--key")} until ${dirIsUnwritable(root) ? "this directory is writable" : "the file above is fixed"}.`;
|
|
3096
|
+
console.log(`\n${c.yellow(c.bold("Setup incomplete."))} ${what}`);
|
|
3097
|
+
console.log(` The gateway accepted your key, so the account side is fine. Models list: ${API_BASE}/models\n`);
|
|
3098
|
+
rl.close();
|
|
3099
|
+
exit(1);
|
|
3100
|
+
}
|
|
3101
|
+
if (manual > 0) {
|
|
3102
|
+
console.log(`\n${c.green(c.bold("Key verified and configured."))} ${c.yellow(`${manual} file(s) still need the one-line change above.`)}`);
|
|
3103
|
+
console.log(` Your key works and .env is set. Models list: ${API_BASE}/models\n`);
|
|
3104
|
+
} else {
|
|
3105
|
+
console.log(`\n${c.green(c.bold("Done."))} Your project is wired to InferAll. Models list: ${API_BASE}/models\n`);
|
|
3106
|
+
}
|
|
607
3107
|
} else if (test.status === 402) {
|
|
608
3108
|
console.log(c.yellow("blocked (402)"));
|
|
609
3109
|
console.log(`\n Everything is wired up, but your account isn't activated yet.`);
|
|
610
|
-
// Real account state beats a generic message
|
|
3110
|
+
// Real account state beats a generic message: trial counter + whether a
|
|
611
3111
|
// checkout was already started (settle vs start are different next steps).
|
|
612
3112
|
const st = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
|
|
3113
|
+
signal: timeoutSignal(),
|
|
613
3114
|
headers: { Authorization: `Bearer ${key}` },
|
|
614
3115
|
}).then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
615
3116
|
if (st?.trial) {
|
|
616
|
-
console.log(` Trial: ${st.trial.used}/${st.trial.cap} free calls used${st.has_card ? c.dim(" · checkout started but not settled
|
|
3117
|
+
console.log(` Trial: ${st.trial.used}/${st.trial.cap} free calls used. ${st.trial.remaining === 0 ? "none left" : `${st.trial.remaining} still available`}${st.has_card ? c.dim(" · checkout started but not settled, and finishing it activates you") : ""}.`);
|
|
617
3118
|
}
|
|
618
|
-
|
|
619
|
-
|
|
3119
|
+
// Same correction as printGeminiGuidance above; see the note there.
|
|
3120
|
+
console.log(` The 10+ free NIM models are ${c.bold("$0 in/out")}; premium tokens bill at InferAll's published per-token rate. A one-time ${c.bold("$5 activation")} (it becomes spendable`);
|
|
3121
|
+
console.log(` balance, not a fee) unlocks every model, including the 10+ free NIM models.`);
|
|
620
3122
|
console.log(` Activate: ${c.cyan(BILLING_URL)}`);
|
|
621
|
-
console.log(` The code changes above are done
|
|
3123
|
+
console.log(` The code changes above are done. The first call will work the moment you activate.`);
|
|
622
3124
|
console.log(` Check anytime: ${c.bold("npx @inferall/cli status")}\n`);
|
|
3125
|
+
} else if (test.status === 401 || test.status === 403) {
|
|
3126
|
+
// 2026-07-27 tick 826: this fell into the generic branch below, which
|
|
3127
|
+
// printed a bare "HTTP 401", advised re-running init, and exited 0.
|
|
3128
|
+
//
|
|
3129
|
+
// Three separate things were wrong with that, in increasing order of harm.
|
|
3130
|
+
// The wizard printed "verifying key ... ok" four lines earlier, so it
|
|
3131
|
+
// contradicted its own output with no acknowledgement. Retrying an
|
|
3132
|
+
// authentication failure with the same credential cannot succeed, so the
|
|
3133
|
+
// remedy we printed was a dead end. And exiting 0 told every calling
|
|
3134
|
+
// script that setup had worked, while the codemod had already repointed a
|
|
3135
|
+
// working project at a gateway that rejects its key. A tool that breaks a
|
|
3136
|
+
// project and reports success is the worst rung of the severity ladder,
|
|
3137
|
+
// and this is the second time the exit status has been the tell (t824).
|
|
3138
|
+
//
|
|
3139
|
+
// The pre-flight hits /ai/v1/key/status and this hits /v1/chat/completions,
|
|
3140
|
+
// so a key revoked between the two, or valid for a different account,
|
|
3141
|
+
// produces exactly this split. Measured against a stub that answers 200 on
|
|
3142
|
+
// status and 401 on chat.
|
|
3143
|
+
console.log(c.red("rejected (401)"));
|
|
3144
|
+
console.log(`\n The gateway accepted this key a moment ago and has now rejected it.`);
|
|
3145
|
+
console.log(` That usually means the key was revoked or rotated between the two checks,`);
|
|
3146
|
+
console.log(` or that it belongs to a different account than the one you are setting up.`);
|
|
3147
|
+
console.log(`\n ${c.bold("Your files were already changed.")} The code and .env now point at InferAll,`);
|
|
3148
|
+
console.log(` so calls will keep failing until the key works. Re-running with the same key`);
|
|
3149
|
+
console.log(` cannot help.`);
|
|
3150
|
+
console.log(`\n Mint a fresh key at ${c.cyan(KEYS_URL)}, then re-run:`);
|
|
3151
|
+
console.log(` ${c.bold("npx @inferall/cli init --key ifu_your_new_key")}\n`);
|
|
3152
|
+
rl.close();
|
|
3153
|
+
exit(1);
|
|
623
3154
|
} else {
|
|
3155
|
+
// Transient or unexpected. Retrying IS the right advice here, unlike the
|
|
3156
|
+
// 401 case above, but the run still failed to verify so it must not exit 0.
|
|
624
3157
|
console.log(c.red(`HTTP ${test.status}`));
|
|
625
3158
|
console.log(` ${JSON.stringify(test.body).slice(0, 300)}`);
|
|
626
3159
|
console.log(` Setup files were written; re-run ${c.bold("npx @inferall/cli init")} to retry the verify step.\n`);
|
|
3160
|
+
rl.close();
|
|
3161
|
+
exit(1);
|
|
627
3162
|
}
|
|
628
3163
|
rl.close();
|
|
629
3164
|
}
|
|
630
3165
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
3166
|
+
// Run only when invoked as the entry point. Before this guard the file executed
|
|
3167
|
+
// main() on import, so the codemod could not be unit-tested at all -- which is
|
|
3168
|
+
// exactly how the 2026-07-25 bug shipped: `init` rewrote a client to point at
|
|
3169
|
+
// InferAll while leaving the caller's OPENAI_API_KEY in place, printed "Done",
|
|
3170
|
+
// and left a project that threw on its first run.
|
|
3171
|
+
/**
|
|
3172
|
+
* True when this file is being RUN, as opposed to imported by a test.
|
|
3173
|
+
*
|
|
3174
|
+
* argv[1] must be resolved through realpath first. npm installs a bin as a
|
|
3175
|
+
* SYMLINK at node_modules/.bin/inferall, and when the shell execs that symlink
|
|
3176
|
+
* via the shebang, argv[1] is the symlink path while import.meta.url is always
|
|
3177
|
+
* the resolved real file. Comparing them unresolved is false for every
|
|
3178
|
+
* npm-installed invocation, so main() never ran and the process exited 0 in
|
|
3179
|
+
* silence: `npx @inferall/cli` did nothing at all.
|
|
3180
|
+
*
|
|
3181
|
+
* The tests could not catch it. They spawn `node <real path to bin/inferall.js>`,
|
|
3182
|
+
* where argv[1] IS the real file and the comparison happens to hold. The guard
|
|
3183
|
+
* was added in #114 to make this module importable BY those tests, so the change
|
|
3184
|
+
* made for testability broke the only path a user actually takes.
|
|
3185
|
+
*/
|
|
3186
|
+
function isRunningAsEntrypoint(p) {
|
|
3187
|
+
if (!p) return false;
|
|
3188
|
+
try {
|
|
3189
|
+
return import.meta.url === pathToFileURL(realpathSync(p)).href;
|
|
3190
|
+
} catch {
|
|
3191
|
+
// A missing or unreadable argv[1] must not silently disable the CLI. Fall
|
|
3192
|
+
// back to the unresolved comparison rather than returning false.
|
|
3193
|
+
return import.meta.url === pathToFileURL(p).href;
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
const isEntrypoint = isRunningAsEntrypoint(argv[1]);
|
|
3197
|
+
if (isEntrypoint) {
|
|
3198
|
+
main().catch((err) => {
|
|
3199
|
+
// 2026-07-27 tick 832: an unreachable gateway reached here and printed
|
|
3200
|
+
// Node's own words -- a bare "✗ fetch failed" with no URL, no cause and no
|
|
3201
|
+
// next step. `models` offline was the measured case.
|
|
3202
|
+
if (isNetworkError(err)) for (const line of unreachableLines(err)) console.error(line);
|
|
3203
|
+
else if (err instanceof InputEndedError) {
|
|
3204
|
+
// Naming the working command matters more than naming the fault. This
|
|
3205
|
+
// exact invocation was verified end to end: it writes .env, gitignores
|
|
3206
|
+
// it, rewrites the client constructors and makes a test call.
|
|
3207
|
+
console.error(`${c.red("✗")} setup needs an answer but there is no more input to read.`);
|
|
3208
|
+
console.error(` Anything reported above already happened and is on disk.`);
|
|
3209
|
+
console.error(` For a non-interactive setup, pass the key and accept the defaults:`);
|
|
3210
|
+
console.error(` ${c.cyan("npx @inferall/cli init --key ifu_... --yes")}`);
|
|
3211
|
+
console.error(` Or run ${c.cyan("npx @inferall/cli init")} in a terminal to answer the prompts.`);
|
|
3212
|
+
} else console.error(`${c.red("✗")} ${err?.message || err}`);
|
|
3213
|
+
rl.close();
|
|
3214
|
+
exit(1);
|
|
3215
|
+
});
|
|
3216
|
+
}
|