copilot-cursor-proxy 1.1.0 → 1.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 +1 -1
- package/auth-config.ts +6 -1
- package/dashboard.html +31 -15
- package/package.json +2 -2
- package/proxy-router.ts +49 -21
- package/responses-bridge.ts +27 -5
- package/responses-converters.ts +9 -3
- package/usage-db.ts +4 -3
package/README.md
CHANGED
package/auth-config.ts
CHANGED
|
@@ -20,15 +20,19 @@ const CONFIG_PATH = join(CONFIG_DIR, 'auth.json');
|
|
|
20
20
|
|
|
21
21
|
const DEFAULT_CONFIG: AuthConfig = { requireApiKey: false, keys: [] };
|
|
22
22
|
|
|
23
|
+
let cachedConfig: AuthConfig | null = null;
|
|
24
|
+
|
|
23
25
|
export function loadAuthConfig(): AuthConfig {
|
|
26
|
+
if (cachedConfig) return cachedConfig;
|
|
24
27
|
try {
|
|
25
28
|
if (!existsSync(CONFIG_PATH)) return { ...DEFAULT_CONFIG, keys: [] };
|
|
26
29
|
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
|
27
30
|
const parsed = JSON.parse(raw);
|
|
28
|
-
|
|
31
|
+
cachedConfig = {
|
|
29
32
|
requireApiKey: !!parsed.requireApiKey,
|
|
30
33
|
keys: Array.isArray(parsed.keys) ? parsed.keys : [],
|
|
31
34
|
};
|
|
35
|
+
return cachedConfig;
|
|
32
36
|
} catch {
|
|
33
37
|
return { ...DEFAULT_CONFIG, keys: [] };
|
|
34
38
|
}
|
|
@@ -37,6 +41,7 @@ export function loadAuthConfig(): AuthConfig {
|
|
|
37
41
|
export function saveAuthConfig(config: AuthConfig): void {
|
|
38
42
|
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
39
43
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
44
|
+
cachedConfig = null; // Invalidate cache on write
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
function randomHex(bytes: number): string {
|
package/dashboard.html
CHANGED
|
@@ -354,10 +354,10 @@ tr:hover td { background: var(--bg-hover); }
|
|
|
354
354
|
</div>
|
|
355
355
|
|
|
356
356
|
<!-- Tabs -->
|
|
357
|
-
<div class="tabs">
|
|
358
|
-
<div class="tab active" data-tab="endpoint">Endpoint</div>
|
|
359
|
-
<div class="tab" data-tab="usage">Usage</div>
|
|
360
|
-
<div class="tab" data-tab="console">Console Log</div>
|
|
357
|
+
<div class="tabs" role="tablist">
|
|
358
|
+
<div class="tab active" data-tab="endpoint" role="tab" tabindex="0" aria-selected="true">Endpoint</div>
|
|
359
|
+
<div class="tab" data-tab="usage" role="tab" tabindex="0" aria-selected="false">Usage</div>
|
|
360
|
+
<div class="tab" data-tab="console" role="tab" tabindex="0" aria-selected="false">Console Log</div>
|
|
361
361
|
</div>
|
|
362
362
|
|
|
363
363
|
<!-- Content -->
|
|
@@ -537,13 +537,20 @@ tr:hover td { background: var(--bg-hover); }
|
|
|
537
537
|
<script>
|
|
538
538
|
/* ── Tab switching ──────────────────────────────────────────── */
|
|
539
539
|
const tabs = document.querySelectorAll('.tab');
|
|
540
|
-
|
|
541
|
-
tabs.forEach(x => x.classList.remove('active'));
|
|
540
|
+
function switchTab(t) {
|
|
541
|
+
tabs.forEach(x => { x.classList.remove('active'); x.setAttribute('aria-selected', 'false'); });
|
|
542
542
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
543
543
|
t.classList.add('active');
|
|
544
|
+
t.setAttribute('aria-selected', 'true');
|
|
544
545
|
document.getElementById('tab-' + t.dataset.tab).classList.add('active');
|
|
545
546
|
if (t.dataset.tab === 'usage') fetchUsage();
|
|
546
|
-
}
|
|
547
|
+
}
|
|
548
|
+
tabs.forEach(t => {
|
|
549
|
+
t.addEventListener('click', () => switchTab(t));
|
|
550
|
+
t.addEventListener('keydown', (e) => {
|
|
551
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchTab(t); }
|
|
552
|
+
});
|
|
553
|
+
});
|
|
547
554
|
|
|
548
555
|
/* ── Helpers ────────────────────────────────────────────────── */
|
|
549
556
|
function copyText(text, btn) {
|
|
@@ -578,7 +585,8 @@ function esc(s) {
|
|
|
578
585
|
/* ── Tab 1: Models ──────────────────────────────────────────── */
|
|
579
586
|
async function fetchModels() {
|
|
580
587
|
try {
|
|
581
|
-
|
|
588
|
+
// Fetch via /api/models to bypass API key auth for dashboard
|
|
589
|
+
const res = await fetch('/api/models');
|
|
582
590
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
583
591
|
const data = await res.json();
|
|
584
592
|
const models = (data.data || []).sort((a, b) => a.id.localeCompare(b.id));
|
|
@@ -598,7 +606,9 @@ async function fetchModels() {
|
|
|
598
606
|
'<td><span class="model-badge">' + esc(m.id) + '</span></td>' +
|
|
599
607
|
'<td style="color:var(--text-dim)">' + esc(origId) + '</td>' +
|
|
600
608
|
'<td style="color:var(--text-dim)">' + esc(m.display_name || m.id) + '</td>' +
|
|
601
|
-
'<td><button class="copy-btn"
|
|
609
|
+
'<td><button class="copy-btn">Copy</button></td>';
|
|
610
|
+
const btn = tr.querySelector('.copy-btn');
|
|
611
|
+
btn.addEventListener('click', function() { copyText(m.id, this); });
|
|
602
612
|
tbody.appendChild(tr);
|
|
603
613
|
});
|
|
604
614
|
} catch (e) {
|
|
@@ -631,10 +641,13 @@ function renderKeys() {
|
|
|
631
641
|
list.innerHTML = '<div style="color: #666; font-size: 13px; padding: 12px 0;">No API keys created yet.</div>';
|
|
632
642
|
return;
|
|
633
643
|
}
|
|
634
|
-
list.innerHTML =
|
|
635
|
-
|
|
644
|
+
list.innerHTML = '';
|
|
645
|
+
authConfig.keys.forEach(k => {
|
|
646
|
+
const row = document.createElement('div');
|
|
647
|
+
row.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #222;';
|
|
648
|
+
row.innerHTML = `
|
|
636
649
|
<label class="toggle" style="flex-shrink: 0;">
|
|
637
|
-
<input type="checkbox" ${k.active ? 'checked' : ''}
|
|
650
|
+
<input type="checkbox" ${k.active ? 'checked' : ''}>
|
|
638
651
|
<span class="toggle-slider"></span>
|
|
639
652
|
</label>
|
|
640
653
|
<div style="flex: 1; min-width: 0;">
|
|
@@ -642,9 +655,12 @@ function renderKeys() {
|
|
|
642
655
|
<code style="color: #888; font-size: 12px;">${esc(k.key)}</code>
|
|
643
656
|
</div>
|
|
644
657
|
<div style="color: #666; font-size: 12px; white-space: nowrap;">${timeAgo(k.createdAt)}</div>
|
|
645
|
-
<button
|
|
646
|
-
|
|
647
|
-
|
|
658
|
+
<button style="background: none; border: none; color: #666; cursor: pointer; font-size: 16px; padding: 4px 8px;" title="Delete">🗑</button>
|
|
659
|
+
`;
|
|
660
|
+
row.querySelector('input[type="checkbox"]').addEventListener('change', function() { toggleKey(k.id, this.checked); });
|
|
661
|
+
row.querySelector('button[title="Delete"]').addEventListener('click', function() { deleteKey(k.id); });
|
|
662
|
+
list.appendChild(row);
|
|
663
|
+
});
|
|
648
664
|
}
|
|
649
665
|
|
|
650
666
|
async function toggleRequireKey(enabled) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copilot-cursor-proxy",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
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"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"scripts": {
|
|
15
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
|
-
"start": "
|
|
17
|
+
"start": "bun dist/start.js"
|
|
18
18
|
},
|
|
19
19
|
"keywords": [
|
|
20
20
|
"copilot",
|
package/proxy-router.ts
CHANGED
|
@@ -124,7 +124,19 @@ Bun.serve({
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
if (url.pathname === "/api/keys" && req.method === "POST") {
|
|
127
|
-
|
|
127
|
+
let body: unknown;
|
|
128
|
+
try {
|
|
129
|
+
body = await req.json();
|
|
130
|
+
} catch {
|
|
131
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400, headers: corsHeaders });
|
|
132
|
+
}
|
|
133
|
+
if (typeof body !== 'object' || body === null) {
|
|
134
|
+
return Response.json({ error: "Request body must be a JSON object" }, { status: 400, headers: corsHeaders });
|
|
135
|
+
}
|
|
136
|
+
const { name } = body as { name?: unknown };
|
|
137
|
+
if (name !== undefined && typeof name !== 'string') {
|
|
138
|
+
return Response.json({ error: "`name` must be a string if provided" }, { status: 400, headers: corsHeaders });
|
|
139
|
+
}
|
|
128
140
|
const config = loadAuthConfig();
|
|
129
141
|
const newKey = generateApiKey(name || 'Untitled');
|
|
130
142
|
config.keys.push(newKey);
|
|
@@ -157,6 +169,28 @@ Bun.serve({
|
|
|
157
169
|
return Response.json({ ok: true }, { headers: corsHeaders });
|
|
158
170
|
}
|
|
159
171
|
|
|
172
|
+
// ── Dashboard API: model list (bypasses API key auth) ──────────────
|
|
173
|
+
if (url.pathname === "/api/models" && req.method === "GET") {
|
|
174
|
+
try {
|
|
175
|
+
const modelsUrl = new URL('/v1/models', TARGET_URL);
|
|
176
|
+
const response = await fetch(modelsUrl.toString());
|
|
177
|
+
const data = await response.json();
|
|
178
|
+
if (data.data && Array.isArray(data.data)) {
|
|
179
|
+
data.data = data.data.map((model: any) => ({
|
|
180
|
+
...model,
|
|
181
|
+
id: PREFIX + model.id,
|
|
182
|
+
display_name: PREFIX + (model.display_name || model.id)
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
return new Response(JSON.stringify(data), {
|
|
186
|
+
status: response.status,
|
|
187
|
+
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
|
|
188
|
+
});
|
|
189
|
+
} catch (e: any) {
|
|
190
|
+
return Response.json({ error: e?.message || 'Failed to fetch models' }, { status: 502, headers: corsHeaders });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
160
194
|
// ── Proxy logic ───────────────────────────────────────────────────────
|
|
161
195
|
const targetUrl = new URL(url.pathname + url.search, TARGET_URL);
|
|
162
196
|
|
|
@@ -170,9 +204,8 @@ Bun.serve({
|
|
|
170
204
|
});
|
|
171
205
|
}
|
|
172
206
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// Check API key if required
|
|
207
|
+
// ── Enforce API key auth on all /v1/* routes ──────────────────────────
|
|
208
|
+
if (url.pathname.startsWith("/v1/")) {
|
|
176
209
|
const authConfig = loadAuthConfig();
|
|
177
210
|
if (authConfig.requireApiKey) {
|
|
178
211
|
const authHeader = req.headers.get('authorization');
|
|
@@ -184,7 +217,10 @@ Bun.serve({
|
|
|
184
217
|
);
|
|
185
218
|
}
|
|
186
219
|
}
|
|
220
|
+
}
|
|
187
221
|
|
|
222
|
+
try {
|
|
223
|
+
if (req.method === "POST" && url.pathname.includes("/chat/completions")) {
|
|
188
224
|
const startTime = Date.now();
|
|
189
225
|
let json = await req.json();
|
|
190
226
|
|
|
@@ -207,6 +243,7 @@ Bun.serve({
|
|
|
207
243
|
|
|
208
244
|
const headers = new Headers(req.headers);
|
|
209
245
|
headers.set("host", targetUrl.host);
|
|
246
|
+
headers.delete("authorization"); // Don't leak proxy API keys upstream
|
|
210
247
|
|
|
211
248
|
const needsResponsesAPI = targetModel.match(/^gpt-5\.[2-9]|^gpt-5\.\d+-codex|^o[1-9]|^goldeneye/i);
|
|
212
249
|
|
|
@@ -220,13 +257,15 @@ Bun.serve({
|
|
|
220
257
|
console.log(`🔀 Model ${targetModel} — using Responses API bridge`);
|
|
221
258
|
const chatId = `chatcmpl-proxy-${++responseCounter}`;
|
|
222
259
|
try {
|
|
223
|
-
const
|
|
260
|
+
const bridgeResult = await handleResponsesAPIBridge(json, req, chatId, TARGET_URL);
|
|
224
261
|
addRequestLog({
|
|
225
262
|
id: getNextRequestId(), timestamp: startTime, model: targetModel,
|
|
226
|
-
promptTokens:
|
|
227
|
-
|
|
263
|
+
promptTokens: bridgeResult.usage.promptTokens,
|
|
264
|
+
completionTokens: bridgeResult.usage.completionTokens,
|
|
265
|
+
totalTokens: bridgeResult.usage.totalTokens,
|
|
266
|
+
status: bridgeResult.response.status, duration: Date.now() - startTime, stream: !!json.stream,
|
|
228
267
|
});
|
|
229
|
-
return
|
|
268
|
+
return bridgeResult.response;
|
|
230
269
|
} catch (e: any) {
|
|
231
270
|
console.error(`❌ Responses API bridge failed for ${targetModel}:`, e?.message || e);
|
|
232
271
|
return new Response(
|
|
@@ -303,21 +342,9 @@ Bun.serve({
|
|
|
303
342
|
}
|
|
304
343
|
|
|
305
344
|
if (req.method === "GET" && url.pathname.includes("/models")) {
|
|
306
|
-
// Check API key if required
|
|
307
|
-
const authConfig = loadAuthConfig();
|
|
308
|
-
if (authConfig.requireApiKey) {
|
|
309
|
-
const authHeader = req.headers.get('authorization');
|
|
310
|
-
const providedKey = authHeader?.replace('Bearer ', '');
|
|
311
|
-
if (!providedKey || !validateApiKey(providedKey)) {
|
|
312
|
-
return Response.json(
|
|
313
|
-
{ error: { message: "Invalid API key. Generate one from the dashboard.", type: "invalid_api_key" } },
|
|
314
|
-
{ status: 401, headers: { "Access-Control-Allow-Origin": "*" } }
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
345
|
const headers = new Headers(req.headers);
|
|
320
346
|
headers.set("host", targetUrl.host);
|
|
347
|
+
headers.delete("authorization"); // Don't leak proxy API keys upstream
|
|
321
348
|
const response = await fetch(targetUrl.toString(), { method: "GET", headers: headers });
|
|
322
349
|
const data = await response.json();
|
|
323
350
|
|
|
@@ -336,6 +363,7 @@ Bun.serve({
|
|
|
336
363
|
|
|
337
364
|
const headers = new Headers(req.headers);
|
|
338
365
|
headers.set("host", targetUrl.host);
|
|
366
|
+
headers.delete("authorization"); // Don't leak proxy API keys upstream
|
|
339
367
|
const response = await fetch(targetUrl.toString(), {
|
|
340
368
|
method: req.method,
|
|
341
369
|
headers: headers,
|
package/responses-bridge.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { convertResponsesSyncToChatCompletions, convertResponsesStreamToChatCompletions } from './responses-converters';
|
|
2
2
|
|
|
3
|
-
export
|
|
3
|
+
export interface BridgeResult {
|
|
4
|
+
response: Response;
|
|
5
|
+
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function handleResponsesAPIBridge(json: any, req: Request, chatId: string, targetUrl: string): Promise<BridgeResult> {
|
|
4
9
|
const corsHeaders = { "Access-Control-Allow-Origin": "*" };
|
|
5
10
|
|
|
6
11
|
const responsesReq: any = {
|
|
@@ -55,7 +60,7 @@ export async function handleResponsesAPIBridge(json: any, req: Request, chatId:
|
|
|
55
60
|
|
|
56
61
|
return {
|
|
57
62
|
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
58
|
-
content
|
|
63
|
+
content,
|
|
59
64
|
};
|
|
60
65
|
}).flat();
|
|
61
66
|
} else {
|
|
@@ -96,6 +101,7 @@ export async function handleResponsesAPIBridge(json: any, req: Request, chatId:
|
|
|
96
101
|
headers.set("host", responsesUrl.host);
|
|
97
102
|
headers.set("content-type", "application/json");
|
|
98
103
|
headers.set("content-length", String(new TextEncoder().encode(responsesBody).length));
|
|
104
|
+
headers.delete("authorization"); // Don't leak proxy API keys upstream
|
|
99
105
|
|
|
100
106
|
const response = await fetch(responsesUrl.toString(), {
|
|
101
107
|
method: "POST",
|
|
@@ -108,12 +114,28 @@ export async function handleResponsesAPIBridge(json: any, req: Request, chatId:
|
|
|
108
114
|
if (!response.ok) {
|
|
109
115
|
const errText = await response.text();
|
|
110
116
|
console.error(`❌ Responses API Error (${response.status}):`, errText);
|
|
111
|
-
return
|
|
117
|
+
return {
|
|
118
|
+
response: new Response(errText, { status: response.status, headers: corsHeaders }),
|
|
119
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
120
|
+
};
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
if (json.stream && response.body) {
|
|
115
|
-
return
|
|
124
|
+
return {
|
|
125
|
+
response: convertResponsesStreamToChatCompletions(response, json.model, chatId, corsHeaders),
|
|
126
|
+
// Streaming usage is embedded in the stream; not available synchronously
|
|
127
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
128
|
+
};
|
|
116
129
|
} else {
|
|
117
|
-
|
|
130
|
+
const data = await response.json() as any;
|
|
131
|
+
const usage = data.usage ? {
|
|
132
|
+
promptTokens: data.usage.input_tokens || 0,
|
|
133
|
+
completionTokens: data.usage.output_tokens || 0,
|
|
134
|
+
totalTokens: data.usage.total_tokens || 0,
|
|
135
|
+
} : { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
136
|
+
return {
|
|
137
|
+
response: convertResponsesSyncToChatCompletions(data, json.model, chatId, corsHeaders),
|
|
138
|
+
usage,
|
|
139
|
+
};
|
|
118
140
|
}
|
|
119
141
|
}
|
package/responses-converters.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
export
|
|
2
|
-
const data = await response.json() as any;
|
|
1
|
+
export function convertResponsesSyncToChatCompletions(data: any, model: string, chatId: string, corsHeaders: any) {
|
|
3
2
|
const result: any = {
|
|
4
3
|
id: chatId,
|
|
5
4
|
object: 'chat.completion',
|
|
@@ -49,7 +48,13 @@ export async function convertResponsesSyncToChatCompletions(response: Response,
|
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
export function convertResponsesStreamToChatCompletions(response: Response, model: string, chatId: string, corsHeaders: any) {
|
|
52
|
-
|
|
51
|
+
if (!response.body) {
|
|
52
|
+
return new Response(JSON.stringify({ error: 'No response body' }), {
|
|
53
|
+
status: 502,
|
|
54
|
+
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const reader = response.body.getReader();
|
|
53
58
|
const decoder = new TextDecoder();
|
|
54
59
|
const encoder = new TextEncoder();
|
|
55
60
|
let buffer = '';
|
|
@@ -101,6 +106,7 @@ export function convertResponsesStreamToChatCompletions(response: Response, mode
|
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
else if (eventType === 'response.function_call_arguments.delta') {
|
|
109
|
+
if (toolCallIndex < 1) continue; // Guard against out-of-order events
|
|
104
110
|
controller.enqueue(encoder.encode(makeChatChunk({
|
|
105
111
|
tool_calls: [{
|
|
106
112
|
index: toolCallIndex - 1,
|
package/usage-db.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdirSync, existsSync, readFileSync
|
|
1
|
+
import { mkdirSync, existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { writeFile } from 'fs/promises';
|
|
2
3
|
import { homedir } from 'os';
|
|
3
4
|
import { join } from 'path';
|
|
4
5
|
|
|
@@ -86,7 +87,7 @@ const loadSync = (): UsageData => {
|
|
|
86
87
|
const saveToDisk = async () => {
|
|
87
88
|
try {
|
|
88
89
|
data.lastSavedAt = Date.now();
|
|
89
|
-
|
|
90
|
+
await writeFile(USAGE_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
90
91
|
} catch (e) {
|
|
91
92
|
console.error('Failed to save usage data:', e);
|
|
92
93
|
}
|
|
@@ -205,6 +206,6 @@ export const flushToDisk = async () => {
|
|
|
205
206
|
await saveToDisk();
|
|
206
207
|
};
|
|
207
208
|
|
|
208
|
-
process.on('beforeExit', () => { flushToDisk(); });
|
|
209
|
+
process.on('beforeExit', async () => { await flushToDisk(); });
|
|
209
210
|
process.on('SIGINT', async () => { await flushToDisk(); process.exit(0); });
|
|
210
211
|
process.on('SIGTERM', async () => { await flushToDisk(); process.exit(0); });
|