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 +1 -1
- package/auth-config.ts +6 -1
- package/dashboard.html +138 -19
- package/package.json +3 -3
- package/proxy-router.ts +79 -21
- package/responses-bridge.ts +28 -5
- package/responses-converters.ts +9 -3
- package/start.ts +4 -1
- package/upstream-auth.ts +82 -0
- 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 -->
|
|
@@ -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">
|
|
380
|
-
<button class="copy-btn" onclick="
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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 =
|
|
635
|
-
|
|
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' : ''}
|
|
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
|
|
646
|
-
|
|
647
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
290
|
+
const bridgeResult = await handleResponsesAPIBridge(json, req, chatId, TARGET_URL);
|
|
224
291
|
addRequestLog({
|
|
225
292
|
id: getNextRequestId(), timestamp: startTime, model: targetModel,
|
|
226
|
-
promptTokens:
|
|
227
|
-
|
|
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
|
|
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,
|
package/responses-bridge.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { convertResponsesSyncToChatCompletions, convertResponsesStreamToChatCompletions } from './responses-converters';
|
|
2
|
+
import { getUpstreamAuthHeader } from './upstream-auth';
|
|
2
3
|
|
|
3
|
-
export
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
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/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);
|
package/upstream-auth.ts
ADDED
|
@@ -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
|
|
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); });
|