copilot-cursor-proxy 1.0.1 → 1.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-cursor-proxy",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Proxy that bridges GitHub Copilot API to Cursor IDE — translates Anthropic format, bridges Responses API for GPT 5.x, and more",
5
5
  "bin": {
6
6
  "copilot-cursor-proxy": "bin/cli.js"
@@ -12,7 +12,7 @@
12
12
  "README.md"
13
13
  ],
14
14
  "scripts": {
15
- "build": "bun build start.ts proxy-router.ts anthropic-transforms.ts responses-bridge.ts responses-converters.ts stream-proxy.ts debug-logger.ts --outdir dist --target node",
15
+ "build": "bun build start.ts proxy-router.ts anthropic-transforms.ts responses-bridge.ts responses-converters.ts stream-proxy.ts debug-logger.ts auth-config.ts --outdir dist --target node",
16
16
  "dev": "bun run start.ts",
17
17
  "start": "node dist/start.js"
18
18
  },
package/proxy-router.ts CHANGED
@@ -2,7 +2,40 @@ import { normalizeRequest } from './anthropic-transforms';
2
2
  import { handleResponsesAPIBridge } from './responses-bridge';
3
3
  import { createStreamProxy } from './stream-proxy';
4
4
  import { logIncomingRequest, logTransformedRequest } from './debug-logger';
5
+ import { addRequestLog, getNextRequestId, getUsageStats, flushToDisk, type RequestLog } from './usage-db';
6
+ import { loadAuthConfig, saveAuthConfig, generateApiKey, validateApiKey } from './auth-config';
5
7
 
8
+ // ── Console capture for SSE streaming ─────────────────────────────────────────
9
+ interface ConsoleLine {
10
+ timestamp: number;
11
+ level: 'LOG' | 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
12
+ message: string;
13
+ }
14
+
15
+ const consoleLines: ConsoleLine[] = [];
16
+ const MAX_CONSOLE_LINES = 500;
17
+ const logSubscribers = new Set<ReadableStreamDefaultController>();
18
+
19
+ const origLog = console.log;
20
+ const origError = console.error;
21
+ const origWarn = console.warn;
22
+
23
+ function addConsoleLine(level: ConsoleLine['level'], args: any[]) {
24
+ const message = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
25
+ const line: ConsoleLine = { timestamp: Date.now(), level, message };
26
+ consoleLines.push(line);
27
+ if (consoleLines.length > MAX_CONSOLE_LINES) consoleLines.shift();
28
+ const data = `data: ${JSON.stringify({ type: 'line', ...line })}\n\n`;
29
+ for (const ctrl of logSubscribers) {
30
+ try { ctrl.enqueue(new TextEncoder().encode(data)); } catch { logSubscribers.delete(ctrl); }
31
+ }
32
+ }
33
+
34
+ console.log = (...args: any[]) => { origLog(...args); addConsoleLine('LOG', args); };
35
+ console.error = (...args: any[]) => { origError(...args); addConsoleLine('ERROR', args); };
36
+ console.warn = (...args: any[]) => { origWarn(...args); addConsoleLine('WARN', args); };
37
+
38
+ // ── Config ────────────────────────────────────────────────────────────────────
6
39
  const PORT = 4142;
7
40
  const TARGET_URL = "http://localhost:4141";
8
41
  const PREFIX = "cus-";
@@ -18,15 +51,113 @@ Bun.serve({
18
51
  async fetch(req) {
19
52
  const url = new URL(req.url);
20
53
 
54
+ // ── Dashboard ─────────────────────────────────────────────────────────
21
55
  if (url.pathname === "/" || url.pathname === "/dashboard.html") {
22
56
  try {
23
- const dashboardContent = await Bun.file("dashboard.html").text();
57
+ const dashboardPath = import.meta.dir + "/dashboard.html";
58
+ const dashboardContent = await Bun.file(dashboardPath).text();
24
59
  return new Response(dashboardContent, { headers: { "Content-Type": "text/html" } });
25
60
  } catch (e) {
26
61
  return new Response("Dashboard not found.", { status: 404 });
27
62
  }
28
63
  }
29
64
 
65
+ // ── Dashboard API: usage stats ────────────────────────────────────────
66
+ if (url.pathname === "/api/usage") {
67
+ return new Response(JSON.stringify(getUsageStats()), {
68
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
69
+ });
70
+ }
71
+
72
+ // ── Dashboard API: flush usage to disk ────────────────────────────────
73
+ if (url.pathname === "/api/usage/flush" && req.method === "POST") {
74
+ await flushToDisk();
75
+ return new Response('{"ok":true}', {
76
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
77
+ });
78
+ }
79
+
80
+ // ── Dashboard API: SSE console log stream ─────────────────────────────
81
+ if (url.pathname === "/api/logs/stream") {
82
+ const stream = new ReadableStream({
83
+ start(controller) {
84
+ const initData = `data: ${JSON.stringify({ type: 'init', lines: consoleLines })}\n\n`;
85
+ controller.enqueue(new TextEncoder().encode(initData));
86
+ logSubscribers.add(controller);
87
+ },
88
+ cancel() {
89
+ // cleaned up on enqueue failure
90
+ },
91
+ });
92
+ return new Response(stream, {
93
+ headers: {
94
+ "Content-Type": "text/event-stream",
95
+ "Cache-Control": "no-cache",
96
+ "Connection": "keep-alive",
97
+ "Access-Control-Allow-Origin": "*",
98
+ },
99
+ });
100
+ }
101
+
102
+ // ── Dashboard API: clear console logs ─────────────────────────────────
103
+ if (url.pathname === "/api/logs/clear" && req.method === "POST") {
104
+ consoleLines.length = 0;
105
+ const data = `data: ${JSON.stringify({ type: 'clear' })}\n\n`;
106
+ for (const ctrl of logSubscribers) {
107
+ try { ctrl.enqueue(new TextEncoder().encode(data)); } catch { logSubscribers.delete(ctrl); }
108
+ }
109
+ return new Response('{"ok":true}', {
110
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
111
+ });
112
+ }
113
+
114
+ // ── API Key management endpoints ──────────────────────────────────
115
+ const corsHeaders = { "Access-Control-Allow-Origin": "*" };
116
+
117
+ if (url.pathname === "/api/keys" && req.method === "GET") {
118
+ const config = loadAuthConfig();
119
+ const maskedKeys = config.keys.map(k => ({
120
+ ...k,
121
+ key: k.key.slice(0, 12) + '...'
122
+ }));
123
+ return Response.json({ requireApiKey: config.requireApiKey, keys: maskedKeys }, { headers: corsHeaders });
124
+ }
125
+
126
+ if (url.pathname === "/api/keys" && req.method === "POST") {
127
+ const { name } = await req.json();
128
+ const config = loadAuthConfig();
129
+ const newKey = generateApiKey(name || 'Untitled');
130
+ config.keys.push(newKey);
131
+ saveAuthConfig(config);
132
+ return Response.json(newKey, { headers: corsHeaders });
133
+ }
134
+
135
+ if (url.pathname.startsWith("/api/keys/") && req.method === "PUT") {
136
+ const id = url.pathname.split('/').pop();
137
+ const { active } = await req.json();
138
+ const config = loadAuthConfig();
139
+ const key = config.keys.find(k => k.id === id);
140
+ if (key) { key.active = active; saveAuthConfig(config); }
141
+ return Response.json({ ok: true }, { headers: corsHeaders });
142
+ }
143
+
144
+ if (url.pathname.startsWith("/api/keys/") && req.method === "DELETE") {
145
+ const id = url.pathname.split('/').pop();
146
+ const config = loadAuthConfig();
147
+ config.keys = config.keys.filter(k => k.id !== id);
148
+ saveAuthConfig(config);
149
+ return Response.json({ ok: true }, { headers: corsHeaders });
150
+ }
151
+
152
+ if (url.pathname === "/api/settings/auth" && req.method === "PUT") {
153
+ const { requireApiKey } = await req.json();
154
+ const config = loadAuthConfig();
155
+ config.requireApiKey = requireApiKey;
156
+ saveAuthConfig(config);
157
+ return Response.json({ ok: true }, { headers: corsHeaders });
158
+ }
159
+
160
+ // ── Proxy logic ───────────────────────────────────────────────────────
30
161
  const targetUrl = new URL(url.pathname + url.search, TARGET_URL);
31
162
 
32
163
  if (req.method === "OPTIONS") {
@@ -41,6 +172,20 @@ Bun.serve({
41
172
 
42
173
  try {
43
174
  if (req.method === "POST" && url.pathname.includes("/chat/completions")) {
175
+ // Check API key if required
176
+ const authConfig = loadAuthConfig();
177
+ if (authConfig.requireApiKey) {
178
+ const authHeader = req.headers.get('authorization');
179
+ const providedKey = authHeader?.replace('Bearer ', '');
180
+ if (!providedKey || !validateApiKey(providedKey)) {
181
+ return Response.json(
182
+ { error: { message: "Invalid API key. Generate one from the dashboard.", type: "invalid_api_key" } },
183
+ { status: 401, headers: { "Access-Control-Allow-Origin": "*" } }
184
+ );
185
+ }
186
+ }
187
+
188
+ const startTime = Date.now();
44
189
  let json = await req.json();
45
190
 
46
191
  logIncomingRequest(json);
@@ -67,10 +212,32 @@ Bun.serve({
67
212
 
68
213
  const needsResponsesAPI = targetModel.match(/^gpt-5\.[2-9]|^gpt-5\.\d+-codex|^o[1-9]|^goldeneye/i);
69
214
 
215
+ // For models that need max_completion_tokens instead of max_tokens
216
+ const needsMaxCompletionTokens = targetModel.match(/^gpt-5\.[2-9]|^gpt-5\.\d+-codex|^goldeneye/i);
217
+ if (needsMaxCompletionTokens && json.max_tokens) {
218
+ json.max_completion_tokens = json.max_tokens;
219
+ delete json.max_tokens;
220
+ console.log(`🔧 Converted max_tokens → max_completion_tokens`);
221
+ }
222
+
223
+ // Try Responses API first for models that may need it; fall back to chat completions
70
224
  if (needsResponsesAPI) {
71
- console.log(`🔀 Model ${targetModel} requires Responses API — converting`);
225
+ console.log(`🔀 Model ${targetModel} trying Responses API bridge`);
72
226
  const chatId = `chatcmpl-proxy-${++responseCounter}`;
73
- return await handleResponsesAPIBridge(json, req, chatId, TARGET_URL);
227
+ try {
228
+ const result = await handleResponsesAPIBridge(json, req, chatId, TARGET_URL);
229
+ if (result.status !== 404) {
230
+ addRequestLog({
231
+ id: getNextRequestId(), timestamp: startTime, model: targetModel,
232
+ promptTokens: 0, completionTokens: 0, totalTokens: 0,
233
+ status: result.status, duration: Date.now() - startTime, stream: !!json.stream,
234
+ });
235
+ return result;
236
+ }
237
+ console.log(`⚠️ Responses API returned 404 — falling back to chat/completions`);
238
+ } catch (e) {
239
+ console.log(`⚠️ Responses API failed — falling back to chat/completions`);
240
+ }
74
241
  }
75
242
 
76
243
  const hasVisionContent = (messages: any[]) => messages?.some(msg =>
@@ -94,13 +261,42 @@ Bun.serve({
94
261
  if (!response.ok) {
95
262
  const errText = await response.text();
96
263
  console.error(`❌ Upstream Error (${response.status}):`, errText);
264
+ addRequestLog({
265
+ id: getNextRequestId(), timestamp: startTime, model: targetModel,
266
+ promptTokens: 0, completionTokens: 0, totalTokens: 0,
267
+ status: response.status, duration: Date.now() - startTime, stream: !!json.stream,
268
+ });
97
269
  return new Response(errText, { status: response.status, headers: responseHeaders });
98
270
  }
99
271
 
100
272
  if (json.stream && response.body) {
101
- return createStreamProxy(response.body, responseHeaders);
273
+ return createStreamProxy(response.body, responseHeaders, (usage) => {
274
+ addRequestLog({
275
+ id: getNextRequestId(), timestamp: startTime, model: targetModel,
276
+ promptTokens: usage.promptTokens, completionTokens: usage.completionTokens,
277
+ totalTokens: usage.totalTokens,
278
+ status: response.status, duration: Date.now() - startTime, stream: true,
279
+ });
280
+ });
102
281
  }
103
282
 
283
+ // Non-streaming: clone and parse to extract usage
284
+ const cloned = response.clone();
285
+ let promptTokens = 0, completionTokens = 0, totalTokens = 0;
286
+ try {
287
+ const respJson = await cloned.json();
288
+ if (respJson.usage) {
289
+ promptTokens = respJson.usage.prompt_tokens || 0;
290
+ completionTokens = respJson.usage.completion_tokens || 0;
291
+ totalTokens = respJson.usage.total_tokens || promptTokens + completionTokens;
292
+ }
293
+ } catch { /* ignore parse errors */ }
294
+ addRequestLog({
295
+ id: getNextRequestId(), timestamp: startTime, model: targetModel,
296
+ promptTokens, completionTokens, totalTokens,
297
+ status: response.status, duration: Date.now() - startTime, stream: false,
298
+ });
299
+
104
300
  return new Response(response.body, {
105
301
  status: response.status,
106
302
  headers: responseHeaders,
@@ -108,6 +304,19 @@ Bun.serve({
108
304
  }
109
305
 
110
306
  if (req.method === "GET" && url.pathname.includes("/models")) {
307
+ // Check API key if required
308
+ const authConfig = loadAuthConfig();
309
+ if (authConfig.requireApiKey) {
310
+ const authHeader = req.headers.get('authorization');
311
+ const providedKey = authHeader?.replace('Bearer ', '');
312
+ if (!providedKey || !validateApiKey(providedKey)) {
313
+ return Response.json(
314
+ { error: { message: "Invalid API key. Generate one from the dashboard.", type: "invalid_api_key" } },
315
+ { status: 401, headers: { "Access-Control-Allow-Origin": "*" } }
316
+ );
317
+ }
318
+ }
319
+
111
320
  const headers = new Headers(req.headers);
112
321
  headers.set("host", targetUrl.host);
113
322
  const response = await fetch(targetUrl.toString(), { method: "GET", headers: headers });
@@ -89,7 +89,7 @@ export function convertResponsesStreamToChatCompletions(response: Response, mode
89
89
  else if (eventType === 'response.output_item.added' && event.item?.type === 'function_call') {
90
90
  const delta: any = {
91
91
  tool_calls: [{
92
- index: toolCallIndex,
92
+ index: toolCallIndex++,
93
93
  id: event.item.call_id || event.item.id,
94
94
  type: 'function',
95
95
  function: { name: event.item.name, arguments: '' },
@@ -103,7 +103,7 @@ export function convertResponsesStreamToChatCompletions(response: Response, mode
103
103
  else if (eventType === 'response.function_call_arguments.delta') {
104
104
  controller.enqueue(encoder.encode(makeChatChunk({
105
105
  tool_calls: [{
106
- index: toolCallIndex,
106
+ index: toolCallIndex - 1,
107
107
  function: { arguments: event.delta },
108
108
  }]
109
109
  })));
@@ -111,7 +111,7 @@ export function convertResponsesStreamToChatCompletions(response: Response, mode
111
111
  }
112
112
 
113
113
  else if (eventType === 'response.output_item.done' && event.item?.type === 'function_call') {
114
- toolCallIndex++;
114
+ // toolCallIndex already incremented on 'added'
115
115
  }
116
116
 
117
117
  else if (eventType === 'response.completed') {
package/stream-proxy.ts CHANGED
@@ -1,7 +1,18 @@
1
- export const createStreamProxy = (responseBody: ReadableStream<Uint8Array>, responseHeaders: Headers) => {
1
+ export interface StreamUsageResult {
2
+ promptTokens: number;
3
+ completionTokens: number;
4
+ totalTokens: number;
5
+ }
6
+
7
+ export const createStreamProxy = (
8
+ responseBody: ReadableStream<Uint8Array>,
9
+ responseHeaders: Headers,
10
+ onComplete?: (usage: StreamUsageResult) => void,
11
+ ) => {
2
12
  let chunkCount = 0;
3
13
  let lastChunkData = '';
4
14
  let totalBytes = 0;
15
+ let extractedUsage: StreamUsageResult = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
5
16
  const reader = responseBody.getReader();
6
17
  const decoder = new TextDecoder();
7
18
 
@@ -12,6 +23,7 @@ export const createStreamProxy = (responseBody: ReadableStream<Uint8Array>, resp
12
23
  if (done) {
13
24
  console.log(`✅ Stream complete: ${chunkCount} chunks, ${totalBytes} bytes`);
14
25
  console.log(`✅ Last chunk: ${lastChunkData.slice(-200)}`);
26
+ if (onComplete) onComplete(extractedUsage);
15
27
  controller.close();
16
28
  return;
17
29
  }
@@ -30,6 +42,24 @@ export const createStreamProxy = (responseBody: ReadableStream<Uint8Array>, resp
30
42
  console.log(`📡 finish_reason: "${match[1]}" at chunk ${chunkCount}`);
31
43
  }
32
44
  }
45
+ // Extract usage from chunks (often in the final chunk)
46
+ if (lastChunkData.includes('"usage"')) {
47
+ try {
48
+ const lines = lastChunkData.split('\n');
49
+ for (const line of lines) {
50
+ const jsonStr = line.replace(/^data:\s*/, '').trim();
51
+ if (!jsonStr || jsonStr === '[DONE]') continue;
52
+ const parsed = JSON.parse(jsonStr);
53
+ if (parsed.usage) {
54
+ extractedUsage = {
55
+ promptTokens: parsed.usage.prompt_tokens || 0,
56
+ completionTokens: parsed.usage.completion_tokens || 0,
57
+ totalTokens: parsed.usage.total_tokens || (parsed.usage.prompt_tokens || 0) + (parsed.usage.completion_tokens || 0),
58
+ };
59
+ }
60
+ }
61
+ } catch { /* ignore parse errors in partial chunks */ }
62
+ }
33
63
  controller.enqueue(value);
34
64
  } catch (err: any) {
35
65
  if (err?.code === 'ERR_INVALID_THIS') return;
@@ -39,6 +69,7 @@ export const createStreamProxy = (responseBody: ReadableStream<Uint8Array>, resp
39
69
  },
40
70
  cancel() {
41
71
  console.log(`⚠️ Stream cancelled by client after ${chunkCount} chunks, ${totalBytes} bytes`);
72
+ if (onComplete) onComplete(extractedUsage);
42
73
  try { reader.cancel(); } catch {}
43
74
  }
44
75
  });
package/usage-db.ts ADDED
@@ -0,0 +1,210 @@
1
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+
5
+ // ── Types ────────────────────────────────────────────────────────────────────
6
+
7
+ export interface RequestLog {
8
+ id: number;
9
+ timestamp: number;
10
+ model: string;
11
+ promptTokens: number;
12
+ completionTokens: number;
13
+ totalTokens: number;
14
+ status: number;
15
+ duration: number;
16
+ stream: boolean;
17
+ }
18
+
19
+ interface DailySnapshot {
20
+ date: string;
21
+ requests: number;
22
+ promptTokens: number;
23
+ completionTokens: number;
24
+ totalTokens: number;
25
+ errors: number;
26
+ byModel: Record<string, { requests: number; promptTokens: number; completionTokens: number; totalTokens: number; errors: number }>;
27
+ }
28
+
29
+ interface UsageData {
30
+ version: 1;
31
+ createdAt: number;
32
+ lastSavedAt: number;
33
+ requestIdCounter: number;
34
+ recentRequests: RequestLog[];
35
+ dailySnapshots: DailySnapshot[];
36
+ lifetimeTotals: {
37
+ requests: number;
38
+ promptTokens: number;
39
+ completionTokens: number;
40
+ totalTokens: number;
41
+ errors: number;
42
+ };
43
+ }
44
+
45
+ // ── Config ───────────────────────────────────────────────────────────────────
46
+
47
+ const MAX_RECENT_REQUESTS = 1000;
48
+ const MAX_DAILY_SNAPSHOTS = 90;
49
+ const DEBOUNCE_MS = 3000;
50
+
51
+ const DATA_DIR = process.env.DATA_DIR || join(homedir(), '.copilot-proxy');
52
+ const USAGE_FILE = join(DATA_DIR, 'usage.json');
53
+
54
+ // ── State ────────────────────────────────────────────────────────────────────
55
+
56
+ let data: UsageData;
57
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
58
+ let dirty = false;
59
+
60
+ // ── Helpers ──────────────────────────────────────────────────────────────────
61
+
62
+ const todayKey = () => new Date().toISOString().slice(0, 10);
63
+
64
+ const emptyData = (): UsageData => ({
65
+ version: 1,
66
+ createdAt: Date.now(),
67
+ lastSavedAt: Date.now(),
68
+ requestIdCounter: 0,
69
+ recentRequests: [],
70
+ dailySnapshots: [],
71
+ lifetimeTotals: { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, errors: 0 },
72
+ });
73
+
74
+ // ── Persistence ──────────────────────────────────────────────────────────────
75
+
76
+ const loadSync = (): UsageData => {
77
+ try {
78
+ if (!existsSync(USAGE_FILE)) return emptyData();
79
+ const text = readFileSync(USAGE_FILE, 'utf-8');
80
+ return JSON.parse(text) as UsageData;
81
+ } catch {
82
+ return emptyData();
83
+ }
84
+ };
85
+
86
+ const saveToDisk = async () => {
87
+ try {
88
+ data.lastSavedAt = Date.now();
89
+ writeFileSync(USAGE_FILE, JSON.stringify(data, null, 2), 'utf-8');
90
+ } catch (e) {
91
+ console.error('Failed to save usage data:', e);
92
+ }
93
+ dirty = false;
94
+ };
95
+
96
+ const scheduleSave = () => {
97
+ dirty = true;
98
+ if (saveTimer) return;
99
+ saveTimer = setTimeout(async () => {
100
+ saveTimer = null;
101
+ if (dirty) await saveToDisk();
102
+ }, DEBOUNCE_MS);
103
+ };
104
+
105
+ // ── Initialize ───────────────────────────────────────────────────────────────
106
+
107
+ const init = () => {
108
+ if (!existsSync(DATA_DIR)) {
109
+ mkdirSync(DATA_DIR, { recursive: true });
110
+ }
111
+ data = loadSync();
112
+ console.log(`💾 Usage DB: ${USAGE_FILE} (${data.lifetimeTotals.requests} lifetime requests)`);
113
+ };
114
+
115
+ init();
116
+
117
+ // ── Public API ───────────────────────────────────────────────────────────────
118
+
119
+ export const getNextRequestId = (): number => {
120
+ data.requestIdCounter++;
121
+ return data.requestIdCounter;
122
+ };
123
+
124
+ export const addRequestLog = (log: RequestLog) => {
125
+ data.recentRequests.push(log);
126
+ if (data.recentRequests.length > MAX_RECENT_REQUESTS) {
127
+ data.recentRequests.shift();
128
+ }
129
+
130
+ data.lifetimeTotals.requests++;
131
+ data.lifetimeTotals.promptTokens += log.promptTokens;
132
+ data.lifetimeTotals.completionTokens += log.completionTokens;
133
+ data.lifetimeTotals.totalTokens += log.totalTokens;
134
+ if (log.status >= 400) data.lifetimeTotals.errors++;
135
+
136
+ const today = todayKey();
137
+ let snap = data.dailySnapshots.find(s => s.date === today);
138
+ if (!snap) {
139
+ snap = { date: today, requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, errors: 0, byModel: {} };
140
+ data.dailySnapshots.push(snap);
141
+ if (data.dailySnapshots.length > MAX_DAILY_SNAPSHOTS) {
142
+ data.dailySnapshots.shift();
143
+ }
144
+ }
145
+ snap.requests++;
146
+ snap.promptTokens += log.promptTokens;
147
+ snap.completionTokens += log.completionTokens;
148
+ snap.totalTokens += log.totalTokens;
149
+ if (log.status >= 400) snap.errors++;
150
+
151
+ if (!snap.byModel[log.model]) {
152
+ snap.byModel[log.model] = { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, errors: 0 };
153
+ }
154
+ const m = snap.byModel[log.model];
155
+ m.requests++;
156
+ m.promptTokens += log.promptTokens;
157
+ m.completionTokens += log.completionTokens;
158
+ m.totalTokens += log.totalTokens;
159
+ if (log.status >= 400) m.errors++;
160
+
161
+ scheduleSave();
162
+ };
163
+
164
+ export const getRecentRequests = (): RequestLog[] => data.recentRequests;
165
+
166
+ export const getUsageStats = () => {
167
+ const logs = data.recentRequests;
168
+ const byModel = Object.entries(
169
+ logs.reduce((acc, r) => {
170
+ if (!acc[r.model]) acc[r.model] = { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, errors: 0, totalDuration: 0, avgDuration: 0 };
171
+ acc[r.model].requests++;
172
+ acc[r.model].promptTokens += r.promptTokens;
173
+ acc[r.model].completionTokens += r.completionTokens;
174
+ acc[r.model].totalTokens += r.totalTokens;
175
+ if (r.status >= 400) acc[r.model].errors++;
176
+ acc[r.model].totalDuration += r.duration;
177
+ acc[r.model].avgDuration = Math.round(acc[r.model].totalDuration / acc[r.model].requests);
178
+ return acc;
179
+ }, {} as Record<string, any>),
180
+ ).map(([model, d]) => ({ model, ...d }));
181
+
182
+ return {
183
+ totalRequests: data.lifetimeTotals.requests,
184
+ totalPromptTokens: data.lifetimeTotals.promptTokens,
185
+ totalCompletionTokens: data.lifetimeTotals.completionTokens,
186
+ totalTokens: data.lifetimeTotals.totalTokens,
187
+ totalErrors: data.lifetimeTotals.errors,
188
+ byModel,
189
+ recentRequests: logs.slice(-50).reverse(),
190
+ dailySnapshots: data.dailySnapshots,
191
+ persistence: {
192
+ dataDir: DATA_DIR,
193
+ file: USAGE_FILE,
194
+ lastSavedAt: data.lastSavedAt,
195
+ createdAt: data.createdAt,
196
+ },
197
+ };
198
+ };
199
+
200
+ export const flushToDisk = async () => {
201
+ if (saveTimer) {
202
+ clearTimeout(saveTimer);
203
+ saveTimer = null;
204
+ }
205
+ await saveToDisk();
206
+ };
207
+
208
+ process.on('beforeExit', () => { flushToDisk(); });
209
+ process.on('SIGINT', async () => { await flushToDisk(); process.exit(0); });
210
+ process.on('SIGTERM', async () => { await flushToDisk(); process.exit(0); });