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 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 { writeFileSecureAtomic } from "../config/fsSecurity.js";
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. By this point all validation passed and the renderer
26
- // produced complete file bodies failures here are IO errors, not user
27
- // input problems.
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,
@@ -227,10 +227,16 @@ export async function runAuthSwitch(opts, accountId) {
227
227
  try {
228
228
  assertValidAccountId(accountId);
229
229
  const index = switchDefaultAccount(accountId);
230
- writeCommandOutput(opts, `Default account is now "${index.defaultAccount}".`, {
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, waitForDaemonReady, warnIfDebugRequestedButInactive } from "../daemon/probes.js";
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 { buildSelfSpawnCommand } from "../daemon/selfSpawn.js";
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 daemonCommand = buildSelfSpawnCommand("daemon");
83
- if (debug) {
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
- if (!debug) {
5
- return process.env;
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)" : "";
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  const FALLBACK_PACKAGE_INFO = {
3
3
  name: "copillm",
4
- version: "0.3.0"
4
+ version: "0.4.0"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
@@ -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;
@@ -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;
@@ -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
- export async function readJson(req) {
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
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
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
- const textSegments = [];
199
- const flushText = () => {
200
- if (textSegments.length > 0) {
201
- translated.push({ role: "user", content: textSegments.join("\n") });
202
- textSegments.length = 0;
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
- textSegments.push(block.text);
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
- flushText();
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
- flushText();
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",