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 CHANGED
@@ -46,7 +46,7 @@ Copy the HTTPS URL (e.g., `https://xxxxx.trycloudflare.com`).
46
46
 
47
47
  ## 🏗 Architecture
48
48
 
49
- ```
49
+ ```text
50
50
  Cursor → (HTTPS tunnel) → proxy-router (:4142) → copilot-api (:4141) → GitHub Copilot
51
51
  ```
52
52
 
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
- return {
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
- tabs.forEach(t => t.addEventListener('click', () => {
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
- const res = await fetch('/v1/models');
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" onclick="copyText(\'' + esc(m.id).replace(/'/g, "\\'") + '\',this)">Copy</button></td>';
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 = authConfig.keys.map(k => `
635
- <div style="display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #222;">
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' : ''} onchange="toggleKey('${esc(k.id)}', this.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 onclick="deleteKey('${esc(k.id)}')" style="background: none; border: none; color: #666; cursor: pointer; font-size: 16px; padding: 4px 8px;" title="Delete">🗑</button>
646
- </div>
647
- `).join('');
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.0",
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": "node dist/start.js"
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
- const { name } = await req.json();
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
- try {
174
- if (req.method === "POST" && url.pathname.includes("/chat/completions")) {
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 result = await handleResponsesAPIBridge(json, req, chatId, TARGET_URL);
260
+ const bridgeResult = await handleResponsesAPIBridge(json, req, chatId, TARGET_URL);
224
261
  addRequestLog({
225
262
  id: getNextRequestId(), timestamp: startTime, model: targetModel,
226
- promptTokens: 0, completionTokens: 0, totalTokens: 0,
227
- status: result.status, duration: Date.now() - startTime, stream: !!json.stream,
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 result;
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,
@@ -1,6 +1,11 @@
1
1
  import { convertResponsesSyncToChatCompletions, convertResponsesStreamToChatCompletions } from './responses-converters';
2
2
 
3
- export async function handleResponsesAPIBridge(json: any, req: Request, chatId: string, targetUrl: string) {
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: typeof content === 'string' ? content : 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 new Response(errText, { status: response.status, headers: corsHeaders });
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 convertResponsesStreamToChatCompletions(response, json.model, chatId, corsHeaders);
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
- return convertResponsesSyncToChatCompletions(response, json.model, chatId, corsHeaders);
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
  }
@@ -1,5 +1,4 @@
1
- export async function convertResponsesSyncToChatCompletions(response: Response, model: string, chatId: string, corsHeaders: any) {
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
- const reader = response.body!.getReader();
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, writeFileSync } from 'fs';
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
- writeFileSync(USAGE_FILE, JSON.stringify(data, null, 2), 'utf-8');
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); });