copilot-cursor-proxy 1.0.1 → 1.0.3
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 +52 -38
- package/auth-config.ts +61 -0
- package/dashboard.html +814 -270
- package/package.json +2 -2
- package/proxy-router.ts +213 -4
- package/responses-converters.ts +3 -3
- package/stream-proxy.ts +32 -1
- package/usage-db.ts +210 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copilot-cursor-proxy",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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
|
|
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}
|
|
225
|
+
console.log(`🔀 Model ${targetModel} — trying Responses API bridge`);
|
|
72
226
|
const chatId = `chatcmpl-proxy-${++responseCounter}`;
|
|
73
|
-
|
|
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 });
|
package/responses-converters.ts
CHANGED
|
@@ -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
|
|
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); });
|