pool-anything 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/data/providers.json +1 -1
- package/dist/admin/branding.js +1 -0
- package/dist/admin/pages.js +632 -46
- package/dist/admin/seo.js +58 -0
- package/dist/admin/shell.js +45 -2
- package/dist/cli/commands.js +276 -0
- package/dist/cli/nl.js +10 -0
- package/dist/cli/repl.js +95 -331
- package/dist/cli/tui.js +254 -0
- package/dist/cli/ui.js +2 -0
- package/dist/cli.js +11 -3
- package/dist/data/providers.json +1 -1
- package/dist/playground/adapters.js +304 -0
- package/dist/pool/index.js +39 -0
- package/dist/proxy/forward.js +44 -2
- package/dist/router/api.js +2 -2
- package/dist/server.js +43 -4
- package/dist/upstream/index.js +10 -0
- package/dist/version.js +160 -0
- package/package.json +8 -3
package/dist/pool/index.js
CHANGED
|
@@ -37,6 +37,22 @@ sdb.exec(`
|
|
|
37
37
|
CREATE INDEX IF NOT EXISTS idx_usage_pool_created ON usage(pool_id, created_at);
|
|
38
38
|
CREATE INDEX IF NOT EXISTS idx_usage_key_created ON usage(key_id, created_at);
|
|
39
39
|
CREATE INDEX IF NOT EXISTS idx_pools_provider ON pools(provider);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS calls (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
pool_id INTEGER NOT NULL REFERENCES pools(id) ON DELETE CASCADE,
|
|
43
|
+
key_id INTEGER NOT NULL REFERENCES pool_keys(id) ON DELETE CASCADE,
|
|
44
|
+
method TEXT NOT NULL DEFAULT 'POST',
|
|
45
|
+
path TEXT NOT NULL DEFAULT '',
|
|
46
|
+
status INTEGER,
|
|
47
|
+
tokens INTEGER NOT NULL DEFAULT 0,
|
|
48
|
+
body TEXT NOT NULL DEFAULT '',
|
|
49
|
+
truncated INTEGER NOT NULL DEFAULT 0,
|
|
50
|
+
encoding TEXT NOT NULL DEFAULT 'text',
|
|
51
|
+
content_type TEXT NOT NULL DEFAULT '',
|
|
52
|
+
error TEXT NOT NULL DEFAULT '',
|
|
53
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_calls_pool_created ON calls(pool_id, created_at);
|
|
40
56
|
`);
|
|
41
57
|
const FALLBACK_PROVIDERS = [
|
|
42
58
|
{
|
|
@@ -108,6 +124,29 @@ export function mask(key) {
|
|
|
108
124
|
return "****";
|
|
109
125
|
return `${key.slice(0, 4)}…${key.slice(-2)}`;
|
|
110
126
|
}
|
|
127
|
+
/** Max response chars kept per call row — bounds DB growth (media relays are MBs). */
|
|
128
|
+
export const CALLS_BODY_CHARS = 4000;
|
|
129
|
+
/** Higher cap for binary media (audio/video/image base64) so generated files
|
|
130
|
+
* stay playable/renderable from History. Still bounded: ~2MB per row worst case. */
|
|
131
|
+
export const CALLS_MEDIA_CHARS = 2_000_000;
|
|
132
|
+
/** Pick the storage slice for a relay body: text stays small, binary media
|
|
133
|
+
* keeps enough bytes to stay playable/renderable. Pure (testable). */
|
|
134
|
+
export function callBodyForStorage(relayBody, encoding, truncated) {
|
|
135
|
+
const cap = encoding === "base64" ? CALLS_MEDIA_CHARS : CALLS_BODY_CHARS;
|
|
136
|
+
if (relayBody.length <= cap)
|
|
137
|
+
return { body: relayBody, truncated };
|
|
138
|
+
return { body: relayBody.slice(0, cap), truncated: true };
|
|
139
|
+
}
|
|
140
|
+
export function logCall(c) {
|
|
141
|
+
try {
|
|
142
|
+
sdb
|
|
143
|
+
.prepare("INSERT INTO calls (pool_id, key_id, method, path, status, tokens, body, truncated, encoding, content_type, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
144
|
+
.run(c.pool_id, c.key_id, c.method.slice(0, 16), c.path.slice(0, 2000), c.status, c.tokens, c.body.slice(0, CALLS_MEDIA_CHARS), c.truncated ? 1 : 0, c.encoding.slice(0, 16), c.contentType.slice(0, 256), c.error.slice(0, 500));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Call logging must never break proxying.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
111
150
|
export function providerKeyFields(provider) {
|
|
112
151
|
return PROVIDERS.find((x) => x.id === provider)?.keyFields ?? ["api_key"];
|
|
113
152
|
}
|
package/dist/proxy/forward.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sdb, getPool } from "../pool/index.js";
|
|
1
|
+
import { sdb, getPool, logCall, callBodyForStorage } from "../pool/index.js";
|
|
2
2
|
import { nextKeyRaw, markSuccess, markFailure } from "../pool/rotation.js";
|
|
3
3
|
import { resolveTarget, resolveTargetForKey, effectiveApiKey, buildUpstreamRequest, validateQueryParams } from "../upstream/index.js";
|
|
4
4
|
import { allowPrivateUpstream, proxyTimeoutMs } from "../config/env.js";
|
|
@@ -168,6 +168,25 @@ export function validateProxyOpts(opts) {
|
|
|
168
168
|
if (!q.ok)
|
|
169
169
|
return { ok: false, error: q.error };
|
|
170
170
|
}
|
|
171
|
+
if (opts.body !== undefined && opts.form !== undefined)
|
|
172
|
+
return { ok: false, error: "body and form are mutually exclusive" };
|
|
173
|
+
if (opts.form !== undefined) {
|
|
174
|
+
if (typeof opts.form !== "object" || opts.form === null || Array.isArray(opts.form))
|
|
175
|
+
return { ok: false, error: "form must be an object" };
|
|
176
|
+
const entries = Object.entries(opts.form);
|
|
177
|
+
if (entries.length > 50)
|
|
178
|
+
return { ok: false, error: "too many form fields" };
|
|
179
|
+
let bytes = 0;
|
|
180
|
+
for (const [k, v] of entries) {
|
|
181
|
+
if (typeof k !== "string" || k.length === 0 || k.length > 256 || /[\r\n\x00]/.test(k))
|
|
182
|
+
return { ok: false, error: "invalid form field name" };
|
|
183
|
+
if (typeof v !== "string" || v.length > 8000 || /[\r\n\x00]/.test(v))
|
|
184
|
+
return { ok: false, error: "invalid form field value" };
|
|
185
|
+
bytes += k.length + v.length;
|
|
186
|
+
}
|
|
187
|
+
if (bytes > MAX_PROXY_BODY_BYTES)
|
|
188
|
+
return { ok: false, error: `proxy form too large (max ${Math.round(MAX_PROXY_BODY_BYTES / 1_000_000)}MB)` };
|
|
189
|
+
}
|
|
171
190
|
if (opts.body !== undefined) {
|
|
172
191
|
try {
|
|
173
192
|
const s = JSON.stringify(opts.body);
|
|
@@ -224,6 +243,14 @@ async function readCappedBody(res, cap) {
|
|
|
224
243
|
export function timeoutMs() {
|
|
225
244
|
return proxyTimeoutMs();
|
|
226
245
|
}
|
|
246
|
+
/** POOL_DEBUG=1: per-request proxy trace on stderr (method/path/pool and
|
|
247
|
+
* upstream statuses). Never logs bodies or key material. */
|
|
248
|
+
function dlog(poolId, opts, tried, extra = "") {
|
|
249
|
+
if (process.env.POOL_DEBUG !== "1")
|
|
250
|
+
return;
|
|
251
|
+
const st = tried.map((t) => (t.status ?? "?")).join(",");
|
|
252
|
+
console.error(`[proxy] ${opts.method || "POST"} ${opts.path || "/"} pool=${poolId} tried=[${st}]${extra ? " " + extra : ""}`);
|
|
253
|
+
}
|
|
227
254
|
/**
|
|
228
255
|
* Forward one request through the pool: rotate, send, fail over on
|
|
229
256
|
* 429/5xx/network errors (key cools down, next key tried). Returns the
|
|
@@ -247,8 +274,10 @@ export async function forward(poolId, opts) {
|
|
|
247
274
|
}
|
|
248
275
|
}
|
|
249
276
|
const valid = validateProxyOpts(opts);
|
|
250
|
-
if (!valid.ok)
|
|
277
|
+
if (!valid.ok) {
|
|
278
|
+
dlog(poolId, opts, [], `invalid: ${valid.error}`);
|
|
251
279
|
return { status: 400, error: valid.error };
|
|
280
|
+
}
|
|
252
281
|
// Bound failover: one client request must not fan out to every key in a
|
|
253
282
|
// large pool (100 keys x timeout each would starve the single-thread server).
|
|
254
283
|
const keyTotal = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE pool_id = ?").get(poolId).n;
|
|
@@ -257,6 +286,7 @@ export async function forward(poolId, opts) {
|
|
|
257
286
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
258
287
|
const sel = nextKeyRaw(poolId);
|
|
259
288
|
if ("error" in sel) {
|
|
289
|
+
dlog(poolId, opts, tried, `no-key: ${sel.error}`);
|
|
260
290
|
return { status: 400, error: sel.error, tried };
|
|
261
291
|
}
|
|
262
292
|
let req;
|
|
@@ -293,13 +323,16 @@ export async function forward(poolId, opts) {
|
|
|
293
323
|
}
|
|
294
324
|
catch {
|
|
295
325
|
markFailure(sel.key_id);
|
|
326
|
+
logCall({ pool_id: poolId, key_id: sel.key_id, method: opts.method || "POST", path: opts.path || "/", status: null, tokens: 0, body: "", truncated: false, encoding: "text", contentType: "", error: "upstream network error" });
|
|
296
327
|
tried.push({ key_id: sel.key_id, label: sel.label, detail: "upstream network error" });
|
|
297
328
|
continue;
|
|
298
329
|
}
|
|
299
330
|
if (upstream.status >= 300 && upstream.status < 400) {
|
|
300
331
|
// Redirects are not followed and are not the key's fault: report without cooling.
|
|
301
332
|
await upstream.body?.cancel().catch(() => undefined);
|
|
333
|
+
logCall({ pool_id: poolId, key_id: sel.key_id, method: opts.method || "POST", path: opts.path || "/", status: upstream.status, tokens: 0, body: "", truncated: false, encoding: "text", contentType: "", error: "redirect not followed" });
|
|
302
334
|
tried.push({ key_id: sel.key_id, label: sel.label, status: upstream.status });
|
|
335
|
+
dlog(poolId, opts, tried, "redirect");
|
|
303
336
|
return { status: 400, error: `upstream redirected (HTTP ${upstream.status}, location not followed)`, tried };
|
|
304
337
|
}
|
|
305
338
|
let text = "";
|
|
@@ -315,6 +348,7 @@ export async function forward(poolId, opts) {
|
|
|
315
348
|
}
|
|
316
349
|
catch {
|
|
317
350
|
markFailure(sel.key_id);
|
|
351
|
+
logCall({ pool_id: poolId, key_id: sel.key_id, method: opts.method || "POST", path: opts.path || "/", status: upstream.status, tokens: 0, body: "", truncated: false, encoding: "text", contentType: "", error: "upstream body error" });
|
|
318
352
|
tried.push({ key_id: sel.key_id, label: sel.label, status: upstream.status, detail: "upstream body error" });
|
|
319
353
|
await upstream.body?.cancel().catch(() => undefined);
|
|
320
354
|
continue;
|
|
@@ -325,10 +359,13 @@ export async function forward(poolId, opts) {
|
|
|
325
359
|
const relayBody = b64 !== undefined ? b64.slice(0, MAX_RELAY_CHARS) : text.slice(0, MAX_RELAY_CHARS);
|
|
326
360
|
truncated = truncated || rawLen > MAX_RELAY_CHARS;
|
|
327
361
|
const encoding = b64 !== undefined ? "base64" : "text";
|
|
362
|
+
const stored = callBodyForStorage(relayBody, encoding, truncated);
|
|
363
|
+
const callBase = { pool_id: poolId, key_id: sel.key_id, method: opts.method || "POST", path: opts.path || "/", body: stored.body, truncated: stored.truncated, encoding, contentType };
|
|
328
364
|
if (upstream.status === 429 || upstream.status >= 500 || upstream.status === 401 || upstream.status === 403) {
|
|
329
365
|
// 401/403 = invalid/revoked key: cool it so rotation skips it instead of
|
|
330
366
|
// paying one dead upstream call on every subsequent request.
|
|
331
367
|
markFailure(sel.key_id);
|
|
368
|
+
logCall({ ...callBase, status: upstream.status, tokens: 0, error: "" });
|
|
332
369
|
tried.push({ key_id: sel.key_id, label: sel.label, status: upstream.status });
|
|
333
370
|
await upstream.body?.cancel().catch(() => undefined);
|
|
334
371
|
continue;
|
|
@@ -337,6 +374,8 @@ export async function forward(poolId, opts) {
|
|
|
337
374
|
// Other 4xx: client/upstream error, not the key's fault. Passthrough
|
|
338
375
|
// without cooling and without charging quota.
|
|
339
376
|
tried.push({ key_id: sel.key_id, label: sel.label, status: upstream.status });
|
|
377
|
+
logCall({ ...callBase, status: upstream.status, tokens: 0, error: "" });
|
|
378
|
+
dlog(poolId, opts, tried, "passthrough");
|
|
340
379
|
return { status: 200, key_id: sel.key_id, label: sel.label, masked: sel.masked, upstreamStatus: upstream.status, body: relayBody, encoding, contentType, truncated, tried };
|
|
341
380
|
}
|
|
342
381
|
markSuccess(sel.key_id, Date.now() - started);
|
|
@@ -344,7 +383,10 @@ export async function forward(poolId, opts) {
|
|
|
344
383
|
if (Number.isInteger(tok) && tok > 0) {
|
|
345
384
|
sdb.prepare("INSERT INTO usage (pool_id, key_id, tokens) VALUES (?, ?, ?)").run(poolId, sel.key_id, tok);
|
|
346
385
|
}
|
|
386
|
+
logCall({ ...callBase, status: upstream.status, tokens: Number.isInteger(tok) && tok > 0 ? tok : 0, error: "" });
|
|
387
|
+
dlog(poolId, opts, tried, `ok upstream=${upstream.status}`);
|
|
347
388
|
return { status: 200, key_id: sel.key_id, label: sel.label, masked: sel.masked, upstreamStatus: upstream.status, body: relayBody, encoding, contentType, truncated, tried };
|
|
348
389
|
}
|
|
390
|
+
dlog(poolId, opts, tried, "all-failed");
|
|
349
391
|
return { status: 400, error: "all keys failed or cooling down", tried };
|
|
350
392
|
}
|
package/dist/router/api.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
export const MAX_POOLS = 1000;
|
|
3
3
|
export const MAX_KEYS_PER_POOL = 100;
|
|
4
4
|
export const MAX_API_KEY_CHARS = 4096;
|
|
5
|
-
/** Match /api/pools/:id[/next|/consume|/usage|/proxy] (never /keys). */
|
|
5
|
+
/** Match /api/pools/:id[/next|/consume|/usage|/proxy|/calls] (never /keys). */
|
|
6
6
|
export function matchPoolRoute(pathname) {
|
|
7
7
|
if (pathname.includes("/keys"))
|
|
8
8
|
return null;
|
|
9
|
-
const m = pathname.match(/^\/api\/pools\/(\d+)(\/next|\/consume|\/usage|\/proxy)?$/);
|
|
9
|
+
const m = pathname.match(/^\/api\/pools\/(\d+)(\/next|\/consume|\/usage|\/proxy|\/calls)?$/);
|
|
10
10
|
if (!m)
|
|
11
11
|
return null;
|
|
12
12
|
return { poolId: Number(m[1]), sub: (m[2] ?? "") };
|
package/dist/server.js
CHANGED
|
@@ -10,9 +10,11 @@ import { sdb, PROVIDERS, mask, maskCredentials, normalizeCredentials, parseCrede
|
|
|
10
10
|
import { nextKey, recordUsage } from "./pool/rotation.js";
|
|
11
11
|
import { forward, MAX_TOKENS_PER_REQUEST, checkTarget } from "./proxy/forward.js";
|
|
12
12
|
import { DOCS_URL } from "./admin/branding.js";
|
|
13
|
-
import {
|
|
13
|
+
import { robotsTxt, sitemapXml } from "./admin/seo.js";
|
|
14
|
+
import { page, keysPage, poolsPage, analyticsPage, historyPage, playgroundPage, providerPage } from "./admin/pages.js";
|
|
14
15
|
import { MAX_POOLS, MAX_KEYS_PER_POOL, MAX_API_KEY_CHARS, matchPoolRoute, matchKeyRoute } from "./router/api.js";
|
|
15
16
|
import { health, readiness, analyticsSnapshot } from "./observability/health.js";
|
|
17
|
+
import { getVersionInfo, performUpdate } from "./version.js";
|
|
16
18
|
const server = http.createServer(async (req, res) => {
|
|
17
19
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
18
20
|
// CORS: only the hosted Pages tools UI (plus http loopback dev and
|
|
@@ -62,6 +64,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
62
64
|
send(res, 200, PROVIDERS);
|
|
63
65
|
return;
|
|
64
66
|
}
|
|
67
|
+
if (req.method === "GET" && url.pathname === "/api/version") {
|
|
68
|
+
send(res, 200, await getVersionInfo());
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (req.method === "POST" && url.pathname === "/api/update") {
|
|
72
|
+
const r = await performUpdate();
|
|
73
|
+
send(res, r.ok ? 200 : 500, r);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
65
76
|
if (req.method === "GET" && url.pathname === "/api/analytics") {
|
|
66
77
|
const r = analyticsSnapshot();
|
|
67
78
|
send(res, r.status, r.body);
|
|
@@ -120,6 +131,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
120
131
|
if (req.method === "DELETE" && !sub) {
|
|
121
132
|
sdb.exec("BEGIN IMMEDIATE");
|
|
122
133
|
try {
|
|
134
|
+
sdb.prepare("DELETE FROM calls WHERE pool_id = ?").run(poolId);
|
|
123
135
|
sdb.prepare("DELETE FROM usage WHERE pool_id = ?").run(poolId);
|
|
124
136
|
sdb.prepare("DELETE FROM pool_keys WHERE pool_id = ?").run(poolId);
|
|
125
137
|
const r = sdb.prepare("DELETE FROM pools WHERE id = ?").run(poolId);
|
|
@@ -168,6 +180,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
168
180
|
send(res, 200, { pool_id: s.id, used: s.used, usedInWindow: s.usedInWindow, quota: s.quota, quotaWindow: s.quotaWindow, remaining: s.remaining, perKey: s.perKey });
|
|
169
181
|
return;
|
|
170
182
|
}
|
|
183
|
+
if (req.method === "GET" && sub === "/calls") {
|
|
184
|
+
const pool = getPool(poolId);
|
|
185
|
+
if (!pool)
|
|
186
|
+
return send(res, 404, { error: "pool not found" });
|
|
187
|
+
const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 50, 1), 200);
|
|
188
|
+
send(res, 200, sdb.prepare("SELECT id, pool_id, key_id, method, path, status, tokens, body, truncated, encoding, content_type, error, created_at FROM calls WHERE pool_id = ? ORDER BY id DESC LIMIT ?").all(poolId, limit));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
171
191
|
if (req.method === "POST" && sub === "/proxy") {
|
|
172
192
|
// Media-aware limit: base64 image/audio/video payloads ride inside JSON.
|
|
173
193
|
const b = (await readJson(req, MAX_PROXY_BODY_BYTES));
|
|
@@ -189,13 +209,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
189
209
|
if (!pool)
|
|
190
210
|
return send(res, 404, { error: "pool not found" });
|
|
191
211
|
const fields = providerKeyFields(pool.provider);
|
|
212
|
+
const now = Date.now();
|
|
192
213
|
const rows = sdb
|
|
193
|
-
.prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at FROM pool_keys WHERE pool_id = ? ORDER BY id")
|
|
214
|
+
.prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at, cooldown_until FROM pool_keys WHERE pool_id = ? ORDER BY id")
|
|
194
215
|
.all(poolId);
|
|
195
216
|
send(res, 200, rows.map((k) => {
|
|
196
217
|
const creds = parseCredentials(k.credentials);
|
|
197
218
|
return {
|
|
198
219
|
id: k.id, label: k.label, masked: mask(k.api_key), info: k.info, created_at: k.created_at,
|
|
220
|
+
cooling: k.cooldown_until > now,
|
|
199
221
|
keyFields: fields,
|
|
200
222
|
credentials: Object.keys(creds).length > 0 ? maskCredentials(creds) : undefined,
|
|
201
223
|
};
|
|
@@ -268,12 +290,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
268
290
|
}
|
|
269
291
|
if (req.method === "GET" && keyId !== undefined) {
|
|
270
292
|
const row = sdb
|
|
271
|
-
.prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at FROM pool_keys WHERE id = ? AND pool_id = ?")
|
|
293
|
+
.prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at, cooldown_until FROM pool_keys WHERE id = ? AND pool_id = ?")
|
|
272
294
|
.get(Number(keyId), poolId);
|
|
273
295
|
if (!row)
|
|
274
296
|
return send(res, 404, { error: "key not found" });
|
|
275
297
|
const creds = parseCredentials(row.credentials);
|
|
276
|
-
send(res, 200, { ...row, credentials: Object.keys(creds).length > 0 ? creds : undefined, masked: mask(row.api_key), maskedCredentials: Object.keys(creds).length > 0 ? maskCredentials(creds) : undefined });
|
|
298
|
+
send(res, 200, { ...row, cooling: row.cooldown_until > Date.now(), credentials: Object.keys(creds).length > 0 ? creds : undefined, masked: mask(row.api_key), maskedCredentials: Object.keys(creds).length > 0 ? maskCredentials(creds) : undefined });
|
|
277
299
|
return;
|
|
278
300
|
}
|
|
279
301
|
if (req.method === "PATCH" && keyId !== undefined) {
|
|
@@ -326,6 +348,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
326
348
|
return;
|
|
327
349
|
}
|
|
328
350
|
if (req.method === "DELETE" && keyId !== undefined) {
|
|
351
|
+
sdb.prepare("DELETE FROM calls WHERE key_id = ?").run(Number(keyId));
|
|
329
352
|
sdb.prepare("DELETE FROM usage WHERE key_id = ?").run(Number(keyId));
|
|
330
353
|
const r = sdb.prepare("DELETE FROM pool_keys WHERE id = ? AND pool_id = ?").run(Number(keyId), poolId);
|
|
331
354
|
if (r.changes === 0)
|
|
@@ -370,6 +393,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
370
393
|
res.end(analyticsPage());
|
|
371
394
|
return;
|
|
372
395
|
}
|
|
396
|
+
if (req.method === "GET" && url.pathname === "/history") {
|
|
397
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
398
|
+
res.end(historyPage());
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
373
401
|
if (req.method === "GET" && url.pathname.startsWith("/provider/")) {
|
|
374
402
|
const pid = url.pathname.slice("/provider/".length);
|
|
375
403
|
if (!/^[a-z0-9-]+$/.test(pid)) {
|
|
@@ -402,6 +430,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
402
430
|
res.end(h.body);
|
|
403
431
|
return;
|
|
404
432
|
}
|
|
433
|
+
if (req.method === "GET" && url.pathname === "/robots.txt") {
|
|
434
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=3600" });
|
|
435
|
+
res.end(robotsTxt());
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (req.method === "GET" && url.pathname === "/sitemap.xml") {
|
|
439
|
+
const ids = PROVIDERS.map((p) => p.id).filter((id) => typeof id === "string");
|
|
440
|
+
res.writeHead(200, { "content-type": "application/xml; charset=utf-8", "cache-control": "public, max-age=3600" });
|
|
441
|
+
res.end(sitemapXml(ids));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
405
444
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
406
445
|
res.end("not found\n");
|
|
407
446
|
}
|
package/dist/upstream/index.js
CHANGED
|
@@ -133,5 +133,15 @@ export function buildUpstreamRequest(target, apiKey, opts) {
|
|
|
133
133
|
headers[target.keyHeader] = target.keyPrefix + apiKey;
|
|
134
134
|
}
|
|
135
135
|
const base = target.baseUrl.endsWith("/") ? target.baseUrl.slice(0, -1) : target.baseUrl;
|
|
136
|
+
if (opts.form !== undefined) {
|
|
137
|
+
// Form-only upstreams: urlencode fields and override the JSON content type.
|
|
138
|
+
// Caller content-type is dropped so the header can't lie about the body.
|
|
139
|
+
for (const k of Object.keys(headers)) {
|
|
140
|
+
if (k.toLowerCase() === "content-type")
|
|
141
|
+
delete headers[k];
|
|
142
|
+
}
|
|
143
|
+
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
144
|
+
return { url: base + p, headers, body: new URLSearchParams(opts.form).toString() };
|
|
145
|
+
}
|
|
136
146
|
return { url: base + p, headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body) };
|
|
137
147
|
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { PACKAGE_ROOT } from "./config/paths.js";
|
|
5
|
+
export const PACKAGE_NAME = "pool-anything";
|
|
6
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
7
|
+
const LATEST_TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
export function readCurrentVersion() {
|
|
9
|
+
try {
|
|
10
|
+
const raw = fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8");
|
|
11
|
+
const pkg = JSON.parse(raw);
|
|
12
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return "0.0.0";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function npmCmd() {
|
|
19
|
+
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
20
|
+
}
|
|
21
|
+
/** Numeric semver compare: >0 when a is newer, 0 when equal, <0 when older. */
|
|
22
|
+
export function compareVersions(a, b) {
|
|
23
|
+
const norm = (v) => v
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/^v/i, "")
|
|
26
|
+
.split("-")[0]
|
|
27
|
+
.split(".")
|
|
28
|
+
.map((p) => {
|
|
29
|
+
const n = Number(p);
|
|
30
|
+
return Number.isFinite(n) ? n : 0;
|
|
31
|
+
});
|
|
32
|
+
const pa = norm(a);
|
|
33
|
+
const pb = norm(b);
|
|
34
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
35
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
36
|
+
if (d !== 0)
|
|
37
|
+
return d;
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
export async function fetchLatestVersion(timeoutMs = 6000) {
|
|
42
|
+
try {
|
|
43
|
+
const ctrl = new AbortController();
|
|
44
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
45
|
+
try {
|
|
46
|
+
const r = await fetch(REGISTRY_URL, { signal: ctrl.signal });
|
|
47
|
+
if (!r.ok)
|
|
48
|
+
return null;
|
|
49
|
+
const j = (await r.json());
|
|
50
|
+
return typeof j.version === "string" ? j.version : null;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(t);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
let cachedLatest = null;
|
|
61
|
+
export async function getVersionInfo() {
|
|
62
|
+
const current = readCurrentVersion();
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
if (cachedLatest && now - cachedLatest.at < LATEST_TTL_MS) {
|
|
65
|
+
const latest = cachedLatest.version;
|
|
66
|
+
return {
|
|
67
|
+
current,
|
|
68
|
+
latest,
|
|
69
|
+
updateAvailable: latest !== null && compareVersions(latest, current) > 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const latest = await fetchLatestVersion();
|
|
73
|
+
cachedLatest = { version: latest, at: now };
|
|
74
|
+
return {
|
|
75
|
+
current,
|
|
76
|
+
latest,
|
|
77
|
+
updateAvailable: latest !== null && compareVersions(latest, current) > 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function runNpm(args) {
|
|
81
|
+
try {
|
|
82
|
+
const r = spawnSync(npmCmd(), args, { encoding: "utf8", timeout: 120_000 });
|
|
83
|
+
const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
|
|
84
|
+
const err = r.error;
|
|
85
|
+
if (err)
|
|
86
|
+
return { ok: false, output: `npm ${args.join(" ")} failed: ${err.message}` };
|
|
87
|
+
return {
|
|
88
|
+
ok: r.status === 0,
|
|
89
|
+
output: out.slice(-2000) || (r.status === 0 ? "ok" : `npm exited with code ${r.status}`),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
return { ok: false, output: `npm ${args.join(" ")} failed: ${e.message}` };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function performUpdate() {
|
|
97
|
+
const previous = readCurrentVersion();
|
|
98
|
+
const latest = await fetchLatestVersion();
|
|
99
|
+
if (latest !== null && compareVersions(latest, previous) <= 0) {
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
previous,
|
|
103
|
+
current: previous,
|
|
104
|
+
latest,
|
|
105
|
+
updated: false,
|
|
106
|
+
text: `Already on latest v${previous}.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const r = runNpm(["install", "-g", `${PACKAGE_NAME}@latest`]);
|
|
110
|
+
if (!r.ok) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
previous,
|
|
114
|
+
current: previous,
|
|
115
|
+
latest,
|
|
116
|
+
updated: false,
|
|
117
|
+
text: `Update failed: ${r.output}`,
|
|
118
|
+
output: r.output,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Bust the cached latest — the registry state is now what we run (or will
|
|
122
|
+
// run after restart).
|
|
123
|
+
cachedLatest = { version: latest, at: Date.now() };
|
|
124
|
+
const current = readCurrentVersion();
|
|
125
|
+
const target = latest ?? (current !== previous ? current : null);
|
|
126
|
+
if (target !== null && current === target && current !== previous) {
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
previous,
|
|
130
|
+
current,
|
|
131
|
+
latest,
|
|
132
|
+
updated: true,
|
|
133
|
+
text: `Updated v${previous} → v${current}. Restart the shell/server to use it.`,
|
|
134
|
+
output: r.output,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Source checkout (npm -g updates the global copy, not this tree) or a
|
|
138
|
+
// registry check that failed: the install succeeded, report what we know.
|
|
139
|
+
const suffix = target !== null ? ` (latest v${target}). Restart to use it.` : `. Restart to use it.`;
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
previous,
|
|
143
|
+
current,
|
|
144
|
+
latest,
|
|
145
|
+
updated: true,
|
|
146
|
+
text: `Update installed${latest !== null ? ` v${previous} → v${latest}` : ""}${suffix} ${r.output}`.trim(),
|
|
147
|
+
output: r.output,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export function performUninstall() {
|
|
151
|
+
const r = runNpm(["uninstall", "-g", PACKAGE_NAME]);
|
|
152
|
+
if (!r.ok) {
|
|
153
|
+
return { ok: false, text: `Uninstall failed: ${r.output}`, output: r.output };
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
text: `Uninstalled ${PACKAGE_NAME}. Your pools/keys in SQLite are untouched (remove ~/.pool-anything to wipe data).`,
|
|
158
|
+
output: r.output,
|
|
159
|
+
};
|
|
160
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pool-anything",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Gather free-tier API keys from any provider into one pool and rotate through them — round-robin key selection, usage/quota tracking, and a drop-in HTTP proxy with a built-in web UI.
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Gather free-tier API keys from any provider into one pool and rotate through them — round-robin key selection, usage/quota tracking, and a drop-in HTTP proxy with a built-in web UI. Dependency-free server core, TypeScript + SQLite.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api-key-pool",
|
|
7
7
|
"key-rotation",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"quota",
|
|
16
16
|
"sqlite",
|
|
17
17
|
"typescript",
|
|
18
|
-
"
|
|
18
|
+
"ink-tui",
|
|
19
19
|
"groq",
|
|
20
20
|
"openrouter",
|
|
21
21
|
"gemini",
|
|
@@ -62,7 +62,12 @@
|
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@types/node": "^22.7.4",
|
|
65
|
+
"@types/react": "^19.3.0",
|
|
65
66
|
"tsx": "^4.19.1",
|
|
66
67
|
"typescript": "^5.6.2"
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"ink": "^7.1.1",
|
|
71
|
+
"react": "^19.3.0"
|
|
67
72
|
}
|
|
68
73
|
}
|