proxy / api /lib /stats.js
OpenCode
feat: add Cloudflare Workers AI fallback for kimi models
3f8984a
Raw
History Blame Contribute Delete
6.15 kB
const fs = require("fs");
const path = require("path");
const STATS_FILE = "/tmp/kimchi-stats.json";
const MAX_RECENT = 500;
const MAX_ERRORS = 500;
const MAX_LOGS = 500;
const EST_COST_PER_1K_INPUT = 0.0006;
const EST_COST_PER_1K_OUTPUT = 0.0024;
let _stats = null;
function load() {
if (_stats) return _stats;
try {
if (fs.existsSync(STATS_FILE)) {
const raw = fs.readFileSync(STATS_FILE, "utf-8");
_stats = JSON.parse(raw);
_stats.keys = _stats.keys || { exhausted: [], throttled: [], errors: {} };
_stats.keys.exhausted = new Set(_stats.keys.exhausted);
_stats.keys.throttled = new Set(_stats.keys.throttled);
_stats.keys.errors = new Map(Object.entries(_stats.keys.errors || {}));
return _stats;
}
} catch {}
_stats = createFreshStats();
return _stats;
}
function createFreshStats() {
return {
totalRequests: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalErrors: 0,
recentRequests: [],
errors: [],
logs: [],
keys: {
exhausted: new Set(),
throttled: new Set(),
errors: new Map(),
},
};
}
function save() {
if (!_stats) return;
try {
const out = {
..._stats,
keys: {
exhausted: [..._stats.keys.exhausted],
throttled: [..._stats.keys.throttled],
errors: Object.fromEntries(_stats.keys.errors),
},
};
fs.writeFileSync(STATS_FILE, JSON.stringify(out), "utf-8");
} catch {}
}
function markKeyExhausted(index) {
const s = load();
s.keys.exhausted.add(index);
save();
}
function markKeyThrottled(index) {
const s = load();
s.keys.throttled.add(index);
save();
}
function unmarkKeyThrottled(index) {
const s = load();
s.keys.throttled.delete(index);
save();
}
function recordKeyError(index, error) {
const s = load();
const key = `key_${index}`;
const prev = s.keys.errors.get(key) || { count: 0, lastError: "", lastTime: 0 };
s.keys.errors.set(key, {
count: prev.count + 1,
lastError: error,
lastTime: Date.now(),
});
save();
}
function logRequest(data) {
const s = load();
s.totalRequests++;
s.totalInputTokens += data.inputTokens || 0;
s.totalOutputTokens += data.outputTokens || 0;
if (data.status >= 400) {
s.totalErrors++;
}
const entry = {
id: s.totalRequests,
model: data.model || "unknown",
provider: data.provider || "kimchi",
inputTokens: data.inputTokens || 0,
outputTokens: data.outputTokens || 0,
keyIndex: data.keyIndex ?? 0,
status: data.status || 200,
elapsed: data.elapsed || 0,
error: data.error || null,
timestamp: Date.now(),
};
s.recentRequests.unshift(entry);
if (s.recentRequests.length > MAX_RECENT) {
s.recentRequests = s.recentRequests.slice(0, MAX_RECENT);
}
if (data.status >= 400 || data.error) {
s.errors.unshift({
id: s.errors.length + 1,
request_id: s.totalRequests,
model: data.model,
status: data.status,
keyIndex: data.keyIndex,
error: data.error || `HTTP ${data.status}`,
details: data.details || null,
timestamp: Date.now(),
});
if (s.errors.length > MAX_ERRORS) {
s.errors = s.errors.slice(0, MAX_ERRORS);
}
}
s.logs.unshift({
id: s.logs.length + 1,
level: data.status >= 400 ? "error" : "info",
message: `${data.model} (${data.provider || "kimchi"}) in:${data.inputTokens || 0} out:${data.outputTokens || 0} ${data.elapsed}ms key:${data.keyIndex} status:${data.status}${data.error ? " err:" + data.error : ""}`,
timestamp: Date.now(),
});
if (s.logs.length > MAX_LOGS) {
s.logs = s.logs.slice(0, MAX_LOGS);
}
save();
return entry;
}
function addLog(entry) {
const s = load();
s.logs.unshift({ ...entry, id: s.logs.length + 1 });
if (s.logs.length > MAX_LOGS) {
s.logs = s.logs.slice(0, MAX_LOGS);
}
save();
}
function filterByRange(arr, range) {
if (range === "all") return arr;
const now = Date.now();
let since = 0;
if (range === "today") {
const d = new Date();
d.setHours(0, 0, 0, 0);
since = d.getTime();
} else if (range === "week") {
since = now - 7 * 24 * 60 * 60 * 1000;
} else if (range === "month") {
since = now - 30 * 24 * 60 * 60 * 1000;
}
return arr.filter((e) => e.timestamp >= since);
}
function getStats(range) {
const s = load();
const filtered = filterByRange(s.recentRequests, range);
const filteredErrors = filterByRange(s.errors, range);
let totalIn = 0;
let totalOut = 0;
let totalReqs = 0;
let totalErrs = 0;
const providerStats = {};
for (const r of filtered) {
totalReqs++;
totalIn += r.inputTokens || 0;
totalOut += r.outputTokens || 0;
if (r.status >= 400) totalErrs++;
const provider = r.provider || "kimchi";
if (!providerStats[provider]) {
providerStats[provider] = { requests: 0, inputTokens: 0, outputTokens: 0, errors: 0 };
}
providerStats[provider].requests++;
providerStats[provider].inputTokens += r.inputTokens || 0;
providerStats[provider].outputTokens += r.outputTokens || 0;
if (r.status >= 400) providerStats[provider].errors++;
}
const cost = (totalIn / 1000) * EST_COST_PER_1K_INPUT + (totalOut / 1000) * EST_COST_PER_1K_OUTPUT;
const keyErrors = {};
s.keys.errors.forEach((val, key) => {
keyErrors[key] = val;
});
const keysRaw = process.env.KIMCHI_API_KEYS || "";
const totalKeys = keysRaw ? keysRaw.split(/[,\s]+/).filter(Boolean).length : 0;
return {
range,
totalRequests: totalReqs,
totalInputTokens: totalIn,
totalOutputTokens: totalOut,
totalErrors: totalErrs,
estimatedCost: cost,
providers: providerStats,
keys: {
total: totalKeys,
active: totalKeys - s.keys.exhausted.size,
exhausted: s.keys.exhausted.size,
throttled: s.keys.throttled.size,
errors: keyErrors,
},
recentRequests: filtered.slice(0, 100),
errors: filteredErrors.slice(0, 50),
logs: s.logs.slice(0, 150),
};
}
module.exports = {
logRequest,
addLog,
getStats,
markKeyExhausted,
markKeyThrottled,
unmarkKeyThrottled,
recordKeyError,
};