copilot-cursor-proxy 1.1.1 → 1.2.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 +17 -0
- package/anthropic-transforms.ts +10 -0
- package/dashboard.html +107 -4
- package/max-mode.ts +305 -0
- package/model-routing.ts +3 -0
- package/package.json +36 -36
- package/proxy-router.ts +44 -7
- package/responses-bridge.ts +2 -1
- package/start.ts +17 -1
- package/upstream-auth.ts +82 -0
package/README.md
CHANGED
|
@@ -28,6 +28,14 @@ cd copilot-for-cursor
|
|
|
28
28
|
bun run start.ts
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
### Enable Max Mode (auto-compact long conversations)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bun run start.ts --max
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> **Max mode** automatically compacts conversation history when the estimated token count exceeds 80% of the model's input token limit. It summarizes older messages into a structured summary while keeping the most recent messages intact — letting you have much longer coding sessions without hitting token limits.
|
|
38
|
+
|
|
31
39
|
### Then start an HTTPS tunnel
|
|
32
40
|
|
|
33
41
|
Cursor requires HTTPS. In a second terminal:
|
|
@@ -66,6 +74,10 @@ Cursor → (HTTPS tunnel) → proxy-router (:4142) → copilot-api (:4141) → G
|
|
|
66
74
|
| `stream-proxy.ts` | Streaming passthrough with chunk logging and error detection |
|
|
67
75
|
| `debug-logger.ts` | Request/response debug logging helpers |
|
|
68
76
|
| `start.ts` | One-command launcher for copilot-api + proxy-router |
|
|
77
|
+
| `max-mode.ts` | Auto-compaction for long conversations (`--max` flag) |
|
|
78
|
+
| `usage-db.ts` | Persistent request/token usage tracking |
|
|
79
|
+
| `auth-config.ts` | API key generation, validation, and config persistence |
|
|
80
|
+
| `upstream-auth.ts` | Upstream copilot-api authentication and key management |
|
|
69
81
|
|
|
70
82
|
---
|
|
71
83
|
|
|
@@ -139,6 +151,7 @@ Cursor → (HTTPS tunnel) → proxy-router (:4142) → copilot-api (:4141) → G
|
|
|
139
151
|
* **💻 Terminal:** `Shell` (run commands)
|
|
140
152
|
* **🔍 Search:** `Grep`, `Glob`, `SemanticSearch`
|
|
141
153
|
* **🔌 MCP Tools:** External tools (Neon, Playwright, etc.)
|
|
154
|
+
* **🗜️ Max Mode:** Auto-compact long conversations to stay within token limits (`--max`)
|
|
142
155
|
|
|
143
156
|
---
|
|
144
157
|
|
|
@@ -187,6 +200,7 @@ Three tabs:
|
|
|
187
200
|
| Plan mode | ✅ Works |
|
|
188
201
|
| Agent mode | ✅ Works |
|
|
189
202
|
| All GPT-5.x models | ✅ Works |
|
|
203
|
+
| Max mode (long session compaction) | ✅ Works (`--max` flag) |
|
|
190
204
|
| Extended thinking (chain-of-thought) | ❌ Stripped |
|
|
191
205
|
| Prompt caching (`cache_control`) | ❌ Stripped |
|
|
192
206
|
| Claude Vision | ❌ Not supported via Copilot |
|
|
@@ -208,6 +222,9 @@ The proxy auto-routes these. Make sure you're running the latest version.
|
|
|
208
222
|
**"connection refused":**
|
|
209
223
|
Ensure services are running: `bun run start.ts` or check `http://localhost:4142`.
|
|
210
224
|
|
|
225
|
+
**Max mode not compacting:**
|
|
226
|
+
Compaction only triggers when estimated tokens exceed 80% of the model's limit and there are at least 15 messages. Check the console log for `🗜️ Max mode` messages.
|
|
227
|
+
|
|
211
228
|
---
|
|
212
229
|
|
|
213
230
|
> ⚠️ **DISCLAIMER:** This project is **unofficial** and for **educational purposes only**. It interacts with undocumented internal APIs of GitHub Copilot and Cursor. Use at your own risk. The authors are not affiliated with GitHub, Microsoft, or Anysphere (Cursor). Please use your API credits responsibly and in accordance with the provider's Terms of Service.
|
package/anthropic-transforms.ts
CHANGED
|
@@ -121,6 +121,16 @@ const transformMessages = (json: any, isClaude: boolean): void => {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// Preserve any existing OpenAI-format tool_calls on the message
|
|
125
|
+
// (hybrid format: content is array but tool_calls are separate)
|
|
126
|
+
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
|
127
|
+
for (const tc of msg.tool_calls) {
|
|
128
|
+
if (!toolCalls.some(t => t.id === tc.id)) {
|
|
129
|
+
toolCalls.push(tc);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
124
134
|
const assistantMsg: any = { role: 'assistant' };
|
|
125
135
|
assistantMsg.content = textParts.join('\n') || null;
|
|
126
136
|
if (toolCalls.length > 0) assistantMsg.tool_calls = toolCalls;
|
package/dashboard.html
CHANGED
|
@@ -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>
|
|
@@ -617,6 +639,87 @@ async function fetchModels() {
|
|
|
617
639
|
}
|
|
618
640
|
fetchModels();
|
|
619
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
|
+
|
|
620
723
|
/* ── Tab 1: API Key Management ─────────────────────────────── */
|
|
621
724
|
let authConfig = { requireApiKey: false, keys: [] };
|
|
622
725
|
|
package/max-mode.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { getUpstreamAuthHeader } from './upstream-auth';
|
|
2
|
+
import { needsResponsesAPI } from './model-routing';
|
|
3
|
+
|
|
4
|
+
// ── Global config ─────────────────────────────────────────────────────────────
|
|
5
|
+
let maxModeEnabled = false;
|
|
6
|
+
|
|
7
|
+
export function enableMaxMode(): void {
|
|
8
|
+
maxModeEnabled = true;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isMaxMode(): boolean {
|
|
12
|
+
return maxModeEnabled;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Model token limits cache ──────────────────────────────────────────────────
|
|
16
|
+
interface ModelLimits {
|
|
17
|
+
maxInputTokens: number;
|
|
18
|
+
maxOutputTokens: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const modelLimitsCache = new Map<string, ModelLimits>();
|
|
22
|
+
|
|
23
|
+
// Fallback defaults — only used when upstream /v1/models doesn't return capabilities.limits.
|
|
24
|
+
// Real limits are fetched dynamically from the copilot-api at startup via fetchAndCacheModelLimits().
|
|
25
|
+
// Output token values: Claude 64K (Sonnet 3.5/4 extended), GPT-4/5 16K, o1/o3 100K reasoning.
|
|
26
|
+
const DEFAULT_LIMITS: Record<string, ModelLimits> = {
|
|
27
|
+
'claude': { maxInputTokens: 200000, maxOutputTokens: 64000 },
|
|
28
|
+
'gpt-4': { maxInputTokens: 128000, maxOutputTokens: 16384 },
|
|
29
|
+
'gpt-5': { maxInputTokens: 128000, maxOutputTokens: 16384 },
|
|
30
|
+
'o1': { maxInputTokens: 200000, maxOutputTokens: 100000 },
|
|
31
|
+
'o3': { maxInputTokens: 200000, maxOutputTokens: 100000 },
|
|
32
|
+
'default': { maxInputTokens: 128000, maxOutputTokens: 16384 }, // conservative general-purpose fallback
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function getDefaultLimits(model: string): ModelLimits {
|
|
36
|
+
const lower = model.toLowerCase();
|
|
37
|
+
for (const [prefix, limits] of Object.entries(DEFAULT_LIMITS)) {
|
|
38
|
+
if (prefix !== 'default' && lower.includes(prefix)) return limits;
|
|
39
|
+
}
|
|
40
|
+
return DEFAULT_LIMITS['default'];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function fetchAndCacheModelLimits(targetUrl: string): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
const resp = await fetch(new URL('/v1/models', targetUrl).toString(), {
|
|
46
|
+
headers: { 'Authorization': getUpstreamAuthHeader() },
|
|
47
|
+
signal: AbortSignal.timeout(10000),
|
|
48
|
+
});
|
|
49
|
+
if (!resp.ok) return;
|
|
50
|
+
const data = await resp.json() as any;
|
|
51
|
+
if (!data.data || !Array.isArray(data.data)) return;
|
|
52
|
+
|
|
53
|
+
for (const model of data.data) {
|
|
54
|
+
const limits = model.capabilities?.limits;
|
|
55
|
+
if (limits) {
|
|
56
|
+
modelLimitsCache.set(model.id, {
|
|
57
|
+
maxInputTokens: limits.max_prompt_tokens || limits.max_input_tokens || getDefaultLimits(model.id).maxInputTokens,
|
|
58
|
+
maxOutputTokens: limits.max_output_tokens || getDefaultLimits(model.id).maxOutputTokens,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
console.log(`📋 Max mode: cached token limits for ${modelLimitsCache.size} models`);
|
|
63
|
+
for (const [id, lim] of modelLimitsCache) {
|
|
64
|
+
console.log(` ${id}: input=${lim.maxInputTokens}, output=${lim.maxOutputTokens}`);
|
|
65
|
+
}
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
console.warn(`⚠️ Max mode: failed to fetch model limits: ${e?.message || e}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getModelLimits(model: string): ModelLimits {
|
|
72
|
+
return modelLimitsCache.get(model) || getDefaultLimits(model);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Token estimation ──────────────────────────────────────────────────────────
|
|
76
|
+
// Simple char/4 heuristic — fast, zero-dependency, ~80% accurate for English.
|
|
77
|
+
// For mixed CJK content each character ≈ 1-2 tokens, so we use a blended ratio.
|
|
78
|
+
|
|
79
|
+
function estimateTokens(text: string): number {
|
|
80
|
+
if (!text) return 0;
|
|
81
|
+
// rough estimate: ascii chars / 4, non-ascii chars / 1.5
|
|
82
|
+
let ascii = 0, nonAscii = 0;
|
|
83
|
+
for (let i = 0; i < text.length; i++) {
|
|
84
|
+
if (text.charCodeAt(i) < 128) ascii++;
|
|
85
|
+
else nonAscii++;
|
|
86
|
+
}
|
|
87
|
+
return Math.ceil(ascii / 4 + nonAscii / 1.5);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function estimateMessagesTokens(messages: any[]): number {
|
|
91
|
+
let total = 0;
|
|
92
|
+
for (const msg of messages) {
|
|
93
|
+
// role overhead
|
|
94
|
+
total += 4;
|
|
95
|
+
if (typeof msg.content === 'string') {
|
|
96
|
+
total += estimateTokens(msg.content);
|
|
97
|
+
} else if (Array.isArray(msg.content)) {
|
|
98
|
+
for (const part of msg.content) {
|
|
99
|
+
if (part.type === 'text') total += estimateTokens(part.text || '');
|
|
100
|
+
else total += estimateTokens(JSON.stringify(part));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// tool calls overhead
|
|
104
|
+
if (msg.tool_calls) {
|
|
105
|
+
total += estimateTokens(JSON.stringify(msg.tool_calls));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return total;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
112
|
+
function truncateContent(content: string, maxChars: number): string {
|
|
113
|
+
if (content.length <= maxChars) return content;
|
|
114
|
+
return content.slice(0, maxChars) + '\n... [truncated]';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function extractResponsesTextContent(data: any): string {
|
|
118
|
+
const outputMessages = (data.output || []).filter((item: any) =>
|
|
119
|
+
item.type === 'message' && Array.isArray(item.content)
|
|
120
|
+
);
|
|
121
|
+
const textParts = outputMessages
|
|
122
|
+
.flatMap((item: any) => item.content)
|
|
123
|
+
.filter((part: any) => part.type === 'output_text');
|
|
124
|
+
if (textParts.length === 0) {
|
|
125
|
+
console.warn('⚠️ Max mode: Responses summarization returned no output_text parts');
|
|
126
|
+
}
|
|
127
|
+
return textParts.map((part: any) => part.text).join('');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Summarization prompt ──────────────────────────────────────────────────────
|
|
131
|
+
// Inspired by claude-code/opencode compaction prompts, adapted for proxy use.
|
|
132
|
+
const SUMMARIZATION_PROMPT = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and the assistant's previous actions.
|
|
133
|
+
|
|
134
|
+
Analyze each message chronologically and identify:
|
|
135
|
+
- The user's primary goals and requests
|
|
136
|
+
- Key technical concepts and decisions
|
|
137
|
+
- Files and code sections discussed or modified
|
|
138
|
+
- Problems encountered and solutions applied
|
|
139
|
+
- The current state of work in progress
|
|
140
|
+
|
|
141
|
+
Your summary MUST:
|
|
142
|
+
1. Preserve all file paths, function names, variable names, and code snippets mentioned
|
|
143
|
+
2. Retain exact error messages and their resolutions
|
|
144
|
+
3. Capture the user's original intent and any refinements
|
|
145
|
+
4. Note what has been completed vs what remains to be done
|
|
146
|
+
5. Include enough technical detail to continue the conversation seamlessly
|
|
147
|
+
|
|
148
|
+
Format as a structured summary, not a conversation replay. Be concise but do NOT omit any technical details that would be needed to continue the work.`;
|
|
149
|
+
|
|
150
|
+
// ── Compaction logic ──────────────────────────────────────────────────────────
|
|
151
|
+
// Threshold: compact when estimated input tokens exceed this fraction of model max
|
|
152
|
+
const COMPACT_THRESHOLD = 0.80;
|
|
153
|
+
// Keep the most recent N messages untouched to preserve immediate context
|
|
154
|
+
const KEEP_RECENT_MESSAGES = 10;
|
|
155
|
+
// Never compact if total messages are below this count
|
|
156
|
+
const MIN_MESSAGES_FOR_COMPACTION = 15;
|
|
157
|
+
// Minimum old messages worth summarizing (below this, compaction is skipped)
|
|
158
|
+
const MIN_MESSAGES_TO_SUMMARIZE = 3;
|
|
159
|
+
// Max characters per individual message when building the summarization input
|
|
160
|
+
const MAX_MESSAGE_CHARS_FOR_SUMMARY = 8000;
|
|
161
|
+
// Acknowledgment message inserted after the summary to maintain conversation flow
|
|
162
|
+
const SUMMARY_ACKNOWLEDGMENT = 'Understood. I have the full context from the conversation summary. Let me continue.';
|
|
163
|
+
|
|
164
|
+
export async function compactIfNeeded(
|
|
165
|
+
json: any,
|
|
166
|
+
targetModel: string,
|
|
167
|
+
targetUrl: string,
|
|
168
|
+
): Promise<any> {
|
|
169
|
+
if (!maxModeEnabled) return json;
|
|
170
|
+
if (!json.messages || !Array.isArray(json.messages) || json.messages.length < MIN_MESSAGES_FOR_COMPACTION) {
|
|
171
|
+
return json;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const limits = getModelLimits(targetModel);
|
|
175
|
+
const estimated = estimateMessagesTokens(json.messages);
|
|
176
|
+
const threshold = Math.floor(limits.maxInputTokens * COMPACT_THRESHOLD);
|
|
177
|
+
|
|
178
|
+
if (estimated <= threshold) {
|
|
179
|
+
return json;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(`🗜️ Max mode: estimated ${estimated} tokens exceeds ${COMPACT_THRESHOLD * 100}% of ${limits.maxInputTokens} — compacting`);
|
|
183
|
+
|
|
184
|
+
// Split: system messages + old messages to summarize + recent messages to keep
|
|
185
|
+
const systemMsgs = json.messages.filter((m: any) => m.role === 'system');
|
|
186
|
+
const nonSystemMsgs = json.messages.filter((m: any) => m.role !== 'system');
|
|
187
|
+
// Keep at most half of non-system messages to ensure there's enough old content to summarize
|
|
188
|
+
const keepCount = Math.min(KEEP_RECENT_MESSAGES, Math.floor(nonSystemMsgs.length / 2));
|
|
189
|
+
const recentMsgs = nonSystemMsgs.slice(-keepCount);
|
|
190
|
+
const oldMsgs = nonSystemMsgs.slice(0, -keepCount);
|
|
191
|
+
|
|
192
|
+
if (oldMsgs.length < MIN_MESSAGES_TO_SUMMARIZE) return json; // nothing meaningful to compact
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const summary = await callSummarize(targetModel, oldMsgs, targetUrl);
|
|
196
|
+
if (!summary) return json; // summarization failed, pass through
|
|
197
|
+
|
|
198
|
+
console.log(`🗜️ Max mode: compacted ${oldMsgs.length} messages → 1 summary (${estimateTokens(summary)} est. tokens)`);
|
|
199
|
+
|
|
200
|
+
// Rebuild messages: system + summary-as-user-message + recent
|
|
201
|
+
json.messages = [
|
|
202
|
+
...systemMsgs,
|
|
203
|
+
{ role: 'user', content: `[Conversation Summary]\n${summary}` },
|
|
204
|
+
{ role: 'assistant', content: SUMMARY_ACKNOWLEDGMENT },
|
|
205
|
+
...recentMsgs,
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
return json;
|
|
209
|
+
} catch (e: any) {
|
|
210
|
+
console.error(`❌ Max mode: compaction failed, passing through original:`, e?.message || e);
|
|
211
|
+
return json;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function callSummarize(model: string, messages: any[], targetUrl: string): Promise<string | null> {
|
|
216
|
+
const conversationText = messages.map(m => {
|
|
217
|
+
const content = typeof m.content === 'string'
|
|
218
|
+
? m.content
|
|
219
|
+
: Array.isArray(m.content)
|
|
220
|
+
? m.content.map((p: any) => p.text || JSON.stringify(p)).join('\n')
|
|
221
|
+
: JSON.stringify(m.content);
|
|
222
|
+
const role = m.role || 'unknown';
|
|
223
|
+
const truncated = truncateContent(content, MAX_MESSAGE_CHARS_FOR_SUMMARY);
|
|
224
|
+
return `[${role}]: ${truncated}`;
|
|
225
|
+
}).join('\n\n');
|
|
226
|
+
|
|
227
|
+
console.log(`🗜️ Max mode: sending summarization request (${messages.length} messages → ${model})`);
|
|
228
|
+
|
|
229
|
+
if (needsResponsesAPI(model)) {
|
|
230
|
+
const responsesUrl = new URL('/v1/responses', targetUrl);
|
|
231
|
+
const responsesBody = JSON.stringify({
|
|
232
|
+
model,
|
|
233
|
+
instructions: SUMMARIZATION_PROMPT,
|
|
234
|
+
input: `Please summarize the following conversation:\n\n${conversationText}`,
|
|
235
|
+
max_output_tokens: 4096,
|
|
236
|
+
temperature: 0.2,
|
|
237
|
+
stream: false,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const resp = await fetch(responsesUrl.toString(), {
|
|
241
|
+
method: 'POST',
|
|
242
|
+
headers: {
|
|
243
|
+
'Content-Type': 'application/json',
|
|
244
|
+
'Authorization': getUpstreamAuthHeader(),
|
|
245
|
+
},
|
|
246
|
+
body: responsesBody,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (!resp.ok) {
|
|
250
|
+
const errText = await resp.text();
|
|
251
|
+
console.error(`❌ Max mode summarization failed (${resp.status}):`, errText.slice(0, 500));
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const data = await resp.json() as any;
|
|
256
|
+
const content = extractResponsesTextContent(data);
|
|
257
|
+
|
|
258
|
+
if (content) {
|
|
259
|
+
console.log(`🗜️ Max mode: summarization complete (${estimateTokens(content)} est. tokens)`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return content || null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const summarizeMessages = [
|
|
266
|
+
{ role: 'system', content: SUMMARIZATION_PROMPT },
|
|
267
|
+
{
|
|
268
|
+
role: 'user',
|
|
269
|
+
content: `Please summarize the following conversation:\n\n${conversationText}`,
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
const chatBody = JSON.stringify({
|
|
274
|
+
model,
|
|
275
|
+
messages: summarizeMessages,
|
|
276
|
+
max_tokens: 4096,
|
|
277
|
+
temperature: 0.2,
|
|
278
|
+
stream: false,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const chatUrl = new URL('/v1/chat/completions', targetUrl);
|
|
282
|
+
const resp = await fetch(chatUrl.toString(), {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: {
|
|
285
|
+
'Content-Type': 'application/json',
|
|
286
|
+
'Authorization': getUpstreamAuthHeader(),
|
|
287
|
+
},
|
|
288
|
+
body: chatBody,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
if (!resp.ok) {
|
|
292
|
+
const errText = await resp.text();
|
|
293
|
+
console.error(`❌ Max mode summarization failed (${resp.status}):`, errText.slice(0, 500));
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const data = await resp.json() as any;
|
|
298
|
+
const content = data.choices?.[0]?.message?.content;
|
|
299
|
+
|
|
300
|
+
if (content) {
|
|
301
|
+
console.log(`🗜️ Max mode: summarization complete (${estimateTokens(content)} est. tokens)`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return content || null;
|
|
305
|
+
}
|
package/model-routing.ts
ADDED
package/package.json
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "copilot-cursor-proxy",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Proxy that bridges GitHub Copilot API to Cursor IDE — translates Anthropic format, bridges Responses API for GPT 5.x, and more",
|
|
5
|
-
"bin": {
|
|
6
|
-
"copilot-cursor-proxy": "bin/cli.js"
|
|
7
|
-
},
|
|
8
|
-
"files": [
|
|
9
|
-
"bin/",
|
|
10
|
-
"*.ts",
|
|
11
|
-
"dashboard.html",
|
|
12
|
-
"README.md"
|
|
13
|
-
],
|
|
14
|
-
"scripts": {
|
|
15
|
-
"build": "bun build start.ts
|
|
16
|
-
"dev": "bun run start.ts",
|
|
17
|
-
"start": "bun dist/start.js"
|
|
18
|
-
},
|
|
19
|
-
"keywords": [
|
|
20
|
-
"copilot",
|
|
21
|
-
"cursor",
|
|
22
|
-
"proxy",
|
|
23
|
-
"anthropic",
|
|
24
|
-
"openai",
|
|
25
|
-
"responses-api"
|
|
26
|
-
],
|
|
27
|
-
"license": "MIT",
|
|
28
|
-
"repository": {
|
|
29
|
-
"type": "git",
|
|
30
|
-
"url": "git+https://github.com/CharlesYWL/copilot-for-cursor.git"
|
|
31
|
-
},
|
|
32
|
-
"engines": {
|
|
33
|
-
"node": ">=18",
|
|
34
|
-
"bun": ">=1.0"
|
|
35
|
-
}
|
|
36
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "copilot-cursor-proxy",
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Proxy that bridges GitHub Copilot API to Cursor IDE — translates Anthropic format, bridges Responses API for GPT 5.x, and more",
|
|
5
|
+
"bin": {
|
|
6
|
+
"copilot-cursor-proxy": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"*.ts",
|
|
11
|
+
"dashboard.html",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "bun build start.ts --outdir dist --target node",
|
|
16
|
+
"dev": "bun run start.ts",
|
|
17
|
+
"start": "bun dist/start.js"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"copilot",
|
|
21
|
+
"cursor",
|
|
22
|
+
"proxy",
|
|
23
|
+
"anthropic",
|
|
24
|
+
"openai",
|
|
25
|
+
"responses-api"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/CharlesYWL/copilot-for-cursor.git"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18",
|
|
34
|
+
"bun": ">=1.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/proxy-router.ts
CHANGED
|
@@ -4,6 +4,9 @@ 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';
|
|
8
|
+
import { compactIfNeeded, isMaxMode } from './max-mode';
|
|
9
|
+
import { needsResponsesAPI } from './model-routing';
|
|
7
10
|
|
|
8
11
|
// ── Console capture for SSE streaming ─────────────────────────────────────────
|
|
9
12
|
interface ConsoleLine {
|
|
@@ -169,11 +172,40 @@ Bun.serve({
|
|
|
169
172
|
return Response.json({ ok: true }, { headers: corsHeaders });
|
|
170
173
|
}
|
|
171
174
|
|
|
175
|
+
// ── Upstream (copilot-api) key management ────────────────────────
|
|
176
|
+
if (url.pathname === "/api/upstream-keys" && req.method === "GET") {
|
|
177
|
+
const keys = getUpstreamApiKeys();
|
|
178
|
+
const masked = keys.map(k => k.slice(0, 14) + '...' + k.slice(-4));
|
|
179
|
+
return Response.json({ keys: masked, count: keys.length }, { headers: corsHeaders });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (url.pathname === "/api/upstream-keys" && req.method === "POST") {
|
|
183
|
+
try {
|
|
184
|
+
const newKey = createUpstreamApiKey();
|
|
185
|
+
return Response.json({ key: newKey }, { headers: corsHeaders });
|
|
186
|
+
} catch (e: any) {
|
|
187
|
+
return Response.json({ error: e?.message || 'Failed to create key' }, { status: 500, headers: corsHeaders });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (url.pathname.startsWith("/api/upstream-keys/") && req.method === "DELETE") {
|
|
192
|
+
const keyPrefix = decodeURIComponent(url.pathname.split('/').pop() || '');
|
|
193
|
+
const keys = getUpstreamApiKeys();
|
|
194
|
+
const match = keys.find(k => k.startsWith(keyPrefix) || k.endsWith(keyPrefix));
|
|
195
|
+
if (match) {
|
|
196
|
+
deleteUpstreamApiKey(match);
|
|
197
|
+
return Response.json({ ok: true }, { headers: corsHeaders });
|
|
198
|
+
}
|
|
199
|
+
return Response.json({ error: 'Key not found' }, { status: 404, headers: corsHeaders });
|
|
200
|
+
}
|
|
201
|
+
|
|
172
202
|
// ── Dashboard API: model list (bypasses API key auth) ──────────────
|
|
173
203
|
if (url.pathname === "/api/models" && req.method === "GET") {
|
|
174
204
|
try {
|
|
175
205
|
const modelsUrl = new URL('/v1/models', TARGET_URL);
|
|
176
|
-
const response = await fetch(modelsUrl.toString()
|
|
206
|
+
const response = await fetch(modelsUrl.toString(), {
|
|
207
|
+
headers: { 'Authorization': getUpstreamAuthHeader() },
|
|
208
|
+
});
|
|
177
209
|
const data = await response.json();
|
|
178
210
|
if (data.data && Array.isArray(data.data)) {
|
|
179
211
|
data.data = data.data.map((model: any) => ({
|
|
@@ -241,19 +273,24 @@ Bun.serve({
|
|
|
241
273
|
|
|
242
274
|
logTransformedRequest(json);
|
|
243
275
|
|
|
276
|
+
// ── Max mode: compact long conversations before sending ───────────
|
|
277
|
+
if (isMaxMode()) {
|
|
278
|
+
json = await compactIfNeeded(json, targetModel, TARGET_URL);
|
|
279
|
+
}
|
|
280
|
+
|
|
244
281
|
const headers = new Headers(req.headers);
|
|
245
282
|
headers.set("host", targetUrl.host);
|
|
246
|
-
headers.
|
|
283
|
+
headers.set("authorization", getUpstreamAuthHeader());
|
|
247
284
|
|
|
248
|
-
const
|
|
285
|
+
const shouldUseResponsesAPI = needsResponsesAPI(targetModel);
|
|
249
286
|
|
|
250
|
-
if (
|
|
287
|
+
if (shouldUseResponsesAPI && json.max_tokens) {
|
|
251
288
|
json.max_completion_tokens = json.max_tokens;
|
|
252
289
|
delete json.max_tokens;
|
|
253
290
|
console.log(`🔧 Converted max_tokens → max_completion_tokens`);
|
|
254
291
|
}
|
|
255
292
|
|
|
256
|
-
if (
|
|
293
|
+
if (shouldUseResponsesAPI) {
|
|
257
294
|
console.log(`🔀 Model ${targetModel} — using Responses API bridge`);
|
|
258
295
|
const chatId = `chatcmpl-proxy-${++responseCounter}`;
|
|
259
296
|
try {
|
|
@@ -344,7 +381,7 @@ Bun.serve({
|
|
|
344
381
|
if (req.method === "GET" && url.pathname.includes("/models")) {
|
|
345
382
|
const headers = new Headers(req.headers);
|
|
346
383
|
headers.set("host", targetUrl.host);
|
|
347
|
-
headers.
|
|
384
|
+
headers.set("authorization", getUpstreamAuthHeader());
|
|
348
385
|
const response = await fetch(targetUrl.toString(), { method: "GET", headers: headers });
|
|
349
386
|
const data = await response.json();
|
|
350
387
|
|
|
@@ -363,7 +400,7 @@ Bun.serve({
|
|
|
363
400
|
|
|
364
401
|
const headers = new Headers(req.headers);
|
|
365
402
|
headers.set("host", targetUrl.host);
|
|
366
|
-
headers.
|
|
403
|
+
headers.set("authorization", getUpstreamAuthHeader());
|
|
367
404
|
const response = await fetch(targetUrl.toString(), {
|
|
368
405
|
method: req.method,
|
|
369
406
|
headers: headers,
|
package/responses-bridge.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { convertResponsesSyncToChatCompletions, convertResponsesStreamToChatCompletions } from './responses-converters';
|
|
2
|
+
import { getUpstreamAuthHeader } from './upstream-auth';
|
|
2
3
|
|
|
3
4
|
export interface BridgeResult {
|
|
4
5
|
response: Response;
|
|
@@ -101,7 +102,7 @@ export async function handleResponsesAPIBridge(json: any, req: Request, chatId:
|
|
|
101
102
|
headers.set("host", responsesUrl.host);
|
|
102
103
|
headers.set("content-type", "application/json");
|
|
103
104
|
headers.set("content-length", String(new TextEncoder().encode(responsesBody).length));
|
|
104
|
-
headers.
|
|
105
|
+
headers.set("authorization", getUpstreamAuthHeader());
|
|
105
106
|
|
|
106
107
|
const response = await fetch(responsesUrl.toString(), {
|
|
107
108
|
method: "POST",
|
package/start.ts
CHANGED
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { spawn, sleep } from 'bun';
|
|
8
8
|
import { existsSync } from 'fs';
|
|
9
|
+
import { getUpstreamAuthHeader } from './upstream-auth';
|
|
10
|
+
import { enableMaxMode, isMaxMode, fetchAndCacheModelLimits } from './max-mode';
|
|
11
|
+
|
|
12
|
+
// ── Parse CLI flags ──────────────────────────────────────────────────────────
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
if (args.includes('--max')) {
|
|
15
|
+
enableMaxMode();
|
|
16
|
+
}
|
|
9
17
|
|
|
10
18
|
const COPILOT_API_PORT = 4141;
|
|
11
19
|
const PROXY_PORT = 4142;
|
|
@@ -30,7 +38,9 @@ async function waitForPort(port: number, timeoutMs = 30000): Promise<boolean> {
|
|
|
30
38
|
const start = Date.now();
|
|
31
39
|
while (Date.now() - start < timeoutMs) {
|
|
32
40
|
try {
|
|
33
|
-
const resp = await fetch(`http://localhost:${port}/v1/models
|
|
41
|
+
const resp = await fetch(`http://localhost:${port}/v1/models`, {
|
|
42
|
+
headers: { 'Authorization': getUpstreamAuthHeader() },
|
|
43
|
+
});
|
|
34
44
|
if (resp.ok) return true;
|
|
35
45
|
} catch {}
|
|
36
46
|
await sleep(500);
|
|
@@ -97,6 +107,12 @@ async function main() {
|
|
|
97
107
|
console.log(`${GREEN}✅ copilot-api is ready on port ${COPILOT_API_PORT}${RESET}`);
|
|
98
108
|
}
|
|
99
109
|
|
|
110
|
+
// 1.5 If --max mode, pre-fetch and cache model token limits
|
|
111
|
+
if (isMaxMode()) {
|
|
112
|
+
console.log(`${CYAN}🔥 Max mode enabled — will auto-compact long conversations${RESET}`);
|
|
113
|
+
await fetchAndCacheModelLimits(`http://localhost:${COPILOT_API_PORT}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
// 2. Check if proxy is already running
|
|
101
117
|
const proxyAlreadyRunning = await isPortInUse(PROXY_PORT);
|
|
102
118
|
if (proxyAlreadyRunning) {
|
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
|
+
}
|