copillm 0.1.2 → 0.1.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/README.md +1 -1
- package/dist/agentconfig/load.js +4 -7
- package/dist/agentconfig/render.js +191 -85
- package/dist/agentconfig/schema.js +7 -9
- package/dist/cli/configCommands.js +15 -2
- package/dist/cli/launchAgent.js +8 -0
- package/dist/cli/processSafetyNet.js +52 -0
- package/dist/cli/resolveAgent.js +4 -2
- package/dist/cli.js +45 -0
- package/dist/server/proxy.js +69 -13
- package/dist/server/requestLifecycle.js +115 -0
- package/dist/translation/streamingOpenAIToAnthropic.js +77 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# copillm
|
|
2
2
|
|
|
3
3
|
[](https://github.com/jcjc-dev/copillm/actions/workflows/pr-gate.yml)
|
|
4
|
-
[](https://github.com/jcjc-dev/copillm/actions/workflows/upstream-e2e.yml)
|
|
5
5
|
[](https://www.npmjs.com/package/copillm)
|
|
6
6
|
[](https://www.npmjs.com/package/copillm)
|
|
7
7
|
|
package/dist/agentconfig/load.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { parse as parseToml, TomlError } from "smol-toml";
|
|
4
4
|
import { ZodError } from "zod";
|
|
5
5
|
import { getCopillmHome } from "../config/home.js";
|
|
6
|
-
import { AgentTomlSchema
|
|
6
|
+
import { AgentTomlSchema } from "./schema.js";
|
|
7
7
|
export class AgentConfigError extends Error {
|
|
8
8
|
constructor(message) {
|
|
9
9
|
super(message);
|
|
@@ -97,16 +97,13 @@ function mergeAndResolve(input) {
|
|
|
97
97
|
const instructions = instructionsBody !== null && instructionsBody.trim().length > 0
|
|
98
98
|
? { body: instructionsBody }
|
|
99
99
|
: null;
|
|
100
|
-
// Merge mcp.servers map; later layers replace earlier same-named entries
|
|
101
|
-
//
|
|
100
|
+
// Merge mcp.servers map; later layers replace earlier same-named entries.
|
|
101
|
+
// Defaults are always-on: a profile may override a default by name but
|
|
102
|
+
// cannot remove it.
|
|
102
103
|
const servers = {};
|
|
103
104
|
for (const layer of layers) {
|
|
104
105
|
const layerServers = layer.mcp?.servers ?? {};
|
|
105
106
|
for (const [name, value] of Object.entries(layerServers)) {
|
|
106
|
-
if ("inherit" in value && value.inherit === UNSET_SENTINEL) {
|
|
107
|
-
delete servers[name];
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
107
|
servers[name] = expandEnv(value);
|
|
111
108
|
}
|
|
112
109
|
}
|
|
@@ -1,33 +1,43 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import fs from "node:fs";
|
|
3
|
-
import
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parse as parseToml, stringify as stringifyToml, TomlError } from "smol-toml";
|
|
4
5
|
import { AgentConfigError } from "./load.js";
|
|
6
|
+
import { getCopillmHome } from "../config/home.js";
|
|
5
7
|
import { HASH_COMMENT, HTML_COMMENT, upsertManagedBlock } from "./markerBlock.js";
|
|
6
8
|
// ─── Codex ────────────────────────────────────────────────────────────────
|
|
7
9
|
export function renderCodex(input) {
|
|
8
10
|
const writes = [];
|
|
9
11
|
const notes = [];
|
|
10
|
-
// 1. MCP block inside <CODEX_HOME>/config.toml. The file is generated by
|
|
11
|
-
// src/codex/init.ts and ends with a blank line; we append the marker
|
|
12
|
-
// section after it.
|
|
13
12
|
const codexConfigPath = path.join(input.codexHomeDir, "config.toml");
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
});
|
|
13
|
+
const existing = fs.existsSync(codexConfigPath) ? fs.readFileSync(codexConfigPath, "utf8") : "";
|
|
14
|
+
let next = existing;
|
|
15
|
+
if (input.codexBaseConfigSourcePath) {
|
|
16
|
+
if (fs.existsSync(input.codexBaseConfigSourcePath)) {
|
|
17
|
+
const source = fs.readFileSync(input.codexBaseConfigSourcePath, "utf8");
|
|
18
|
+
next = mergeCodexBaseConfig(next, source, codexConfigPath, input.codexBaseConfigSourcePath);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
notes.push(`Codex source config not found at ${input.codexBaseConfigSourcePath}; ` +
|
|
22
|
+
`run \`copillm start\` or \`copillm codex\` once first.`);
|
|
25
23
|
}
|
|
26
24
|
}
|
|
27
|
-
|
|
25
|
+
const mcpToml = renderCodexMcpToml(input.resolved.mcpServers);
|
|
26
|
+
if (next.length === 0 && mcpToml.length > 0) {
|
|
28
27
|
notes.push(`Codex config not found at ${codexConfigPath}; skipping MCP injection. ` +
|
|
29
28
|
`Run \`copillm start\` first.`);
|
|
30
29
|
}
|
|
30
|
+
else {
|
|
31
|
+
next = upsertManagedBlock(next, mcpToml, HASH_COMMENT);
|
|
32
|
+
}
|
|
33
|
+
if (next !== existing) {
|
|
34
|
+
writes.push({
|
|
35
|
+
path: codexConfigPath,
|
|
36
|
+
content: next,
|
|
37
|
+
mode: 0o600,
|
|
38
|
+
description: "Codex config.toml"
|
|
39
|
+
});
|
|
40
|
+
}
|
|
31
41
|
// 2. AGENTS.md instruction block.
|
|
32
42
|
if (input.resolved.instructions) {
|
|
33
43
|
const agentsPath = path.join(input.codexHomeDir, "AGENTS.md");
|
|
@@ -44,6 +54,53 @@ export function renderCodex(input) {
|
|
|
44
54
|
}
|
|
45
55
|
return { writes, envOverlay: {}, cliArgs: [], notes };
|
|
46
56
|
}
|
|
57
|
+
function mergeCodexBaseConfig(targetRaw, sourceRaw, targetPath, sourcePath) {
|
|
58
|
+
const targetDoc = parseCodexToml(targetRaw, targetPath);
|
|
59
|
+
const sourceDoc = parseCodexToml(sourceRaw, sourcePath);
|
|
60
|
+
const providerId = getStringField(sourceDoc, "model_provider");
|
|
61
|
+
if (!providerId) {
|
|
62
|
+
throw new AgentConfigError(`Codex source config at ${sourcePath} is missing model_provider.`);
|
|
63
|
+
}
|
|
64
|
+
for (const key of ["model", "model_provider", "model_reasoning_effort", "approvals_reviewer"]) {
|
|
65
|
+
if (key in sourceDoc) {
|
|
66
|
+
targetDoc[key] = sourceDoc[key];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const sourceProviders = asRecord(sourceDoc.model_providers);
|
|
70
|
+
const selectedProvider = asRecord(sourceProviders?.[providerId]);
|
|
71
|
+
if (!selectedProvider) {
|
|
72
|
+
throw new AgentConfigError(`Codex source config at ${sourcePath} is missing [model_providers.${providerId}].`);
|
|
73
|
+
}
|
|
74
|
+
const targetProviders = asRecord(targetDoc.model_providers) ?? {};
|
|
75
|
+
targetProviders[providerId] = selectedProvider;
|
|
76
|
+
targetDoc.model_providers = targetProviders;
|
|
77
|
+
return `${stringifyToml(targetDoc).trimEnd()}\n`;
|
|
78
|
+
}
|
|
79
|
+
function parseCodexToml(raw, filePath) {
|
|
80
|
+
if (raw.trim().length === 0) {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const parsed = parseToml(raw);
|
|
85
|
+
return asRecord(parsed) ?? {};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof TomlError) {
|
|
89
|
+
throw new AgentConfigError(`Failed to parse ${filePath}: ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function asRecord(value) {
|
|
95
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function getStringField(doc, key) {
|
|
101
|
+
const value = doc[key];
|
|
102
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
103
|
+
}
|
|
47
104
|
function renderCodexMcpToml(servers) {
|
|
48
105
|
if (Object.keys(servers).length === 0)
|
|
49
106
|
return "";
|
|
@@ -79,93 +136,134 @@ const TOML_IDENT = /^[A-Za-z0-9_-]+$/;
|
|
|
79
136
|
function isValidTomlIdent(name) {
|
|
80
137
|
return TOML_IDENT.test(name);
|
|
81
138
|
}
|
|
139
|
+
// ─── Claude Code ──────────────────────────────────────────────────────────
|
|
140
|
+
/**
|
|
141
|
+
* Launcher mode writes a copillm-owned MCP config and returns --mcp-config.
|
|
142
|
+
* Native sync mode writes the user-level Claude config that Claude reads
|
|
143
|
+
* without a copillm wrapper.
|
|
144
|
+
*/
|
|
82
145
|
export function renderClaude(input) {
|
|
83
146
|
const writes = [];
|
|
84
147
|
const notes = [];
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
});
|
|
148
|
+
const cliArgs = [];
|
|
149
|
+
if (input.nativeSync) {
|
|
150
|
+
writes.push(...renderClaudeNativeWrites(input));
|
|
151
|
+
if (input.resolved.instructions) {
|
|
152
|
+
notes.push("Claude: instructions fan-out is unsupported (Claude has no out-of-tree " +
|
|
153
|
+
"instructions hook). Move guidance to ~/.claude/CLAUDE.md or your " +
|
|
154
|
+
"project's CLAUDE.md manually.");
|
|
155
|
+
}
|
|
156
|
+
return { writes, envOverlay: {}, cliArgs, notes };
|
|
95
157
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
158
|
+
const claudeDir = path.join(getCopillmHome(), "claude");
|
|
159
|
+
const mcpJsonPath = path.join(claudeDir, "mcp.json");
|
|
160
|
+
const serverCount = Object.keys(input.resolved.mcpServers).length;
|
|
161
|
+
if (serverCount > 0) {
|
|
162
|
+
const content = renderClaudeMcp(input.resolved.mcpServers);
|
|
163
|
+
const existing = fs.existsSync(mcpJsonPath) ? fs.readFileSync(mcpJsonPath, "utf8") : null;
|
|
164
|
+
if (existing !== content) {
|
|
102
165
|
writes.push({
|
|
103
|
-
path:
|
|
104
|
-
content
|
|
166
|
+
path: mcpJsonPath,
|
|
167
|
+
content,
|
|
105
168
|
mode: 0o600,
|
|
106
|
-
description: "
|
|
169
|
+
description: "Claude Code mcp.json (copillm-managed)"
|
|
107
170
|
});
|
|
108
171
|
}
|
|
172
|
+
cliArgs.push("--mcp-config", mcpJsonPath);
|
|
109
173
|
}
|
|
110
|
-
|
|
174
|
+
else if (fs.existsSync(mcpJsonPath)) {
|
|
175
|
+
// Profile no longer declares any servers — clear the stale file so we
|
|
176
|
+
// don't keep referencing dead config on the next launch.
|
|
177
|
+
fs.rmSync(mcpJsonPath, { force: true });
|
|
178
|
+
notes.push(`Removed stale ${mcpJsonPath} (no MCP servers in active profile).`);
|
|
179
|
+
}
|
|
180
|
+
if (input.resolved.instructions) {
|
|
181
|
+
notes.push("Claude: instructions fan-out is unsupported (Claude has no out-of-tree " +
|
|
182
|
+
"instructions hook). Move guidance to ~/.claude/CLAUDE.md or your " +
|
|
183
|
+
"project's CLAUDE.md manually.");
|
|
184
|
+
}
|
|
185
|
+
return { writes, envOverlay: {}, cliArgs, notes };
|
|
111
186
|
}
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
187
|
+
function renderClaudeNativeWrites(input) {
|
|
188
|
+
const writes = [];
|
|
189
|
+
const homeDir = userHomeDir();
|
|
190
|
+
const userConfigPath = path.join(homeDir, ".claude.json");
|
|
191
|
+
const settingsPath = path.join(homeDir, ".claude", "settings.json");
|
|
192
|
+
const manifestPath = path.join(getCopillmHome(), "claude", "native-mcp-manifest.json");
|
|
193
|
+
const serverNames = Object.keys(input.resolved.mcpServers);
|
|
194
|
+
const previousServerNames = readClaudeNativeManifest(manifestPath);
|
|
195
|
+
if (serverNames.length > 0 || previousServerNames.length > 0) {
|
|
196
|
+
const existing = readJsonObject(userConfigPath);
|
|
197
|
+
const mcpServers = asRecord(existing.mcpServers) ?? {};
|
|
198
|
+
for (const name of previousServerNames) {
|
|
199
|
+
if (!serverNames.includes(name)) {
|
|
200
|
+
delete mcpServers[name];
|
|
119
201
|
}
|
|
120
202
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
`Fix or remove the file and re-run.`);
|
|
203
|
+
for (const [name, server] of Object.entries(input.resolved.mcpServers)) {
|
|
204
|
+
mcpServers[name] = serverToClaudeShape(server);
|
|
124
205
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const userOwned = new Set(Object.keys(existing.mcpServers ?? {}).filter((n) => !managed.has(n)));
|
|
128
|
-
// Detect conflicts: a copillm-managed name collides with a user-owned name.
|
|
129
|
-
const newNames = Object.keys(servers);
|
|
130
|
-
for (const name of newNames) {
|
|
131
|
-
if (userOwned.has(name)) {
|
|
132
|
-
throw new AgentConfigError(`MCP server name "${name}" already exists in ${mcpJsonPath} and is owned by the user ` +
|
|
133
|
-
`(not previously managed by copillm). Rename one side, or move the user's entry ` +
|
|
134
|
-
`into agent.toml so copillm can manage it.`);
|
|
206
|
+
if (Object.keys(mcpServers).length > 0) {
|
|
207
|
+
existing.mcpServers = mcpServers;
|
|
135
208
|
}
|
|
209
|
+
else {
|
|
210
|
+
delete existing.mcpServers;
|
|
211
|
+
}
|
|
212
|
+
writes.push({
|
|
213
|
+
path: userConfigPath,
|
|
214
|
+
content: `${JSON.stringify(existing, null, 2)}\n`,
|
|
215
|
+
mode: 0o600,
|
|
216
|
+
description: "Claude Code user MCP config"
|
|
217
|
+
});
|
|
218
|
+
writes.push({
|
|
219
|
+
path: manifestPath,
|
|
220
|
+
content: `${JSON.stringify({ servers: serverNames }, null, 2)}\n`,
|
|
221
|
+
mode: 0o600,
|
|
222
|
+
description: "Claude Code native MCP manifest"
|
|
223
|
+
});
|
|
136
224
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (Object.keys(nextServers).length > 0) {
|
|
148
|
-
nextFile.mcpServers = nextServers;
|
|
225
|
+
if (input.env && Object.keys(input.env).length > 0) {
|
|
226
|
+
const settings = readJsonObject(settingsPath);
|
|
227
|
+
const env = asRecord(settings.env) ?? {};
|
|
228
|
+
settings.env = { ...env, ...input.env };
|
|
229
|
+
writes.push({
|
|
230
|
+
path: settingsPath,
|
|
231
|
+
content: `${JSON.stringify(settings, null, 2)}\n`,
|
|
232
|
+
mode: 0o600,
|
|
233
|
+
description: "Claude Code settings.json env block"
|
|
234
|
+
});
|
|
149
235
|
}
|
|
150
|
-
|
|
151
|
-
|
|
236
|
+
return writes;
|
|
237
|
+
}
|
|
238
|
+
function userHomeDir() {
|
|
239
|
+
return process.env.HOME ?? os.homedir();
|
|
240
|
+
}
|
|
241
|
+
function readClaudeNativeManifest(filePath) {
|
|
242
|
+
const doc = readJsonObject(filePath);
|
|
243
|
+
const servers = doc.servers;
|
|
244
|
+
if (!Array.isArray(servers)) {
|
|
245
|
+
return [];
|
|
152
246
|
}
|
|
153
|
-
|
|
154
|
-
|
|
247
|
+
return servers.filter((server) => typeof server === "string");
|
|
248
|
+
}
|
|
249
|
+
function readJsonObject(filePath) {
|
|
250
|
+
if (!fs.existsSync(filePath)) {
|
|
251
|
+
return {};
|
|
155
252
|
}
|
|
156
|
-
|
|
157
|
-
|
|
253
|
+
try {
|
|
254
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
255
|
+
return asRecord(parsed) ?? {};
|
|
158
256
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (fs.existsSync(mcpJsonPath) && fs.readFileSync(mcpJsonPath, "utf8") === serialized) {
|
|
162
|
-
return null;
|
|
257
|
+
catch (error) {
|
|
258
|
+
throw new AgentConfigError(`Failed to parse ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
163
259
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
260
|
+
}
|
|
261
|
+
function renderClaudeMcp(servers) {
|
|
262
|
+
const out = {};
|
|
263
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
264
|
+
out[name] = serverToClaudeShape(server);
|
|
167
265
|
}
|
|
168
|
-
return
|
|
266
|
+
return `${JSON.stringify({ mcpServers: out }, null, 2)}\n`;
|
|
169
267
|
}
|
|
170
268
|
function serverToClaudeShape(server) {
|
|
171
269
|
if (server.transport === "stdio") {
|
|
@@ -305,10 +403,18 @@ export function planRender(opts, load) {
|
|
|
305
403
|
if (!opts.codexHomeDir) {
|
|
306
404
|
throw new AgentConfigError("renderCodex requires codexHomeDir");
|
|
307
405
|
}
|
|
308
|
-
return renderCodex({
|
|
406
|
+
return renderCodex({
|
|
407
|
+
...baseInput,
|
|
408
|
+
codexHomeDir: opts.codexHomeDir,
|
|
409
|
+
codexBaseConfigSourcePath: opts.codexBaseConfigSourcePath
|
|
410
|
+
});
|
|
309
411
|
}
|
|
310
412
|
case "claude":
|
|
311
|
-
return renderClaude(
|
|
413
|
+
return renderClaude({
|
|
414
|
+
...baseInput,
|
|
415
|
+
nativeSync: opts.claudeNativeSync,
|
|
416
|
+
env: opts.claudeEnv
|
|
417
|
+
});
|
|
312
418
|
case "pi":
|
|
313
419
|
return renderPi(baseInput);
|
|
314
420
|
case "copilot":
|
|
@@ -3,12 +3,15 @@ import { z } from "zod";
|
|
|
3
3
|
* Schema for `~/.copillm/agent.toml` (global) and `<cwd>/.copillm/agent.toml`
|
|
4
4
|
* (project overlay). See plans/unified-booping-mango.md for design rationale.
|
|
5
5
|
*
|
|
6
|
-
* Sections under `[defaults.*]` apply
|
|
7
|
-
*
|
|
6
|
+
* Sections under `[defaults.*]` always apply, regardless of which profile is
|
|
7
|
+
* active. A profile may override a default by re-declaring an entry with the
|
|
8
|
+
* same key (e.g. `[profiles.work.mcp.servers.<name>]` replaces the same-named
|
|
9
|
+
* `[defaults.mcp.servers.<name>]`). There is no way to *remove* a default from
|
|
10
|
+
* a profile — defaults are intentionally always-on. v1 only wires
|
|
11
|
+
* `instructions` and `mcp` into fan-out — the other
|
|
8
12
|
* sections (`skills`, `agents`, `hooks`, `permissions`) are reserved-but-
|
|
9
13
|
* permissive so users can start populating them without future TOML breaking.
|
|
10
14
|
*/
|
|
11
|
-
export const UNSET_SENTINEL = "@unset";
|
|
12
15
|
const StringRecord = z.record(z.string());
|
|
13
16
|
const McpStdioSchema = z
|
|
14
17
|
.object({
|
|
@@ -28,12 +31,7 @@ const McpHttpSchema = z
|
|
|
28
31
|
scope: z.enum(["project", "user"]).optional()
|
|
29
32
|
})
|
|
30
33
|
.strict();
|
|
31
|
-
const
|
|
32
|
-
.object({
|
|
33
|
-
inherit: z.literal(UNSET_SENTINEL)
|
|
34
|
-
})
|
|
35
|
-
.strict();
|
|
36
|
-
export const McpServerSchema = z.union([McpStdioSchema, McpHttpSchema, McpInheritUnset]);
|
|
34
|
+
export const McpServerSchema = z.union([McpStdioSchema, McpHttpSchema]);
|
|
37
35
|
const InstructionsSchema = z
|
|
38
36
|
.object({
|
|
39
37
|
body: z.string()
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
4
5
|
import { getCopillmHome } from "../config/home.js";
|
|
5
6
|
import { ensureSecureDirectory, writeFileSecureAtomic } from "../config/fsSecurity.js";
|
|
7
|
+
import { loadConfig } from "../config/config.js";
|
|
6
8
|
import { AgentConfigError, loadAgentConfig } from "../agentconfig/load.js";
|
|
7
9
|
import { applyAgentConfig, formatApplyNotes } from "../agentconfig/apply.js";
|
|
10
|
+
import { buildClaudeEnvBundle } from "./agentEnv.js";
|
|
8
11
|
const SCAFFOLD_TOML = `# copillm agent config — one source of truth for instructions and MCP servers
|
|
9
12
|
# fanned out to each coding agent on \`copillm <agent>\` launch.
|
|
10
13
|
# See: https://github.com/jcjc-dev/copillm (plans/unified-booping-mango.md)
|
|
@@ -112,7 +115,7 @@ export function registerConfigCommands(program) {
|
|
|
112
115
|
});
|
|
113
116
|
config
|
|
114
117
|
.command("sync")
|
|
115
|
-
.description("
|
|
118
|
+
.description("Sync resolved config to the agent's native paths without launching")
|
|
116
119
|
.requiredOption("--agent <kind>", "codex | claude | pi | copilot")
|
|
117
120
|
.option("--profile <name>", "Override active profile for this run")
|
|
118
121
|
.action((opts) => {
|
|
@@ -122,11 +125,21 @@ export function registerConfigCommands(program) {
|
|
|
122
125
|
process.exit(1);
|
|
123
126
|
}
|
|
124
127
|
try {
|
|
128
|
+
const claudeEnv = agent === "claude"
|
|
129
|
+
? buildClaudeEnvBundle({
|
|
130
|
+
port: loadConfig().preferredPort,
|
|
131
|
+
callerSecret: null,
|
|
132
|
+
enableGatewayDiscovery: true
|
|
133
|
+
}).env
|
|
134
|
+
: undefined;
|
|
125
135
|
const result = applyAgentConfig({
|
|
126
136
|
agent,
|
|
127
137
|
cwd: process.cwd(),
|
|
128
138
|
profileOverride: opts.profile ?? null,
|
|
129
|
-
codexHomeDir: agent === "codex" ? path.join(
|
|
139
|
+
codexHomeDir: agent === "codex" ? path.join(os.homedir(), ".codex") : undefined,
|
|
140
|
+
codexBaseConfigSourcePath: agent === "codex" ? path.join(getCopillmHome(), "codex", "config.toml") : undefined,
|
|
141
|
+
claudeNativeSync: agent === "claude",
|
|
142
|
+
claudeEnv
|
|
130
143
|
});
|
|
131
144
|
for (const line of formatApplyNotes(result, agent)) {
|
|
132
145
|
process.stdout.write(`${line}\n`);
|
package/dist/cli/launchAgent.js
CHANGED
|
@@ -52,6 +52,14 @@ function installHint(agent) {
|
|
|
52
52
|
" npm i -g @earendil-works/pi-coding-agent"
|
|
53
53
|
].join("\n");
|
|
54
54
|
}
|
|
55
|
+
if (agent === "copilot") {
|
|
56
|
+
return [
|
|
57
|
+
"Hint: install GitHub Copilot CLI manually with one of:",
|
|
58
|
+
" brew install --cask github-copilot-cli",
|
|
59
|
+
" npm i -g @github/copilot",
|
|
60
|
+
" https://github.com/github/copilot-cli"
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
55
63
|
return [
|
|
56
64
|
"Hint: install Claude Code manually with:",
|
|
57
65
|
" npm i -g @anthropic-ai/claude-code"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isBenignSocketError } from "../server/requestLifecycle.js";
|
|
2
|
+
/**
|
|
3
|
+
* Install a process-level safety net for the daemon. When an unexpected
|
|
4
|
+
* `uncaughtException` or `unhandledRejection` escapes the per-request error
|
|
5
|
+
* handling, we log it loudly and keep the process alive. A daemon dying on
|
|
6
|
+
* a per-request bug is strictly worse than continuing to serve the next
|
|
7
|
+
* request: clients (Codex, Claude Code, pi) lose ALL in-flight streams when
|
|
8
|
+
* the process exits, and the user has to manually `copillm start` again.
|
|
9
|
+
*
|
|
10
|
+
* Benign socket errors (ECONNRESET / EPIPE / ERR_STREAM_DESTROYED / aborted
|
|
11
|
+
* fetches) are downgraded to debug — they're a normal part of SSE life and
|
|
12
|
+
* would otherwise spam the logs.
|
|
13
|
+
*
|
|
14
|
+
* Returns a disposer that uninstalls the handlers (used by tests).
|
|
15
|
+
*/
|
|
16
|
+
export function installProcessSafetyNet(logger) {
|
|
17
|
+
const onUncaught = (error) => {
|
|
18
|
+
if (isBenignSocketError(error)) {
|
|
19
|
+
logger.debug({ event: "process_safety_net", kind: "uncaught_exception", err: toErrLike(error) }, "swallowed benign socket error at process level");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
logger.error({ event: "process_safety_net", kind: "uncaught_exception", err: toErrLike(error) }, "uncaught exception in daemon — keeping process alive");
|
|
23
|
+
};
|
|
24
|
+
const onUnhandled = (reason) => {
|
|
25
|
+
if (isBenignSocketError(reason)) {
|
|
26
|
+
logger.debug({ event: "process_safety_net", kind: "unhandled_rejection", err: toErrLike(reason) }, "swallowed benign socket rejection at process level");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
logger.error({ event: "process_safety_net", kind: "unhandled_rejection", err: toErrLike(reason) }, "unhandled promise rejection in daemon — keeping process alive");
|
|
30
|
+
};
|
|
31
|
+
process.on("uncaughtException", onUncaught);
|
|
32
|
+
process.on("unhandledRejection", onUnhandled);
|
|
33
|
+
return () => {
|
|
34
|
+
process.off("uncaughtException", onUncaught);
|
|
35
|
+
process.off("unhandledRejection", onUnhandled);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function toErrLike(value) {
|
|
39
|
+
if (value instanceof Error) {
|
|
40
|
+
const out = {
|
|
41
|
+
type: value.name,
|
|
42
|
+
message: value.message
|
|
43
|
+
};
|
|
44
|
+
if (value.stack)
|
|
45
|
+
out.stack = value.stack;
|
|
46
|
+
const code = value.code;
|
|
47
|
+
if (typeof code === "string")
|
|
48
|
+
out.code = code;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
return { type: typeof value, message: String(value) };
|
|
52
|
+
}
|
package/dist/cli/resolveAgent.js
CHANGED
|
@@ -6,12 +6,14 @@ import { getCopillmHome } from "../config/home.js";
|
|
|
6
6
|
const NPM_PACKAGES = {
|
|
7
7
|
codex: "@openai/codex",
|
|
8
8
|
claude: "@anthropic-ai/claude-code",
|
|
9
|
-
pi: "@earendil-works/pi-coding-agent"
|
|
9
|
+
pi: "@earendil-works/pi-coding-agent",
|
|
10
|
+
copilot: "@github/copilot"
|
|
10
11
|
};
|
|
11
12
|
const BIN_NAMES = {
|
|
12
13
|
codex: "codex",
|
|
13
14
|
claude: "claude",
|
|
14
|
-
pi: "pi"
|
|
15
|
+
pi: "pi",
|
|
16
|
+
copilot: "copilot"
|
|
15
17
|
};
|
|
16
18
|
export function packageNameFor(agent) {
|
|
17
19
|
return NPM_PACKAGES[agent];
|
package/dist/cli.js
CHANGED
|
@@ -25,6 +25,7 @@ import { buildClaudeEnvBundle, buildCodexEnvBundle, buildPiEnvBundle } from "./c
|
|
|
25
25
|
import { launchAgent } from "./cli/launchAgent.js";
|
|
26
26
|
import { applyAgentConfig, formatApplyNotes } from "./agentconfig/apply.js";
|
|
27
27
|
import { registerConfigCommands } from "./cli/configCommands.js";
|
|
28
|
+
import { installProcessSafetyNet } from "./cli/processSafetyNet.js";
|
|
28
29
|
const logger = createLogger();
|
|
29
30
|
const program = new Command();
|
|
30
31
|
program.name("copillm").description("Local Copilot proxy").version("0.1.0");
|
|
@@ -754,6 +755,49 @@ program
|
|
|
754
755
|
});
|
|
755
756
|
process.exit(exitCode);
|
|
756
757
|
});
|
|
758
|
+
program
|
|
759
|
+
.command("copilot")
|
|
760
|
+
.description("Launch GitHub Copilot CLI reusing copillm's stored GitHub token (no second device flow)")
|
|
761
|
+
.option("--copillm-use <spec>", "Pin copilot package version (e.g. 1.0.52 or @github/copilot@1.0.52)")
|
|
762
|
+
.option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
|
|
763
|
+
.option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
|
|
764
|
+
.allowUnknownOption(true)
|
|
765
|
+
.passThroughOptions()
|
|
766
|
+
.helpOption(false)
|
|
767
|
+
.argument("[args...]", "Args forwarded to copilot")
|
|
768
|
+
.action(async (forwardedArgs, opts) => {
|
|
769
|
+
const credential = await loadStoredCredential();
|
|
770
|
+
if (!credential) {
|
|
771
|
+
process.stderr.write("copillm: no stored GitHub credential — run `copillm auth login` first.\n");
|
|
772
|
+
process.exit(1);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_COPILOT_VERSION ?? undefined;
|
|
776
|
+
const applyResult = applyAgentConfig({
|
|
777
|
+
agent: "copilot",
|
|
778
|
+
cwd: process.cwd(),
|
|
779
|
+
profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null,
|
|
780
|
+
skip: Boolean(opts.copillmNoConfig)
|
|
781
|
+
});
|
|
782
|
+
for (const line of formatApplyNotes(applyResult, "copilot")) {
|
|
783
|
+
process.stderr.write(`${line}\n`);
|
|
784
|
+
}
|
|
785
|
+
// Inject the stored GitHub OAuth token into the child env only — never
|
|
786
|
+
// export to the parent shell and never persist. Copilot CLI honours
|
|
787
|
+
// COPILOT_GITHUB_TOKEN ahead of its own stored credentials, so this
|
|
788
|
+
// short-circuits its device-flow login when copillm already has a token.
|
|
789
|
+
const env = {
|
|
790
|
+
...applyResult.envOverlay,
|
|
791
|
+
COPILOT_GITHUB_TOKEN: credential.token
|
|
792
|
+
};
|
|
793
|
+
const exitCode = await launchAgent({
|
|
794
|
+
agent: "copilot",
|
|
795
|
+
args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
|
|
796
|
+
env,
|
|
797
|
+
pinnedSpec
|
|
798
|
+
});
|
|
799
|
+
process.exit(exitCode);
|
|
800
|
+
});
|
|
757
801
|
registerConfigCommands(program);
|
|
758
802
|
program.parseAsync(process.argv).catch((error) => {
|
|
759
803
|
if (error instanceof Error) {
|
|
@@ -814,6 +858,7 @@ async function runDaemon(options) {
|
|
|
814
858
|
tokenManager.clear();
|
|
815
859
|
throw new Error(`No available port in configured range (${ports[0]}-${ports[ports.length - 1]}).`);
|
|
816
860
|
}
|
|
861
|
+
installProcessSafetyNet(logger);
|
|
817
862
|
let shuttingDown = false;
|
|
818
863
|
const shutdown = async () => {
|
|
819
864
|
if (shuttingDown) {
|
package/dist/server/proxy.js
CHANGED
|
@@ -10,6 +10,7 @@ import { translateOpenAIStreamToAnthropic, writeAnthropicPrelude } from "../tran
|
|
|
10
10
|
import { buildCodexCatalog } from "./codexSchema.js";
|
|
11
11
|
import { getGithubUserSummary, GithubUserFetchError } from "./debugInfo.js";
|
|
12
12
|
import { buildAnthropicModelsResponse } from "./anthropicModelsResponse.js";
|
|
13
|
+
import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson, safeWrite } from "./requestLifecycle.js";
|
|
13
14
|
const COPILOT_HEADERS = {
|
|
14
15
|
"Content-Type": "application/json",
|
|
15
16
|
"Copilot-Integration-Id": "vscode-chat",
|
|
@@ -49,6 +50,7 @@ export async function startProxyServer(input) {
|
|
|
49
50
|
const requestId = randomUUID();
|
|
50
51
|
const startedAt = Date.now();
|
|
51
52
|
const pathname = safePathname(req.url);
|
|
53
|
+
const lifecycle = attachRequestLifecycle(req, res, input.logger, requestId);
|
|
52
54
|
res.on("finish", () => {
|
|
53
55
|
input.logger.info({
|
|
54
56
|
event: "http_request",
|
|
@@ -199,11 +201,17 @@ export async function startProxyServer(input) {
|
|
|
199
201
|
body: upstreamBody,
|
|
200
202
|
requestId,
|
|
201
203
|
logger: input.logger,
|
|
202
|
-
upstreamPath
|
|
204
|
+
upstreamPath,
|
|
205
|
+
signal: lifecycle.signal
|
|
203
206
|
});
|
|
204
207
|
await forwardResponse(upstream, route.anthroShape, res, requestedModel ?? undefined, prelude);
|
|
205
208
|
}
|
|
206
209
|
catch (error) {
|
|
210
|
+
if (isBenignSocketError(error)) {
|
|
211
|
+
input.logger.debug({ event: "upstream_aborted", request_id: requestId, err: error }, "upstream request aborted (client disconnected)");
|
|
212
|
+
safeEnd(res);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
207
215
|
if (error instanceof CopilotTokenManagerError) {
|
|
208
216
|
if (prelude) {
|
|
209
217
|
writeAnthropicSseError(res, prelude, "token_refresh_failed");
|
|
@@ -232,8 +240,31 @@ export async function startProxyServer(input) {
|
|
|
232
240
|
sendJson(res, 400, { error: error.code, detail: error.message });
|
|
233
241
|
return;
|
|
234
242
|
}
|
|
235
|
-
|
|
243
|
+
if (isBenignSocketError(error)) {
|
|
244
|
+
input.logger.debug({ event: "request_client_gone", request_id: requestId, err: error }, "request handler aborted because client disconnected");
|
|
245
|
+
safeEnd(res);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
input.logger.error({ err: error, request_id: requestId }, "request failed");
|
|
236
249
|
sendJson(res, 500, { error: "internal_error" });
|
|
250
|
+
safeEnd(res);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
server.on("clientError", (err, socket) => {
|
|
254
|
+
input.logger.debug({ event: "client_error", code: err?.code }, "malformed HTTP from client");
|
|
255
|
+
if (socket.writable) {
|
|
256
|
+
try {
|
|
257
|
+
socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Socket likely already gone — nothing to do.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
socket.destroy();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// best effort
|
|
237
268
|
}
|
|
238
269
|
});
|
|
239
270
|
await new Promise((resolve, reject) => {
|
|
@@ -256,8 +287,11 @@ async function postToCopilot(input) {
|
|
|
256
287
|
let forceRefresh = false;
|
|
257
288
|
let authRefreshRetried = false;
|
|
258
289
|
for (let attempt = 1; attempt <= MAX_UPSTREAM_ATTEMPTS; attempt += 1) {
|
|
290
|
+
if (input.signal?.aborted) {
|
|
291
|
+
throw abortErrorFromSignal(input.signal);
|
|
292
|
+
}
|
|
259
293
|
try {
|
|
260
|
-
const response = await postWithCurrentBearer(input.tokenManager, input.accountType, input.body, forceRefresh, input.requestId, input.upstreamPath);
|
|
294
|
+
const response = await postWithCurrentBearer(input.tokenManager, input.accountType, input.body, forceRefresh, input.requestId, input.upstreamPath, input.signal);
|
|
261
295
|
forceRefresh = false;
|
|
262
296
|
if (response.status === 401 && !authRefreshRetried && attempt < MAX_UPSTREAM_ATTEMPTS) {
|
|
263
297
|
authRefreshRetried = true;
|
|
@@ -275,6 +309,10 @@ async function postToCopilot(input) {
|
|
|
275
309
|
return response;
|
|
276
310
|
}
|
|
277
311
|
catch (error) {
|
|
312
|
+
if (isBenignSocketError(error)) {
|
|
313
|
+
// Client disconnected — propagate so the request handler can clean up.
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
278
316
|
if (!isRetryableTransportError(error) || attempt >= MAX_UPSTREAM_ATTEMPTS) {
|
|
279
317
|
throw error;
|
|
280
318
|
}
|
|
@@ -284,7 +322,7 @@ async function postToCopilot(input) {
|
|
|
284
322
|
}
|
|
285
323
|
throw new Error("Upstream retry budget exhausted unexpectedly.");
|
|
286
324
|
}
|
|
287
|
-
async function postWithCurrentBearer(tokenManager, accountType, body, forceRefresh, requestId, upstreamPath) {
|
|
325
|
+
async function postWithCurrentBearer(tokenManager, accountType, body, forceRefresh, requestId, upstreamPath, signal) {
|
|
288
326
|
const bearer = await tokenManager.ensureToken({ forceRefresh });
|
|
289
327
|
return fetch(`${accountBaseUrl(accountType)}${upstreamPath}`, {
|
|
290
328
|
method: "POST",
|
|
@@ -293,9 +331,19 @@ async function postWithCurrentBearer(tokenManager, accountType, body, forceRefre
|
|
|
293
331
|
Authorization: `Bearer ${bearer}`,
|
|
294
332
|
"X-Request-Id": requestId
|
|
295
333
|
},
|
|
296
|
-
body: JSON.stringify(body)
|
|
334
|
+
body: JSON.stringify(body),
|
|
335
|
+
signal
|
|
297
336
|
});
|
|
298
337
|
}
|
|
338
|
+
function abortErrorFromSignal(signal) {
|
|
339
|
+
const reason = signal.reason;
|
|
340
|
+
if (reason instanceof Error) {
|
|
341
|
+
return reason;
|
|
342
|
+
}
|
|
343
|
+
const err = new Error("Request aborted by client");
|
|
344
|
+
err.name = "AbortError";
|
|
345
|
+
return err;
|
|
346
|
+
}
|
|
299
347
|
async function forwardResponse(upstream, anthroShape, res, requestedModel, prelude) {
|
|
300
348
|
if (!upstream.ok) {
|
|
301
349
|
await discardUpstreamBody(upstream);
|
|
@@ -386,7 +434,17 @@ async function pipeEventStream(upstream, res) {
|
|
|
386
434
|
if (res.socket && typeof res.socket.setNoDelay === "function") {
|
|
387
435
|
res.socket.setNoDelay(true);
|
|
388
436
|
}
|
|
389
|
-
|
|
437
|
+
try {
|
|
438
|
+
await pipeline(Readable.fromWeb(upstream.body), res);
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
if (isBenignSocketError(error)) {
|
|
442
|
+
// Client went away mid-stream — normal for SSE consumers (Codex,
|
|
443
|
+
// Claude Code, pi) that cancel pending responses on user input.
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
390
448
|
}
|
|
391
449
|
function isEventStream(upstream) {
|
|
392
450
|
const contentType = upstream.headers.get("content-type");
|
|
@@ -415,16 +473,16 @@ function beginAnthropicSseResponse(res, req) {
|
|
|
415
473
|
export function writeAnthropicSseError(res, prelude, code) {
|
|
416
474
|
void prelude;
|
|
417
475
|
try {
|
|
418
|
-
res
|
|
476
|
+
safeWrite(res, `event: message_delta\ndata: ${JSON.stringify({
|
|
419
477
|
type: "message_delta",
|
|
420
478
|
delta: { stop_reason: "end_turn", stop_sequence: null },
|
|
421
479
|
usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 }
|
|
422
480
|
})}\n\n`);
|
|
423
|
-
res
|
|
424
|
-
res
|
|
481
|
+
safeWrite(res, `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: code } })}\n\n`);
|
|
482
|
+
safeWrite(res, `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
|
|
425
483
|
}
|
|
426
484
|
finally {
|
|
427
|
-
res
|
|
485
|
+
safeEnd(res);
|
|
428
486
|
}
|
|
429
487
|
}
|
|
430
488
|
function isLocalRequest(req) {
|
|
@@ -455,9 +513,7 @@ async function readJson(req) {
|
|
|
455
513
|
}
|
|
456
514
|
}
|
|
457
515
|
function sendJson(res, status, payload) {
|
|
458
|
-
res
|
|
459
|
-
res.setHeader("Content-Type", "application/json");
|
|
460
|
-
res.end(JSON.stringify(payload));
|
|
516
|
+
safeSendJson(res, status, payload);
|
|
461
517
|
}
|
|
462
518
|
function readRequestedModel(payload) {
|
|
463
519
|
if (!payload || typeof payload !== "object") {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Node error codes we treat as "client went away, this is normal" — they
|
|
2
|
+
// must never crash the daemon and must not be reported as errors. SSE clients
|
|
3
|
+
// (Codex, Claude Code, pi) abort streams constantly: user types another
|
|
4
|
+
// prompt, hits Esc, switches turns, etc.
|
|
5
|
+
const BENIGN_SOCKET_ERROR_CODES = new Set([
|
|
6
|
+
"ECONNRESET",
|
|
7
|
+
"EPIPE",
|
|
8
|
+
"ERR_STREAM_PREMATURE_CLOSE",
|
|
9
|
+
"ERR_STREAM_DESTROYED",
|
|
10
|
+
"ERR_STREAM_WRITE_AFTER_END",
|
|
11
|
+
"ERR_HTTP_HEADERS_SENT"
|
|
12
|
+
]);
|
|
13
|
+
export function isBenignSocketError(error) {
|
|
14
|
+
if (!error || typeof error !== "object") {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const err = error;
|
|
18
|
+
if (typeof err.code === "string" && BENIGN_SOCKET_ERROR_CODES.has(err.code)) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (err.name === "AbortError") {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (err.cause && isBenignSocketError(err.cause)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
export function attachRequestLifecycle(req, res, logger, requestId) {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
let alive = true;
|
|
32
|
+
const markGone = (source, err) => {
|
|
33
|
+
if (!alive) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
alive = false;
|
|
37
|
+
if (err && !isBenignSocketError(err)) {
|
|
38
|
+
logger.debug({ event: "request_lifecycle_error", request_id: requestId, source, err }, "request stream errored");
|
|
39
|
+
}
|
|
40
|
+
else if (source !== "close" || err) {
|
|
41
|
+
logger.debug({ event: "request_lifecycle_closed", request_id: requestId, source }, "client disconnected");
|
|
42
|
+
}
|
|
43
|
+
if (!controller.signal.aborted) {
|
|
44
|
+
controller.abort();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
res.on("close", () => markGone("close"));
|
|
48
|
+
res.on("error", (err) => markGone("error", err));
|
|
49
|
+
req.on("aborted", () => markGone("aborted"));
|
|
50
|
+
req.on("error", (err) => markGone("error", err));
|
|
51
|
+
return {
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
isAlive: () => alive && res.writable && !res.writableEnded
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Writes a JSON response, but is a no-op when the response is already
|
|
58
|
+
* committed or the socket is gone. This is the safe replacement for
|
|
59
|
+
* `res.setHeader(...) + res.end(...)` in any path that might run after a
|
|
60
|
+
* streaming response has started flushing.
|
|
61
|
+
*/
|
|
62
|
+
export function safeSendJson(res, status, payload) {
|
|
63
|
+
if (res.headersSent || !res.writable || res.writableEnded) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
res.statusCode = status;
|
|
68
|
+
res.setHeader("Content-Type", "application/json");
|
|
69
|
+
res.end(JSON.stringify(payload));
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (isBenignSocketError(error)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Best-effort write to a downstream Writable. Returns false when the chunk
|
|
80
|
+
* could not be delivered (because the stream is destroyed or the write
|
|
81
|
+
* threw a benign socket error). Non-benign errors are re-thrown so they're
|
|
82
|
+
* still visible to the caller.
|
|
83
|
+
*/
|
|
84
|
+
export function safeWrite(downstream, chunk) {
|
|
85
|
+
if (!downstream.writable || downstream.writableEnded || downstream.destroyed) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return downstream.write(chunk);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (isBenignSocketError(error)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Best-effort end of a downstream Writable. Swallows benign socket errors;
|
|
100
|
+
* never throws on a destroyed stream.
|
|
101
|
+
*/
|
|
102
|
+
export function safeEnd(downstream) {
|
|
103
|
+
if (downstream.writableEnded || downstream.destroyed) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
downstream.end();
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (isBenignSocketError(error)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isBenignSocketError } from "../server/requestLifecycle.js";
|
|
2
3
|
const PING_INTERVAL_MS = 1000;
|
|
3
4
|
export async function translateOpenAIStreamToAnthropic(options) {
|
|
4
5
|
const { upstream, downstream } = options;
|
|
@@ -16,11 +17,37 @@ export async function translateOpenAIStreamToAnthropic(options) {
|
|
|
16
17
|
let nextAnthropicIndex = 0;
|
|
17
18
|
let streamErrored = false;
|
|
18
19
|
let pingTimer = null;
|
|
20
|
+
let downstreamGone = false;
|
|
21
|
+
function isDownstreamAlive() {
|
|
22
|
+
if (downstreamGone)
|
|
23
|
+
return false;
|
|
24
|
+
return downstream.writable && !downstream.writableEnded && !downstream.destroyed;
|
|
25
|
+
}
|
|
26
|
+
function markDownstreamGone() {
|
|
27
|
+
if (downstreamGone)
|
|
28
|
+
return;
|
|
29
|
+
downstreamGone = true;
|
|
30
|
+
stopPings();
|
|
31
|
+
// Best-effort: also stop reading upstream so we don't pull a megabyte
|
|
32
|
+
// of SSE we'll never deliver.
|
|
33
|
+
try {
|
|
34
|
+
upstream.destroy();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// ignore
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
downstream.on("close", markDownstreamGone);
|
|
41
|
+
downstream.on("error", markDownstreamGone);
|
|
19
42
|
function startPings() {
|
|
20
43
|
if (pingTimer !== null) {
|
|
21
44
|
return;
|
|
22
45
|
}
|
|
23
46
|
pingTimer = setInterval(() => {
|
|
47
|
+
if (!isDownstreamAlive()) {
|
|
48
|
+
stopPings();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
24
51
|
writeEvent("ping", { type: "ping" });
|
|
25
52
|
}, PING_INTERVAL_MS);
|
|
26
53
|
if (typeof pingTimer.unref === "function") {
|
|
@@ -34,7 +61,20 @@ export async function translateOpenAIStreamToAnthropic(options) {
|
|
|
34
61
|
}
|
|
35
62
|
}
|
|
36
63
|
function writeEvent(eventName, data) {
|
|
37
|
-
|
|
64
|
+
if (!isDownstreamAlive()) {
|
|
65
|
+
markDownstreamGone();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
downstream.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (isBenignSocketError(error)) {
|
|
73
|
+
markDownstreamGone();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
38
78
|
}
|
|
39
79
|
function emitMessageStart() {
|
|
40
80
|
if (messageStarted) {
|
|
@@ -198,6 +238,10 @@ export async function translateOpenAIStreamToAnthropic(options) {
|
|
|
198
238
|
startPings();
|
|
199
239
|
try {
|
|
200
240
|
for await (const chunk of upstream) {
|
|
241
|
+
if (!isDownstreamAlive()) {
|
|
242
|
+
markDownstreamGone();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
201
245
|
const text = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
202
246
|
buffer += text;
|
|
203
247
|
let newlineIndex;
|
|
@@ -213,8 +257,15 @@ export async function translateOpenAIStreamToAnthropic(options) {
|
|
|
213
257
|
}
|
|
214
258
|
}
|
|
215
259
|
catch (error) {
|
|
216
|
-
streamErrored = true;
|
|
217
260
|
stopPings();
|
|
261
|
+
if (!isDownstreamAlive() || isBenignSocketError(error)) {
|
|
262
|
+
// Either we destroyed the upstream because downstream went away, or
|
|
263
|
+
// the upstream rejected with a benign socket error. Either way, no
|
|
264
|
+
// recovery write is possible — just stop.
|
|
265
|
+
markDownstreamGone();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
streamErrored = true;
|
|
218
269
|
emitMessageStart();
|
|
219
270
|
closeAllBlocks();
|
|
220
271
|
writeEvent("message_delta", {
|
|
@@ -227,13 +278,16 @@ export async function translateOpenAIStreamToAnthropic(options) {
|
|
|
227
278
|
error: { type: "api_error", message: error instanceof Error ? error.message : "upstream stream error" }
|
|
228
279
|
});
|
|
229
280
|
writeEvent("message_stop", { type: "message_stop" });
|
|
230
|
-
|
|
281
|
+
safeEndDownstream();
|
|
231
282
|
return;
|
|
232
283
|
}
|
|
233
284
|
if (streamErrored) {
|
|
234
285
|
return;
|
|
235
286
|
}
|
|
236
287
|
stopPings();
|
|
288
|
+
if (!isDownstreamAlive()) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
237
291
|
emitMessageStart();
|
|
238
292
|
closeAllBlocks();
|
|
239
293
|
writeEvent("message_delta", {
|
|
@@ -242,7 +296,18 @@ export async function translateOpenAIStreamToAnthropic(options) {
|
|
|
242
296
|
usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens }
|
|
243
297
|
});
|
|
244
298
|
writeEvent("message_stop", { type: "message_stop" });
|
|
245
|
-
|
|
299
|
+
safeEndDownstream();
|
|
300
|
+
function safeEndDownstream() {
|
|
301
|
+
if (downstream.writableEnded || downstream.destroyed)
|
|
302
|
+
return;
|
|
303
|
+
try {
|
|
304
|
+
downstream.end();
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (!isBenignSocketError(error))
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
246
311
|
}
|
|
247
312
|
/**
|
|
248
313
|
* Write the Anthropic `message_start` event (and an initial ping) to the
|
|
@@ -269,8 +334,14 @@ export function writeAnthropicPrelude(downstream, model) {
|
|
|
269
334
|
usage: { input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 }
|
|
270
335
|
}
|
|
271
336
|
};
|
|
272
|
-
|
|
273
|
-
|
|
337
|
+
try {
|
|
338
|
+
downstream.write(`event: message_start\ndata: ${JSON.stringify(messageStart)}\n\n`);
|
|
339
|
+
downstream.write(`event: ping\ndata: ${JSON.stringify({ type: "ping" })}\n\n`);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (!isBenignSocketError(error))
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
274
345
|
return { messageId };
|
|
275
346
|
}
|
|
276
347
|
function mapFinishReason(reason) {
|