copilot-relay 0.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 +92 -0
- package/config.default.yaml +45 -0
- package/dist/main.js +2212 -0
- package/dist/main.js.map +1 -0
- package/package.json +58 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,2212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { defineCommand, runMain } from "citty";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import { inspect } from "node:util";
|
|
5
|
+
import consola from "consola";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path, { dirname, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { events } from "fetch-event-stream";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { serve } from "@hono/node-server";
|
|
13
|
+
import { Hono } from "hono";
|
|
14
|
+
import { cors } from "hono/cors";
|
|
15
|
+
import { streamSSE } from "hono/streaming";
|
|
16
|
+
|
|
17
|
+
//#region src/lib/error.ts
|
|
18
|
+
var ProxyNotImplementedError = class extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "ProxyNotImplementedError";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var HTTPError = class extends Error {
|
|
25
|
+
response;
|
|
26
|
+
constructor(message, response) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "HTTPError";
|
|
29
|
+
this.response = response;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/lib/paths.ts
|
|
35
|
+
const homeDir = os.homedir();
|
|
36
|
+
const appDir = path.join(homeDir, ".copilot-relay");
|
|
37
|
+
const legacyGithubTokenPaths = [
|
|
38
|
+
path.join(homeDir, ".copilot-tennel", "github_token"),
|
|
39
|
+
path.join(homeDir, "Library", "Application Support", "copilot-tennel", "github_token"),
|
|
40
|
+
path.join(homeDir, ".local", "share", "copilot-tennel", "github_token"),
|
|
41
|
+
path.join(homeDir, "Library", "Application Support", "copilot-relay", "github_token"),
|
|
42
|
+
path.join(homeDir, ".local", "share", "copilot-relay", "github_token"),
|
|
43
|
+
path.join(homeDir, ".local", "share", "copilot-bridge", "github_token")
|
|
44
|
+
];
|
|
45
|
+
const legacyCopilotTokenPaths = [
|
|
46
|
+
path.join(homeDir, ".copilot-tennel", "copilot_token.json"),
|
|
47
|
+
path.join(homeDir, "Library", "Application Support", "copilot-tennel", "copilot_token.json"),
|
|
48
|
+
path.join(homeDir, ".local", "share", "copilot-tennel", "copilot_token.json"),
|
|
49
|
+
path.join(homeDir, "Library", "Application Support", "copilot-relay", "copilot_token.json"),
|
|
50
|
+
path.join(homeDir, ".local", "share", "copilot-relay", "copilot_token.json")
|
|
51
|
+
];
|
|
52
|
+
const configPath = path.join(appDir, "config.yaml");
|
|
53
|
+
const copilotTokenPath = path.join(appDir, "copilot_token.json");
|
|
54
|
+
const githubTokenPath = path.join(appDir, "github_token");
|
|
55
|
+
const logsDir = path.join(appDir, "logs");
|
|
56
|
+
const logPath = path.join(logsDir, "copilot-relay.log");
|
|
57
|
+
const paths = {
|
|
58
|
+
appDir,
|
|
59
|
+
configPath,
|
|
60
|
+
copilotTokenPath,
|
|
61
|
+
githubTokenPath,
|
|
62
|
+
legacyConfigPaths: [
|
|
63
|
+
path.join(homeDir, ".copilot-relay.yaml"),
|
|
64
|
+
path.join(homeDir, ".copilot-tennel", "config.yaml"),
|
|
65
|
+
path.join(homeDir, ".copilot-tennel.yaml")
|
|
66
|
+
],
|
|
67
|
+
legacyCopilotTokenPaths,
|
|
68
|
+
logPath,
|
|
69
|
+
logsDir
|
|
70
|
+
};
|
|
71
|
+
async function ensurePaths() {
|
|
72
|
+
await fs.mkdir(paths.appDir, { recursive: true });
|
|
73
|
+
await ensureFile(paths.githubTokenPath, { legacyPaths: legacyGithubTokenPaths });
|
|
74
|
+
}
|
|
75
|
+
async function ensureFile(filePath, options = {}) {
|
|
76
|
+
try {
|
|
77
|
+
await fs.access(filePath, fs.constants.W_OK);
|
|
78
|
+
} catch {
|
|
79
|
+
for (const legacyPath of options.legacyPaths ?? []) {
|
|
80
|
+
if (legacyPath === filePath) continue;
|
|
81
|
+
try {
|
|
82
|
+
const content = await fs.readFile(legacyPath, "utf8");
|
|
83
|
+
if (content.trim()) {
|
|
84
|
+
await fs.writeFile(filePath, content, { mode: 384 });
|
|
85
|
+
await fs.chmod(filePath, 384);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error.code !== "ENOENT") throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
await fs.writeFile(filePath, "");
|
|
93
|
+
await fs.chmod(filePath, 384);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/lib/log.ts
|
|
99
|
+
const logCleanupCheckIntervalMs = 3600 * 1e3;
|
|
100
|
+
let logRetentionDays = 3;
|
|
101
|
+
let nextLogCleanupCheckAt = 0;
|
|
102
|
+
const consolaLevelByName = {
|
|
103
|
+
silent: -Infinity,
|
|
104
|
+
error: 0,
|
|
105
|
+
warn: 1,
|
|
106
|
+
info: 3,
|
|
107
|
+
debug: 4,
|
|
108
|
+
trace: 5
|
|
109
|
+
};
|
|
110
|
+
const setLogLevel = (level) => {
|
|
111
|
+
consola.level = consolaLevelByName[level];
|
|
112
|
+
};
|
|
113
|
+
const formatLogValue = (value) => typeof value === "string" ? value : inspect(value, { depth: null });
|
|
114
|
+
const writeLogFile = async (level, values) => {
|
|
115
|
+
await fs.mkdir(paths.logsDir, { recursive: true });
|
|
116
|
+
await cleanupLogsIfDue();
|
|
117
|
+
const line = [
|
|
118
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
119
|
+
level,
|
|
120
|
+
values.map(formatLogValue).join(" ")
|
|
121
|
+
].join(" ");
|
|
122
|
+
await fs.appendFile(paths.logPath, `${line}\n`);
|
|
123
|
+
};
|
|
124
|
+
const cleanupLogsIfDue = async () => {
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
if (now < nextLogCleanupCheckAt) return;
|
|
127
|
+
nextLogCleanupCheckAt = now + logCleanupCheckIntervalMs;
|
|
128
|
+
await cleanupLogs(logRetentionDays);
|
|
129
|
+
};
|
|
130
|
+
const wrapFileLog = (level, fn) => ((...args) => {
|
|
131
|
+
writeLogFile(level, args).catch(() => void 0);
|
|
132
|
+
return fn(...args);
|
|
133
|
+
});
|
|
134
|
+
consola.error = wrapFileLog("error", consola.error.bind(consola));
|
|
135
|
+
consola.warn = wrapFileLog("warn", consola.warn.bind(consola));
|
|
136
|
+
consola.info = wrapFileLog("info", consola.info.bind(consola));
|
|
137
|
+
consola.success = wrapFileLog("success", consola.success.bind(consola));
|
|
138
|
+
consola.debug = wrapFileLog("debug", consola.debug.bind(consola));
|
|
139
|
+
consola.trace = wrapFileLog("trace", consola.trace.bind(consola));
|
|
140
|
+
const cleanupLogs = async (retentionDays) => {
|
|
141
|
+
logRetentionDays = retentionDays;
|
|
142
|
+
await fs.mkdir(paths.logsDir, { recursive: true });
|
|
143
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
|
|
144
|
+
const entries = await fs.readdir(paths.logsDir, { withFileTypes: true });
|
|
145
|
+
await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".log")).map(async (entry) => {
|
|
146
|
+
const filePath = `${paths.logsDir}/${entry.name}`;
|
|
147
|
+
if ((await fs.stat(filePath)).mtimeMs < cutoff) await fs.unlink(filePath);
|
|
148
|
+
}));
|
|
149
|
+
};
|
|
150
|
+
const log = consola;
|
|
151
|
+
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/lib/auth.ts
|
|
154
|
+
const copilotVersion$1 = "0.26.7";
|
|
155
|
+
const editorPluginVersion$1 = `copilot-chat/${copilotVersion$1}`;
|
|
156
|
+
const userAgent$1 = `GitHubCopilotChat/${copilotVersion$1}`;
|
|
157
|
+
const githubApiVersion = "2022-11-28";
|
|
158
|
+
const githubApiBaseUrl = "https://api.github.com";
|
|
159
|
+
const githubBaseUrl = "https://github.com";
|
|
160
|
+
const githubClientId = "Iv1.b507a08c87ecfe98";
|
|
161
|
+
const githubAppScopes = ["read:user"].join(" ");
|
|
162
|
+
let copilotTokenRefreshTimer;
|
|
163
|
+
const standardHeaders = () => ({
|
|
164
|
+
accept: "application/json",
|
|
165
|
+
"content-type": "application/json"
|
|
166
|
+
});
|
|
167
|
+
const sleep = (ms) => new Promise((resolve$1) => {
|
|
168
|
+
setTimeout(resolve$1, ms);
|
|
169
|
+
});
|
|
170
|
+
const readGitHubToken = async () => {
|
|
171
|
+
await ensurePaths();
|
|
172
|
+
return (await fs.readFile(paths.githubTokenPath, "utf8")).trim();
|
|
173
|
+
};
|
|
174
|
+
const writeGitHubToken = async (token) => {
|
|
175
|
+
await ensurePaths();
|
|
176
|
+
await fs.writeFile(paths.githubTokenPath, `${token.trim()}\n`, { mode: 384 });
|
|
177
|
+
};
|
|
178
|
+
const readStoredCopilotToken = async () => {
|
|
179
|
+
await ensurePaths();
|
|
180
|
+
try {
|
|
181
|
+
const payload = JSON.parse(await fs.readFile(paths.copilotTokenPath, "utf8"));
|
|
182
|
+
if (typeof payload.token === "string" && typeof payload.refreshedAt === "number" && typeof payload.refreshIn === "number") return payload;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error.code !== "ENOENT") throw error;
|
|
185
|
+
}
|
|
186
|
+
for (const legacyPath of paths.legacyCopilotTokenPaths) try {
|
|
187
|
+
const content = await fs.readFile(legacyPath, "utf8");
|
|
188
|
+
const payload = JSON.parse(content);
|
|
189
|
+
if (typeof payload.token === "string" && typeof payload.refreshedAt === "number" && typeof payload.refreshIn === "number") {
|
|
190
|
+
await fs.writeFile(paths.copilotTokenPath, content, { mode: 384 });
|
|
191
|
+
await fs.chmod(paths.copilotTokenPath, 384);
|
|
192
|
+
return payload;
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error.code !== "ENOENT") throw error;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const writeStoredCopilotToken = async (input) => {
|
|
199
|
+
await ensurePaths();
|
|
200
|
+
const payload = {
|
|
201
|
+
refreshedAt: Date.now(),
|
|
202
|
+
refreshIn: input.refresh_in,
|
|
203
|
+
token: input.token
|
|
204
|
+
};
|
|
205
|
+
await fs.writeFile(paths.copilotTokenPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 384 });
|
|
206
|
+
};
|
|
207
|
+
const getStoredTokenRemainingSeconds = (token) => {
|
|
208
|
+
const ageSeconds = Math.floor((Date.now() - token.refreshedAt) / 1e3);
|
|
209
|
+
return token.refreshIn - ageSeconds;
|
|
210
|
+
};
|
|
211
|
+
const githubHeaders = (githubToken, vsCodeVersion) => ({
|
|
212
|
+
...standardHeaders(),
|
|
213
|
+
authorization: `token ${githubToken}`,
|
|
214
|
+
"editor-plugin-version": editorPluginVersion$1,
|
|
215
|
+
"editor-version": `vscode/${vsCodeVersion}`,
|
|
216
|
+
"user-agent": userAgent$1,
|
|
217
|
+
"x-github-api-version": githubApiVersion,
|
|
218
|
+
"x-vscode-user-agent-library-version": "electron-fetch"
|
|
219
|
+
});
|
|
220
|
+
const getDeviceCode = async () => {
|
|
221
|
+
const response = await fetch(`${githubBaseUrl}/login/device/code`, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: standardHeaders(),
|
|
224
|
+
body: JSON.stringify({
|
|
225
|
+
client_id: githubClientId,
|
|
226
|
+
scope: githubAppScopes
|
|
227
|
+
})
|
|
228
|
+
});
|
|
229
|
+
if (!response.ok) throw new HTTPError("Failed to get device code", response);
|
|
230
|
+
return await response.json();
|
|
231
|
+
};
|
|
232
|
+
const pollAccessToken = async (deviceCode) => {
|
|
233
|
+
const sleepDuration = (deviceCode.interval + 1) * 1e3;
|
|
234
|
+
while (true) {
|
|
235
|
+
const response = await fetch(`${githubBaseUrl}/login/oauth/access_token`, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: standardHeaders(),
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
client_id: githubClientId,
|
|
240
|
+
device_code: deviceCode.device_code,
|
|
241
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
242
|
+
})
|
|
243
|
+
});
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
await sleep(sleepDuration);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const json = await response.json();
|
|
249
|
+
if (json.access_token) return json.access_token;
|
|
250
|
+
await sleep(sleepDuration);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const getGitHubUser = async (githubToken, vsCodeVersion) => {
|
|
254
|
+
const response = await fetch(`${githubApiBaseUrl}/user`, { headers: githubHeaders(githubToken, vsCodeVersion) });
|
|
255
|
+
if (!response.ok) throw new HTTPError("Failed to get GitHub user", response);
|
|
256
|
+
return await response.json();
|
|
257
|
+
};
|
|
258
|
+
const getCopilotToken = async (githubToken, vsCodeVersion) => {
|
|
259
|
+
const response = await fetch(`${githubApiBaseUrl}/copilot_internal/v2/token`, { headers: githubHeaders(githubToken, vsCodeVersion) });
|
|
260
|
+
if (!response.ok) throw new HTTPError("Failed to get Copilot token", response);
|
|
261
|
+
return await response.json();
|
|
262
|
+
};
|
|
263
|
+
const isCopilotTokenError = (error) => error instanceof HTTPError && error.message === "Failed to get Copilot token";
|
|
264
|
+
const ensureGitHubToken = async (options = {}) => {
|
|
265
|
+
const existingToken = options.force ? "" : await readGitHubToken();
|
|
266
|
+
if (existingToken) {
|
|
267
|
+
log.debug(`Using cached GitHub token at ${paths.githubTokenPath}`);
|
|
268
|
+
return existingToken;
|
|
269
|
+
}
|
|
270
|
+
const deviceCode = await getDeviceCode();
|
|
271
|
+
log.info(`Open ${deviceCode.verification_uri} and enter code ${deviceCode.user_code}`);
|
|
272
|
+
const githubToken = await pollAccessToken(deviceCode);
|
|
273
|
+
await writeGitHubToken(githubToken);
|
|
274
|
+
log.success(`GitHub token synced to ${paths.githubTokenPath}`);
|
|
275
|
+
return githubToken;
|
|
276
|
+
};
|
|
277
|
+
const loadGitHubLogin = async (githubToken, vsCodeVersion) => {
|
|
278
|
+
try {
|
|
279
|
+
return (await getGitHubUser(githubToken, vsCodeVersion)).login;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
log.warn("Could not load GitHub user:", error);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const setupProxyAuth = async (config, options = {}) => {
|
|
286
|
+
let githubToken = await ensureGitHubToken(options);
|
|
287
|
+
const applyCopilotToken = async () => {
|
|
288
|
+
const tokenResponse = await getCopilotToken(githubToken, config.vsCodeVersion);
|
|
289
|
+
config.copilotToken = tokenResponse.token;
|
|
290
|
+
await writeStoredCopilotToken(tokenResponse);
|
|
291
|
+
return tokenResponse.refresh_in;
|
|
292
|
+
};
|
|
293
|
+
const scheduleCopilotTokenRefresh = (refreshIn$1) => {
|
|
294
|
+
if (copilotTokenRefreshTimer) clearTimeout(copilotTokenRefreshTimer);
|
|
295
|
+
const refreshMs = Math.max(refreshIn$1 - 60, 60) * 1e3;
|
|
296
|
+
log.debug(`Next Copilot token refresh in ${Math.round(refreshMs / 1e3)}s`);
|
|
297
|
+
copilotTokenRefreshTimer = setTimeout(async () => {
|
|
298
|
+
try {
|
|
299
|
+
const nextRefreshIn = await applyCopilotToken();
|
|
300
|
+
log.debug("Refreshed Copilot token");
|
|
301
|
+
scheduleCopilotTokenRefresh(nextRefreshIn);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
log.error("Failed to refresh Copilot token:", error);
|
|
304
|
+
scheduleCopilotTokenRefresh(60);
|
|
305
|
+
}
|
|
306
|
+
}, refreshMs);
|
|
307
|
+
if (typeof copilotTokenRefreshTimer.unref === "function") copilotTokenRefreshTimer.unref();
|
|
308
|
+
};
|
|
309
|
+
const storedToken = await readStoredCopilotToken();
|
|
310
|
+
let refreshIn;
|
|
311
|
+
if (storedToken && getStoredTokenRemainingSeconds(storedToken) > 60) {
|
|
312
|
+
config.copilotToken = storedToken.token;
|
|
313
|
+
refreshIn = getStoredTokenRemainingSeconds(storedToken);
|
|
314
|
+
log.debug(`Using cached Copilot token at ${paths.copilotTokenPath}`);
|
|
315
|
+
} else try {
|
|
316
|
+
refreshIn = await applyCopilotToken();
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (options.force || !isCopilotTokenError(error)) throw error;
|
|
319
|
+
log.warn("Cached GitHub auth could not get a Copilot token; running device auth again.");
|
|
320
|
+
githubToken = await ensureGitHubToken({
|
|
321
|
+
...options,
|
|
322
|
+
force: true
|
|
323
|
+
});
|
|
324
|
+
refreshIn = await applyCopilotToken();
|
|
325
|
+
}
|
|
326
|
+
scheduleCopilotTokenRefresh(refreshIn);
|
|
327
|
+
return {
|
|
328
|
+
githubLogin: await loadGitHubLogin(githubToken, config.vsCodeVersion),
|
|
329
|
+
source: "github-token"
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region src/lib/state.ts
|
|
335
|
+
const runtimeState = {};
|
|
336
|
+
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/lib/models.ts
|
|
339
|
+
const defaultReasoningEffort = "xhigh";
|
|
340
|
+
const defaultModelRouting = {
|
|
341
|
+
gptModel: "gpt-5.5",
|
|
342
|
+
opusModel: "claude-opus-4.8"
|
|
343
|
+
};
|
|
344
|
+
const isReasoningEffort = (value) => value === "none" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
|
|
345
|
+
const getModelRouting = () => runtimeState.modelRouting ?? defaultModelRouting;
|
|
346
|
+
const getExposedModelIds = () => {
|
|
347
|
+
const routing = getModelRouting();
|
|
348
|
+
return [routing.gptModel, routing.opusModel];
|
|
349
|
+
};
|
|
350
|
+
const routeModelId = (model) => {
|
|
351
|
+
const routing = getModelRouting();
|
|
352
|
+
return model.trim().toLowerCase().includes("opus") ? routing.opusModel : routing.gptModel;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/lib/app-config.ts
|
|
357
|
+
const logLevels = [
|
|
358
|
+
"silent",
|
|
359
|
+
"error",
|
|
360
|
+
"warn",
|
|
361
|
+
"info",
|
|
362
|
+
"debug",
|
|
363
|
+
"trace"
|
|
364
|
+
];
|
|
365
|
+
const defaultConfig = {
|
|
366
|
+
claudeSetup: true,
|
|
367
|
+
copilotBaseUrl: "https://api.githubcopilot.com",
|
|
368
|
+
gptModel: "gpt-5.5",
|
|
369
|
+
host: "127.0.0.1",
|
|
370
|
+
logLevel: "info",
|
|
371
|
+
logRetentionDays: 3,
|
|
372
|
+
opusModel: "claude-opus-4.8",
|
|
373
|
+
port: 4142,
|
|
374
|
+
thinkEffort: defaultReasoningEffort
|
|
375
|
+
};
|
|
376
|
+
const isLogLevelName = (value) => typeof value === "string" && logLevels.includes(value.toLowerCase());
|
|
377
|
+
const normalizeLogLevel = (value) => isLogLevelName(value) ? value.toLowerCase() : void 0;
|
|
378
|
+
const normalizeBoolean = (value) => {
|
|
379
|
+
if (typeof value === "boolean") return value;
|
|
380
|
+
if (typeof value !== "string") return;
|
|
381
|
+
const normalized = value.toLowerCase();
|
|
382
|
+
if (normalized === "true") return true;
|
|
383
|
+
if (normalized === "false") return false;
|
|
384
|
+
};
|
|
385
|
+
const normalizePort = (value) => {
|
|
386
|
+
const port = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
387
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : void 0;
|
|
388
|
+
};
|
|
389
|
+
const normalizePositiveInteger = (value) => {
|
|
390
|
+
const number = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
391
|
+
return Number.isInteger(number) && number > 0 ? number : void 0;
|
|
392
|
+
};
|
|
393
|
+
const normalizeString = (value) => typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
394
|
+
const normalizeThinkEffort = (value) => {
|
|
395
|
+
if (typeof value !== "string") return;
|
|
396
|
+
const normalized = value.toLowerCase() === "minimal" ? "low" : value.toLowerCase();
|
|
397
|
+
return isReasoningEffort(normalized) ? normalized : void 0;
|
|
398
|
+
};
|
|
399
|
+
const readRawConfig = async () => {
|
|
400
|
+
try {
|
|
401
|
+
return parseConfigYaml(await fs.readFile(paths.configPath, "utf8"));
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error.code === "ENOENT") {
|
|
404
|
+
for (const legacyPath of paths.legacyConfigPaths) try {
|
|
405
|
+
return parseConfigYaml(await fs.readFile(legacyPath, "utf8"));
|
|
406
|
+
} catch (legacyError) {
|
|
407
|
+
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
408
|
+
}
|
|
409
|
+
return readDefaultConfigTemplate();
|
|
410
|
+
}
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
const readDefaultConfigTemplate = async () => {
|
|
415
|
+
let currentDir = dirname(fileURLToPath(import.meta.url));
|
|
416
|
+
while (true) try {
|
|
417
|
+
return parseConfigYaml(await fs.readFile(resolve(currentDir, "config.default.yaml"), "utf8"));
|
|
418
|
+
} catch {
|
|
419
|
+
const parentDir = dirname(currentDir);
|
|
420
|
+
if (parentDir === currentDir) return {};
|
|
421
|
+
currentDir = parentDir;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const unquoteYamlScalar = (value) => {
|
|
425
|
+
const trimmed = value.trim();
|
|
426
|
+
if (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1);
|
|
427
|
+
return trimmed;
|
|
428
|
+
};
|
|
429
|
+
const parseConfigYaml = (content) => {
|
|
430
|
+
const config = {};
|
|
431
|
+
for (const line of content.split(/\r?\n/)) {
|
|
432
|
+
const trimmed = line.trim();
|
|
433
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
434
|
+
const match = trimmed.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$/);
|
|
435
|
+
if (!match) throw new Error(`Invalid config line in ${paths.configPath}: ${line}`);
|
|
436
|
+
const [, key, value] = match;
|
|
437
|
+
switch (key) {
|
|
438
|
+
case "claudeSetup":
|
|
439
|
+
case "claude_setup":
|
|
440
|
+
config.claudeSetup = unquoteYamlScalar(value);
|
|
441
|
+
break;
|
|
442
|
+
case "copilotBaseUrl":
|
|
443
|
+
case "copilot_base_url":
|
|
444
|
+
config.copilotBaseUrl = unquoteYamlScalar(value);
|
|
445
|
+
break;
|
|
446
|
+
case "host":
|
|
447
|
+
config.host = unquoteYamlScalar(value);
|
|
448
|
+
break;
|
|
449
|
+
case "gptModel":
|
|
450
|
+
case "gpt_model":
|
|
451
|
+
config.gptModel = unquoteYamlScalar(value);
|
|
452
|
+
break;
|
|
453
|
+
case "logLevel":
|
|
454
|
+
case "log_level":
|
|
455
|
+
config.logLevel = unquoteYamlScalar(value);
|
|
456
|
+
break;
|
|
457
|
+
case "logRetentionDays":
|
|
458
|
+
case "log_retention_days":
|
|
459
|
+
config.logRetentionDays = unquoteYamlScalar(value);
|
|
460
|
+
break;
|
|
461
|
+
case "opusModel":
|
|
462
|
+
case "opus_model":
|
|
463
|
+
config.opusModel = unquoteYamlScalar(value);
|
|
464
|
+
break;
|
|
465
|
+
case "port":
|
|
466
|
+
config.port = unquoteYamlScalar(value);
|
|
467
|
+
break;
|
|
468
|
+
case "thinkEffort":
|
|
469
|
+
case "think_effort":
|
|
470
|
+
config.thinkEffort = unquoteYamlScalar(value);
|
|
471
|
+
break;
|
|
472
|
+
default: break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return config;
|
|
476
|
+
};
|
|
477
|
+
const writeConfig = async (config) => {
|
|
478
|
+
await fs.mkdir(paths.appDir, { recursive: true });
|
|
479
|
+
const content = serializeConfig(config);
|
|
480
|
+
if (await fs.readFile(paths.configPath, "utf8").catch(() => "") === content) return;
|
|
481
|
+
await fs.writeFile(paths.configPath, content, { mode: 384 });
|
|
482
|
+
};
|
|
483
|
+
const serializeConfig = (config) => [
|
|
484
|
+
"# copilot-relay configuration",
|
|
485
|
+
"#",
|
|
486
|
+
"# This file is hot-reloaded while copilot-relay is running.",
|
|
487
|
+
"",
|
|
488
|
+
"# Local host for the Claude Code-compatible HTTP server.",
|
|
489
|
+
`host: ${config.host}`,
|
|
490
|
+
"",
|
|
491
|
+
"# Local port for the HTTP server.",
|
|
492
|
+
`port: ${config.port}`,
|
|
493
|
+
"",
|
|
494
|
+
"# GitHub Copilot API base URL.",
|
|
495
|
+
`copilotBaseUrl: ${config.copilotBaseUrl}`,
|
|
496
|
+
"",
|
|
497
|
+
"# Update ~/.claude/settings.json on start.",
|
|
498
|
+
`claudeSetup: ${config.claudeSetup}`,
|
|
499
|
+
"",
|
|
500
|
+
"# Log verbosity:",
|
|
501
|
+
"# silent - no logs",
|
|
502
|
+
"# error - startup/preflight/request failures only",
|
|
503
|
+
"# warn - error logs plus recoverable warnings",
|
|
504
|
+
"# info - warn logs plus startup status, preflight status, HTTP requests,",
|
|
505
|
+
"# and model routing summaries",
|
|
506
|
+
"# debug - info logs plus Copilot upstream timings, token refresh scheduling,",
|
|
507
|
+
"# and upstream error request summaries",
|
|
508
|
+
"# trace - debug logs plus full Claude request payloads and full upstream",
|
|
509
|
+
"# Copilot request payloads without redaction",
|
|
510
|
+
`logLevel: ${config.logLevel}`,
|
|
511
|
+
"",
|
|
512
|
+
"# Number of days to keep files in ~/.copilot-relay/logs.",
|
|
513
|
+
`logRetentionDays: ${config.logRetentionDays}`,
|
|
514
|
+
"",
|
|
515
|
+
"# Default upstream thinking/reasoning effort: none, low, medium, high, xhigh.",
|
|
516
|
+
`thinkEffort: ${config.thinkEffort}`,
|
|
517
|
+
"",
|
|
518
|
+
"# Model routing: requests containing \"opus\" use opusModel; all others use gptModel.",
|
|
519
|
+
"",
|
|
520
|
+
"# Upstream Copilot model used for non-Opus requests.",
|
|
521
|
+
`gptModel: ${config.gptModel}`,
|
|
522
|
+
"",
|
|
523
|
+
"# Upstream Copilot model used for requests containing \"opus\".",
|
|
524
|
+
`opusModel: ${config.opusModel}`,
|
|
525
|
+
""
|
|
526
|
+
].join("\n");
|
|
527
|
+
async function readAppConfig() {
|
|
528
|
+
const raw = await readRawConfig();
|
|
529
|
+
const claudeSetup = normalizeBoolean(raw.claudeSetup);
|
|
530
|
+
const host = normalizeString(raw.host);
|
|
531
|
+
const logLevel = normalizeLogLevel(raw.logLevel);
|
|
532
|
+
const logRetentionDays$1 = normalizePositiveInteger(raw.logRetentionDays);
|
|
533
|
+
const port = normalizePort(raw.port);
|
|
534
|
+
const thinkEffort = normalizeThinkEffort(raw.thinkEffort);
|
|
535
|
+
const config = {
|
|
536
|
+
claudeSetup: claudeSetup ?? defaultConfig.claudeSetup,
|
|
537
|
+
copilotBaseUrl: normalizeString(raw.copilotBaseUrl) ?? defaultConfig.copilotBaseUrl,
|
|
538
|
+
gptModel: normalizeString(raw.gptModel) ?? defaultConfig.gptModel,
|
|
539
|
+
host: host ?? defaultConfig.host,
|
|
540
|
+
logLevel: logLevel ?? defaultConfig.logLevel,
|
|
541
|
+
logRetentionDays: logRetentionDays$1 ?? defaultConfig.logRetentionDays,
|
|
542
|
+
opusModel: normalizeString(raw.opusModel) ?? defaultConfig.opusModel,
|
|
543
|
+
port: port ?? defaultConfig.port,
|
|
544
|
+
thinkEffort: thinkEffort ?? defaultConfig.thinkEffort
|
|
545
|
+
};
|
|
546
|
+
await writeConfig(config);
|
|
547
|
+
return config;
|
|
548
|
+
}
|
|
549
|
+
const getConfigMtime = async () => {
|
|
550
|
+
try {
|
|
551
|
+
return (await fs.stat(paths.configPath)).mtimeMs;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (error.code === "ENOENT") return 0;
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
const watchAppConfig = (onReload) => {
|
|
558
|
+
let lastMtime = 0;
|
|
559
|
+
const timer = setInterval(async () => {
|
|
560
|
+
try {
|
|
561
|
+
const nextMtime = await getConfigMtime();
|
|
562
|
+
if (nextMtime === lastMtime) return;
|
|
563
|
+
lastMtime = nextMtime;
|
|
564
|
+
onReload(await readAppConfig());
|
|
565
|
+
} catch {}
|
|
566
|
+
}, 1e3);
|
|
567
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
568
|
+
getConfigMtime().then((mtime) => {
|
|
569
|
+
lastMtime = mtime;
|
|
570
|
+
});
|
|
571
|
+
return timer;
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
//#endregion
|
|
575
|
+
//#region src/lib/config.ts
|
|
576
|
+
const vscodeVersion = "1.99.3";
|
|
577
|
+
const readProxyConfig = (config) => ({
|
|
578
|
+
copilotBaseUrl: config.copilotBaseUrl,
|
|
579
|
+
copilotToken: void 0,
|
|
580
|
+
host: config.host,
|
|
581
|
+
port: config.port,
|
|
582
|
+
vsCodeVersion: vscodeVersion
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
//#endregion
|
|
586
|
+
//#region src/auth.ts
|
|
587
|
+
const auth = defineCommand({
|
|
588
|
+
meta: {
|
|
589
|
+
name: "auth",
|
|
590
|
+
description: "Run GitHub device auth and cache credentials for copilot-relay."
|
|
591
|
+
},
|
|
592
|
+
async run() {
|
|
593
|
+
const config = readProxyConfig(await readAppConfig());
|
|
594
|
+
config.copilotToken = void 0;
|
|
595
|
+
const authSession = await setupProxyAuth(config, { force: true });
|
|
596
|
+
if (authSession.githubLogin) log.info(`Logged in as ${authSession.githubLogin}`);
|
|
597
|
+
log.success("copilot-relay auth completed");
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
//#endregion
|
|
602
|
+
//#region src/lib/claude-settings.ts
|
|
603
|
+
/**
|
|
604
|
+
* Update `~/.claude/settings.json` so its env block points Claude Code at the
|
|
605
|
+
* running proxy. Preserves all unrelated keys (model overrides, plugins,
|
|
606
|
+
* marketplaces, etc.). Sets a dummy auth token only if none is present.
|
|
607
|
+
*/
|
|
608
|
+
async function applyClaudeConfig(input) {
|
|
609
|
+
const { configPath: configPath$1, baseUrl } = input;
|
|
610
|
+
let raw = "";
|
|
611
|
+
let created = false;
|
|
612
|
+
try {
|
|
613
|
+
raw = await fs.readFile(configPath$1, "utf8");
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (error.code === "ENOENT") created = true;
|
|
616
|
+
else throw error;
|
|
617
|
+
}
|
|
618
|
+
let parsed = {};
|
|
619
|
+
if (raw.trim().length > 0) try {
|
|
620
|
+
parsed = JSON.parse(raw);
|
|
621
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) parsed = {};
|
|
622
|
+
} catch {
|
|
623
|
+
return {
|
|
624
|
+
configPath: configPath$1,
|
|
625
|
+
changed: false,
|
|
626
|
+
created: false
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
const env = typeof parsed.env === "object" && parsed.env !== null && !Array.isArray(parsed.env) ? { ...parsed.env } : {};
|
|
630
|
+
const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : void 0;
|
|
631
|
+
env.ANTHROPIC_BASE_URL = baseUrl;
|
|
632
|
+
if (typeof env.ANTHROPIC_AUTH_TOKEN !== "string" || !env.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = "dummy";
|
|
633
|
+
const next = {
|
|
634
|
+
...parsed,
|
|
635
|
+
env
|
|
636
|
+
};
|
|
637
|
+
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
638
|
+
if (serialized === raw) return {
|
|
639
|
+
configPath: configPath$1,
|
|
640
|
+
changed: false,
|
|
641
|
+
created: false,
|
|
642
|
+
previousBaseUrl
|
|
643
|
+
};
|
|
644
|
+
await fs.mkdir(path.dirname(configPath$1), { recursive: true });
|
|
645
|
+
await fs.writeFile(configPath$1, serialized);
|
|
646
|
+
return {
|
|
647
|
+
configPath: configPath$1,
|
|
648
|
+
changed: true,
|
|
649
|
+
created,
|
|
650
|
+
previousBaseUrl
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
//#endregion
|
|
655
|
+
//#region src/lib/defaults.ts
|
|
656
|
+
const claudeConfigPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
657
|
+
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/copilot/client.ts
|
|
660
|
+
const copilotVersion = "0.26.7";
|
|
661
|
+
const editorPluginVersion = `copilot-chat/${copilotVersion}`;
|
|
662
|
+
const userAgent = `GitHubCopilotChat/${copilotVersion}`;
|
|
663
|
+
const apiVersion = "2025-04-01";
|
|
664
|
+
const maxFetchAttempts = 2;
|
|
665
|
+
const getCopilotProviderContext = (config) => ({
|
|
666
|
+
baseUrl: config.copilotBaseUrl,
|
|
667
|
+
token: config.copilotToken,
|
|
668
|
+
vsCodeVersion: config.vsCodeVersion
|
|
669
|
+
});
|
|
670
|
+
const shouldRetryResponse = (response) => response.status >= 500 && response.status <= 599;
|
|
671
|
+
const buildHeaders = (provider, init, options) => {
|
|
672
|
+
const headers = new Headers(init.headers);
|
|
673
|
+
headers.set("authorization", `Bearer ${provider.token}`);
|
|
674
|
+
headers.set("copilot-integration-id", "vscode-chat");
|
|
675
|
+
headers.set("editor-version", `vscode/${provider.vsCodeVersion}`);
|
|
676
|
+
headers.set("editor-plugin-version", editorPluginVersion);
|
|
677
|
+
headers.set("user-agent", userAgent);
|
|
678
|
+
headers.set("openai-intent", "conversation-panel");
|
|
679
|
+
headers.set("x-github-api-version", apiVersion);
|
|
680
|
+
headers.set("x-request-id", randomUUID());
|
|
681
|
+
headers.set("x-vscode-user-agent-library-version", "electron-fetch");
|
|
682
|
+
if (options.vision) headers.set("copilot-vision-request", "true");
|
|
683
|
+
if (options.initiator) headers.set("x-initiator", options.initiator);
|
|
684
|
+
if (!headers.has("content-type") && init.body !== void 0) headers.set("content-type", "application/json");
|
|
685
|
+
if (!headers.has("accept")) headers.set("accept", "application/json");
|
|
686
|
+
return headers;
|
|
687
|
+
};
|
|
688
|
+
const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
689
|
+
if (!provider.token) throw new ProxyNotImplementedError("Copilot token is not configured for copilot-relay.");
|
|
690
|
+
let lastError;
|
|
691
|
+
for (let attempt = 1; attempt <= maxFetchAttempts; attempt++) try {
|
|
692
|
+
const started = performance.now();
|
|
693
|
+
const response = await fetch(`${provider.baseUrl}${path$1}`, {
|
|
694
|
+
...init,
|
|
695
|
+
headers: buildHeaders(provider, init, options)
|
|
696
|
+
});
|
|
697
|
+
const ms = Math.round(performance.now() - started);
|
|
698
|
+
log.debug(`Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt})`);
|
|
699
|
+
if (!shouldRetryResponse(response) || attempt === maxFetchAttempts) return response;
|
|
700
|
+
log.warn(`Copilot ${path$1} returned ${response.status}; retrying (${attempt}/${maxFetchAttempts})`);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
lastError = error;
|
|
703
|
+
if (attempt === maxFetchAttempts) throw error;
|
|
704
|
+
log.warn(`Copilot ${path$1} request failed; retrying (${attempt}/${maxFetchAttempts})`, error);
|
|
705
|
+
}
|
|
706
|
+
throw lastError;
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
//#endregion
|
|
710
|
+
//#region src/lib/upstream-diagnostics.ts
|
|
711
|
+
const openaiToolNamePattern = /^[A-Za-z0-9_-]{1,64}$/;
|
|
712
|
+
const toolSchemaSuspectKeys = new Set([
|
|
713
|
+
"$defs",
|
|
714
|
+
"allOf",
|
|
715
|
+
"definitions",
|
|
716
|
+
"dependentRequired",
|
|
717
|
+
"dependentSchemas",
|
|
718
|
+
"else",
|
|
719
|
+
"if",
|
|
720
|
+
"not",
|
|
721
|
+
"oneOf",
|
|
722
|
+
"patternProperties",
|
|
723
|
+
"then"
|
|
724
|
+
]);
|
|
725
|
+
const maxDiagnosticItems = 8;
|
|
726
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
727
|
+
const getToolName = (tool) => {
|
|
728
|
+
if (!isRecord(tool)) return "";
|
|
729
|
+
const functionValue = tool.function;
|
|
730
|
+
if (isRecord(functionValue) && typeof functionValue.name === "string") return functionValue.name;
|
|
731
|
+
return typeof tool.name === "string" ? tool.name : "";
|
|
732
|
+
};
|
|
733
|
+
const getToolParameters = (tool) => {
|
|
734
|
+
if (!isRecord(tool)) return;
|
|
735
|
+
const functionValue = tool.function;
|
|
736
|
+
if (isRecord(functionValue) && "parameters" in functionValue) return functionValue.parameters;
|
|
737
|
+
return tool.parameters;
|
|
738
|
+
};
|
|
739
|
+
const collectSuspiciousSchemaKeys = (value, path$1 = "$", keys = []) => {
|
|
740
|
+
if (keys.length >= maxDiagnosticItems) return keys;
|
|
741
|
+
if (Array.isArray(value)) {
|
|
742
|
+
for (const [index, item] of value.entries()) {
|
|
743
|
+
collectSuspiciousSchemaKeys(item, `${path$1}[${index}]`, keys);
|
|
744
|
+
if (keys.length >= maxDiagnosticItems) break;
|
|
745
|
+
}
|
|
746
|
+
return keys;
|
|
747
|
+
}
|
|
748
|
+
if (!isRecord(value)) return keys;
|
|
749
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
750
|
+
const nextPath = `${path$1}.${key}`;
|
|
751
|
+
if (toolSchemaSuspectKeys.has(key)) {
|
|
752
|
+
if (!keys.includes(nextPath)) keys.push(nextPath);
|
|
753
|
+
if (keys.length >= maxDiagnosticItems) break;
|
|
754
|
+
}
|
|
755
|
+
collectSuspiciousSchemaKeys(nestedValue, nextPath, keys);
|
|
756
|
+
if (keys.length >= maxDiagnosticItems) break;
|
|
757
|
+
}
|
|
758
|
+
return keys;
|
|
759
|
+
};
|
|
760
|
+
const summarizeToolsForDiagnostics = (tools) => {
|
|
761
|
+
if (!Array.isArray(tools) || tools.length === 0) return;
|
|
762
|
+
const invalidNames = tools.map((tool) => getToolName(tool)).filter((name) => !openaiToolNamePattern.test(name)).slice(0, maxDiagnosticItems);
|
|
763
|
+
const suspiciousSchemas = tools.flatMap((tool) => {
|
|
764
|
+
const name = getToolName(tool) || "<missing>";
|
|
765
|
+
const keys = collectSuspiciousSchemaKeys(getToolParameters(tool));
|
|
766
|
+
return keys.length > 0 ? [{
|
|
767
|
+
keys,
|
|
768
|
+
name
|
|
769
|
+
}] : [];
|
|
770
|
+
}).slice(0, maxDiagnosticItems);
|
|
771
|
+
return {
|
|
772
|
+
count: tools.length,
|
|
773
|
+
...invalidNames.length > 0 ? { invalidNames } : {},
|
|
774
|
+
...suspiciousSchemas.length > 0 ? { suspiciousSchemas } : {}
|
|
775
|
+
};
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
//#endregion
|
|
779
|
+
//#region src/copilot/responses.ts
|
|
780
|
+
const responsesOnlyModelPattern = /^(?:gpt-5\.5)(?:-|$)/i;
|
|
781
|
+
function shouldUseResponsesApiForModel(model) {
|
|
782
|
+
return responsesOnlyModelPattern.test(model);
|
|
783
|
+
}
|
|
784
|
+
function buildResponsesRequestPayload(payload, reasoningEffort) {
|
|
785
|
+
return {
|
|
786
|
+
model: payload.model,
|
|
787
|
+
input: translateMessagesToResponsesInput(payload.messages),
|
|
788
|
+
stream: payload.stream,
|
|
789
|
+
max_output_tokens: payload.max_tokens,
|
|
790
|
+
temperature: payload.temperature,
|
|
791
|
+
top_p: payload.top_p,
|
|
792
|
+
user: sanitizeUserIdentifier(payload.user),
|
|
793
|
+
tools: translateTools(payload.tools),
|
|
794
|
+
tool_choice: translateToolChoice(payload.tool_choice),
|
|
795
|
+
reasoning: reasoningEffort ? { effort: reasoningEffort } : void 0,
|
|
796
|
+
text: payload.response_format?.type === "json_object" ? { format: { type: "json_object" } } : void 0
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
function translateResponsesToChatCompletion(response) {
|
|
800
|
+
const assistantMessages = response.output.filter((item) => item.type === "message");
|
|
801
|
+
const functionCalls = response.output.filter((item) => item.type === "function_call");
|
|
802
|
+
const content = assistantMessages.flatMap((item) => item.content).map((part) => part.text).join("");
|
|
803
|
+
const reasoningText = getResponsesReasoningText(response);
|
|
804
|
+
return {
|
|
805
|
+
id: response.id,
|
|
806
|
+
object: "chat.completion",
|
|
807
|
+
created: response.created_at,
|
|
808
|
+
model: response.model,
|
|
809
|
+
choices: [{
|
|
810
|
+
index: 0,
|
|
811
|
+
message: {
|
|
812
|
+
role: "assistant",
|
|
813
|
+
content: content || null,
|
|
814
|
+
...reasoningText && { reasoning_text: reasoningText },
|
|
815
|
+
...functionCalls.length > 0 && { tool_calls: functionCalls.map((toolCall) => ({
|
|
816
|
+
id: toolCall.call_id,
|
|
817
|
+
type: "function",
|
|
818
|
+
function: {
|
|
819
|
+
name: toolCall.name,
|
|
820
|
+
arguments: toolCall.arguments
|
|
821
|
+
}
|
|
822
|
+
})) }
|
|
823
|
+
},
|
|
824
|
+
logprobs: null,
|
|
825
|
+
finish_reason: getFinishReason(response, functionCalls.length > 0)
|
|
826
|
+
}],
|
|
827
|
+
usage: translateUsage(response.usage)
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
async function* translateResponsesStreamToChatCompletionStream(responseStream) {
|
|
831
|
+
const state = {
|
|
832
|
+
responseId: randomUUID(),
|
|
833
|
+
createdAt: Math.floor(Date.now() / 1e3),
|
|
834
|
+
model: "",
|
|
835
|
+
started: false,
|
|
836
|
+
reasoningTextSent: false,
|
|
837
|
+
outputTextByIndex: {},
|
|
838
|
+
toolArgumentsByIndex: {},
|
|
839
|
+
toolStartedByIndex: {}
|
|
840
|
+
};
|
|
841
|
+
for await (const rawEvent of responseStream) {
|
|
842
|
+
if (!rawEvent.data || rawEvent.data === "[DONE]") continue;
|
|
843
|
+
const event = JSON.parse(rawEvent.data);
|
|
844
|
+
if (event.response) {
|
|
845
|
+
state.responseId = event.response.id;
|
|
846
|
+
state.createdAt = event.response.created_at;
|
|
847
|
+
state.model = event.response.model;
|
|
848
|
+
}
|
|
849
|
+
if (event.type === "response.output_item.added") {
|
|
850
|
+
const chunk = handleOutputItemAdded(state, event);
|
|
851
|
+
if (chunk) yield chunk;
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (event.type === "response.output_item.done") {
|
|
855
|
+
for (const chunk of handleOutputItemDone(state, event)) yield chunk;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (event.type === "response.reasoning_summary_text.delta") {
|
|
859
|
+
if (event.delta) {
|
|
860
|
+
yield createRoleChunk(state);
|
|
861
|
+
yield createChunk(state, { delta: { reasoning_text: event.delta } });
|
|
862
|
+
state.reasoningTextSent = true;
|
|
863
|
+
}
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (event.type === "response.output_text.delta") {
|
|
867
|
+
const outputIndex = event.output_index ?? 0;
|
|
868
|
+
state.outputTextByIndex[outputIndex] = `${state.outputTextByIndex[outputIndex] ?? ""}${event.delta ?? ""}`;
|
|
869
|
+
yield createRoleChunk(state);
|
|
870
|
+
yield createChunk(state, { delta: { content: event.delta } });
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (event.type === "response.output_text.done") {
|
|
874
|
+
const outputIndex = event.output_index ?? 0;
|
|
875
|
+
const content = getMissingSuffix(state.outputTextByIndex[outputIndex] ?? "", event.text);
|
|
876
|
+
if (content) {
|
|
877
|
+
state.outputTextByIndex[outputIndex] = `${state.outputTextByIndex[outputIndex] ?? ""}${content}`;
|
|
878
|
+
yield createRoleChunk(state);
|
|
879
|
+
yield createChunk(state, { delta: { content } });
|
|
880
|
+
}
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (event.type === "response.function_call_arguments.delta") {
|
|
884
|
+
if (event.output_index === void 0) continue;
|
|
885
|
+
state.toolArgumentsByIndex[event.output_index] = `${state.toolArgumentsByIndex[event.output_index] ?? ""}${event.delta ?? ""}`;
|
|
886
|
+
yield createRoleChunk(state);
|
|
887
|
+
yield createChunk(state, { delta: { tool_calls: [{
|
|
888
|
+
index: event.output_index,
|
|
889
|
+
type: "function",
|
|
890
|
+
function: { arguments: event.delta ?? "" }
|
|
891
|
+
}] } });
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (event.type === "response.function_call_arguments.done") {
|
|
895
|
+
if (event.output_index === void 0) continue;
|
|
896
|
+
const content = getMissingSuffix(state.toolArgumentsByIndex[event.output_index] ?? "", event.arguments);
|
|
897
|
+
if (content) {
|
|
898
|
+
state.toolArgumentsByIndex[event.output_index] = `${state.toolArgumentsByIndex[event.output_index] ?? ""}${content}`;
|
|
899
|
+
yield createRoleChunk(state);
|
|
900
|
+
yield createChunk(state, { delta: { tool_calls: [{
|
|
901
|
+
index: event.output_index,
|
|
902
|
+
type: "function",
|
|
903
|
+
function: { arguments: content }
|
|
904
|
+
}] } });
|
|
905
|
+
}
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (event.type === "response.completed") {
|
|
909
|
+
if (!event.response) continue;
|
|
910
|
+
const reasoningText = getResponsesReasoningText({
|
|
911
|
+
output: event.response.output ?? [],
|
|
912
|
+
reasoning: event.response.reasoning
|
|
913
|
+
});
|
|
914
|
+
if (reasoningText && !state.reasoningTextSent) {
|
|
915
|
+
yield createRoleChunk(state);
|
|
916
|
+
yield createChunk(state, { delta: { reasoning_text: reasoningText } });
|
|
917
|
+
state.reasoningTextSent = true;
|
|
918
|
+
}
|
|
919
|
+
yield createChunk({
|
|
920
|
+
...state,
|
|
921
|
+
responseId: event.response.id,
|
|
922
|
+
createdAt: event.response.created_at,
|
|
923
|
+
model: event.response.model
|
|
924
|
+
}, {
|
|
925
|
+
delta: {},
|
|
926
|
+
finishReason: getFinishReason({
|
|
927
|
+
output: event.response.output ?? [],
|
|
928
|
+
incomplete_details: event.response.incomplete_details
|
|
929
|
+
}, (event.response.output ?? []).some((item) => item.type === "function_call")),
|
|
930
|
+
usage: translateUsage(event.response.usage)
|
|
931
|
+
});
|
|
932
|
+
yield { data: "[DONE]" };
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function handleOutputItemAdded(state, event) {
|
|
938
|
+
if (event.item?.type === "message") return createRoleChunk(state);
|
|
939
|
+
if (event.item?.type === "reasoning") {
|
|
940
|
+
const reasoningText = getSummaryText(event.item.summary);
|
|
941
|
+
if (!reasoningText) return;
|
|
942
|
+
state.reasoningTextSent = true;
|
|
943
|
+
return createChunk(state, { delta: { reasoning_text: reasoningText } });
|
|
944
|
+
}
|
|
945
|
+
if (event.item?.type === "function_call" && event.output_index !== void 0) {
|
|
946
|
+
const initialArguments = event.item.arguments ?? "";
|
|
947
|
+
state.started = true;
|
|
948
|
+
state.toolStartedByIndex[event.output_index] = true;
|
|
949
|
+
state.toolArgumentsByIndex[event.output_index] = initialArguments;
|
|
950
|
+
return createChunk(state, { delta: {
|
|
951
|
+
role: "assistant",
|
|
952
|
+
tool_calls: [{
|
|
953
|
+
index: event.output_index,
|
|
954
|
+
id: event.item.call_id,
|
|
955
|
+
type: "function",
|
|
956
|
+
function: {
|
|
957
|
+
name: event.item.name,
|
|
958
|
+
arguments: initialArguments
|
|
959
|
+
}
|
|
960
|
+
}]
|
|
961
|
+
} });
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
function handleOutputItemDone(state, event) {
|
|
965
|
+
if (event.item?.type === "reasoning") {
|
|
966
|
+
const reasoningText = getSummaryText(event.item.summary);
|
|
967
|
+
if (!reasoningText || state.reasoningTextSent) return [];
|
|
968
|
+
state.reasoningTextSent = true;
|
|
969
|
+
return [createChunk(state, { delta: { reasoning_text: reasoningText } })];
|
|
970
|
+
}
|
|
971
|
+
if (event.item?.type === "message") {
|
|
972
|
+
const outputIndex = event.output_index ?? 0;
|
|
973
|
+
const fullText = getMessageOutputText(event.item.content);
|
|
974
|
+
const content = getMissingSuffix(state.outputTextByIndex[outputIndex] ?? "", fullText);
|
|
975
|
+
if (!content) return [];
|
|
976
|
+
state.outputTextByIndex[outputIndex] = `${state.outputTextByIndex[outputIndex] ?? ""}${content}`;
|
|
977
|
+
return [createRoleChunk(state), createChunk(state, { delta: { content } })];
|
|
978
|
+
}
|
|
979
|
+
if (event.item?.type === "function_call" && event.output_index !== void 0) {
|
|
980
|
+
const outputIndex = event.output_index;
|
|
981
|
+
const chunks = [];
|
|
982
|
+
if (!state.toolStartedByIndex[outputIndex]) {
|
|
983
|
+
const initialArguments = event.item.arguments ?? "";
|
|
984
|
+
state.started = true;
|
|
985
|
+
state.toolStartedByIndex[outputIndex] = true;
|
|
986
|
+
state.toolArgumentsByIndex[outputIndex] = initialArguments;
|
|
987
|
+
chunks.push(createChunk(state, { delta: {
|
|
988
|
+
role: "assistant",
|
|
989
|
+
tool_calls: [{
|
|
990
|
+
index: outputIndex,
|
|
991
|
+
id: event.item.call_id,
|
|
992
|
+
type: "function",
|
|
993
|
+
function: {
|
|
994
|
+
name: event.item.name,
|
|
995
|
+
arguments: initialArguments
|
|
996
|
+
}
|
|
997
|
+
}]
|
|
998
|
+
} }));
|
|
999
|
+
return chunks;
|
|
1000
|
+
}
|
|
1001
|
+
const content = getMissingSuffix(state.toolArgumentsByIndex[outputIndex] ?? "", event.item.arguments);
|
|
1002
|
+
if (content) {
|
|
1003
|
+
state.toolArgumentsByIndex[outputIndex] = `${state.toolArgumentsByIndex[outputIndex] ?? ""}${content}`;
|
|
1004
|
+
chunks.push(createRoleChunk(state), createChunk(state, { delta: { tool_calls: [{
|
|
1005
|
+
index: outputIndex,
|
|
1006
|
+
type: "function",
|
|
1007
|
+
function: { arguments: content }
|
|
1008
|
+
}] } }));
|
|
1009
|
+
}
|
|
1010
|
+
return chunks;
|
|
1011
|
+
}
|
|
1012
|
+
return [];
|
|
1013
|
+
}
|
|
1014
|
+
function createRoleChunk(state) {
|
|
1015
|
+
if (state.started) return { data: "" };
|
|
1016
|
+
state.started = true;
|
|
1017
|
+
return createChunk(state, { delta: { role: "assistant" } });
|
|
1018
|
+
}
|
|
1019
|
+
function createChunk(state, { delta, finishReason = null, usage }) {
|
|
1020
|
+
return { data: JSON.stringify({
|
|
1021
|
+
id: state.responseId,
|
|
1022
|
+
object: "chat.completion.chunk",
|
|
1023
|
+
created: state.createdAt,
|
|
1024
|
+
model: state.model,
|
|
1025
|
+
choices: [{
|
|
1026
|
+
index: 0,
|
|
1027
|
+
delta,
|
|
1028
|
+
finish_reason: finishReason,
|
|
1029
|
+
logprobs: null
|
|
1030
|
+
}],
|
|
1031
|
+
usage
|
|
1032
|
+
}) };
|
|
1033
|
+
}
|
|
1034
|
+
function translateMessagesToResponsesInput(messages) {
|
|
1035
|
+
const items = messages.flatMap((message) => translateMessage(message));
|
|
1036
|
+
if (items.length === 1 && "role" in items[0] && items[0].role === "user" && typeof items[0].content === "string") return items[0].content;
|
|
1037
|
+
return items;
|
|
1038
|
+
}
|
|
1039
|
+
function translateMessage(message) {
|
|
1040
|
+
if (message.role === "tool") return [{
|
|
1041
|
+
type: "function_call_output",
|
|
1042
|
+
call_id: message.tool_call_id ?? "",
|
|
1043
|
+
output: stringifyToolOutput(message.content)
|
|
1044
|
+
}];
|
|
1045
|
+
const translated = [];
|
|
1046
|
+
if (hasContent(message.content)) translated.push({
|
|
1047
|
+
role: message.role,
|
|
1048
|
+
content: translateContent(message.content)
|
|
1049
|
+
});
|
|
1050
|
+
if (message.role === "assistant" && message.tool_calls) translated.push(...message.tool_calls.map((toolCall) => ({
|
|
1051
|
+
type: "function_call",
|
|
1052
|
+
call_id: toolCall.id,
|
|
1053
|
+
name: toolCall.function.name,
|
|
1054
|
+
arguments: toolCall.function.arguments
|
|
1055
|
+
})));
|
|
1056
|
+
return translated;
|
|
1057
|
+
}
|
|
1058
|
+
function hasContent(content) {
|
|
1059
|
+
if (content === null) return false;
|
|
1060
|
+
if (typeof content === "string") return content.length > 0;
|
|
1061
|
+
return content.length > 0;
|
|
1062
|
+
}
|
|
1063
|
+
function translateContent(content) {
|
|
1064
|
+
if (typeof content === "string") return content;
|
|
1065
|
+
if (!content || content.length === 0) return "";
|
|
1066
|
+
return content.map((part) => translateContentPart(part));
|
|
1067
|
+
}
|
|
1068
|
+
function translateContentPart(part) {
|
|
1069
|
+
if (part.type === "text") return {
|
|
1070
|
+
type: "input_text",
|
|
1071
|
+
text: part.text
|
|
1072
|
+
};
|
|
1073
|
+
return {
|
|
1074
|
+
type: "input_image",
|
|
1075
|
+
image_url: part.image_url.url,
|
|
1076
|
+
detail: part.image_url.detail ?? "auto"
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
function stringifyToolOutput(content) {
|
|
1080
|
+
if (typeof content === "string") return content;
|
|
1081
|
+
if (!content) return "";
|
|
1082
|
+
return content.filter((part) => part.type === "text").map((part) => part.text).join("\n\n") || JSON.stringify(content);
|
|
1083
|
+
}
|
|
1084
|
+
function translateTools(tools) {
|
|
1085
|
+
if (!tools) return;
|
|
1086
|
+
return tools.map((tool) => ({
|
|
1087
|
+
type: "function",
|
|
1088
|
+
name: tool.function.name,
|
|
1089
|
+
description: tool.function.description,
|
|
1090
|
+
parameters: tool.function.parameters
|
|
1091
|
+
}));
|
|
1092
|
+
}
|
|
1093
|
+
function translateToolChoice(toolChoice) {
|
|
1094
|
+
if (!toolChoice) return;
|
|
1095
|
+
if (typeof toolChoice === "string") return toolChoice;
|
|
1096
|
+
return {
|
|
1097
|
+
type: "function",
|
|
1098
|
+
name: toolChoice.function.name
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
function translateUsage(usage) {
|
|
1102
|
+
if (!usage) return;
|
|
1103
|
+
return {
|
|
1104
|
+
prompt_tokens: usage.input_tokens,
|
|
1105
|
+
completion_tokens: usage.output_tokens,
|
|
1106
|
+
total_tokens: usage.total_tokens,
|
|
1107
|
+
...usage.input_tokens_details?.cached_tokens !== void 0 && { prompt_tokens_details: { cached_tokens: usage.input_tokens_details.cached_tokens } }
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
function getMessageOutputText(content) {
|
|
1111
|
+
return content?.filter((part) => part.type === "output_text").map((part) => part.text).join("") || void 0;
|
|
1112
|
+
}
|
|
1113
|
+
function getMissingSuffix(alreadySent, finalValue) {
|
|
1114
|
+
if (!finalValue) return;
|
|
1115
|
+
if (!alreadySent) return finalValue;
|
|
1116
|
+
if (!finalValue.startsWith(alreadySent)) return;
|
|
1117
|
+
return finalValue.slice(alreadySent.length) || void 0;
|
|
1118
|
+
}
|
|
1119
|
+
function getResponsesReasoningText(response) {
|
|
1120
|
+
const parts = [getSummaryText(response.reasoning?.summary), ...response.output.filter((item) => item.type === "reasoning").map((item) => getSummaryText(item.summary))].filter((part) => Boolean(part));
|
|
1121
|
+
return parts.length > 0 ? parts.join("\n\n") : void 0;
|
|
1122
|
+
}
|
|
1123
|
+
function getSummaryText(summary) {
|
|
1124
|
+
if (typeof summary === "string") return summary || void 0;
|
|
1125
|
+
if (!Array.isArray(summary)) return;
|
|
1126
|
+
return summary.map((part) => part.text).filter((part) => Boolean(part)).join("\n\n") || void 0;
|
|
1127
|
+
}
|
|
1128
|
+
function getFinishReason(response, hasFunctionCalls) {
|
|
1129
|
+
if (hasFunctionCalls) return "tool_calls";
|
|
1130
|
+
if (response.incomplete_details?.reason?.includes("max_output_tokens")) return "length";
|
|
1131
|
+
return "stop";
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
//#endregion
|
|
1135
|
+
//#region src/copilot/chat.ts
|
|
1136
|
+
const usesMaxCompletionTokens = (modelId) => modelId.startsWith("gpt-5");
|
|
1137
|
+
const maxUserLength = 64;
|
|
1138
|
+
const sanitizeReasoningEffortForModel = (_modelId, reasoningEffort) => {
|
|
1139
|
+
return reasoningEffort ?? void 0;
|
|
1140
|
+
};
|
|
1141
|
+
const getRequestedReasoningEffort = (payload) => {
|
|
1142
|
+
return sanitizeReasoningEffortForModel(payload.model, runtimeState.thinkEffort ?? defaultReasoningEffort);
|
|
1143
|
+
};
|
|
1144
|
+
const sanitizeUserIdentifier = (user) => {
|
|
1145
|
+
if (!user) return;
|
|
1146
|
+
return user.slice(0, maxUserLength);
|
|
1147
|
+
};
|
|
1148
|
+
const summarizeRequestForDiagnostics = (payload) => ({
|
|
1149
|
+
max_completion_tokens: payload.max_completion_tokens,
|
|
1150
|
+
max_tokens: payload.max_tokens,
|
|
1151
|
+
message_count: payload.messages.length,
|
|
1152
|
+
output_config_effort: payload.output_config?.effort,
|
|
1153
|
+
reasoning_effort: payload.reasoning_effort,
|
|
1154
|
+
stream: payload.stream ?? void 0,
|
|
1155
|
+
tool_choice: payload.tool_choice,
|
|
1156
|
+
tools: summarizeToolsForDiagnostics(payload.tools)
|
|
1157
|
+
});
|
|
1158
|
+
const buildRequestPayload = (payload) => {
|
|
1159
|
+
const requestedReasoningEffort = getRequestedReasoningEffort(payload);
|
|
1160
|
+
if (!usesMaxCompletionTokens(payload.model) || payload.max_tokens === null || payload.max_tokens === void 0) return {
|
|
1161
|
+
...payload,
|
|
1162
|
+
reasoning_effort: requestedReasoningEffort,
|
|
1163
|
+
user: sanitizeUserIdentifier(payload.user)
|
|
1164
|
+
};
|
|
1165
|
+
return {
|
|
1166
|
+
...payload,
|
|
1167
|
+
max_tokens: void 0,
|
|
1168
|
+
max_completion_tokens: payload.max_tokens,
|
|
1169
|
+
reasoning_effort: requestedReasoningEffort,
|
|
1170
|
+
user: sanitizeUserIdentifier(payload.user)
|
|
1171
|
+
};
|
|
1172
|
+
};
|
|
1173
|
+
const isAgentInitiator = (messages) => messages.some((msg) => msg.role === "assistant" || msg.role === "tool") ? "agent" : "user";
|
|
1174
|
+
const messagesIncludeImage = (messages) => messages.some((msg) => typeof msg.content !== "string" && msg.content?.some((part) => part.type === "image_url"));
|
|
1175
|
+
const createChatCompletions = async (config, payload, options = {}) => {
|
|
1176
|
+
const client = options.client ?? "generic";
|
|
1177
|
+
const requestedModel = options.requestedModel ?? payload.model;
|
|
1178
|
+
const requestedThinkEffort = options.requestedThinkEffort ?? payload.reasoning_effort ?? "none";
|
|
1179
|
+
const requestedThinking = options.requestedThinking ?? "none";
|
|
1180
|
+
const upstreamModelId = routeModelId(payload.model);
|
|
1181
|
+
const upstreamPayload = upstreamModelId === payload.model ? payload : {
|
|
1182
|
+
...payload,
|
|
1183
|
+
model: upstreamModelId
|
|
1184
|
+
};
|
|
1185
|
+
const provider = getCopilotProviderContext(config);
|
|
1186
|
+
const enableVision = messagesIncludeImage(upstreamPayload.messages);
|
|
1187
|
+
const initiator = isAgentInitiator(upstreamPayload.messages);
|
|
1188
|
+
const requestPayload = buildRequestPayload(upstreamPayload);
|
|
1189
|
+
log.info([
|
|
1190
|
+
"Model request",
|
|
1191
|
+
`client=${client}`,
|
|
1192
|
+
`requested_model=${requestedModel}`,
|
|
1193
|
+
`upstream_model=${upstreamPayload.model}`,
|
|
1194
|
+
`requested_think_effort=${requestedThinkEffort}`,
|
|
1195
|
+
`requested_thinking=${requestedThinking}`,
|
|
1196
|
+
`effective_think_effort=${requestPayload.reasoning_effort ?? "none"}`
|
|
1197
|
+
].join(" "));
|
|
1198
|
+
log.trace("Full request payload", { payload: requestPayload });
|
|
1199
|
+
if (shouldUseResponsesApiForModel(upstreamPayload.model)) return createResponses(provider, upstreamPayload, {
|
|
1200
|
+
vision: enableVision,
|
|
1201
|
+
initiator
|
|
1202
|
+
});
|
|
1203
|
+
const response = await fetchCopilot(provider, "/chat/completions", {
|
|
1204
|
+
method: "POST",
|
|
1205
|
+
headers: {
|
|
1206
|
+
accept: upstreamPayload.stream ? "text/event-stream" : "application/json",
|
|
1207
|
+
"content-type": "application/json"
|
|
1208
|
+
},
|
|
1209
|
+
body: JSON.stringify(requestPayload)
|
|
1210
|
+
}, {
|
|
1211
|
+
vision: enableVision,
|
|
1212
|
+
initiator
|
|
1213
|
+
});
|
|
1214
|
+
if (!response.ok) {
|
|
1215
|
+
if (await shouldRetryWithResponses(response)) return createResponses(provider, upstreamPayload, {
|
|
1216
|
+
vision: enableVision,
|
|
1217
|
+
initiator
|
|
1218
|
+
});
|
|
1219
|
+
await logUpstreamError("Failed to create chat completions", response, {
|
|
1220
|
+
model: payload.model,
|
|
1221
|
+
request: requestPayload,
|
|
1222
|
+
route: "/chat/completions"
|
|
1223
|
+
});
|
|
1224
|
+
throw new HTTPError("Failed to create chat completions", response);
|
|
1225
|
+
}
|
|
1226
|
+
if (upstreamPayload.stream) return events(response);
|
|
1227
|
+
return await response.json();
|
|
1228
|
+
};
|
|
1229
|
+
async function createResponses(provider, payload, options) {
|
|
1230
|
+
const reasoningEffort = getRequestedReasoningEffort(payload);
|
|
1231
|
+
const response = await fetchCopilot(provider, "/responses", {
|
|
1232
|
+
method: "POST",
|
|
1233
|
+
headers: {
|
|
1234
|
+
accept: payload.stream ? "text/event-stream" : "application/json",
|
|
1235
|
+
"content-type": "application/json"
|
|
1236
|
+
},
|
|
1237
|
+
body: JSON.stringify(buildResponsesRequestPayload(payload, reasoningEffort))
|
|
1238
|
+
}, {
|
|
1239
|
+
vision: options.vision,
|
|
1240
|
+
initiator: options.initiator
|
|
1241
|
+
});
|
|
1242
|
+
if (!response.ok) {
|
|
1243
|
+
await logUpstreamError("Failed to create responses", response, {
|
|
1244
|
+
model: payload.model,
|
|
1245
|
+
route: "/responses"
|
|
1246
|
+
});
|
|
1247
|
+
throw new HTTPError("Failed to create responses", response);
|
|
1248
|
+
}
|
|
1249
|
+
if (payload.stream) return translateResponsesStreamToChatCompletionStream(events(response));
|
|
1250
|
+
return translateResponsesToChatCompletion(await response.json());
|
|
1251
|
+
}
|
|
1252
|
+
async function logUpstreamError(message, response, context) {
|
|
1253
|
+
const errorBody = await response.clone().text().catch(() => "");
|
|
1254
|
+
log.error(message, {
|
|
1255
|
+
route: context.route,
|
|
1256
|
+
model: context.model,
|
|
1257
|
+
status: response.status,
|
|
1258
|
+
statusText: response.statusText,
|
|
1259
|
+
body: errorBody || void 0,
|
|
1260
|
+
request: runtimeState.debug && context.request ? JSON.stringify(summarizeRequestForDiagnostics(context.request)) : void 0
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
async function shouldRetryWithResponses(response) {
|
|
1264
|
+
try {
|
|
1265
|
+
return (await response.clone().json()).error?.code === "unsupported_api_for_model";
|
|
1266
|
+
} catch {
|
|
1267
|
+
return false;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
//#endregion
|
|
1272
|
+
//#region src/lib/preflight.ts
|
|
1273
|
+
const getUpstreamModelIds = async (config) => {
|
|
1274
|
+
const response = await fetchCopilot(getCopilotProviderContext(config), "/models", {
|
|
1275
|
+
method: "GET",
|
|
1276
|
+
headers: { accept: "application/json" }
|
|
1277
|
+
});
|
|
1278
|
+
if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
|
|
1279
|
+
const payload = await response.json();
|
|
1280
|
+
return new Set((payload.data ?? []).map((model) => model.id).filter((id) => typeof id === "string" && id.length > 0));
|
|
1281
|
+
};
|
|
1282
|
+
const ensureRequiredModels = async (config) => {
|
|
1283
|
+
const upstreamModels = await getUpstreamModelIds(config);
|
|
1284
|
+
const requiredModels = getExposedModelIds();
|
|
1285
|
+
const missingModels = requiredModels.filter((model) => !upstreamModels.has(model));
|
|
1286
|
+
if (missingModels.length > 0) throw new Error(`Required Copilot model(s) unavailable upstream: ${missingModels.join(", ")}`);
|
|
1287
|
+
log.info(`Upstream models available: ${requiredModels.join(", ")}`);
|
|
1288
|
+
};
|
|
1289
|
+
const createProbePayload = (model) => ({
|
|
1290
|
+
model,
|
|
1291
|
+
max_tokens: 16,
|
|
1292
|
+
stream: false,
|
|
1293
|
+
messages: [{
|
|
1294
|
+
role: "user",
|
|
1295
|
+
content: "Reply with OK only."
|
|
1296
|
+
}]
|
|
1297
|
+
});
|
|
1298
|
+
const validateModelRequest = async (config, model, thinkEffort) => {
|
|
1299
|
+
try {
|
|
1300
|
+
const response = await createChatCompletions(config, createProbePayload(model), {
|
|
1301
|
+
client: "generic",
|
|
1302
|
+
requestedModel: model
|
|
1303
|
+
});
|
|
1304
|
+
if (typeof response !== "object" || response === null || !("choices" in response)) throw new Error(`Preflight request for ${model} unexpectedly streamed`);
|
|
1305
|
+
log.info(`Preflight OK: model=${model} think_effort=${thinkEffort}`);
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
if (error instanceof HTTPError) {
|
|
1308
|
+
const text = await error.response.text().catch(() => "");
|
|
1309
|
+
throw new Error(`Preflight failed for model=${model} think_effort=${thinkEffort}: ${error.response.status} ${error.response.statusText}${text ? ` ${text}` : ""}`);
|
|
1310
|
+
}
|
|
1311
|
+
throw error;
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
const validateUpstream = async (config, thinkEffort) => {
|
|
1315
|
+
log.info("Running upstream preflight");
|
|
1316
|
+
await ensureRequiredModels(config);
|
|
1317
|
+
for (const model of getExposedModelIds()) await validateModelRequest(config, model, thinkEffort);
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
//#endregion
|
|
1321
|
+
//#region src/lib/version.ts
|
|
1322
|
+
const packageName = "copilot-relay";
|
|
1323
|
+
const readPackageVersion = () => {
|
|
1324
|
+
let currentDir = dirname(fileURLToPath(import.meta.url));
|
|
1325
|
+
while (true) {
|
|
1326
|
+
try {
|
|
1327
|
+
const packageJsonPath = resolve(currentDir, "package.json");
|
|
1328
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
1329
|
+
if (packageJson.name === packageName && typeof packageJson.version === "string") return packageJson.version;
|
|
1330
|
+
} catch {}
|
|
1331
|
+
const parentDir = dirname(currentDir);
|
|
1332
|
+
if (parentDir === currentDir) return "unknown";
|
|
1333
|
+
currentDir = parentDir;
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
const appVersion = readPackageVersion();
|
|
1337
|
+
|
|
1338
|
+
//#endregion
|
|
1339
|
+
//#region src/claude/utils.ts
|
|
1340
|
+
function mapOpenAIStopReasonToClaude(finishReason) {
|
|
1341
|
+
if (finishReason === null) return null;
|
|
1342
|
+
return {
|
|
1343
|
+
stop: "end_turn",
|
|
1344
|
+
length: "max_tokens",
|
|
1345
|
+
tool_calls: "tool_use",
|
|
1346
|
+
content_filter: "end_turn"
|
|
1347
|
+
}[finishReason];
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
//#endregion
|
|
1351
|
+
//#region src/claude/tool-names.ts
|
|
1352
|
+
const strictToolNameCharsPattern = /^[A-Za-z0-9_-]+$/;
|
|
1353
|
+
const dottedToolNameCharsPattern = /^[A-Za-z0-9_.-]+$/;
|
|
1354
|
+
const defaultToolNameMaxLength = 64;
|
|
1355
|
+
const extendedToolNameMaxLength = 128;
|
|
1356
|
+
const hashLength = 10;
|
|
1357
|
+
const getToolNameMapperOptionsForModel = (modelId) => {
|
|
1358
|
+
if (modelId.trim().toLowerCase().replace(/\[1m\]$/, "-1m").replace(/[._]/g, "-").startsWith("gpt-")) return {
|
|
1359
|
+
allowDots: false,
|
|
1360
|
+
maxNameLength: extendedToolNameMaxLength
|
|
1361
|
+
};
|
|
1362
|
+
return {
|
|
1363
|
+
allowDots: false,
|
|
1364
|
+
maxNameLength: defaultToolNameMaxLength
|
|
1365
|
+
};
|
|
1366
|
+
};
|
|
1367
|
+
const makeHash = (value) => createHash("sha1").update(value).digest("hex").slice(0, hashLength);
|
|
1368
|
+
const getAllowedNamePattern = (allowDots) => allowDots ? dottedToolNameCharsPattern : strictToolNameCharsPattern;
|
|
1369
|
+
const cleanToolName = (name, allowDots) => {
|
|
1370
|
+
const invalidCharsPattern = allowDots ? /[^A-Za-z0-9_.-]/g : /[^A-Za-z0-9_-]/g;
|
|
1371
|
+
return name.replace(invalidCharsPattern, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "") || "tool";
|
|
1372
|
+
};
|
|
1373
|
+
const isValidToolName = (name, maxNameLength, allowDots) => name.length > 0 && name.length <= maxNameLength && getAllowedNamePattern(allowDots).test(name);
|
|
1374
|
+
const makeValidToolName = (name, maxNameLength, allowDots) => {
|
|
1375
|
+
if (isValidToolName(name, maxNameLength, allowDots)) return name;
|
|
1376
|
+
const cleaned = cleanToolName(name, allowDots);
|
|
1377
|
+
if (cleaned.length <= maxNameLength) return cleaned;
|
|
1378
|
+
const hash = makeHash(name);
|
|
1379
|
+
const prefixLength = maxNameLength - hash.length - 1;
|
|
1380
|
+
return `${cleaned.slice(0, prefixLength)}_${hash}`;
|
|
1381
|
+
};
|
|
1382
|
+
const makeUniqueToolName = (name, used, maxNameLength, allowDots) => {
|
|
1383
|
+
const candidate = makeValidToolName(name, maxNameLength, allowDots);
|
|
1384
|
+
if (!used.has(candidate)) return candidate;
|
|
1385
|
+
for (let index = 2;; index++) {
|
|
1386
|
+
const suffix = `_${makeHash(`${name}:${index}`)}`;
|
|
1387
|
+
const prefixLength = maxNameLength - suffix.length;
|
|
1388
|
+
const next = `${cleanToolName(name, allowDots).slice(0, prefixLength)}${suffix}`;
|
|
1389
|
+
if (!used.has(next)) return next;
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
const createClaudeToolNameMapper = (tools, options = {}) => {
|
|
1393
|
+
const maxNameLength = options.maxNameLength ?? defaultToolNameMaxLength;
|
|
1394
|
+
const allowDots = options.allowDots ?? false;
|
|
1395
|
+
const claudeToOpenAI = /* @__PURE__ */ new Map();
|
|
1396
|
+
const openAIToClaude = /* @__PURE__ */ new Map();
|
|
1397
|
+
const used = /* @__PURE__ */ new Set();
|
|
1398
|
+
for (const tool of tools ?? []) {
|
|
1399
|
+
if (claudeToOpenAI.has(tool.name)) continue;
|
|
1400
|
+
const openAIName = makeUniqueToolName(tool.name, used, maxNameLength, allowDots);
|
|
1401
|
+
used.add(openAIName);
|
|
1402
|
+
claudeToOpenAI.set(tool.name, openAIName);
|
|
1403
|
+
openAIToClaude.set(openAIName, tool.name);
|
|
1404
|
+
}
|
|
1405
|
+
return {
|
|
1406
|
+
toClaude: (name) => openAIToClaude.get(name) ?? name,
|
|
1407
|
+
toOpenAI: (name) => claudeToOpenAI.get(name) ?? makeValidToolName(name, maxNameLength, allowDots)
|
|
1408
|
+
};
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
//#endregion
|
|
1412
|
+
//#region src/claude/translate.ts
|
|
1413
|
+
function translateModelName(model) {
|
|
1414
|
+
return routeModelId(model);
|
|
1415
|
+
}
|
|
1416
|
+
function translateToOpenAI(payload, _settings, toolNameMapper) {
|
|
1417
|
+
const model = translateModelName(payload.model);
|
|
1418
|
+
const mapper = toolNameMapper ?? createClaudeToolNameMapper(payload.tools, { ...getToolNameMapperOptionsForModel(model) });
|
|
1419
|
+
const tools = translateClaudeToolsToOpenAI(payload.tools, mapper);
|
|
1420
|
+
return {
|
|
1421
|
+
model,
|
|
1422
|
+
messages: translateClaudeMessagesToOpenAI(payload.messages, payload.system, mapper),
|
|
1423
|
+
max_tokens: payload.max_tokens,
|
|
1424
|
+
stop: payload.stop_sequences,
|
|
1425
|
+
stream: payload.stream,
|
|
1426
|
+
temperature: payload.temperature,
|
|
1427
|
+
top_p: payload.top_p,
|
|
1428
|
+
reasoning_effort: runtimeState.thinkEffort ?? defaultReasoningEffort,
|
|
1429
|
+
user: payload.metadata?.user_id,
|
|
1430
|
+
tools,
|
|
1431
|
+
tool_choice: tools && tools.length > 0 ? translateClaudeToolChoiceToOpenAI(payload.tool_choice, mapper) : void 0
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
function translateClaudeMessagesToOpenAI(claudeMessages, system, toolNameMapper) {
|
|
1435
|
+
const systemMessages = handleSystemPrompt(system);
|
|
1436
|
+
const otherMessages = claudeMessages.flatMap((message) => message.role === "user" ? handleUserMessage(message) : handleAssistantMessage(message, toolNameMapper));
|
|
1437
|
+
return [...systemMessages, ...otherMessages];
|
|
1438
|
+
}
|
|
1439
|
+
function handleSystemPrompt(system) {
|
|
1440
|
+
if (!system) return [];
|
|
1441
|
+
if (typeof system === "string") return [{
|
|
1442
|
+
role: "system",
|
|
1443
|
+
content: system
|
|
1444
|
+
}];
|
|
1445
|
+
return [{
|
|
1446
|
+
role: "system",
|
|
1447
|
+
content: system.map((block) => block.text).join("\n\n")
|
|
1448
|
+
}];
|
|
1449
|
+
}
|
|
1450
|
+
function handleUserMessage(message) {
|
|
1451
|
+
const newMessages = [];
|
|
1452
|
+
if (Array.isArray(message.content)) {
|
|
1453
|
+
const toolResultBlocks = message.content.filter((block) => block.type === "tool_result");
|
|
1454
|
+
const otherBlocks = message.content.filter((block) => block.type !== "tool_result");
|
|
1455
|
+
for (const block of toolResultBlocks) newMessages.push({
|
|
1456
|
+
role: "tool",
|
|
1457
|
+
tool_call_id: block.tool_use_id,
|
|
1458
|
+
content: mapContent(block.content)
|
|
1459
|
+
});
|
|
1460
|
+
if (otherBlocks.length > 0) newMessages.push({
|
|
1461
|
+
role: "user",
|
|
1462
|
+
content: mapContent(otherBlocks)
|
|
1463
|
+
});
|
|
1464
|
+
} else newMessages.push({
|
|
1465
|
+
role: "user",
|
|
1466
|
+
content: mapContent(message.content)
|
|
1467
|
+
});
|
|
1468
|
+
return newMessages;
|
|
1469
|
+
}
|
|
1470
|
+
function handleAssistantMessage(message, toolNameMapper) {
|
|
1471
|
+
if (!Array.isArray(message.content)) return [{
|
|
1472
|
+
role: "assistant",
|
|
1473
|
+
content: mapContent(message.content)
|
|
1474
|
+
}];
|
|
1475
|
+
const toolUseBlocks = message.content.filter((block) => block.type === "tool_use");
|
|
1476
|
+
const textBlocks = message.content.filter((block) => block.type === "text");
|
|
1477
|
+
const thinkingBlocks = message.content.filter((block) => block.type === "thinking");
|
|
1478
|
+
const allTextContent = [...textBlocks.map((b) => b.text), ...thinkingBlocks.map((b) => b.thinking)].join("\n\n");
|
|
1479
|
+
return toolUseBlocks.length > 0 ? [{
|
|
1480
|
+
role: "assistant",
|
|
1481
|
+
content: allTextContent || null,
|
|
1482
|
+
tool_calls: toolUseBlocks.map((toolUse) => ({
|
|
1483
|
+
id: toolUse.id,
|
|
1484
|
+
type: "function",
|
|
1485
|
+
function: {
|
|
1486
|
+
name: toolNameMapper.toOpenAI(toolUse.name),
|
|
1487
|
+
arguments: JSON.stringify(toolUse.input)
|
|
1488
|
+
}
|
|
1489
|
+
}))
|
|
1490
|
+
}] : [{
|
|
1491
|
+
role: "assistant",
|
|
1492
|
+
content: mapContent(message.content)
|
|
1493
|
+
}];
|
|
1494
|
+
}
|
|
1495
|
+
function mapContent(content) {
|
|
1496
|
+
if (typeof content === "string") return content;
|
|
1497
|
+
if (!Array.isArray(content)) return null;
|
|
1498
|
+
if (!content.some((block) => block.type === "image")) return content.filter((block) => block.type === "text" || block.type === "thinking").map((block) => block.type === "text" ? block.text : block.thinking).join("\n\n");
|
|
1499
|
+
const contentParts = [];
|
|
1500
|
+
for (const block of content) switch (block.type) {
|
|
1501
|
+
case "text":
|
|
1502
|
+
contentParts.push({
|
|
1503
|
+
type: "text",
|
|
1504
|
+
text: block.text
|
|
1505
|
+
});
|
|
1506
|
+
break;
|
|
1507
|
+
case "thinking":
|
|
1508
|
+
contentParts.push({
|
|
1509
|
+
type: "text",
|
|
1510
|
+
text: block.thinking
|
|
1511
|
+
});
|
|
1512
|
+
break;
|
|
1513
|
+
case "image":
|
|
1514
|
+
contentParts.push({
|
|
1515
|
+
type: "image_url",
|
|
1516
|
+
image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` }
|
|
1517
|
+
});
|
|
1518
|
+
break;
|
|
1519
|
+
}
|
|
1520
|
+
return contentParts;
|
|
1521
|
+
}
|
|
1522
|
+
function translateClaudeToolsToOpenAI(claudeTools, toolNameMapper) {
|
|
1523
|
+
if (!claudeTools || claudeTools.length === 0) return;
|
|
1524
|
+
const tools = claudeTools.flatMap((tool) => {
|
|
1525
|
+
if (!tool.input_schema) return [];
|
|
1526
|
+
return [{
|
|
1527
|
+
type: "function",
|
|
1528
|
+
function: {
|
|
1529
|
+
name: toolNameMapper.toOpenAI(tool.name),
|
|
1530
|
+
description: tool.description,
|
|
1531
|
+
parameters: tool.input_schema
|
|
1532
|
+
}
|
|
1533
|
+
}];
|
|
1534
|
+
});
|
|
1535
|
+
return tools.length > 0 ? tools : void 0;
|
|
1536
|
+
}
|
|
1537
|
+
function translateClaudeToolChoiceToOpenAI(claudeToolChoice, toolNameMapper) {
|
|
1538
|
+
if (!claudeToolChoice) return;
|
|
1539
|
+
switch (claudeToolChoice.type) {
|
|
1540
|
+
case "auto": return "auto";
|
|
1541
|
+
case "any": return "required";
|
|
1542
|
+
case "tool":
|
|
1543
|
+
if (claudeToolChoice.name) return {
|
|
1544
|
+
type: "function",
|
|
1545
|
+
function: { name: toolNameMapper.toOpenAI(claudeToolChoice.name) }
|
|
1546
|
+
};
|
|
1547
|
+
return;
|
|
1548
|
+
case "none": return "none";
|
|
1549
|
+
default: return;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
function translateToClaude(response, toolNameMapper = createClaudeToolNameMapper(void 0)) {
|
|
1553
|
+
const allThinkingBlocks = [];
|
|
1554
|
+
const allTextBlocks = [];
|
|
1555
|
+
const allToolUseBlocks = [];
|
|
1556
|
+
let stopReason = null;
|
|
1557
|
+
stopReason = response.choices[0]?.finish_reason ?? stopReason;
|
|
1558
|
+
for (const choice of response.choices) {
|
|
1559
|
+
allThinkingBlocks.push(...getClaudeThinkingBlocks(choice.message.reasoning_text ?? choice.message.reasoning_content));
|
|
1560
|
+
allTextBlocks.push(...getClaudeTextBlocks(choice.message.content));
|
|
1561
|
+
allToolUseBlocks.push(...getClaudeToolUseBlocks(choice.message.tool_calls, toolNameMapper));
|
|
1562
|
+
if (choice.finish_reason === "tool_calls" || stopReason === "stop") stopReason = choice.finish_reason;
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
id: response.id,
|
|
1566
|
+
type: "message",
|
|
1567
|
+
role: "assistant",
|
|
1568
|
+
model: response.model,
|
|
1569
|
+
content: [
|
|
1570
|
+
...allThinkingBlocks,
|
|
1571
|
+
...allTextBlocks,
|
|
1572
|
+
...allToolUseBlocks
|
|
1573
|
+
],
|
|
1574
|
+
stop_reason: mapOpenAIStopReasonToClaude(stopReason),
|
|
1575
|
+
stop_sequence: null,
|
|
1576
|
+
usage: {
|
|
1577
|
+
input_tokens: (response.usage?.prompt_tokens ?? 0) - (response.usage?.prompt_tokens_details?.cached_tokens ?? 0),
|
|
1578
|
+
output_tokens: response.usage?.completion_tokens ?? 0,
|
|
1579
|
+
...response.usage?.prompt_tokens_details?.cached_tokens !== void 0 && { cache_read_input_tokens: response.usage.prompt_tokens_details.cached_tokens }
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
function getClaudeThinkingBlocks(reasoningContent) {
|
|
1584
|
+
if (!reasoningContent) return [];
|
|
1585
|
+
return [{
|
|
1586
|
+
type: "thinking",
|
|
1587
|
+
thinking: reasoningContent
|
|
1588
|
+
}];
|
|
1589
|
+
}
|
|
1590
|
+
function getClaudeTextBlocks(messageContent) {
|
|
1591
|
+
if (typeof messageContent === "string") return [{
|
|
1592
|
+
type: "text",
|
|
1593
|
+
text: messageContent
|
|
1594
|
+
}];
|
|
1595
|
+
if (Array.isArray(messageContent)) return messageContent.filter((part) => part.type === "text").map((part) => ({
|
|
1596
|
+
type: "text",
|
|
1597
|
+
text: part.text
|
|
1598
|
+
}));
|
|
1599
|
+
return [];
|
|
1600
|
+
}
|
|
1601
|
+
function getClaudeToolUseBlocks(toolCalls, toolNameMapper) {
|
|
1602
|
+
if (!toolCalls) return [];
|
|
1603
|
+
return toolCalls.map((toolCall) => ({
|
|
1604
|
+
type: "tool_use",
|
|
1605
|
+
id: toolCall.id,
|
|
1606
|
+
name: toolNameMapper.toClaude(toolCall.function.name),
|
|
1607
|
+
input: safeJsonParse(toolCall.function.arguments)
|
|
1608
|
+
}));
|
|
1609
|
+
}
|
|
1610
|
+
function safeJsonParse(input) {
|
|
1611
|
+
try {
|
|
1612
|
+
return JSON.parse(input);
|
|
1613
|
+
} catch {
|
|
1614
|
+
return { raw: input };
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
//#endregion
|
|
1619
|
+
//#region src/claude/stream.ts
|
|
1620
|
+
function isToolBlockOpen(state) {
|
|
1621
|
+
if (!state.contentBlockOpen) return false;
|
|
1622
|
+
return Object.values(state.toolCalls).some((tc) => tc.claudeBlockIndex === state.contentBlockIndex);
|
|
1623
|
+
}
|
|
1624
|
+
const closeOpenContentBlock = (events$1, state) => {
|
|
1625
|
+
if (!state.contentBlockOpen) return;
|
|
1626
|
+
events$1.push({
|
|
1627
|
+
type: "content_block_stop",
|
|
1628
|
+
index: state.contentBlockIndex
|
|
1629
|
+
});
|
|
1630
|
+
state.contentBlockIndex++;
|
|
1631
|
+
state.contentBlockOpen = false;
|
|
1632
|
+
state.thinkingBlockOpen = false;
|
|
1633
|
+
};
|
|
1634
|
+
function translateChunkToClaudeEvents(chunk, state, toolNameMapper = createClaudeToolNameMapper(void 0)) {
|
|
1635
|
+
const events$1 = [];
|
|
1636
|
+
if (chunk.choices.length === 0) return events$1;
|
|
1637
|
+
const choice = chunk.choices[0];
|
|
1638
|
+
const { delta } = choice;
|
|
1639
|
+
const reasoningContent = delta.reasoning_text ?? delta.reasoning_content;
|
|
1640
|
+
if (!state.messageStartSent) {
|
|
1641
|
+
events$1.push({
|
|
1642
|
+
type: "message_start",
|
|
1643
|
+
message: {
|
|
1644
|
+
id: chunk.id,
|
|
1645
|
+
type: "message",
|
|
1646
|
+
role: "assistant",
|
|
1647
|
+
content: [],
|
|
1648
|
+
model: chunk.model,
|
|
1649
|
+
stop_reason: null,
|
|
1650
|
+
stop_sequence: null,
|
|
1651
|
+
usage: {
|
|
1652
|
+
input_tokens: (chunk.usage?.prompt_tokens ?? 0) - (chunk.usage?.prompt_tokens_details?.cached_tokens ?? 0),
|
|
1653
|
+
output_tokens: 0,
|
|
1654
|
+
...chunk.usage?.prompt_tokens_details?.cached_tokens !== void 0 && { cache_read_input_tokens: chunk.usage.prompt_tokens_details.cached_tokens }
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
state.messageStartSent = true;
|
|
1659
|
+
}
|
|
1660
|
+
if (reasoningContent) {
|
|
1661
|
+
if (state.contentBlockOpen && !state.thinkingBlockOpen) closeOpenContentBlock(events$1, state);
|
|
1662
|
+
if (!state.contentBlockOpen) {
|
|
1663
|
+
events$1.push({
|
|
1664
|
+
type: "content_block_start",
|
|
1665
|
+
index: state.contentBlockIndex,
|
|
1666
|
+
content_block: {
|
|
1667
|
+
type: "thinking",
|
|
1668
|
+
thinking: ""
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
state.contentBlockOpen = true;
|
|
1672
|
+
state.thinkingBlockOpen = true;
|
|
1673
|
+
}
|
|
1674
|
+
events$1.push({
|
|
1675
|
+
type: "content_block_delta",
|
|
1676
|
+
index: state.contentBlockIndex,
|
|
1677
|
+
delta: {
|
|
1678
|
+
type: "thinking_delta",
|
|
1679
|
+
thinking: reasoningContent
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
if (delta.content) {
|
|
1684
|
+
if (state.thinkingBlockOpen || isToolBlockOpen(state)) closeOpenContentBlock(events$1, state);
|
|
1685
|
+
if (!state.contentBlockOpen) {
|
|
1686
|
+
events$1.push({
|
|
1687
|
+
type: "content_block_start",
|
|
1688
|
+
index: state.contentBlockIndex,
|
|
1689
|
+
content_block: {
|
|
1690
|
+
type: "text",
|
|
1691
|
+
text: ""
|
|
1692
|
+
}
|
|
1693
|
+
});
|
|
1694
|
+
state.contentBlockOpen = true;
|
|
1695
|
+
}
|
|
1696
|
+
events$1.push({
|
|
1697
|
+
type: "content_block_delta",
|
|
1698
|
+
index: state.contentBlockIndex,
|
|
1699
|
+
delta: {
|
|
1700
|
+
type: "text_delta",
|
|
1701
|
+
text: delta.content
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
if (delta.tool_calls) for (const toolCall of delta.tool_calls) {
|
|
1706
|
+
if (toolCall.id && toolCall.function?.name) {
|
|
1707
|
+
if (state.contentBlockOpen) closeOpenContentBlock(events$1, state);
|
|
1708
|
+
const toolName = toolNameMapper.toClaude(toolCall.function.name);
|
|
1709
|
+
const claudeBlockIndex = state.contentBlockIndex;
|
|
1710
|
+
state.toolCalls[toolCall.index] = {
|
|
1711
|
+
id: toolCall.id,
|
|
1712
|
+
name: toolName,
|
|
1713
|
+
claudeBlockIndex
|
|
1714
|
+
};
|
|
1715
|
+
events$1.push({
|
|
1716
|
+
type: "content_block_start",
|
|
1717
|
+
index: claudeBlockIndex,
|
|
1718
|
+
content_block: {
|
|
1719
|
+
type: "tool_use",
|
|
1720
|
+
id: toolCall.id,
|
|
1721
|
+
name: toolName,
|
|
1722
|
+
input: {}
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
state.contentBlockOpen = true;
|
|
1726
|
+
}
|
|
1727
|
+
if (toolCall.function?.arguments) {
|
|
1728
|
+
const toolCallInfo = state.toolCalls[toolCall.index];
|
|
1729
|
+
if (toolCallInfo) events$1.push({
|
|
1730
|
+
type: "content_block_delta",
|
|
1731
|
+
index: toolCallInfo.claudeBlockIndex,
|
|
1732
|
+
delta: {
|
|
1733
|
+
type: "input_json_delta",
|
|
1734
|
+
partial_json: toolCall.function.arguments
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
if (choice.finish_reason) {
|
|
1740
|
+
if (state.contentBlockOpen) closeOpenContentBlock(events$1, state);
|
|
1741
|
+
events$1.push({
|
|
1742
|
+
type: "message_delta",
|
|
1743
|
+
delta: {
|
|
1744
|
+
stop_reason: mapOpenAIStopReasonToClaude(choice.finish_reason),
|
|
1745
|
+
stop_sequence: null
|
|
1746
|
+
},
|
|
1747
|
+
usage: {
|
|
1748
|
+
input_tokens: (chunk.usage?.prompt_tokens ?? 0) - (chunk.usage?.prompt_tokens_details?.cached_tokens ?? 0),
|
|
1749
|
+
output_tokens: chunk.usage?.completion_tokens ?? 0,
|
|
1750
|
+
...chunk.usage?.prompt_tokens_details?.cached_tokens !== void 0 && { cache_read_input_tokens: chunk.usage.prompt_tokens_details.cached_tokens }
|
|
1751
|
+
}
|
|
1752
|
+
}, { type: "message_stop" });
|
|
1753
|
+
}
|
|
1754
|
+
return events$1;
|
|
1755
|
+
}
|
|
1756
|
+
function translateErrorToClaudeErrorEvent() {
|
|
1757
|
+
return {
|
|
1758
|
+
type: "error",
|
|
1759
|
+
error: {
|
|
1760
|
+
type: "api_error",
|
|
1761
|
+
message: "An unexpected error occurred during streaming."
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
//#endregion
|
|
1767
|
+
//#region src/lib/tokenizer.ts
|
|
1768
|
+
const encodingMap = {
|
|
1769
|
+
o200k_base: () => import("gpt-tokenizer/encoding/o200k_base"),
|
|
1770
|
+
cl100k_base: () => import("gpt-tokenizer/encoding/cl100k_base"),
|
|
1771
|
+
p50k_base: () => import("gpt-tokenizer/encoding/p50k_base"),
|
|
1772
|
+
p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
|
|
1773
|
+
r50k_base: () => import("gpt-tokenizer/encoding/r50k_base")
|
|
1774
|
+
};
|
|
1775
|
+
const encodingCache = /* @__PURE__ */ new Map();
|
|
1776
|
+
const calculateToolCallsTokens = (toolCalls, encoder, constants) => {
|
|
1777
|
+
let tokens = 0;
|
|
1778
|
+
for (const toolCall of toolCalls) {
|
|
1779
|
+
tokens += constants.funcInit;
|
|
1780
|
+
tokens += encoder.encode(JSON.stringify(toolCall)).length;
|
|
1781
|
+
}
|
|
1782
|
+
tokens += constants.funcEnd;
|
|
1783
|
+
return tokens;
|
|
1784
|
+
};
|
|
1785
|
+
const calculateContentPartsTokens = (contentParts, encoder) => {
|
|
1786
|
+
let tokens = 0;
|
|
1787
|
+
for (const part of contentParts) if (part.type === "image_url") tokens += encoder.encode(part.image_url.url).length + 85;
|
|
1788
|
+
else if (part.text) tokens += encoder.encode(part.text).length;
|
|
1789
|
+
return tokens;
|
|
1790
|
+
};
|
|
1791
|
+
const calculateMessageTokens = (message, encoder, constants) => {
|
|
1792
|
+
const tokensPerMessage = 3;
|
|
1793
|
+
const tokensPerName = 1;
|
|
1794
|
+
let tokens = tokensPerMessage;
|
|
1795
|
+
for (const [key, value] of Object.entries(message)) {
|
|
1796
|
+
if (typeof value === "string") tokens += encoder.encode(value).length;
|
|
1797
|
+
if (key === "name") tokens += tokensPerName;
|
|
1798
|
+
if (key === "tool_calls") tokens += calculateToolCallsTokens(value, encoder, constants);
|
|
1799
|
+
if (key === "content" && Array.isArray(value)) tokens += calculateContentPartsTokens(value, encoder);
|
|
1800
|
+
}
|
|
1801
|
+
return tokens;
|
|
1802
|
+
};
|
|
1803
|
+
const calculateTokens = (messages, encoder, constants) => {
|
|
1804
|
+
if (messages.length === 0) return 0;
|
|
1805
|
+
let numTokens = 0;
|
|
1806
|
+
for (const message of messages) numTokens += calculateMessageTokens(message, encoder, constants);
|
|
1807
|
+
numTokens += 3;
|
|
1808
|
+
return numTokens;
|
|
1809
|
+
};
|
|
1810
|
+
const getEncodeChatFunction = async (encoding) => {
|
|
1811
|
+
if (encodingCache.has(encoding)) {
|
|
1812
|
+
const cached = encodingCache.get(encoding);
|
|
1813
|
+
if (cached) return cached;
|
|
1814
|
+
}
|
|
1815
|
+
const supported = encoding;
|
|
1816
|
+
if (!(supported in encodingMap)) {
|
|
1817
|
+
const fallback = await encodingMap.o200k_base();
|
|
1818
|
+
encodingCache.set(encoding, fallback);
|
|
1819
|
+
return fallback;
|
|
1820
|
+
}
|
|
1821
|
+
const mod = await encodingMap[supported]();
|
|
1822
|
+
encodingCache.set(encoding, mod);
|
|
1823
|
+
return mod;
|
|
1824
|
+
};
|
|
1825
|
+
const getTokenizerFromModel = (model) => model.capabilities.tokenizer || "o200k_base";
|
|
1826
|
+
const getModelConstants = (model) => model.id === "gpt-3.5-turbo" || model.id === "gpt-4" ? {
|
|
1827
|
+
funcInit: 10,
|
|
1828
|
+
propInit: 3,
|
|
1829
|
+
propKey: 3,
|
|
1830
|
+
enumInit: -3,
|
|
1831
|
+
enumItem: 3,
|
|
1832
|
+
funcEnd: 12
|
|
1833
|
+
} : {
|
|
1834
|
+
funcInit: 7,
|
|
1835
|
+
propInit: 3,
|
|
1836
|
+
propKey: 3,
|
|
1837
|
+
enumInit: -3,
|
|
1838
|
+
enumItem: 3,
|
|
1839
|
+
funcEnd: 12
|
|
1840
|
+
};
|
|
1841
|
+
const calculateParameterTokens = (key, prop, context) => {
|
|
1842
|
+
const { encoder, constants } = context;
|
|
1843
|
+
let tokens = constants.propKey;
|
|
1844
|
+
if (typeof prop !== "object" || prop === null) return tokens;
|
|
1845
|
+
const param = prop;
|
|
1846
|
+
const paramName = key;
|
|
1847
|
+
const paramType = param.type || "string";
|
|
1848
|
+
let paramDesc = param.description || "";
|
|
1849
|
+
if (param.enum && Array.isArray(param.enum)) {
|
|
1850
|
+
tokens += constants.enumInit;
|
|
1851
|
+
for (const item of param.enum) {
|
|
1852
|
+
tokens += constants.enumItem;
|
|
1853
|
+
tokens += encoder.encode(String(item)).length;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
if (paramDesc.endsWith(".")) paramDesc = paramDesc.slice(0, -1);
|
|
1857
|
+
const line = `${paramName}:${paramType}:${paramDesc}`;
|
|
1858
|
+
tokens += encoder.encode(line).length;
|
|
1859
|
+
const excludedKeys = new Set([
|
|
1860
|
+
"type",
|
|
1861
|
+
"description",
|
|
1862
|
+
"enum"
|
|
1863
|
+
]);
|
|
1864
|
+
for (const propertyName of Object.keys(param)) {
|
|
1865
|
+
if (excludedKeys.has(propertyName)) continue;
|
|
1866
|
+
const propertyValue = param[propertyName];
|
|
1867
|
+
const propertyText = typeof propertyValue === "string" ? propertyValue : JSON.stringify(propertyValue);
|
|
1868
|
+
tokens += encoder.encode(`${propertyName}:${propertyText}`).length;
|
|
1869
|
+
}
|
|
1870
|
+
return tokens;
|
|
1871
|
+
};
|
|
1872
|
+
const calculateParametersTokens = (parameters, encoder, constants) => {
|
|
1873
|
+
if (!parameters || typeof parameters !== "object") return 0;
|
|
1874
|
+
const params = parameters;
|
|
1875
|
+
let tokens = 0;
|
|
1876
|
+
for (const [key, value] of Object.entries(params)) if (key === "properties") {
|
|
1877
|
+
const properties = value;
|
|
1878
|
+
if (Object.keys(properties).length > 0) {
|
|
1879
|
+
tokens += constants.propInit;
|
|
1880
|
+
for (const propKey of Object.keys(properties)) tokens += calculateParameterTokens(propKey, properties[propKey], {
|
|
1881
|
+
encoder,
|
|
1882
|
+
constants
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
} else {
|
|
1886
|
+
const paramText = typeof value === "string" ? value : JSON.stringify(value);
|
|
1887
|
+
tokens += encoder.encode(`${key}:${paramText}`).length;
|
|
1888
|
+
}
|
|
1889
|
+
return tokens;
|
|
1890
|
+
};
|
|
1891
|
+
const calculateToolTokens = (tool, encoder, constants) => {
|
|
1892
|
+
let tokens = constants.funcInit;
|
|
1893
|
+
const func = tool.function;
|
|
1894
|
+
const fName = func.name;
|
|
1895
|
+
let fDesc = func.description || "";
|
|
1896
|
+
if (fDesc.endsWith(".")) fDesc = fDesc.slice(0, -1);
|
|
1897
|
+
tokens += encoder.encode(`${fName}:${fDesc}`).length;
|
|
1898
|
+
if (typeof func.parameters === "object" && func.parameters !== null) tokens += calculateParametersTokens(func.parameters, encoder, constants);
|
|
1899
|
+
return tokens;
|
|
1900
|
+
};
|
|
1901
|
+
const numTokensForTools = (tools, encoder, constants) => {
|
|
1902
|
+
let funcTokenCount = 0;
|
|
1903
|
+
for (const tool of tools) funcTokenCount += calculateToolTokens(tool, encoder, constants);
|
|
1904
|
+
funcTokenCount += constants.funcEnd;
|
|
1905
|
+
return funcTokenCount;
|
|
1906
|
+
};
|
|
1907
|
+
const getTokenCount = async (payload, model) => {
|
|
1908
|
+
const encoder = await getEncodeChatFunction(getTokenizerFromModel(model));
|
|
1909
|
+
const inputMessages = payload.messages.filter((msg) => msg.role !== "assistant");
|
|
1910
|
+
const outputMessages = payload.messages.filter((msg) => msg.role === "assistant");
|
|
1911
|
+
const constants = getModelConstants(model);
|
|
1912
|
+
let inputTokens = calculateTokens(inputMessages, encoder, constants);
|
|
1913
|
+
if (payload.tools && payload.tools.length > 0) inputTokens += numTokensForTools(payload.tools, encoder, constants);
|
|
1914
|
+
const outputTokens = calculateTokens(outputMessages, encoder, constants);
|
|
1915
|
+
return {
|
|
1916
|
+
input: inputTokens,
|
|
1917
|
+
output: outputTokens
|
|
1918
|
+
};
|
|
1919
|
+
};
|
|
1920
|
+
|
|
1921
|
+
//#endregion
|
|
1922
|
+
//#region src/routes/claude.ts
|
|
1923
|
+
const claudeRoutes = new Hono();
|
|
1924
|
+
const createTokenCountModel = (modelId) => ({
|
|
1925
|
+
capabilities: { tokenizer: "o200k_base" },
|
|
1926
|
+
id: modelId
|
|
1927
|
+
});
|
|
1928
|
+
const isNonStreamingResponse = (response) => typeof response === "object" && response !== null && Object.hasOwn(response, "choices");
|
|
1929
|
+
const getClaudeRequestedThinkEffort = (payload) => payload.reasoning_effort ?? "none";
|
|
1930
|
+
const getClaudeRequestedThinking = (payload) => {
|
|
1931
|
+
if (!payload.thinking) return "none";
|
|
1932
|
+
return [`type:${payload.thinking.type}`, `budget:${payload.thinking.budget_tokens ?? "none"}`].join(",");
|
|
1933
|
+
};
|
|
1934
|
+
const eventsFromClaudeResponse = (response) => {
|
|
1935
|
+
const events$1 = [{
|
|
1936
|
+
type: "message_start",
|
|
1937
|
+
message: {
|
|
1938
|
+
id: response.id,
|
|
1939
|
+
type: response.type,
|
|
1940
|
+
role: response.role,
|
|
1941
|
+
content: [],
|
|
1942
|
+
model: response.model,
|
|
1943
|
+
stop_reason: null,
|
|
1944
|
+
stop_sequence: null,
|
|
1945
|
+
usage: response.usage
|
|
1946
|
+
}
|
|
1947
|
+
}];
|
|
1948
|
+
response.content.forEach((block, index) => {
|
|
1949
|
+
events$1.push({
|
|
1950
|
+
type: "content_block_start",
|
|
1951
|
+
index,
|
|
1952
|
+
content_block: block
|
|
1953
|
+
});
|
|
1954
|
+
if (block.type === "text") events$1.push({
|
|
1955
|
+
type: "content_block_delta",
|
|
1956
|
+
index,
|
|
1957
|
+
delta: {
|
|
1958
|
+
type: "text_delta",
|
|
1959
|
+
text: block.text
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
else if (block.type === "thinking") events$1.push({
|
|
1963
|
+
type: "content_block_delta",
|
|
1964
|
+
index,
|
|
1965
|
+
delta: {
|
|
1966
|
+
type: "thinking_delta",
|
|
1967
|
+
thinking: block.thinking
|
|
1968
|
+
}
|
|
1969
|
+
});
|
|
1970
|
+
else if (block.type === "tool_use") events$1.push({
|
|
1971
|
+
type: "content_block_delta",
|
|
1972
|
+
index,
|
|
1973
|
+
delta: {
|
|
1974
|
+
type: "input_json_delta",
|
|
1975
|
+
partial_json: JSON.stringify(block.input)
|
|
1976
|
+
}
|
|
1977
|
+
});
|
|
1978
|
+
events$1.push({
|
|
1979
|
+
type: "content_block_stop",
|
|
1980
|
+
index
|
|
1981
|
+
});
|
|
1982
|
+
});
|
|
1983
|
+
events$1.push({
|
|
1984
|
+
type: "message_delta",
|
|
1985
|
+
delta: {
|
|
1986
|
+
stop_reason: response.stop_reason,
|
|
1987
|
+
stop_sequence: response.stop_sequence
|
|
1988
|
+
},
|
|
1989
|
+
usage: { output_tokens: response.usage.output_tokens }
|
|
1990
|
+
});
|
|
1991
|
+
events$1.push({ type: "message_stop" });
|
|
1992
|
+
return events$1;
|
|
1993
|
+
};
|
|
1994
|
+
claudeRoutes.get("/models", (c) => c.json({
|
|
1995
|
+
object: "list",
|
|
1996
|
+
data: getExposedModelIds().map((id) => ({
|
|
1997
|
+
id,
|
|
1998
|
+
object: "model",
|
|
1999
|
+
created: 0,
|
|
2000
|
+
owned_by: "github-copilot"
|
|
2001
|
+
}))
|
|
2002
|
+
}));
|
|
2003
|
+
claudeRoutes.post("/messages", async (c) => {
|
|
2004
|
+
const config = c.get("config");
|
|
2005
|
+
const claudePayload = await c.req.json();
|
|
2006
|
+
log.trace("Full Claude request payload", { payload: claudePayload });
|
|
2007
|
+
try {
|
|
2008
|
+
const upstreamModel = translateModelName(claudePayload.model);
|
|
2009
|
+
const toolNameMapper = createClaudeToolNameMapper(claudePayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
|
|
2010
|
+
const response = await createChatCompletions(config, translateToOpenAI(claudePayload, void 0, toolNameMapper), {
|
|
2011
|
+
client: "claude",
|
|
2012
|
+
requestedModel: claudePayload.model,
|
|
2013
|
+
requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
|
|
2014
|
+
requestedThinking: getClaudeRequestedThinking(claudePayload)
|
|
2015
|
+
});
|
|
2016
|
+
if (isNonStreamingResponse(response)) {
|
|
2017
|
+
const claudeResponse = translateToClaude(response, toolNameMapper);
|
|
2018
|
+
if (!claudePayload.stream) return c.json(claudeResponse);
|
|
2019
|
+
return streamSSE(c, async (stream) => {
|
|
2020
|
+
for (const event of eventsFromClaudeResponse(claudeResponse)) await stream.writeSSE({
|
|
2021
|
+
event: event.type,
|
|
2022
|
+
data: JSON.stringify(event)
|
|
2023
|
+
});
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
return streamSSE(c, async (stream) => {
|
|
2027
|
+
const streamState = {
|
|
2028
|
+
messageStartSent: false,
|
|
2029
|
+
contentBlockIndex: 0,
|
|
2030
|
+
contentBlockOpen: false,
|
|
2031
|
+
thinkingBlockOpen: false,
|
|
2032
|
+
toolCalls: {}
|
|
2033
|
+
};
|
|
2034
|
+
try {
|
|
2035
|
+
for await (const rawEvent of response) {
|
|
2036
|
+
if (rawEvent.data === "[DONE]") break;
|
|
2037
|
+
if (!rawEvent.data) continue;
|
|
2038
|
+
let chunk;
|
|
2039
|
+
try {
|
|
2040
|
+
chunk = JSON.parse(rawEvent.data);
|
|
2041
|
+
} catch {
|
|
2042
|
+
continue;
|
|
2043
|
+
}
|
|
2044
|
+
for (const event of translateChunkToClaudeEvents(chunk, streamState, toolNameMapper)) await stream.writeSSE({
|
|
2045
|
+
event: event.type,
|
|
2046
|
+
data: JSON.stringify(event)
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
} catch (error) {
|
|
2050
|
+
log.error("Error during Claude stream translation:", error);
|
|
2051
|
+
const errorEvent = translateErrorToClaudeErrorEvent();
|
|
2052
|
+
await stream.writeSSE({
|
|
2053
|
+
event: errorEvent.type,
|
|
2054
|
+
data: JSON.stringify(errorEvent)
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
});
|
|
2058
|
+
} catch (error) {
|
|
2059
|
+
if (error instanceof ProxyNotImplementedError) return c.json({ error: {
|
|
2060
|
+
type: error.name,
|
|
2061
|
+
message: error.message
|
|
2062
|
+
} }, 501);
|
|
2063
|
+
if (error instanceof HTTPError) {
|
|
2064
|
+
const text = await error.response.text().catch(() => "");
|
|
2065
|
+
return new Response(text, {
|
|
2066
|
+
status: error.response.status,
|
|
2067
|
+
headers: { "content-type": error.response.headers.get("content-type") ?? "application/json" }
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
throw error;
|
|
2071
|
+
}
|
|
2072
|
+
});
|
|
2073
|
+
claudeRoutes.post("/messages/count_tokens", async (c) => {
|
|
2074
|
+
try {
|
|
2075
|
+
const claudeBeta = c.req.header("claude-beta");
|
|
2076
|
+
const claudePayload = await c.req.json();
|
|
2077
|
+
const openAIPayload = translateToOpenAI(claudePayload);
|
|
2078
|
+
const exposedModels = getExposedModelIds();
|
|
2079
|
+
const selectedModel = createTokenCountModel(openAIPayload.model === exposedModels[1] ? exposedModels[1] : exposedModels[0]);
|
|
2080
|
+
const tokenCount = await getTokenCount(openAIPayload, selectedModel);
|
|
2081
|
+
const effectiveModelId = selectedModel.id;
|
|
2082
|
+
if (claudePayload.tools && claudePayload.tools.length > 0) {
|
|
2083
|
+
let mcpToolExist = false;
|
|
2084
|
+
if (claudeBeta?.startsWith("claude-code")) mcpToolExist = claudePayload.tools.some((tool) => tool.name.startsWith("mcp__"));
|
|
2085
|
+
if (!mcpToolExist && effectiveModelId.startsWith("claude")) tokenCount.input += 346;
|
|
2086
|
+
}
|
|
2087
|
+
const multiplier = effectiveModelId.startsWith("claude") ? 1.15 : 1;
|
|
2088
|
+
const finalTokenCount = Math.round((tokenCount.input + tokenCount.output) * multiplier);
|
|
2089
|
+
return c.json({ input_tokens: Math.max(1, finalTokenCount) });
|
|
2090
|
+
} catch (error) {
|
|
2091
|
+
log.error("Error counting tokens:", error);
|
|
2092
|
+
return c.json({ input_tokens: 1 });
|
|
2093
|
+
}
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
//#endregion
|
|
2097
|
+
//#region src/server.ts
|
|
2098
|
+
const createServer = (config) => {
|
|
2099
|
+
const app = new Hono();
|
|
2100
|
+
app.use("*", cors());
|
|
2101
|
+
app.use("*", async (c, next) => {
|
|
2102
|
+
c.set("config", config);
|
|
2103
|
+
const started = performance.now();
|
|
2104
|
+
try {
|
|
2105
|
+
await next();
|
|
2106
|
+
} finally {
|
|
2107
|
+
const ms = Math.round(performance.now() - started);
|
|
2108
|
+
log.info(`${c.req.method} ${c.req.path} -> ${c.res.status} ${ms}ms`);
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
app.onError((error, c) => {
|
|
2112
|
+
log.error(`${c.req.method} ${c.req.path} failed`, error);
|
|
2113
|
+
return c.json({ error: { message: "Internal server error" } }, 500);
|
|
2114
|
+
});
|
|
2115
|
+
app.get("/", (c) => c.json({
|
|
2116
|
+
name: "copilot-relay",
|
|
2117
|
+
status: "ok"
|
|
2118
|
+
}));
|
|
2119
|
+
app.get("/healthz", (c) => c.json({ ok: true }));
|
|
2120
|
+
app.route("/v1", claudeRoutes);
|
|
2121
|
+
return app;
|
|
2122
|
+
};
|
|
2123
|
+
const startServer = (config) => serve({
|
|
2124
|
+
fetch: createServer(config).fetch,
|
|
2125
|
+
hostname: config.host,
|
|
2126
|
+
port: config.port
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
//#endregion
|
|
2130
|
+
//#region src/start.ts
|
|
2131
|
+
const start = defineCommand({
|
|
2132
|
+
meta: {
|
|
2133
|
+
name: "start",
|
|
2134
|
+
description: "Start the copilot-relay HTTP server."
|
|
2135
|
+
},
|
|
2136
|
+
async run() {
|
|
2137
|
+
const appConfig = await readAppConfig();
|
|
2138
|
+
setLogLevel(appConfig.logLevel);
|
|
2139
|
+
await cleanupLogs(appConfig.logRetentionDays);
|
|
2140
|
+
const claudeConfigPath$1 = claudeConfigPath;
|
|
2141
|
+
const config = readProxyConfig(appConfig);
|
|
2142
|
+
const applyRuntimeConfig = (nextConfig) => {
|
|
2143
|
+
setLogLevel(nextConfig.logLevel);
|
|
2144
|
+
cleanupLogs(nextConfig.logRetentionDays);
|
|
2145
|
+
runtimeState.debug = nextConfig.logLevel === "debug" || nextConfig.logLevel === "trace";
|
|
2146
|
+
runtimeState.thinkEffort = nextConfig.thinkEffort;
|
|
2147
|
+
config.copilotBaseUrl = nextConfig.copilotBaseUrl;
|
|
2148
|
+
config.host = nextConfig.host;
|
|
2149
|
+
config.port = nextConfig.port;
|
|
2150
|
+
runtimeState.modelRouting = {
|
|
2151
|
+
gptModel: nextConfig.gptModel,
|
|
2152
|
+
opusModel: nextConfig.opusModel
|
|
2153
|
+
};
|
|
2154
|
+
};
|
|
2155
|
+
applyRuntimeConfig(appConfig);
|
|
2156
|
+
if (runtimeState.debug) log.info("Debug diagnostics enabled; upstream errors include request summaries");
|
|
2157
|
+
log.info(`Log level: ${appConfig.logLevel}`);
|
|
2158
|
+
log.info(`Think effort: ${appConfig.thinkEffort}`);
|
|
2159
|
+
log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
|
|
2160
|
+
const authSession = await setupProxyAuth(config);
|
|
2161
|
+
try {
|
|
2162
|
+
await validateUpstream(config, appConfig.thinkEffort);
|
|
2163
|
+
} catch (error) {
|
|
2164
|
+
log.error("Startup preflight failed:", error);
|
|
2165
|
+
process.exit(1);
|
|
2166
|
+
}
|
|
2167
|
+
const server = startServer(config);
|
|
2168
|
+
log.info(`copilot-relay version: ${appVersion}`);
|
|
2169
|
+
if (authSession.githubLogin) log.info(`GitHub user: ${authSession.githubLogin}`);
|
|
2170
|
+
else log.warn("GitHub user: unavailable");
|
|
2171
|
+
log.success(`copilot-relay listening on http://${config.host}:${config.port}`);
|
|
2172
|
+
log.debug(`copilot base url: ${config.copilotBaseUrl}`);
|
|
2173
|
+
const baseUrl = `http://${config.host}:${config.port}`;
|
|
2174
|
+
if (appConfig.claudeSetup) try {
|
|
2175
|
+
const claudeResult = await applyClaudeConfig({
|
|
2176
|
+
baseUrl,
|
|
2177
|
+
configPath: claudeConfigPath$1
|
|
2178
|
+
});
|
|
2179
|
+
if (claudeResult.changed) {
|
|
2180
|
+
log.success(`claude settings ${claudeResult.created ? "created" : "updated"}: ${claudeResult.configPath}`);
|
|
2181
|
+
if (claudeResult.previousBaseUrl && claudeResult.previousBaseUrl !== baseUrl) log.info(`ANTHROPIC_BASE_URL: ${claudeResult.previousBaseUrl} → ${baseUrl}`);
|
|
2182
|
+
} else log.info(`claude settings already up to date: ${claudeResult.configPath}`);
|
|
2183
|
+
} catch (error) {
|
|
2184
|
+
log.warn(`Could not update claude settings (${claudeConfigPath$1}):`, error);
|
|
2185
|
+
}
|
|
2186
|
+
watchAppConfig((nextConfig) => {
|
|
2187
|
+
applyRuntimeConfig(nextConfig);
|
|
2188
|
+
log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort}`);
|
|
2189
|
+
});
|
|
2190
|
+
await new Promise((resolve$1, reject) => {
|
|
2191
|
+
server.on("close", resolve$1);
|
|
2192
|
+
server.on("error", reject);
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
});
|
|
2196
|
+
|
|
2197
|
+
//#endregion
|
|
2198
|
+
//#region src/main.ts
|
|
2199
|
+
await runMain(defineCommand({
|
|
2200
|
+
meta: {
|
|
2201
|
+
name: "copilot-relay",
|
|
2202
|
+
description: "Yet, just another relay for Claude Code to use a GitHub Copilot subscription."
|
|
2203
|
+
},
|
|
2204
|
+
subCommands: {
|
|
2205
|
+
auth,
|
|
2206
|
+
start
|
|
2207
|
+
}
|
|
2208
|
+
}));
|
|
2209
|
+
|
|
2210
|
+
//#endregion
|
|
2211
|
+
export { };
|
|
2212
|
+
//# sourceMappingURL=main.js.map
|