copillm 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- downstream.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`);
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
- downstream.end();
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
- downstream.end();
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
- downstream.write(`event: message_start\ndata: ${JSON.stringify(messageStart)}\n\n`);
273
- downstream.write(`event: ping\ndata: ${JSON.stringify({ type: "ping" })}\n\n`);
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",