copillm 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/agentconfig/apply.js +7 -5
- package/dist/cli/auth/runAuth.js +8 -2
- package/dist/cli/commands/daemon.js +106 -53
- package/dist/cli/daemon/restart.js +35 -0
- package/dist/cli/daemon/spawnDetached.js +27 -0
- package/dist/cli/daemon/spawnEnv.js +14 -8
- package/dist/cli/integrations/banner.js +1 -1
- package/dist/cli/packageInfo.js +1 -1
- package/dist/config/fsSecurity.js +36 -0
- package/dist/server/errors.js +8 -0
- package/dist/server/proxy.js +5 -1
- package/dist/server/routes/shared.js +30 -3
- package/dist/translation/openaiAnthropic.js +67 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -89,6 +89,10 @@ The **default account** is what every agent and the model endpoints use unless
|
|
|
89
89
|
told otherwise. `copillm auth status` lists each account with its plan type and
|
|
90
90
|
whether a credential is stored; tokens are never printed.
|
|
91
91
|
|
|
92
|
+
If the daemon is already running when you `auth switch`, copillm reminds you to
|
|
93
|
+
run `copillm restart` so the new default takes effect for the next agent launch.
|
|
94
|
+
(`auth logout` stops the daemon for you, so it needs no restart.)
|
|
95
|
+
|
|
92
96
|
Different accounts can be entitled to different models, so each account keeps
|
|
93
97
|
its own model list.
|
|
94
98
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { writeFilesSecureAtomic } from "../config/fsSecurity.js";
|
|
2
2
|
import { loadAgentConfig } from "./load.js";
|
|
3
3
|
import { planRender } from "./render.js";
|
|
4
4
|
import { backupIfMismatch } from "./markerBlock.js";
|
|
@@ -22,13 +22,15 @@ export function applyAgentConfig(opts) {
|
|
|
22
22
|
return { active: null, writes: [], envOverlay: {}, cliArgs: [], notes: [], sources: [], yolo: null };
|
|
23
23
|
}
|
|
24
24
|
const rendered = planRender(opts, load);
|
|
25
|
-
// Phase 2: write.
|
|
26
|
-
//
|
|
27
|
-
//
|
|
25
|
+
// Phase 2: write. All validation passed and the renderer produced complete
|
|
26
|
+
// file bodies, so failures here are IO errors. Back up any user-edited
|
|
27
|
+
// targets first, then commit every file as one two-phase atomic batch: each
|
|
28
|
+
// file is staged to a temp path before any is renamed into place, so an IO
|
|
29
|
+
// failure mid-fan-out leaves nothing committed rather than a half-updated set.
|
|
28
30
|
for (const write of rendered.writes) {
|
|
29
31
|
backupIfMismatch(write.path, write.content);
|
|
30
|
-
writeFileSecureAtomic(write.path, write.content, write.mode);
|
|
31
32
|
}
|
|
33
|
+
writeFilesSecureAtomic(rendered.writes);
|
|
32
34
|
return {
|
|
33
35
|
active: load.active,
|
|
34
36
|
writes: rendered.writes,
|
package/dist/cli/auth/runAuth.js
CHANGED
|
@@ -227,10 +227,16 @@ export async function runAuthSwitch(opts, accountId) {
|
|
|
227
227
|
try {
|
|
228
228
|
assertValidAccountId(accountId);
|
|
229
229
|
const index = switchDefaultAccount(accountId);
|
|
230
|
-
|
|
230
|
+
// A running daemon snapshots the default account at startup, so a switch
|
|
231
|
+
// only affects new agent launches after the daemon is restarted. (`auth
|
|
232
|
+
// logout` doesn't need this hint — it already stops the daemon.)
|
|
233
|
+
const daemonRunning = inspectLock().state === "running";
|
|
234
|
+
const hint = daemonRunning ? " Restart the daemon for this to take effect: copillm restart." : "";
|
|
235
|
+
writeCommandOutput(opts, `Default account is now "${index.defaultAccount}".${hint}`, {
|
|
231
236
|
status: "ok",
|
|
232
237
|
action: "switch",
|
|
233
|
-
default_account: index.defaultAccount
|
|
238
|
+
default_account: index.defaultAccount,
|
|
239
|
+
restart_required: daemonRunning
|
|
234
240
|
});
|
|
235
241
|
}
|
|
236
242
|
catch (error) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { inspectStoredCredential } from "../../auth/credentials.js";
|
|
3
2
|
import { loadConfig } from "../../config/config.js";
|
|
4
3
|
import { getCopillmHome } from "../../config/home.js";
|
|
@@ -8,9 +7,8 @@ import { inspectLock, releaseLock } from "../../server/lock.js";
|
|
|
8
7
|
import { buildCodexEnvBundle } from "../agentEnv.js";
|
|
9
8
|
import { ensureAuthenticatedInteractive } from "../auth/ensure.js";
|
|
10
9
|
import { computeUptimeSeconds, formatUptime, stopByPid } from "../daemon/lifecycle.js";
|
|
11
|
-
import { probeHealth, readLiveLock,
|
|
10
|
+
import { probeDebugEndpoint, probeHealth, readLiveLock, warnIfDebugRequestedButInactive } from "../daemon/probes.js";
|
|
12
11
|
import { runDaemon } from "../daemon/runDaemon.js";
|
|
13
|
-
import { daemonSpawnEnv } from "../daemon/spawnEnv.js";
|
|
14
12
|
import { buildClaudeExportCommand } from "../integrations/claudeExport.js";
|
|
15
13
|
import { formatStartBanner, formatStopHumanLine, displayHomePath } from "../integrations/banner.js";
|
|
16
14
|
import { refreshCodexHome } from "../integrations/refreshCodex.js";
|
|
@@ -19,7 +17,8 @@ import { writeAuthStatusLine } from "../shared/backends.js";
|
|
|
19
17
|
import { currentDebugLogPath, enableRuntimeDebug, getRootLogger, resolveCopillmDebug } from "../shared/debug.js";
|
|
20
18
|
import { isDevModeActive } from "../shared/devMode.js";
|
|
21
19
|
import { writeCommandOutput, writeHealthOutput } from "../shared/output.js";
|
|
22
|
-
import {
|
|
20
|
+
import { spawnDetachedDaemon } from "../daemon/spawnDetached.js";
|
|
21
|
+
import { resolveRestartDecision } from "../daemon/restart.js";
|
|
23
22
|
export function register(program) {
|
|
24
23
|
program
|
|
25
24
|
.command("start")
|
|
@@ -79,55 +78,8 @@ export function register(program) {
|
|
|
79
78
|
});
|
|
80
79
|
return;
|
|
81
80
|
}
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
daemonCommand.args.push("--debug");
|
|
85
|
-
}
|
|
86
|
-
const child = spawn(daemonCommand.command, daemonCommand.args, {
|
|
87
|
-
detached: true,
|
|
88
|
-
stdio: "ignore",
|
|
89
|
-
env: daemonSpawnEnv(debug)
|
|
90
|
-
});
|
|
91
|
-
child.unref();
|
|
92
|
-
const started = await waitForDaemonReady(child.pid ?? null, 8_000);
|
|
93
|
-
if (!started) {
|
|
94
|
-
throw new Error("Detached daemon start timed out.");
|
|
95
|
-
}
|
|
96
|
-
const sharedDetached = await loadSharedStartContextIfNeeded(opts);
|
|
97
|
-
const codex = opts.codex === false ? null : await refreshCodexHome(started.port, opts.codexModel ?? null, sharedDetached);
|
|
98
|
-
const pi = opts.pi === false ? null : await refreshPiHome(started.port, sharedDetached);
|
|
99
|
-
const claude = buildClaudeExportCommand(started.port, null);
|
|
100
|
-
const banner = formatStartBanner({
|
|
101
|
-
port: started.port,
|
|
102
|
-
pid: started.pid,
|
|
103
|
-
mode: "detached",
|
|
104
|
-
debug,
|
|
105
|
-
debugLogPath: currentDebugLogPath(debug),
|
|
106
|
-
codex,
|
|
107
|
-
pi
|
|
108
|
-
});
|
|
109
|
-
writeCommandOutput(opts, banner, {
|
|
110
|
-
status: "ok",
|
|
111
|
-
mode: "detached",
|
|
112
|
-
pid: started.pid,
|
|
113
|
-
port: started.port,
|
|
114
|
-
debug,
|
|
115
|
-
debug_log_path: currentDebugLogPath(debug),
|
|
116
|
-
url: `http://127.0.0.1:${started.port}`,
|
|
117
|
-
codex_home: codex?.outDir ?? null,
|
|
118
|
-
codex_export_command: codex?.exportCommand ?? null,
|
|
119
|
-
codex_env: codex ? buildCodexEnvBundle(codex.outDir).env : null,
|
|
120
|
-
codex_default_model: codex?.defaultModel ?? null,
|
|
121
|
-
codex_model_count: codex?.modelCount ?? null,
|
|
122
|
-
pi_home: pi?.outDir ?? null,
|
|
123
|
-
pi_config_path: pi?.configPath ?? null,
|
|
124
|
-
pi_mirror_path: pi?.mirrorPath ?? null,
|
|
125
|
-
pi_backup_path: pi?.backupPath ?? null,
|
|
126
|
-
pi_model_count: pi?.modelCount ?? null,
|
|
127
|
-
claude_export_command: claude.command,
|
|
128
|
-
claude_env: claude.bundle.env,
|
|
129
|
-
claude_defaults: claude.defaults
|
|
130
|
-
});
|
|
81
|
+
const started = await spawnDetachedDaemon({ debug });
|
|
82
|
+
await emitDetachedStartOutput(opts, started, debug, "detached");
|
|
131
83
|
return;
|
|
132
84
|
}
|
|
133
85
|
// Foreground path: interactively prompt for login if needed.
|
|
@@ -205,6 +157,46 @@ export function register(program) {
|
|
|
205
157
|
claude_defaults: claude.defaults
|
|
206
158
|
});
|
|
207
159
|
});
|
|
160
|
+
program
|
|
161
|
+
.command("restart")
|
|
162
|
+
.description("Restart the daemon, preserving its current port and debug mode")
|
|
163
|
+
.option("--debug", "Force debug endpoints on for the restarted daemon")
|
|
164
|
+
.option("--no-codex", "Skip generating ~/.copillm/codex/ for Codex CLI")
|
|
165
|
+
.option("--codex-model <id>", "Default Codex model slug")
|
|
166
|
+
.option("--no-pi", "Skip generating the copillm-owned pi models.json for pi coding agent")
|
|
167
|
+
.option("--json", "JSON output")
|
|
168
|
+
.action(async (opts) => {
|
|
169
|
+
const forceDebug = resolveCopillmDebug(opts.debug);
|
|
170
|
+
if (isDevModeActive()) {
|
|
171
|
+
process.stderr.write(`dev mode: isolated COPILLM_HOME ${displayHomePath(getCopillmHome())}\n`);
|
|
172
|
+
}
|
|
173
|
+
// Restart always brings the daemon back up detached, so fail fast on
|
|
174
|
+
// missing credentials rather than letting the detached child die silently.
|
|
175
|
+
const authState = await inspectStoredCredential();
|
|
176
|
+
if (!authState.stored) {
|
|
177
|
+
throw new Error("Not authenticated. Run `copillm auth login` first.");
|
|
178
|
+
}
|
|
179
|
+
// Detect the running daemon's debug mode *before* stopping it — `/_debug`
|
|
180
|
+
// is only reachable while the daemon is up.
|
|
181
|
+
const lockSnapshot = toDaemonLockState(inspectLock());
|
|
182
|
+
const detectedDebug = lockSnapshot.state === "running" ? await probeDebugEndpoint(lockSnapshot.port) : false;
|
|
183
|
+
const decision = resolveRestartDecision({ lock: lockSnapshot, detectedDebug, forceDebug });
|
|
184
|
+
enableRuntimeDebug(decision.debug);
|
|
185
|
+
if (decision.action === "restart" && decision.previousPid !== null) {
|
|
186
|
+
await stopByPid(decision.previousPid);
|
|
187
|
+
}
|
|
188
|
+
else if (decision.clearStaleLock) {
|
|
189
|
+
releaseLock();
|
|
190
|
+
}
|
|
191
|
+
// Mirror `stop`: clearing the Claude gateway cache on the way down keeps
|
|
192
|
+
// Claude Code from pinning a stale model picker across the restart.
|
|
193
|
+
const cache = clearClaudeGatewayCache();
|
|
194
|
+
const started = await spawnDetachedDaemon({ debug: decision.debug, forcePort: decision.forcePort });
|
|
195
|
+
await emitDetachedStartOutput(opts, started, decision.debug, "restarted", {
|
|
196
|
+
previousPid: decision.previousPid,
|
|
197
|
+
claudeCache: cache
|
|
198
|
+
});
|
|
199
|
+
});
|
|
208
200
|
program
|
|
209
201
|
.command("daemon")
|
|
210
202
|
.description("Internal background command")
|
|
@@ -384,6 +376,67 @@ export function register(program) {
|
|
|
384
376
|
}
|
|
385
377
|
});
|
|
386
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Emit the human banner + JSON payload for a daemon that was just brought up in
|
|
381
|
+
* the background. Shared by `copillm start --detach` (`mode: "detached"`) and
|
|
382
|
+
* `copillm restart` (`mode: "restarted"`), so both surface the same codex/pi/
|
|
383
|
+
* claude wiring. `extra` carries restart-only fields (`previous_pid`, the
|
|
384
|
+
* Claude cache-clear result).
|
|
385
|
+
*/
|
|
386
|
+
async function emitDetachedStartOutput(opts, started, debug, mode, extra) {
|
|
387
|
+
const shared = await loadSharedStartContextIfNeeded(opts);
|
|
388
|
+
const codex = opts.codex === false ? null : await refreshCodexHome(started.port, opts.codexModel ?? null, shared);
|
|
389
|
+
const pi = opts.pi === false ? null : await refreshPiHome(started.port, shared);
|
|
390
|
+
const claude = buildClaudeExportCommand(started.port, null);
|
|
391
|
+
const banner = formatStartBanner({
|
|
392
|
+
port: started.port,
|
|
393
|
+
pid: started.pid,
|
|
394
|
+
mode,
|
|
395
|
+
debug,
|
|
396
|
+
debugLogPath: currentDebugLogPath(debug),
|
|
397
|
+
codex,
|
|
398
|
+
pi
|
|
399
|
+
});
|
|
400
|
+
const payload = {
|
|
401
|
+
status: "ok",
|
|
402
|
+
mode,
|
|
403
|
+
pid: started.pid,
|
|
404
|
+
port: started.port,
|
|
405
|
+
debug,
|
|
406
|
+
debug_log_path: currentDebugLogPath(debug),
|
|
407
|
+
url: `http://127.0.0.1:${started.port}`,
|
|
408
|
+
codex_home: codex?.outDir ?? null,
|
|
409
|
+
codex_export_command: codex?.exportCommand ?? null,
|
|
410
|
+
codex_env: codex ? buildCodexEnvBundle(codex.outDir).env : null,
|
|
411
|
+
codex_default_model: codex?.defaultModel ?? null,
|
|
412
|
+
codex_model_count: codex?.modelCount ?? null,
|
|
413
|
+
pi_home: pi?.outDir ?? null,
|
|
414
|
+
pi_config_path: pi?.configPath ?? null,
|
|
415
|
+
pi_mirror_path: pi?.mirrorPath ?? null,
|
|
416
|
+
pi_backup_path: pi?.backupPath ?? null,
|
|
417
|
+
pi_model_count: pi?.modelCount ?? null,
|
|
418
|
+
claude_export_command: claude.command,
|
|
419
|
+
claude_env: claude.bundle.env,
|
|
420
|
+
claude_defaults: claude.defaults
|
|
421
|
+
};
|
|
422
|
+
if (extra?.previousPid !== undefined) {
|
|
423
|
+
payload.previous_pid = extra.previousPid;
|
|
424
|
+
}
|
|
425
|
+
if (extra?.claudeCache !== undefined) {
|
|
426
|
+
payload.claude_cache = extra.claudeCache;
|
|
427
|
+
}
|
|
428
|
+
writeCommandOutput(opts, banner, payload);
|
|
429
|
+
}
|
|
430
|
+
/** Narrow the lock inspection down to the shape `resolveRestartDecision` needs. */
|
|
431
|
+
function toDaemonLockState(lockState) {
|
|
432
|
+
if (lockState.state === "running") {
|
|
433
|
+
return { state: "running", pid: lockState.lock.pid, port: lockState.lock.port };
|
|
434
|
+
}
|
|
435
|
+
if (lockState.state === "stale") {
|
|
436
|
+
return { state: "stale" };
|
|
437
|
+
}
|
|
438
|
+
return { state: "missing" };
|
|
439
|
+
}
|
|
387
440
|
/**
|
|
388
441
|
* Load the shared credential/config/discovery context for `copillm start`'s
|
|
389
442
|
* codex + pi init steps, ONLY when at least one of them is going to run.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision logic for `copillm restart`.
|
|
3
|
+
*
|
|
4
|
+
* `restart` brings the daemon back up on the settings it is *actually* running
|
|
5
|
+
* on — its port and its debug mode — without persisting anything to disk. Port
|
|
6
|
+
* comes straight from the live lock; debug mode is detected at runtime by
|
|
7
|
+
* probing `/_debug` (only mounted when debug is on). The home/dev scope is
|
|
8
|
+
* inherent: the lock is per-`COPILLM_HOME`, so `copillm --dev restart` only
|
|
9
|
+
* ever touches the dev daemon.
|
|
10
|
+
*
|
|
11
|
+
* Keeping the decision pure (no I/O) makes every branch — running, stale,
|
|
12
|
+
* missing, debug-detected, debug-forced — trivially unit-testable.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveRestartDecision(input) {
|
|
15
|
+
const { lock, detectedDebug, forceDebug } = input;
|
|
16
|
+
if (lock.state === "running") {
|
|
17
|
+
return {
|
|
18
|
+
action: "restart",
|
|
19
|
+
previousPid: lock.pid,
|
|
20
|
+
forcePort: lock.port,
|
|
21
|
+
clearStaleLock: false,
|
|
22
|
+
// `--debug` forces it on; otherwise preserve whatever the running daemon had.
|
|
23
|
+
debug: forceDebug || detectedDebug
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
action: "start_fresh",
|
|
28
|
+
previousPid: null,
|
|
29
|
+
forcePort: null,
|
|
30
|
+
clearStaleLock: lock.state === "stale",
|
|
31
|
+
// Nothing was running, so there is no debug state to preserve — only the
|
|
32
|
+
// explicit `--debug` request can turn it on.
|
|
33
|
+
debug: forceDebug
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { waitForDaemonReady } from "./probes.js";
|
|
3
|
+
import { buildSelfSpawnCommand } from "./selfSpawn.js";
|
|
4
|
+
import { daemonSpawnEnv } from "./spawnEnv.js";
|
|
5
|
+
/**
|
|
6
|
+
* Spawn the daemon as a detached background process and wait until it is
|
|
7
|
+
* reachable. Shared by `copillm start --detach` and `copillm restart` so the
|
|
8
|
+
* two paths can never drift in how they launch the background daemon.
|
|
9
|
+
*
|
|
10
|
+
* `forcePort` pins the daemon back onto a specific port (used by `restart` to
|
|
11
|
+
* rebind the port the previous daemon was running on); when null the daemon
|
|
12
|
+
* falls back to the normal configured-port scan.
|
|
13
|
+
*/
|
|
14
|
+
export async function spawnDetachedDaemon(input) {
|
|
15
|
+
const daemonCommand = buildSelfSpawnCommand("daemon", input.debug ? ["--debug"] : []);
|
|
16
|
+
const child = spawn(daemonCommand.command, daemonCommand.args, {
|
|
17
|
+
detached: true,
|
|
18
|
+
stdio: "ignore",
|
|
19
|
+
env: daemonSpawnEnv(input.debug, { port: input.forcePort ?? null })
|
|
20
|
+
});
|
|
21
|
+
child.unref();
|
|
22
|
+
const started = await waitForDaemonReady(child.pid ?? null, 8_000);
|
|
23
|
+
if (!started) {
|
|
24
|
+
throw new Error("Detached daemon start timed out.");
|
|
25
|
+
}
|
|
26
|
+
return started;
|
|
27
|
+
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { debugLogPath } from "../../config/home.js";
|
|
2
2
|
import { currentDebugLogPath } from "../shared/debug.js";
|
|
3
|
-
export function daemonSpawnEnv(debug) {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export function daemonSpawnEnv(debug, options) {
|
|
4
|
+
// Always return a fresh object so callers that inject a port (e.g. `restart`
|
|
5
|
+
// pinning the daemon back onto its previous port) never mutate the live
|
|
6
|
+
// `process.env`.
|
|
7
|
+
const env = debug
|
|
8
|
+
? {
|
|
9
|
+
...process.env,
|
|
10
|
+
COPILLM_LOG_LEVEL: "debug",
|
|
11
|
+
COPILLM_LOG_FILE: currentDebugLogPath(true) ?? debugLogPath()
|
|
12
|
+
}
|
|
13
|
+
: { ...process.env };
|
|
14
|
+
if (options?.port != null) {
|
|
15
|
+
env.COPILLM_PORT = String(options.port);
|
|
6
16
|
}
|
|
7
|
-
return
|
|
8
|
-
...process.env,
|
|
9
|
-
COPILLM_LOG_LEVEL: "debug",
|
|
10
|
-
COPILLM_LOG_FILE: currentDebugLogPath(true) ?? debugLogPath()
|
|
11
|
-
};
|
|
17
|
+
return env;
|
|
12
18
|
}
|
|
@@ -6,7 +6,7 @@ export function displayHomePath(p) {
|
|
|
6
6
|
return p;
|
|
7
7
|
}
|
|
8
8
|
export function formatStartBanner(input) {
|
|
9
|
-
const verb = input.mode === "foreground" ? "listening on" : "running on";
|
|
9
|
+
const verb = input.mode === "foreground" ? "listening on" : input.mode === "restarted" ? "restarted on" : "running on";
|
|
10
10
|
const lines = [];
|
|
11
11
|
const debugSuffix = input.debug ? " [debug]" : "";
|
|
12
12
|
const modeSuffix = input.mode === "already_running" ? " (already running)" : "";
|
package/dist/cli/packageInfo.js
CHANGED
|
@@ -14,6 +14,42 @@ export function writeFileSecureAtomic(filePath, content, mode) {
|
|
|
14
14
|
replaceFile(tempPath, filePath);
|
|
15
15
|
applyModeIfSupported(filePath, mode);
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Atomically write a batch of files with two-phase commit semantics. Phase 1
|
|
19
|
+
* stages every file to a temp path; if any staging write throws, all temps
|
|
20
|
+
* written so far are removed and the error is rethrown, so nothing is
|
|
21
|
+
* committed. Phase 2 renames each staged temp into place — by then all content
|
|
22
|
+
* is safely on disk, so a partial commit is far less likely than a mid-write
|
|
23
|
+
* failure (the failure mode this replaces). Use this when several files must
|
|
24
|
+
* land together (e.g. an agent config fan-out).
|
|
25
|
+
*/
|
|
26
|
+
export function writeFilesSecureAtomic(writes) {
|
|
27
|
+
const staged = [];
|
|
28
|
+
try {
|
|
29
|
+
for (const write of writes) {
|
|
30
|
+
ensureSecureDirectory(path.dirname(write.path));
|
|
31
|
+
const tempPath = `${write.path}.tmp-${process.pid}-${Date.now()}-${staged.length}`;
|
|
32
|
+
fs.writeFileSync(tempPath, write.content, { mode: write.mode });
|
|
33
|
+
applyModeIfSupported(tempPath, write.mode);
|
|
34
|
+
staged.push({ tempPath, finalPath: write.path, mode: write.mode });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
for (const item of staged) {
|
|
39
|
+
try {
|
|
40
|
+
fs.unlinkSync(item.tempPath);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// best effort — nothing was committed, leftover temps are harmless
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
for (const item of staged) {
|
|
49
|
+
replaceFile(item.tempPath, item.finalPath);
|
|
50
|
+
applyModeIfSupported(item.finalPath, item.mode);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
17
53
|
export function applyModeIfSupported(targetPath, mode) {
|
|
18
54
|
if (process.platform === "win32") {
|
|
19
55
|
return;
|
package/dist/server/errors.js
CHANGED
|
@@ -12,6 +12,14 @@ export class InvalidRequestShapeError extends Error {
|
|
|
12
12
|
this.name = "InvalidRequestShapeError";
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
export class RequestBodyTooLargeError extends Error {
|
|
16
|
+
maxBytes;
|
|
17
|
+
constructor(maxBytes) {
|
|
18
|
+
super(`Request body exceeds the maximum allowed size of ${maxBytes} bytes.`);
|
|
19
|
+
this.maxBytes = maxBytes;
|
|
20
|
+
this.name = "RequestBodyTooLargeError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
15
23
|
export async function readUpstreamError(response) {
|
|
16
24
|
const contentType = response.headers.get("content-type");
|
|
17
25
|
let text;
|
package/dist/server/proxy.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { singleAccountResolver } from "./accountResolver.js";
|
|
4
4
|
import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson } from "./requestLifecycle.js";
|
|
5
|
-
import { InvalidRequestShapeError, JsonRequestParseError } from "./errors.js";
|
|
5
|
+
import { InvalidRequestShapeError, JsonRequestParseError, RequestBodyTooLargeError } from "./errors.js";
|
|
6
6
|
import { ProtocolTranslationError } from "../translation/openaiAnthropic.js";
|
|
7
7
|
import { handleHealthz, handleLivez } from "./routes/health.js";
|
|
8
8
|
import { handleModels } from "./routes/models.js";
|
|
@@ -119,6 +119,10 @@ export async function startProxyServer(input) {
|
|
|
119
119
|
safeSendJson(res, 400, { error: "invalid_request_json", detail: error.message });
|
|
120
120
|
return;
|
|
121
121
|
}
|
|
122
|
+
if (error instanceof RequestBodyTooLargeError) {
|
|
123
|
+
safeSendJson(res, 413, { error: "payload_too_large", detail: error.message });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
122
126
|
if (error instanceof InvalidRequestShapeError) {
|
|
123
127
|
safeSendJson(res, 400, { error: "invalid_request_shape", detail: error.message });
|
|
124
128
|
return;
|
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
import { stripOneMillionAlias } from "../../translation/openaiAnthropic.js";
|
|
2
2
|
import { isValidAccountId } from "../../config/accountId.js";
|
|
3
|
-
import { JsonRequestParseError } from "../errors.js";
|
|
4
|
-
|
|
3
|
+
import { JsonRequestParseError, RequestBodyTooLargeError } from "../errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* Default cap on a single request body. The daemon buffers the whole body in
|
|
6
|
+
* memory before forwarding, so an unbounded read is an OOM vector even on a
|
|
7
|
+
* loopback-only socket (a runaway agent or a pathological context). 32 MiB is
|
|
8
|
+
* far above any real chat/completions payload while still bounding memory.
|
|
9
|
+
* Override with `COPILLM_MAX_REQUEST_BYTES` (a positive integer count of bytes).
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_MAX_REQUEST_BYTES = 32 * 1024 * 1024;
|
|
12
|
+
export function maxRequestBytes() {
|
|
13
|
+
const raw = process.env.COPILLM_MAX_REQUEST_BYTES;
|
|
14
|
+
if (raw === undefined || raw.trim().length === 0) {
|
|
15
|
+
return DEFAULT_MAX_REQUEST_BYTES;
|
|
16
|
+
}
|
|
17
|
+
const parsed = Number(raw.trim());
|
|
18
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
19
|
+
return DEFAULT_MAX_REQUEST_BYTES;
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
export async function readJson(req, maxBytes = maxRequestBytes()) {
|
|
5
24
|
const chunks = [];
|
|
25
|
+
let total = 0;
|
|
6
26
|
for await (const chunk of req) {
|
|
7
|
-
|
|
27
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
28
|
+
total += buffer.length;
|
|
29
|
+
if (total > maxBytes) {
|
|
30
|
+
// Stop accumulating immediately so an oversized body can't be buffered
|
|
31
|
+
// into memory; throwing ends the async iteration and tears down the read.
|
|
32
|
+
throw new RequestBodyTooLargeError(maxBytes);
|
|
33
|
+
}
|
|
34
|
+
chunks.push(buffer);
|
|
8
35
|
}
|
|
9
36
|
if (chunks.length === 0) {
|
|
10
37
|
return {};
|
|
@@ -25,6 +25,14 @@ export function anthropicToOpenAI(body) {
|
|
|
25
25
|
if (request.temperature !== undefined) {
|
|
26
26
|
translated.temperature = request.temperature;
|
|
27
27
|
}
|
|
28
|
+
if (request.top_p !== undefined) {
|
|
29
|
+
translated.top_p = request.top_p;
|
|
30
|
+
}
|
|
31
|
+
if (request.stop_sequences !== undefined) {
|
|
32
|
+
// Anthropic `stop_sequences` (array of strings) maps 1:1 onto OpenAI's
|
|
33
|
+
// `stop` field, which Copilot's chat/completions endpoint accepts.
|
|
34
|
+
translated.stop = request.stop_sequences;
|
|
35
|
+
}
|
|
28
36
|
if (request.stream !== undefined) {
|
|
29
37
|
translated.stream = request.stream;
|
|
30
38
|
}
|
|
@@ -102,6 +110,12 @@ function parseAnthropicRequest(body) {
|
|
|
102
110
|
if (body.temperature !== undefined) {
|
|
103
111
|
parsed.temperature = body.temperature;
|
|
104
112
|
}
|
|
113
|
+
if (body.top_p !== undefined) {
|
|
114
|
+
parsed.top_p = body.top_p;
|
|
115
|
+
}
|
|
116
|
+
if (body.stop_sequences !== undefined) {
|
|
117
|
+
parsed.stop_sequences = body.stop_sequences;
|
|
118
|
+
}
|
|
105
119
|
if (body.stream !== undefined) {
|
|
106
120
|
parsed.stream = body.stream;
|
|
107
121
|
}
|
|
@@ -195,11 +209,11 @@ function translateAssistantMessageBlocks(blocks) {
|
|
|
195
209
|
}
|
|
196
210
|
function translateUserMessageBlocks(blocks) {
|
|
197
211
|
const translated = [];
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
201
|
-
translated.push({ role: "user", content:
|
|
202
|
-
|
|
212
|
+
let parts = [];
|
|
213
|
+
const flushParts = () => {
|
|
214
|
+
if (parts.length > 0) {
|
|
215
|
+
translated.push({ role: "user", content: collapseUserParts(parts) });
|
|
216
|
+
parts = [];
|
|
203
217
|
}
|
|
204
218
|
};
|
|
205
219
|
for (const block of blocks) {
|
|
@@ -210,14 +224,18 @@ function translateUserMessageBlocks(blocks) {
|
|
|
210
224
|
if (typeof block.text !== "string") {
|
|
211
225
|
throw new ProtocolTranslationError("invalid_text_block", "Anthropic text block must include string text.");
|
|
212
226
|
}
|
|
213
|
-
|
|
227
|
+
parts.push({ type: "text", text: block.text });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (block.type === "image") {
|
|
231
|
+
parts.push({ type: "image_url", image_url: { url: translateImageSource(block.source) } });
|
|
214
232
|
continue;
|
|
215
233
|
}
|
|
216
234
|
if (block.type === "tool_result") {
|
|
217
235
|
if (typeof block.tool_use_id !== "string" || block.tool_use_id.length === 0) {
|
|
218
236
|
throw new ProtocolTranslationError("invalid_tool_result", "Anthropic tool_result requires non-empty tool_use_id.");
|
|
219
237
|
}
|
|
220
|
-
|
|
238
|
+
flushParts();
|
|
221
239
|
const rawContent = translateToolResultContent(block.content);
|
|
222
240
|
const content = block.is_error ? `[tool_error] ${rawContent}` : rawContent;
|
|
223
241
|
translated.push({
|
|
@@ -232,12 +250,53 @@ function translateUserMessageBlocks(blocks) {
|
|
|
232
250
|
}
|
|
233
251
|
throw new ProtocolTranslationError("unsupported_block", `Unsupported Anthropic user block type: ${block.type}.`);
|
|
234
252
|
}
|
|
235
|
-
|
|
253
|
+
flushParts();
|
|
236
254
|
if (translated.length === 0) {
|
|
237
255
|
translated.push({ role: "user", content: "" });
|
|
238
256
|
}
|
|
239
257
|
return translated;
|
|
240
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Collapse the accumulated content parts into an OpenAI message `content`.
|
|
261
|
+
* Text-only messages stay a plain newline-joined string (the historical shape
|
|
262
|
+
* every text request produced); once an image is present we must emit the
|
|
263
|
+
* OpenAI multi-part content array so the `image_url` parts survive to upstream.
|
|
264
|
+
*/
|
|
265
|
+
function collapseUserParts(parts) {
|
|
266
|
+
const hasImage = parts.some((part) => part.type === "image_url");
|
|
267
|
+
if (!hasImage) {
|
|
268
|
+
return parts.map((part) => (part.type === "text" ? part.text : "")).join("\n");
|
|
269
|
+
}
|
|
270
|
+
return parts;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Translate an Anthropic image `source` into an OpenAI `image_url.url`. A
|
|
274
|
+
* base64 source becomes a `data:` URL; a url source passes its URL through.
|
|
275
|
+
* Copilot's chat/completions endpoint accepts both for vision-capable models;
|
|
276
|
+
* sending an image to a non-vision model surfaces as an upstream error, which
|
|
277
|
+
* is the correct place for that signal — translation stays capability-agnostic.
|
|
278
|
+
*/
|
|
279
|
+
function translateImageSource(source) {
|
|
280
|
+
if (!isObject(source) || typeof source.type !== "string") {
|
|
281
|
+
throw new ProtocolTranslationError("invalid_image_source", "Anthropic image block requires a source object with a type.");
|
|
282
|
+
}
|
|
283
|
+
if (source.type === "base64") {
|
|
284
|
+
if (typeof source.media_type !== "string" || source.media_type.length === 0) {
|
|
285
|
+
throw new ProtocolTranslationError("invalid_image_source", "Anthropic base64 image source requires a media_type.");
|
|
286
|
+
}
|
|
287
|
+
if (typeof source.data !== "string" || source.data.length === 0) {
|
|
288
|
+
throw new ProtocolTranslationError("invalid_image_source", "Anthropic base64 image source requires data.");
|
|
289
|
+
}
|
|
290
|
+
return `data:${source.media_type};base64,${source.data}`;
|
|
291
|
+
}
|
|
292
|
+
if (source.type === "url") {
|
|
293
|
+
if (typeof source.url !== "string" || source.url.length === 0) {
|
|
294
|
+
throw new ProtocolTranslationError("invalid_image_source", "Anthropic url image source requires a non-empty url.");
|
|
295
|
+
}
|
|
296
|
+
return source.url;
|
|
297
|
+
}
|
|
298
|
+
throw new ProtocolTranslationError("unsupported_image_source", `Unsupported Anthropic image source type: ${String(source.type)}.`);
|
|
299
|
+
}
|
|
241
300
|
function translateToolResultContent(content) {
|
|
242
301
|
if (typeof content === "string") {
|
|
243
302
|
return content;
|