Nucleic: Backend Claude OAuth Token Refresh
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user