apilive 1.1.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/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/PRIVACY.md +74 -0
- package/README.md +206 -0
- package/SECURITY.md +62 -0
- package/SUPPORT.md +33 -0
- package/TERMS.md +66 -0
- package/bin/apilive.js +20 -0
- package/docs/GUIDE.md +167 -0
- package/package.json +62 -0
- package/public/app.js +1026 -0
- package/public/favicon.svg +1 -0
- package/public/index.html +244 -0
- package/public/style.css +554 -0
- package/src/check.js +204 -0
- package/src/cli.js +427 -0
- package/src/demo.js +40 -0
- package/src/env.js +134 -0
- package/src/markdown.js +133 -0
- package/src/providers.js +400 -0
- package/src/server.js +293 -0
- package/src/term.js +28 -0
- package/src/update.js +129 -0
- package/src/util.js +13 -0
package/src/check.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Runs a single key check and normalises the result.
|
|
2
|
+
//
|
|
3
|
+
// Result:
|
|
4
|
+
// {
|
|
5
|
+
// provider, status, latencyMs, httpStatus, message,
|
|
6
|
+
// models: string[] | null, modelCount, balance, account, rateLimit, region
|
|
7
|
+
// }
|
|
8
|
+
//
|
|
9
|
+
// status is one of:
|
|
10
|
+
// live key authenticated
|
|
11
|
+
// invalid provider rejected the key (401/403, or a 400 that says so)
|
|
12
|
+
// no_credit key is valid but the account has no credit (402)
|
|
13
|
+
// limited key is valid but rate-limited right now (429)
|
|
14
|
+
// error could not reach the provider / unexpected response
|
|
15
|
+
|
|
16
|
+
import { providers } from "./providers.js";
|
|
17
|
+
import { mask } from "./util.js";
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_TIMEOUT_MS = 15000;
|
|
20
|
+
const AUTH_HINT = /api[ _-]?key|token|auth|credential|unauthori[sz]ed|not valid/i;
|
|
21
|
+
|
|
22
|
+
export function cleanKey(raw) {
|
|
23
|
+
return String(raw ?? "")
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/^Bearer\s+/i, "")
|
|
26
|
+
.replace(/^["'`]+|["'`]+$/g, "")
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validateKeyShape(key) {
|
|
31
|
+
if (!key) return "No API key provided";
|
|
32
|
+
if (key.length > 1024) return "That doesn't look like an API key (too long)";
|
|
33
|
+
if (/[\s\u0000-\u001f\u007f]/.test(key)) return "API keys can't contain spaces or line breaks";
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function send(fetchImpl, { url, method = "GET", headers = {}, body }, timeoutMs) {
|
|
38
|
+
const res = await fetchImpl(url, {
|
|
39
|
+
method,
|
|
40
|
+
headers: { Accept: "application/json", "User-Agent": "apilive", ...headers },
|
|
41
|
+
body,
|
|
42
|
+
redirect: "error",
|
|
43
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
44
|
+
});
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
let json = null;
|
|
47
|
+
try {
|
|
48
|
+
json = text ? JSON.parse(text) : {};
|
|
49
|
+
} catch {
|
|
50
|
+
json = null;
|
|
51
|
+
}
|
|
52
|
+
return { status: res.status, headers: res.headers, body: json, text };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractMessage(res) {
|
|
56
|
+
const b = res.body;
|
|
57
|
+
const pick = [
|
|
58
|
+
b?.error?.message,
|
|
59
|
+
typeof b?.error === "string" ? b.error : null,
|
|
60
|
+
b?.message,
|
|
61
|
+
typeof b?.detail === "string" ? b.detail : b?.detail?.message,
|
|
62
|
+
b?.title,
|
|
63
|
+
b?.msg,
|
|
64
|
+
].find((x) => typeof x === "string" && x.trim());
|
|
65
|
+
if (pick) return pick.trim();
|
|
66
|
+
if (res.text && res.text.length < 200 && !res.text.trim().startsWith("<")) return res.text.trim();
|
|
67
|
+
return `HTTP ${res.status}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function classify(provider, res) {
|
|
71
|
+
const s = res.status;
|
|
72
|
+
// A 2xx only counts if it's a real API (JSON) response — not an HTML
|
|
73
|
+
// captive portal, bot challenge or proxy page.
|
|
74
|
+
if (s >= 200 && s < 300) return res.body !== null ? "live" : "error";
|
|
75
|
+
// Validation-probe providers: the key authenticated only if the provider
|
|
76
|
+
// answered with a JSON validation error that isn't about authentication.
|
|
77
|
+
// Anything else (empty 404s from some regions, HTML) is unverified.
|
|
78
|
+
if (provider.acceptStatus?.includes(s)) {
|
|
79
|
+
const msg = extractMessage(res);
|
|
80
|
+
const validation = res.body && msg !== `HTTP ${s}` && !AUTH_HINT.test(msg);
|
|
81
|
+
return validation ? "live" : AUTH_HINT.test(msg) ? "invalid" : "error";
|
|
82
|
+
}
|
|
83
|
+
if (s === 401 || s === 403) return "invalid";
|
|
84
|
+
if (s === 402) return "no_credit";
|
|
85
|
+
if (s === 429) return "limited";
|
|
86
|
+
if (s === 400 && AUTH_HINT.test(extractMessage(res))) return "invalid";
|
|
87
|
+
return "error";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function networkMessage(e, timeoutMs) {
|
|
91
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
92
|
+
return `Timed out after ${Math.round(timeoutMs / 1000)}s`;
|
|
93
|
+
}
|
|
94
|
+
const code = e?.cause?.code || e?.code;
|
|
95
|
+
const map = {
|
|
96
|
+
ENOTFOUND: "Could not resolve the provider's host (offline or DNS blocked?)",
|
|
97
|
+
ECONNREFUSED: "Connection refused by the provider",
|
|
98
|
+
ECONNRESET: "Connection reset by the provider",
|
|
99
|
+
CERT_HAS_EXPIRED: "TLS certificate error",
|
|
100
|
+
UND_ERR_CONNECT_TIMEOUT: "Connection timed out",
|
|
101
|
+
};
|
|
102
|
+
return map[code] || `Network error${code ? ` (${code})` : ""}: ${e?.message || "request failed"}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Never echo a key back, even partially-masked ones providers send us.
|
|
106
|
+
function scrub(message, key) {
|
|
107
|
+
let m = String(message);
|
|
108
|
+
if (key && key.length >= 8) m = m.split(key).join(mask(key));
|
|
109
|
+
return m.length > 300 ? m.slice(0, 297) + "…" : m;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function checkKey(providerId, rawKey, opts = {}) {
|
|
113
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = globalThis.fetch } = opts;
|
|
114
|
+
const provider = providers[providerId];
|
|
115
|
+
const base = {
|
|
116
|
+
provider: providerId,
|
|
117
|
+
status: "error",
|
|
118
|
+
latencyMs: null,
|
|
119
|
+
httpStatus: null,
|
|
120
|
+
message: null,
|
|
121
|
+
models: null,
|
|
122
|
+
modelCount: null,
|
|
123
|
+
balance: null,
|
|
124
|
+
account: null,
|
|
125
|
+
rateLimit: null,
|
|
126
|
+
region: null,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (!provider) return { ...base, message: `Unknown provider "${providerId}"` };
|
|
130
|
+
const key = cleanKey(rawKey);
|
|
131
|
+
const shapeError = validateKeyShape(key);
|
|
132
|
+
if (shapeError) return { ...base, status: "invalid", message: shapeError };
|
|
133
|
+
|
|
134
|
+
const regions = provider.regions;
|
|
135
|
+
let last = null;
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < regions.length; i++) {
|
|
138
|
+
const region = regions[i];
|
|
139
|
+
const t0 = Date.now();
|
|
140
|
+
let res;
|
|
141
|
+
try {
|
|
142
|
+
res = await send(fetchImpl, provider.request(key, region), timeoutMs);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
last = { ...base, latencyMs: Date.now() - t0, message: scrub(networkMessage(e, timeoutMs), key) };
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const status = classify(provider, res);
|
|
148
|
+
const result = {
|
|
149
|
+
...base,
|
|
150
|
+
status,
|
|
151
|
+
latencyMs: Date.now() - t0,
|
|
152
|
+
httpStatus: res.status,
|
|
153
|
+
region: regions.length > 1 ? new URL(region).hostname : null,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (status === "live") {
|
|
157
|
+
let parsed = {};
|
|
158
|
+
try {
|
|
159
|
+
parsed = provider.parse(res.body ?? {}, res.headers) || {};
|
|
160
|
+
} catch {
|
|
161
|
+
parsed = {};
|
|
162
|
+
}
|
|
163
|
+
if (provider.extra) {
|
|
164
|
+
try {
|
|
165
|
+
const get = (url, headers) => send(fetchImpl, { url, headers }, timeoutMs);
|
|
166
|
+
const more = await provider.extra(key, { base: region, get });
|
|
167
|
+
parsed = { ...parsed, ...Object.fromEntries(Object.entries(more || {}).filter(([, v]) => v != null)) };
|
|
168
|
+
} catch {
|
|
169
|
+
// Optional enrichment — the key is still live.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const models = parsed.models?.length ? parsed.models : null;
|
|
173
|
+
return {
|
|
174
|
+
...result,
|
|
175
|
+
models,
|
|
176
|
+
modelCount: models ? models.length : null,
|
|
177
|
+
balance: parsed.balance ?? null,
|
|
178
|
+
account: parsed.account ?? null,
|
|
179
|
+
rateLimit: parsed.rateLimit ?? null,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
result.message = scrub(extractMessage(res), key);
|
|
184
|
+
last = result;
|
|
185
|
+
// Only try the next region when this one said "not my key".
|
|
186
|
+
if (status !== "invalid") break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return last;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Run tasks with bounded concurrency, preserving order.
|
|
193
|
+
export async function pool(items, limit, fn) {
|
|
194
|
+
const out = new Array(items.length);
|
|
195
|
+
let next = 0;
|
|
196
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
197
|
+
while (next < items.length) {
|
|
198
|
+
const i = next++;
|
|
199
|
+
out[i] = await fn(items[i], i);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
await Promise.all(workers);
|
|
203
|
+
return out;
|
|
204
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// Command-line interface.
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { providerList, providers } from "./providers.js";
|
|
6
|
+
import { checkKey, pool, DEFAULT_TIMEOUT_MS } from "./check.js";
|
|
7
|
+
import { extractKeys, scanEnvironment } from "./env.js";
|
|
8
|
+
import { createApp, listenWithFallback, openBrowser, MAX_KEYS } from "./server.js";
|
|
9
|
+
import { checkForUpdate, installKind, updateCommand, runGlobalUpdate, readState, writeState, RELEASES_URL } from "./update.js";
|
|
10
|
+
import readline from "node:readline/promises";
|
|
11
|
+
import { mask, plural } from "./util.js";
|
|
12
|
+
import { c, sym, pad } from "./term.js";
|
|
13
|
+
|
|
14
|
+
const VERSION = JSON.parse(
|
|
15
|
+
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
16
|
+
).version;
|
|
17
|
+
|
|
18
|
+
const HELP = `
|
|
19
|
+
${c.bold("apilive")} ${c.dim("v" + VERSION)} — check if your LLM API keys are live, without spending credits.
|
|
20
|
+
|
|
21
|
+
${c.bold("Usage")}
|
|
22
|
+
${c.cyan("npx apilive")} Open the local web UI
|
|
23
|
+
${c.cyan("npx apilive check")} ${c.dim("[files…]")} Check keys in .env files + environment
|
|
24
|
+
${c.cyan("npx apilive providers")} List supported providers
|
|
25
|
+
${c.cyan("npx apilive update")} Update to the latest version
|
|
26
|
+
${c.cyan("npx apilive privacy")} | ${c.cyan("terms")} Show the Privacy Policy or Terms of Use
|
|
27
|
+
|
|
28
|
+
${c.bold("Options")}
|
|
29
|
+
--port <n> UI port ${c.dim("(default 4577)")}
|
|
30
|
+
--no-open Don't open the browser
|
|
31
|
+
--no-env Ignore environment variables, only read .env files
|
|
32
|
+
--stdin Read keys or .env text from stdin ${c.dim("(or: check -)")}
|
|
33
|
+
-p, --provider <id> Treat keys from stdin as this provider
|
|
34
|
+
--json Print machine-readable JSON ${c.dim("(check)")}
|
|
35
|
+
--models List each key's models ${c.dim("(check)")}
|
|
36
|
+
--demo UI with sample keys and fake responses ${c.dim("(for screenshots)")}
|
|
37
|
+
--timeout <sec> Per-request timeout ${c.dim(`(default ${DEFAULT_TIMEOUT_MS / 1000})`)}
|
|
38
|
+
--concurrency <n> Checks in parallel ${c.dim("(default 6)")}
|
|
39
|
+
--no-update-check Don't check npm for a newer version
|
|
40
|
+
-y, --yes Don't ask for confirmation (update)
|
|
41
|
+
-h, --help Show this help
|
|
42
|
+
-v, --version Show version
|
|
43
|
+
|
|
44
|
+
${c.bold("Examples")}
|
|
45
|
+
${c.dim("# scan ./.env, ./.env.local … and your shell environment")}
|
|
46
|
+
npx apilive check
|
|
47
|
+
|
|
48
|
+
${c.dim("# check one key without it landing in shell history")}
|
|
49
|
+
pbpaste | npx apilive check --stdin
|
|
50
|
+
|
|
51
|
+
${c.dim("# fail a CI job if any key is dead")}
|
|
52
|
+
npx apilive check --no-env .env.production
|
|
53
|
+
|
|
54
|
+
${c.bold("Exit codes")} 0 all keys live · 1 a key failed · 2 no keys found / bad usage
|
|
55
|
+
|
|
56
|
+
Keys are only ever sent to their own provider's official API, using free
|
|
57
|
+
metadata endpoints (model lists, balance). Nothing is stored or logged.
|
|
58
|
+
Only check keys you own or are authorized to use. See: apilive terms
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
function parseArgs(argv) {
|
|
62
|
+
const opts = {
|
|
63
|
+
command: "ui",
|
|
64
|
+
files: [],
|
|
65
|
+
port: 4577,
|
|
66
|
+
open: true,
|
|
67
|
+
env: true,
|
|
68
|
+
stdin: false,
|
|
69
|
+
provider: null,
|
|
70
|
+
json: false,
|
|
71
|
+
models: false,
|
|
72
|
+
demo: false,
|
|
73
|
+
updateCheck: true,
|
|
74
|
+
yes: false,
|
|
75
|
+
timeout: DEFAULT_TIMEOUT_MS / 1000,
|
|
76
|
+
concurrency: 6,
|
|
77
|
+
};
|
|
78
|
+
const need = (i, flag) => {
|
|
79
|
+
if (argv[i + 1] == null) throw usage(`${flag} needs a value`);
|
|
80
|
+
return argv[i + 1];
|
|
81
|
+
};
|
|
82
|
+
for (let i = 0; i < argv.length; i++) {
|
|
83
|
+
const a = argv[i];
|
|
84
|
+
switch (a) {
|
|
85
|
+
case "-h": case "--help": opts.command = "help"; break;
|
|
86
|
+
case "-v": case "--version": opts.command = "version"; break;
|
|
87
|
+
case "--port": opts.port = Number(need(i, a)); i++; break;
|
|
88
|
+
case "--no-open": opts.open = false; break;
|
|
89
|
+
case "--no-env": opts.env = false; break;
|
|
90
|
+
case "--stdin": case "-": opts.stdin = true; break;
|
|
91
|
+
case "-p": case "--provider": opts.provider = need(i, a); i++; break;
|
|
92
|
+
case "--json": opts.json = true; break;
|
|
93
|
+
case "--models": opts.models = true; break;
|
|
94
|
+
case "--demo": opts.demo = true; break;
|
|
95
|
+
case "--no-update-check": case "--no-update-notifier": opts.updateCheck = false; break;
|
|
96
|
+
case "-y": case "--yes": opts.yes = true; break;
|
|
97
|
+
case "--timeout": opts.timeout = Number(need(i, a)); i++; break;
|
|
98
|
+
case "--concurrency": opts.concurrency = Number(need(i, a)); i++; break;
|
|
99
|
+
default:
|
|
100
|
+
if (a.startsWith("-")) throw usage(`Unknown option ${a}`);
|
|
101
|
+
if (["ui", "check", "providers", "help", "version", "update", "privacy", "terms"].includes(a) && opts.command === "ui" && !opts.files.length) {
|
|
102
|
+
opts.command = a;
|
|
103
|
+
} else {
|
|
104
|
+
opts.files.push(a);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) throw usage("--port must be 0–65535");
|
|
109
|
+
if (!(opts.timeout > 0)) throw usage("--timeout must be a positive number of seconds");
|
|
110
|
+
if (!(opts.concurrency >= 1)) throw usage("--concurrency must be at least 1");
|
|
111
|
+
if (opts.provider && !providers[opts.provider]) {
|
|
112
|
+
throw usage(`Unknown provider "${opts.provider}". Run \`apilive providers\` to see ids.`);
|
|
113
|
+
}
|
|
114
|
+
return opts;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function usage(message) {
|
|
118
|
+
return Object.assign(new Error(message), { usage: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readStdin() {
|
|
122
|
+
if (process.stdin.isTTY) {
|
|
123
|
+
process.stderr.write(c.dim(" Paste keys or .env contents, then press Ctrl+D (Ctrl+Z, Enter on Windows):\n"));
|
|
124
|
+
}
|
|
125
|
+
const chunks = [];
|
|
126
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
127
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const STATUS_STYLE = {
|
|
131
|
+
live: (s) => c.green(s),
|
|
132
|
+
limited: (s) => c.yellow(s),
|
|
133
|
+
no_credit: (s) => c.yellow(s),
|
|
134
|
+
invalid: (s) => c.red(s),
|
|
135
|
+
error: (s) => c.magenta(s),
|
|
136
|
+
};
|
|
137
|
+
const STATUS_LABEL = { live: "live", limited: "rate-limited", no_credit: "no credit", invalid: "invalid", error: "error" };
|
|
138
|
+
|
|
139
|
+
async function runCheck(opts) {
|
|
140
|
+
let entries = [];
|
|
141
|
+
let scanned = [];
|
|
142
|
+
|
|
143
|
+
if (opts.stdin) {
|
|
144
|
+
const text = await readStdin();
|
|
145
|
+
entries = extractKeys(text, { forced: opts.provider });
|
|
146
|
+
} else {
|
|
147
|
+
const files = opts.files.length ? opts.files.map((f) => path.resolve(f)) : null;
|
|
148
|
+
const r = scanEnvironment({ files, includeEnv: opts.env });
|
|
149
|
+
entries = r.keys;
|
|
150
|
+
scanned = r.scanned;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const missing = scanned.filter((s) => s.error);
|
|
154
|
+
for (const m of missing) process.stderr.write(c.yellow(` ${sym.error} ${m.file}: ${m.error}\n`));
|
|
155
|
+
|
|
156
|
+
if (entries.length > MAX_KEYS) {
|
|
157
|
+
console.error(c.yellow(` Found ${entries.length} keys. apilive checks at most ${MAX_KEYS} at a time. It's built for your own keys, not bulk testing.`));
|
|
158
|
+
entries = entries.slice(0, MAX_KEYS);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const unknown = entries.filter((e) => !e.provider);
|
|
162
|
+
entries = entries.filter((e) => e.provider);
|
|
163
|
+
|
|
164
|
+
if (!entries.length) {
|
|
165
|
+
if (opts.json) {
|
|
166
|
+
process.stdout.write(JSON.stringify({ results: [], summary: { total: 0 } }, null, 2) + "\n");
|
|
167
|
+
} else {
|
|
168
|
+
const where = scanned.filter((s) => !s.error).map((s) => path.relative(process.cwd(), s.file) || s.file);
|
|
169
|
+
console.log(`\n ${c.yellow("No API keys found.")}`);
|
|
170
|
+
const looked = [...where, opts.env && !opts.stdin ? "environment variables" : null].filter(Boolean);
|
|
171
|
+
console.log(c.dim(` Looked in: ${looked.length ? looked.join(", ") : "no .env files in this folder"}${opts.env ? "" : " (--no-env: environment skipped)"}`));
|
|
172
|
+
if (unknown.length) console.log(c.dim(` ${plural(unknown.length, "key")} had an unrecognised format — pass --provider <id>.`));
|
|
173
|
+
console.log(c.dim(` Try: echo "sk-..." | npx apilive check --stdin\n`));
|
|
174
|
+
}
|
|
175
|
+
return 2;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const started = Date.now();
|
|
179
|
+
const tty = process.stderr.isTTY && !opts.json;
|
|
180
|
+
let done = 0;
|
|
181
|
+
if (!opts.json) {
|
|
182
|
+
console.log(`\n ${c.bold("apilive")} ${c.dim(`v${VERSION}`)} ${c.dim(sym.sep)} checking ${plural(entries.length, "key")} ${c.dim(sym.sep + " free endpoints only, no credits spent")}\n`);
|
|
183
|
+
}
|
|
184
|
+
const progress = () => tty && process.stderr.write(`\r ${c.dim(`Checking ${done}/${entries.length}…`)}`);
|
|
185
|
+
progress();
|
|
186
|
+
|
|
187
|
+
const results = await pool(entries, opts.concurrency, async (e) => {
|
|
188
|
+
const r = await checkKey(e.provider, e.key, { timeoutMs: opts.timeout * 1000 });
|
|
189
|
+
done++;
|
|
190
|
+
progress();
|
|
191
|
+
return { entry: e, result: r };
|
|
192
|
+
});
|
|
193
|
+
if (tty) process.stderr.write("\r\x1b[2K");
|
|
194
|
+
|
|
195
|
+
const counts = { live: 0, limited: 0, no_credit: 0, invalid: 0, error: 0 };
|
|
196
|
+
for (const { result } of results) counts[result.status]++;
|
|
197
|
+
const failed = counts.invalid + counts.no_credit + counts.error;
|
|
198
|
+
|
|
199
|
+
if (opts.json) {
|
|
200
|
+
const out = results.map(({ entry, result }) => ({
|
|
201
|
+
provider: entry.provider,
|
|
202
|
+
key: mask(entry.key),
|
|
203
|
+
sources: entry.sources || [],
|
|
204
|
+
...result,
|
|
205
|
+
models: opts.models ? result.models : undefined,
|
|
206
|
+
}));
|
|
207
|
+
process.stdout.write(
|
|
208
|
+
JSON.stringify({ results: out, summary: { total: results.length, ...counts, ms: Date.now() - started } }, null, 2) + "\n",
|
|
209
|
+
);
|
|
210
|
+
return failed ? 1 : 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const nameWidth = Math.max(...results.map((r) => providers[r.entry.provider].name.length)) + 2;
|
|
214
|
+
const keyWidth = Math.max(...results.map((r) => mask(r.entry.key).length)) + 2;
|
|
215
|
+
|
|
216
|
+
for (const { entry, result } of results) {
|
|
217
|
+
const style = STATUS_STYLE[result.status];
|
|
218
|
+
const bits = [style(STATUS_LABEL[result.status])];
|
|
219
|
+
if (result.latencyMs != null) bits.push(`${result.latencyMs}ms`);
|
|
220
|
+
if (result.modelCount) bits.push(plural(result.modelCount, "model"));
|
|
221
|
+
if (result.balance) bits.push(c.cyan(result.balance));
|
|
222
|
+
if (result.account) bits.push(result.account);
|
|
223
|
+
if (result.rateLimit) bits.push(c.dim(result.rateLimit));
|
|
224
|
+
if (result.message && result.status !== "live") bits.push(c.dim(result.message));
|
|
225
|
+
|
|
226
|
+
console.log(
|
|
227
|
+
` ${style(sym[result.status])} ${pad(c.bold(providers[entry.provider].name), nameWidth)}${pad(c.dim(mask(entry.key)), keyWidth)}${bits.join(c.dim(` ${sym.sep} `))}`,
|
|
228
|
+
);
|
|
229
|
+
const meta = [...(entry.sources || []), entry.confident === false ? `guessed ${providers[entry.provider].name} — use --provider to override` : null].filter(Boolean);
|
|
230
|
+
if (meta.length) console.log(` ${" ".repeat(nameWidth)}${c.dim(meta.join(", "))}`);
|
|
231
|
+
if (opts.models && result.models?.length) {
|
|
232
|
+
console.log(` ${" ".repeat(nameWidth)}${c.gray(result.models.join(", "))}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const summary = [
|
|
237
|
+
counts.live && c.green(`${counts.live} live`),
|
|
238
|
+
counts.limited && c.yellow(`${counts.limited} rate-limited`),
|
|
239
|
+
counts.no_credit && c.yellow(`${counts.no_credit} no credit`),
|
|
240
|
+
counts.invalid && c.red(`${counts.invalid} invalid`),
|
|
241
|
+
counts.error && c.magenta(`${plural(counts.error, "error")}`),
|
|
242
|
+
].filter(Boolean);
|
|
243
|
+
console.log(`\n ${summary.join(c.dim(` ${sym.sep} `))} ${c.dim(`${sym.sep} ${((Date.now() - started) / 1000).toFixed(1)}s ${sym.sep} 0 tokens spent`)}`);
|
|
244
|
+
if (unknown.length) console.log(c.dim(` Skipped ${plural(unknown.length, "key")} with an unrecognised format (use --provider).`));
|
|
245
|
+
console.log();
|
|
246
|
+
return failed ? 1 : 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function runProviders(opts) {
|
|
250
|
+
if (opts.json) {
|
|
251
|
+
process.stdout.write(JSON.stringify(providerList.map((p) => ({ id: p.id, name: p.name, env: p.env, keyUrl: p.keyUrl })), null, 2) + "\n");
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
console.log(`\n ${c.bold(`${providerList.length} supported providers`)}\n`);
|
|
255
|
+
const w = Math.max(...providerList.map((p) => p.id.length)) + 3;
|
|
256
|
+
const nw = Math.max(...providerList.map((p) => p.name.length)) + 3;
|
|
257
|
+
for (const p of providerList) {
|
|
258
|
+
console.log(` ${pad(c.cyan(p.id), w)}${pad(p.name, nw)}${c.dim(p.env.join(", "))}`);
|
|
259
|
+
}
|
|
260
|
+
console.log();
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function runUi(opts) {
|
|
265
|
+
const app = createApp({
|
|
266
|
+
version: VERSION,
|
|
267
|
+
includeEnv: opts.env,
|
|
268
|
+
demo: opts.demo,
|
|
269
|
+
updateCheck: opts.updateCheck,
|
|
270
|
+
files: opts.files.length ? opts.files.map((f) => path.resolve(f)) : null,
|
|
271
|
+
timeoutMs: opts.timeout * 1000,
|
|
272
|
+
});
|
|
273
|
+
let port;
|
|
274
|
+
try {
|
|
275
|
+
port = await listenWithFallback(app, opts.port);
|
|
276
|
+
} catch (e) {
|
|
277
|
+
console.error(c.red(` Could not start server: ${e.message}`));
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
const url = `http://127.0.0.1:${port}`;
|
|
281
|
+
const found = app.keys.size;
|
|
282
|
+
|
|
283
|
+
console.log(`
|
|
284
|
+
${c.green(sym.dot)} ${c.bold("apilive")} ${c.dim(`v${VERSION}`)}
|
|
285
|
+
|
|
286
|
+
${c.dim("Local:")} ${c.cyan(url)}
|
|
287
|
+
${c.dim("Found:")} ${found ? `${plural(found, "key")} in ${describeSources(app)}` : c.dim("no keys in .env / environment — paste some in the UI")}
|
|
288
|
+
|
|
289
|
+
${c.dim(`Runs only on this machine. Keys go only to their own provider and are never stored.`)}
|
|
290
|
+
${c.dim("Press Ctrl+C to stop.")}
|
|
291
|
+
`);
|
|
292
|
+
if (opts.open) openBrowser(url);
|
|
293
|
+
if (!opts.demo) checkForUpdate(VERSION, { disabled: !opts.updateCheck }).then(printUpdateNotice, () => {});
|
|
294
|
+
|
|
295
|
+
await new Promise((resolve) => {
|
|
296
|
+
const stop = () => {
|
|
297
|
+
console.log(c.dim("\n Stopped. Keys cleared from memory.\n"));
|
|
298
|
+
app.close().finally(resolve);
|
|
299
|
+
setTimeout(resolve, 500).unref();
|
|
300
|
+
};
|
|
301
|
+
process.once("SIGINT", stop);
|
|
302
|
+
process.once("SIGTERM", stop);
|
|
303
|
+
});
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function describeSources(app) {
|
|
308
|
+
const s = new Set();
|
|
309
|
+
for (const k of app.keys.values()) {
|
|
310
|
+
for (const src of k.sources) s.add(src.split(" · ").pop());
|
|
311
|
+
}
|
|
312
|
+
return [...s].map((x) => (x === "env" ? "environment" : x)).join(", ");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function main(argv) {
|
|
316
|
+
let opts;
|
|
317
|
+
try {
|
|
318
|
+
opts = parseArgs(argv);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
console.error(c.red(` ${e.message}`) + c.dim(" (see apilive --help)"));
|
|
321
|
+
return 2;
|
|
322
|
+
}
|
|
323
|
+
switch (opts.command) {
|
|
324
|
+
case "help": console.log(HELP); return 0;
|
|
325
|
+
case "version": console.log(VERSION); return 0;
|
|
326
|
+
case "providers": return runProviders(opts);
|
|
327
|
+
case "update": return runUpdate(opts);
|
|
328
|
+
case "privacy": return printDoc("PRIVACY.md");
|
|
329
|
+
case "terms": return printDoc("TERMS.md");
|
|
330
|
+
case "check": {
|
|
331
|
+
const update = checkForUpdate(VERSION, { disabled: !opts.updateCheck || opts.json });
|
|
332
|
+
firstRunNotice(opts);
|
|
333
|
+
const code = await runCheck(opts);
|
|
334
|
+
if (!opts.json) printUpdateNotice(await Promise.race([update, new Promise((r) => setTimeout(r, 800, null))]));
|
|
335
|
+
return code;
|
|
336
|
+
}
|
|
337
|
+
default: return runUi(opts);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function printDoc(file) {
|
|
342
|
+
process.stdout.write(fs.readFileSync(new URL(`../${file}`, import.meta.url), "utf8") + "\n");
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Shown once per machine, on stderr so it never pollutes piped output.
|
|
347
|
+
function firstRunNotice(opts) {
|
|
348
|
+
if (opts.json || readState().noticeShown) return;
|
|
349
|
+
process.stderr.write(
|
|
350
|
+
c.dim(`
|
|
351
|
+
Note: only check keys you own or are authorized to use. By using apilive you agree to its
|
|
352
|
+
Terms (apilive terms) and Privacy Policy (apilive privacy). This note is shown once.
|
|
353
|
+
`),
|
|
354
|
+
);
|
|
355
|
+
writeState({ noticeShown: true });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function printUpdateNotice(u) {
|
|
359
|
+
if (!u?.updateAvailable) return;
|
|
360
|
+
const cmd = updateCommand(installKind());
|
|
361
|
+
const lines = [
|
|
362
|
+
`Update available: ${c.dim(u.current)} ${sym.arrow} ${c.green(u.latest)}`,
|
|
363
|
+
`Run ${c.cyan(cmd === "npx apilive@latest" ? "npx apilive@latest" : "apilive update")} ${c.dim("·")} notes: ${c.dim(RELEASES_URL)}`,
|
|
364
|
+
];
|
|
365
|
+
process.stderr.write(` ${lines.join("\n ")}\n\n`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function runUpdate(opts) {
|
|
369
|
+
console.log(`
|
|
370
|
+
${c.bold("apilive")} ${c.dim(`v${VERSION}`)} ${c.dim("checking for updates…")}`);
|
|
371
|
+
const u = await checkForUpdate(VERSION, { force: true });
|
|
372
|
+
if (u.disabled && !u.latest) {
|
|
373
|
+
console.log(c.yellow(" Update checks are disabled (NO_UPDATE_NOTIFIER / DO_NOT_TRACK / CI). Unset them to use this command.\n"));
|
|
374
|
+
return 2;
|
|
375
|
+
}
|
|
376
|
+
if (!u.latest && u.registryStatus === 404) {
|
|
377
|
+
console.log(c.yellow(" apilive isn't published on the npm registry yet, so there's nothing to update to.\n"));
|
|
378
|
+
return 1;
|
|
379
|
+
}
|
|
380
|
+
if (!u.latest) {
|
|
381
|
+
console.log(c.yellow(" Couldn't reach the npm registry. Check your connection and try again.\n"));
|
|
382
|
+
return 1;
|
|
383
|
+
}
|
|
384
|
+
if (!u.updateAvailable) {
|
|
385
|
+
console.log(` ${c.green(sym.live)} You're on the latest version (${VERSION}).
|
|
386
|
+
`);
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
console.log(` New version: ${c.green(u.latest)} ${c.dim(`(release notes: ${RELEASES_URL})`)}
|
|
390
|
+
`);
|
|
391
|
+
const kind = installKind();
|
|
392
|
+
if (kind === "npx") {
|
|
393
|
+
console.log(` You're running apilive through npx. Run this to use the latest version:
|
|
394
|
+
|
|
395
|
+
${c.cyan("npx apilive@latest")}
|
|
396
|
+
`);
|
|
397
|
+
return 0;
|
|
398
|
+
}
|
|
399
|
+
if (kind !== "global") {
|
|
400
|
+
console.log(` Update with:
|
|
401
|
+
|
|
402
|
+
${c.cyan(updateCommand(kind))}
|
|
403
|
+
`);
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
if (!opts.yes) {
|
|
407
|
+
if (!process.stdin.isTTY) {
|
|
408
|
+
console.log(` Run ${c.cyan("apilive update --yes")} to install without a prompt.
|
|
409
|
+
`);
|
|
410
|
+
return 2;
|
|
411
|
+
}
|
|
412
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
413
|
+
const answer = (await rl.question(` Install apilive ${u.latest} now with "npm install -g apilive@latest"? (y/N) `)).trim().toLowerCase();
|
|
414
|
+
rl.close();
|
|
415
|
+
if (answer !== "y" && answer !== "yes") {
|
|
416
|
+
console.log(c.dim(" Cancelled.\n"));
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const code = await runGlobalUpdate();
|
|
421
|
+
console.log(code === 0 ? `
|
|
422
|
+
${c.green(sym.live)} Updated to ${u.latest}.
|
|
423
|
+
` : c.red(`
|
|
424
|
+
npm exited with code ${code}. Try: npm install -g apilive@latest
|
|
425
|
+
`));
|
|
426
|
+
return code === 0 ? 0 : 1;
|
|
427
|
+
}
|
package/src/demo.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// `apilive --demo`: sample keys + canned provider responses, no network.
|
|
2
|
+
// Handy for screenshots and screen recordings without exposing real keys.
|
|
3
|
+
|
|
4
|
+
const models = (...ids) => ({ data: ids.map((id) => ({ id })) });
|
|
5
|
+
|
|
6
|
+
const RESPONSES = {
|
|
7
|
+
"api.openai.com": [200, models("gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-4.1", "gpt-4.1-mini", "o3", "o4-mini", "gpt-image-1", "text-embedding-3-large", "text-embedding-3-small", "whisper-1", "tts-1"), { "x-ratelimit-remaining-requests": "4999" }],
|
|
8
|
+
"api.anthropic.com": [200, models("claude-opus-5-5", "claude-sonnet-5", "claude-haiku-4-5-20251001", "claude-opus-4-1", "claude-sonnet-4-5")],
|
|
9
|
+
"generativelanguage.googleapis.com": [200, { models: ["gemini-3-pro", "gemini-3-flash", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite", "text-embedding-004"].map((n) => ({ name: `models/${n}` })) }],
|
|
10
|
+
"openrouter.ai": [200, { data: { label: "laptop", limit: 20, limit_remaining: 13.42, usage: 6.58 } }],
|
|
11
|
+
"api.deepseek.com": [200, { is_available: true, balance_infos: [{ currency: "USD", total_balance: "4.20" }] }],
|
|
12
|
+
"api.groq.com": [429, { error: { message: "Rate limit reached for requests per minute. Please try again in 2s." } }],
|
|
13
|
+
"api.mistral.ai": [401, { detail: "Unauthorized" }],
|
|
14
|
+
"api.x.ai": [402, { error: "Your team has run out of credits. Purchase more at console.x.ai." }],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const DEMO_KEYS = [
|
|
18
|
+
["openai", "OPENAI_API_KEY", "sk-proj-DEMOdemoDEMOdemoDEMOdemo7Hq2"],
|
|
19
|
+
["anthropic", "ANTHROPIC_API_KEY", "sk-ant-api03-DEMOdemoDEMOdemoDEMOdemoQ9xa"],
|
|
20
|
+
["gemini", "GEMINI_API_KEY", "AIzaSyDEMOdemoDEMOdemoDEMOdemoDEMOd4kQ"],
|
|
21
|
+
["openrouter", "OPENROUTER_API_KEY", "sk-or-v1-DEMOdemoDEMOdemoDEMOdemo2c81"],
|
|
22
|
+
["deepseek", "DEEPSEEK_API_KEY", "sk-DEMOdemoDEMOdemoDEMOdemo0f3e"],
|
|
23
|
+
["groq", "GROQ_API_KEY", "gsk_DEMOdemoDEMOdemoDEMOdemoWm4T"],
|
|
24
|
+
["mistral", "MISTRAL_API_KEY", "demo_mistral_not_a_real_key_91Lz"],
|
|
25
|
+
["xai", "XAI_API_KEY", "xai-DEMOdemoDEMOdemoDEMOdemo5sYr"],
|
|
26
|
+
].map(([provider, name, key]) => ({
|
|
27
|
+
provider,
|
|
28
|
+
name,
|
|
29
|
+
key,
|
|
30
|
+
confident: true,
|
|
31
|
+
candidates: [],
|
|
32
|
+
sources: [`${name} · ${provider === "groq" || provider === "xai" ? ".env.local" : ".env"}`],
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
export async function demoFetch(url) {
|
|
36
|
+
const host = new URL(url).hostname;
|
|
37
|
+
await new Promise((r) => setTimeout(r, 180 + Math.random() * 700));
|
|
38
|
+
const [status, body, headers = {}] = RESPONSES[host] || [401, { error: { message: "Invalid API key" } }];
|
|
39
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
|
|
40
|
+
}
|