pool-anything 0.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/LICENSE +202 -0
- package/README.md +280 -0
- package/bin/pool-anything.js +4 -0
- package/data/providers.json +38 -0
- package/dist/admin/branding.js +34 -0
- package/dist/admin/components.js +189 -0
- package/dist/admin/pages.js +1891 -0
- package/dist/admin/shell.js +129 -0
- package/dist/admin/tokens.js +32 -0
- package/dist/cli/nl.js +147 -0
- package/dist/cli/repl.js +470 -0
- package/dist/cli/ui.js +81 -0
- package/dist/cli.js +199 -0
- package/dist/common/http.js +55 -0
- package/dist/config/env.js +94 -0
- package/dist/config/paths.js +112 -0
- package/dist/data/providers.json +38 -0
- package/dist/db/index.js +20 -0
- package/dist/db/init.js +11 -0
- package/dist/logo.png +0 -0
- package/dist/media/capabilities.js +103 -0
- package/dist/middleware/auth.js +37 -0
- package/dist/observability/health.js +31 -0
- package/dist/pool/index.js +407 -0
- package/dist/pool/rotation.js +123 -0
- package/dist/proxy/dns.js +39 -0
- package/dist/proxy/forward.js +350 -0
- package/dist/public/logos/anthropic.svg +1 -0
- package/dist/public/logos/anyscale.svg +1 -0
- package/dist/public/logos/appwrite.svg +1 -0
- package/dist/public/logos/brave.svg +1 -0
- package/dist/public/logos/cartesia.svg +4 -0
- package/dist/public/logos/claude.svg +1 -0
- package/dist/public/logos/cloudflare.svg +1 -0
- package/dist/public/logos/cohere-command.svg +1 -0
- package/dist/public/logos/cohere.svg +1 -0
- package/dist/public/logos/deepgram.svg +1 -0
- package/dist/public/logos/elevenlabs.svg +1 -0
- package/dist/public/logos/finnhub.png +0 -0
- package/dist/public/logos/fireworks.svg +1 -0
- package/dist/public/logos/gemini.svg +1 -0
- package/dist/public/logos/google.svg +1 -0
- package/dist/public/logos/googlemaps.svg +1 -0
- package/dist/public/logos/groq.svg +1 -0
- package/dist/public/logos/huggingface.svg +1 -0
- package/dist/public/logos/mailgun.svg +1 -0
- package/dist/public/logos/manifest.json +197 -0
- package/dist/public/logos/mapbox.svg +1 -0
- package/dist/public/logos/mistral.svg +1 -0
- package/dist/public/logos/neon.svg +1 -0
- package/dist/public/logos/openai.svg +1 -0
- package/dist/public/logos/openrouter.svg +1 -0
- package/dist/public/logos/openweather.png +0 -0
- package/dist/public/logos/perplexity.svg +1 -0
- package/dist/public/logos/pool-anything.svg +111 -0
- package/dist/public/logos/postmark.svg +4 -0
- package/dist/public/logos/replicate.svg +1 -0
- package/dist/public/logos/resend.svg +1 -0
- package/dist/public/logos/scrapingbee.svg +72 -0
- package/dist/public/logos/sendgrid.svg +1 -0
- package/dist/public/logos/serper.png +0 -0
- package/dist/public/logos/supabase.svg +1 -0
- package/dist/public/logos/tavily.svg +1 -0
- package/dist/public/logos/together.svg +1 -0
- package/dist/public/logos/turso.svg +1 -0
- package/dist/public/logos/twilio.svg +1 -0
- package/dist/public/logos/upstash.svg +1 -0
- package/dist/router/api.js +20 -0
- package/dist/server.js +431 -0
- package/dist/upstream/index.js +137 -0
- package/package.json +68 -0
- package/public/logos/anthropic.svg +1 -0
- package/public/logos/anyscale.svg +1 -0
- package/public/logos/appwrite.svg +1 -0
- package/public/logos/brave.svg +1 -0
- package/public/logos/cartesia.svg +4 -0
- package/public/logos/claude.svg +1 -0
- package/public/logos/cloudflare.svg +1 -0
- package/public/logos/cohere-command.svg +1 -0
- package/public/logos/cohere.svg +1 -0
- package/public/logos/deepgram.svg +1 -0
- package/public/logos/elevenlabs.svg +1 -0
- package/public/logos/finnhub.png +0 -0
- package/public/logos/fireworks.svg +1 -0
- package/public/logos/gemini.svg +1 -0
- package/public/logos/google.svg +1 -0
- package/public/logos/googlemaps.svg +1 -0
- package/public/logos/groq.svg +1 -0
- package/public/logos/huggingface.svg +1 -0
- package/public/logos/mailgun.svg +1 -0
- package/public/logos/manifest.json +197 -0
- package/public/logos/mapbox.svg +1 -0
- package/public/logos/mistral.svg +1 -0
- package/public/logos/neon.svg +1 -0
- package/public/logos/openai.svg +1 -0
- package/public/logos/openrouter.svg +1 -0
- package/public/logos/openweather.png +0 -0
- package/public/logos/perplexity.svg +1 -0
- package/public/logos/pool-anything.svg +111 -0
- package/public/logos/postmark.svg +4 -0
- package/public/logos/replicate.svg +1 -0
- package/public/logos/resend.svg +1 -0
- package/public/logos/scrapingbee.svg +72 -0
- package/public/logos/sendgrid.svg +1 -0
- package/public/logos/serper.png +0 -0
- package/public/logos/supabase.svg +1 -0
- package/public/logos/tavily.svg +1 -0
- package/public/logos/together.svg +1 -0
- package/public/logos/turso.svg +1 -0
- package/public/logos/twilio.svg +1 -0
- package/public/logos/upstash.svg +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Multimodal capability map + provider-specific message builders.
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for which providers/models accept media in/out.
|
|
4
|
+
// The Playground mirrors this table client-side (see playgroundPage); keep the
|
|
5
|
+
// two in sync when adding providers. Model-name heuristics refine the
|
|
6
|
+
// provider baseline (e.g. tts/whisper/embed models never take image input).
|
|
7
|
+
export const MAX_MEDIA_FILE_BYTES = 8_000_000; // ~8MB per attachment (abuse cap)
|
|
8
|
+
export const MAX_PROXY_BODY_BYTES = 12_000_000; // proxy JSON cap incl. base64 media
|
|
9
|
+
// Upstream->caller relay cap (raised from 4000 for Studio media). Large!
|
|
10
|
+
// One media proxy transiently holds raw bytes + base64 (~2x); keep conservative.
|
|
11
|
+
export const MAX_RELAY_CHARS = 8_000_000;
|
|
12
|
+
export const MAX_UPSTREAM_MEDIA_BYTES = 12_000_000;
|
|
13
|
+
export const ALLOWED_IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp"];
|
|
14
|
+
export const ALLOWED_AUDIO_MIMES = ["audio/mpeg", "audio/wav", "audio/webm", "audio/ogg", "audio/mp4"];
|
|
15
|
+
export const ALLOWED_VIDEO_MIMES = ["video/mp4", "video/webm"];
|
|
16
|
+
const BASE_CAPS = {
|
|
17
|
+
openai: { vision: true, audioIn: true, videoIn: false, imageOut: true, audioOut: true, videoOut: false },
|
|
18
|
+
anthropic: { vision: true, audioIn: false, videoIn: false, imageOut: false, audioOut: false, videoOut: false },
|
|
19
|
+
gemini: { vision: true, audioIn: true, videoIn: true, imageOut: true, audioOut: true, videoOut: true },
|
|
20
|
+
groq: { vision: true, audioIn: true, videoIn: false, imageOut: false, audioOut: false, videoOut: false },
|
|
21
|
+
openrouter: { vision: true, audioIn: false, videoIn: false, imageOut: true, audioOut: false, videoOut: false },
|
|
22
|
+
mistral: { vision: true, audioIn: false, videoIn: false, imageOut: false, audioOut: false, videoOut: false },
|
|
23
|
+
fireworks: { vision: true, audioIn: false, videoIn: false, imageOut: true, audioOut: false, videoOut: false },
|
|
24
|
+
together: { vision: true, audioIn: false, videoIn: false, imageOut: true, audioOut: false, videoOut: false },
|
|
25
|
+
huggingface: { vision: true, audioIn: false, videoIn: false, imageOut: true, audioOut: false, videoOut: false },
|
|
26
|
+
replicate: { vision: true, audioIn: false, videoIn: false, imageOut: true, audioOut: false, videoOut: true },
|
|
27
|
+
perplexity: { vision: true, audioIn: false, videoIn: false, imageOut: false, audioOut: false, videoOut: false },
|
|
28
|
+
cohere: { vision: true, audioIn: false, videoIn: false, imageOut: false, audioOut: false, videoOut: false },
|
|
29
|
+
anyscale: { vision: false, audioIn: false, videoIn: false, imageOut: false, audioOut: false, videoOut: false },
|
|
30
|
+
elevenlabs: { vision: false, audioIn: false, videoIn: false, imageOut: false, audioOut: true, videoOut: false },
|
|
31
|
+
deepgram: { vision: false, audioIn: true, videoIn: false, imageOut: false, audioOut: true, videoOut: false },
|
|
32
|
+
cartesia: { vision: false, audioIn: false, videoIn: false, imageOut: false, audioOut: true, videoOut: false },
|
|
33
|
+
};
|
|
34
|
+
const NONE = { vision: false, audioIn: false, videoIn: false, imageOut: false, audioOut: false, videoOut: false };
|
|
35
|
+
const NO_VISION_MODEL = /tts|whisper|transcri|embed|image|flux|diffusion|imagen|dall|sonic|nova|stt|audit/i;
|
|
36
|
+
const IMAGE_GEN_MODEL = /image|flux|diffusion|imagen|dall|stable|midjourney/i;
|
|
37
|
+
const VIDEO_GEN_MODEL = /video|veo|pika|runway|sora/i;
|
|
38
|
+
/** Provider baseline refined by model-name heuristics. Pure + tested. */
|
|
39
|
+
export function capabilityFor(providerId, model = "") {
|
|
40
|
+
const base = BASE_CAPS[providerId] ?? NONE;
|
|
41
|
+
if (!model)
|
|
42
|
+
return { ...base };
|
|
43
|
+
const m = model.toLowerCase();
|
|
44
|
+
const noVision = NO_VISION_MODEL.test(m);
|
|
45
|
+
if (/embed/.test(m))
|
|
46
|
+
return { ...NONE };
|
|
47
|
+
if (IMAGE_GEN_MODEL.test(m))
|
|
48
|
+
return { ...base, vision: false, audioIn: false, videoIn: false };
|
|
49
|
+
if (VIDEO_GEN_MODEL.test(m))
|
|
50
|
+
return { ...base, vision: false, audioIn: false, videoIn: false };
|
|
51
|
+
if (/tts|sonic|eleven/.test(m))
|
|
52
|
+
return { ...base, vision: false, audioIn: false, videoIn: false };
|
|
53
|
+
if (/whisper|transcri|^nova|stt/.test(m))
|
|
54
|
+
return { ...base, vision: false, videoIn: false };
|
|
55
|
+
if (noVision)
|
|
56
|
+
return { ...base, vision: false };
|
|
57
|
+
return { ...base };
|
|
58
|
+
}
|
|
59
|
+
export function supports(providerId, model, cap) {
|
|
60
|
+
return capabilityFor(providerId, model ?? "")[cap];
|
|
61
|
+
}
|
|
62
|
+
/** OpenAI-compatible vision content parts (groq/openrouter/mistral/etc.). */
|
|
63
|
+
export function openAiVisionParts(text, images) {
|
|
64
|
+
const parts = [{ type: "text", text }];
|
|
65
|
+
for (const img of images) {
|
|
66
|
+
parts.push({ type: "image_url", image_url: { url: `data:${img.mime};base64,${img.b64}` } });
|
|
67
|
+
}
|
|
68
|
+
return parts;
|
|
69
|
+
}
|
|
70
|
+
/** Anthropic native vision content blocks (image source.type base64). */
|
|
71
|
+
export function anthropicVisionBlocks(text, images) {
|
|
72
|
+
const blocks = [{ type: "text", text }];
|
|
73
|
+
for (const img of images) {
|
|
74
|
+
blocks.push({ type: "image", source: { type: "base64", media_type: img.mime, data: img.b64 } });
|
|
75
|
+
}
|
|
76
|
+
return blocks;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build chat messages for a provider given text + image attachments.
|
|
80
|
+
* Anthropic uses native image blocks; everyone else uses the
|
|
81
|
+
* OpenAI-compatible image_url data-URI shape (which groq, mistral,
|
|
82
|
+
* together, fireworks, openrouter, gemini-compat, etc. all accept).
|
|
83
|
+
*/
|
|
84
|
+
export function buildVisionMessages(providerId, text, images) {
|
|
85
|
+
if (images.length === 0)
|
|
86
|
+
return [{ role: "user", content: text }];
|
|
87
|
+
if (providerId === "anthropic") {
|
|
88
|
+
return [{ role: "user", content: anthropicVisionBlocks(text, images) }];
|
|
89
|
+
}
|
|
90
|
+
return [{ role: "user", content: openAiVisionParts(text, images) }];
|
|
91
|
+
}
|
|
92
|
+
/** Human-readable gate note when a model lacks a capability. */
|
|
93
|
+
export function gateNote(cap, providerId, model) {
|
|
94
|
+
const label = cap === "vision" ? "image input" : cap === "audioIn" ? "audio input" : cap === "videoIn" ? "video input" : cap === "imageOut" ? "image output" : cap === "audioOut" ? "audio output" : "video output";
|
|
95
|
+
return `this model doesn't support ${label} (${providerId} ${model || "default model"})`;
|
|
96
|
+
}
|
|
97
|
+
export function isAllowedMime(kind, mime) {
|
|
98
|
+
if (kind === "image")
|
|
99
|
+
return ALLOWED_IMAGE_MIMES.includes(mime);
|
|
100
|
+
if (kind === "audio")
|
|
101
|
+
return ALLOWED_AUDIO_MIMES.includes(mime);
|
|
102
|
+
return ALLOWED_VIDEO_MIMES.includes(mime);
|
|
103
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { apiToken, authEnabled } from "../config/env.js";
|
|
2
|
+
/** True when the request carries the configured bearer token (or auth is disabled). */
|
|
3
|
+
export function isAuthorized(req) {
|
|
4
|
+
if (!authEnabled())
|
|
5
|
+
return true;
|
|
6
|
+
const h = req.headers["authorization"];
|
|
7
|
+
if (typeof h !== "string")
|
|
8
|
+
return false;
|
|
9
|
+
// Constant-time-ish compare without leaking length via early exit timing is
|
|
10
|
+
// overkill here, but avoid `startsWith` prefix-oracle confusion: exact match only.
|
|
11
|
+
const want = `Bearer ${apiToken()}`;
|
|
12
|
+
if (h.length !== want.length)
|
|
13
|
+
return false;
|
|
14
|
+
let diff = 0;
|
|
15
|
+
for (let i = 0; i < h.length; i++)
|
|
16
|
+
diff |= h.charCodeAt(i) ^ want.charCodeAt(i);
|
|
17
|
+
return diff === 0;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Routes that disclose raw keys, mutate state, spend quota, or advance the
|
|
21
|
+
* rotation cursor require auth when POOL_API_TOKEN is set. Masked read-only
|
|
22
|
+
* summaries stay public so the dashboard works without a token.
|
|
23
|
+
*/
|
|
24
|
+
export function requiresAuth(method, pathname) {
|
|
25
|
+
// Raw key disclosure.
|
|
26
|
+
if (/^\/api\/pools\/\d+\/keys\/\d+$/.test(pathname))
|
|
27
|
+
return true;
|
|
28
|
+
// Writes.
|
|
29
|
+
if (method === "POST" || method === "PATCH" || method === "DELETE") {
|
|
30
|
+
if (pathname.startsWith("/api/"))
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
// Rotation cursor advances (state change) even though the key is masked.
|
|
34
|
+
if (method === "GET" && /^\/api\/pools\/\d+\/next$/.test(pathname))
|
|
35
|
+
return true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { dbPing } from "../db/index.js";
|
|
2
|
+
import { getAnalytics } from "../pool/index.js";
|
|
3
|
+
/** Liveness: never touches the DB. */
|
|
4
|
+
export function health() {
|
|
5
|
+
return { status: 200, body: "ok\n", contentType: "text/plain" };
|
|
6
|
+
}
|
|
7
|
+
/** Readiness: SQLite reachable and writable enough to SELECT. Never throws. */
|
|
8
|
+
export function readiness() {
|
|
9
|
+
try {
|
|
10
|
+
return { status: 200, body: dbPing() };
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return { status: 500, body: { ok: false } };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Analytics snapshot for the dashboard. Never throws (empty on failure). */
|
|
17
|
+
export function analyticsSnapshot() {
|
|
18
|
+
try {
|
|
19
|
+
return { status: 200, body: getAnalytics() };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {
|
|
23
|
+
status: 500,
|
|
24
|
+
body: {
|
|
25
|
+
requests: 0, tokens: 0, pools: 0, keys: 0, cooling: 0, avgTokens: 0,
|
|
26
|
+
series: [], keysSeries: [], poolsSeries: [],
|
|
27
|
+
deltaRequestsPct: 0, deltaTokensPct: 0,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DB_PATH } from "../config/env.js";
|
|
5
|
+
import { providersJsonPath } from "../config/paths.js";
|
|
6
|
+
export { DB_PATH };
|
|
7
|
+
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
8
|
+
export const sdb = new DatabaseSync(DB_PATH);
|
|
9
|
+
sdb.exec(`PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;`);
|
|
10
|
+
sdb.exec(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS pools (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
provider TEXT NOT NULL,
|
|
14
|
+
name TEXT NOT NULL,
|
|
15
|
+
cursor INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
17
|
+
);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS pool_keys (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
pool_id INTEGER NOT NULL REFERENCES pools(id) ON DELETE CASCADE,
|
|
21
|
+
label TEXT NOT NULL,
|
|
22
|
+
api_key TEXT NOT NULL,
|
|
23
|
+
info TEXT NOT NULL DEFAULT '',
|
|
24
|
+
cooldown_until INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
latency_ms REAL,
|
|
26
|
+
consec_fail INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
28
|
+
);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS usage (
|
|
30
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
+
pool_id INTEGER NOT NULL REFERENCES pools(id) ON DELETE CASCADE,
|
|
32
|
+
key_id INTEGER NOT NULL REFERENCES pool_keys(id) ON DELETE CASCADE,
|
|
33
|
+
tokens INTEGER NOT NULL,
|
|
34
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_pool_keys_pool ON pool_keys(pool_id);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_usage_pool_created ON usage(pool_id, created_at);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_usage_key_created ON usage(key_id, created_at);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_pools_provider ON pools(provider);
|
|
40
|
+
`);
|
|
41
|
+
const FALLBACK_PROVIDERS = [
|
|
42
|
+
{
|
|
43
|
+
id: "cartesia",
|
|
44
|
+
name: "Cartesia",
|
|
45
|
+
quota: "20,000 free tokens",
|
|
46
|
+
keyFields: ["api_key"],
|
|
47
|
+
hint: "Paste a Cartesia API key. Add key 1, key 2… to pool them.",
|
|
48
|
+
baseUrl: "https://api.cartesia.ai",
|
|
49
|
+
keyHeader: "X-API-Key",
|
|
50
|
+
keyPrefix: "",
|
|
51
|
+
extraHeaders: { "Cartesia-Version": "2024-06-10" },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "groq",
|
|
55
|
+
name: "Groq",
|
|
56
|
+
quota: "free tier",
|
|
57
|
+
keyFields: ["api_key"],
|
|
58
|
+
hint: "Paste a Groq API key (gsk_…). Add key 1, key 2… to pool them.",
|
|
59
|
+
baseUrl: "https://api.groq.com/openai/v1",
|
|
60
|
+
keyHeader: "Authorization",
|
|
61
|
+
keyPrefix: "Bearer ",
|
|
62
|
+
extraHeaders: {},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "custom",
|
|
66
|
+
name: "Custom",
|
|
67
|
+
quota: "bring your own",
|
|
68
|
+
keyFields: ["api_key"],
|
|
69
|
+
hint: "Anything we don't provide: name it, add keys + info.",
|
|
70
|
+
baseUrl: "",
|
|
71
|
+
keyHeader: "X-API-Key",
|
|
72
|
+
keyPrefix: "",
|
|
73
|
+
extraHeaders: {},
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
function loadProviders() {
|
|
77
|
+
try {
|
|
78
|
+
const raw = JSON.parse(fs.readFileSync(providersJsonPath(), "utf8"));
|
|
79
|
+
if (!Array.isArray(raw))
|
|
80
|
+
throw new Error("not an array");
|
|
81
|
+
return raw
|
|
82
|
+
.filter((p) => typeof p.id === "string" && typeof p.name === "string" && typeof p.baseUrl === "string")
|
|
83
|
+
.map((p) => ({
|
|
84
|
+
id: p.id,
|
|
85
|
+
name: p.name,
|
|
86
|
+
quota: String(p.quota ?? ""),
|
|
87
|
+
keyFields: Array.isArray(p.keyFields) && p.keyFields.every((k) => typeof k === "string") ? p.keyFields : ["api_key"],
|
|
88
|
+
hint: String(p.hint ?? ""),
|
|
89
|
+
baseUrl: p.baseUrl,
|
|
90
|
+
keyHeader: String(p.keyHeader ?? "X-API-Key"),
|
|
91
|
+
keyPrefix: String(p.keyPrefix ?? ""),
|
|
92
|
+
extraHeaders: p.extraHeaders !== null && typeof p.extraHeaders === "object" && !Array.isArray(p.extraHeaders)
|
|
93
|
+
? Object.fromEntries(Object.entries(p.extraHeaders).filter(([, v]) => typeof v === "string"))
|
|
94
|
+
: {},
|
|
95
|
+
docsUrl: typeof p.docsUrl === "string" ? p.docsUrl : undefined,
|
|
96
|
+
logoFile: typeof p.logo === "string" && p.logo ? path.basename(p.logo) : undefined,
|
|
97
|
+
poolable: (p.poolable ?? true),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
console.warn(`[warn] failed to load data/providers.json, using fallback providers: ${e.message}`);
|
|
102
|
+
return FALLBACK_PROVIDERS;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export const PROVIDERS = loadProviders();
|
|
106
|
+
export function mask(key) {
|
|
107
|
+
if (typeof key !== "string" || key.length <= 8)
|
|
108
|
+
return "****";
|
|
109
|
+
return `${key.slice(0, 4)}…${key.slice(-2)}`;
|
|
110
|
+
}
|
|
111
|
+
export function providerKeyFields(provider) {
|
|
112
|
+
return PROVIDERS.find((x) => x.id === provider)?.keyFields ?? ["api_key"];
|
|
113
|
+
}
|
|
114
|
+
export function isMultiFieldProvider(provider) {
|
|
115
|
+
const f = providerKeyFields(provider);
|
|
116
|
+
return !(f.length === 1 && (f[0] === "api_key" || f[0] === "apiToken"));
|
|
117
|
+
}
|
|
118
|
+
function cleanFieldValue(v) {
|
|
119
|
+
if (typeof v !== "string")
|
|
120
|
+
return null;
|
|
121
|
+
const t = v.trim();
|
|
122
|
+
if (!t || t.length > 4096 || /[\r\n\0]/.test(t))
|
|
123
|
+
return null;
|
|
124
|
+
return t;
|
|
125
|
+
}
|
|
126
|
+
/** Validate + normalize a credentials object for a provider. Returns normalized or error. */
|
|
127
|
+
export function normalizeCredentials(provider, input) {
|
|
128
|
+
const fields = providerKeyFields(provider);
|
|
129
|
+
if (input === undefined || input === null)
|
|
130
|
+
return { ok: false, error: `missing credentials: ${fields.join(", ")} required` };
|
|
131
|
+
if (typeof input !== "object" || Array.isArray(input))
|
|
132
|
+
return { ok: false, error: "credentials must be an object" };
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const f of fields) {
|
|
135
|
+
const v = cleanFieldValue(input[f]);
|
|
136
|
+
if (v === null)
|
|
137
|
+
return { ok: false, error: `field ${f} required (non-empty, <=4096 chars, no CR/LF)` };
|
|
138
|
+
out[f] = v;
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, value: out };
|
|
141
|
+
}
|
|
142
|
+
export function parseCredentials(raw) {
|
|
143
|
+
if (typeof raw !== "string" || !raw || raw === "{}")
|
|
144
|
+
return {};
|
|
145
|
+
try {
|
|
146
|
+
const o = JSON.parse(raw);
|
|
147
|
+
if (o !== null && typeof o === "object" && !Array.isArray(o)) {
|
|
148
|
+
const out = {};
|
|
149
|
+
for (const [k, v] of Object.entries(o)) {
|
|
150
|
+
if (typeof v === "string" && /^[A-Za-z0-9_-]+$/.test(k))
|
|
151
|
+
out[k] = v;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch { /* fall through */ }
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
export function maskCredentials(creds) {
|
|
160
|
+
const out = {};
|
|
161
|
+
for (const [k, v] of Object.entries(creds))
|
|
162
|
+
out[k] = mask(v);
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
export const QUOTA = {
|
|
166
|
+
cartesia: 20000, // "20,000 free tokens" — countable tokens
|
|
167
|
+
elevenlabs: 10000, // "10k credits/mo free" — countable credits
|
|
168
|
+
tavily: 1000, // "1,000 credits/mo free" — countable credits
|
|
169
|
+
"brave-search": 2000, // "2,000 queries/mo free" — countable queries
|
|
170
|
+
serper: 2500, // "2,500 queries one-time" — countable queries
|
|
171
|
+
scrapingbee: 1000, // "1,000 free credits" — countable credits
|
|
172
|
+
resend: 3000, // "3,000/mo ... free" — countable emails/mo
|
|
173
|
+
mailgun: 5000, // "5k/3mo trial" — countable emails per trial
|
|
174
|
+
postmark: 100, // "100/mo trial" — countable emails/mo
|
|
175
|
+
upstash: 500000, // "500k cmds/mo free" — countable commands/mo
|
|
176
|
+
custom: null,
|
|
177
|
+
// Null with reason — rate-limits belong to cooldown, not QUOTA:
|
|
178
|
+
groq: null, // "30 req/min, 14.4k req/day" — rate-limit only
|
|
179
|
+
openrouter: null, // "~20 RPM / 200 RPD" — rate-limit only
|
|
180
|
+
gemini: null, // "15 RPM / 1500 RPD" — rate-limit only
|
|
181
|
+
finnhub: null, // "60/min free" — rate-limit only
|
|
182
|
+
openweather: null, // "1,000/day, 60/min" — daily/minute rate, belongs to cooldown
|
|
183
|
+
sendgrid: null, // "100/day free (60-day trial)" — daily rate + time-boxed trial
|
|
184
|
+
cloudflare: null, // "100k req/day" — daily rate, belongs to cooldown
|
|
185
|
+
huggingface: null, // "hourly limits" — rate-limit, no countable total
|
|
186
|
+
cohere: null, // "trial key, rate-limited" — no numeric total
|
|
187
|
+
// Null with reason — dollar-denominated, not comparable to token/credit/request counts:
|
|
188
|
+
fireworks: null, // "$1 credit" — dollars, not countable units
|
|
189
|
+
together: null, // "$1 credit" — dollars
|
|
190
|
+
replicate: null, // "~$5 trial, per-second GPU" — dollars + usage-based
|
|
191
|
+
openai: null, // "PAYG, $5 min prepay" — PAYG dollars
|
|
192
|
+
anthropic: null, // "$5 credit" — dollars
|
|
193
|
+
perplexity: null, // "$5 credit" — dollars
|
|
194
|
+
deepgram: null, // "$200 credit" — dollars
|
|
195
|
+
twilio: null, // "$15 credit trial" — dollars
|
|
196
|
+
// Null with reason — non-countable, vague, or mixed units:
|
|
197
|
+
mistral: null, // "trial credits, then PAYG" — no numeric total
|
|
198
|
+
anyscale: null, // "legacy, deprecated" — no quota
|
|
199
|
+
supabase: null, // "500MB DB, 5GB egress, 50k MAU" — storage/bandwidth/users, not tokens/credits/emails/queries
|
|
200
|
+
neon: null, // "0.5GB, 100 CU-hrs/mo" — storage/compute, not comparable units
|
|
201
|
+
turso: null, // "5-9GB, 500M reads/mo" — mixed storage range + reads, no single quota
|
|
202
|
+
appwrite: null, // "75k MAU, 2 projects" — users/projects, not countable consumption units
|
|
203
|
+
mapbox: null, // "100k geocode/mo, 50k loads/mo" — mixed units, no single quota
|
|
204
|
+
googlemaps: null, // "~10k/SKU/mo under $200 credit" — approximate, SKU-varying, dollar-backed
|
|
205
|
+
};
|
|
206
|
+
export const QUOTA_WINDOW = {
|
|
207
|
+
cartesia: "all", // token grant per key, no monthly reset documented
|
|
208
|
+
elevenlabs: "month",
|
|
209
|
+
tavily: "month",
|
|
210
|
+
"brave-search": "month",
|
|
211
|
+
serper: "all", // one-time grant
|
|
212
|
+
scrapingbee: "all", // one-time credits
|
|
213
|
+
resend: "month",
|
|
214
|
+
mailgun: "all", // trial grant
|
|
215
|
+
postmark: "month",
|
|
216
|
+
upstash: "month",
|
|
217
|
+
custom: "all",
|
|
218
|
+
// Null-quota providers: window is irrelevant (no limit enforced), recorded as "all".
|
|
219
|
+
groq: "all",
|
|
220
|
+
openrouter: "all",
|
|
221
|
+
gemini: "all",
|
|
222
|
+
finnhub: "all",
|
|
223
|
+
openweather: "all",
|
|
224
|
+
sendgrid: "all",
|
|
225
|
+
cloudflare: "all",
|
|
226
|
+
huggingface: "all",
|
|
227
|
+
cohere: "all",
|
|
228
|
+
fireworks: "all",
|
|
229
|
+
together: "all",
|
|
230
|
+
replicate: "all",
|
|
231
|
+
openai: "all",
|
|
232
|
+
anthropic: "all",
|
|
233
|
+
perplexity: "all",
|
|
234
|
+
deepgram: "all",
|
|
235
|
+
twilio: "all",
|
|
236
|
+
mistral: "all",
|
|
237
|
+
anyscale: "all",
|
|
238
|
+
supabase: "all",
|
|
239
|
+
neon: "all",
|
|
240
|
+
turso: "all",
|
|
241
|
+
appwrite: "all",
|
|
242
|
+
mapbox: "all",
|
|
243
|
+
googlemaps: "all",
|
|
244
|
+
};
|
|
245
|
+
export function quotaForProvider(provider) {
|
|
246
|
+
return { limit: QUOTA[provider] ?? null, window: QUOTA_WINDOW[provider] ?? "all" };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Daily caps enforced in addition to the primary quota. A key is eligible
|
|
250
|
+
* only when it is under *every* applicable limit (e.g. resend: 3000/mo AND
|
|
251
|
+
* 100/day). Primary `QUOTA` stays the legacy single-limit view; this table
|
|
252
|
+
* adds the second dimension without breaking `quotaForProvider` callers.
|
|
253
|
+
*/
|
|
254
|
+
export const QUOTA_DAILY = {
|
|
255
|
+
groq: 14400, // "14.4k req/day"
|
|
256
|
+
openrouter: 200, // "~200 RPD"
|
|
257
|
+
gemini: 1500, // "1500 RPD"
|
|
258
|
+
openweather: 1000, // "1,000/day"
|
|
259
|
+
sendgrid: 100, // "100/day"
|
|
260
|
+
cloudflare: 100000, // "100k req/day"
|
|
261
|
+
resend: 100, // "100/day" in addition to 3000/mo
|
|
262
|
+
};
|
|
263
|
+
/** All enforceable limits for a provider (primary + daily). Empty = unlimited. */
|
|
264
|
+
export function quotaLimitsForProvider(provider) {
|
|
265
|
+
const out = [];
|
|
266
|
+
const primary = quotaForProvider(provider);
|
|
267
|
+
if (primary.limit !== null)
|
|
268
|
+
out.push({ limit: primary.limit, window: primary.window });
|
|
269
|
+
const daily = QUOTA_DAILY[provider];
|
|
270
|
+
if (typeof daily === "number" && daily > 0) {
|
|
271
|
+
// Avoid duplicating when the primary limit already is the daily one.
|
|
272
|
+
if (!(primary.limit === daily && primary.window === "day"))
|
|
273
|
+
out.push({ limit: daily, window: "day" });
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
/** SQL fragment restricting joined usage rows to the quota window (UTC, matches datetime('now')). */
|
|
278
|
+
export function quotaWindowFilter(window, column = "u.created_at") {
|
|
279
|
+
if (column !== "u.created_at" && column !== "created_at")
|
|
280
|
+
throw new Error("invalid quota column");
|
|
281
|
+
if (window === "month")
|
|
282
|
+
return ` AND ${column} >= date('now','start of month')`;
|
|
283
|
+
if (window === "day")
|
|
284
|
+
return ` AND ${column} >= date('now')`;
|
|
285
|
+
return "";
|
|
286
|
+
}
|
|
287
|
+
export function getPool(poolId) {
|
|
288
|
+
return sdb.prepare("SELECT * FROM pools WHERE id = ?").get(poolId);
|
|
289
|
+
}
|
|
290
|
+
export function keyCount(poolId) {
|
|
291
|
+
return sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE pool_id = ?").get(poolId).n;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
sdb.exec(`ALTER TABLE pools ADD COLUMN base_url TEXT NOT NULL DEFAULT ''`);
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
if (!/duplicate column/i.test(e.message))
|
|
298
|
+
throw e;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
sdb.exec(`ALTER TABLE pools ADD COLUMN key_header TEXT NOT NULL DEFAULT 'X-API-Key'`);
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
if (!/duplicate column/i.test(e.message))
|
|
305
|
+
throw e;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
sdb.exec(`ALTER TABLE pools ADD COLUMN key_prefix TEXT NOT NULL DEFAULT ''`);
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
if (!/duplicate column/i.test(e.message))
|
|
312
|
+
throw e;
|
|
313
|
+
}
|
|
314
|
+
for (const col of ["cooldown_until INTEGER NOT NULL DEFAULT 0", "latency_ms REAL", "consec_fail INTEGER NOT NULL DEFAULT 0"]) {
|
|
315
|
+
try {
|
|
316
|
+
sdb.exec(`ALTER TABLE pool_keys ADD COLUMN ${col}`);
|
|
317
|
+
}
|
|
318
|
+
catch (e) {
|
|
319
|
+
if (!/duplicate column/i.test(e.message))
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
sdb.exec(`ALTER TABLE pool_keys ADD COLUMN credentials TEXT NOT NULL DEFAULT '{}'`);
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
if (!/duplicate column/i.test(e.message))
|
|
328
|
+
throw e;
|
|
329
|
+
}
|
|
330
|
+
export function firstPoolWithKeys(provider) {
|
|
331
|
+
const pools = sdb.prepare("SELECT * FROM pools WHERE provider = ? ORDER BY id").all(provider);
|
|
332
|
+
for (const p of pools) {
|
|
333
|
+
if (keyCount(p.id) > 0)
|
|
334
|
+
return p;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
export function poolSummary(poolId) {
|
|
339
|
+
const pool = getPool(poolId);
|
|
340
|
+
if (!pool)
|
|
341
|
+
return null;
|
|
342
|
+
const keys = keyCount(poolId);
|
|
343
|
+
const used = sdb.prepare("SELECT COALESCE(SUM(tokens),0) AS t FROM usage WHERE pool_id = ?").get(poolId).t;
|
|
344
|
+
const { limit: quota, window } = quotaForProvider(pool.provider);
|
|
345
|
+
const windowed = sdb.prepare(`SELECT COALESCE(SUM(tokens),0) AS t FROM usage WHERE pool_id = ?${quotaWindowFilter(window, "created_at")}`).get(poolId).t;
|
|
346
|
+
const perKey = sdb
|
|
347
|
+
.prepare(`SELECT k.id, k.label, COALESCE(SUM(u.tokens),0) AS used
|
|
348
|
+
FROM pool_keys k LEFT JOIN usage u ON u.key_id = k.id${quotaWindowFilter(window)}
|
|
349
|
+
WHERE k.pool_id = ? GROUP BY k.id ORDER BY k.id`)
|
|
350
|
+
.all(poolId);
|
|
351
|
+
// All enforceable limits (primary + daily) with pool-level windowed totals.
|
|
352
|
+
const limits = quotaLimitsForProvider(pool.provider).map(({ limit, window: w }) => {
|
|
353
|
+
const wUsed = sdb.prepare(`SELECT COALESCE(SUM(tokens),0) AS t FROM usage WHERE pool_id = ?${quotaWindowFilter(w, "created_at")}`).get(poolId).t;
|
|
354
|
+
return { limit, window: w, usedInWindow: wUsed, remaining: Math.max(0, limit * keys - wUsed) };
|
|
355
|
+
});
|
|
356
|
+
return { ...pool, keys, used, usedInWindow: windowed, quota, quotaWindow: window, remaining: quota === null ? null : Math.max(0, quota * keys - windowed), perKey, limits };
|
|
357
|
+
}
|
|
358
|
+
/** Home-page analytics: totals + last-14-day daily series + 7d/7d deltas. */
|
|
359
|
+
export function getAnalytics() {
|
|
360
|
+
const requests = sdb.prepare("SELECT COUNT(*) AS n FROM usage").get().n;
|
|
361
|
+
const tokens = sdb.prepare("SELECT COALESCE(SUM(tokens),0) AS t FROM usage").get().t;
|
|
362
|
+
// Empty pools (no keys) don't count as pools — same rule as the /pools page.
|
|
363
|
+
const pools = sdb.prepare("SELECT COUNT(*) AS n FROM pools p WHERE EXISTS (SELECT 1 FROM pool_keys k WHERE k.pool_id = p.id)").get().n;
|
|
364
|
+
const keys = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys").get().n;
|
|
365
|
+
const cooling = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE cooldown_until > ?").get(Date.now()).n;
|
|
366
|
+
const rows = sdb
|
|
367
|
+
.prepare(`SELECT date(created_at) AS day, COUNT(*) AS requests, COALESCE(SUM(tokens),0) AS tokens
|
|
368
|
+
FROM usage WHERE created_at >= date('now','-13 days') GROUP BY day`)
|
|
369
|
+
.all();
|
|
370
|
+
const keyRows = sdb
|
|
371
|
+
.prepare(`SELECT date(created_at) AS day, COUNT(*) AS n
|
|
372
|
+
FROM pool_keys WHERE created_at >= date('now','-13 days') GROUP BY day`)
|
|
373
|
+
.all();
|
|
374
|
+
const poolRows = sdb
|
|
375
|
+
.prepare(`SELECT date(p.created_at) AS day, COUNT(*) AS n
|
|
376
|
+
FROM pools p WHERE p.created_at >= date('now','-13 days')
|
|
377
|
+
AND EXISTS (SELECT 1 FROM pool_keys k WHERE k.pool_id = p.id)
|
|
378
|
+
GROUP BY day`)
|
|
379
|
+
.all();
|
|
380
|
+
const byDay = new Map(rows.map((r) => [r.day, r]));
|
|
381
|
+
const byKeyDay = new Map(keyRows.map((r) => [r.day, r.n]));
|
|
382
|
+
const byPoolDay = new Map(poolRows.map((r) => [r.day, r.n]));
|
|
383
|
+
const series = [];
|
|
384
|
+
const keysSeries = [];
|
|
385
|
+
const poolsSeries = [];
|
|
386
|
+
const today = new Date();
|
|
387
|
+
for (let i = 13; i >= 0; i--) {
|
|
388
|
+
const d = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - i));
|
|
389
|
+
const day = d.toISOString().slice(0, 10);
|
|
390
|
+
const hit = byDay.get(day);
|
|
391
|
+
series.push({ day, requests: hit?.requests ?? 0, tokens: hit?.tokens ?? 0 });
|
|
392
|
+
keysSeries.push({ day, count: byKeyDay.get(day) ?? 0 });
|
|
393
|
+
poolsSeries.push({ day, count: byPoolDay.get(day) ?? 0 });
|
|
394
|
+
}
|
|
395
|
+
const prevReq = series.slice(0, 7).reduce((a, p) => a + p.requests, 0);
|
|
396
|
+
const lastReq = series.slice(7).reduce((a, p) => a + p.requests, 0);
|
|
397
|
+
const deltaRequestsPct = prevReq === 0 ? (lastReq === 0 ? 0 : null) : ((lastReq - prevReq) / prevReq) * 100;
|
|
398
|
+
const prevTok = series.slice(0, 7).reduce((a, p) => a + p.tokens, 0);
|
|
399
|
+
const lastTok = series.slice(7).reduce((a, p) => a + p.tokens, 0);
|
|
400
|
+
const deltaTokensPct = prevTok === 0 ? (lastTok === 0 ? 0 : null) : ((lastTok - prevTok) / prevTok) * 100;
|
|
401
|
+
return {
|
|
402
|
+
requests, tokens, pools, keys, cooling,
|
|
403
|
+
avgTokens: requests === 0 ? 0 : Math.round((tokens / requests) * 10) / 10,
|
|
404
|
+
series, keysSeries, poolsSeries,
|
|
405
|
+
deltaRequestsPct, deltaTokensPct,
|
|
406
|
+
};
|
|
407
|
+
}
|