copilot-cursor-proxy 1.0.4 → 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 +14 -14
- package/auth-config.ts +6 -1
- package/dashboard.html +31 -15
- package/package.json +2 -2
- package/proxy-router.ts +63 -36
- package/responses-bridge.ts +27 -5
- package/responses-converters.ts +9 -3
- package/start.ts +1 -1
- package/usage-db.ts +4 -3
package/README.md
CHANGED
|
@@ -46,12 +46,12 @@ 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
|
|
|
53
|
-
* **Port 4141 (`copilot-api`):** Authenticates with GitHub
|
|
54
|
-
* *Powered by [copilot-api](https://
|
|
53
|
+
* **Port 4141 (`copilot-api`):** Authenticates with GitHub, provides the OpenAI-compatible API, and natively handles the Responses API for GPT-5.x models.
|
|
54
|
+
* *Powered by [@jeffreycao/copilot-api](https://github.com/caozhiyuan/copilot-api) (installed via `npx`).*
|
|
55
55
|
* **Port 4142 (`proxy-router`):** Converts Anthropic-format messages to OpenAI format, bridges Responses API for GPT-5.x models, handles the `cus-` prefix, and serves the dashboard.
|
|
56
56
|
* **HTTPS tunnel:** Cursor requires HTTPS — a tunnel exposes the local proxy.
|
|
57
57
|
|
|
@@ -81,32 +81,32 @@ Cursor → (HTTPS tunnel) → proxy-router (:4142) → copilot-api (:4141) → G
|
|
|
81
81
|
|
|
82
82
|
> **💡 Tip:** Visit the [Dashboard](http://localhost:4142) to see all available models and copy their IDs.
|
|
83
83
|
|
|
84
|
-
### Tested Models (
|
|
84
|
+
### Tested Models (19/20 passing)
|
|
85
85
|
|
|
86
86
|
| Cursor Model Name | Actual Model | Status |
|
|
87
87
|
|---|---|---|
|
|
88
88
|
| `cus-gpt-4o` | GPT-4o | ✅ |
|
|
89
89
|
| `cus-gpt-4.1` | GPT-4.1 | ✅ |
|
|
90
|
+
| `cus-gpt-41-copilot` | GPT-4.1 Copilot | ❌ Not supported by GitHub |
|
|
90
91
|
| `cus-gpt-5-mini` | GPT-5 Mini | ✅ |
|
|
91
|
-
| `cus-gpt-5.1` | GPT-5.1 | ✅ |
|
|
92
|
-
| `cus-gpt-5.2` | GPT-5.2 |
|
|
93
|
-
| `cus-gpt-5.2-codex` | GPT-5.2 Codex |
|
|
94
|
-
| `cus-gpt-5.3-codex` | GPT-5.3 Codex |
|
|
95
|
-
| `cus-gpt-5.4` | GPT-5.4 |
|
|
96
|
-
| `cus-gpt-5.4-mini` | GPT-5.4 Mini |
|
|
97
|
-
| `cus-goldeneye` | Goldeneye | ⚠️ See note |
|
|
92
|
+
| `cus-gpt-5.1` | GPT-5.1 | ✅ (deprecating 2026-04-15) |
|
|
93
|
+
| `cus-gpt-5.2` | GPT-5.2 | ✅ |
|
|
94
|
+
| `cus-gpt-5.2-codex` | GPT-5.2 Codex | ✅ |
|
|
95
|
+
| `cus-gpt-5.3-codex` | GPT-5.3 Codex | ✅ |
|
|
96
|
+
| `cus-gpt-5.4` | GPT-5.4 | ✅ |
|
|
97
|
+
| `cus-gpt-5.4-mini` | GPT-5.4 Mini | ✅ |
|
|
98
98
|
| `cus-claude-haiku-4.5` | Claude Haiku 4.5 | ✅ |
|
|
99
99
|
| `cus-claude-sonnet-4` | Claude Sonnet 4 | ✅ |
|
|
100
100
|
| `cus-claude-sonnet-4.5` | Claude Sonnet 4.5 | ✅ |
|
|
101
101
|
| `cus-claude-sonnet-4.6` | Claude Sonnet 4.6 | ✅ |
|
|
102
102
|
| `cus-claude-opus-4.5` | Claude Opus 4.5 | ✅ |
|
|
103
103
|
| `cus-claude-opus-4.6` | Claude Opus 4.6 | ✅ |
|
|
104
|
-
| `cus-claude-opus-4.6-1m` | Claude Opus 4.6 (1M) | ✅ |
|
|
105
104
|
| `cus-gemini-2.5-pro` | Gemini 2.5 Pro | ✅ |
|
|
106
105
|
| `cus-gemini-3-flash-preview` | Gemini 3 Flash | ✅ |
|
|
107
106
|
| `cus-gemini-3.1-pro-preview` | Gemini 3.1 Pro | ✅ |
|
|
107
|
+
| `cus-text-embedding-3-small` | Text Embedding 3 Small | N/A (embedding model) |
|
|
108
108
|
|
|
109
|
-
>
|
|
109
|
+
> All GPT-5.x models now work thanks to the switch to [@jeffreycao/copilot-api](https://github.com/caozhiyuan/copilot-api), which natively supports the Responses API. The proxy also includes its own Responses API bridge as a fallback.
|
|
110
110
|
|
|
111
111
|

|
|
112
112
|
|
|
@@ -186,7 +186,7 @@ Three tabs:
|
|
|
186
186
|
| Streaming | ✅ Works |
|
|
187
187
|
| Plan mode | ✅ Works |
|
|
188
188
|
| Agent mode | ✅ Works |
|
|
189
|
-
| GPT-5.x models |
|
|
189
|
+
| All GPT-5.x models | ✅ Works |
|
|
190
190
|
| Extended thinking (chain-of-thought) | ❌ Stripped |
|
|
191
191
|
| Prompt caching (`cache_control`) | ❌ Stripped |
|
|
192
192
|
| Claude Vision | ❌ Not supported via Copilot |
|
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.
|
|
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
|
|
|
@@ -205,38 +241,37 @@ Bun.serve({
|
|
|
205
241
|
|
|
206
242
|
logTransformedRequest(json);
|
|
207
243
|
|
|
208
|
-
const body = JSON.stringify(json);
|
|
209
244
|
const headers = new Headers(req.headers);
|
|
210
245
|
headers.set("host", targetUrl.host);
|
|
211
|
-
headers.
|
|
246
|
+
headers.delete("authorization"); // Don't leak proxy API keys upstream
|
|
212
247
|
|
|
213
248
|
const needsResponsesAPI = targetModel.match(/^gpt-5\.[2-9]|^gpt-5\.\d+-codex|^o[1-9]|^goldeneye/i);
|
|
214
249
|
|
|
215
|
-
|
|
216
|
-
const needsMaxCompletionTokens = targetModel.match(/^gpt-5\.[2-9]|^gpt-5\.\d+-codex|^goldeneye/i);
|
|
217
|
-
if (needsMaxCompletionTokens && json.max_tokens) {
|
|
250
|
+
if (needsResponsesAPI && json.max_tokens) {
|
|
218
251
|
json.max_completion_tokens = json.max_tokens;
|
|
219
252
|
delete json.max_tokens;
|
|
220
253
|
console.log(`🔧 Converted max_tokens → max_completion_tokens`);
|
|
221
254
|
}
|
|
222
255
|
|
|
223
|
-
// Try Responses API first for models that may need it; fall back to chat completions
|
|
224
256
|
if (needsResponsesAPI) {
|
|
225
|
-
console.log(`🔀 Model ${targetModel} —
|
|
257
|
+
console.log(`🔀 Model ${targetModel} — using Responses API bridge`);
|
|
226
258
|
const chatId = `chatcmpl-proxy-${++responseCounter}`;
|
|
227
259
|
try {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
260
|
+
const bridgeResult = await handleResponsesAPIBridge(json, req, chatId, TARGET_URL);
|
|
261
|
+
addRequestLog({
|
|
262
|
+
id: getNextRequestId(), timestamp: startTime, model: targetModel,
|
|
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,
|
|
267
|
+
});
|
|
268
|
+
return bridgeResult.response;
|
|
269
|
+
} catch (e: any) {
|
|
270
|
+
console.error(`❌ Responses API bridge failed for ${targetModel}:`, e?.message || e);
|
|
271
|
+
return new Response(
|
|
272
|
+
JSON.stringify({ error: { message: `Responses API bridge failed: ${e?.message || 'Unknown error'}`, type: "proxy_error" } }),
|
|
273
|
+
{ status: 502, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }
|
|
274
|
+
);
|
|
240
275
|
}
|
|
241
276
|
}
|
|
242
277
|
|
|
@@ -248,6 +283,9 @@ Bun.serve({
|
|
|
248
283
|
headers.set("Copilot-Vision-Request", "true");
|
|
249
284
|
}
|
|
250
285
|
|
|
286
|
+
const body = JSON.stringify(json);
|
|
287
|
+
headers.set("content-length", String(new TextEncoder().encode(body).length));
|
|
288
|
+
|
|
251
289
|
const response = await fetch(targetUrl.toString(), {
|
|
252
290
|
method: "POST",
|
|
253
291
|
headers: headers,
|
|
@@ -304,21 +342,9 @@ Bun.serve({
|
|
|
304
342
|
}
|
|
305
343
|
|
|
306
344
|
if (req.method === "GET" && url.pathname.includes("/models")) {
|
|
307
|
-
// Check API key if required
|
|
308
|
-
const authConfig = loadAuthConfig();
|
|
309
|
-
if (authConfig.requireApiKey) {
|
|
310
|
-
const authHeader = req.headers.get('authorization');
|
|
311
|
-
const providedKey = authHeader?.replace('Bearer ', '');
|
|
312
|
-
if (!providedKey || !validateApiKey(providedKey)) {
|
|
313
|
-
return Response.json(
|
|
314
|
-
{ error: { message: "Invalid API key. Generate one from the dashboard.", type: "invalid_api_key" } },
|
|
315
|
-
{ status: 401, headers: { "Access-Control-Allow-Origin": "*" } }
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
345
|
const headers = new Headers(req.headers);
|
|
321
346
|
headers.set("host", targetUrl.host);
|
|
347
|
+
headers.delete("authorization"); // Don't leak proxy API keys upstream
|
|
322
348
|
const response = await fetch(targetUrl.toString(), { method: "GET", headers: headers });
|
|
323
349
|
const data = await response.json();
|
|
324
350
|
|
|
@@ -337,6 +363,7 @@ Bun.serve({
|
|
|
337
363
|
|
|
338
364
|
const headers = new Headers(req.headers);
|
|
339
365
|
headers.set("host", targetUrl.host);
|
|
366
|
+
headers.delete("authorization"); // Don't leak proxy API keys upstream
|
|
340
367
|
const response = await fetch(targetUrl.toString(), {
|
|
341
368
|
method: req.method,
|
|
342
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/start.ts
CHANGED
|
@@ -54,7 +54,7 @@ async function main() {
|
|
|
54
54
|
const isWindows = process.platform === 'win32';
|
|
55
55
|
const npxCmd = isWindows ? 'npx.cmd' : 'npx';
|
|
56
56
|
|
|
57
|
-
copilotProc = spawn([npxCmd, 'copilot-api', 'start'], {
|
|
57
|
+
copilotProc = spawn([npxCmd, '@jeffreycao/copilot-api@latest', 'start'], {
|
|
58
58
|
stdout: 'pipe',
|
|
59
59
|
stderr: 'pipe',
|
|
60
60
|
});
|
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); });
|