feat(track-a): multi-provider passthrough routes + 4 security fixes - #758
feat(track-a): multi-provider passthrough routes + 4 security fixes#758PaoloC68 wants to merge 13 commits into
Conversation
Code Review — PR #758 (multi-provider passthrough routes)Reviewed against 🔴 Bugs1. UPSTREAM_BASES = {
"openai": "https://api.openai.com",
"gemini": "https://generativelanguage.googleapis.com",
"anthropic": "https://api.anthropic.com/v1", # <-- has /v1
}The rest of the codebase ( The mock e2e tests don't catch it because they set 2. Streaming responses silently drop the upstream HTTP status — if streaming:
async def stream_chunks():
status = 200
...
async with streaming_client.stream(...) as response:
status = response.status_code # captured here
async for chunk in response.aiter_bytes():
yield chunk
...
return StreamingResponse(stream_chunks(), media_type="text/event-stream")
Standard fix: open the request, await headers, branch — return a regular 3. Hardcoded 4. Empty outbound row inserted for every passthrough request — 5. Migration comments say "Migration 018" — all three 🟡 Minor / design questions
✅ What looks good
📋 Test coverageStrong for the auth, header stripping, and recorder wiring paths. Two gaps worth filling once bugs 1–3 are fixed:
Happy to look again once the streaming-status / anthropic-base issues are addressed. |
- session_id validation: drop inaccurate truncation claim; gateway stores as-is with no enforcement (behavior for malformed values deferred to PR-B) - trust boundary: narrow strip advice to specific headers listed in this doc; add note about TRUST_USER_ID_HEADER interaction to avoid breaking user attribution - x-luthien-provider: fix closed/open enum contradiction; rephrase type as 'String — known values: ...' to make open-ended nature explicit - header link: replace self-referential PR #757 link with PR #758 (gateway impl)
- x-luthien-agent: document that gateway stores literal 'unknown' as-is (not normalized to NULL); advise consumers to COALESCE(agent, 'unknown') at query time to treat both cases uniformly - add status disclaimer at top: contract published ahead of implementation, forward refs to passthrough_routes.py and migration 019 land in PR #758
- Drop /v1 from UPSTREAM_BASES[anthropic] to fix double-prefix (/anthropic/v1/messages was reaching /v1/v1/messages upstream) - Fix streaming to peek at upstream status before committing HTTP 200; non-2xx responses now returned as plain Response with real status code - Forward upstream Content-Type in streaming path instead of hardcoding text/event-stream (fixes Gemini JSON array responses) - Skip empty outbound row in recorder._write_logs for passthrough requests that only populate the inbound side - Fail fast with 503 when OPENAI_API_KEY / GOOGLE_API_KEY unset - Fix stale Migration 018 comment in all three 019 migration files - Add unit tests: anthropic base URL regression, streaming upstream error path (401/429/500/503), Content-Type forwarding, 503 fast-fail
The 503 fast-fail for missing OPENAI_API_KEY/GOOGLE_API_KEY broke all pre-existing tests that hit /openai/ or /gemini/ without setting those env vars. Add an autouse fixture that patches both keys for the module. Also fix test_flush_called_on_upstream_error to use /anthropic/ (which forwards client auth and needs no server-side key) so the ConnectError path is actually exercised.
Review — Track A multi-provider passthroughOverall this is a well-scoped bridge PR with strong test coverage and the four called-out security fixes are real improvements. Below are the issues I'd want to see addressed (or explicitly deferred) before merge. I've grouped them by severity. 🔴 Should fix before merge1. 2. Gemini caller-supplied 3. Resource leak on if response.status_code >= 300:
error_body = await response.aread() # ← may raise
await upstream_cm.__aexit__(None, None, None)If 4. 🟡 Worth addressing5. Passthrough request/response bodies aren't recorded — 6. 7. Duplicate tests in 8. 9. 10. 🟢 Nits
✅ Things I liked
|
Code ReviewSolid PR — the security fixes called out in the description are well-implemented (lifespan-managed httpx clients, hop-by-hop stripping, strict Bugs1. outbound_auth = captured.get("Authorization", "")
assert api_key not in outbound_auth, "Proxy key must not reach upstream"The proxy normalizes outbound header names to lowercase ( Fix: lower-case the lookup, or use a case-insensitive comparison: outbound_auth = next((v for k, v in captured.items() if k.lower() == "authorization"), "")2. Duplicate fixtures and tests in
3. Comment typo — Security / defense in depth4. Body-size limit can be bypassed via chunked transfer. 5. Unfiltered header forwarding to third-party APIs. All inbound headers except 6. Smaller issues7. HTTP methods limited to GET/POST/PUT/DELETE/PATCH. No 8. 9. Hardcoded user-agent version 10. Unused Things that look good
🤖 Generated with Claude Code |
- Fix false-positive security test: use case-insensitive header lookup
for Authorization in test_openai_server_key_not_leaked_to_upstream
(proxy lowercases outbound headers; captured.get('Authorization')
always returned '' making the assertion trivially true)
- Remove duplicate module-level test functions and dead underscore
fixtures from unit tests (merge artifact)
- Fix stale 'migration 018' comment in test_request_logs_schema.py
- Remove dead request.state.luthien_* writes in _handle_passthrough
(values passed directly to recorder; state writes were never read)
- Add comment explaining why body={} is intentional for passthrough
(non-JSON / large bodies not worth serializing)
Code Review — multi-provider passthrough routesSolid security-conscious work. The strict-key gate on Bugs1. httpx auto-decompresses Fix: add 2. Non-numeric content_length = request.headers.get(\"content-length\")
if content_length and int(content_length) > MAX_REQUEST_PAYLOAD_BYTES:A client sending 3. If 4. Comment/code mismatch on shutdown ordering. The block comment says "Webhook sender goes first" but the new code Code quality5. SQLite migration is not idempotent. 6. Duplicate fixture definitions. 7. Env vars re-read per request.
8. The Security9. Strict-key auth is good, but consider: 10. Bounded by 11. PerformanceLifespan-managed clients with appropriate timeouts ( Test coverageExcellent. Gaps worth a follow-up (not blockers):
Conventions
Suggested merge actionAddress #1, #2, #3 before merging (real bugs, all small). The rest are polish. |
- Strip content-encoding (and content-length) from upstream responses: httpx auto-decompresses, so forwarding content-encoding: gzip with decoded bytes causes clients to double-gunzip. Added _STRIP_RESPONSE frozenset applied in both buffered and non-2xx streaming branches. - Wrap int(content_length) in try/except ValueError -> 400: a client sending content-length: abc previously crashed the handler with 500. - Fix upstream connection leak on aread() failure: moved __aexit__ into a try/finally so the upstream connection is always closed even if aread() raises. - Fix misleading shutdown comment in main.py: passthrough clients close before webhook sender (they are independent); updated comment to reflect actual ordering and preserve the webhook ordering rationale. - Add unit tests for all three bugs: content-encoding stripping, malformed content-length -> 400, valid content-length passthrough.
Review — multi-provider passthrough routesOverall this is a thoughtful, security-conscious PR. The 4 documented fixes (strict client key on /openai+/gemini, body-size limit, lifespan-managed httpx clients, hop-by-hop stripping) all look correctly implemented. The streaming "peek at status before committing to Below are concerns ordered roughly by impact. Security1. 2. Body-size enforcement is reactive (low/medium). 3. Operator foot-gun: Correctness / Bugs4. Streaming 2xx drops all upstream response headers except content-type (medium). Compare the buffered path ( 5. Connection leak window if 6. Test coverage gaps7. No unit test for the new 8. No test for the streaming-headers-dropped behavior in #4 above — if you preserve headers on streaming responses, add a regression test (e.g. mock upstream returns 9. No test for the Nits
Nice work. The architecture comment marking this as "replaced by native pipeline in Track B" sets the right expectation that this is bridge code. |
- Strip ?key= from Gemini query string before forwarding upstream to
prevent clients from bypassing server-injected x-goog-api-key auth.
Other query params (e.g. ?alt=sse) are preserved. Only applies to
the gemini provider; openai/anthropic query strings pass through.
- Pass safe_headers to StreamingResponse on 2xx path so upstream
headers (x-request-id, anthropic-ratelimit-*, openai-organization,
etc.) are forwarded to streaming clients, matching the buffered path.
Extracted _safe_response_headers() helper to deduplicate the filter
logic across buffered, non-2xx streaming, and 2xx streaming branches.
- Add unit tests:
- TestGeminiKeyQueryStripping: key= stripped, other params preserved,
non-gemini providers unaffected
- TestStreamingResponseHeaders: safe headers forwarded on 2xx,
dangerous headers stripped on 2xx streaming
- TestWriteLogsSkipsEmptyOutbound: regression guard for the
recorder._write_logs skip-empty-outbound branch
Code ReviewSolid PR — the security framing in the description matches what's in the diff, the unit and e2e coverage is good, and the streaming "peek status then commit" pattern is the right call. Comments below are roughly in priority order. Bugs / correctness1. if pending.http_method is None and pending.direction == "outbound":
continueUsing 2. Recorder records the inbound URL verbatim ( recorder.record_inbound_request(... url=str(request.url) ...)
3. Starlette's 4. Possible upstream connection leak on
Security5. The Anthropic and OpenAI SDKs emit 6. Documented behavior, and consistent with the existing /v1 chain. Worth confirming this is fine for the Anthropic alias since the alias forwards client auth as-is. The PR description says "Open proxy closed" for /openai and /gemini — would be worth restating in the changelog that /anthropic intentionally is not subject to strict CLIENT_API_KEY because it inherits the existing /v1 auth semantics, to head off confusion in security audits. Code quality7. Duplicated header-filtering at Same expression as 8. Two
9. Minor consistency nit. TestsCoverage is strong. Gaps that would be worth adding:
Done well
🤖 Generated with Claude Code |
- recorder.py: replace brittle http_method-is-None sentinel with an
explicit 'populated' bool on _PendingLog, set in record_outbound_request.
Non-passthrough flows that forget to call record_outbound_request now
produce a visible NULL row instead of being silently dropped.
- passthrough_routes.py: sanitize ?key= from inbound URL before logging
to recorder so Gemini API keys don't end up stored in request_logs.
- passthrough_routes.py: replace duplicated header-filter dict
comprehension in non-2xx streaming branch with _safe_response_headers().
- main.py: add comment explaining why two httpx clients exist (different
read timeouts: 300s streaming vs 30s buffered).
- Add unit tests:
- TestIsStreaming: empty body, non-JSON, stream=True bool, stream=False,
stream='true' string, :streamGenerateContent path, no stream key
- TestPendingLogPopulatedFlag: defaults False, set by record_outbound_request,
_write_logs skips unpopulated outbound, includes populated outbound
Code Review (claude-opus-4-7)Solid PR overall. The security-fix summary in the description matches the implementation, and unit-test coverage of the new passthrough surface is good. A few items worth addressing before merge. Likely bugs
Medium
Low / nits
Things done well
— claude-opus-4-7 |
- recorder.py: fix misleading comment in _write_logs — 'continue' silently skips the outbound row, it does NOT surface a NULL row. - recorder.py: set populated=True in record_outbound_response() so callers that only invoke response (not request) don't silently lose their outbound row. Prevents quiet data loss for any future caller that records a response without a preceding request. - main.py: increase buffered httpx client read timeout from 30s to 120s. Long non-streaming generations (extended thinking, large max_tokens) regularly exceed 30s; 30s was causing unnecessary 502s. - passthrough_routes.py: tighten _is_streaming to use strict 'is True' check instead of bool() so non-boolean truthy values (e.g. 'true' string) don't incorrectly trigger streaming mode. - passthrough_routes.py: add comment on mid-stream error path noting that the client has already received 200 OK and a partial body. - Update tests: test_stream_string_true now asserts False (strict bool), add test_record_outbound_response_sets_populated.
Code Review — PR #758: Multi-provider passthrough routesGenerally solid PR with good test coverage (810 lines of unit tests + sqlite/mock e2e). The 4 explicit security fixes from the #614 review are well-implemented. A few concerns worth surfacing before merge: Security:
|
- passthrough_routes.py: pass status_code=response.status_code to StreamingResponse so 201/206 upstream responses are not silently downgraded to 200. - passthrough_routes.py: add 'from exc' to ValueError HTTPException raise to preserve exception chain. - main.py: emit logger.warning at startup when /anthropic/* passthrough route is active, noting that requests bypass the policy chain. This is a known temporary limitation until Track B (#563-569) lands.
Review: Track A passthrough routes + security fixesThoughtful PR with a clear security narrative and strong test coverage. A few items worth addressing before merge. Findings1. 2. Streaming flush can be skipped if finally:
await upstream_cm.__aexit__(None, None, None) # if this raises…
recorder.record_inbound_response(status=status, error=error) # …never runs
recorder.flush()If the upstream connection cleanup itself raises (network blip, TLS teardown error), the recorder is never flushed and you lose the inbound row. Wrap 3. Passthrough body recorded as recorder.record_inbound_request(
...
body={}, # passthrough bodies may be non-JSON or very large; not loggedThe recorder serializes 4. 5. Minor: redundant 6. Minor: What's good
Nits
Overall: solid Track A bridge with the right security posture. Address #2 (flush ordering) before merge; the rest are quality-of-life improvements. 🤖 Review generated with Claude Code |
- passthrough_routes.py: wrap upstream_cm.__aexit__ in try/except in
stream_chunks finally block so recorder.flush() always runs even if
connection cleanup raises (network blip, TLS teardown error).
- passthrough_routes.py: pass body=None instead of body={} to recorder
so 'not captured' is distinguishable from 'empty payload' in logs.
- recorder.py: widen record_inbound_request body param to
dict[str, Any] | None to accept None (serialize_body already handles
None correctly; also fixes pre-existing type error at test line 520).
- main.py: downgrade /anthropic/* startup log from warning to info
since the bypass is intentional documented behavior for Track A.
Code Review — PR #758: Multi-provider Passthrough RoutesReviewed 🔴 Recommend addressing1. Inbound hop-by-hop headers forwarded upstream (
|
- passthrough_routes.py: strip hop-by-hop headers (connection, keep-alive, transfer-encoding, te, trailer, upgrade, proxy-authenticate, proxy-authorization) and cookie from inbound requests before forwarding upstream. RFC 7230 §6.1 requires intermediaries not to forward hop-by-hop headers; cookie forwarding could leak browser session data to third-party APIs. - passthrough_routes.py: remove content-type from safe_headers on the streaming 2xx path to avoid duplicate Content-Type header when media_type= is also passed to StreamingResponse. - recorder.py: fix NoOpRequestLogRecorder.record_inbound_request signature drift — body param now matches the real implementation's dict[str, Any] | None type.
Code Review — PR #758Solid PR. The four advertised security fixes are real and the test coverage for each is concrete. I have one medium-severity concern about the body-size limit, and a handful of nits and consistency observations. Nothing I'd block on. Security🟡 Body-size limit is too late to prevent memory exhaustion — content_length = request.headers.get("content-length")
if content_length:
try:
if int(content_length) > MAX_REQUEST_PAYLOAD_BYTES:
raise HTTPException(status_code=413, ...)
except ValueError as exc:
raise HTTPException(status_code=400, ...)
body = await request.body() # ← reads entire body into memory
if len(body) > MAX_REQUEST_PAYLOAD_BYTES:
raise HTTPException(status_code=413, ...)The post- body = b""
async for chunk in request.stream():
body += chunk
if len(body) > MAX_REQUEST_PAYLOAD_BYTES:
raise HTTPException(status_code=413, detail="Request payload too large")…or enforce the limit at the ASGI middleware layer. The current code is fine against honest clients but doesn't really close the DoS vector against a hostile one. 🟢 The other three security fixes hold up under read-through. 🟢 Lifespan-managed httpx clients are correctly torn down in Bugs / Correctness🟡 Inbound
streaming = _is_streaming(path, body)
recorder.record_inbound_request(
...,
is_streaming=streaming,
)🟡 If a caller ever invokes 🟢 Streaming non-2xx handoff is well-designed — Manually 🟢 Stream cleanup on mid-flight error — The Code Quality / Consistency🟡 Provider config bypasses the central config registry —
🟢 Comments document non-obvious decisions clearly — The two-client design (300s vs 120s timeouts), the 🟢 Anthropic alias in CLIENT_KEY mode is effectively useless but not broken — In CLIENT_KEY mode, Test Coverage🟢 Excellent. Unit tests cover both auth helpers across all three auth modes, body-size enforcement, hop-by-hop stripping (request and response), missing-server-key 503 paths, streaming error-status forwarding, Gemini One small gap: there's no test that verifies the body-size check actually triggers via a streaming/chunked-encoded request (which is the case where the header check is bypassed). Worth adding once that path is hardened. Migrations🟢 019 looks correct. Postgres uses Summary
Approve once the chunked-body DoS path is either fixed or explicitly accepted with a follow-up ticket. |
…_response populated - passthrough_routes.py: compute _is_streaming before record_inbound_request and pass is_streaming=streaming so streaming requests are correctly logged as streaming in request_logs (was always False before). - recorder.py: remove populated=True from record_outbound_response — only record_outbound_request should mark the outbound row as populated. Callers that only call response without request would produce a row with NULL method/url/etc., which is misleading.
Code review — multi-provider passthrough routesSolid scope reduction from #614 with thoughtful security hardening. The two-client split (streaming vs buffered), peek-before-commit pattern for streaming status codes, and strict Bugs / correctness1. 2. No rate limiting on passthrough routes (medium) 3. Possible connection leak between 4. Smaller observations
TestsCoverage is genuinely strong — 281 lines on auth, 810 lines on routes, sqlite_e2e and mock_e2e tiers, mock servers for both new providers, schema regression test for migration 019. Particularly nice: Gaps I noticed:
Migrations
Nice work overall — the security hardening (closed open proxy, strict client-key check, hop-by-hop stripping, lifespan-managed clients, query |
Anthropic SDKs default to x-api-key header rather than Authorization: Bearer. verify_passthrough_token now extracts the token from Bearer, x-api-key, or x-anthropic-api-key headers (in that order) so real Anthropic SDK clients work without modification. Also: add exc_info=True to upstream connection close warning so the exception cause is surfaced in logs. Tests: add request= param to all verify_passthrough_token calls, add tests for x-api-key and x-anthropic-api-key header auth.
PR Review: Multi-provider passthrough routes + 4 security fixesOverall this is a well-scoped, well-tested PR with clear "why" comments throughout and a documented threat model. The security hardening (strict client-key auth, body-size limit, hop-by-hop stripping, lifespan-managed clients) is solid. Below are observations grouped by severity. Bugs / correctness
Security observations
Code quality
Tests
Migration
SummaryApprove with minor nits. The bugs above are mostly hardening suggestions; the open-proxy and policy-bypass concerns on Nice work on the test depth and the threat-model commentary in the docstrings. 🤖 Generated with Claude Code |
# Conflicts: # src/luthien_proxy/main.py
Code Review — PR #758 (multi-provider passthrough + security fixes)Overall this is a well-scoped split-out. The security posture is materially better than the original PR #614 — strict A few things worth addressing before merge. Substantive
Smaller items
Nits
Nothing blocking on security grounds. The config-system bypass (#1) is the most important architectural item; the body-reparse cost (#2) and missing mid-stream-error test (#5) would be nice to address before merge. |
83267d9 to
e797ff0
Compare
Review: multi-provider passthrough + 4 security fixesSolid PR — auth model is tight, the 4 security fixes match real risks, and test coverage on the sanitize/auth surface is genuinely good. A few items worth addressing before merge, plus some smaller polish notes. Security / correctness — worth fixing before merge
Code quality
Tests
Migration
Things done well, for the record
Nothing here blocks the spirit of the PR — the security model is correct, and the surface this introduces is well-understood. Items 1–5 are the ones I'd recommend addressing before promoting out of draft. |
|
Claude-generated merge-queue triage of all open Luthien PRs, requested by Scott (Jul 7, 2026). Advisory only; Scott has not yet acted on these recommendations. Recommendation: close in favor of #796, with the security findings here carried forward. #796 delivers the multi-provider passthrough capture this PR pioneered, is mergeable against current main, and both PRs create the same |
Part 2 of 3 splitting PR #614.
Depends on PR #757 (httpx-sse dep) — merge order: #757 → #758 → #759.
Changes
/openai/{path},/gemini/{path},/anthropic/{path}agentcolumn torequest_logsSecurity Fixes (from PR #614 review)
/openaiand/geminirequire strictCLIENT_API_KEYmatchMAX_REQUEST_PAYLOAD_BYTESon passthrough bodytransfer-encoding,set-cookie,serverstripped from responsesCloses part of #614.