copilot-custom-endpoint 1.0.0 → 1.0.2
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 +8 -4
- package/lib/create-proxy.mjs +186 -0
- package/lib/shared.mjs +125 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -25,12 +25,11 @@ This repo is for those situations: validated, copy-paste-ready configs when Open
|
|
|
25
25
|
| **Moonshot (Kimi)** | `kimi-k2.6` | Yes — `proxy/kimi-proxy.mjs` | ✅ | ✅ | ✅ | ✅ |
|
|
26
26
|
| **Alibaba Cloud (DashScope)** | `qwen3.6-plus` | Optional — `proxy/qwen-proxy.mjs`¹ | ✅² | ✅ | ✅ | ✅ |
|
|
27
27
|
| **Alibaba Cloud (DashScope)** | `qwen3.7-max` | Optional — `proxy/qwen-proxy.mjs`¹ | ✅² | ✅ | ✅ | ❌ |
|
|
28
|
-
| **DeepSeek** | `deepseek-v4` | No — uses a VS Code extension | ✅ | ✅ | ✅ |
|
|
28
|
+
| **DeepSeek** | `deepseek-v4` | No — uses a VS Code extension | ✅ | ✅ | ✅ | ✅³ |
|
|
29
29
|
|
|
30
30
|
¹ Proxy is optional: direct path works with static `enable_thinking: false`. Proxy adds dynamic thinking suppression (thinking ON in plain chat, OFF in tool loops).
|
|
31
|
-
² With proxy: reasoning visible in plain chat. Without proxy: always suppressed.
|
|
32
|
-
|
|
33
|
-
¹ Vision is supported through a proxy model (Claude, GPT-4o) that describes the image before sending to DeepSeek.
|
|
31
|
+
² With proxy: reasoning visible in plain chat. Without proxy: always suppressed.
|
|
32
|
+
³ Vision is supported through a proxy model (Claude, GPT-4o) that describes the image before sending to DeepSeek.
|
|
34
33
|
|
|
35
34
|
Pick the model you want and follow the corresponding section below.
|
|
36
35
|
|
|
@@ -64,6 +63,11 @@ npx copilot-custom-endpoint kimi
|
|
|
64
63
|
npx copilot-custom-endpoint # starts both proxies
|
|
65
64
|
# or directly
|
|
66
65
|
node proxy/kimi-proxy.mjs
|
|
66
|
+
|
|
67
|
+
# clean up debug logs
|
|
68
|
+
npm run clean:logs
|
|
69
|
+
# or with npx
|
|
70
|
+
npx copilot-custom-endpoint clean
|
|
67
71
|
```
|
|
68
72
|
|
|
69
73
|
You should see:
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { Readable } from 'node:stream'
|
|
3
|
+
import {
|
|
4
|
+
appendLog,
|
|
5
|
+
buildForwardHeaders,
|
|
6
|
+
buildResponseHeaders,
|
|
7
|
+
readRequestBody,
|
|
8
|
+
redactHeaders
|
|
9
|
+
} from './shared.mjs'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates an HTTP proxy server (not yet listening) that forwards chat-completion
|
|
13
|
+
* requests to an upstream provider, applying provider-specific request rewrites.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} options
|
|
16
|
+
* @param {string} options.upstreamUrl - Upstream chat-completions endpoint
|
|
17
|
+
* @param {number} options.port - Local listen port
|
|
18
|
+
* @param {string} options.logPath - Path to NDJSON log file
|
|
19
|
+
* @param {string} options.label - Short label for console messages (e.g. 'kimi-proxy')
|
|
20
|
+
* @param {object} options.healthCheckExtras - Extra fields to include in /healthz response
|
|
21
|
+
* @param {number} [options.fetchTimeoutMs=300_000] - Timeout for upstream fetch calls
|
|
22
|
+
* @param {function} options.rewriteRequest - (payload) => { summary: object, consoleMsg: string }
|
|
23
|
+
* Called before forwarding. Should mutate `payload` in place. The returned `summary`
|
|
24
|
+
* is merged into the NDJSON log entry. `consoleMsg` is printed to stdout.
|
|
25
|
+
* @param {function} options.startupMessages - (port, upstreamUrl) => string[] of startup log lines
|
|
26
|
+
* @returns {{ server: import('node:http').Server, start: () => void }}
|
|
27
|
+
*/
|
|
28
|
+
export function createProxy({
|
|
29
|
+
upstreamUrl,
|
|
30
|
+
port,
|
|
31
|
+
logPath,
|
|
32
|
+
label,
|
|
33
|
+
healthCheckExtras,
|
|
34
|
+
fetchTimeoutMs = 300_000,
|
|
35
|
+
rewriteRequest,
|
|
36
|
+
startupMessages
|
|
37
|
+
}) {
|
|
38
|
+
if (!Number.isInteger(port) || port < 0) {
|
|
39
|
+
throw new Error(`Invalid port: ${port}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const server = createServer(async (request, response) => {
|
|
43
|
+
// ---- Health check ----
|
|
44
|
+
if (request.method === 'GET' && request.url === '/healthz') {
|
|
45
|
+
response.writeHead(200, { 'content-type': 'application/json' })
|
|
46
|
+
response.end(
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
ok: true,
|
|
49
|
+
upstreamUrl,
|
|
50
|
+
port,
|
|
51
|
+
...healthCheckExtras
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- Only POST ----
|
|
58
|
+
if (request.method !== 'POST') {
|
|
59
|
+
response.writeHead(404, { 'content-type': 'application/json' })
|
|
60
|
+
response.end(JSON.stringify({ error: 'Not found' }))
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const startedAt = new Date().toISOString()
|
|
65
|
+
let requestBody
|
|
66
|
+
|
|
67
|
+
// ---- Read body ----
|
|
68
|
+
try {
|
|
69
|
+
requestBody = await readRequestBody(request)
|
|
70
|
+
} catch (error) {
|
|
71
|
+
response.writeHead(400, { 'content-type': 'application/json' })
|
|
72
|
+
response.end(JSON.stringify({ error: 'Unable to read request body' }))
|
|
73
|
+
|
|
74
|
+
await appendLog(
|
|
75
|
+
{
|
|
76
|
+
timestamp: startedAt,
|
|
77
|
+
type: 'read-error',
|
|
78
|
+
path: request.url,
|
|
79
|
+
error: error instanceof Error ? error.message : String(error)
|
|
80
|
+
},
|
|
81
|
+
logPath
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- Parse JSON ----
|
|
87
|
+
let payload
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
payload = JSON.parse(requestBody)
|
|
91
|
+
} catch {
|
|
92
|
+
response.writeHead(400, { 'content-type': 'application/json' })
|
|
93
|
+
response.end(JSON.stringify({ error: 'Expected JSON request body' }))
|
|
94
|
+
|
|
95
|
+
await appendLog(
|
|
96
|
+
{
|
|
97
|
+
timestamp: startedAt,
|
|
98
|
+
type: 'invalid-json',
|
|
99
|
+
path: request.url,
|
|
100
|
+
headers: redactHeaders(request.headers)
|
|
101
|
+
},
|
|
102
|
+
logPath
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- Provider-specific rewrite ----
|
|
108
|
+
const { summary, consoleMsg } = rewriteRequest(payload)
|
|
109
|
+
|
|
110
|
+
// ---- Log request ----
|
|
111
|
+
await appendLog(
|
|
112
|
+
{
|
|
113
|
+
timestamp: startedAt,
|
|
114
|
+
type: 'request',
|
|
115
|
+
path: request.url,
|
|
116
|
+
headers: redactHeaders(request.headers),
|
|
117
|
+
summary
|
|
118
|
+
},
|
|
119
|
+
logPath
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
console.log(`[${label}] ${request.method} ${request.url} ${consoleMsg}`)
|
|
123
|
+
|
|
124
|
+
// ---- Forward to upstream ----
|
|
125
|
+
try {
|
|
126
|
+
const upstreamResponse = await fetch(upstreamUrl, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: buildForwardHeaders(request.headers),
|
|
129
|
+
body: JSON.stringify(payload),
|
|
130
|
+
signal: AbortSignal.timeout(fetchTimeoutMs)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// ---- Log response ----
|
|
134
|
+
await appendLog(
|
|
135
|
+
{
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
type: 'response',
|
|
138
|
+
path: request.url,
|
|
139
|
+
status: upstreamResponse.status,
|
|
140
|
+
statusText: upstreamResponse.statusText,
|
|
141
|
+
contentType: upstreamResponse.headers.get('content-type'),
|
|
142
|
+
upstreamRequestId:
|
|
143
|
+
upstreamResponse.headers.get('x-request-id') ??
|
|
144
|
+
upstreamResponse.headers.get('request-id')
|
|
145
|
+
},
|
|
146
|
+
logPath
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
// ---- Forward response ----
|
|
150
|
+
response.writeHead(
|
|
151
|
+
upstreamResponse.status,
|
|
152
|
+
buildResponseHeaders(upstreamResponse.headers)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if (!upstreamResponse.body) {
|
|
156
|
+
response.end()
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
Readable.fromWeb(upstreamResponse.body).pipe(response)
|
|
161
|
+
} catch (error) {
|
|
162
|
+
response.writeHead(502, { 'content-type': 'application/json' })
|
|
163
|
+
response.end(JSON.stringify({ error: 'Upstream request failed' }))
|
|
164
|
+
|
|
165
|
+
await appendLog(
|
|
166
|
+
{
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
type: 'upstream-error',
|
|
169
|
+
path: request.url,
|
|
170
|
+
error: error instanceof Error ? error.message : String(error)
|
|
171
|
+
},
|
|
172
|
+
logPath
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
function start() {
|
|
178
|
+
server.listen(port, '127.0.0.1', () => {
|
|
179
|
+
for (const msg of startupMessages(port, upstreamUrl)) {
|
|
180
|
+
console.log(msg)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { server, start }
|
|
186
|
+
}
|
package/lib/shared.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// ---- Constants ----
|
|
5
|
+
|
|
6
|
+
export const hopByHopHeaders = new Set([
|
|
7
|
+
'connection',
|
|
8
|
+
'keep-alive',
|
|
9
|
+
'proxy-authenticate',
|
|
10
|
+
'proxy-authorization',
|
|
11
|
+
'te',
|
|
12
|
+
'trailer',
|
|
13
|
+
'transfer-encoding',
|
|
14
|
+
'upgrade'
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
// ---- Header utilities ----
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns a shallow copy of `headers` with sensitive values redacted.
|
|
21
|
+
* `authorization` → `Bearer <redacted>`
|
|
22
|
+
* `x-api-key` → `<redacted>`
|
|
23
|
+
* `undefined` values are dropped entirely.
|
|
24
|
+
*/
|
|
25
|
+
export function redactHeaders(headers) {
|
|
26
|
+
const redacted = {}
|
|
27
|
+
|
|
28
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
29
|
+
if (value === undefined) {
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (name.toLowerCase() === 'authorization') {
|
|
34
|
+
redacted[name] = 'Bearer <redacted>'
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (name.toLowerCase() === 'x-api-key') {
|
|
39
|
+
redacted[name] = '<redacted>'
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
redacted[name] = value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return redacted
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a Headers object for the upstream request, filtering out
|
|
51
|
+
* hop-by-hop headers, `host`, and `content-length`, then forcing
|
|
52
|
+
* `content-type: application/json`.
|
|
53
|
+
*/
|
|
54
|
+
export function buildForwardHeaders(headers) {
|
|
55
|
+
const forwardHeaders = new Headers()
|
|
56
|
+
|
|
57
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
58
|
+
const lowerName = name.toLowerCase()
|
|
59
|
+
|
|
60
|
+
if (
|
|
61
|
+
value === undefined ||
|
|
62
|
+
lowerName === 'host' ||
|
|
63
|
+
lowerName === 'content-length' ||
|
|
64
|
+
hopByHopHeaders.has(lowerName)
|
|
65
|
+
) {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
for (const item of value) {
|
|
71
|
+
forwardHeaders.append(name, item)
|
|
72
|
+
}
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
forwardHeaders.set(name, value)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
forwardHeaders.set('content-type', 'application/json')
|
|
80
|
+
|
|
81
|
+
return forwardHeaders
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a plain object from a Headers instance, filtering out hop-by-hop headers.
|
|
86
|
+
*/
|
|
87
|
+
export function buildResponseHeaders(headers) {
|
|
88
|
+
const responseHeaders = {}
|
|
89
|
+
|
|
90
|
+
for (const [name, value] of headers.entries()) {
|
|
91
|
+
if (hopByHopHeaders.has(name.toLowerCase())) {
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
responseHeaders[name] = value
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return responseHeaders
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- Request body ----
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read the full body from an async-iterable request-like object
|
|
105
|
+
* (e.g. `IncomingMessage` or a `Readable` stream) and return it as a UTF-8 string.
|
|
106
|
+
*/
|
|
107
|
+
export async function readRequestBody(request) {
|
|
108
|
+
const chunks = []
|
|
109
|
+
|
|
110
|
+
for await (const chunk of request) {
|
|
111
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Logging ----
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Append an NDJSON entry to `logPath`, creating parent directories as needed.
|
|
121
|
+
*/
|
|
122
|
+
export async function appendLog(entry, logPath) {
|
|
123
|
+
await mkdir(dirname(logPath), { recursive: true })
|
|
124
|
+
await appendFile(logPath, `${JSON.stringify(entry)}\n`, 'utf8')
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copilot-custom-endpoint",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Local proxies for VS Code Copilot custom endpoints — Kimi K2 & Qwen 3.x",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"cli.mjs",
|
|
14
|
+
"lib/",
|
|
14
15
|
"proxy/"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|