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.
Files changed (111) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +280 -0
  3. package/bin/pool-anything.js +4 -0
  4. package/data/providers.json +38 -0
  5. package/dist/admin/branding.js +34 -0
  6. package/dist/admin/components.js +189 -0
  7. package/dist/admin/pages.js +1891 -0
  8. package/dist/admin/shell.js +129 -0
  9. package/dist/admin/tokens.js +32 -0
  10. package/dist/cli/nl.js +147 -0
  11. package/dist/cli/repl.js +470 -0
  12. package/dist/cli/ui.js +81 -0
  13. package/dist/cli.js +199 -0
  14. package/dist/common/http.js +55 -0
  15. package/dist/config/env.js +94 -0
  16. package/dist/config/paths.js +112 -0
  17. package/dist/data/providers.json +38 -0
  18. package/dist/db/index.js +20 -0
  19. package/dist/db/init.js +11 -0
  20. package/dist/logo.png +0 -0
  21. package/dist/media/capabilities.js +103 -0
  22. package/dist/middleware/auth.js +37 -0
  23. package/dist/observability/health.js +31 -0
  24. package/dist/pool/index.js +407 -0
  25. package/dist/pool/rotation.js +123 -0
  26. package/dist/proxy/dns.js +39 -0
  27. package/dist/proxy/forward.js +350 -0
  28. package/dist/public/logos/anthropic.svg +1 -0
  29. package/dist/public/logos/anyscale.svg +1 -0
  30. package/dist/public/logos/appwrite.svg +1 -0
  31. package/dist/public/logos/brave.svg +1 -0
  32. package/dist/public/logos/cartesia.svg +4 -0
  33. package/dist/public/logos/claude.svg +1 -0
  34. package/dist/public/logos/cloudflare.svg +1 -0
  35. package/dist/public/logos/cohere-command.svg +1 -0
  36. package/dist/public/logos/cohere.svg +1 -0
  37. package/dist/public/logos/deepgram.svg +1 -0
  38. package/dist/public/logos/elevenlabs.svg +1 -0
  39. package/dist/public/logos/finnhub.png +0 -0
  40. package/dist/public/logos/fireworks.svg +1 -0
  41. package/dist/public/logos/gemini.svg +1 -0
  42. package/dist/public/logos/google.svg +1 -0
  43. package/dist/public/logos/googlemaps.svg +1 -0
  44. package/dist/public/logos/groq.svg +1 -0
  45. package/dist/public/logos/huggingface.svg +1 -0
  46. package/dist/public/logos/mailgun.svg +1 -0
  47. package/dist/public/logos/manifest.json +197 -0
  48. package/dist/public/logos/mapbox.svg +1 -0
  49. package/dist/public/logos/mistral.svg +1 -0
  50. package/dist/public/logos/neon.svg +1 -0
  51. package/dist/public/logos/openai.svg +1 -0
  52. package/dist/public/logos/openrouter.svg +1 -0
  53. package/dist/public/logos/openweather.png +0 -0
  54. package/dist/public/logos/perplexity.svg +1 -0
  55. package/dist/public/logos/pool-anything.svg +111 -0
  56. package/dist/public/logos/postmark.svg +4 -0
  57. package/dist/public/logos/replicate.svg +1 -0
  58. package/dist/public/logos/resend.svg +1 -0
  59. package/dist/public/logos/scrapingbee.svg +72 -0
  60. package/dist/public/logos/sendgrid.svg +1 -0
  61. package/dist/public/logos/serper.png +0 -0
  62. package/dist/public/logos/supabase.svg +1 -0
  63. package/dist/public/logos/tavily.svg +1 -0
  64. package/dist/public/logos/together.svg +1 -0
  65. package/dist/public/logos/turso.svg +1 -0
  66. package/dist/public/logos/twilio.svg +1 -0
  67. package/dist/public/logos/upstash.svg +1 -0
  68. package/dist/router/api.js +20 -0
  69. package/dist/server.js +431 -0
  70. package/dist/upstream/index.js +137 -0
  71. package/package.json +68 -0
  72. package/public/logos/anthropic.svg +1 -0
  73. package/public/logos/anyscale.svg +1 -0
  74. package/public/logos/appwrite.svg +1 -0
  75. package/public/logos/brave.svg +1 -0
  76. package/public/logos/cartesia.svg +4 -0
  77. package/public/logos/claude.svg +1 -0
  78. package/public/logos/cloudflare.svg +1 -0
  79. package/public/logos/cohere-command.svg +1 -0
  80. package/public/logos/cohere.svg +1 -0
  81. package/public/logos/deepgram.svg +1 -0
  82. package/public/logos/elevenlabs.svg +1 -0
  83. package/public/logos/finnhub.png +0 -0
  84. package/public/logos/fireworks.svg +1 -0
  85. package/public/logos/gemini.svg +1 -0
  86. package/public/logos/google.svg +1 -0
  87. package/public/logos/googlemaps.svg +1 -0
  88. package/public/logos/groq.svg +1 -0
  89. package/public/logos/huggingface.svg +1 -0
  90. package/public/logos/mailgun.svg +1 -0
  91. package/public/logos/manifest.json +197 -0
  92. package/public/logos/mapbox.svg +1 -0
  93. package/public/logos/mistral.svg +1 -0
  94. package/public/logos/neon.svg +1 -0
  95. package/public/logos/openai.svg +1 -0
  96. package/public/logos/openrouter.svg +1 -0
  97. package/public/logos/openweather.png +0 -0
  98. package/public/logos/perplexity.svg +1 -0
  99. package/public/logos/pool-anything.svg +111 -0
  100. package/public/logos/postmark.svg +4 -0
  101. package/public/logos/replicate.svg +1 -0
  102. package/public/logos/resend.svg +1 -0
  103. package/public/logos/scrapingbee.svg +72 -0
  104. package/public/logos/sendgrid.svg +1 -0
  105. package/public/logos/serper.png +0 -0
  106. package/public/logos/supabase.svg +1 -0
  107. package/public/logos/tavily.svg +1 -0
  108. package/public/logos/together.svg +1 -0
  109. package/public/logos/turso.svg +1 -0
  110. package/public/logos/twilio.svg +1 -0
  111. 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
+ }