Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 84 additions & 27 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
resolveAdmissionModelScope,
} from "./admission-model-scope";
import { jsonUtf8Bytes } from "../lib/json-byte-size";
import { redactSecretString } from "../lib/redact";
import { sseFieldValue } from "../lib/sse-decoder";
import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard";
import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize";
Expand Down Expand Up @@ -295,46 +296,55 @@ export interface PassthroughBodyGuard {
}

type PassthroughCloseReason = "terminal" | "client_cancel" | "body_stall" | "body_overflow";
type PassthroughFinalizeMeta = { closeReason: PassthroughCloseReason; terminalStatus?: "failed" };

/**
* Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal),
* bounding body occupancy: idle (silence-only, timed ONLY while a reader.read() is
* pending so downstream backpressure never counts as upstream inactivity) and a
* cumulative byte cap. On stall/overflow it appends a protocol-compatible Anthropic
* `event: error` terminal frame after a blank-line boundary, closes, and cancels the
* upstream reader — never a total-wall-clock bound (slow-but-alive streams live).
* cumulative byte cap. On stall/overflow, or when the upstream read fails after the
* headers went out, it appends a protocol-compatible Anthropic `event: error` terminal
* frame after a blank-line boundary, closes, and cancels the upstream reader — never a
* total-wall-clock bound (slow-but-alive streams live).
* Exported for deterministic unit tests.
*/
export function tapAnthropicSseForLog(
upstream: ReadableStream<Uint8Array>,
logCtx: RequestLogContext,
finalize: (status: number, meta: { closeReason: PassthroughCloseReason }) => void,
finalize: (status: number, meta: PassthroughFinalizeMeta) => void,
guard?: PassthroughBodyGuard,
): ReadableStream<Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
let usageAcc: Rec = {};
// message_stop or an upstream error event: the turn's own terminal already went through.
let terminalSeen = false;
const inspectFrame = (frame: string) => {
const dataLine = frame
.split("\n")
.map(l => sseFieldValue(l, "data"))
.filter((v): v is string => v !== null)
.join("");
if (!dataLine) return;
let data: unknown;
try { data = JSON.parse(dataLine); } catch { return; }
if (!isRec(data)) return;
if (data.type === "message_start" && isRec(data.message) && isRec(data.message.usage)) {
usageAcc = { ...usageAcc, ...data.message.usage };
} else if (data.type === "message_delta" && isRec(data.usage)) {
usageAcc = { ...usageAcc, ...data.usage };
} else if (data.type === "message_stop" || data.type === "error") {
terminalSeen = true;
}
};
const inspect = (chunk: Uint8Array) => {
buffer += decoder.decode(chunk, { stream: true });
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const dataLine = frame
.split("\n")
.map(l => sseFieldValue(l, "data"))
.filter((v): v is string => v !== null)
.join("");
if (!dataLine) continue;
let data: unknown;
try { data = JSON.parse(dataLine); } catch { continue; }
if (!isRec(data)) continue;
if (data.type === "message_start" && isRec(data.message) && isRec(data.message.usage)) {
usageAcc = { ...usageAcc, ...data.message.usage };
} else if (data.type === "message_delta" && isRec(data.usage)) {
usageAcc = { ...usageAcc, ...data.usage };
}
inspectFrame(frame);
}
};
const reader = upstream.getReader();
Expand All @@ -345,20 +355,23 @@ export function tapAnthropicSseForLog(
const recordUsage = () => {
logCtx.usage = anthropicUsageToOcx(Object.keys(usageAcc).length > 0 ? usageAcc : undefined);
};
const failBody = (closeReason: "body_stall" | "body_overflow", errType: string, message: string) => {
if (settled) return;
settled = true;
idle.cancel();
detachAbort();
recordUsage();
finalize(200, { closeReason });
const closeWithErrorFrame = (errType: string, message: string) => {
const payload = JSON.stringify({ type: "error", error: { type: errType, message } });
try {
// Leading blank line terminates any partial SSE block so the frame parses cleanly
// (relaySseWithFailedTail policy, Anthropic wire shape).
tapController?.enqueue(encoder.encode(`\n\nevent: error\ndata: ${payload}\n\n`));
tapController?.close();
} catch { /* client already torn down */ }
};
const failBody = (closeReason: "body_stall" | "body_overflow", errType: string, message: string) => {
if (settled) return;
settled = true;
idle.cancel();
detachAbort();
recordUsage();
finalize(200, { closeReason });
closeWithErrorFrame(errType, message);
reader.cancel(new DOMException(message, closeReason === "body_stall" ? "TimeoutError" : "QuotaExceededError")).catch(() => {});
};
const idle = idleDeadline(guard?.stallMs ?? 0, () => {
Expand Down Expand Up @@ -427,12 +440,56 @@ export function tapAnthropicSseForLog(
controller.enqueue(value);
} catch (err) {
if (settled) return;
// Bun can settle a fetch body read before it dispatches the abort listeners
// (consumeForInspection in relay.ts): read the signal itself before calling this
// rejection an upstream failure.
if (guard?.reqSignal?.aborted) {
onClientAbort();
return;
}
settled = true;
idle.cancel();
detachAbort();
// A read error can follow the last SSE block before its blank-line delimiter. Count
// that block before deciding how the turn ended, as the Responses relay does.
const terminalBeforeTail = terminalSeen;
const tail = buffer + decoder.decode();
buffer = "";
if (tail) inspectFrame(tail);
recordUsage();
finalize(200, { closeReason: "terminal" });
try { controller.error(err); } catch { /* torn down */ }
if (isTranslatorBudgetExceededError(err)) {
// A local cap, not an upstream failure: the non-streaming native Messages fold
// maps this error to a 413 itself, so it still errors the stream.
finalize(200, { closeReason: "terminal" });
try { controller.error(err); } catch { /* torn down */ }
return;
}
if (terminalSeen) {
// Only the transport trailer was lost; the client already has the turn's terminal.
// The Responses relay likewise reports a read error only without a seen terminal.
finalize(200, { closeReason: "terminal" });
try {
// An SSE parser drops an event that EOF cuts off before its blank line, so restore
// the delimiter when the terminal was only found in that unterminated tail.
if (!terminalBeforeTail) controller.enqueue(encoder.encode("\n\n"));
controller.close();
} catch { /* torn down */ }
reader.cancel(err).catch(() => {});
return;
}
// The upstream read failed after the 200 went out (a mid-stream socket reset). Log it
// the way the Responses relay does (onReadError): a truncated body is a failed turn,
// not a completed one. The client gets the same Anthropic error terminal as a stall,
// instead of a connection reset — or, on some Bun releases, a bare EOF that reads as
// a finished message.
const message = redactSecretString(`anthropic passthrough upstream stream failed: ${err instanceof Error ? err.message : String(err)}`);
logCtx.transportPhase = "mid_stream";
logCtx.terminalSource = "synthetic";
logCtx.upstreamError = message.slice(0, 500);
if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true;
finalize(502, { terminalStatus: "failed", closeReason: "terminal" });
closeWithErrorFrame("api_error", message);
reader.cancel(err).catch(() => {});
}
},
cancel(reason) {
Expand Down
22 changes: 15 additions & 7 deletions src/server/messages-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export interface HandleNativeMessagesOptions {
callerAnthropicBeta?: string | null;
}

type FinishLog = (status: number, message?: string, closeReason?: FinalRequestLogMeta["closeReason"]) => void;
type FinishLog = (status: number, message?: string, meta?: FinalRequestLogMeta) => void;

/** Relay an upstream body unchanged, recording first output on its first non-empty chunk. */
function observeFirstChunk(body: ReadableStream<Uint8Array>, onFirst: () => void): ReadableStream<Uint8Array> {
Expand Down Expand Up @@ -311,10 +311,10 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
attemptHandle.seal(logCtx.accountLogLabel);
const { attempt } = attemptHandle;
const finalLog = createFinalRequestLog(logIds, logCtx);
const finishLog: FinishLog = (status, message, closeReason = "non_stream") => {
const finishLog: FinishLog = (status, message, meta = { closeReason: "non_stream" }) => {
if (finalLog.finished()) return;
if (message) logCtx.upstreamError = redactSecretString(message).slice(0, 500);
finalLog.finish(status, { closeReason });
finalLog.finish(status, meta);
};
const bindUsage = (usage: OcxUsage | undefined) => {
if (!usage) return;
Expand Down Expand Up @@ -597,7 +597,9 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)

const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
if (contentType.includes("text/event-stream") && response.body) {
const bodyGuard = resolvePassthroughBodyGuard(config, req.signal);
// `upstream` follows req.signal and is also what shutdown and turn release abort, so both
// the client leaving and this lane's own abort read as a cancel, not as a failed stream.
const bodyGuard = resolvePassthroughBodyGuard(config, upstream.signal);
const observed = logIds ? observeFirstChunk(response.body, () => recordFirstOutput(logCtx, logIds.start)) : response.body;
const renamed = activeRequest.oauthToolNames
? restoreOAuthToolNamesInSse(observed, activeRequest.oauthToolNames, translatorBudget)
Expand All @@ -609,7 +611,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
try {
cleanupAbort();
bindUsage(logCtx.usage);
finishLog(status, undefined, meta.closeReason);
finishLog(status, undefined, meta);
if (meta.closeReason !== "terminal") upstream.abort();
} finally {
releaseStreamTurn();
Expand All @@ -621,9 +623,10 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
});
}
// A non-streaming caller whose upstream streamed anyway: fold the stream into one message.
const tapState: { closeReason?: FinalRequestLogMeta["closeReason"] } = {};
const tapState: { closeReason?: FinalRequestLogMeta["closeReason"]; meta?: FinalRequestLogMeta } = {};
const tapped = tapAnthropicSseForLog(source, logCtx, (_status, meta) => {
tapState.closeReason = meta.closeReason;
tapState.meta = meta;
}, bodyGuard);
try {
const message = await collectAnthropicMessage(tapped, requestedModel, translatorBudget);
Expand All @@ -634,7 +637,12 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
bindUsage(logCtx.usage);
if (message.type === "error") {
const error = isRec(message.error) ? message.error : {};
return fail(502, typeof error.message === "string" ? error.message : "upstream stream failed", "api_error");
const text = typeof error.message === "string" ? error.message : "upstream stream failed";
// The tap's own verdict (a reset, a stall, the byte cap) closes this row with the same meta
// as the streaming lane's row; a plain upstream error event keeps the non_stream row.
const tapMeta = tapState.meta;
if (tapMeta && (tapMeta.terminalStatus || tapMeta.closeReason !== "terminal")) finishLog(502, text, tapMeta);
return fail(502, text, "api_error");
}
finishLog(200);
return Response.json(message);
Expand Down
4 changes: 4 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,4 +462,8 @@ The [compaction routing override](../transports/responses-failover.md#compaction

Native Anthropic passthrough in `src/server/claude-messages.ts` forwards the caller's body except for tool-call ids: `sanitizePassthroughToolCallIds` runs the request-scoped allocator from `src/adapters/tool-call-id.ts` over every `*tool_use` id and `*tool_result` `tool_use_id`. Conforming ids are reserved first and stay byte-identical, a non-conforming or overlength id is rewritten to a conforming id of at most 64 characters with call/result pairing kept, and an empty id throws `AnthropicRequestError`, so the request fails with a local 400 before the upstream fetch. `tests/claude-integration/claude-native-passthrough.test.ts` covers rewriting, pairing, the empty id, the overlength id and collision with an existing valid id.

## Native passthrough stream terminals

`tapAnthropicSseForLog` in `src/server/claude-messages.ts` relays the streamed body of both the native passthrough and the managed native Messages lane (`src/server/messages-native.ts`). The response headers are already sent, so a stall, a byte-cap overflow, or an upstream read failure ends the body with an Anthropic `event: error` frame after a blank-line boundary and a clean close: `timeout_error` for an idle stall, `api_error` for the byte cap, and `api_error` when an upstream read fails mid-stream (a socket reset). The mid-stream reset is logged like the Responses relay's read error: status 502, `terminalStatus: "failed"`, `closeReason: "terminal"`, `transportPhase: "mid_stream"`, a synthetic terminal source, the attempt marked `streamAborted`, the redacted reason in `upstreamError`, and the usage seen before the reset. The non-streaming fold in the managed lane closes its row with the tap's meta for a reset, a stall or the byte cap, so its row matches the streaming lane's. The request is not replayed. The logged status is not uniform across these frames: a stall and the byte cap keep status 200 with `closeReason` `body_stall` or `body_overflow`, which classify as `incomplete`, while a reset is a 502 that classifies as `failed`. Some read failures are not upstream failures. When the cancel signal is already aborted, the rejection is a `499` client cancel, because Bun can reject the read before it dispatches the abort listener. The managed lane passes its upstream controller's signal, so shutdown and turn release count as cancels too. When `message_stop` or an upstream `error` event has been seen, including one still in the buffer without its blank-line delimiter, the turn is complete: it logs 200 and closes with no error frame. A terminal found only in that unterminated tail gets its blank line restored, because an SSE parser drops an event that EOF cuts off. A translator budget overflow is a local cap, so it still errors the stream, and the non-streaming fold answers it with 413. `tests/claude-integration/claude-native-passthrough.test.ts` and `tests/claude-integration/messages-native.test.ts` cover both lanes against an upstream that resets after a partial or a complete body, plus both cancel paths.

Linked-machine data uses the [connection-bound relay contract](../remote-link.md#connection-bound-relay-authentication); client-local credentials and routing policy remain unchanged.
Loading
Loading