@inferall/cli 0.2.0
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 +55 -0
- package/bin/inferall.js +635 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @inferall/cli
|
|
2
|
+
|
|
3
|
+
Install [InferAll](https://inferall.ai) into your project in one command:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @inferall/cli init
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The wizard:
|
|
10
|
+
|
|
11
|
+
1. **Detects your project** — Node or Python, and whether you already use the
|
|
12
|
+
`openai`, `@anthropic-ai/sdk`, or `@google/generative-ai` SDKs.
|
|
13
|
+
2. **Sets up your key** — writes `INFERALL_API_KEY` to `.env`, adds a
|
|
14
|
+
placeholder to `.env.example`, and makes sure `.env` is gitignored.
|
|
15
|
+
3. **Migrates existing code** — points `OpenAI` and `Anthropic` client
|
|
16
|
+
constructors at the InferAll gateway (the OpenAI SDK gets
|
|
17
|
+
`https://api.inferall.ai/v1`; the Anthropic SDK gets the host root since it
|
|
18
|
+
appends `/v1/messages` itself). Simple constructors are rewritten
|
|
19
|
+
deterministically; anything ambiguous gets an AI-suggested patch (generated
|
|
20
|
+
by InferAll's own $0 NIM models — the wizard dogfoods the gateway) that you
|
|
21
|
+
review before it's applied. Google Gemini SDK usage is detected too — that
|
|
22
|
+
SDK speaks its own protocol, so instead of a risky rewrite you get the exact
|
|
23
|
+
OpenAI-compat snippet (`model: "gemini/gemini-2.5-flash"`) to migrate at
|
|
24
|
+
your own pace.
|
|
25
|
+
4. **Verifies with a live call** — and if your account isn't activated yet, it
|
|
26
|
+
says exactly that, with the link, instead of failing opaquely.
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
- `init` — the wizard above (default).
|
|
31
|
+
- `doctor` — health-check an existing setup: key present + valid, `.env`
|
|
32
|
+
gitignore hygiene, whether every client call site points at the gateway, and
|
|
33
|
+
a live call that reports your real account state (active vs `$5` activation
|
|
34
|
+
pending). Exit code 0 only when everything passes — CI-friendly.
|
|
35
|
+
- `models` — the live model list with real prices, straight from the gateway.
|
|
36
|
+
`--free` shows only the $0 models; `--json` gives the raw pricing map.
|
|
37
|
+
- `status` — your account state from the key's point of view: tier, activation,
|
|
38
|
+
balance, trial calls remaining, and what the free models saved you this week.
|
|
39
|
+
The fast answer to "did my payment land?"
|
|
40
|
+
|
|
41
|
+
## Flags
|
|
42
|
+
|
|
43
|
+
| Flag | Effect |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `--key ifu_...` | Skip the key prompt |
|
|
46
|
+
| `--yes` / `-y` | Accept all defaults (non-interactive) |
|
|
47
|
+
| `--no-ai` | Deterministic codemod only, no AI-suggested patches |
|
|
48
|
+
|
|
49
|
+
## Why this exists
|
|
50
|
+
|
|
51
|
+
Most gateways make you read docs to change one line. This makes the one-line
|
|
52
|
+
change for you, tells you the truth about the $5 activation gate, and leaves
|
|
53
|
+
your code otherwise untouched — no reformatting, no renames.
|
|
54
|
+
|
|
55
|
+
Zero runtime dependencies. Node 18+.
|
package/bin/inferall.js
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// InferAll install wizard — `npx @inferall/cli init`
|
|
3
|
+
//
|
|
4
|
+
// Gets a project from zero to a working InferAll call in one command:
|
|
5
|
+
// 1. Detects the project (Node / Python) and any existing AI SDK usage.
|
|
6
|
+
// 2. Sets up INFERALL_API_KEY in .env (+ .env.example, .gitignore).
|
|
7
|
+
// 3. Points existing OpenAI-SDK call sites at the InferAll gateway
|
|
8
|
+
// (deterministic codemod; AI-assisted patch suggestions for call
|
|
9
|
+
// sites the codemod can't confidently rewrite — powered by
|
|
10
|
+
// InferAll's own $0 NIM models, so the wizard dogfoods the product).
|
|
11
|
+
// 4. Verifies with a live call and explains exactly what state the
|
|
12
|
+
// account is in (including the $5 activation gate) instead of
|
|
13
|
+
// failing opaquely.
|
|
14
|
+
//
|
|
15
|
+
// Zero runtime dependencies. Node 18+ (built-in fetch).
|
|
16
|
+
|
|
17
|
+
import { createInterface } from "node:readline/promises";
|
|
18
|
+
import { stdin, stdout, exit, argv, env, cwd } from "node:process";
|
|
19
|
+
import {
|
|
20
|
+
readFileSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
appendFileSync,
|
|
23
|
+
existsSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
statSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { join, relative, extname } from "node:path";
|
|
28
|
+
|
|
29
|
+
const API_BASE = env.INFERALL_API_BASE || "https://api.inferall.ai/v1";
|
|
30
|
+
// The Anthropic SDK appends /v1/messages itself, so its baseURL is the host root.
|
|
31
|
+
const ANTHROPIC_BASE = API_BASE.replace(/\/v1$/, "");
|
|
32
|
+
const KEYS_URL = "https://inferall.ai/keys";
|
|
33
|
+
const BILLING_URL = "https://inferall.ai/billing?src=cli_init";
|
|
34
|
+
const FREE_MODEL = "meta/llama-3.1-8b-instruct";
|
|
35
|
+
|
|
36
|
+
const c = {
|
|
37
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
38
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
39
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
40
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
41
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
42
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const args = argv.slice(2);
|
|
46
|
+
const cmd = args.find((a) => !a.startsWith("-")) || "init";
|
|
47
|
+
const flag = (name) => args.includes(`--${name}`);
|
|
48
|
+
const flagValue = (name) => {
|
|
49
|
+
const i = args.indexOf(`--${name}`);
|
|
50
|
+
return i >= 0 && args[i + 1] && !args[i + 1].startsWith("-") ? args[i + 1] : undefined;
|
|
51
|
+
};
|
|
52
|
+
const YES = flag("yes") || flag("y");
|
|
53
|
+
const NO_AI = flag("no-ai");
|
|
54
|
+
|
|
55
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
56
|
+
async function ask(q, fallback) {
|
|
57
|
+
if (YES) return fallback ?? "y";
|
|
58
|
+
const a = (await rl.question(q)).trim();
|
|
59
|
+
return a === "" ? (fallback ?? "") : a;
|
|
60
|
+
}
|
|
61
|
+
async function confirm(q, defaultYes = true) {
|
|
62
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
63
|
+
const a = (await ask(`${q} ${c.dim(`[${hint}]`)} `, defaultYes ? "y" : "n")).toLowerCase();
|
|
64
|
+
return defaultYes ? a !== "n" : a === "y";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------- project detection ----------
|
|
68
|
+
|
|
69
|
+
function detectProject(root) {
|
|
70
|
+
const p = { root, kind: null, sdks: [], pkg: null };
|
|
71
|
+
const pkgPath = join(root, "package.json");
|
|
72
|
+
if (existsSync(pkgPath)) {
|
|
73
|
+
p.kind = "node";
|
|
74
|
+
try {
|
|
75
|
+
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
|
+
} catch {
|
|
81
|
+
/* unparseable package.json — treat as bare node */
|
|
82
|
+
}
|
|
83
|
+
} else if (existsSync(join(root, "pyproject.toml")) || existsSync(join(root, "requirements.txt"))) {
|
|
84
|
+
p.kind = "python";
|
|
85
|
+
const reqs = ["pyproject.toml", "requirements.txt"]
|
|
86
|
+
.filter((f) => existsSync(join(root, f)))
|
|
87
|
+
.map((f) => readFileSync(join(root, f), "utf8"))
|
|
88
|
+
.join("\n");
|
|
89
|
+
for (const name of ["openai", "anthropic", "google-generativeai", "google-genai", "inferall-ai"]) {
|
|
90
|
+
if (new RegExp(`^\\s*"?${name}`, "m").test(reqs)) p.sdks.push(name);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return p;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------- env / key setup ----------
|
|
97
|
+
|
|
98
|
+
function upsertEnvFile(root, key) {
|
|
99
|
+
const envPath = join(root, ".env");
|
|
100
|
+
const line = `INFERALL_API_KEY=${key}`;
|
|
101
|
+
if (existsSync(envPath)) {
|
|
102
|
+
const body = readFileSync(envPath, "utf8");
|
|
103
|
+
if (/^INFERALL_API_KEY=/m.test(body)) {
|
|
104
|
+
writeFileSync(envPath, body.replace(/^INFERALL_API_KEY=.*$/m, line));
|
|
105
|
+
} else {
|
|
106
|
+
appendFileSync(envPath, `${body.endsWith("\n") || body === "" ? "" : "\n"}${line}\n`);
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
writeFileSync(envPath, `${line}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const examplePath = join(root, ".env.example");
|
|
113
|
+
const exampleLine = "INFERALL_API_KEY=ifu_your_key_here";
|
|
114
|
+
if (existsSync(examplePath)) {
|
|
115
|
+
const body = readFileSync(examplePath, "utf8");
|
|
116
|
+
if (!/^INFERALL_API_KEY=/m.test(body)) {
|
|
117
|
+
appendFileSync(examplePath, `${body.endsWith("\n") || body === "" ? "" : "\n"}${exampleLine}\n`);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
writeFileSync(examplePath, `${exampleLine}\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const giPath = join(root, ".gitignore");
|
|
124
|
+
if (existsSync(giPath)) {
|
|
125
|
+
const gi = readFileSync(giPath, "utf8");
|
|
126
|
+
if (!gi.split("\n").some((l) => l.trim() === ".env" || l.trim() === ".env*" || l.trim() === "*.env")) {
|
|
127
|
+
appendFileSync(giPath, `${gi.endsWith("\n") ? "" : "\n"}.env\n`);
|
|
128
|
+
console.log(` ${c.green("+")} added ${c.bold(".env")} to .gitignore`);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
writeFileSync(giPath, ".env\n");
|
|
132
|
+
console.log(` ${c.green("+")} created .gitignore with .env`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------- gateway API helpers ----------
|
|
137
|
+
|
|
138
|
+
async function apiGet(path, key) {
|
|
139
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
140
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
141
|
+
});
|
|
142
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function apiChat(key, messages, model = FREE_MODEL) {
|
|
146
|
+
const res = await fetch(`${API_BASE}/chat/completions`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ model, messages, max_tokens: 512 }),
|
|
150
|
+
});
|
|
151
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------- codemod: point OpenAI SDK at InferAll ----------
|
|
155
|
+
|
|
156
|
+
const SOURCE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py"]);
|
|
157
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "venv", ".venv", "__pycache__", ".turbo"]);
|
|
158
|
+
|
|
159
|
+
function* walk(dir, depth = 0) {
|
|
160
|
+
if (depth > 6) return;
|
|
161
|
+
let entries = [];
|
|
162
|
+
try {
|
|
163
|
+
entries = readdirSync(dir);
|
|
164
|
+
} catch {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
for (const name of entries) {
|
|
168
|
+
if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
|
|
169
|
+
const full = join(dir, name);
|
|
170
|
+
let st;
|
|
171
|
+
try {
|
|
172
|
+
st = statSync(full);
|
|
173
|
+
} catch {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (st.isDirectory()) yield* walk(full, depth + 1);
|
|
177
|
+
else if (st.isFile() && SOURCE_EXTS.has(extname(name)) && st.size < 512 * 1024) yield full;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function findCallSites(root) {
|
|
182
|
+
const sites = [];
|
|
183
|
+
for (const file of walk(root)) {
|
|
184
|
+
let body;
|
|
185
|
+
try {
|
|
186
|
+
body = readFileSync(file, "utf8");
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const isPy = file.endsWith(".py");
|
|
191
|
+
const baseKey = isPy ? "base_url" : "baseURL";
|
|
192
|
+
for (const sdk of ["OpenAI", "Anthropic"]) {
|
|
193
|
+
const ctorRe = isPy ? new RegExp(`\\b${sdk}\\s*\\(`) : new RegExp(`new\\s+${sdk}\\s*\\(`);
|
|
194
|
+
if (!ctorRe.test(body)) continue;
|
|
195
|
+
sites.push({
|
|
196
|
+
file,
|
|
197
|
+
body,
|
|
198
|
+
isPy,
|
|
199
|
+
sdk,
|
|
200
|
+
alreadyPointed: body.includes(new URL(API_BASE).host),
|
|
201
|
+
hasBaseUrl: new RegExp(`${baseKey}\\s*[:=]`).test(body),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return sites;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Deterministic rewrite for the common zero-arg / simple-config constructor.
|
|
209
|
+
// The OpenAI SDK's baseURL includes /v1; the Anthropic SDK appends /v1 itself.
|
|
210
|
+
function patchSite(site) {
|
|
211
|
+
const { body, isPy, sdk } = site;
|
|
212
|
+
const base = sdk === "Anthropic" ? ANTHROPIC_BASE : API_BASE;
|
|
213
|
+
const baseLine = isPy
|
|
214
|
+
? `base_url="${base}", api_key=os.environ["INFERALL_API_KEY"]`
|
|
215
|
+
: `baseURL: "${base}", apiKey: process.env.INFERALL_API_KEY`;
|
|
216
|
+
|
|
217
|
+
let patched = null;
|
|
218
|
+
if (isPy) {
|
|
219
|
+
const bare = new RegExp(`\\b${sdk}\\s*\\(\\s*\\)`);
|
|
220
|
+
if (bare.test(body)) {
|
|
221
|
+
patched = body.replace(bare, `${sdk}(${baseLine})`);
|
|
222
|
+
if (!/^import os$/m.test(patched)) patched = `import os\n${patched}`;
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
const bare = new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\)`);
|
|
226
|
+
const obj = new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\{`);
|
|
227
|
+
if (bare.test(body)) {
|
|
228
|
+
patched = body.replace(bare, `new ${sdk}({ ${baseLine} })`);
|
|
229
|
+
} else if (obj.test(body) && !site.hasBaseUrl) {
|
|
230
|
+
patched = body.replace(obj, `new ${sdk}({ baseURL: "${base}", `);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return patched;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Gemini SDKs (@google/generative-ai, @google/genai, google-generativeai) speak
|
|
237
|
+
// their own wire protocol (generateContent), which the gateway doesn't serve —
|
|
238
|
+
// Gemini models are reached through the OpenAI-compat endpoint with a gemini/
|
|
239
|
+
// model prefix. These sites get exact guidance, never a baseURL patch, and no
|
|
240
|
+
// AI rewrite either: a cross-protocol reshape (response shapes differ) is too
|
|
241
|
+
// invasive to automate safely.
|
|
242
|
+
function findGeminiSites(root) {
|
|
243
|
+
const sites = [];
|
|
244
|
+
for (const file of walk(root)) {
|
|
245
|
+
let body;
|
|
246
|
+
try {
|
|
247
|
+
body = readFileSync(file, "utf8");
|
|
248
|
+
} catch {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const isPy = file.endsWith(".py");
|
|
252
|
+
const hit = isPy
|
|
253
|
+
? /\bgenai\.(configure|GenerativeModel|Client)\s*\(/.test(body)
|
|
254
|
+
: /new\s+(GoogleGenerativeAI|GoogleGenAI)\s*\(/.test(body);
|
|
255
|
+
if (hit) sites.push({ file, isPy });
|
|
256
|
+
}
|
|
257
|
+
return sites;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function printGeminiGuidance(root, sites) {
|
|
261
|
+
console.log(`\n ${c.bold("Gemini SDK usage")} found in ${sites.length} file(s):`);
|
|
262
|
+
for (const s of sites) console.log(` - ${relative(root, s.file)}`);
|
|
263
|
+
console.log(` The Google SDK speaks its own protocol, so it can't be re-pointed with a base URL.`);
|
|
264
|
+
console.log(` Gemini models work through InferAll's OpenAI-compatible endpoint instead:`);
|
|
265
|
+
if (sites.some((s) => s.isPy)) {
|
|
266
|
+
console.log(c.dim(` # pip install openai`));
|
|
267
|
+
console.log(c.cyan(` ai = OpenAI(base_url="${API_BASE}", api_key=os.environ["INFERALL_API_KEY"])`));
|
|
268
|
+
console.log(c.cyan(` ai.chat.completions.create(model="gemini/gemini-2.5-flash", messages=messages)`));
|
|
269
|
+
} else {
|
|
270
|
+
console.log(c.dim(` // npm install openai`));
|
|
271
|
+
console.log(c.cyan(` const ai = new OpenAI({ baseURL: "${API_BASE}", apiKey: process.env.INFERALL_API_KEY });`));
|
|
272
|
+
console.log(c.cyan(` await ai.chat.completions.create({ model: "gemini/gemini-2.5-flash", messages });`));
|
|
273
|
+
}
|
|
274
|
+
console.log(` Same Gemini models, one ifu_ key, $0 markup — migrate at your own pace.`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// AI-assisted patch for call sites the deterministic codemod can't handle.
|
|
278
|
+
// Uses the user's own key against a $0 NIM model — the wizard dogfoods the
|
|
279
|
+
// gateway. Returns the suggested full-file replacement, or null.
|
|
280
|
+
async function aiSuggestPatch(key, site) {
|
|
281
|
+
const base = site.sdk === "Anthropic" ? ANTHROPIC_BASE : API_BASE;
|
|
282
|
+
const prompt = [
|
|
283
|
+
{
|
|
284
|
+
role: "user",
|
|
285
|
+
content:
|
|
286
|
+
`Rewrite this ${site.isPy ? "Python" : "JavaScript/TypeScript"} file so the ${site.sdk} SDK client sends requests to the InferAll gateway.\n` +
|
|
287
|
+
`Rules:\n` +
|
|
288
|
+
`- Set the client's ${site.isPy ? `base_url to "${base}" and api_key to os.environ["INFERALL_API_KEY"]` : `baseURL to "${base}" and apiKey to process.env.INFERALL_API_KEY`}.\n` +
|
|
289
|
+
`- Change NOTHING else: no reformatting, no renames, no comment changes.\n` +
|
|
290
|
+
`- Reply with ONLY the complete rewritten file, no code fences, no commentary.\n\n` +
|
|
291
|
+
site.body,
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
const { status, body } = await apiChat(key, prompt);
|
|
295
|
+
if (status !== 200) return { status, patched: null };
|
|
296
|
+
const text = body?.choices?.[0]?.message?.content;
|
|
297
|
+
if (!text || Math.abs(text.length - site.body.length) > site.body.length) return { status, patched: null };
|
|
298
|
+
return { status, patched: text.replace(/^```[a-z]*\n?/, "").replace(/\n?```\s*$/, "") };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------- fresh-install example ----------
|
|
302
|
+
|
|
303
|
+
function writeExample(root, kind) {
|
|
304
|
+
if (kind === "python") {
|
|
305
|
+
const p = join(root, "inferall_hello.py");
|
|
306
|
+
if (existsSync(p)) return null;
|
|
307
|
+
writeFileSync(
|
|
308
|
+
p,
|
|
309
|
+
`"""InferAll hello — run: python inferall_hello.py (pip install inferall-ai)"""\nfrom inferall import Inferall\n\nai = Inferall() # reads INFERALL_API_KEY from the environment\nprint(ai.text("Explain InferAll in one sentence."))\n`
|
|
310
|
+
);
|
|
311
|
+
return p;
|
|
312
|
+
}
|
|
313
|
+
const p = join(root, "inferall-hello.mjs");
|
|
314
|
+
if (existsSync(p)) return null;
|
|
315
|
+
writeFileSync(
|
|
316
|
+
p,
|
|
317
|
+
`// InferAll hello — run: node --env-file=.env inferall-hello.mjs\nconst res = await fetch("${API_BASE}/chat/completions", {\n method: "POST",\n headers: {\n Authorization: \`Bearer \${process.env.INFERALL_API_KEY}\`,\n "Content-Type": "application/json",\n },\n body: JSON.stringify({\n model: "${FREE_MODEL}",\n messages: [{ role: "user", content: "Explain InferAll in one sentence." }],\n }),\n});\nconsole.log((await res.json()).choices?.[0]?.message?.content ?? (await res.text()));\n`
|
|
318
|
+
);
|
|
319
|
+
return p;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------- models ----------
|
|
323
|
+
|
|
324
|
+
// Pricing lives on the gateway's public metadata endpoint (host root, not the
|
|
325
|
+
// OpenAI-compat surface): { "<id>": { type, inputPerM|perImage|perSecond, … } }.
|
|
326
|
+
function priceLabel(p) {
|
|
327
|
+
if (p.type === "token") {
|
|
328
|
+
if (!p.inputPerM && !p.outputPerM) return "$0";
|
|
329
|
+
return `$${p.inputPerM}/M in · $${p.outputPerM}/M out`;
|
|
330
|
+
}
|
|
331
|
+
if (p.perImage != null) return `$${p.perImage}/image`;
|
|
332
|
+
if (p.perSecond != null) return `$${p.perSecond}/s`;
|
|
333
|
+
return p.type;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function modelsCmd() {
|
|
337
|
+
const res = await fetch(`${ANTHROPIC_BASE}/ai/v1/models`);
|
|
338
|
+
if (!res.ok) {
|
|
339
|
+
console.log(`${c.red("✗")} couldn't fetch the model list (HTTP ${res.status})`);
|
|
340
|
+
rl.close();
|
|
341
|
+
exit(1);
|
|
342
|
+
}
|
|
343
|
+
const map = await res.json();
|
|
344
|
+
let rows = Object.entries(map).map(([id, p]) => ({
|
|
345
|
+
id,
|
|
346
|
+
type: p.type,
|
|
347
|
+
free: p.type === "token" && !p.inputPerM && !p.outputPerM,
|
|
348
|
+
price: priceLabel(p),
|
|
349
|
+
sort: p.type === "token" ? (p.inputPerM || 0) : Infinity,
|
|
350
|
+
}));
|
|
351
|
+
if (flag("free")) rows = rows.filter((r) => r.free);
|
|
352
|
+
if (flag("json")) {
|
|
353
|
+
console.log(JSON.stringify(flag("free") ? Object.fromEntries(rows.map((r) => [r.id, map[r.id]])) : map, null, 2));
|
|
354
|
+
rl.close();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
rows.sort((a, b) => (a.free !== b.free ? (a.free ? -1 : 1) : a.sort - b.sort || a.id.localeCompare(b.id)));
|
|
358
|
+
const w = Math.min(48, Math.max(...rows.map((r) => r.id.length)) + 2);
|
|
359
|
+
console.log(`\n${c.bold("MODEL".padEnd(w))}${c.bold("TYPE".padEnd(12))}${c.bold("PRICE")}`);
|
|
360
|
+
for (const r of rows) {
|
|
361
|
+
const price = r.free ? c.green("$0") : r.price;
|
|
362
|
+
console.log(`${r.id.padEnd(w)}${r.type.padEnd(12)}${price}`);
|
|
363
|
+
}
|
|
364
|
+
const freeCount = Object.values(map).filter((p) => p.type === "token" && !p.inputPerM && !p.outputPerM).length;
|
|
365
|
+
console.log(`\n${Object.keys(map).length} models — ${c.green(`${freeCount} at $0`)} (NVIDIA NIM). Use any with your ifu_ key.\n`);
|
|
366
|
+
rl.close();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------- status ----------
|
|
370
|
+
|
|
371
|
+
// GET /ai/v1/key/status (host root, API-key auth) — tier, trial counter,
|
|
372
|
+
// balance, savings. Answers "did my payment land?" without opening a browser.
|
|
373
|
+
async function statusCmd() {
|
|
374
|
+
let key = env.INFERALL_API_KEY || flagValue("key") || "";
|
|
375
|
+
const envPath = join(cwd(), ".env");
|
|
376
|
+
if (!key && existsSync(envPath)) {
|
|
377
|
+
const m = readFileSync(envPath, "utf8").match(/^INFERALL_API_KEY=(.+)$/m);
|
|
378
|
+
if (m) key = m[1].trim();
|
|
379
|
+
}
|
|
380
|
+
if (!key) {
|
|
381
|
+
console.log(`${c.red("✗")} No key in INFERALL_API_KEY or .env — run ${c.bold("npx @inferall/cli init")}`);
|
|
382
|
+
rl.close();
|
|
383
|
+
exit(1);
|
|
384
|
+
}
|
|
385
|
+
const res = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
|
|
386
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
387
|
+
});
|
|
388
|
+
if (!res.ok) {
|
|
389
|
+
console.log(`${c.red("✗")} status check failed (HTTP ${res.status}) — check the key at ${KEYS_URL}`);
|
|
390
|
+
rl.close();
|
|
391
|
+
exit(1);
|
|
392
|
+
}
|
|
393
|
+
const s = await res.json();
|
|
394
|
+
console.log(`\n${c.bold("InferAll account status")} ${c.dim(`(key ${key.slice(0, 8)}…)`)}\n`);
|
|
395
|
+
if (s.project) {
|
|
396
|
+
console.log(` tier: ${c.cyan(s.tier)} ${c.dim("(project key — billed separately, no trial counter)")}\n`);
|
|
397
|
+
rl.close();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const active = s.has_paid_successfully;
|
|
401
|
+
console.log(` tier: ${c.cyan(s.tier)}`);
|
|
402
|
+
console.log(` activated: ${active ? c.green("yes") : c.yellow("not yet")}${!active && s.has_card ? c.dim(" (checkout started, not settled)") : ""}`);
|
|
403
|
+
console.log(` balance: ${s.balance_usd > 0 ? c.green(`$${s.balance_usd.toFixed(2)}`) : `$${s.balance_usd.toFixed(2)}`}`);
|
|
404
|
+
if (s.trial) console.log(` trial: ${s.trial.remaining}/${s.trial.cap} calls remaining`);
|
|
405
|
+
const sv = s.savings_7d;
|
|
406
|
+
if (sv && (sv.vs_gpt4o_mini_usd > 0 || sv.vs_claude_sonnet_usd > 0)) {
|
|
407
|
+
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
|
+
}
|
|
409
|
+
if (!active) {
|
|
410
|
+
console.log(`\n Activate with the one-time ${c.bold("$5 starter pack")} (becomes spendable balance):`);
|
|
411
|
+
console.log(` ${c.cyan(BILLING_URL)}`);
|
|
412
|
+
}
|
|
413
|
+
console.log("");
|
|
414
|
+
rl.close();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------- doctor ----------
|
|
418
|
+
|
|
419
|
+
async function doctor() {
|
|
420
|
+
console.log(`\n${c.bold("InferAll doctor")} ${c.dim(`— checks your setup against ${API_BASE}`)}\n`);
|
|
421
|
+
const root = cwd();
|
|
422
|
+
let ok = true;
|
|
423
|
+
|
|
424
|
+
// 1. key present
|
|
425
|
+
let key = env.INFERALL_API_KEY || "";
|
|
426
|
+
const envPath = join(root, ".env");
|
|
427
|
+
if (!key && existsSync(envPath)) {
|
|
428
|
+
const m = readFileSync(envPath, "utf8").match(/^INFERALL_API_KEY=(.+)$/m);
|
|
429
|
+
if (m) key = m[1].trim();
|
|
430
|
+
}
|
|
431
|
+
if (!key) {
|
|
432
|
+
console.log(` ${c.red("✗")} INFERALL_API_KEY not found in environment or .env — run ${c.bold("npx @inferall/cli init")}`);
|
|
433
|
+
rl.close();
|
|
434
|
+
exit(1);
|
|
435
|
+
}
|
|
436
|
+
console.log(` ${c.green("✓")} key found (${key.slice(0, 8)}…)`);
|
|
437
|
+
if (!key.startsWith("ifu_")) {
|
|
438
|
+
console.log(` ${c.yellow("~")} key doesn't have the ifu_ prefix — is this an InferAll key?`);
|
|
439
|
+
ok = false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 2. .env hygiene
|
|
443
|
+
const giPath = join(root, ".gitignore");
|
|
444
|
+
const giCovers =
|
|
445
|
+
existsSync(giPath) &&
|
|
446
|
+
readFileSync(giPath, "utf8").split("\n").some((l) => [".env", ".env*", "*.env"].includes(l.trim()));
|
|
447
|
+
if (existsSync(envPath) && !giCovers) {
|
|
448
|
+
console.log(` ${c.yellow("~")} .env exists but is NOT gitignored — your key could be committed`);
|
|
449
|
+
ok = false;
|
|
450
|
+
} else {
|
|
451
|
+
console.log(` ${c.green("✓")} .env hygiene ok`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 3. key valid against the gateway
|
|
455
|
+
const models = await apiGet("/models", key).catch(() => null);
|
|
456
|
+
if (!models || (models.status !== 200 && models.status !== 402)) {
|
|
457
|
+
console.log(` ${c.red("✗")} key rejected by gateway (${models ? `HTTP ${models.status}` : "network error"}) — check ${KEYS_URL}`);
|
|
458
|
+
rl.close();
|
|
459
|
+
exit(1);
|
|
460
|
+
}
|
|
461
|
+
console.log(` ${c.green("✓")} key accepted by gateway`);
|
|
462
|
+
|
|
463
|
+
// 4. call-site wiring
|
|
464
|
+
const sites = findCallSites(root);
|
|
465
|
+
const pointed = sites.filter((s) => s.alreadyPointed).length;
|
|
466
|
+
const unpointed = sites.length - pointed;
|
|
467
|
+
if (sites.length === 0) {
|
|
468
|
+
console.log(` ${c.dim("·")} no OpenAI/Anthropic client constructors found in this project`);
|
|
469
|
+
} else if (unpointed === 0) {
|
|
470
|
+
console.log(` ${c.green("✓")} all ${sites.length} client constructor site(s) point at InferAll`);
|
|
471
|
+
} else {
|
|
472
|
+
console.log(` ${c.yellow("~")} ${unpointed} of ${sites.length} client site(s) NOT pointed at InferAll — run ${c.bold("npx @inferall/cli init")}`);
|
|
473
|
+
ok = false;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// 4b. Gemini SDK sites — different protocol, can't be re-pointed
|
|
477
|
+
const gSites = findGeminiSites(root);
|
|
478
|
+
if (gSites.length) {
|
|
479
|
+
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
|
+
ok = false;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 5. live account state
|
|
484
|
+
process.stdout.write(` live call (${FREE_MODEL}) … `);
|
|
485
|
+
const test = await apiChat(key, [{ role: "user", content: "Reply with the single word: ready" }]);
|
|
486
|
+
if (test.status === 200) {
|
|
487
|
+
console.log(c.green("account active ✓"));
|
|
488
|
+
} else if (test.status === 402) {
|
|
489
|
+
console.log(c.yellow("account not activated (402)"));
|
|
490
|
+
console.log(` One-time ${c.bold("$5 activation")} (becomes spendable balance): ${c.cyan(BILLING_URL)}`);
|
|
491
|
+
ok = false;
|
|
492
|
+
} else {
|
|
493
|
+
console.log(c.red(`HTTP ${test.status}`));
|
|
494
|
+
ok = false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
console.log(ok ? `\n${c.green(c.bold("All checks passed."))}\n` : `\n${c.yellow(c.bold("Some checks need attention (see above)."))}\n`);
|
|
498
|
+
rl.close();
|
|
499
|
+
exit(ok ? 0 : 1);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ---------- main ----------
|
|
503
|
+
|
|
504
|
+
async function main() {
|
|
505
|
+
if (flag("version") || cmd === "version") {
|
|
506
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
507
|
+
console.log(pkg.version);
|
|
508
|
+
rl.close();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (cmd === "doctor") return doctor();
|
|
512
|
+
if (cmd === "models") return modelsCmd();
|
|
513
|
+
if (cmd === "status") return statusCmd();
|
|
514
|
+
if (cmd !== "init") {
|
|
515
|
+
console.log(`Usage: npx @inferall/cli <init|doctor|models|status|version> [--key ifu_...] [--yes] [--no-ai] [--free] [--json]`);
|
|
516
|
+
exit(cmd === "help" || flag("help") ? 0 : 1);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
console.log(`\n${c.bold("InferAll setup")} ${c.dim("— one API for OpenAI, Anthropic, Google, and 118+ $0 NIM models")}\n`);
|
|
520
|
+
|
|
521
|
+
const root = cwd();
|
|
522
|
+
const project = detectProject(root);
|
|
523
|
+
if (!project.kind) {
|
|
524
|
+
console.log(`${c.yellow("!")} No package.json / pyproject.toml / requirements.txt found here.`);
|
|
525
|
+
console.log(` Run this from your project root, or continue for a bare .env + example setup.`);
|
|
526
|
+
if (!(await confirm("Continue anyway?", false))) exit(0);
|
|
527
|
+
project.kind = "node";
|
|
528
|
+
}
|
|
529
|
+
console.log(` detected: ${c.cyan(project.kind)} project${project.sdks.length ? ` using ${c.cyan(project.sdks.join(", "))}` : ""}`);
|
|
530
|
+
|
|
531
|
+
// --- key ---
|
|
532
|
+
let key = flagValue("key") || env.INFERALL_API_KEY || "";
|
|
533
|
+
if (!key) {
|
|
534
|
+
console.log(`\n Grab a key at ${c.cyan(KEYS_URL)} (free account, key starts with ${c.bold("ifu_")})`);
|
|
535
|
+
key = await ask(` Paste your InferAll API key: `);
|
|
536
|
+
}
|
|
537
|
+
if (!key.startsWith("ifu_")) {
|
|
538
|
+
console.log(`${c.red("✗")} That doesn't look like an InferAll key (expected ifu_ prefix).`);
|
|
539
|
+
exit(1);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
process.stdout.write(` verifying key against ${API_BASE} … `);
|
|
543
|
+
const models = await apiGet("/models", key).catch(() => null);
|
|
544
|
+
if (!models || (models.status !== 200 && models.status !== 402)) {
|
|
545
|
+
console.log(c.red(`failed (${models ? `HTTP ${models.status}` : "network error"})`));
|
|
546
|
+
console.log(` Check the key at ${KEYS_URL} and re-run.`);
|
|
547
|
+
exit(1);
|
|
548
|
+
}
|
|
549
|
+
console.log(c.green("ok"));
|
|
550
|
+
|
|
551
|
+
// --- env files ---
|
|
552
|
+
upsertEnvFile(root, key);
|
|
553
|
+
console.log(` ${c.green("+")} INFERALL_API_KEY written to ${c.bold(".env")} (and .env.example placeholder)`);
|
|
554
|
+
|
|
555
|
+
// --- integration ---
|
|
556
|
+
const usesSdk = project.sdks.some((s) => ["openai", "@anthropic-ai/sdk", "anthropic"].includes(s));
|
|
557
|
+
if (usesSdk) {
|
|
558
|
+
const sites = findCallSites(root).filter((s) => !s.alreadyPointed);
|
|
559
|
+
if (sites.length === 0) {
|
|
560
|
+
console.log(` ${c.green("✓")} AI SDK present but no un-migrated client constructors found.`);
|
|
561
|
+
} else {
|
|
562
|
+
console.log(`\n Found ${c.bold(sites.length)} client constructor site(s):`);
|
|
563
|
+
for (const s of sites) console.log(` - ${relative(root, s.file)} ${c.dim(`(${s.sdk})`)}`);
|
|
564
|
+
if (await confirm(` Point them at the InferAll gateway?`)) {
|
|
565
|
+
for (const s of sites) {
|
|
566
|
+
let patched = patchSite(s);
|
|
567
|
+
let via = "codemod";
|
|
568
|
+
if (!patched && !NO_AI) {
|
|
569
|
+
process.stdout.write(` ${c.dim("AI-assist")} ${relative(root, s.file)} … `);
|
|
570
|
+
const r = await aiSuggestPatch(key, s);
|
|
571
|
+
if (r.status === 402) {
|
|
572
|
+
console.log(c.yellow("skipped (account not activated yet — see verify step below)"));
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
patched = r.patched;
|
|
576
|
+
via = "AI";
|
|
577
|
+
console.log(patched ? c.green("patch ready") : c.yellow("no confident patch"));
|
|
578
|
+
}
|
|
579
|
+
if (!patched) {
|
|
580
|
+
const manualBase = s.sdk === "Anthropic" ? ANTHROPIC_BASE : API_BASE;
|
|
581
|
+
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.`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (via === "AI" && !(await confirm(` Apply AI-suggested rewrite to ${relative(root, s.file)}?`))) continue;
|
|
585
|
+
writeFileSync(s.file, patched);
|
|
586
|
+
console.log(` ${c.green("✓")} ${relative(root, s.file)} ${c.dim(`(${via})`)}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} else {
|
|
591
|
+
const example = writeExample(root, project.kind);
|
|
592
|
+
if (example) console.log(` ${c.green("+")} example written: ${c.bold(relative(root, example))}`);
|
|
593
|
+
if (project.kind === "node") console.log(` ${c.dim("optional:")} npm install @inferall/sdk`);
|
|
594
|
+
else console.log(` ${c.dim("optional:")} pip install inferall-ai`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const geminiSites = findGeminiSites(root);
|
|
598
|
+
if (geminiSites.length) printGeminiGuidance(root, geminiSites);
|
|
599
|
+
|
|
600
|
+
// --- verify with a live call ---
|
|
601
|
+
const testModel = flagValue("model") || FREE_MODEL;
|
|
602
|
+
process.stdout.write(`\n test call (${testModel}${testModel === FREE_MODEL ? ", $0 model" : ""}) … `);
|
|
603
|
+
const test = await apiChat(key, [{ role: "user", content: "Reply with the single word: ready" }], testModel);
|
|
604
|
+
if (test.status === 200) {
|
|
605
|
+
console.log(c.green("success ✓"));
|
|
606
|
+
console.log(`\n${c.green(c.bold("Done."))} Your project is wired to InferAll. Models list: ${API_BASE}/models\n`);
|
|
607
|
+
} else if (test.status === 402) {
|
|
608
|
+
console.log(c.yellow("blocked (402)"));
|
|
609
|
+
console.log(`\n Everything is wired up, but your account isn't activated yet.`);
|
|
610
|
+
// Real account state beats a generic message — trial counter + whether a
|
|
611
|
+
// checkout was already started (settle vs start are different next steps).
|
|
612
|
+
const st = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
|
|
613
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
614
|
+
}).then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
615
|
+
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 — finishing it activates you") : ""}.`);
|
|
617
|
+
}
|
|
618
|
+
console.log(` InferAll charges ${c.bold("$0 markup")} — a one-time ${c.bold("$5 activation")} (it becomes spendable`);
|
|
619
|
+
console.log(` balance, not a fee) unlocks every model, including the 118+ free NIM models.`);
|
|
620
|
+
console.log(` Activate: ${c.cyan(BILLING_URL)}`);
|
|
621
|
+
console.log(` The code changes above are done — the first call will work the moment you activate.`);
|
|
622
|
+
console.log(` Check anytime: ${c.bold("npx @inferall/cli status")}\n`);
|
|
623
|
+
} else {
|
|
624
|
+
console.log(c.red(`HTTP ${test.status}`));
|
|
625
|
+
console.log(` ${JSON.stringify(test.body).slice(0, 300)}`);
|
|
626
|
+
console.log(` Setup files were written; re-run ${c.bold("npx @inferall/cli init")} to retry the verify step.\n`);
|
|
627
|
+
}
|
|
628
|
+
rl.close();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
main().catch((err) => {
|
|
632
|
+
console.error(`${c.red("✗")} ${err?.message || err}`);
|
|
633
|
+
rl.close();
|
|
634
|
+
exit(1);
|
|
635
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inferall/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Install InferAll into your project in one command: npx @inferall/cli init",
|
|
5
|
+
"bin": {
|
|
6
|
+
"inferall": "bin/inferall.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"inferall",
|
|
18
|
+
"ai",
|
|
19
|
+
"gateway",
|
|
20
|
+
"openai",
|
|
21
|
+
"anthropic",
|
|
22
|
+
"nim",
|
|
23
|
+
"cli",
|
|
24
|
+
"wizard"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/kindlyrobotics/inferall.ai.git",
|
|
29
|
+
"directory": "packages/cli"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT"
|
|
32
|
+
}
|