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
43 changes: 33 additions & 10 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,14 @@ async function anthropicNativePassthrough(
logCtx.provider = "anthropic-native";
logCtx.requestedModel = model;
const finalize = createFinalRequestLog(logIds, logCtx).finish;
// Locally generated diagnostics are fixed text plus validated guard limits. Runtime
// fetch errors retain their existing client response but use a fixed stored reason.
const fail = (status: number, closeReason: PassthroughCloseReason | "non_stream", message: string, type: string, storedReason = message) => {
const safeMessage = redactSecretString(message);
logCtx.upstreamError = storedReason.slice(0, 500);
finalize(status, { closeReason });
return anthropicErrorResponse(status, safeMessage, type);
};

const base = (config.claudeCode?.anthropicBaseUrl ?? "https://api.anthropic.com").replace(/\/$/, "");
const search = new URL(req.url).search;
Expand All @@ -581,13 +589,11 @@ async function anthropicNativePassthrough(
req.signal,
);
if (result.kind === "timeout") {
finalize(504, { closeReason: "non_stream" });
return anthropicErrorResponse(504, "anthropic passthrough timed out waiting for response headers", "timeout_error");
return fail(504, "non_stream", "anthropic passthrough timed out waiting for response headers", "timeout_error");
}
if (result.kind === "error") {
const err = result.error;
finalize(502, { closeReason: "non_stream" });
return anthropicErrorResponse(502, `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error");
return fail(502, "non_stream", `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error", "anthropic passthrough failed: upstream connection error");
Comment on lines 594 to +596

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify pre-header aborts as client cancellations

When the client disconnects while the upstream request is still waiting for headers, req.signal aborts the fetch and fetchWithHeaderDeadline returns kind: "error" because its catch only distinguishes expiration from other rejections. This branch consequently records the new fixed diagnostic as an upstream connection failure with status 502, even though the cause is a client cancellation; check req.signal.aborted here and use the existing 499/client_cancel representation before assigning the connection-error reason.

Useful? React with 👍 / 👎.

}
const upstream = result.upstream;

Expand All @@ -607,23 +613,35 @@ async function anthropicNativePassthrough(
// idle/size bounds — headers are NOT yet sent here, so real statuses are available.
const bodyResult = await readBoundedPassthroughBody(upstream, bodyGuard);
if (bodyResult.kind === "client_cancel") {
finalize(499, { closeReason: "client_cancel" });
return anthropicErrorResponse(499, "client closed request during anthropic passthrough", "api_error");
return fail(499, "client_cancel", "client closed request during anthropic passthrough", "api_error");
}
if (bodyResult.kind === "stall") {
finalize(504, { closeReason: "body_stall" });
return anthropicErrorResponse(504, `anthropic passthrough body stalled: no upstream bytes for ${Math.round(bodyGuard.stallMs / 1000)}s`, "timeout_error");
return fail(504, "body_stall", `anthropic passthrough body stalled: no upstream bytes for ${Math.round(bodyGuard.stallMs / 1000)}s`, "timeout_error");
}
if (bodyResult.kind === "overflow") {
finalize(502, { closeReason: "body_overflow" });
return anthropicErrorResponse(502, `anthropic passthrough body exceeded ${bodyGuard.maxBytes} bytes`, "api_error");
return fail(502, "body_overflow", `anthropic passthrough body exceeded ${bodyGuard.maxBytes} bytes`, "api_error");
}
const text = bodyResult.text;
if (upstream.ok) {
try {
const parsed = JSON.parse(text) as { usage?: Rec };
if (isRec(parsed?.usage)) logCtx.usage = anthropicUsageToOcx(parsed.usage);
} catch { /* count_tokens etc. */ }
} else if (upstream.status >= 400) {
// Upstream text is untrusted: retain only a closed type vocabulary in history.
// Bound parsing work independently of the larger response-relay body limit.
const statusReason = `Provider error ${upstream.status}`;
logCtx.upstreamError = statusReason;
if (text.length <= PASSTHROUGH_ERROR_DETAIL_MAX_CHARS) {
try {
const parsed: unknown = JSON.parse(text);
if (isRec(parsed) && parsed.type === "error" && isRec(parsed.error)) {
const upstreamType = parsed.error.type;
const knownType = PASSTHROUGH_ERROR_TYPES.find(type => type === upstreamType);
if (knownType) logCtx.upstreamError = `${statusReason}: ${knownType}`;
}
} catch { /* Malformed errors keep the fixed status-only diagnostic. */ }
}
}
finalize(upstream.status, { closeReason: "non_stream" });
const retryAfter = upstream.headers.get("retry-after");
Expand All @@ -633,6 +651,11 @@ async function anthropicNativePassthrough(
});
}

const PASSTHROUGH_ERROR_TYPES = [
"invalid_request_error", "authentication_error", "permission_error", "not_found_error",
"rate_limit_error", "api_error", "overloaded_error", "request_too_large",
] as const;
const PASSTHROUGH_ERROR_DETAIL_MAX_CHARS = 64 * 1024;
const DEFAULT_BODY_STALL_SEC = 90;
const DEFAULT_BODY_MAX_BYTES = 64 * 1024 * 1024;

Expand Down
2 changes: 2 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,4 +466,6 @@ Native Anthropic passthrough in `src/server/claude-messages.ts` forwards the cal

`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.

A native passthrough answered without a stream records its reason in `upstreamError`, so the row and the failure diagnostics in usage.jsonl name the cause. An upstream error response (status 400 or above) is relayed verbatim; the stored diagnostic is `Provider error <status>: <type>` only for a valid Anthropic error envelope with one of the closed types `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`, `rate_limit_error`, `api_error`, `overloaded_error` or `request_too_large`. Upstream messages and arbitrary type strings never enter this diagnostic. Unknown or malformed envelopes, non-JSON bodies and bodies over 64 Ki characters log `Provider error <status>`. Local header timeout, body stall, byte-cap overflow and cancel diagnostics contain fixed text plus validated guard limits. Fetch failures log the fixed reason `anthropic passthrough failed: upstream connection error`; the existing redacted client response is preserved. Classification uses these stored reasons and HTTP status, so a `permission_error` at 403 is `permission_denied` regardless of upstream message wording. `tests/claude-integration/claude-native-passthrough.test.ts` covers these cases and checks that echoed account identifiers and request content are absent from both history sinks.

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