copilot-relay 0.0.3 → 0.0.5
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/LICENSE +21 -0
- package/README.md +16 -7
- package/config.default.yaml +8 -9
- package/dist/main.js +114 -146
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 D0n9X1n
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -52,12 +52,11 @@ thinkEffort: xhigh
|
|
|
52
52
|
|
|
53
53
|
| Level | Logs |
|
|
54
54
|
| --- | --- |
|
|
55
|
-
| `silent` | Nothing |
|
|
56
55
|
| `error` | Startup, preflight, and request failures |
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
| `info` | Errors plus startup status, preflight status, and local HTTP status codes |
|
|
57
|
+
| `debug` | Info plus model routing summaries, Copilot upstream timings, and request payloads |
|
|
58
|
+
|
|
59
|
+
Any other `logLevel` value is invalid and stops startup.
|
|
61
60
|
|
|
62
61
|
Valid `thinkEffort`: `none`, `low`, `medium`, `high`, `xhigh`.
|
|
63
62
|
|
|
@@ -72,17 +71,27 @@ copilot-relay start
|
|
|
72
71
|
|
|
73
72
|
## Logging
|
|
74
73
|
|
|
75
|
-
At `
|
|
74
|
+
At `debug`, every model request logs the requested model, upstream model, requested think effort, requested thinking, and effective think effort.
|
|
76
75
|
|
|
77
|
-
|
|
76
|
+
Upstream failures are logged at `error` with full request and response context in the same log file.
|
|
78
77
|
|
|
79
78
|
Logs are written to `~/.copilot-relay/logs/copilot-relay.log`; old `.log` files are cleaned according to `logRetentionDays`.
|
|
80
79
|
|
|
80
|
+
Quick inspection:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
tail -f ~/.copilot-relay/logs/copilot-relay.log
|
|
84
|
+
grep -n "Failed to create\\|Startup preflight failed" ~/.copilot-relay/logs/copilot-relay.log
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
See [`docs/troubleshooting.md`](docs/troubleshooting.md) for common debugging workflows.
|
|
88
|
+
|
|
81
89
|
## Development
|
|
82
90
|
|
|
83
91
|
Developer notes live in [`docs/development.md`](docs/development.md).
|
|
84
92
|
Architecture notes live in [`docs/architecture.md`](docs/architecture.md).
|
|
85
93
|
Logging notes live in [`docs/logging.md`](docs/logging.md).
|
|
94
|
+
Troubleshooting notes live in [`docs/troubleshooting.md`](docs/troubleshooting.md).
|
|
86
95
|
|
|
87
96
|
```sh
|
|
88
97
|
npm install
|
package/config.default.yaml
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
#
|
|
3
3
|
# This file is copied to ~/.copilot-relay/config.yaml on first start.
|
|
4
4
|
# Edit the user config file, not this template, for local changes.
|
|
5
|
+
# Most values hot-reload while the service is running. host and port still
|
|
6
|
+
# require restart because the listening socket is already bound.
|
|
5
7
|
|
|
6
8
|
# Local host for the Claude Code-compatible HTTP server.
|
|
7
9
|
host: 127.0.0.1
|
|
@@ -18,15 +20,12 @@ copilotBaseUrl: https://api.githubcopilot.com
|
|
|
18
20
|
claudeSetup: true
|
|
19
21
|
|
|
20
22
|
# Log verbosity:
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
# and upstream error request summaries
|
|
28
|
-
# trace - debug logs plus full Claude request payloads and full upstream
|
|
29
|
-
# Copilot request payloads without redaction
|
|
23
|
+
# error - startup/preflight/request failures only
|
|
24
|
+
# info - error logs plus startup status, preflight status, and local HTTP
|
|
25
|
+
# status codes
|
|
26
|
+
# debug - info logs plus model routing summaries, Copilot upstream timings,
|
|
27
|
+
# and request payloads
|
|
28
|
+
# Any other value is invalid and stops startup.
|
|
30
29
|
logLevel: info
|
|
31
30
|
|
|
32
31
|
# Number of days to keep files in ~/.copilot-relay/logs.
|
package/dist/main.js
CHANGED
|
@@ -22,10 +22,12 @@ var ProxyNotImplementedError = class extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
};
|
|
24
24
|
var HTTPError = class extends Error {
|
|
25
|
+
detail;
|
|
25
26
|
response;
|
|
26
|
-
constructor(message, response) {
|
|
27
|
+
constructor(message, response, detail) {
|
|
27
28
|
super(message);
|
|
28
29
|
this.name = "HTTPError";
|
|
30
|
+
this.detail = detail;
|
|
29
31
|
this.response = response;
|
|
30
32
|
}
|
|
31
33
|
};
|
|
@@ -100,14 +102,18 @@ const logCleanupCheckIntervalMs = 3600 * 1e3;
|
|
|
100
102
|
let logRetentionDays = 3;
|
|
101
103
|
let nextLogCleanupCheckAt = 0;
|
|
102
104
|
const consolaLevelByName = {
|
|
103
|
-
silent: -Infinity,
|
|
104
105
|
error: 0,
|
|
105
|
-
warn: 1,
|
|
106
106
|
info: 3,
|
|
107
|
-
debug: 4
|
|
108
|
-
trace: 5
|
|
107
|
+
debug: 4
|
|
109
108
|
};
|
|
109
|
+
const fileLevelByMethod = {
|
|
110
|
+
error: consolaLevelByName.error,
|
|
111
|
+
info: consolaLevelByName.info,
|
|
112
|
+
debug: consolaLevelByName.debug
|
|
113
|
+
};
|
|
114
|
+
let currentLogLevel = consolaLevelByName.info;
|
|
110
115
|
const setLogLevel = (level) => {
|
|
116
|
+
currentLogLevel = consolaLevelByName[level];
|
|
111
117
|
consola.level = consolaLevelByName[level];
|
|
112
118
|
};
|
|
113
119
|
const formatLogValue = (value) => typeof value === "string" ? value : inspect(value, { depth: null });
|
|
@@ -128,15 +134,12 @@ const cleanupLogsIfDue = async () => {
|
|
|
128
134
|
await cleanupLogs(logRetentionDays);
|
|
129
135
|
};
|
|
130
136
|
const wrapFileLog = (level, fn) => ((...args) => {
|
|
131
|
-
writeLogFile(level, args).catch(() => void 0);
|
|
137
|
+
if ((fileLevelByMethod[level] ?? consolaLevelByName.info) <= currentLogLevel) writeLogFile(level, args).catch(() => void 0);
|
|
132
138
|
return fn(...args);
|
|
133
139
|
});
|
|
134
140
|
consola.error = wrapFileLog("error", consola.error.bind(consola));
|
|
135
|
-
consola.warn = wrapFileLog("warn", consola.warn.bind(consola));
|
|
136
141
|
consola.info = wrapFileLog("info", consola.info.bind(consola));
|
|
137
|
-
consola.success = wrapFileLog("success", consola.success.bind(consola));
|
|
138
142
|
consola.debug = wrapFileLog("debug", consola.debug.bind(consola));
|
|
139
|
-
consola.trace = wrapFileLog("trace", consola.trace.bind(consola));
|
|
140
143
|
const cleanupLogs = async (retentionDays) => {
|
|
141
144
|
logRetentionDays = retentionDays;
|
|
142
145
|
await fs.mkdir(paths.logsDir, { recursive: true });
|
|
@@ -264,21 +267,21 @@ const isCopilotTokenError = (error) => error instanceof HTTPError && error.messa
|
|
|
264
267
|
const ensureGitHubToken = async (options = {}) => {
|
|
265
268
|
const existingToken = options.force ? "" : await readGitHubToken();
|
|
266
269
|
if (existingToken) {
|
|
267
|
-
log.
|
|
270
|
+
log.info(`Using cached GitHub token at ${paths.githubTokenPath}`);
|
|
268
271
|
return existingToken;
|
|
269
272
|
}
|
|
270
273
|
const deviceCode = await getDeviceCode();
|
|
271
274
|
log.info(`Open ${deviceCode.verification_uri} and enter code ${deviceCode.user_code}`);
|
|
272
275
|
const githubToken = await pollAccessToken(deviceCode);
|
|
273
276
|
await writeGitHubToken(githubToken);
|
|
274
|
-
log.
|
|
277
|
+
log.info(`GitHub token synced to ${paths.githubTokenPath}`);
|
|
275
278
|
return githubToken;
|
|
276
279
|
};
|
|
277
280
|
const loadGitHubLogin = async (githubToken, vsCodeVersion) => {
|
|
278
281
|
try {
|
|
279
282
|
return (await getGitHubUser(githubToken, vsCodeVersion)).login;
|
|
280
283
|
} catch (error) {
|
|
281
|
-
log.
|
|
284
|
+
log.error("Could not load GitHub user:", error);
|
|
282
285
|
return;
|
|
283
286
|
}
|
|
284
287
|
};
|
|
@@ -293,11 +296,11 @@ const setupProxyAuth = async (config, options = {}) => {
|
|
|
293
296
|
const scheduleCopilotTokenRefresh = (refreshIn$1) => {
|
|
294
297
|
if (copilotTokenRefreshTimer) clearTimeout(copilotTokenRefreshTimer);
|
|
295
298
|
const refreshMs = Math.max(refreshIn$1 - 60, 60) * 1e3;
|
|
296
|
-
log.
|
|
299
|
+
log.info(`Next Copilot token refresh in ${Math.round(refreshMs / 1e3)}s`);
|
|
297
300
|
copilotTokenRefreshTimer = setTimeout(async () => {
|
|
298
301
|
try {
|
|
299
302
|
const nextRefreshIn = await applyCopilotToken();
|
|
300
|
-
log.
|
|
303
|
+
log.info("Refreshed Copilot token");
|
|
301
304
|
scheduleCopilotTokenRefresh(nextRefreshIn);
|
|
302
305
|
} catch (error) {
|
|
303
306
|
log.error("Failed to refresh Copilot token:", error);
|
|
@@ -311,12 +314,12 @@ const setupProxyAuth = async (config, options = {}) => {
|
|
|
311
314
|
if (storedToken && getStoredTokenRemainingSeconds(storedToken) > 60) {
|
|
312
315
|
config.copilotToken = storedToken.token;
|
|
313
316
|
refreshIn = getStoredTokenRemainingSeconds(storedToken);
|
|
314
|
-
log.
|
|
317
|
+
log.info(`Using cached Copilot token at ${paths.copilotTokenPath}`);
|
|
315
318
|
} else try {
|
|
316
319
|
refreshIn = await applyCopilotToken();
|
|
317
320
|
} catch (error) {
|
|
318
321
|
if (options.force || !isCopilotTokenError(error)) throw error;
|
|
319
|
-
log.
|
|
322
|
+
log.error("Cached GitHub auth could not get a Copilot token; running device auth again.");
|
|
320
323
|
githubToken = await ensureGitHubToken({
|
|
321
324
|
...options,
|
|
322
325
|
force: true
|
|
@@ -355,12 +358,9 @@ const routeModelId = (model) => {
|
|
|
355
358
|
//#endregion
|
|
356
359
|
//#region src/lib/app-config.ts
|
|
357
360
|
const logLevels = [
|
|
358
|
-
"silent",
|
|
359
361
|
"error",
|
|
360
|
-
"warn",
|
|
361
362
|
"info",
|
|
362
|
-
"debug"
|
|
363
|
-
"trace"
|
|
363
|
+
"debug"
|
|
364
364
|
];
|
|
365
365
|
const defaultConfig = {
|
|
366
366
|
claudeSetup: true,
|
|
@@ -374,7 +374,13 @@ const defaultConfig = {
|
|
|
374
374
|
thinkEffort: defaultReasoningEffort
|
|
375
375
|
};
|
|
376
376
|
const isLogLevelName = (value) => typeof value === "string" && logLevels.includes(value.toLowerCase());
|
|
377
|
-
const normalizeLogLevel = (value) =>
|
|
377
|
+
const normalizeLogLevel = (value) => {
|
|
378
|
+
if (value === void 0) return;
|
|
379
|
+
if (typeof value !== "string") throw new Error(`Invalid logLevel: expected one of ${logLevels.join(", ")}`);
|
|
380
|
+
const normalized = value.toLowerCase();
|
|
381
|
+
if (!isLogLevelName(normalized)) throw new Error(`Invalid logLevel "${value}": expected one of ${logLevels.join(", ")}`);
|
|
382
|
+
return normalized;
|
|
383
|
+
};
|
|
378
384
|
const normalizeBoolean = (value) => {
|
|
379
385
|
if (typeof value === "boolean") return value;
|
|
380
386
|
if (typeof value !== "string") return;
|
|
@@ -498,15 +504,11 @@ const serializeConfig = (config) => [
|
|
|
498
504
|
`claudeSetup: ${config.claudeSetup}`,
|
|
499
505
|
"",
|
|
500
506
|
"# Log verbosity:",
|
|
501
|
-
"#
|
|
502
|
-
"#
|
|
503
|
-
"#
|
|
504
|
-
"#
|
|
505
|
-
"#
|
|
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",
|
|
507
|
+
"# error - startup/preflight/request failures only",
|
|
508
|
+
"# info - error logs plus startup status, preflight status, and local HTTP",
|
|
509
|
+
"# status codes",
|
|
510
|
+
"# debug - info logs plus model routing summaries, Copilot upstream timings,",
|
|
511
|
+
"# and request payloads",
|
|
510
512
|
`logLevel: ${config.logLevel}`,
|
|
511
513
|
"",
|
|
512
514
|
"# Number of days to keep files in ~/.copilot-relay/logs.",
|
|
@@ -594,7 +596,7 @@ const auth = defineCommand({
|
|
|
594
596
|
config.copilotToken = void 0;
|
|
595
597
|
const authSession = await setupProxyAuth(config, { force: true });
|
|
596
598
|
if (authSession.githubLogin) log.info(`Logged in as ${authSession.githubLogin}`);
|
|
597
|
-
log.
|
|
599
|
+
log.info("copilot-relay auth completed");
|
|
598
600
|
}
|
|
599
601
|
});
|
|
600
602
|
|
|
@@ -697,84 +699,15 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
|
697
699
|
const ms = Math.round(performance.now() - started);
|
|
698
700
|
log.debug(`Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt})`);
|
|
699
701
|
if (!shouldRetryResponse(response) || attempt === maxFetchAttempts) return response;
|
|
700
|
-
log.
|
|
702
|
+
log.error(`Copilot ${path$1} returned ${response.status}; retrying (${attempt}/${maxFetchAttempts})`);
|
|
701
703
|
} catch (error) {
|
|
702
704
|
lastError = error;
|
|
703
705
|
if (attempt === maxFetchAttempts) throw error;
|
|
704
|
-
log.
|
|
706
|
+
log.error(`Copilot ${path$1} request failed; retrying (${attempt}/${maxFetchAttempts})`, error);
|
|
705
707
|
}
|
|
706
708
|
throw lastError;
|
|
707
709
|
};
|
|
708
710
|
|
|
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
711
|
//#endregion
|
|
779
712
|
//#region src/copilot/responses.ts
|
|
780
713
|
const responsesOnlyModelPattern = /^(?:gpt-5\.5)(?:-|$)/i;
|
|
@@ -1145,16 +1078,6 @@ const sanitizeUserIdentifier = (user) => {
|
|
|
1145
1078
|
if (!user) return;
|
|
1146
1079
|
return user.slice(0, maxUserLength);
|
|
1147
1080
|
};
|
|
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
1081
|
const buildRequestPayload = (payload) => {
|
|
1159
1082
|
const requestedReasoningEffort = getRequestedReasoningEffort(payload);
|
|
1160
1083
|
if (!usesMaxCompletionTokens(payload.model) || payload.max_tokens === null || payload.max_tokens === void 0) return {
|
|
@@ -1186,7 +1109,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1186
1109
|
const enableVision = messagesIncludeImage(upstreamPayload.messages);
|
|
1187
1110
|
const initiator = isAgentInitiator(upstreamPayload.messages);
|
|
1188
1111
|
const requestPayload = buildRequestPayload(upstreamPayload);
|
|
1189
|
-
log.
|
|
1112
|
+
log.debug([
|
|
1190
1113
|
"Model request",
|
|
1191
1114
|
`client=${client}`,
|
|
1192
1115
|
`requested_model=${requestedModel}`,
|
|
@@ -1195,7 +1118,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1195
1118
|
`requested_thinking=${requestedThinking}`,
|
|
1196
1119
|
`effective_think_effort=${requestPayload.reasoning_effort ?? "none"}`
|
|
1197
1120
|
].join(" "));
|
|
1198
|
-
log.
|
|
1121
|
+
log.debug("Full request payload", { payload: requestPayload });
|
|
1199
1122
|
if (shouldUseResponsesApiForModel(upstreamPayload.model)) return createResponses(provider, upstreamPayload, {
|
|
1200
1123
|
vision: enableVision,
|
|
1201
1124
|
initiator
|
|
@@ -1216,49 +1139,62 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1216
1139
|
vision: enableVision,
|
|
1217
1140
|
initiator
|
|
1218
1141
|
});
|
|
1219
|
-
await logUpstreamError("Failed to create chat completions", response, {
|
|
1142
|
+
throw new HTTPError("Failed to create chat completions", response, await logUpstreamError("Failed to create chat completions", response, {
|
|
1220
1143
|
model: payload.model,
|
|
1221
1144
|
request: requestPayload,
|
|
1222
1145
|
route: "/chat/completions"
|
|
1223
|
-
});
|
|
1224
|
-
throw new HTTPError("Failed to create chat completions", response);
|
|
1146
|
+
}));
|
|
1225
1147
|
}
|
|
1226
1148
|
if (upstreamPayload.stream) return events(response);
|
|
1227
1149
|
return await response.json();
|
|
1228
1150
|
};
|
|
1229
1151
|
async function createResponses(provider, payload, options) {
|
|
1230
|
-
const
|
|
1152
|
+
const requestPayload = buildResponsesRequestPayload(payload, getRequestedReasoningEffort(payload));
|
|
1231
1153
|
const response = await fetchCopilot(provider, "/responses", {
|
|
1232
1154
|
method: "POST",
|
|
1233
1155
|
headers: {
|
|
1234
1156
|
accept: payload.stream ? "text/event-stream" : "application/json",
|
|
1235
1157
|
"content-type": "application/json"
|
|
1236
1158
|
},
|
|
1237
|
-
body: JSON.stringify(
|
|
1159
|
+
body: JSON.stringify(requestPayload)
|
|
1238
1160
|
}, {
|
|
1239
1161
|
vision: options.vision,
|
|
1240
1162
|
initiator: options.initiator
|
|
1241
1163
|
});
|
|
1242
|
-
if (!response.ok) {
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
throw new HTTPError("Failed to create responses", response);
|
|
1248
|
-
}
|
|
1164
|
+
if (!response.ok) throw new HTTPError("Failed to create responses", response, await logUpstreamError("Failed to create responses", response, {
|
|
1165
|
+
model: payload.model,
|
|
1166
|
+
request: requestPayload,
|
|
1167
|
+
route: "/responses"
|
|
1168
|
+
}));
|
|
1249
1169
|
if (payload.stream) return translateResponsesStreamToChatCompletionStream(events(response));
|
|
1250
1170
|
return translateResponsesToChatCompletion(await response.json());
|
|
1251
1171
|
}
|
|
1252
1172
|
async function logUpstreamError(message, response, context) {
|
|
1253
1173
|
const errorBody = await response.clone().text().catch(() => "");
|
|
1254
|
-
|
|
1174
|
+
const detail = getUpstreamErrorDetail(response, errorBody);
|
|
1175
|
+
log.error(`${message}: route=${context.route} model=${context.model} status=${response.status}`, {
|
|
1176
|
+
message,
|
|
1255
1177
|
route: context.route,
|
|
1256
1178
|
model: context.model,
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1179
|
+
request: context.request,
|
|
1180
|
+
response: {
|
|
1181
|
+
status: response.status,
|
|
1182
|
+
statusText: response.statusText || void 0,
|
|
1183
|
+
url: response.url || void 0,
|
|
1184
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
1185
|
+
body: errorBody || void 0
|
|
1186
|
+
}
|
|
1261
1187
|
});
|
|
1188
|
+
return detail;
|
|
1189
|
+
}
|
|
1190
|
+
function getUpstreamErrorDetail(response, body) {
|
|
1191
|
+
if (!body) return response.statusText || void 0;
|
|
1192
|
+
try {
|
|
1193
|
+
const payload = JSON.parse(body);
|
|
1194
|
+
return payload.error?.message ?? payload.error?.code ?? body.slice(0, 240);
|
|
1195
|
+
} catch {
|
|
1196
|
+
return body.slice(0, 240);
|
|
1197
|
+
}
|
|
1262
1198
|
}
|
|
1263
1199
|
async function shouldRetryWithResponses(response) {
|
|
1264
1200
|
try {
|
|
@@ -1419,7 +1355,7 @@ function translateToOpenAI(payload, _settings, toolNameMapper) {
|
|
|
1419
1355
|
const tools = translateClaudeToolsToOpenAI(payload.tools, mapper);
|
|
1420
1356
|
return {
|
|
1421
1357
|
model,
|
|
1422
|
-
messages: translateClaudeMessagesToOpenAI(payload.messages, payload.system, mapper),
|
|
1358
|
+
messages: normalizeFinalAssistantPrefill(translateClaudeMessagesToOpenAI(payload.messages, payload.system, mapper)),
|
|
1423
1359
|
max_tokens: payload.max_tokens,
|
|
1424
1360
|
stop: payload.stop_sequences,
|
|
1425
1361
|
stream: payload.stream,
|
|
@@ -1492,6 +1428,31 @@ function handleAssistantMessage(message, toolNameMapper) {
|
|
|
1492
1428
|
content: mapContent(message.content)
|
|
1493
1429
|
}];
|
|
1494
1430
|
}
|
|
1431
|
+
const continuePrefillPrompt = "Continue the assistant response from the previous assistant message.";
|
|
1432
|
+
const normalizeFinalAssistantPrefill = (messages) => {
|
|
1433
|
+
let lastAssistantIndex = -1;
|
|
1434
|
+
for (let index = messages.length - 1; index >= 0; index--) if (messages[index]?.role === "assistant") {
|
|
1435
|
+
lastAssistantIndex = index;
|
|
1436
|
+
break;
|
|
1437
|
+
}
|
|
1438
|
+
if (lastAssistantIndex < 0) return messages;
|
|
1439
|
+
if (lastAssistantIndex !== messages.length - 1) return messages;
|
|
1440
|
+
const lastAssistant = messages[lastAssistantIndex];
|
|
1441
|
+
if (typeof lastAssistant.content !== "string") return messages.slice(0, -1);
|
|
1442
|
+
const trimmedContent = lastAssistant.content.trimEnd();
|
|
1443
|
+
const nextMessages = [...messages];
|
|
1444
|
+
if (trimmedContent) {
|
|
1445
|
+
nextMessages[lastAssistantIndex] = {
|
|
1446
|
+
...lastAssistant,
|
|
1447
|
+
content: trimmedContent
|
|
1448
|
+
};
|
|
1449
|
+
nextMessages.push({
|
|
1450
|
+
role: "user",
|
|
1451
|
+
content: continuePrefillPrompt
|
|
1452
|
+
});
|
|
1453
|
+
} else nextMessages.pop();
|
|
1454
|
+
return nextMessages;
|
|
1455
|
+
};
|
|
1495
1456
|
function mapContent(content) {
|
|
1496
1457
|
if (typeof content === "string") return content;
|
|
1497
1458
|
if (!Array.isArray(content)) return null;
|
|
@@ -2003,7 +1964,7 @@ claudeRoutes.get("/models", (c) => c.json({
|
|
|
2003
1964
|
claudeRoutes.post("/messages", async (c) => {
|
|
2004
1965
|
const config = c.get("config");
|
|
2005
1966
|
const claudePayload = await c.req.json();
|
|
2006
|
-
log.
|
|
1967
|
+
log.debug("Full Claude request payload", { payload: claudePayload });
|
|
2007
1968
|
try {
|
|
2008
1969
|
const upstreamModel = translateModelName(claudePayload.model);
|
|
2009
1970
|
const toolNameMapper = createClaudeToolNameMapper(claudePayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
|
|
@@ -2056,12 +2017,16 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
2056
2017
|
}
|
|
2057
2018
|
});
|
|
2058
2019
|
} catch (error) {
|
|
2059
|
-
if (error instanceof ProxyNotImplementedError)
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2020
|
+
if (error instanceof ProxyNotImplementedError) {
|
|
2021
|
+
c.set("requestErrorMessage", error.message);
|
|
2022
|
+
return c.json({ error: {
|
|
2023
|
+
type: error.name,
|
|
2024
|
+
message: error.message
|
|
2025
|
+
} }, 501);
|
|
2026
|
+
}
|
|
2063
2027
|
if (error instanceof HTTPError) {
|
|
2064
2028
|
const text = await error.response.text().catch(() => "");
|
|
2029
|
+
c.set("requestErrorMessage", error.detail ?? text.slice(0, 240));
|
|
2065
2030
|
return new Response(text, {
|
|
2066
2031
|
status: error.response.status,
|
|
2067
2032
|
headers: { "content-type": error.response.headers.get("content-type") ?? "application/json" }
|
|
@@ -2095,6 +2060,10 @@ claudeRoutes.post("/messages/count_tokens", async (c) => {
|
|
|
2095
2060
|
|
|
2096
2061
|
//#endregion
|
|
2097
2062
|
//#region src/server.ts
|
|
2063
|
+
const formatStatusLog = (method, path$1, status, ms, errorMessage) => {
|
|
2064
|
+
const base = `${method} ${path$1} -> ${status} ${ms}ms`;
|
|
2065
|
+
return status >= 400 && errorMessage ? `${base} error=${JSON.stringify(errorMessage)}` : base;
|
|
2066
|
+
};
|
|
2098
2067
|
const createServer = (config) => {
|
|
2099
2068
|
const app = new Hono();
|
|
2100
2069
|
app.use("*", cors());
|
|
@@ -2105,11 +2074,11 @@ const createServer = (config) => {
|
|
|
2105
2074
|
await next();
|
|
2106
2075
|
} finally {
|
|
2107
2076
|
const ms = Math.round(performance.now() - started);
|
|
2108
|
-
log.info(
|
|
2077
|
+
log.info(formatStatusLog(c.req.method, c.req.path, c.res.status, ms, c.get("requestErrorMessage")));
|
|
2109
2078
|
}
|
|
2110
2079
|
});
|
|
2111
2080
|
app.onError((error, c) => {
|
|
2112
|
-
|
|
2081
|
+
c.set("requestErrorMessage", error.message);
|
|
2113
2082
|
return c.json({ error: { message: "Internal server error" } }, 500);
|
|
2114
2083
|
});
|
|
2115
2084
|
app.get("/", (c) => c.json({
|
|
@@ -2142,7 +2111,7 @@ const start = defineCommand({
|
|
|
2142
2111
|
const applyRuntimeConfig = (nextConfig) => {
|
|
2143
2112
|
setLogLevel(nextConfig.logLevel);
|
|
2144
2113
|
cleanupLogs(nextConfig.logRetentionDays);
|
|
2145
|
-
runtimeState.debug = nextConfig.logLevel === "debug"
|
|
2114
|
+
runtimeState.debug = nextConfig.logLevel === "debug";
|
|
2146
2115
|
runtimeState.thinkEffort = nextConfig.thinkEffort;
|
|
2147
2116
|
config.copilotBaseUrl = nextConfig.copilotBaseUrl;
|
|
2148
2117
|
config.host = nextConfig.host;
|
|
@@ -2153,7 +2122,6 @@ const start = defineCommand({
|
|
|
2153
2122
|
};
|
|
2154
2123
|
};
|
|
2155
2124
|
applyRuntimeConfig(appConfig);
|
|
2156
|
-
if (runtimeState.debug) log.info("Debug diagnostics enabled; upstream errors include request summaries");
|
|
2157
2125
|
log.info(`Log level: ${appConfig.logLevel}`);
|
|
2158
2126
|
log.info(`Think effort: ${appConfig.thinkEffort}`);
|
|
2159
2127
|
log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
|
|
@@ -2167,9 +2135,9 @@ const start = defineCommand({
|
|
|
2167
2135
|
const server = startServer(config);
|
|
2168
2136
|
log.info(`copilot-relay version: ${appVersion}`);
|
|
2169
2137
|
if (authSession.githubLogin) log.info(`GitHub user: ${authSession.githubLogin}`);
|
|
2170
|
-
else log.
|
|
2171
|
-
log.
|
|
2172
|
-
log.
|
|
2138
|
+
else log.error("GitHub user: unavailable");
|
|
2139
|
+
log.info(`copilot-relay listening on http://${config.host}:${config.port}`);
|
|
2140
|
+
log.info(`copilot base url: ${config.copilotBaseUrl}`);
|
|
2173
2141
|
const baseUrl = `http://${config.host}:${config.port}`;
|
|
2174
2142
|
if (appConfig.claudeSetup) try {
|
|
2175
2143
|
const claudeResult = await applyClaudeConfig({
|
|
@@ -2177,11 +2145,11 @@ const start = defineCommand({
|
|
|
2177
2145
|
configPath: claudeConfigPath$1
|
|
2178
2146
|
});
|
|
2179
2147
|
if (claudeResult.changed) {
|
|
2180
|
-
log.
|
|
2148
|
+
log.info(`claude settings ${claudeResult.created ? "created" : "updated"}: ${claudeResult.configPath}`);
|
|
2181
2149
|
if (claudeResult.previousBaseUrl && claudeResult.previousBaseUrl !== baseUrl) log.info(`ANTHROPIC_BASE_URL: ${claudeResult.previousBaseUrl} → ${baseUrl}`);
|
|
2182
2150
|
} else log.info(`claude settings already up to date: ${claudeResult.configPath}`);
|
|
2183
2151
|
} catch (error) {
|
|
2184
|
-
log.
|
|
2152
|
+
log.error(`Could not update claude settings (${claudeConfigPath$1}):`, error);
|
|
2185
2153
|
}
|
|
2186
2154
|
watchAppConfig((nextConfig) => {
|
|
2187
2155
|
applyRuntimeConfig(nextConfig);
|