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 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.
@@ -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">dummy</div>
380
- <button class="copy-btn" onclick="copyText('dummy',this)">Copy</button>
379
+ <div class="card-value mono" id="upstream-key-display">Loading…</div>
380
+ <button class="copy-btn" id="upstream-key-copy-btn" style="display:none" onclick="copyUpstreamKey(this)">Copy</button>
381
381
  </div>
382
382
  </div>
383
383
  <div class="card" style="grid-column: 1 / -1">
@@ -388,10 +388,32 @@ tr:hover td { background: var(--bg-hover); }
388
388
  </div>
389
389
  </div>
390
390
 
391
+ <!-- Upstream (copilot-api) Keys -->
392
+ <div class="card" style="margin-bottom: 24px;">
393
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
394
+ <h3 style="margin: 0; border: none; padding: 0;">Copilot API Keys</h3>
395
+ <button onclick="handleCreateUpstreamKey()" style="background: var(--accent); color: #000; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 600; white-space: nowrap;">+ New Key</button>
396
+ </div>
397
+ <p style="color: #888; font-size: 13px; margin-bottom: 12px;">
398
+ Keys stored in copilot-api's <code style="color:var(--accent)">config.json</code>. The first key is used by the proxy for upstream requests.
399
+ <br><span style="color:var(--yellow);">Note:</span> New keys require a copilot-api restart to take effect.
400
+ </p>
401
+
402
+ <div id="newUpstreamKeyAlert" style="display: none; background: #1a2e1a; border: 1px solid #22c55e; border-radius: 8px; padding: 12px; margin-bottom: 16px;">
403
+ <div style="color: #22c55e; font-weight: 600; margin-bottom: 4px;">Copy this key now — it won't be shown in full again!</div>
404
+ <div style="display: flex; align-items: center; gap: 8px;">
405
+ <code id="newUpstreamKeyValue" style="flex: 1; background: #111; padding: 8px; border-radius: 4px; font-size: 13px; color: #22c55e; word-break: break-all;"></code>
406
+ <button onclick="copyText(document.getElementById('newUpstreamKeyValue').textContent, this)" style="background: #333; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer;">Copy</button>
407
+ </div>
408
+ </div>
409
+
410
+ <div id="upstreamKeysList"></div>
411
+ </div>
412
+
391
413
  <!-- API Key Management -->
392
414
  <div class="card" style="margin-bottom: 24px;">
393
415
  <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
394
- <h3 style="margin: 0; border: none; padding: 0;">API Key Protection</h3>
416
+ <h3 style="margin: 0; border: none; padding: 0;">Proxy API Key Protection</h3>
395
417
  <label class="toggle">
396
418
  <input type="checkbox" id="requireKeyToggle" onchange="toggleRequireKey(this.checked)">
397
419
  <span class="toggle-slider"></span>
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export function needsResponsesAPI(model: string): boolean {
2
+ return /^(?:gpt-5\.(?:[2-9]|\d{2,})(?:-codex)?|o\d+|goldeneye)/i.test(model);
3
+ }
package/package.json CHANGED
@@ -1,36 +1,36 @@
1
- {
2
- "name": "copilot-cursor-proxy",
3
- "version": "1.1.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 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
- "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.delete("authorization"); // Don't leak proxy API keys upstream
283
+ headers.set("authorization", getUpstreamAuthHeader());
247
284
 
248
- const needsResponsesAPI = targetModel.match(/^gpt-5\.[2-9]|^gpt-5\.\d+-codex|^o[1-9]|^goldeneye/i);
285
+ const shouldUseResponsesAPI = needsResponsesAPI(targetModel);
249
286
 
250
- if (needsResponsesAPI && json.max_tokens) {
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 (needsResponsesAPI) {
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.delete("authorization"); // Don't leak proxy API keys upstream
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.delete("authorization"); // Don't leak proxy API keys upstream
403
+ headers.set("authorization", getUpstreamAuthHeader());
367
404
  const response = await fetch(targetUrl.toString(), {
368
405
  method: req.method,
369
406
  headers: headers,
@@ -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.delete("authorization"); // Don't leak proxy API keys upstream
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) {
@@ -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
+ }