copilot-cursor-proxy 1.1.0 β†’ 1.2.0

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 -->
@@ -374,10 +374,10 @@ tr:hover td { background: var(--bg-hover); }
374
374
  </div>
375
375
  </div>
376
376
  <div class="card">
377
- <div class="card-label">API Key</div>
377
+ <div class="card-label">API Key (copilot-api)</div>
378
378
  <div class="copy-row">
379
- <div class="card-value mono">dummy</div>
380
- <button class="copy-btn" onclick="copyText('dummy',this)">Copy</button>
379
+ <div class="card-value mono" id="upstream-key-display">Loading…</div>
380
+ <button class="copy-btn" id="upstream-key-copy-btn" style="display:none" onclick="copyUpstreamKey(this)">Copy</button>
381
381
  </div>
382
382
  </div>
383
383
  <div class="card" style="grid-column: 1 / -1">
@@ -388,10 +388,32 @@ tr:hover td { background: var(--bg-hover); }
388
388
  </div>
389
389
  </div>
390
390
 
391
+ <!-- Upstream (copilot-api) Keys -->
392
+ <div class="card" style="margin-bottom: 24px;">
393
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
394
+ <h3 style="margin: 0; border: none; padding: 0;">Copilot API Keys</h3>
395
+ <button onclick="handleCreateUpstreamKey()" style="background: var(--accent); color: #000; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 600; white-space: nowrap;">+ New Key</button>
396
+ </div>
397
+ <p style="color: #888; font-size: 13px; margin-bottom: 12px;">
398
+ Keys stored in copilot-api's <code style="color:var(--accent)">config.json</code>. The first key is used by the proxy for upstream requests.
399
+ <br><span style="color:var(--yellow);">Note:</span> New keys require a copilot-api restart to take effect.
400
+ </p>
401
+
402
+ <div id="newUpstreamKeyAlert" style="display: none; background: #1a2e1a; border: 1px solid #22c55e; border-radius: 8px; padding: 12px; margin-bottom: 16px;">
403
+ <div style="color: #22c55e; font-weight: 600; margin-bottom: 4px;">Copy this key now β€” it won't be shown in full again!</div>
404
+ <div style="display: flex; align-items: center; gap: 8px;">
405
+ <code id="newUpstreamKeyValue" style="flex: 1; background: #111; padding: 8px; border-radius: 4px; font-size: 13px; color: #22c55e; word-break: break-all;"></code>
406
+ <button onclick="copyText(document.getElementById('newUpstreamKeyValue').textContent, this)" style="background: #333; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer;">Copy</button>
407
+ </div>
408
+ </div>
409
+
410
+ <div id="upstreamKeysList"></div>
411
+ </div>
412
+
391
413
  <!-- API Key Management -->
392
414
  <div class="card" style="margin-bottom: 24px;">
393
415
  <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
394
- <h3 style="margin: 0; border: none; padding: 0;">API Key Protection</h3>
416
+ <h3 style="margin: 0; border: none; padding: 0;">Proxy API Key Protection</h3>
395
417
  <label class="toggle">
396
418
  <input type="checkbox" id="requireKeyToggle" onchange="toggleRequireKey(this.checked)">
397
419
  <span class="toggle-slider"></span>
@@ -537,13 +559,20 @@ tr:hover td { background: var(--bg-hover); }
537
559
  <script>
538
560
  /* ── Tab switching ──────────────────────────────────────────── */
539
561
  const tabs = document.querySelectorAll('.tab');
540
- tabs.forEach(t => t.addEventListener('click', () => {
541
- tabs.forEach(x => x.classList.remove('active'));
562
+ function switchTab(t) {
563
+ tabs.forEach(x => { x.classList.remove('active'); x.setAttribute('aria-selected', 'false'); });
542
564
  document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
543
565
  t.classList.add('active');
566
+ t.setAttribute('aria-selected', 'true');
544
567
  document.getElementById('tab-' + t.dataset.tab).classList.add('active');
545
568
  if (t.dataset.tab === 'usage') fetchUsage();
546
- }));
569
+ }
570
+ tabs.forEach(t => {
571
+ t.addEventListener('click', () => switchTab(t));
572
+ t.addEventListener('keydown', (e) => {
573
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchTab(t); }
574
+ });
575
+ });
547
576
 
548
577
  /* ── Helpers ────────────────────────────────────────────────── */
549
578
  function copyText(text, btn) {
@@ -578,7 +607,8 @@ function esc(s) {
578
607
  /* ── Tab 1: Models ──────────────────────────────────────────── */
579
608
  async function fetchModels() {
580
609
  try {
581
- const res = await fetch('/v1/models');
610
+ // Fetch via /api/models to bypass API key auth for dashboard
611
+ const res = await fetch('/api/models');
582
612
  if (!res.ok) throw new Error('HTTP ' + res.status);
583
613
  const data = await res.json();
584
614
  const models = (data.data || []).sort((a, b) => a.id.localeCompare(b.id));
@@ -598,7 +628,9 @@ async function fetchModels() {
598
628
  '<td><span class="model-badge">' + esc(m.id) + '</span></td>' +
599
629
  '<td style="color:var(--text-dim)">' + esc(origId) + '</td>' +
600
630
  '<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>';
631
+ '<td><button class="copy-btn">Copy</button></td>';
632
+ const btn = tr.querySelector('.copy-btn');
633
+ btn.addEventListener('click', function() { copyText(m.id, this); });
602
634
  tbody.appendChild(tr);
603
635
  });
604
636
  } catch (e) {
@@ -607,6 +639,87 @@ async function fetchModels() {
607
639
  }
608
640
  fetchModels();
609
641
 
642
+ /* ── Tab 1: Upstream (copilot-api) Keys ───────────────────── */
643
+ let fullUpstreamKey = '';
644
+
645
+ async function loadUpstreamKeys() {
646
+ try {
647
+ const resp = await fetch('/api/upstream-keys');
648
+ const data = await resp.json();
649
+ const display = document.getElementById('upstream-key-display');
650
+ const copyBtn = document.getElementById('upstream-key-copy-btn');
651
+
652
+ if (data.keys && data.keys.length > 0) {
653
+ display.textContent = data.keys[0];
654
+ copyBtn.style.display = '';
655
+ } else {
656
+ display.textContent = 'No keys configured';
657
+ display.style.color = 'var(--yellow)';
658
+ }
659
+
660
+ renderUpstreamKeys(data.keys || []);
661
+ } catch (e) {
662
+ document.getElementById('upstream-key-display').textContent = 'Failed to load';
663
+ }
664
+ }
665
+
666
+ function renderUpstreamKeys(maskedKeys) {
667
+ const list = document.getElementById('upstreamKeysList');
668
+ if (maskedKeys.length === 0) {
669
+ list.innerHTML = '<div style="color: #666; font-size: 13px; padding: 12px 0;">No API keys configured. Create one to authenticate with copilot-api.</div>';
670
+ return;
671
+ }
672
+ list.innerHTML = '';
673
+ maskedKeys.forEach((k, i) => {
674
+ const row = document.createElement('div');
675
+ row.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #222;';
676
+ const badge = i === 0
677
+ ? '<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:#1a2332;color:var(--accent);margin-left:8px;">Active</span>'
678
+ : '';
679
+ row.innerHTML =
680
+ '<div style="flex: 1; min-width: 0;">' +
681
+ '<code style="color: #aaa; font-size: 13px;">' + esc(k) + '</code>' + badge +
682
+ '</div>' +
683
+ '<button class="copy-btn" title="Copy masked key" onclick="copyText(\'' + esc(k) + '\', this)">Copy</button>' +
684
+ '<button style="background: none; border: none; color: #666; cursor: pointer; font-size: 16px; padding: 4px 8px;" title="Delete" data-key="' + esc(k) + '">πŸ—‘</button>';
685
+ row.querySelector('button[title="Delete"]').addEventListener('click', function() { handleDeleteUpstreamKey(this.dataset.key); });
686
+ list.appendChild(row);
687
+ });
688
+ }
689
+
690
+ function copyUpstreamKey(btn) {
691
+ const display = document.getElementById('upstream-key-display').textContent;
692
+ copyText(display, btn);
693
+ }
694
+
695
+ async function handleCreateUpstreamKey() {
696
+ try {
697
+ const resp = await fetch('/api/upstream-keys', { method: 'POST' });
698
+ const data = await resp.json();
699
+ if (data.key) {
700
+ document.getElementById('newUpstreamKeyAlert').style.display = 'block';
701
+ document.getElementById('newUpstreamKeyValue').textContent = data.key;
702
+ loadUpstreamKeys();
703
+ }
704
+ } catch (e) {
705
+ alert('Failed to create key: ' + e.message);
706
+ }
707
+ }
708
+
709
+ async function handleDeleteUpstreamKey(maskedKey) {
710
+ if (!confirm('Delete this copilot-api key?')) return;
711
+ const prefix = maskedKey.split('...')[0];
712
+ try {
713
+ await fetch('/api/upstream-keys/' + encodeURIComponent(prefix), { method: 'DELETE' });
714
+ document.getElementById('newUpstreamKeyAlert').style.display = 'none';
715
+ loadUpstreamKeys();
716
+ } catch (e) {
717
+ alert('Failed to delete key: ' + e.message);
718
+ }
719
+ }
720
+
721
+ loadUpstreamKeys();
722
+
610
723
  /* ── Tab 1: API Key Management ─────────────────────────────── */
611
724
  let authConfig = { requireApiKey: false, keys: [] };
612
725
 
@@ -631,10 +744,13 @@ function renderKeys() {
631
744
  list.innerHTML = '<div style="color: #666; font-size: 13px; padding: 12px 0;">No API keys created yet.</div>';
632
745
  return;
633
746
  }
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;">
747
+ list.innerHTML = '';
748
+ authConfig.keys.forEach(k => {
749
+ const row = document.createElement('div');
750
+ row.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #222;';
751
+ row.innerHTML = `
636
752
  <label class="toggle" style="flex-shrink: 0;">
637
- <input type="checkbox" ${k.active ? 'checked' : ''} onchange="toggleKey('${esc(k.id)}', this.checked)">
753
+ <input type="checkbox" ${k.active ? 'checked' : ''}>
638
754
  <span class="toggle-slider"></span>
639
755
  </label>
640
756
  <div style="flex: 1; min-width: 0;">
@@ -642,9 +758,12 @@ function renderKeys() {
642
758
  <code style="color: #888; font-size: 12px;">${esc(k.key)}</code>
643
759
  </div>
644
760
  <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('');
761
+ <button style="background: none; border: none; color: #666; cursor: pointer; font-size: 16px; padding: 4px 8px;" title="Delete">πŸ—‘</button>
762
+ `;
763
+ row.querySelector('input[type="checkbox"]').addEventListener('change', function() { toggleKey(k.id, this.checked); });
764
+ row.querySelector('button[title="Delete"]').addEventListener('click', function() { deleteKey(k.id); });
765
+ list.appendChild(row);
766
+ });
648
767
  }
649
768
 
650
769
  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.2.0",
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,9 +12,9 @@
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 auth-config.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 upstream-auth.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
@@ -4,6 +4,7 @@ import { createStreamProxy } from './stream-proxy';
4
4
  import { logIncomingRequest, logTransformedRequest } from './debug-logger';
5
5
  import { addRequestLog, getNextRequestId, getUsageStats, flushToDisk, type RequestLog } from './usage-db';
6
6
  import { loadAuthConfig, saveAuthConfig, generateApiKey, validateApiKey } from './auth-config';
7
+ import { getUpstreamAuthHeader, getUpstreamApiKeys, createUpstreamApiKey, deleteUpstreamApiKey } from './upstream-auth';
7
8
 
8
9
  // ── Console capture for SSE streaming ─────────────────────────────────────────
9
10
  interface ConsoleLine {
@@ -124,7 +125,19 @@ Bun.serve({
124
125
  }
125
126
 
126
127
  if (url.pathname === "/api/keys" && req.method === "POST") {
127
- const { name } = await req.json();
128
+ let body: unknown;
129
+ try {
130
+ body = await req.json();
131
+ } catch {
132
+ return Response.json({ error: "Invalid JSON body" }, { status: 400, headers: corsHeaders });
133
+ }
134
+ if (typeof body !== 'object' || body === null) {
135
+ return Response.json({ error: "Request body must be a JSON object" }, { status: 400, headers: corsHeaders });
136
+ }
137
+ const { name } = body as { name?: unknown };
138
+ if (name !== undefined && typeof name !== 'string') {
139
+ return Response.json({ error: "`name` must be a string if provided" }, { status: 400, headers: corsHeaders });
140
+ }
128
141
  const config = loadAuthConfig();
129
142
  const newKey = generateApiKey(name || 'Untitled');
130
143
  config.keys.push(newKey);
@@ -157,6 +170,57 @@ Bun.serve({
157
170
  return Response.json({ ok: true }, { headers: corsHeaders });
158
171
  }
159
172
 
173
+ // ── Upstream (copilot-api) key management ────────────────────────
174
+ if (url.pathname === "/api/upstream-keys" && req.method === "GET") {
175
+ const keys = getUpstreamApiKeys();
176
+ const masked = keys.map(k => k.slice(0, 14) + '...' + k.slice(-4));
177
+ return Response.json({ keys: masked, count: keys.length }, { headers: corsHeaders });
178
+ }
179
+
180
+ if (url.pathname === "/api/upstream-keys" && req.method === "POST") {
181
+ try {
182
+ const newKey = createUpstreamApiKey();
183
+ return Response.json({ key: newKey }, { headers: corsHeaders });
184
+ } catch (e: any) {
185
+ return Response.json({ error: e?.message || 'Failed to create key' }, { status: 500, headers: corsHeaders });
186
+ }
187
+ }
188
+
189
+ if (url.pathname.startsWith("/api/upstream-keys/") && req.method === "DELETE") {
190
+ const keyPrefix = decodeURIComponent(url.pathname.split('/').pop() || '');
191
+ const keys = getUpstreamApiKeys();
192
+ const match = keys.find(k => k.startsWith(keyPrefix) || k.endsWith(keyPrefix));
193
+ if (match) {
194
+ deleteUpstreamApiKey(match);
195
+ return Response.json({ ok: true }, { headers: corsHeaders });
196
+ }
197
+ return Response.json({ error: 'Key not found' }, { status: 404, headers: corsHeaders });
198
+ }
199
+
200
+ // ── Dashboard API: model list (bypasses API key auth) ──────────────
201
+ if (url.pathname === "/api/models" && req.method === "GET") {
202
+ try {
203
+ const modelsUrl = new URL('/v1/models', TARGET_URL);
204
+ const response = await fetch(modelsUrl.toString(), {
205
+ headers: { 'Authorization': getUpstreamAuthHeader() },
206
+ });
207
+ const data = await response.json();
208
+ if (data.data && Array.isArray(data.data)) {
209
+ data.data = data.data.map((model: any) => ({
210
+ ...model,
211
+ id: PREFIX + model.id,
212
+ display_name: PREFIX + (model.display_name || model.id)
213
+ }));
214
+ }
215
+ return new Response(JSON.stringify(data), {
216
+ status: response.status,
217
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
218
+ });
219
+ } catch (e: any) {
220
+ return Response.json({ error: e?.message || 'Failed to fetch models' }, { status: 502, headers: corsHeaders });
221
+ }
222
+ }
223
+
160
224
  // ── Proxy logic ───────────────────────────────────────────────────────
161
225
  const targetUrl = new URL(url.pathname + url.search, TARGET_URL);
162
226
 
@@ -170,9 +234,8 @@ Bun.serve({
170
234
  });
171
235
  }
172
236
 
173
- try {
174
- if (req.method === "POST" && url.pathname.includes("/chat/completions")) {
175
- // Check API key if required
237
+ // ── Enforce API key auth on all /v1/* routes ──────────────────────────
238
+ if (url.pathname.startsWith("/v1/")) {
176
239
  const authConfig = loadAuthConfig();
177
240
  if (authConfig.requireApiKey) {
178
241
  const authHeader = req.headers.get('authorization');
@@ -184,7 +247,10 @@ Bun.serve({
184
247
  );
185
248
  }
186
249
  }
250
+ }
187
251
 
252
+ try {
253
+ if (req.method === "POST" && url.pathname.includes("/chat/completions")) {
188
254
  const startTime = Date.now();
189
255
  let json = await req.json();
190
256
 
@@ -207,6 +273,7 @@ Bun.serve({
207
273
 
208
274
  const headers = new Headers(req.headers);
209
275
  headers.set("host", targetUrl.host);
276
+ headers.set("authorization", getUpstreamAuthHeader());
210
277
 
211
278
  const needsResponsesAPI = targetModel.match(/^gpt-5\.[2-9]|^gpt-5\.\d+-codex|^o[1-9]|^goldeneye/i);
212
279
 
@@ -220,13 +287,15 @@ Bun.serve({
220
287
  console.log(`πŸ”€ Model ${targetModel} β€” using Responses API bridge`);
221
288
  const chatId = `chatcmpl-proxy-${++responseCounter}`;
222
289
  try {
223
- const result = await handleResponsesAPIBridge(json, req, chatId, TARGET_URL);
290
+ const bridgeResult = await handleResponsesAPIBridge(json, req, chatId, TARGET_URL);
224
291
  addRequestLog({
225
292
  id: getNextRequestId(), timestamp: startTime, model: targetModel,
226
- promptTokens: 0, completionTokens: 0, totalTokens: 0,
227
- status: result.status, duration: Date.now() - startTime, stream: !!json.stream,
293
+ promptTokens: bridgeResult.usage.promptTokens,
294
+ completionTokens: bridgeResult.usage.completionTokens,
295
+ totalTokens: bridgeResult.usage.totalTokens,
296
+ status: bridgeResult.response.status, duration: Date.now() - startTime, stream: !!json.stream,
228
297
  });
229
- return result;
298
+ return bridgeResult.response;
230
299
  } catch (e: any) {
231
300
  console.error(`❌ Responses API bridge failed for ${targetModel}:`, e?.message || e);
232
301
  return new Response(
@@ -303,21 +372,9 @@ Bun.serve({
303
372
  }
304
373
 
305
374
  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
375
  const headers = new Headers(req.headers);
320
376
  headers.set("host", targetUrl.host);
377
+ headers.set("authorization", getUpstreamAuthHeader());
321
378
  const response = await fetch(targetUrl.toString(), { method: "GET", headers: headers });
322
379
  const data = await response.json();
323
380
 
@@ -336,6 +393,7 @@ Bun.serve({
336
393
 
337
394
  const headers = new Headers(req.headers);
338
395
  headers.set("host", targetUrl.host);
396
+ headers.set("authorization", getUpstreamAuthHeader());
339
397
  const response = await fetch(targetUrl.toString(), {
340
398
  method: req.method,
341
399
  headers: headers,
@@ -1,6 +1,12 @@
1
1
  import { convertResponsesSyncToChatCompletions, convertResponsesStreamToChatCompletions } from './responses-converters';
2
+ import { getUpstreamAuthHeader } from './upstream-auth';
2
3
 
3
- export async function handleResponsesAPIBridge(json: any, req: Request, chatId: string, targetUrl: string) {
4
+ export interface BridgeResult {
5
+ response: Response;
6
+ usage: { promptTokens: number; completionTokens: number; totalTokens: number };
7
+ }
8
+
9
+ export async function handleResponsesAPIBridge(json: any, req: Request, chatId: string, targetUrl: string): Promise<BridgeResult> {
4
10
  const corsHeaders = { "Access-Control-Allow-Origin": "*" };
5
11
 
6
12
  const responsesReq: any = {
@@ -55,7 +61,7 @@ export async function handleResponsesAPIBridge(json: any, req: Request, chatId:
55
61
 
56
62
  return {
57
63
  role: m.role === 'assistant' ? 'assistant' : 'user',
58
- content: typeof content === 'string' ? content : content,
64
+ content,
59
65
  };
60
66
  }).flat();
61
67
  } else {
@@ -96,6 +102,7 @@ export async function handleResponsesAPIBridge(json: any, req: Request, chatId:
96
102
  headers.set("host", responsesUrl.host);
97
103
  headers.set("content-type", "application/json");
98
104
  headers.set("content-length", String(new TextEncoder().encode(responsesBody).length));
105
+ headers.set("authorization", getUpstreamAuthHeader());
99
106
 
100
107
  const response = await fetch(responsesUrl.toString(), {
101
108
  method: "POST",
@@ -108,12 +115,28 @@ export async function handleResponsesAPIBridge(json: any, req: Request, chatId:
108
115
  if (!response.ok) {
109
116
  const errText = await response.text();
110
117
  console.error(`❌ Responses API Error (${response.status}):`, errText);
111
- return new Response(errText, { status: response.status, headers: corsHeaders });
118
+ return {
119
+ response: new Response(errText, { status: response.status, headers: corsHeaders }),
120
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
121
+ };
112
122
  }
113
123
 
114
124
  if (json.stream && response.body) {
115
- return convertResponsesStreamToChatCompletions(response, json.model, chatId, corsHeaders);
125
+ return {
126
+ response: convertResponsesStreamToChatCompletions(response, json.model, chatId, corsHeaders),
127
+ // Streaming usage is embedded in the stream; not available synchronously
128
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
129
+ };
116
130
  } else {
117
- return convertResponsesSyncToChatCompletions(response, json.model, chatId, corsHeaders);
131
+ const data = await response.json() as any;
132
+ const usage = data.usage ? {
133
+ promptTokens: data.usage.input_tokens || 0,
134
+ completionTokens: data.usage.output_tokens || 0,
135
+ totalTokens: data.usage.total_tokens || 0,
136
+ } : { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
137
+ return {
138
+ response: convertResponsesSyncToChatCompletions(data, json.model, chatId, corsHeaders),
139
+ usage,
140
+ };
118
141
  }
119
142
  }
@@ -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/start.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { spawn, sleep } from 'bun';
8
8
  import { existsSync } from 'fs';
9
+ import { getUpstreamAuthHeader } from './upstream-auth';
9
10
 
10
11
  const COPILOT_API_PORT = 4141;
11
12
  const PROXY_PORT = 4142;
@@ -30,7 +31,9 @@ async function waitForPort(port: number, timeoutMs = 30000): Promise<boolean> {
30
31
  const start = Date.now();
31
32
  while (Date.now() - start < timeoutMs) {
32
33
  try {
33
- const resp = await fetch(`http://localhost:${port}/v1/models`);
34
+ const resp = await fetch(`http://localhost:${port}/v1/models`, {
35
+ headers: { 'Authorization': getUpstreamAuthHeader() },
36
+ });
34
37
  if (resp.ok) return true;
35
38
  } catch {}
36
39
  await sleep(500);
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { randomBytes } from 'crypto';
5
+
6
+ const DEFAULT_DATA_DIR = join(homedir(), '.local', 'share', 'copilot-api');
7
+
8
+ interface CopilotApiConfig {
9
+ auth?: { apiKeys?: string[] };
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ let cachedKey: string | null = null;
14
+
15
+ function getConfigPath(): string {
16
+ const dataDir = process.env.COPILOT_API_HOME || DEFAULT_DATA_DIR;
17
+ return join(dataDir, 'config.json');
18
+ }
19
+
20
+ function loadUpstreamConfig(): CopilotApiConfig {
21
+ const configPath = getConfigPath();
22
+ try {
23
+ if (existsSync(configPath)) {
24
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
25
+ }
26
+ } catch {}
27
+ return {};
28
+ }
29
+
30
+ function saveUpstreamConfig(config: CopilotApiConfig): void {
31
+ const configPath = getConfigPath();
32
+ const dir = join(configPath, '..');
33
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
34
+ writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
35
+ cachedKey = null;
36
+ }
37
+
38
+ export function getUpstreamApiKeys(): string[] {
39
+ const config = loadUpstreamConfig();
40
+ return Array.isArray(config.auth?.apiKeys) ? config.auth!.apiKeys! : [];
41
+ }
42
+
43
+ export function getUpstreamApiKey(): string {
44
+ if (cachedKey) return cachedKey;
45
+
46
+ const keys = getUpstreamApiKeys();
47
+ if (keys.length > 0) {
48
+ cachedKey = keys[0];
49
+ return cachedKey;
50
+ }
51
+
52
+ cachedKey = 'dummy';
53
+ return cachedKey;
54
+ }
55
+
56
+ export function getUpstreamAuthHeader(): string {
57
+ return `Bearer ${getUpstreamApiKey()}`;
58
+ }
59
+
60
+ export function createUpstreamApiKey(): string {
61
+ const config = loadUpstreamConfig();
62
+ if (!config.auth) config.auth = {};
63
+ if (!Array.isArray(config.auth.apiKeys)) config.auth.apiKeys = [];
64
+
65
+ const newKey = 'sk-copilot-' + randomBytes(16).toString('base64url');
66
+ config.auth.apiKeys.push(newKey);
67
+ saveUpstreamConfig(config);
68
+ return newKey;
69
+ }
70
+
71
+ export function deleteUpstreamApiKey(key: string): boolean {
72
+ const config = loadUpstreamConfig();
73
+ const keys = config.auth?.apiKeys;
74
+ if (!Array.isArray(keys)) return false;
75
+
76
+ const idx = keys.indexOf(key);
77
+ if (idx === -1) return false;
78
+
79
+ keys.splice(idx, 1);
80
+ saveUpstreamConfig(config);
81
+ return true;
82
+ }
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); });