mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 13:35:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91e1f0d18e |
+2
-21
@@ -55,13 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="flex" style="justify-content: space-between; align-items: center;">
|
<h2>Usage by Key</h2>
|
||||||
<h2 style="margin: 0;">Usage by Key</h2>
|
|
||||||
<label id="usage-scope-toggle" class="flex" style="display:none; gap: 0.4rem; font-size: 0.8rem; color: #94a3b8; cursor: pointer;">
|
|
||||||
<input type="checkbox" id="usage-show-all" style="cursor: pointer;">
|
|
||||||
<span>Show all keys</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<table id="key-usage-table">
|
<table id="key-usage-table">
|
||||||
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
@@ -176,8 +170,7 @@ async function refreshStatus() {
|
|||||||
|
|
||||||
async function refreshUsage() {
|
async function refreshUsage() {
|
||||||
try {
|
try {
|
||||||
const showAll = localStorage.getItem("ocp_usage_show_all") === "1";
|
const data = await api("/api/usage");
|
||||||
const data = await api(showAll ? "/api/usage?all=true" : "/api/usage");
|
|
||||||
const tbody = document.querySelector("#key-usage-table tbody");
|
const tbody = document.querySelector("#key-usage-table tbody");
|
||||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -211,8 +204,6 @@ async function refreshKeys() {
|
|||||||
try {
|
try {
|
||||||
const data = await api("/api/keys");
|
const data = await api("/api/keys");
|
||||||
document.getElementById("key-mgmt-section").style.display = "";
|
document.getElementById("key-mgmt-section").style.display = "";
|
||||||
// Admin-only "Show all keys" toggle for /api/usage scope.
|
|
||||||
document.getElementById("usage-scope-toggle").style.display = "flex";
|
|
||||||
const tbody = document.querySelector("#keys-table tbody");
|
const tbody = document.querySelector("#keys-table tbody");
|
||||||
tbody.innerHTML = (data.keys || []).map(k => `
|
tbody.innerHTML = (data.keys || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -249,16 +240,6 @@ async function refreshAll() {
|
|||||||
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire "Show all keys" toggle (visibility gated to admin via refreshKeys()).
|
|
||||||
(function setupUsageScopeToggle() {
|
|
||||||
const cb = document.getElementById("usage-show-all");
|
|
||||||
cb.checked = localStorage.getItem("ocp_usage_show_all") === "1";
|
|
||||||
cb.addEventListener("change", () => {
|
|
||||||
localStorage.setItem("ocp_usage_show_all", cb.checked ? "1" : "0");
|
|
||||||
refreshUsage();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
refreshAll();
|
refreshAll();
|
||||||
setInterval(refreshAll, 30000);
|
setInterval(refreshAll, 30000);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+44
-57
@@ -202,25 +202,36 @@ const MODEL_MAP = Object.fromEntries([
|
|||||||
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
||||||
|
|
||||||
// ── Session management ──────────────────────────────────────────────────
|
// ── Session management ──────────────────────────────────────────────────
|
||||||
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
|
// Maps namespaced session keys to Claude CLI session UUIDs.
|
||||||
|
// Key format: "${keyName}|${conversationId}" — prevents cross-key collision
|
||||||
|
// when two callers (different API keys or anon + authenticated) use the same
|
||||||
|
// session_id string. Anonymous callers use "anon"; admin uses "admin".
|
||||||
// Enables --resume for multi-turn conversations, reducing token waste.
|
// Enables --resume for multi-turn conversations, reducing token waste.
|
||||||
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
|
const sessions = new Map(); // `${keyName}|${conversationId}` → { uuid, messageCount, lastUsed, model }
|
||||||
|
|
||||||
|
// Build the namespaced key used for all sessions Map operations.
|
||||||
|
// Returns null when conversationId is falsy (one-off requests bypass session tracking).
|
||||||
|
function _sessionKey(conversationId, keyName) {
|
||||||
|
return conversationId ? `${keyName || "anon"}|${conversationId}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionCleanupInterval = setInterval(() => {
|
const sessionCleanupInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
const idleMs = now - s.lastUsed;
|
const idleMs = now - s.lastUsed;
|
||||||
const ageMs = s.firstSeen ? now - s.firstSeen : null;
|
const ageMs = s.firstSeen ? now - s.firstSeen : null;
|
||||||
|
// id is "${keyName}|${conversationId}"; strip prefix for log output
|
||||||
|
const convIdShort = id.includes("|") ? id.slice(id.indexOf("|") + 1, id.indexOf("|") + 13) : id.slice(0, 12);
|
||||||
if (idleMs > SESSION_TTL) {
|
if (idleMs > SESSION_TTL) {
|
||||||
sessions.delete(id);
|
sessions.delete(id);
|
||||||
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round(idleMs / 60000)}m)`);
|
console.log(`[session] expired ${convIdShort}... (idle ${Math.round(idleMs / 60000)}m)`);
|
||||||
logEvent("info", "session_expired", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
|
logEvent("info", "session_expired", { conversationId: convIdShort + "...", idleMs, ageMs });
|
||||||
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
|
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
|
||||||
// #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old
|
// #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old
|
||||||
// but whose lastUsed keeps getting bumped (never idle long enough to expire)
|
// but whose lastUsed keeps getting bumped (never idle long enough to expire)
|
||||||
// is the suspected bug. Log without action so the pattern can be confirmed
|
// is the suspected bug. Log without action so the pattern can be confirmed
|
||||||
// in /logs. Do NOT enforce an absolute age cap here speculatively.
|
// in /logs. Do NOT enforce an absolute age cap here speculatively.
|
||||||
logEvent("warn", "session_long_lived", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
|
logEvent("warn", "session_long_lived", { conversationId: convIdShort + "...", idleMs, ageMs });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
@@ -427,7 +438,7 @@ function getModelTier(cliModel) {
|
|||||||
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
||||||
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
||||||
// timeouts. Returns context object or throws synchronously.
|
// timeouts. Returns context object or throws synchronously.
|
||||||
function spawnClaudeProcess(model, messages, conversationId) {
|
function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||||
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
||||||
}
|
}
|
||||||
@@ -443,8 +454,11 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
let prompt;
|
let prompt;
|
||||||
|
|
||||||
// ── Session logic ──
|
// ── Session logic ──
|
||||||
if (conversationId && sessions.has(conversationId)) {
|
// sessionKey namespaces the Map key by keyName to prevent cross-caller collision
|
||||||
const session = sessions.get(conversationId);
|
// when two callers with different API keys share the same conversationId string.
|
||||||
|
const sessionKey = _sessionKey(conversationId, keyName);
|
||||||
|
if (sessionKey && sessions.has(sessionKey)) {
|
||||||
|
const session = sessions.get(sessionKey);
|
||||||
session.lastUsed = Date.now();
|
session.lastUsed = Date.now();
|
||||||
sessionInfo = { uuid: session.uuid, resume: true };
|
sessionInfo = { uuid: session.uuid, resume: true };
|
||||||
stats.sessionHits++;
|
stats.sessionHits++;
|
||||||
@@ -455,17 +469,17 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
: "";
|
: "";
|
||||||
session.messageCount = messages.length;
|
session.messageCount = messages.length;
|
||||||
|
|
||||||
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||||
|
|
||||||
} else if (conversationId) {
|
} else if (sessionKey) {
|
||||||
const uuid = randomUUID();
|
const uuid = randomUUID();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
sessions.set(conversationId, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
|
sessions.set(sessionKey, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
|
||||||
sessionInfo = { uuid, resume: false };
|
sessionInfo = { uuid, resume: false };
|
||||||
stats.sessionMisses++;
|
stats.sessionMisses++;
|
||||||
prompt = messagesToPrompt(messages);
|
prompt = messagesToPrompt(messages);
|
||||||
|
|
||||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
console.log(`[session] new conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
stats.oneOffRequests++;
|
stats.oneOffRequests++;
|
||||||
@@ -510,11 +524,11 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
proc.once("exit", cleanup);
|
proc.once("exit", cleanup);
|
||||||
|
|
||||||
function handleSessionFailure() {
|
function handleSessionFailure() {
|
||||||
if (sessionInfo?.resume && conversationId) {
|
if (sessionInfo?.resume && sessionKey) {
|
||||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
||||||
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
|
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
|
||||||
sessions.delete(conversationId);
|
sessions.delete(sessionKey);
|
||||||
} else if (sessionInfo && !sessionInfo.resume && conversationId) {
|
} else if (sessionInfo && !sessionInfo.resume && sessionKey) {
|
||||||
// #41 evidence-gathering: session-create failures currently leave a stale entry
|
// #41 evidence-gathering: session-create failures currently leave a stale entry
|
||||||
// in the sessions map. Log without action so the staleness pattern can be
|
// in the sessions map. Log without action so the staleness pattern can be
|
||||||
// confirmed in /logs before any code change. Do NOT delete here speculatively.
|
// confirmed in /logs before any code change. Do NOT delete here speculatively.
|
||||||
@@ -557,11 +571,11 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||||
// No pool = no crash loops, no stale workers, no degraded states.
|
// No pool = no crash loops, no stale workers, no degraded states.
|
||||||
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
||||||
function callClaude(model, messages, conversationId) {
|
function callClaude(model, messages, conversationId, keyName) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
ctx = spawnClaudeProcess(model, messages, conversationId, keyName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
@@ -641,7 +655,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
|
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||||
}
|
}
|
||||||
@@ -1324,7 +1338,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
// will re-read the freshly-populated cache entry here rather than spawning.
|
// will re-read the freshly-populated cache entry here rather than spawning.
|
||||||
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
||||||
if (recheck) return recheck.response;
|
if (recheck) return recheck.response;
|
||||||
const c = await callClaude(model, messages, conversationId);
|
const c = await callClaude(model, messages, conversationId, req._authKeyName);
|
||||||
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
return c;
|
return c;
|
||||||
});
|
});
|
||||||
@@ -1346,7 +1360,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
||||||
try {
|
try {
|
||||||
const content = await callClaude(model, messages, conversationId);
|
const content = await callClaude(model, messages, conversationId, req._authKeyName);
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
completionResponse(res, id, model, content);
|
completionResponse(res, id, model, content);
|
||||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
@@ -1480,8 +1494,10 @@ const server = createServer(async (req, res) => {
|
|||||||
const uptimeMs = Date.now() - START_TIME;
|
const uptimeMs = Date.now() - START_TIME;
|
||||||
const sessionList = [];
|
const sessionList = [];
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
|
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||||
|
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
|
||||||
sessionList.push({
|
sessionList.push({
|
||||||
id: id.slice(0, 12) + "...",
|
id: convId.slice(0, 12) + "...",
|
||||||
model: s.model,
|
model: s.model,
|
||||||
messages: s.messageCount,
|
messages: s.messageCount,
|
||||||
idleMs: Date.now() - s.lastUsed,
|
idleMs: Date.now() - s.lastUsed,
|
||||||
@@ -1526,7 +1542,9 @@ const server = createServer(async (req, res) => {
|
|||||||
if (req.url === "/sessions" && req.method === "GET") {
|
if (req.url === "/sessions" && req.method === "GET") {
|
||||||
const list = [];
|
const list = [];
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||||
|
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
|
||||||
|
list.push({ id: convId, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
||||||
}
|
}
|
||||||
return jsonResponse(res, 200, { sessions: list });
|
return jsonResponse(res, 200, { sessions: list });
|
||||||
}
|
}
|
||||||
@@ -1616,45 +1634,14 @@ const server = createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
|
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
|
||||||
// Least-privilege scope rules (security audit follow-up):
|
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||||
// - non-admin authenticated key → only own rows
|
|
||||||
// - anonymous (PROXY_ANONYMOUS_KEY) → only "anonymous" rows; ?all=true ignored
|
|
||||||
// - admin without ?all=true → only own ("admin") rows
|
|
||||||
// - admin with ?all=true → full byKey/recent (legacy behavior); audited
|
|
||||||
// Authenticated callers are required (anyone reaching here passed the auth gate above);
|
|
||||||
// remote+no-auth requests would have been rejected before this point.
|
|
||||||
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
|
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
|
||||||
const since = url.searchParams.get("since");
|
const since = url.searchParams.get("since");
|
||||||
const until = url.searchParams.get("until");
|
const until = url.searchParams.get("until");
|
||||||
const wantAll = url.searchParams.get("all") === "true";
|
|
||||||
const callerName = req._authKeyName;
|
|
||||||
|
|
||||||
// Anonymous callers may never opt into all-keys view, even if they pass ?all=true.
|
|
||||||
const isAnonCaller = callerName === "anonymous";
|
|
||||||
const fullScope = isAdmin && wantAll && !isAnonCaller;
|
|
||||||
|
|
||||||
// scopeName === null when fullScope is true (no filter); otherwise the key_name to filter by.
|
|
||||||
const scopeName = fullScope ? null : callerName;
|
|
||||||
|
|
||||||
if (fullScope) {
|
|
||||||
logEvent("info", "admin_usage_full_scope", { caller: callerName, ip: req.socket.remoteAddress || null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const byKeyAll = getUsageByKey({ since, until });
|
|
||||||
const recentAll = getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500));
|
|
||||||
const timeline = getUsageTimeline({
|
|
||||||
keyName: scopeName || undefined,
|
|
||||||
hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720),
|
|
||||||
});
|
|
||||||
|
|
||||||
const byKey = scopeName ? byKeyAll.filter((row) => row.key_name === scopeName) : byKeyAll;
|
|
||||||
const recent = scopeName ? recentAll.filter((row) => row.key_name === scopeName) : recentAll;
|
|
||||||
|
|
||||||
return jsonResponse(res, 200, {
|
return jsonResponse(res, 200, {
|
||||||
byKey,
|
byKey: getUsageByKey({ since, until }),
|
||||||
timeline,
|
timeline: getUsageTimeline({ hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720) }),
|
||||||
recent,
|
recent: getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)),
|
||||||
scope: { self: scopeName, all: fullScope },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user