copillm 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +94 -18
- package/dist/config/home.js +3 -0
- package/dist/config/logging.js +27 -5
- package/dist/models/anthropicDefaults.js +1 -0
- package/dist/server/proxy.js +223 -13
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ import { acquireLock, inspectLock, LockAlreadyRunningError, releaseLock } from "
|
|
|
16
16
|
import { startProxyServer } from "./server/proxy.js";
|
|
17
17
|
import { defaultOutputDir, generateCodexHome } from "./codex/init.js";
|
|
18
18
|
import { defaultOutputDir as defaultPiOutputDir, generatePiHome } from "./pi/init.js";
|
|
19
|
-
import { getCopillmHome } from "./config/home.js";
|
|
19
|
+
import { debugLogPath, getCopillmHome } from "./config/home.js";
|
|
20
20
|
import { clearClaudeGatewayCache } from "./claude/cache.js";
|
|
21
21
|
import { detectClaudeSettingsConflicts, formatSettingsConflictWarning } from "./claude/settingsConflict.js";
|
|
22
22
|
import { buildClaudeExportCommand as buildClaudeExport, computeAnthropicDefaults, readModelIdsFromCache } from "./models/anthropicDefaults.js";
|
|
@@ -30,6 +30,7 @@ const logger = createLogger();
|
|
|
30
30
|
const program = new Command();
|
|
31
31
|
program.name("copillm").description("Local Copilot proxy").version("0.1.0");
|
|
32
32
|
program.enablePositionalOptions();
|
|
33
|
+
program.option("--debug", "Enable copillm debug mode (debug endpoint plus verbose daemon diagnostics)");
|
|
33
34
|
program
|
|
34
35
|
.command("login")
|
|
35
36
|
.description("[deprecated] Use `copillm auth login`")
|
|
@@ -129,6 +130,8 @@ program
|
|
|
129
130
|
.option("--no-pi", "Skip generating ~/.pi/agent/models.json for pi coding agent")
|
|
130
131
|
.option("--json", "JSON output")
|
|
131
132
|
.action(async (opts) => {
|
|
133
|
+
const debug = resolveCopillmDebug(opts.debug);
|
|
134
|
+
enableRuntimeDebug(debug);
|
|
132
135
|
if (opts.detach) {
|
|
133
136
|
// Fail fast on missing credentials rather than letting the detached
|
|
134
137
|
// child die silently and surface as a generic "start timed out" error.
|
|
@@ -138,6 +141,7 @@ program
|
|
|
138
141
|
}
|
|
139
142
|
const existingLock = await readLiveLock();
|
|
140
143
|
if (existingLock) {
|
|
144
|
+
const activeDebug = await warnIfDebugRequestedButInactive(debug, existingLock.port);
|
|
141
145
|
const codex = opts.codex === false ? null : await refreshCodexHome(existingLock.port, opts.codexModel ?? null);
|
|
142
146
|
const pi = opts.pi === false ? null : await refreshPiHome(existingLock.port);
|
|
143
147
|
const claude = buildClaudeExportCommand(existingLock.port, null);
|
|
@@ -145,7 +149,8 @@ program
|
|
|
145
149
|
port: existingLock.port,
|
|
146
150
|
pid: existingLock.pid,
|
|
147
151
|
mode: "already_running",
|
|
148
|
-
debug:
|
|
152
|
+
debug: activeDebug,
|
|
153
|
+
debugLogPath: null,
|
|
149
154
|
codex,
|
|
150
155
|
pi
|
|
151
156
|
});
|
|
@@ -153,6 +158,7 @@ program
|
|
|
153
158
|
status: "already_running",
|
|
154
159
|
pid: existingLock.pid,
|
|
155
160
|
port: existingLock.port,
|
|
161
|
+
debug: activeDebug,
|
|
156
162
|
url: `http://127.0.0.1:${existingLock.port}`,
|
|
157
163
|
codex_home: codex?.outDir ?? null,
|
|
158
164
|
codex_export_command: codex?.exportCommand ?? null,
|
|
@@ -169,12 +175,13 @@ program
|
|
|
169
175
|
return;
|
|
170
176
|
}
|
|
171
177
|
const daemonArgs = [process.argv[1], "daemon"];
|
|
172
|
-
if (
|
|
178
|
+
if (debug) {
|
|
173
179
|
daemonArgs.push("--debug");
|
|
174
180
|
}
|
|
175
181
|
const child = spawn(process.execPath, daemonArgs, {
|
|
176
182
|
detached: true,
|
|
177
|
-
stdio: "ignore"
|
|
183
|
+
stdio: "ignore",
|
|
184
|
+
env: daemonSpawnEnv(debug)
|
|
178
185
|
});
|
|
179
186
|
child.unref();
|
|
180
187
|
const started = await waitForDaemonReady(child.pid ?? null, 8_000);
|
|
@@ -188,7 +195,8 @@ program
|
|
|
188
195
|
port: started.port,
|
|
189
196
|
pid: started.pid,
|
|
190
197
|
mode: "detached",
|
|
191
|
-
debug
|
|
198
|
+
debug,
|
|
199
|
+
debugLogPath: currentDebugLogPath(debug),
|
|
192
200
|
codex,
|
|
193
201
|
pi
|
|
194
202
|
});
|
|
@@ -197,7 +205,8 @@ program
|
|
|
197
205
|
mode: "detached",
|
|
198
206
|
pid: started.pid,
|
|
199
207
|
port: started.port,
|
|
200
|
-
debug
|
|
208
|
+
debug,
|
|
209
|
+
debug_log_path: currentDebugLogPath(debug),
|
|
201
210
|
url: `http://127.0.0.1:${started.port}`,
|
|
202
211
|
codex_home: codex?.outDir ?? null,
|
|
203
212
|
codex_export_command: codex?.exportCommand ?? null,
|
|
@@ -217,8 +226,9 @@ program
|
|
|
217
226
|
}
|
|
218
227
|
// Foreground path: interactively prompt for login if needed.
|
|
219
228
|
await ensureAuthenticatedInteractive();
|
|
220
|
-
const started = await runDaemon({ debug
|
|
229
|
+
const started = await runDaemon({ debug });
|
|
221
230
|
if (started.kind === "already_running") {
|
|
231
|
+
const activeDebug = await warnIfDebugRequestedButInactive(debug, started.lock.port);
|
|
222
232
|
const codex = opts.codex === false ? null : await refreshCodexHome(started.lock.port, opts.codexModel ?? null);
|
|
223
233
|
const pi = opts.pi === false ? null : await refreshPiHome(started.lock.port);
|
|
224
234
|
const claude = buildClaudeExportCommand(started.lock.port, null);
|
|
@@ -226,7 +236,8 @@ program
|
|
|
226
236
|
port: started.lock.port,
|
|
227
237
|
pid: started.lock.pid,
|
|
228
238
|
mode: "already_running",
|
|
229
|
-
debug:
|
|
239
|
+
debug: activeDebug,
|
|
240
|
+
debugLogPath: null,
|
|
230
241
|
codex,
|
|
231
242
|
pi
|
|
232
243
|
});
|
|
@@ -234,6 +245,7 @@ program
|
|
|
234
245
|
status: "already_running",
|
|
235
246
|
pid: started.lock.pid,
|
|
236
247
|
port: started.lock.port,
|
|
248
|
+
debug: activeDebug,
|
|
237
249
|
url: `http://127.0.0.1:${started.lock.port}`,
|
|
238
250
|
codex_home: codex?.outDir ?? null,
|
|
239
251
|
codex_export_command: codex?.exportCommand ?? null,
|
|
@@ -256,7 +268,8 @@ program
|
|
|
256
268
|
port: started.port,
|
|
257
269
|
pid: process.pid,
|
|
258
270
|
mode: "foreground",
|
|
259
|
-
debug
|
|
271
|
+
debug,
|
|
272
|
+
debugLogPath: currentDebugLogPath(debug),
|
|
260
273
|
codex,
|
|
261
274
|
pi
|
|
262
275
|
});
|
|
@@ -265,7 +278,8 @@ program
|
|
|
265
278
|
mode: "foreground",
|
|
266
279
|
pid: process.pid,
|
|
267
280
|
port: started.port,
|
|
268
|
-
debug
|
|
281
|
+
debug,
|
|
282
|
+
debug_log_path: currentDebugLogPath(debug),
|
|
269
283
|
url: `http://127.0.0.1:${started.port}`,
|
|
270
284
|
caller_secret: started.callerSecret,
|
|
271
285
|
codex_home: codex?.outDir ?? null,
|
|
@@ -288,11 +302,13 @@ program
|
|
|
288
302
|
.description("Internal background command")
|
|
289
303
|
.option("--debug", "Enable debug endpoints")
|
|
290
304
|
.action(async (opts) => {
|
|
291
|
-
const
|
|
305
|
+
const debug = resolveCopillmDebug(opts.debug);
|
|
306
|
+
enableRuntimeDebug(debug);
|
|
307
|
+
const started = await runDaemon({ debug });
|
|
292
308
|
if (started.kind === "already_running") {
|
|
293
309
|
process.exit(0);
|
|
294
310
|
}
|
|
295
|
-
process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${
|
|
311
|
+
process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${debug ? " [debug]" : ""}\n`);
|
|
296
312
|
});
|
|
297
313
|
program
|
|
298
314
|
.command("stop")
|
|
@@ -655,7 +671,9 @@ program
|
|
|
655
671
|
.helpOption(false)
|
|
656
672
|
.argument("[args...]", "Args forwarded to codex")
|
|
657
673
|
.action(async (forwardedArgs, opts) => {
|
|
658
|
-
const
|
|
674
|
+
const debug = resolveCopillmDebug(opts.copillmDebug);
|
|
675
|
+
enableRuntimeDebug(debug);
|
|
676
|
+
const lock = await ensureDaemonRunningForLauncher({ debug });
|
|
659
677
|
const codex = await refreshCodexHome(lock.port, null);
|
|
660
678
|
if (!codex) {
|
|
661
679
|
throw new Error("Failed to prepare Codex home (see warning above).");
|
|
@@ -693,7 +711,9 @@ program
|
|
|
693
711
|
.helpOption(false)
|
|
694
712
|
.argument("[args...]", "Args forwarded to claude")
|
|
695
713
|
.action(async (forwardedArgs, opts) => {
|
|
696
|
-
const
|
|
714
|
+
const debug = resolveCopillmDebug(opts.copillmDebug);
|
|
715
|
+
enableRuntimeDebug(debug);
|
|
716
|
+
const lock = await ensureDaemonRunningForLauncher({ debug });
|
|
697
717
|
const claude = buildClaudeExportCommand(lock.port, null);
|
|
698
718
|
const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_CLAUDE_VERSION ?? undefined;
|
|
699
719
|
const conflicts = detectClaudeSettingsConflicts(claude.bundle.env);
|
|
@@ -730,7 +750,9 @@ program
|
|
|
730
750
|
.helpOption(false)
|
|
731
751
|
.argument("[args...]", "Args forwarded to pi")
|
|
732
752
|
.action(async (forwardedArgs, opts) => {
|
|
733
|
-
const
|
|
753
|
+
const debug = resolveCopillmDebug(opts.copillmDebug);
|
|
754
|
+
enableRuntimeDebug(debug);
|
|
755
|
+
const lock = await ensureDaemonRunningForLauncher({ debug });
|
|
734
756
|
const pi = await refreshPiHome(lock.port);
|
|
735
757
|
if (!pi) {
|
|
736
758
|
throw new Error("Failed to prepare pi models.json (see warning above).");
|
|
@@ -985,6 +1007,32 @@ function writeCommandOutput(opts, humanLine, payload) {
|
|
|
985
1007
|
}
|
|
986
1008
|
process.stdout.write(`${humanLine}\n`);
|
|
987
1009
|
}
|
|
1010
|
+
function resolveCopillmDebug(commandDebug) {
|
|
1011
|
+
return Boolean(commandDebug) || Boolean(program.opts().debug);
|
|
1012
|
+
}
|
|
1013
|
+
function enableRuntimeDebug(debug) {
|
|
1014
|
+
if (!debug) {
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
process.env.COPILLM_LOG_LEVEL = "debug";
|
|
1018
|
+
logger.level = "debug";
|
|
1019
|
+
}
|
|
1020
|
+
function currentDebugLogPath(debug) {
|
|
1021
|
+
if (!debug) {
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
return process.env.COPILLM_LOG_FILE ?? debugLogPath();
|
|
1025
|
+
}
|
|
1026
|
+
function daemonSpawnEnv(debug) {
|
|
1027
|
+
if (!debug) {
|
|
1028
|
+
return process.env;
|
|
1029
|
+
}
|
|
1030
|
+
return {
|
|
1031
|
+
...process.env,
|
|
1032
|
+
COPILLM_LOG_LEVEL: "debug",
|
|
1033
|
+
COPILLM_LOG_FILE: currentDebugLogPath(true) ?? debugLogPath()
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
988
1036
|
function formatStopHumanLine(primary, cache) {
|
|
989
1037
|
if (cache.cleared) {
|
|
990
1038
|
return `${primary} Cleared Claude Code gateway cache.`;
|
|
@@ -1057,6 +1105,9 @@ function formatStartBanner(input) {
|
|
|
1057
1105
|
if (input.codex) {
|
|
1058
1106
|
lines.push(` ${input.codex.modelCount} Copilot models discovered \u00B7 default: ${input.codex.defaultModel}`);
|
|
1059
1107
|
}
|
|
1108
|
+
if (input.debugLogPath) {
|
|
1109
|
+
lines.push(` debug log: ${displayHomePath(input.debugLogPath)}`);
|
|
1110
|
+
}
|
|
1060
1111
|
if (input.pi) {
|
|
1061
1112
|
lines.push(` pi: wrote ${input.pi.modelCount} models to ${displayHomePath(input.pi.configPath)}${input.pi.backupPath ? ` (backed up prior config to ${displayHomePath(input.pi.backupPath)})` : ""}`);
|
|
1062
1113
|
}
|
|
@@ -1096,6 +1147,25 @@ async function probeLivez(port) {
|
|
|
1096
1147
|
return false;
|
|
1097
1148
|
}
|
|
1098
1149
|
}
|
|
1150
|
+
async function warnIfDebugRequestedButInactive(debugRequested, port) {
|
|
1151
|
+
if (!debugRequested) {
|
|
1152
|
+
return false;
|
|
1153
|
+
}
|
|
1154
|
+
const active = await probeDebugEndpoint(port);
|
|
1155
|
+
if (!active) {
|
|
1156
|
+
process.stderr.write(`warning: copillm is already running without debug mode; run \`copillm stop\` then \`copillm --debug start --detach\` to enable daemon diagnostics.\n`);
|
|
1157
|
+
}
|
|
1158
|
+
return active;
|
|
1159
|
+
}
|
|
1160
|
+
async function probeDebugEndpoint(port) {
|
|
1161
|
+
try {
|
|
1162
|
+
const response = await fetch(`http://127.0.0.1:${port}/_debug`, { signal: AbortSignal.timeout(1_200) });
|
|
1163
|
+
return response.ok;
|
|
1164
|
+
}
|
|
1165
|
+
catch {
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1099
1169
|
async function probeHealth(port) {
|
|
1100
1170
|
try {
|
|
1101
1171
|
const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(1_500) });
|
|
@@ -1200,15 +1270,21 @@ function parseAgentName(raw) {
|
|
|
1200
1270
|
}
|
|
1201
1271
|
async function ensureDaemonRunningForLauncher(opts) {
|
|
1202
1272
|
const live = await readLiveLock();
|
|
1203
|
-
if (live)
|
|
1273
|
+
if (live) {
|
|
1274
|
+
await warnIfDebugRequestedButInactive(opts.debug, live.port);
|
|
1204
1275
|
return live;
|
|
1205
|
-
|
|
1276
|
+
}
|
|
1277
|
+
const debugLog = currentDebugLogPath(opts.debug);
|
|
1278
|
+
process.stderr.write(opts.debug && debugLog
|
|
1279
|
+
? `Starting copillm in background with debug logging at ${displayHomePath(debugLog)}...\n`
|
|
1280
|
+
: `Starting copillm in background...\n`);
|
|
1206
1281
|
const daemonArgs = [process.argv[1], "daemon"];
|
|
1207
1282
|
if (opts.debug)
|
|
1208
1283
|
daemonArgs.push("--debug");
|
|
1209
1284
|
const child = spawn(process.execPath, daemonArgs, {
|
|
1210
1285
|
detached: true,
|
|
1211
|
-
stdio: "ignore"
|
|
1286
|
+
stdio: "ignore",
|
|
1287
|
+
env: daemonSpawnEnv(opts.debug)
|
|
1212
1288
|
});
|
|
1213
1289
|
child.unref();
|
|
1214
1290
|
const started = await waitForDaemonReady(child.pid ?? null, 10_000);
|
package/dist/config/home.js
CHANGED
|
@@ -32,6 +32,9 @@ export function modelsCachePath() {
|
|
|
32
32
|
export function modelsCacheReadPath() {
|
|
33
33
|
return resolveReadablePath("models.cache.json");
|
|
34
34
|
}
|
|
35
|
+
export function debugLogPath() {
|
|
36
|
+
return path.join(getCopillmHome(), "debug.log");
|
|
37
|
+
}
|
|
35
38
|
function resolveReadablePath(fileName) {
|
|
36
39
|
const canonical = path.join(getCopillmHome(), fileName);
|
|
37
40
|
if (fs.existsSync(canonical)) {
|
package/dist/config/logging.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import pino from "pino";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { applyModeIfSupported } from "./fsSecurity.js";
|
|
5
|
+
export function createLogger(input) {
|
|
6
|
+
const destinationPath = input?.destinationPath ?? process.env.COPILLM_LOG_FILE;
|
|
7
|
+
const options = {
|
|
8
|
+
level: input?.level ?? process.env.COPILLM_LOG_LEVEL ?? "info",
|
|
5
9
|
redact: {
|
|
6
10
|
paths: [
|
|
7
11
|
"req.headers.authorization",
|
|
@@ -27,7 +31,25 @@ export function createLogger() {
|
|
|
27
31
|
remove: true
|
|
28
32
|
},
|
|
29
33
|
transport: process.env.COPILLM_LOG_PRETTY === "1"
|
|
30
|
-
? { target: "pino-pretty", options: { colorize: true } }
|
|
34
|
+
? { target: "pino-pretty", options: { colorize: destinationPath ? false : true, destination: destinationPath ?? 2 } }
|
|
31
35
|
: undefined
|
|
32
|
-
}
|
|
36
|
+
};
|
|
37
|
+
if (process.env.COPILLM_LOG_PRETTY === "1") {
|
|
38
|
+
if (destinationPath) {
|
|
39
|
+
prepareLogFile(destinationPath);
|
|
40
|
+
}
|
|
41
|
+
return pino(options);
|
|
42
|
+
}
|
|
43
|
+
if (destinationPath) {
|
|
44
|
+
prepareLogFile(destinationPath);
|
|
45
|
+
return pino(options, pino.destination(destinationPath));
|
|
46
|
+
}
|
|
47
|
+
return pino(options, pino.destination(2));
|
|
48
|
+
}
|
|
49
|
+
function prepareLogFile(filePath) {
|
|
50
|
+
const resolvedPath = path.resolve(filePath);
|
|
51
|
+
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true, mode: 0o700 });
|
|
52
|
+
const fd = fs.openSync(resolvedPath, "a", 0o600);
|
|
53
|
+
fs.closeSync(fd);
|
|
54
|
+
applyModeIfSupported(resolvedPath, 0o600);
|
|
33
55
|
}
|
package/dist/server/proxy.js
CHANGED
|
@@ -150,6 +150,7 @@ export async function startProxyServer(input) {
|
|
|
150
150
|
}
|
|
151
151
|
await handleDebug(res, {
|
|
152
152
|
config: input.config,
|
|
153
|
+
logger: input.logger,
|
|
153
154
|
tokenManager: input.tokenManager,
|
|
154
155
|
githubToken: input.githubToken,
|
|
155
156
|
port: input.port
|
|
@@ -194,6 +195,17 @@ export async function startProxyServer(input) {
|
|
|
194
195
|
beginAnthropicSseResponse(res, req);
|
|
195
196
|
prelude = writeAnthropicPrelude(res, requestedModel ?? "");
|
|
196
197
|
}
|
|
198
|
+
input.logger.debug({
|
|
199
|
+
event: "request_prepared",
|
|
200
|
+
request_id: requestId,
|
|
201
|
+
route: route.kind,
|
|
202
|
+
anthro_shape: route.anthroShape,
|
|
203
|
+
requested_model: requestedModel,
|
|
204
|
+
upstream_model: readRequestedModel(upstreamBody),
|
|
205
|
+
model_resolution_rule: resolvedModel?.rule ?? null,
|
|
206
|
+
upstream_path: upstreamPath,
|
|
207
|
+
...summarizeUpstreamPayload(upstreamBody)
|
|
208
|
+
}, "prepared upstream request");
|
|
197
209
|
try {
|
|
198
210
|
const upstream = await postToCopilot({
|
|
199
211
|
tokenManager: input.tokenManager,
|
|
@@ -204,7 +216,12 @@ export async function startProxyServer(input) {
|
|
|
204
216
|
upstreamPath,
|
|
205
217
|
signal: lifecycle.signal
|
|
206
218
|
});
|
|
207
|
-
await forwardResponse(upstream, route.anthroShape, res,
|
|
219
|
+
await forwardResponse(upstream, route.anthroShape, res, {
|
|
220
|
+
requestedModel: requestedModel ?? undefined,
|
|
221
|
+
prelude,
|
|
222
|
+
logger: input.logger,
|
|
223
|
+
requestId
|
|
224
|
+
});
|
|
208
225
|
}
|
|
209
226
|
catch (error) {
|
|
210
227
|
if (isBenignSocketError(error)) {
|
|
@@ -291,7 +308,25 @@ async function postToCopilot(input) {
|
|
|
291
308
|
throw abortErrorFromSignal(input.signal);
|
|
292
309
|
}
|
|
293
310
|
try {
|
|
311
|
+
const attemptStartedAt = Date.now();
|
|
312
|
+
input.logger.debug({
|
|
313
|
+
event: "upstream_request",
|
|
314
|
+
request_id: input.requestId,
|
|
315
|
+
attempt,
|
|
316
|
+
upstream_path: input.upstreamPath,
|
|
317
|
+
force_refresh: forceRefresh
|
|
318
|
+
}, "posting upstream request");
|
|
294
319
|
const response = await postWithCurrentBearer(input.tokenManager, input.accountType, input.body, forceRefresh, input.requestId, input.upstreamPath, input.signal);
|
|
320
|
+
input.logger.debug({
|
|
321
|
+
event: "upstream_response",
|
|
322
|
+
request_id: input.requestId,
|
|
323
|
+
attempt,
|
|
324
|
+
upstream_path: input.upstreamPath,
|
|
325
|
+
status_code: response.status,
|
|
326
|
+
duration_ms: Date.now() - attemptStartedAt,
|
|
327
|
+
content_type: response.headers.get("content-type"),
|
|
328
|
+
retry_after: response.headers.get("retry-after")
|
|
329
|
+
}, "received upstream response");
|
|
295
330
|
forceRefresh = false;
|
|
296
331
|
if (response.status === 401 && !authRefreshRetried && attempt < MAX_UPSTREAM_ATTEMPTS) {
|
|
297
332
|
authRefreshRetried = true;
|
|
@@ -344,43 +379,57 @@ function abortErrorFromSignal(signal) {
|
|
|
344
379
|
err.name = "AbortError";
|
|
345
380
|
return err;
|
|
346
381
|
}
|
|
347
|
-
async function forwardResponse(upstream, anthroShape, res,
|
|
382
|
+
async function forwardResponse(upstream, anthroShape, res, diagnostics) {
|
|
348
383
|
if (!upstream.ok) {
|
|
349
|
-
await
|
|
384
|
+
const upstreamError = await readUpstreamError(upstream);
|
|
385
|
+
const category = upstreamStatusCategory(upstream.status);
|
|
386
|
+
diagnostics.logger.warn({
|
|
387
|
+
event: "upstream_non_ok",
|
|
388
|
+
request_id: diagnostics.requestId,
|
|
389
|
+
status_code: upstream.status,
|
|
390
|
+
error: category,
|
|
391
|
+
upstream_content_type: upstreamError.contentType,
|
|
392
|
+
upstream_error_code: upstreamError.code,
|
|
393
|
+
upstream_error_type: upstreamError.type,
|
|
394
|
+
upstream_error_message: upstreamError.message,
|
|
395
|
+
upstream_response_bytes: upstreamError.responseBytes
|
|
396
|
+
}, "upstream request failed");
|
|
397
|
+
const message = formatUpstreamErrorMessage(category, upstreamError);
|
|
398
|
+
const prelude = diagnostics.prelude ?? null;
|
|
350
399
|
if (prelude) {
|
|
351
|
-
writeAnthropicSseError(res, prelude,
|
|
400
|
+
writeAnthropicSseError(res, prelude, message);
|
|
352
401
|
return;
|
|
353
402
|
}
|
|
354
|
-
sendJson(res, upstream.status,
|
|
403
|
+
sendJson(res, upstream.status, buildUpstreamErrorPayload(category, upstream.status, diagnostics.requestId, upstreamError, anthroShape));
|
|
355
404
|
return;
|
|
356
405
|
}
|
|
357
406
|
if (isEventStream(upstream)) {
|
|
358
407
|
if (anthroShape) {
|
|
359
408
|
if (!upstream.body) {
|
|
360
|
-
if (prelude) {
|
|
361
|
-
writeAnthropicSseError(res, prelude, "invalid_upstream_response");
|
|
409
|
+
if (diagnostics.prelude) {
|
|
410
|
+
writeAnthropicSseError(res, diagnostics.prelude, "invalid_upstream_response");
|
|
362
411
|
return;
|
|
363
412
|
}
|
|
364
413
|
sendJson(res, 502, { error: "invalid_upstream_response", detail: "Upstream stream body is missing." });
|
|
365
414
|
return;
|
|
366
415
|
}
|
|
367
|
-
if (!prelude) {
|
|
416
|
+
if (!diagnostics.prelude) {
|
|
368
417
|
beginAnthropicSseResponse(res);
|
|
369
418
|
}
|
|
370
419
|
const upstreamReadable = Readable.fromWeb(upstream.body);
|
|
371
420
|
await translateOpenAIStreamToAnthropic({
|
|
372
421
|
upstream: upstreamReadable,
|
|
373
422
|
downstream: res,
|
|
374
|
-
fallbackModel: requestedModel,
|
|
375
|
-
preEmittedMessageId: prelude?.messageId
|
|
423
|
+
fallbackModel: diagnostics.requestedModel,
|
|
424
|
+
preEmittedMessageId: diagnostics.prelude?.messageId
|
|
376
425
|
});
|
|
377
426
|
return;
|
|
378
427
|
}
|
|
379
428
|
await pipeEventStream(upstream, res);
|
|
380
429
|
return;
|
|
381
430
|
}
|
|
382
|
-
if (prelude) {
|
|
383
|
-
writeAnthropicSseError(res, prelude, "invalid_upstream_response");
|
|
431
|
+
if (diagnostics.prelude) {
|
|
432
|
+
writeAnthropicSseError(res, diagnostics.prelude, "invalid_upstream_response");
|
|
384
433
|
return;
|
|
385
434
|
}
|
|
386
435
|
let json;
|
|
@@ -604,7 +653,9 @@ async function handleDebug(res, input) {
|
|
|
604
653
|
uptime_seconds: uptimeSeconds,
|
|
605
654
|
account_type: input.config.accountType,
|
|
606
655
|
selected_models: input.config.selectedModels,
|
|
607
|
-
require_caller_secret: input.config.requireCallerSecret
|
|
656
|
+
require_caller_secret: input.config.requireCallerSecret,
|
|
657
|
+
log_level: input.logger.level,
|
|
658
|
+
log_file: process.env.COPILLM_LOG_FILE ?? null
|
|
608
659
|
},
|
|
609
660
|
auth: {
|
|
610
661
|
bearer_ttl_seconds: bearerTtlSeconds,
|
|
@@ -712,6 +763,165 @@ function upstreamStatusCategory(status) {
|
|
|
712
763
|
}
|
|
713
764
|
return "upstream_error";
|
|
714
765
|
}
|
|
766
|
+
async function readUpstreamError(response) {
|
|
767
|
+
const contentType = response.headers.get("content-type");
|
|
768
|
+
let text;
|
|
769
|
+
try {
|
|
770
|
+
text = await response.text();
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
return { contentType, code: null, type: null, message: null, responseBytes: null };
|
|
774
|
+
}
|
|
775
|
+
const trimmed = text.trim();
|
|
776
|
+
const responseBytes = Buffer.byteLength(text, "utf8");
|
|
777
|
+
if (trimmed.length === 0) {
|
|
778
|
+
return { contentType, code: null, type: null, message: null, responseBytes };
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
const parsed = JSON.parse(trimmed);
|
|
782
|
+
const extracted = extractErrorFields(parsed);
|
|
783
|
+
if (extracted.message || extracted.code || extracted.type) {
|
|
784
|
+
return { contentType, responseBytes, ...extracted };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
// Fall through to a plain-text snippet below.
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
contentType,
|
|
792
|
+
code: null,
|
|
793
|
+
type: null,
|
|
794
|
+
message: truncateForDiagnostics(trimmed),
|
|
795
|
+
responseBytes
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
function extractErrorFields(payload) {
|
|
799
|
+
if (!payload || typeof payload !== "object") {
|
|
800
|
+
return { code: null, type: null, message: typeof payload === "string" ? truncateForDiagnostics(payload) : null };
|
|
801
|
+
}
|
|
802
|
+
const record = payload;
|
|
803
|
+
const nested = record.error;
|
|
804
|
+
if (typeof nested === "string") {
|
|
805
|
+
return {
|
|
806
|
+
code: readStringField(record, "code"),
|
|
807
|
+
type: readStringField(record, "type"),
|
|
808
|
+
message: truncateForDiagnostics(nested)
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
if (nested && typeof nested === "object") {
|
|
812
|
+
const errorRecord = nested;
|
|
813
|
+
return {
|
|
814
|
+
code: readStringField(errorRecord, "code") ?? readStringField(record, "code"),
|
|
815
|
+
type: readStringField(errorRecord, "type") ?? readStringField(record, "type"),
|
|
816
|
+
message: readTruncatedStringField(errorRecord, "message") ??
|
|
817
|
+
readTruncatedStringField(errorRecord, "detail") ??
|
|
818
|
+
readTruncatedStringField(record, "message") ??
|
|
819
|
+
readTruncatedStringField(record, "detail")
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
code: readStringField(record, "code"),
|
|
824
|
+
type: readStringField(record, "type"),
|
|
825
|
+
message: readTruncatedStringField(record, "message") ?? readTruncatedStringField(record, "detail")
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function buildUpstreamErrorPayload(category, statusCode, requestId, upstreamError, anthroShape) {
|
|
829
|
+
const code = upstreamError.code ?? category;
|
|
830
|
+
const type = upstreamError.type ?? category;
|
|
831
|
+
const message = formatUserFacingUpstreamErrorMessage(category, upstreamError);
|
|
832
|
+
if (anthroShape) {
|
|
833
|
+
return {
|
|
834
|
+
type: "error",
|
|
835
|
+
error: {
|
|
836
|
+
type,
|
|
837
|
+
message,
|
|
838
|
+
code,
|
|
839
|
+
upstream_status_code: statusCode,
|
|
840
|
+
request_id: requestId
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
error: {
|
|
846
|
+
type,
|
|
847
|
+
code,
|
|
848
|
+
message,
|
|
849
|
+
upstream_status_code: statusCode,
|
|
850
|
+
request_id: requestId
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
function formatUpstreamErrorMessage(category, upstreamError) {
|
|
855
|
+
const parts = [upstreamError.code, upstreamError.type, upstreamError.message].filter((part) => typeof part === "string" && part.length > 0);
|
|
856
|
+
return parts.length > 0 ? `${category}: ${parts.join(": ")}` : category;
|
|
857
|
+
}
|
|
858
|
+
function formatUserFacingUpstreamErrorMessage(category, upstreamError) {
|
|
859
|
+
if (upstreamError.code && upstreamError.message) {
|
|
860
|
+
return `${upstreamError.code}: ${upstreamError.message}`;
|
|
861
|
+
}
|
|
862
|
+
if (upstreamError.message) {
|
|
863
|
+
return upstreamError.message;
|
|
864
|
+
}
|
|
865
|
+
return upstreamError.code ?? upstreamError.type ?? category;
|
|
866
|
+
}
|
|
867
|
+
function readStringField(record, key) {
|
|
868
|
+
const value = record[key];
|
|
869
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
870
|
+
}
|
|
871
|
+
function readTruncatedStringField(record, key) {
|
|
872
|
+
const value = readStringField(record, key);
|
|
873
|
+
return value ? truncateForDiagnostics(value) : null;
|
|
874
|
+
}
|
|
875
|
+
function truncateForDiagnostics(value) {
|
|
876
|
+
const maxChars = 500;
|
|
877
|
+
return value.length > maxChars ? `${value.slice(0, maxChars)}...` : value;
|
|
878
|
+
}
|
|
879
|
+
function summarizeUpstreamPayload(payload) {
|
|
880
|
+
let requestBytes = null;
|
|
881
|
+
try {
|
|
882
|
+
requestBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
requestBytes = null;
|
|
886
|
+
}
|
|
887
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
888
|
+
return { upstream_request_bytes: requestBytes };
|
|
889
|
+
}
|
|
890
|
+
const record = payload;
|
|
891
|
+
const messages = Array.isArray(record.messages) ? record.messages : null;
|
|
892
|
+
const input = Array.isArray(record.input) ? record.input : null;
|
|
893
|
+
return {
|
|
894
|
+
upstream_request_bytes: requestBytes,
|
|
895
|
+
stream: record.stream === true,
|
|
896
|
+
max_tokens: typeof record.max_tokens === "number" ? record.max_tokens : null,
|
|
897
|
+
message_count: messages?.length ?? null,
|
|
898
|
+
input_item_count: input?.length ?? null,
|
|
899
|
+
tool_count: Array.isArray(record.tools) ? record.tools.length : 0,
|
|
900
|
+
text_characters: sumTextCharacters(payload)
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
function sumTextCharacters(value) {
|
|
904
|
+
if (typeof value === "string") {
|
|
905
|
+
return value.length;
|
|
906
|
+
}
|
|
907
|
+
if (!value || typeof value !== "object") {
|
|
908
|
+
return 0;
|
|
909
|
+
}
|
|
910
|
+
if (Array.isArray(value)) {
|
|
911
|
+
return value.reduce((total, item) => total + sumTextCharacters(item), 0);
|
|
912
|
+
}
|
|
913
|
+
let total = 0;
|
|
914
|
+
const record = value;
|
|
915
|
+
for (const [key, nested] of Object.entries(record)) {
|
|
916
|
+
if (key === "text" || key === "content" || key === "arguments" || key === "input") {
|
|
917
|
+
total += sumTextCharacters(nested);
|
|
918
|
+
}
|
|
919
|
+
else if (nested && typeof nested === "object" && key !== "data" && key !== "image_url" && key !== "source") {
|
|
920
|
+
total += sumTextCharacters(nested);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return total;
|
|
924
|
+
}
|
|
715
925
|
function healthFailure(error) {
|
|
716
926
|
if (error instanceof CopilotTokenExchangeError) {
|
|
717
927
|
if (error.statusCode === 401 || error.statusCode === 403) {
|