diff --git a/Sources/NucleicCore/Claude/ClaudeTokenProxy.swift b/Sources/NucleicCore/Claude/ClaudeTokenProxy.swift index b55f6fde..507396b2 100644 --- a/Sources/NucleicCore/Claude/ClaudeTokenProxy.swift +++ b/Sources/NucleicCore/Claude/ClaudeTokenProxy.swift @@ -154,14 +154,16 @@ public actor ClaudeTokenProxy { } } - /// Serialize an upstream response back to the client: status line, headers (hop-by-hop stripped, - /// `Connection: close` added, `Content-Length`/`Transfer-Encoding` dropped since we stream and - /// close), then the body chunks as they arrive. + /// Serialize an upstream response back to the client: status line, headers (hop-by-hop and stale + /// representation framing stripped), `Connection: close`, then the body chunks as they arrive. + /// URLSession transparently decodes compressed upstream bodies, so forwarding the original + /// `Content-Encoding` would make Claude's fetch decode those bytes a second time (`ZlibError`). static func writeResponse(_ response: UpstreamResponse, to writer: ByteWriter) async { var head = "HTTP/1.1 \(response.status) \(reasonPhrase(response.status))\r\n" for (name, value) in response.headers { let lower = name.lowercased() - if lower == "content-length" || lower == "transfer-encoding" || lower == "connection" { continue } + if lower == "content-length" || lower == "transfer-encoding" + || lower == "content-encoding" || lower == "connection" { continue } head += "\(name): \(value)\r\n" } head += "Connection: close\r\n\r\n" @@ -238,6 +240,11 @@ public actor ClaudeTokenProxy { for (name, value) in request.headers { urlRequest.setValue(value, forHTTPHeaderField: name) } + // Claude's fetch advertises gzip/br. URLSession decodes compressed responses before exposing + // their bytes but can retain the upstream Content-Encoding header, which would make the + // downstream fetch decompress the decoded stream again. Request identity as the primary + // defense; writeResponse also drops stale encoding metadata defensively. + urlRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding") if !request.body.isEmpty { urlRequest.httpBody = request.body } #if canImport(FoundationNetworking) diff --git a/Tests/NucleicCoreTests/ClaudeTokenProxyTests.swift b/Tests/NucleicCoreTests/ClaudeTokenProxyTests.swift index e44ffb59..e98cf3a3 100644 --- a/Tests/NucleicCoreTests/ClaudeTokenProxyTests.swift +++ b/Tests/NucleicCoreTests/ClaudeTokenProxyTests.swift @@ -152,7 +152,11 @@ struct ClaudeTokenProxyTests { let stub = UpstreamStub { _ in Self.response( status: 200, - headers: [("content-type", "text/event-stream"), ("content-length", "999")], + headers: [ + ("content-type", "text/event-stream"), + ("content-encoding", "gzip"), + ("content-length", "999"), + ], chunks: ["event: a\ndata: 1\n\n", "event: b\ndata: 2\n\n"]) } let proxy = ClaudeTokenProxy(tokenProvider: { "t" }, upstream: stub.upstream()) @@ -167,6 +171,8 @@ struct ClaudeTokenProxyTests { #expect(out.contains("Connection: close")) // We stream + close, so the upstream framing headers are dropped (they'd mislead the client). #expect(!out.lowercased().contains("content-length: 999")) + // URLSession has already decoded the body; advertising gzip makes fetch double-decode it. + #expect(!out.lowercased().contains("content-encoding")) #expect(out.contains("event: a\ndata: 1\n\n")) #expect(out.contains("event: b\ndata: 2\n\n")) } @@ -208,7 +214,12 @@ struct ClaudeTokenProxyTests { #if canImport(Network) || os(Linux) @Test func loopbackListenerInjectsTokenEndToEnd() async throws { let stub = UpstreamStub { _ in - Self.response(status: 200, headers: [("content-type", "text/plain")], chunks: ["ok"]) + // Model URLSession's decoded bytes plus the stale upstream gzip header that caused + // Claude/undici to throw ZlibError before the proxy normalized response metadata. + Self.response( + status: 200, + headers: [("content-type", "text/plain"), ("content-encoding", "gzip")], + chunks: ["ok"]) } let proxy = ClaudeTokenProxy(tokenProvider: { "fresh" }, upstream: stub.upstream()) let port = try await proxy.start()