copilot-cursor-proxy 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +226 -0
- package/anthropic-transforms.ts +185 -0
- package/bin/cli.js +49 -0
- package/dashboard.html +299 -0
- package/debug-logger.ts +53 -0
- package/package.json +36 -0
- package/proxy-router.ts +148 -0
- package/responses-bridge.ts +119 -0
- package/responses-converters.ts +170 -0
- package/start.ts +138 -0
- package/stream-proxy.ts +50 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export async function convertResponsesSyncToChatCompletions(response: Response, model: string, chatId: string, corsHeaders: any) {
|
|
2
|
+
const data = await response.json() as any;
|
|
3
|
+
const result: any = {
|
|
4
|
+
id: chatId,
|
|
5
|
+
object: 'chat.completion',
|
|
6
|
+
created: data.created_at || Math.floor(Date.now() / 1000),
|
|
7
|
+
model: model,
|
|
8
|
+
choices: [],
|
|
9
|
+
usage: data.usage ? {
|
|
10
|
+
prompt_tokens: data.usage.input_tokens,
|
|
11
|
+
completion_tokens: data.usage.output_tokens,
|
|
12
|
+
total_tokens: data.usage.total_tokens,
|
|
13
|
+
} : undefined,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let textContent = '';
|
|
17
|
+
const toolCalls: any[] = [];
|
|
18
|
+
|
|
19
|
+
for (const item of (data.output || [])) {
|
|
20
|
+
if (item.type === 'message' && item.content) {
|
|
21
|
+
for (const part of item.content) {
|
|
22
|
+
if (part.type === 'output_text') textContent += part.text;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (item.type === 'function_call') {
|
|
26
|
+
toolCalls.push({
|
|
27
|
+
id: item.call_id || item.id,
|
|
28
|
+
type: 'function',
|
|
29
|
+
function: { name: item.name, arguments: item.arguments },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const choice: any = {
|
|
35
|
+
index: 0,
|
|
36
|
+
message: {
|
|
37
|
+
role: 'assistant',
|
|
38
|
+
content: textContent || null,
|
|
39
|
+
},
|
|
40
|
+
finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
|
41
|
+
};
|
|
42
|
+
if (toolCalls.length > 0) choice.message.tool_calls = toolCalls;
|
|
43
|
+
result.choices.push(choice);
|
|
44
|
+
|
|
45
|
+
return new Response(JSON.stringify(result), {
|
|
46
|
+
status: 200,
|
|
47
|
+
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function convertResponsesStreamToChatCompletions(response: Response, model: string, chatId: string, corsHeaders: any) {
|
|
52
|
+
const reader = response.body!.getReader();
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
const encoder = new TextEncoder();
|
|
55
|
+
let buffer = '';
|
|
56
|
+
let toolCallIndex = 0;
|
|
57
|
+
let sentRole = false;
|
|
58
|
+
let chunkCount = 0;
|
|
59
|
+
|
|
60
|
+
const makeChatChunk = (delta: any, finishReason: string | null = null) => {
|
|
61
|
+
const chunk: any = {
|
|
62
|
+
id: chatId,
|
|
63
|
+
object: 'chat.completion.chunk',
|
|
64
|
+
created: Math.floor(Date.now() / 1000),
|
|
65
|
+
model: model,
|
|
66
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
67
|
+
};
|
|
68
|
+
return `data: ${JSON.stringify(chunk)}\n\n`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function processLines(lines: string[], controller: ReadableStreamDefaultController) {
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
if (!line.startsWith('data: ')) continue;
|
|
74
|
+
const jsonStr = line.slice(6).trim();
|
|
75
|
+
if (!jsonStr || jsonStr === '[DONE]') continue;
|
|
76
|
+
|
|
77
|
+
let event: any;
|
|
78
|
+
try { event = JSON.parse(jsonStr); } catch { continue; }
|
|
79
|
+
|
|
80
|
+
const eventType = event.type;
|
|
81
|
+
|
|
82
|
+
if (eventType === 'response.output_text.delta') {
|
|
83
|
+
const delta: any = { content: event.delta };
|
|
84
|
+
if (!sentRole) { delta.role = 'assistant'; sentRole = true; }
|
|
85
|
+
controller.enqueue(encoder.encode(makeChatChunk(delta)));
|
|
86
|
+
chunkCount++;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
else if (eventType === 'response.output_item.added' && event.item?.type === 'function_call') {
|
|
90
|
+
const delta: any = {
|
|
91
|
+
tool_calls: [{
|
|
92
|
+
index: toolCallIndex,
|
|
93
|
+
id: event.item.call_id || event.item.id,
|
|
94
|
+
type: 'function',
|
|
95
|
+
function: { name: event.item.name, arguments: '' },
|
|
96
|
+
}]
|
|
97
|
+
};
|
|
98
|
+
if (!sentRole) { delta.role = 'assistant'; sentRole = true; }
|
|
99
|
+
controller.enqueue(encoder.encode(makeChatChunk(delta)));
|
|
100
|
+
chunkCount++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
else if (eventType === 'response.function_call_arguments.delta') {
|
|
104
|
+
controller.enqueue(encoder.encode(makeChatChunk({
|
|
105
|
+
tool_calls: [{
|
|
106
|
+
index: toolCallIndex,
|
|
107
|
+
function: { arguments: event.delta },
|
|
108
|
+
}]
|
|
109
|
+
})));
|
|
110
|
+
chunkCount++;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
else if (eventType === 'response.output_item.done' && event.item?.type === 'function_call') {
|
|
114
|
+
toolCallIndex++;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
else if (eventType === 'response.completed') {
|
|
118
|
+
const hasToolCalls = (event.response?.output || []).some((o: any) => o.type === 'function_call');
|
|
119
|
+
const finishReason = hasToolCalls ? 'tool_calls' : 'stop';
|
|
120
|
+
controller.enqueue(encoder.encode(makeChatChunk({}, finishReason)));
|
|
121
|
+
if (event.response?.usage) {
|
|
122
|
+
const usageChunk: any = {
|
|
123
|
+
id: chatId, object: 'chat.completion.chunk',
|
|
124
|
+
created: Math.floor(Date.now() / 1000), model,
|
|
125
|
+
choices: [],
|
|
126
|
+
usage: {
|
|
127
|
+
prompt_tokens: event.response.usage.input_tokens,
|
|
128
|
+
completion_tokens: event.response.usage.output_tokens,
|
|
129
|
+
total_tokens: event.response.usage.total_tokens,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(usageChunk)}\n\n`));
|
|
133
|
+
}
|
|
134
|
+
chunkCount++;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const stream = new ReadableStream({
|
|
140
|
+
async start(controller) {
|
|
141
|
+
try {
|
|
142
|
+
while (true) {
|
|
143
|
+
const { done, value } = await reader.read();
|
|
144
|
+
if (done) {
|
|
145
|
+
if (buffer.trim()) {
|
|
146
|
+
processLines(buffer.split('\n'), controller);
|
|
147
|
+
}
|
|
148
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
|
149
|
+
console.log(`✅ Responses→Chat stream complete: ${chunkCount} events processed`);
|
|
150
|
+
controller.close();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
buffer += decoder.decode(value, { stream: true });
|
|
155
|
+
const lines = buffer.split('\n');
|
|
156
|
+
buffer = lines.pop() || '';
|
|
157
|
+
processLines(lines, controller);
|
|
158
|
+
}
|
|
159
|
+
} catch (err: any) {
|
|
160
|
+
console.error('❌ Responses stream conversion error:', err);
|
|
161
|
+
try { controller.close(); } catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return new Response(stream, {
|
|
167
|
+
status: 200,
|
|
168
|
+
headers: { ...corsHeaders, "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
|
|
169
|
+
});
|
|
170
|
+
}
|
package/start.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* One-command startup: launches copilot-api (port 4141) + proxy-router (port 4142)
|
|
4
|
+
* Usage: bun run start.ts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn, sleep } from 'bun';
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
|
|
10
|
+
const COPILOT_API_PORT = 4141;
|
|
11
|
+
const PROXY_PORT = 4142;
|
|
12
|
+
|
|
13
|
+
// Colors for distinguishing output
|
|
14
|
+
const RED = '\x1b[31m';
|
|
15
|
+
const GREEN = '\x1b[32m';
|
|
16
|
+
const YELLOW = '\x1b[33m';
|
|
17
|
+
const CYAN = '\x1b[36m';
|
|
18
|
+
const RESET = '\x1b[0m';
|
|
19
|
+
|
|
20
|
+
async function isPortInUse(port: number): Promise<boolean> {
|
|
21
|
+
try {
|
|
22
|
+
await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(2000) });
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function waitForPort(port: number, timeoutMs = 30000): Promise<boolean> {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
while (Date.now() - start < timeoutMs) {
|
|
32
|
+
try {
|
|
33
|
+
const resp = await fetch(`http://localhost:${port}/v1/models`);
|
|
34
|
+
if (resp.ok) return true;
|
|
35
|
+
} catch {}
|
|
36
|
+
await sleep(500);
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
console.log(`${CYAN}🚀 Starting Copilot Proxy Stack...${RESET}\n`);
|
|
43
|
+
|
|
44
|
+
// 1. Check if copilot-api is already running
|
|
45
|
+
const copilotAlreadyRunning = await isPortInUse(COPILOT_API_PORT);
|
|
46
|
+
let copilotProc: ReturnType<typeof spawn> | null = null;
|
|
47
|
+
|
|
48
|
+
if (copilotAlreadyRunning) {
|
|
49
|
+
console.log(`${GREEN}✅ copilot-api already running on port ${COPILOT_API_PORT}${RESET}`);
|
|
50
|
+
} else {
|
|
51
|
+
console.log(`${YELLOW}⏳ Starting copilot-api on port ${COPILOT_API_PORT}...${RESET}`);
|
|
52
|
+
|
|
53
|
+
// Detect npx path
|
|
54
|
+
const isWindows = process.platform === 'win32';
|
|
55
|
+
const npxCmd = isWindows ? 'npx.cmd' : 'npx';
|
|
56
|
+
|
|
57
|
+
copilotProc = spawn([npxCmd, 'copilot-api', 'start'], {
|
|
58
|
+
stdout: 'pipe',
|
|
59
|
+
stderr: 'pipe',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Stream copilot-api output with prefix
|
|
63
|
+
(async () => {
|
|
64
|
+
const reader = copilotProc!.stdout.getReader();
|
|
65
|
+
const decoder = new TextDecoder();
|
|
66
|
+
while (true) {
|
|
67
|
+
const { done, value } = await reader.read();
|
|
68
|
+
if (done) break;
|
|
69
|
+
const text = decoder.decode(value, { stream: true });
|
|
70
|
+
for (const line of text.split('\n').filter(Boolean)) {
|
|
71
|
+
console.log(`${RED}[copilot-api]${RESET} ${line}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
(async () => {
|
|
77
|
+
const reader = copilotProc!.stderr.getReader();
|
|
78
|
+
const decoder = new TextDecoder();
|
|
79
|
+
while (true) {
|
|
80
|
+
const { done, value } = await reader.read();
|
|
81
|
+
if (done) break;
|
|
82
|
+
const text = decoder.decode(value, { stream: true });
|
|
83
|
+
for (const line of text.split('\n').filter(Boolean)) {
|
|
84
|
+
console.log(`${RED}[copilot-api]${RESET} ${line}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
|
|
89
|
+
// Wait for copilot-api to be ready
|
|
90
|
+
console.log(`${YELLOW}⏳ Waiting for copilot-api to be ready...${RESET}`);
|
|
91
|
+
const ready = await waitForPort(COPILOT_API_PORT);
|
|
92
|
+
if (!ready) {
|
|
93
|
+
console.error(`${RED}❌ copilot-api failed to start within 30s${RESET}`);
|
|
94
|
+
copilotProc.kill();
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
console.log(`${GREEN}✅ copilot-api is ready on port ${COPILOT_API_PORT}${RESET}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. Check if proxy is already running
|
|
101
|
+
const proxyAlreadyRunning = await isPortInUse(PROXY_PORT);
|
|
102
|
+
if (proxyAlreadyRunning) {
|
|
103
|
+
console.log(`${GREEN}✅ proxy-router already running on port ${PROXY_PORT}${RESET}`);
|
|
104
|
+
console.log(`\n${CYAN}🎉 Everything is running! Configure Cursor to use: http://localhost:${PROXY_PORT}/v1${RESET}`);
|
|
105
|
+
// Keep alive if we started copilot-api
|
|
106
|
+
if (copilotProc) await copilotProc.exited;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 3. Start proxy-router in the same process
|
|
111
|
+
console.log(`${YELLOW}⏳ Starting proxy-router on port ${PROXY_PORT}...${RESET}`);
|
|
112
|
+
await import('./proxy-router');
|
|
113
|
+
|
|
114
|
+
console.log(`\n${CYAN}🎉 All services running!${RESET}`);
|
|
115
|
+
console.log(`${CYAN} copilot-api: http://localhost:${COPILOT_API_PORT}${RESET}`);
|
|
116
|
+
console.log(`${CYAN} proxy-router: http://localhost:${PROXY_PORT}${RESET}`);
|
|
117
|
+
console.log(`${CYAN} dashboard: http://localhost:${PROXY_PORT}/${RESET}`);
|
|
118
|
+
console.log(`${CYAN} Cursor config: http://localhost:${PROXY_PORT}/v1${RESET}`);
|
|
119
|
+
|
|
120
|
+
// Handle graceful shutdown
|
|
121
|
+
process.on('SIGINT', () => {
|
|
122
|
+
console.log(`\n${YELLOW}🛑 Shutting down...${RESET}`);
|
|
123
|
+
if (copilotProc) copilotProc.kill();
|
|
124
|
+
process.exit(0);
|
|
125
|
+
});
|
|
126
|
+
process.on('SIGTERM', () => {
|
|
127
|
+
if (copilotProc) copilotProc.kill();
|
|
128
|
+
process.exit(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Keep alive
|
|
132
|
+
if (copilotProc) await copilotProc.exited;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
main().catch(err => {
|
|
136
|
+
console.error(`${RED}❌ Fatal error:${RESET}`, err);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
});
|
package/stream-proxy.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const createStreamProxy = (responseBody: ReadableStream<Uint8Array>, responseHeaders: Headers) => {
|
|
2
|
+
let chunkCount = 0;
|
|
3
|
+
let lastChunkData = '';
|
|
4
|
+
let totalBytes = 0;
|
|
5
|
+
const reader = responseBody.getReader();
|
|
6
|
+
const decoder = new TextDecoder();
|
|
7
|
+
|
|
8
|
+
const stream = new ReadableStream({
|
|
9
|
+
async pull(controller) {
|
|
10
|
+
try {
|
|
11
|
+
const { done, value } = await reader.read();
|
|
12
|
+
if (done) {
|
|
13
|
+
console.log(`✅ Stream complete: ${chunkCount} chunks, ${totalBytes} bytes`);
|
|
14
|
+
console.log(`✅ Last chunk: ${lastChunkData.slice(-200)}`);
|
|
15
|
+
controller.close();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
chunkCount++;
|
|
19
|
+
totalBytes += value.length;
|
|
20
|
+
lastChunkData = decoder.decode(value, { stream: true });
|
|
21
|
+
if (chunkCount === 1) {
|
|
22
|
+
console.log(`📡 Stream started, first chunk: ${lastChunkData.slice(0, 200)}`);
|
|
23
|
+
}
|
|
24
|
+
if (lastChunkData.includes('"error"')) {
|
|
25
|
+
console.error(`❌ Error in stream chunk ${chunkCount}: ${lastChunkData.slice(0, 500)}`);
|
|
26
|
+
}
|
|
27
|
+
if (lastChunkData.includes('finish_reason')) {
|
|
28
|
+
const match = lastChunkData.match(/"finish_reason"\s*:\s*"([^"]+)"/);
|
|
29
|
+
if (match) {
|
|
30
|
+
console.log(`📡 finish_reason: "${match[1]}" at chunk ${chunkCount}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
controller.enqueue(value);
|
|
34
|
+
} catch (err: any) {
|
|
35
|
+
if (err?.code === 'ERR_INVALID_THIS') return;
|
|
36
|
+
console.error(`❌ Stream read error at chunk ${chunkCount}:`, err);
|
|
37
|
+
try { controller.error(err); } catch {}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
cancel() {
|
|
41
|
+
console.log(`⚠️ Stream cancelled by client after ${chunkCount} chunks, ${totalBytes} bytes`);
|
|
42
|
+
try { reader.cancel(); } catch {}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return new Response(stream, {
|
|
47
|
+
status: 200,
|
|
48
|
+
headers: responseHeaders,
|
|
49
|
+
});
|
|
50
|
+
};
|