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/src/env.js ADDED
@@ -0,0 +1,134 @@
1
+ // Finds API keys in .env files, the process environment, or pasted text.
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { detectProvider, providerForEnvName, providers } from "./providers.js";
6
+ import { cleanKey } from "./check.js";
7
+
8
+ export const DEFAULT_ENV_FILES = [
9
+ ".env",
10
+ ".env.local",
11
+ ".env.development",
12
+ ".env.development.local",
13
+ ".env.test",
14
+ ".env.test.local",
15
+ ".env.production",
16
+ ".env.production.local",
17
+ ];
18
+
19
+ const PLACEHOLDER = /^(|x+|\.\.\.|changeme|change_me|todo|null|undefined|none|your[-_ ].*|<.*>|\$\{.*\}|sk-\.\.\.|sk-x+|.*\*{3,}.*)$/i;
20
+
21
+ // Minimal dotenv parser: KEY=VALUE, `export KEY=VALUE`, quotes, inline comments.
22
+ export function parseDotenv(text) {
23
+ const out = [];
24
+ const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
25
+ for (const line of lines) {
26
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*[=:]\s*(.*)?$/);
27
+ if (!m) continue;
28
+ let value = (m[2] ?? "").trim();
29
+ const q = value[0];
30
+ if ((q === '"' || q === "'" || q === "`") && value.indexOf(q, 1) > 0) {
31
+ value = value.slice(1, value.indexOf(q, 1));
32
+ } else {
33
+ value = value.replace(/\s+#.*$/, "").trim();
34
+ }
35
+ out.push({ name: m[1], value });
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function looksLikeKey(value) {
41
+ return value.length >= 16 && !PLACEHOLDER.test(value) && !/\s/.test(value);
42
+ }
43
+
44
+ // Turn arbitrary pasted text (one key, many keys, or a whole .env) into
45
+ // candidate entries. `forced` pins every bare key to one provider.
46
+ export function extractKeys(text, { forced = null } = {}) {
47
+ const found = [];
48
+ const lines = String(text).replace(/\r\n?/g, "\n").split("\n").map((l) => l.trim()).filter(Boolean);
49
+
50
+ for (const line of lines) {
51
+ if (line.startsWith("#")) continue;
52
+ const [pair] = parseDotenv(line);
53
+ // A base64-ish key ending in "=" parses as a pair too, so only treat the
54
+ // line as NAME=VALUE when the value itself looks like a key.
55
+ if (pair && looksLikeKey(cleanKey(pair.value))) {
56
+ const key = cleanKey(pair.value);
57
+ const byName = providerForEnvName(pair.name);
58
+ if (byName || forced) {
59
+ found.push({ key, name: pair.name, provider: forced || byName, confident: true, candidates: [] });
60
+ continue;
61
+ }
62
+ const d = detectProvider(key);
63
+ if (d.provider) found.push({ key, name: pair.name, ...d });
64
+ continue;
65
+ }
66
+ // NAME=placeholder / NAME= lines are config, not a bare key.
67
+ if (pair && /^[A-Z][A-Z0-9]*_[A-Z0-9_]*$/.test(pair.name)) continue;
68
+ const key = cleanKey(line);
69
+ if (!key || /\s/.test(key)) continue;
70
+ if (forced) {
71
+ found.push({ key, name: null, provider: forced, confident: true, candidates: [] });
72
+ } else {
73
+ found.push({ key, name: null, ...detectProvider(key) });
74
+ }
75
+ }
76
+ return dedupe(found);
77
+ }
78
+
79
+ function dedupe(entries) {
80
+ const map = new Map();
81
+ for (const e of entries) {
82
+ const id = `${e.provider}\u0000${e.key}`;
83
+ const prev = map.get(id);
84
+ if (prev) {
85
+ for (const s of e.sources || []) if (!prev.sources?.includes(s)) prev.sources = [...(prev.sources || []), s];
86
+ } else {
87
+ map.set(id, { ...e });
88
+ }
89
+ }
90
+ return [...map.values()];
91
+ }
92
+
93
+ // Scan .env files in `cwd` (or explicit `files`) plus the process environment.
94
+ export function scanEnvironment({ cwd = process.cwd(), files = null, env = process.env, includeEnv = true } = {}) {
95
+ const found = [];
96
+ const targets = files ?? DEFAULT_ENV_FILES.map((f) => path.join(cwd, f));
97
+ const scanned = [];
98
+
99
+ for (const file of targets) {
100
+ let text;
101
+ try {
102
+ text = fs.readFileSync(file, "utf8");
103
+ } catch (e) {
104
+ if (files) scanned.push({ file, error: e.code === "ENOENT" ? "not found" : e.message });
105
+ continue;
106
+ }
107
+ scanned.push({ file });
108
+ const label = path.relative(cwd, file) || file;
109
+ for (const { name, value } of parseDotenv(text)) {
110
+ const entry = fromVar(name, value);
111
+ if (entry) found.push({ ...entry, sources: [`${name} · ${label}`] });
112
+ }
113
+ }
114
+
115
+ if (includeEnv) {
116
+ for (const [name, value] of Object.entries(env)) {
117
+ const entry = fromVar(name, value ?? "");
118
+ if (entry) found.push({ ...entry, sources: [`${name} · env`] });
119
+ }
120
+ }
121
+
122
+ return { keys: dedupe(found), scanned };
123
+ }
124
+
125
+ function fromVar(name, value) {
126
+ const key = cleanKey(value);
127
+ if (!looksLikeKey(key)) return null;
128
+ const byName = providerForEnvName(name);
129
+ if (byName) return { key, name, provider: byName, confident: true, candidates: [] };
130
+ // Unknown variable name: only accept keys whose format is unambiguous.
131
+ const d = detectProvider(key);
132
+ if (d.confident && providers[d.provider]) return { key, name, ...d };
133
+ return null;
134
+ }
@@ -0,0 +1,133 @@
1
+ // Minimal Markdown → HTML for the bundled docs (README-style subset).
2
+ // All text is HTML-escaped first; only a fixed set of tags is ever emitted.
3
+
4
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5
+
6
+ export const slugify = (s) =>
7
+ s.toLowerCase().replace(/<[^>]+>/g, "").replace(/&[a-z]+;/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
8
+
9
+ // Map links between bundled docs onto in-app routes.
10
+ const DOC_ROUTES = {
11
+ "PRIVACY.md": "#/docs/privacy",
12
+ "TERMS.md": "#/docs/terms",
13
+ "SECURITY.md": "#/docs/security",
14
+ "SUPPORT.md": "#/docs/support",
15
+ "CHANGELOG.md": "#/docs/changelog",
16
+ "LICENSE": "#/docs/license",
17
+ "docs/GUIDE.md": "#/docs/guide",
18
+ "GUIDE.md": "#/docs/guide",
19
+ };
20
+
21
+ function href(url) {
22
+ const [base, hash] = url.split("#");
23
+ const clean = base.replace(/^\.\.?\//, "");
24
+ if (DOC_ROUTES[clean]) return { url: DOC_ROUTES[clean] + (hash ? `#${hash}` : ""), external: false };
25
+ if (/^https?:\/\//i.test(url) || /^mailto:/i.test(url)) return { url, external: true };
26
+ if (url.startsWith("#")) return { url, external: false };
27
+ return null; // unknown relative link — render as text
28
+ }
29
+
30
+ function inline(text) {
31
+ const codes = [];
32
+ let s = text.replace(/`([^`]+)`/g, (_, c) => {
33
+ codes.push(`<code>${esc(c)}</code>`);
34
+ return `\u0000${codes.length - 1}\u0000`;
35
+ });
36
+ s = esc(s);
37
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
38
+ const target = href(url.replace(/&amp;/g, "&"));
39
+ if (!target) return label;
40
+ const ext = target.external ? ' target="_blank" rel="noopener noreferrer"' : "";
41
+ return `<a href="${esc(target.url)}"${ext}>${label}</a>`;
42
+ });
43
+ s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, (_, pre, url) => `${pre}<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
44
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
45
+ s = s.replace(/(^|[\s(])_([^_]+)_(?=[\s).,;:]|$)/g, "$1<em>$2</em>");
46
+ s = s.replace(/(^|[\s(])\*([^*]+)\*(?=[\s).,;:]|$)/g, "$1<em>$2</em>");
47
+ return s.replace(/\u0000(\d+)\u0000/g, (_, i) => codes[i]);
48
+ }
49
+
50
+ export function renderMarkdown(src) {
51
+ const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
52
+ const out = [];
53
+ const toc = [];
54
+ let i = 0;
55
+
56
+ const isBlockStart = (l) => /^(#{1,6}\s|```|>|\s*[-*]\s|\s*\d+\.\s|\|)/.test(l) || /^-{3,}\s*$/.test(l);
57
+
58
+ while (i < lines.length) {
59
+ const line = lines[i];
60
+
61
+ if (!line.trim()) {
62
+ i++;
63
+ continue;
64
+ }
65
+ if (line.startsWith("```")) {
66
+ const lang = line.slice(3).trim();
67
+ const buf = [];
68
+ i++;
69
+ while (i < lines.length && !lines[i].startsWith("```")) buf.push(lines[i++]);
70
+ i++;
71
+ out.push(`<pre><code${lang ? ` data-lang="${esc(lang)}"` : ""}>${esc(buf.join("\n"))}</code></pre>`);
72
+ continue;
73
+ }
74
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
75
+ if (h) {
76
+ const level = h[1].length;
77
+ const html = inline(h[2].trim());
78
+ const id = slugify(html);
79
+ if (level === 2 || level === 3) toc.push({ level, id, text: html.replace(/<[^>]+>/g, "") });
80
+ out.push(`<h${level} id="${id}">${html}</h${level}>`);
81
+ i++;
82
+ continue;
83
+ }
84
+ if (/^-{3,}\s*$/.test(line)) {
85
+ out.push("<hr>");
86
+ i++;
87
+ continue;
88
+ }
89
+ if (line.startsWith(">")) {
90
+ const buf = [];
91
+ while (i < lines.length && lines[i].startsWith(">")) buf.push(lines[i++].replace(/^>\s?/, ""));
92
+ out.push(`<blockquote>${renderMarkdown(buf.join("\n")).html}</blockquote>`);
93
+ continue;
94
+ }
95
+ if (line.startsWith("|")) {
96
+ const rows = [];
97
+ while (i < lines.length && lines[i].startsWith("|")) rows.push(lines[i++]);
98
+ const cells = (r) => r.replace(/^\||\|\s*$/g, "").split("|").map((c) => c.trim());
99
+ const [head, sep, ...body] = rows;
100
+ if (sep && /^[\s|:-]+$/.test(sep)) {
101
+ out.push(
102
+ `<div class="table-wrap"><table><thead><tr>${cells(head).map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead>` +
103
+ `<tbody>${body.map((r) => `<tr>${cells(r).map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`,
104
+ );
105
+ } else {
106
+ out.push(`<p>${rows.map((r) => inline(r)).join("<br>")}</p>`);
107
+ }
108
+ continue;
109
+ }
110
+ const list = line.match(/^\s*([-*]|\d+\.)\s+/);
111
+ if (list) {
112
+ const ordered = /\d/.test(list[1]);
113
+ const items = [];
114
+ while (i < lines.length) {
115
+ const m = lines[i].match(/^\s*([-*]|\d+\.)\s+(.*)$/);
116
+ if (m) {
117
+ items.push(m[2]);
118
+ i++;
119
+ } else if (lines[i].trim() && /^\s{2,}/.test(lines[i]) && items.length) {
120
+ items[items.length - 1] += " " + lines[i++].trim();
121
+ } else break;
122
+ }
123
+ const tag = ordered ? "ol" : "ul";
124
+ out.push(`<${tag}>${items.map((t) => `<li>${inline(t)}</li>`).join("")}</${tag}>`);
125
+ continue;
126
+ }
127
+ const buf = [];
128
+ while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i])) buf.push(lines[i++].trim());
129
+ if (!buf.length) buf.push(lines[i++].trim());
130
+ out.push(`<p>${inline(buf.join(" "))}</p>`);
131
+ }
132
+ return { html: out.join("\n"), toc };
133
+ }
@@ -0,0 +1,400 @@
1
+ // Provider registry.
2
+ //
3
+ // Every provider is validated against a FREE endpoint — model listing,
4
+ // account or balance. These authenticate the key and return metadata without
5
+ // running inference, so a check never spends tokens or credits.
6
+ //
7
+ // Each endpoint here has been confirmed to REJECT a fake key (see
8
+ // `npm run verify`). An endpoint that answers 200 without auth would report
9
+ // every key as live, so public listing endpoints are never used.
10
+ //
11
+ // Provider shape:
12
+ // id, name, mark display (plain monogram, never a logo)
13
+ // keyUrl where users create / manage keys
14
+ // env env var names that hold this provider's key
15
+ // pattern regex that uniquely identifies the key format
16
+ // loose regex that *might* be this provider (ambiguous)
17
+ // regions base URLs tried in order until one accepts the key
18
+ // request(key, base) -> { url, method?, headers, body? }
19
+ // parse(body, headers) -> { models?, balance?, account?, rateLimit? }
20
+ // acceptStatus extra HTTP statuses that prove the key authenticated
21
+ // extra(key, ctx) optional follow-up lookup (e.g. balance); failures ignored
22
+
23
+ const bearer = (key) => ({ Authorization: `Bearer ${key}` });
24
+
25
+ export function modelIds(list) {
26
+ if (!Array.isArray(list)) return [];
27
+ const ids = list
28
+ .map((m) => (typeof m === "string" ? m : m?.id ?? m?.name))
29
+ .filter((x) => typeof x === "string" && x)
30
+ .map((x) => x.replace(/^models\//, ""));
31
+ return [...new Set(ids)].sort((a, b) => a.localeCompare(b));
32
+ }
33
+
34
+ function rateLimit(headers) {
35
+ const req = headers?.get?.("x-ratelimit-remaining-requests");
36
+ const tok = headers?.get?.("x-ratelimit-remaining-tokens");
37
+ const parts = [];
38
+ if (req != null) parts.push(`${req} req`);
39
+ if (tok != null) parts.push(`${tok} tokens`);
40
+ return parts.length ? `${parts.join(" · ")} remaining` : null;
41
+ }
42
+
43
+ const money = (n, currency = "USD") => {
44
+ const v = Number(n);
45
+ if (!Number.isFinite(v)) return null;
46
+ const sym = { USD: "$", CNY: "¥", RMB: "¥" }[String(currency).toUpperCase()];
47
+ return sym ? `${sym}${v.toFixed(2)}` : `${v.toFixed(2)} ${currency}`;
48
+ };
49
+
50
+ // OpenAI-compatible `GET {base}/models`.
51
+ function openAICompatible(path = "/models") {
52
+ return {
53
+ request: (key, base) => ({ url: base + path, headers: bearer(key) }),
54
+ parse: (body, headers) => ({
55
+ models: modelIds(Array.isArray(body) ? body : body?.data),
56
+ rateLimit: rateLimit(headers),
57
+ }),
58
+ };
59
+ }
60
+
61
+ const list = [
62
+ {
63
+ id: "openai",
64
+ name: "OpenAI",
65
+ mark: "OA",
66
+ keyUrl: "https://platform.openai.com/api-keys",
67
+ env: ["OPENAI_API_KEY", "OPENAI_KEY"],
68
+ pattern: /^sk-(proj|svcacct|admin)-[\w-]{20,}$/,
69
+ loose: /^sk-[A-Za-z0-9]{48}$/,
70
+ regions: ["https://api.openai.com/v1"],
71
+ ...openAICompatible(),
72
+ },
73
+ {
74
+ id: "anthropic",
75
+ name: "Anthropic",
76
+ mark: "An",
77
+ keyUrl: "https://console.anthropic.com/settings/keys",
78
+ env: ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
79
+ pattern: /^sk-ant-[\w-]{20,}$/,
80
+ regions: ["https://api.anthropic.com/v1"],
81
+ request: (key, base) => ({
82
+ url: `${base}/models?limit=1000`,
83
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
84
+ }),
85
+ parse: (body) => ({ models: modelIds(body?.data) }),
86
+ },
87
+ {
88
+ id: "gemini",
89
+ name: "Google Gemini",
90
+ mark: "Ge",
91
+ keyUrl: "https://aistudio.google.com/app/apikey",
92
+ env: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
93
+ pattern: /^AIza[\w-]{35}$/,
94
+ regions: ["https://generativelanguage.googleapis.com/v1beta"],
95
+ // Header auth keeps the key out of URLs (and any proxy logs).
96
+ request: (key, base) => ({
97
+ url: `${base}/models?pageSize=1000`,
98
+ headers: { "x-goog-api-key": key },
99
+ }),
100
+ parse: (body) => ({ models: modelIds(body?.models) }),
101
+ },
102
+ {
103
+ id: "groq",
104
+ name: "Groq",
105
+ mark: "Gq",
106
+ keyUrl: "https://console.groq.com/keys",
107
+ env: ["GROQ_API_KEY"],
108
+ pattern: /^gsk_\w{20,}$/,
109
+ regions: ["https://api.groq.com/openai/v1"],
110
+ ...openAICompatible(),
111
+ },
112
+ {
113
+ id: "mistral",
114
+ name: "Mistral",
115
+ mark: "Mi",
116
+ keyUrl: "https://console.mistral.ai/api-keys",
117
+ env: ["MISTRAL_API_KEY"],
118
+ regions: ["https://api.mistral.ai/v1"],
119
+ ...openAICompatible(),
120
+ },
121
+ {
122
+ id: "openrouter",
123
+ name: "OpenRouter",
124
+ mark: "OR",
125
+ keyUrl: "https://openrouter.ai/keys",
126
+ env: ["OPENROUTER_API_KEY"],
127
+ pattern: /^sk-or-[\w-]{20,}$/,
128
+ regions: ["https://openrouter.ai/api/v1"],
129
+ // /key reports usage + credit limit; /models is public so it can't validate.
130
+ request: (key, base) => ({ url: `${base}/key`, headers: bearer(key) }),
131
+ parse: (body) => {
132
+ const d = body?.data || {};
133
+ let balance = null;
134
+ if (d.limit_remaining != null) balance = `${money(d.limit_remaining)} / ${money(d.limit)}`;
135
+ else if (d.usage != null) balance = `${money(d.usage)} used · no limit`;
136
+ if (balance && d.is_free_tier) balance += " · free tier";
137
+ return { balance, account: d.label || null };
138
+ },
139
+ },
140
+ {
141
+ id: "deepseek",
142
+ name: "DeepSeek",
143
+ mark: "DS",
144
+ keyUrl: "https://platform.deepseek.com/api_keys",
145
+ env: ["DEEPSEEK_API_KEY"],
146
+ loose: /^sk-[a-f0-9]{32}$/,
147
+ regions: ["https://api.deepseek.com"],
148
+ request: (key, base) => ({ url: `${base}/user/balance`, headers: bearer(key) }),
149
+ parse: (body) => {
150
+ const info = (body?.balance_infos || [])[0];
151
+ if (info) return { balance: money(info.total_balance, info.currency) };
152
+ if (body?.is_available != null) return { balance: body.is_available ? "Balance available" : "No balance" };
153
+ return {};
154
+ },
155
+ },
156
+ {
157
+ id: "xai",
158
+ name: "xAI Grok",
159
+ mark: "xA",
160
+ keyUrl: "https://console.x.ai",
161
+ env: ["XAI_API_KEY", "GROK_API_KEY"],
162
+ pattern: /^xai-\w{20,}$/,
163
+ regions: ["https://api.x.ai/v1"],
164
+ ...openAICompatible(),
165
+ },
166
+ {
167
+ id: "together",
168
+ name: "Together AI",
169
+ mark: "To",
170
+ keyUrl: "https://api.together.ai/settings/api-keys",
171
+ env: ["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"],
172
+ pattern: /^tgp_v1_[\w-]{20,}$/,
173
+ regions: ["https://api.together.xyz/v1"],
174
+ ...openAICompatible(),
175
+ },
176
+ {
177
+ id: "cohere",
178
+ name: "Cohere",
179
+ mark: "Co",
180
+ keyUrl: "https://dashboard.cohere.com/api-keys",
181
+ env: ["COHERE_API_KEY", "CO_API_KEY"],
182
+ regions: ["https://api.cohere.com/v1"],
183
+ request: (key, base) => ({ url: `${base}/models?page_size=1000`, headers: bearer(key) }),
184
+ parse: (body) => ({ models: modelIds(body?.models) }),
185
+ },
186
+ {
187
+ id: "kimi",
188
+ name: "Kimi (Moonshot)",
189
+ mark: "Ki",
190
+ keyUrl: "https://platform.moonshot.ai/console/api-keys",
191
+ env: ["MOONSHOT_API_KEY", "KIMI_API_KEY"],
192
+ loose: /^sk-\w{40,}$/,
193
+ regions: ["https://api.moonshot.ai/v1", "https://api.moonshot.cn/v1"],
194
+ ...openAICompatible(),
195
+ async extra(key, { base, get }) {
196
+ const res = await get(`${base}/users/me/balance`, bearer(key));
197
+ const d = res.body?.data;
198
+ if (d?.available_balance == null) return {};
199
+ const cur = base.includes(".cn") ? "CNY" : "USD";
200
+ return { balance: money(d.available_balance, cur) };
201
+ },
202
+ },
203
+ {
204
+ id: "qwen",
205
+ name: "Qwen (DashScope)",
206
+ mark: "Qw",
207
+ keyUrl: "https://bailian.console.alibabacloud.com/?apiKey=1",
208
+ env: ["DASHSCOPE_API_KEY", "QWEN_API_KEY"],
209
+ loose: /^sk-[a-f0-9]{32}$/,
210
+ regions: [
211
+ "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
212
+ "https://dashscope.aliyuncs.com/compatible-mode/v1",
213
+ ],
214
+ ...openAICompatible(),
215
+ },
216
+ {
217
+ id: "glm",
218
+ name: "GLM (Zhipu / Z.ai)",
219
+ mark: "GL",
220
+ keyUrl: "https://z.ai/manage-apikey/apikey-list",
221
+ env: ["ZHIPUAI_API_KEY", "ZAI_API_KEY", "GLM_API_KEY"],
222
+ pattern: /^[a-f0-9]{32}\.[A-Za-z0-9]{16}$/,
223
+ regions: ["https://api.z.ai/api/paas/v4", "https://open.bigmodel.cn/api/paas/v4"],
224
+ ...openAICompatible(),
225
+ },
226
+ {
227
+ id: "siliconflow",
228
+ name: "SiliconFlow",
229
+ mark: "SF",
230
+ keyUrl: "https://cloud.siliconflow.com/account/ak",
231
+ env: ["SILICONFLOW_API_KEY", "SILICON_FLOW_API_KEY"],
232
+ loose: /^sk-[a-z]{40,}$/,
233
+ regions: ["https://api.siliconflow.com/v1", "https://api.siliconflow.cn/v1"],
234
+ ...openAICompatible(),
235
+ async extra(key, { base, get }) {
236
+ const res = await get(`${base}/user/info`, bearer(key));
237
+ const d = res.body?.data;
238
+ if (d?.totalBalance == null) return {};
239
+ const cur = base.includes(".cn") ? "CNY" : "USD";
240
+ return { balance: money(d.totalBalance, cur), account: d.name || d.email || null };
241
+ },
242
+ },
243
+ {
244
+ id: "perplexity",
245
+ name: "Perplexity",
246
+ mark: "Px",
247
+ keyUrl: "https://www.perplexity.ai/settings/api",
248
+ env: ["PERPLEXITY_API_KEY", "PPLX_API_KEY"],
249
+ pattern: /^pplx-\w{20,}$/,
250
+ regions: ["https://api.perplexity.ai/v1"],
251
+ ...openAICompatible(),
252
+ },
253
+ {
254
+ id: "cerebras",
255
+ name: "Cerebras",
256
+ mark: "Ce",
257
+ keyUrl: "https://cloud.cerebras.ai",
258
+ env: ["CEREBRAS_API_KEY"],
259
+ pattern: /^csk-\w{20,}$/,
260
+ regions: ["https://api.cerebras.ai/v1"],
261
+ ...openAICompatible(),
262
+ },
263
+ {
264
+ id: "fireworks",
265
+ name: "Fireworks AI",
266
+ mark: "Fw",
267
+ keyUrl: "https://fireworks.ai/account/api-keys",
268
+ env: ["FIREWORKS_API_KEY"],
269
+ pattern: /^fw_\w{20,}$/,
270
+ regions: ["https://api.fireworks.ai/inference/v1"],
271
+ ...openAICompatible(),
272
+ },
273
+ {
274
+ id: "sambanova",
275
+ name: "SambaNova",
276
+ mark: "Sa",
277
+ keyUrl: "https://cloud.sambanova.ai/apis",
278
+ env: ["SAMBANOVA_API_KEY"],
279
+ regions: ["https://api.sambanova.ai/v1"],
280
+ // SambaNova's /models is public, so it can't prove anything. Instead we
281
+ // send an embeddings request with no input: a bad key gets 401, a good key
282
+ // gets a validation error. Nothing is ever computed or billed.
283
+ request: (key, base) => ({
284
+ url: `${base}/embeddings`,
285
+ method: "POST",
286
+ headers: { ...bearer(key), "Content-Type": "application/json" },
287
+ body: JSON.stringify({ model: "E5-Mistral-7B-Instruct" }),
288
+ }),
289
+ acceptStatus: [400, 422],
290
+ parse: () => ({}),
291
+ },
292
+ {
293
+ id: "huggingface",
294
+ name: "Hugging Face",
295
+ mark: "HF",
296
+ keyUrl: "https://huggingface.co/settings/tokens",
297
+ env: ["HF_TOKEN", "HUGGINGFACE_API_KEY", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACEHUB_API_TOKEN"],
298
+ pattern: /^hf_\w{20,}$/,
299
+ regions: ["https://huggingface.co/api"],
300
+ request: (key, base) => ({ url: `${base}/whoami-v2`, headers: bearer(key) }),
301
+ parse: (body) => {
302
+ const role = body?.auth?.accessToken?.role;
303
+ return { account: [body?.name, role && `${role} token`].filter(Boolean).join(" · ") || null };
304
+ },
305
+ },
306
+ {
307
+ id: "replicate",
308
+ name: "Replicate",
309
+ mark: "Re",
310
+ keyUrl: "https://replicate.com/account/api-tokens",
311
+ env: ["REPLICATE_API_TOKEN", "REPLICATE_API_KEY"],
312
+ pattern: /^r8_\w{20,}$/,
313
+ regions: ["https://api.replicate.com/v1"],
314
+ request: (key, base) => ({ url: `${base}/account`, headers: bearer(key) }),
315
+ parse: (body) => ({
316
+ account: [body?.username, body?.type].filter(Boolean).join(" · ") || null,
317
+ }),
318
+ },
319
+ {
320
+ id: "novita",
321
+ name: "Novita AI",
322
+ mark: "No",
323
+ keyUrl: "https://novita.ai/settings/key-management",
324
+ env: ["NOVITA_API_KEY"],
325
+ regions: ["https://api.novita.ai"],
326
+ // Novita's /models is public. The billing endpoint requires auth and is
327
+ // free. Amounts are in 1/10000 USD.
328
+ request: (key, base) => ({
329
+ url: `${base}/openapi/v1/billing/balance/detail`,
330
+ headers: bearer(key),
331
+ }),
332
+ parse: (body) =>
333
+ body?.availableBalance != null
334
+ ? { balance: money(Number(body.availableBalance) / 10000) }
335
+ : {},
336
+ },
337
+ {
338
+ id: "deepinfra",
339
+ name: "DeepInfra",
340
+ mark: "DI",
341
+ keyUrl: "https://deepinfra.com/dash/api_keys",
342
+ env: ["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
343
+ regions: ["https://api.deepinfra.com/v1/openai"],
344
+ ...openAICompatible(),
345
+ },
346
+ {
347
+ id: "hyperbolic",
348
+ name: "Hyperbolic",
349
+ mark: "Hy",
350
+ keyUrl: "https://app.hyperbolic.xyz/settings",
351
+ env: ["HYPERBOLIC_API_KEY"],
352
+ regions: ["https://api.hyperbolic.xyz/v1"],
353
+ ...openAICompatible(),
354
+ },
355
+ {
356
+ id: "nebius",
357
+ name: "Nebius AI Studio",
358
+ mark: "Nb",
359
+ keyUrl: "https://studio.nebius.com/settings/api-keys",
360
+ env: ["NEBIUS_API_KEY"],
361
+ regions: ["https://api.studio.nebius.com/v1"],
362
+ ...openAICompatible(),
363
+ },
364
+ ];
365
+
366
+ export const providers = Object.fromEntries(list.map((p) => [p.id, p]));
367
+ export const providerList = list;
368
+
369
+ // Public, serialisable view for the UI / `apilive providers`.
370
+ export function describeProviders() {
371
+ return list.map((p) => ({
372
+ id: p.id,
373
+ name: p.name,
374
+ mark: p.mark,
375
+ keyUrl: p.keyUrl,
376
+ env: p.env,
377
+ pattern: p.pattern?.source ?? null,
378
+ loose: p.loose?.source ?? null,
379
+ balance: Boolean(p.extra) || ["openrouter", "deepseek", "novita"].includes(p.id),
380
+ }));
381
+ }
382
+
383
+ const envIndex = new Map(list.flatMap((p) => p.env.map((name) => [name, p.id])));
384
+
385
+ // Work out which provider a key belongs to.
386
+ // Returns { provider: id|null, confident: boolean, candidates: id[] }.
387
+ export function detectProvider(key, envName) {
388
+ if (envName && envIndex.has(envName)) {
389
+ return { provider: envIndex.get(envName), confident: true, candidates: [] };
390
+ }
391
+ const exact = list.find((p) => p.pattern?.test(key));
392
+ if (exact) return { provider: exact.id, confident: true, candidates: [] };
393
+
394
+ const candidates = list.filter((p) => p.loose?.test(key)).map((p) => p.id);
395
+ return { provider: candidates[0] ?? null, confident: false, candidates };
396
+ }
397
+
398
+ export function providerForEnvName(name) {
399
+ return envIndex.get(name) ?? null;
400
+ }