Skip to content

Commit 2d8eaff

Browse files
author
Hermes Agent
committed
[verified] Fix proxy cancellation, timeouts, tool deltas and validation
1 parent a97eb90 commit 2d8eaff

10 files changed

Lines changed: 885 additions & 94 deletions

‎src/config.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface Config {
1515
ccVersion: string;
1616
logLevel: string;
1717
corsOrigin: string;
18-
/** Max wall-clock ms for upstream to send response headers + first byte. */
18+
/** Per-attempt deadline for upstream headers and any non-2xx error body. */
1919
upstreamTimeoutMs: number;
2020
/** Max ms between consecutive chunks during streaming. 0 = disabled. */
2121
idleTimeoutMs: number;
@@ -92,7 +92,7 @@ export function loadConfig(): Config {
9292
const corsOrigin = process.env.CORS_ORIGIN ?? "*";
9393

9494
// Upstream timeouts. The connection timeout covers the wall-clock time
95-
// until the upstream returns response headers + first byte — bump it for
95+
// until the upstream returns headers (and consumes any error body) — bump it for
9696
// slow reasoning models. The idle timeout catches stalled streams where
9797
// the upstream opened the connection but stopped sending chunks
9898
// mid-response (e.g. tool call hung on the upstream side). Set

‎src/server.ts‎

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { getProxyVersion } from "@/version.js";
1717
import {
1818
validateOpenAIChatRequest,
1919
validateAnthropicRequest,
20+
validateCountTokensRequest,
2021
ValidationError,
2122
} from "@/translate/validation.js";
2223
import type { AnthropicRequest, AnthropicSSERecord } from "@/translate/anthropic-types.js";
@@ -159,24 +160,17 @@ function corsHeaders(): Record<string, string> {
159160
// Helpers
160161
// ──────────────────────────────────────────
161162

162-
function abortOnClientDisconnect(
163-
req: http.IncomingMessage,
164-
res: http.ServerResponse,
165-
): AbortController {
163+
function abortOnClientDisconnect(res: http.ServerResponse): AbortController {
166164
const abort = new AbortController();
167-
req.on("close", () => {
165+
// IncomingMessage.close marks a completed upload, not a lost response client.
166+
const onClose = (): void => {
168167
if (!res.writableEnded) abort.abort();
169-
});
168+
};
169+
res.once("close", onClose);
170+
if (res.destroyed) onClose();
170171
return abort;
171172
}
172173

173-
function destroyStreamOnClientDisconnect(
174-
req: http.IncomingMessage,
175-
stream: NodeJS.ReadableStream,
176-
): void {
177-
req.on("close", () => (stream as Readable).destroy());
178-
}
179-
180174
/**
181175
* Write a chunk to `res`, returning a Promise that resolves once the
182176
* underlying socket has drained (when backpressure applies). Returns
@@ -186,7 +180,17 @@ function writeSSE(res: http.ServerResponse, chunk: string): Promise<boolean> {
186180
if (res.writableEnded || res.destroyed) return Promise.resolve(false);
187181
if (res.write(chunk)) return Promise.resolve(true);
188182
return new Promise((resolve) => {
189-
res.once("drain", () => resolve(!res.writableEnded && !res.destroyed));
183+
const settle = (writable: boolean): void => {
184+
res.off("drain", onDrain);
185+
res.off("close", onClose);
186+
res.off("error", onClose);
187+
resolve(writable);
188+
};
189+
const onDrain = (): void => settle(!res.writableEnded && !res.destroyed);
190+
const onClose = (): void => settle(false);
191+
res.once("drain", onDrain);
192+
res.once("close", onClose);
193+
res.once("error", onClose);
190194
});
191195
}
192196

@@ -214,7 +218,7 @@ async function pumpStream(
214218
// Encoder blew up — turn it into a stream error so the catch below
215219
// handles it uniformly instead of crashing the proxy.
216220
(stream as Readable).destroy(err as Error);
217-
break;
221+
throw err;
218222
}
219223
for (const chunk of chunks) {
220224
if (!(await writable(chunk))) return;
@@ -322,7 +326,7 @@ async function handleChatCompletions(
322326

323327
const ccBody = toCCRequest(openAIReq);
324328

325-
const abort = abortOnClientDisconnect(req, res);
329+
const abort = abortOnClientDisconnect(res);
326330

327331
try {
328332
const result = await sendToCC(
@@ -366,13 +370,8 @@ async function handleChatCompletions(
366370
res.write(formatSSEDone());
367371
res.end();
368372
}
369-
// No destroyStreamOnClientDisconnect here — by the time pumpStream
370-
// returns the stream has already ended or errored, so the call would
371-
// be a no-op. Mid-stream disconnects are handled by the abort signal
372-
// (see abortOnClientDisconnect + nodeReaderToStream's abortSignal
373-
// listener).
373+
// The response abort signal covers both streaming and JSON clients.
374374
} else {
375-
destroyStreamOnClientDisconnect(req, stream);
376375
const events = await collectEvents(stream);
377376
const response = buildNonStreamingResponse(events, model, encoder.id);
378377
sendJson(res, 200, response);
@@ -418,7 +417,7 @@ async function handleMessages(req: http.IncomingMessage, res: http.ServerRespons
418417
const encoder = new AnthropicStreamEncoder(model);
419418
const ccBody = anToCCRequest(anthropicReq);
420419

421-
const abort = abortOnClientDisconnect(req, res);
420+
const abort = abortOnClientDisconnect(res);
422421

423422
try {
424423
const result = await sendToCC(
@@ -462,10 +461,8 @@ async function handleMessages(req: http.IncomingMessage, res: http.ServerRespons
462461
},
463462
);
464463
if (!res.writableEnded && !res.destroyed) res.end();
465-
// No destroyStreamOnClientDisconnect here — see OpenAI streaming path
466-
// for rationale (abort signal already covers mid-stream disconnect).
464+
// The response abort signal already covers mid-stream disconnects.
467465
} else {
468-
destroyStreamOnClientDisconnect(req, stream);
469466
const events = await collectEvents(stream);
470467
const response = buildAnthropicResponse(events, model, encoder.messageId);
471468
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
@@ -494,7 +491,17 @@ async function handleCountTokens(
494491
);
495492
}
496493

497-
const body = rawBody as Record<string, unknown>;
494+
let body: Record<string, unknown>;
495+
try {
496+
body = validateCountTokensRequest(rawBody);
497+
} catch (err) {
498+
return sendAnthropicError(
499+
res,
500+
400,
501+
"invalid_request_error",
502+
err instanceof ValidationError ? err.message : "Invalid request body",
503+
);
504+
}
498505

499506
const parts: string[] = [];
500507
if (typeof body.system === "string") parts.push(body.system);

‎src/translate/anthropic.ts‎

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import crypto from "node:crypto";
2+
import { toolArgumentSuffix } from "./tool-arguments.js";
23
import type {
34
AnthropicRequest,
45
AnthropicContentBlock,
@@ -245,8 +246,12 @@ export function toCCRequest(
245246
export class AnthropicStreamEncoder {
246247
readonly messageId: string;
247248
private blockIndex = 0;
249+
private currentBlockIndex = 0;
248250
private currentBlockType: "text" | "thinking" | "tool_use" | null = null;
249-
private currentToolCallId: string | null = null;
251+
private readonly toolBlocks = new Map<
252+
string,
253+
{ index: number; arguments: string; closed: boolean }
254+
>();
250255
private pendingStart: CCEvent | null = null;
251256
private started = false;
252257
private pinged = false;
@@ -281,6 +286,7 @@ export class AnthropicStreamEncoder {
281286
records.push(this.makeMessageStart(0));
282287
}
283288
this.closeCurrentBlock(records);
289+
this.closeToolBlocks(records);
284290
records.push({
285291
event: "error",
286292
data: { type: "error", error: { type: "api_error", message: msg } },
@@ -328,6 +334,7 @@ export class AnthropicStreamEncoder {
328334
}
329335

330336
this.closeCurrentBlock(records);
337+
this.closeToolBlocks(records);
331338

332339
const finishReason = (event.data.finishReason as string) ?? "stop";
333340
const usage = extractUsage(event.data as Record<string, unknown>);
@@ -381,21 +388,18 @@ export class AnthropicStreamEncoder {
381388
case "tool-call-delta": {
382389
const tcId = (event.data.toolCallId as string) ?? "";
383390
const tcName = (event.data.name as string) ?? "";
384-
if (this.currentBlockType !== "tool_use" || this.currentToolCallId !== tcId) {
385-
this.closeCurrentBlock(records);
386-
this.ensureBlockOpenWith(records, "tool_use", {
387-
type: "tool_use",
388-
id: tcId,
389-
name: tcName,
390-
input: {},
391-
});
392-
this.currentToolCallId = tcId;
393-
}
391+
const block = this.ensureToolBlock(records, tcId, tcName);
392+
if (block.closed) throw new Error("Inconsistent upstream tool arguments");
393+
const args = (event.data.arguments as string) ?? "";
394+
block.arguments += args;
394395
records.push(
395-
this.makeDelta({
396-
type: "input_json_delta",
397-
partial_json: (event.data.arguments as string) ?? "",
398-
}),
396+
this.makeDelta(
397+
{
398+
type: "input_json_delta",
399+
partial_json: args,
400+
},
401+
block.index,
402+
),
399403
);
400404
break;
401405
}
@@ -406,33 +410,51 @@ export class AnthropicStreamEncoder {
406410
const input = event.data.input ?? event.data.arguments;
407411
const argsStr =
408412
typeof input === "string" ? input : input != null ? JSON.stringify(input) : "";
409-
// If the upstream streamed deltas for this tool call first and then
410-
// sent the final `tool-call` event with the same id, reuse the block
411-
// it already opened instead of creating a duplicate `tool_use`.
412-
if (this.currentBlockType === "tool_use" && this.currentToolCallId === tcId) {
413-
if (argsStr) {
414-
records.push(this.makeDelta({ type: "input_json_delta", partial_json: argsStr }));
415-
}
416-
break;
417-
}
418-
this.closeCurrentBlock(records);
419-
this.ensureBlockOpenWith(records, "tool_use", {
420-
type: "tool_use",
421-
id: tcId,
422-
name: tcName,
423-
input: {},
424-
});
425-
if (argsStr) {
426-
records.push(this.makeDelta({ type: "input_json_delta", partial_json: argsStr }));
413+
const block = this.ensureToolBlock(records, tcId, tcName);
414+
const suffix = toolArgumentSuffix(block.arguments, argsStr);
415+
if (suffix) {
416+
if (block.closed) throw new Error("Inconsistent upstream tool arguments");
417+
records.push(
418+
this.makeDelta({ type: "input_json_delta", partial_json: suffix }, block.index),
419+
);
420+
block.arguments += suffix;
427421
}
428-
this.closeCurrentBlock(records);
422+
this.closeToolBlock(records, block);
429423
break;
430424
}
431425
}
432426

433427
return records;
434428
}
435429

430+
private ensureToolBlock(records: AnthropicSSERecord[], id: string, name: string) {
431+
const existing = this.toolBlocks.get(id);
432+
if (existing) return existing;
433+
this.closeCurrentBlock(records);
434+
this.ensureBlockOpenWith(records, "tool_use", { type: "tool_use", id, name, input: {} });
435+
const block = { index: this.currentBlockIndex, arguments: "", closed: false };
436+
this.toolBlocks.set(id, block);
437+
// Tool blocks have independent lifetimes: interleaved deltas retain indices.
438+
this.currentBlockType = null;
439+
return block;
440+
}
441+
442+
private closeToolBlock(
443+
records: AnthropicSSERecord[],
444+
block: { index: number; closed: boolean },
445+
): void {
446+
if (block.closed) return;
447+
records.push({
448+
event: "content_block_stop",
449+
data: { type: "content_block_stop", index: block.index },
450+
});
451+
block.closed = true;
452+
}
453+
454+
private closeToolBlocks(records: AnthropicSSERecord[]): void {
455+
for (const block of this.toolBlocks.values()) this.closeToolBlock(records, block);
456+
}
457+
436458
private ensureBlockOpen(
437459
records: AnthropicSSERecord[],
438460
type: "text" | "thinking" | "tool_use",
@@ -450,11 +472,12 @@ export class AnthropicStreamEncoder {
450472
block: ContentBlockStartShape,
451473
): void {
452474
this.currentBlockType = type;
475+
this.currentBlockIndex = this.blockIndex++;
453476
records.push({
454477
event: "content_block_start",
455478
data: {
456479
type: "content_block_start",
457-
index: this.blockIndex,
480+
index: this.currentBlockIndex,
458481
content_block: block,
459482
},
460483
});
@@ -477,17 +500,16 @@ export class AnthropicStreamEncoder {
477500

478501
records.push({
479502
event: "content_block_stop",
480-
data: { type: "content_block_stop", index: this.blockIndex },
503+
data: { type: "content_block_stop", index: this.currentBlockIndex },
481504
});
482505

483-
this.blockIndex++;
484506
this.currentBlockType = null;
485507
}
486508

487-
private makeDelta(delta: DeltaShape): AnthropicSSERecord {
509+
private makeDelta(delta: DeltaShape, index = this.currentBlockIndex): AnthropicSSERecord {
488510
return {
489511
event: "content_block_delta",
490-
data: { type: "content_block_delta", index: this.blockIndex, delta },
512+
data: { type: "content_block_delta", index, delta },
491513
};
492514
}
493515

@@ -529,6 +551,7 @@ export class AnthropicStreamEncoder {
529551
this.started = true;
530552
}
531553
this.closeCurrentBlock(records);
554+
this.closeToolBlocks(records);
532555
records.push({
533556
event: "message_delta",
534557
data: {
@@ -555,6 +578,9 @@ export function buildAnthropicResponse(
555578

556579
for (const event of events) {
557580
switch (event.type) {
581+
case "error":
582+
// Do not turn failed generations (or private diagnostics) into content.
583+
throw new Error("CC upstream generation failed");
558584
case "text-delta":
559585
textContent += (event.data.text as string) ?? "";
560586
break;

0 commit comments

Comments
 (0)