Skip to content

Commit e4309d1

Browse files
v0.8.0: first-class authentication (#120)
* feat(clients): per-client auth credential mechanism (v0.8.0 Phase A) Add the one-credential-per-request mechanism at the client layer: forge carries exactly one credential to the backend in its native auth header, and two credentials present anywhere is a hard error (Design Principle #1, fail loud, no silent merge or precedence). - base.py: AUTH_HEADER_NAMES, has_auth_header, static_auth_present (construction-time two-source guard), resolve_request_headers (per-call one-credential rule), redact_auth_headers; extra_headers added to the send/send_stream protocol. New MultipleCredentialsError. - openai_compat/ollama/llamafile/vllm: construction api_key+extra_headers (new for the latter three) and per-call extra_headers on every outbound path (incl. ollama think-retry re-issues and llamafile's three bodies), via a _request_headers helper. Never mutates shared construction headers, so a per-request credential can't leak across the proxy's reused client. - anthropic: construction default_headers + per-call extra_headers via the SDK's per-call extra_headers=; re-cases auth headers to the SDK's case-sensitive X-Api-Key/Authorization slots; drops SDK-pinned anthropic-version/anthropic-beta; strips SDK control kwargs (extra_headers/extra_body/extra_query/timeout) a verbatim/passthrough body could smuggle past the credential gate. - Removes the old silent "extra_headers overrides api_key Authorization" behavior (ambiguous two-source config) in favor of a fail-loud guard. Tests: tests/unit/test_client_auth.py asserts real wire headers via httpx.MockTransport (and the Anthropic SDK pipeline), covering construction and per-call credentials, the two-source raises, the no-leak property across serialized requests, cross-protocol re-casing, and the smuggle guards. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NTCB4kdRSVnaw7JrGzJ6Xu * feat(inference): thread per-call credential through run_inference (v0.8.0 Phase B) run_inference and _send_streaming gain an extra_headers param, forwarded to client.send / send_stream using the same splat-only-when-set idiom as raw_openai_tools — the kwarg is passed only when set, so clients and test doubles that don't declare it keep their original signature. This is the seam the proxy uses to forward a relocated inbound credential to the backend. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NTCB4kdRSVnaw7JrGzJ6Xu * feat(proxy): inbound credential relocation + --backend-api-key (v0.8.0 Phase C) Wire the proxy to forward exactly one credential to the backend in its native auth header. An inbound auth header is relocated to the target protocol's canonical slot; a static --backend-api-key is the alternative; two credentials anywhere is a hard error (Design Principle #1). - proxy/auth.py (new): extract_inbound_credential (refuses two distinct auth headers, and — via a marker the header reader sets — the same auth header name repeated; treats a blank value as absent), relocate_credential (same-protocol verbatim; cross-protocol normalizes the token and writes the target's canonical slot: anthropic x-api-key, openai Authorization: Bearer), resolve_inbound_credential (extract → refuse inbound+static → relocate). forge forwards ONLY the one relocated credential; no other inbound header is forwarded, so httpx recomputes transport headers and no hop-by-hop stripping is needed. - server.py: thread inbound headers (per-request _QueueItem.headers) through both the direct and serialized-queue dispatch paths; HTTPServer learns backend_protocol + backend_api_key_present (never the raw secret). A credential conflict surfaces as 400 (client error), not 502. - handler.py: resolve the one credential once; thread into run_inference and the no-tools direct send. - proxy.py / __main__.py: --backend-api-key flag (FORGE_BACKEND_API_KEY env), baked into all backend clients at construction. - anthropic.py: when forge owns the credential (api_key is not None, incl. ""), suppress ambient ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN during construction and map ""→None, so no env credential and no spurious empty X-Api-Key can become a hidden second credential. api_key=None still defers to env (WR). Tests: proxy/auth full relocation matrix + hard errors; server-level threading across both serialize paths; duplicate-header and inbound+static both refused 400 with no secret in the body; Anthropic env-suppression regressions. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NTCB4kdRSVnaw7JrGzJ6Xu * docs(auth): redacted credential debug log + BACKEND_SETUP auth section (v0.8.0 Phase D) - handler.py: at the proxy's credential-resolve point, emit a DEBUG log of the forwarded auth header NAME with the value redacted wholesale (x-api-key: ***) via redact_auth_headers. Never logs a raw secret. No-ops without handlers (library use); the proxy CLI enables it under -v. - docs/BACKEND_SETUP.md: new Authentication section — the one-credential principle, WorkflowRunner (construction vs per-call) and proxy (inbound passthrough vs --backend-api-key) usage, the cross-protocol relocation table, the documented OAuth-via-OpenAI-endpoint limitation, and the ambient-env / keyless-passthrough / redacted-logging notes. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NTCB4kdRSVnaw7JrGzJ6Xu * fix(proxy): zero-credential request to Anthropic backend fails loud as 401 Live testing surfaced that a request reaching an Anthropic backend with NO credential at all (proxy pure-passthrough: api_key="" -> None, no ambient env, no inbound auth header) was refused by the Anthropic SDK with an opaque client-side "Could not resolve authentication method" error, surfaced to the caller as HTTP 502 + raw SDK text. The refusal is correct (an Anthropic backend always needs a credential) but the status/message were poor. Detect the zero-credential precondition before dispatch and fail loud with a clear forge error mapped to HTTP 401: - errors.py: new MissingCredentialError (counterpart to MultipleCredentialsError; carries no secret). - clients/anthropic.py: _ensure_credential() guards send()/send_stream() — a credential is present iff the SDK resolved a construction key (api_key/ auth_token, incl. ambient env at build time) or this call carries a per-call auth header. WR direct use (api_key=None reading ANTHROPIC_API_KEY) and static --backend-api-key are unaffected; only true zero-credential dispatch raises. - proxy/server.py: map MissingCredentialError -> 401 (Unauthorized). Verified live: pure-passthrough Anthropic proxy with no inbound auth now returns a clean 401 with the forge message, no SDK gibberish. +3 unit tests; full suite 1270 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NTCB4kdRSVnaw7JrGzJ6Xu * feat(proxy): defer external-mode backend discovery to first request (v0.8.0 finding #2) External passthrough mode (no --backend-api-key) against a gated OpenAI-shape backend crashed at startup: the context-length probe (llama.cpp /props, vLLM /v1/models) and vLLM's served-name probe ran unauthenticated at boot and got a 401. Defer all external-mode startup backend probes to the first request, where they authenticate with that request's inbound credential — preserving zero-config auto-discovery for the gated-passthrough cohort instead of forcing --budget-tokens. - New LLMClient.discover_backend_metadata(extra_headers): one credentialed probe that returns the context budget and adopts any backend-owned wire identity (vLLM served-model-name) into the client. vLLM collapses its two /v1/models round-trips into one; anthropic/openai_compat/ollama are no-network stubs. - proxy._setup_external defers when passthrough (no static key) and there is a probe to run; a static --backend-api-key keeps eager startup discovery and boot fail-fast. Managed mode and the Anthropic external path are untouched. - A LazyDiscovery latch carries the deferral state proxy -> HTTPServer -> handler; the handler runs the probe once, before BOTH dispatch paths (vLLM needs its identity on every request), fails loud on a missing budget (no silent default), and latches on success only (a failed probe retries on the next request). - New BackendDiscoveryError -> 401 on a backend auth rejection (401/403), else 502. Validation: - 1302 unit tests (32 new: deferral wiring, lazy probe run/latch/failure, client discover_backend_metadata, concurrent first-requests, error->status mapping). - New self-contained gated-backend smoke test (scripts/smoke_test_proxy.py) proving deferred startup, first-request credentialed discovery, clean 401 on a missing credential, and no-latch-on-failure (retry succeeds). Also unstales harness scripts surfaced while live-validating (pre-existing drift): integration_test_proxy.py used a removed `mode=` kwarg (now backend_capability); smoke_test_proxy.py's path-1 test asserted pre-finding-#1 behaviour (now sends an inbound credential, as the one-credential rule requires) and its mocks send Connection: close for the two-hit deferred-probe flow. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NTCB4kdRSVnaw7JrGzJ6Xu * fix(proxy): address Codex review — secret hygiene, auth-error status, empty-bearer, CORS Independent review (Codex) of the v0.8.0 auth branch. This commit addresses the findings that don't change request behaviour; streaming-error status (#2) is held for discussion and the discovery race (#3) is documented, not locked. - #1 (secret hygiene): a backend error body or traceback can echo an inbound auth header. Add redact_secrets() and apply it at the proxy boundary — error responses, the error log, and the handler-error traceback — scrubbing Bearer tokens, x-api-key values, and sk- key prefixes. Best-effort net; forge still never authors a secret into a message. - #4 (empty credential): "Authorization: Bearer " (scheme, no token) was treated as present and relocated to an empty x-api-key / "Bearer ". Presence now checks the token after the scheme, matching relocate_credential — token-less auth headers are absent and fail loud. - #5 (status): a backend 401/403 during normal dispatch was mapped to 502. Map BackendError(401/403) -> 401 (caller's auth problem), keeping other faults 502. - #6 (CORS): allow X-Api-Key (+ anthropic-version/anthropic-beta) in preflight so browser clients can send the Anthropic credential slot. - #3 (documented): comment the deferred-discovery concurrency model — no lock by design (idempotent probe + await-free commit, no torn state) and the single-backend/credential-independent-metadata assumption it relies on. +12 tests (empty/scheme-only bearer, redact_secrets, backend 401/403->401 vs 500->502, no-secret-leak through the proxy response, CORS x-api-key). 1314 unit pass; gated-passthrough smoke suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NTCB4kdRSVnaw7JrGzJ6Xu * fix(proxy): resolve credential + run discovery before flushing streaming headers (Codex #2) A streaming request (stream=true) flushed the 200 OK / SSE headers before the handler ran, so a bad/duplicate credential or a failed first-request discovery came back as 200 + an SSE error event instead of a real 400/401. Run those checks in a pre-dispatch pass before the header flush: on failure return the real HTTP status; on success flush as before (success path unchanged, including the managed-mode "alive while queued" early header). - Extract run_lazy_discovery() from the handler so the pre-check and the handler share one copy (the handler call is a no-op once latched). - server._predispatch resolves the credential + runs discovery; failures map via a shared _send_exception (also dedups the existing error-status mapping). - Only the streaming-error path changes; non-streaming already carried a real status, and successful streaming is unchanged. - Update the BACKEND_SETUP SSE caveat accordingly. Scope note: this catches the proxy-level streaming errors — MultipleCredentials (two creds) and deferred-discovery 401/502. MissingCredentialError raised by the Anthropic client at send time (external passthrough, zero credentials) is a client-send decision the proxy can't pre-detect without a per-backend opinion, so that narrow streaming case still surfaces as an SSE event. +3 tests (streaming dup-auth -> 400, streaming discovery 401, streaming success unchanged). 1317 unit pass; smoke suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NTCB4kdRSVnaw7JrGzJ6Xu * fix(auth): cut backend bodies from error messages; enforce one-credential + reject blank creds at the client layer (Codex pass 2) Second Codex pass on the v0.8.0 auth branch. #3 (secret hygiene, the right way): replace the best-effort redact_secrets() denylist with safe-by-construction errors. BackendError keeps a forge-authored `detail` in its message (safe — forge never writes a secret) and takes the raw backend body via `raw_body=`, which rides exc.body and never enters the message, logs, or traceback. All raw-response sites (resp.text / streaming error bodies) route through raw_body; forge's own diagnostics ("no choices", "missing max_model_len", ...) stay in the message. redact_secrets and the proxy redaction plumbing are deleted. #2 (one credential at the client layer): static_auth_present and resolve_request_headers now COUNT auth credentials and refuse more than one in any single bag — two construction auth headers, or two per-call extra_headers — not just the static-vs-per-call collision. The proxy already enforced this on inbound; this brings the direct library path in line. #4 (no blank/garbage credentials): a blank/whitespace/scheme-only credential is not counted or forwarded. Client constructors guard the Bearer header on api_key.strip(); ProxyServer normalizes a blank --backend-api-key to None so it neither rides the wire as "Bearer " nor wrongly disables lazy discovery. #1 (streaming): kept the pre-dispatch checks before the SSE header. The residual — a backend rejecting the credential mid-dispatch on a streaming request arrives as an SSE error event because the proxy buffers — is documented in BACKEND_SETUP as a known limitation (the real fix is incremental streaming; WorkflowRunner already streams). #5 (multi-tenant race) is out of scope: it requires multiple tenants sharing one forge proxy in front of a per-key model router; the one-backend-one- model assumption is already noted in code. +10 auth tests (two-header refusal at construction & per-call, blank-credential absence, blank static key still defers); secret-not-leaked updated to the cut. 1323 unit pass; smoke suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NTCB4kdRSVnaw7JrGzJ6Xu * chore(release): bump to v0.8.0 + CHANGELOG entry Version 0.7.6 -> 0.8.0 and the v0.8.0 changelog section (first-class auth; one BREAKING change scoped to auth-required backends, ungated local backends unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NTCB4kdRSVnaw7JrGzJ6Xu --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent f670d32 commit e4309d1

29 files changed

Lines changed: 3187 additions & 129 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to forge are documented here.
44

5+
## [0.8.0] — 2026-06-27
6+
7+
First-class authentication across all proxy modes and backends. forge now forwards exactly one credential to the backend — a static `--backend-api-key` or a single inbound auth header — relocating it across protocols (`x-api-key``Authorization: Bearer`) when the frontend and backend differ. Gated OpenAI-compatible backends (LM Studio, hosted vLLM, service accounts) work without monkey-patching. Closes #119.
8+
9+
### Added
10+
- **`--backend-api-key` / `FORGE_BACKEND_API_KEY`** — a static credential forge sends to the backend in its native auth slot (LM Studio, hosted providers, service accounts — the case where the caller sends nothing). Baked into the backend client at startup and relocated to the backend's protocol slot. When set, an inbound auth header is refused as a second credential.
11+
- **Cross-protocol credential relocation** — an inbound `x-api-key``Authorization: Bearer` is rewritten to the backend's protocol when frontend and backend differ (the SSO/forwarded-token case). Frontend protocol is by path (`/v1/chat/completions` = openai, `/v1/messages` = anthropic); backend by `--backend-protocol`. See the auth section in [Backend Setup](docs/BACKEND_SETUP.md).
12+
- **Deferred backend discovery for gated external backends.** The context-length / served-model-name probe (llama.cpp `/props`, vLLM `/v1/models`) now runs on the **first request**, authenticated by that request's credential, instead of unauthenticated at startup — which previously 401'd and crashed boot against a gated backend. Managed mode and the Anthropic external path are unaffected. New `LLMClient.discover_backend_metadata(extra_headers)`; vLLM collapses its two `/v1/models` round-trips into one.
13+
14+
### Changed
15+
- **BREAKING — credential handling for auth-required backends.** This only affects backends that *require* auth. **Ungated local backends are unchanged — leave `--backend-api-key` unset and send no auth header, exactly as before; zero credentials is the normal local path and still works.** What changed: when a backend *does* require a credential, a request carrying **zero** now fails loud (401) instead of sending an empty/garbage header that produced an opaque downstream error; and **two** credentials at once (static + inbound, or two inbound auth headers) are refused with **400** rather than one silently winning. Migration: nothing to do for local/ungated backends. For gated backends, supply exactly one credential — set `--backend-api-key` (or `FORGE_BACKEND_API_KEY`), *or* forward an inbound auth header, not both.
16+
- **Removed the silent `extra_headers`-overrides-`api_key` merge.** A per-call auth header in `extra_headers` no longer shadows a client's configured `api_key`; that combination is now a two-credential conflict (400). Migration: pass the credential one way only. (Undocumented prior behavior, unlikely to be relied on.)
17+
518
## [0.7.6] — 2026-06-20
619

720
A bug-fix release for the Ollama backend and inline reasoning capture. Multi-turn tool sessions and multi-part message content no longer 400 against Ollama's native API, and chain-of-thought emitted inline in `content` is now captured on vLLM and Ollama as it already was on the structured-field path.

docs/BACKEND_SETUP.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,103 @@ Install instructions for each backend live with the upstream project. Below is w
1515

1616
---
1717

18+
## Authentication
19+
20+
forge carries **exactly one credential** to the backend, placed in the backend's
21+
native auth header. forge does not validate the credential, manage its lifecycle
22+
(expiry/refresh), or form any opinion on its value — it only relocates it into
23+
the correct header slot for the target backend. Auth failures therefore surface
24+
as the backend's own error (401/403), not a forge error.
25+
26+
**The one rule:** exactly one credential reaches the backend. If two are present
27+
anywhere, forge **refuses the request** — it never merges, never picks a winner,
28+
never silently drops one. (Design Principle #1: fail fast, fail loud.)
29+
30+
### WorkflowRunner (library use)
31+
32+
Supply the credential at construction, or per call for a rotating token:
33+
34+
```python
35+
# Static credential (API key, service account): set once at construction.
36+
client = OpenAICompatClient(model=..., base_url=..., api_key=API_KEY)
37+
38+
# Rotating credential (e.g. an SSO token refreshed out of band): per call.
39+
await client.send(messages, extra_headers={"Authorization": f"Bearer {token()}"})
40+
```
41+
42+
A construction credential **and** a per-call auth header on the same call is two
43+
credentials → raises `MultipleCredentialsError`. Pass auth through one channel.
44+
45+
For a non-Bearer scheme, pass `extra_headers` alone (omit `api_key`); supplying
46+
both `api_key` and an auth header at construction is also refused.
47+
48+
### Proxy
49+
50+
The proxy gets its one credential from one of two sources — never both:
51+
52+
1. **Inbound passthrough.** The caller's request already carries a credential;
53+
forge forwards it, relocating the header to the backend's protocol when they
54+
differ (see the table below). This is the SSO/forwarded-token case.
55+
2. **Static `--backend-api-key`** (or the `FORGE_BACKEND_API_KEY` env var) for
56+
backends where the caller sends nothing — LM Studio, hosted providers,
57+
service accounts. Baked into the backend client at startup.
58+
59+
If an inbound auth header **and** `--backend-api-key` are both present, or a
60+
single request carries **two** auth headers, the proxy refuses it with **HTTP
61+
400** (a client error — the message names the conflicting slots, never a secret).
62+
This holds for **streaming** requests too: the credential is resolved (and a
63+
gated backend's context discovered) *before* the `200 OK` / SSE headers are
64+
flushed, so a *conflict* (two credentials) or a discovery failure returns the
65+
real status (400/401) rather than a stream that opens `200 OK` and then carries
66+
an error event.
67+
68+
One streaming case is unavoidable today: an error that surfaces only when the
69+
backend is actually called — the backend **rejecting the credential** (401), or
70+
refusing a request with no credential — happens *after* the SSE headers are
71+
flushed, because the proxy buffers the response rather than streaming it
72+
incrementally. Such failures arrive as an error *event* inside the already-open
73+
`200` stream, not as a `401` status. (Non-streaming requests always get the real
74+
status; and the direct `WorkflowRunner` library path streams incrementally, so
75+
this is specific to the buffered proxy.)
76+
77+
**Cross-protocol relocation.** forge moves the one credential into the target
78+
backend's canonical auth slot (it never reads the secret value):
79+
80+
| Target backend | forge writes |
81+
|---|---|
82+
| OpenAI-wire (llama.cpp, vLLM, Ollama, hosted) | `Authorization: Bearer <token>` |
83+
| Anthropic-wire | `x-api-key: <token>` (forge pins its own `anthropic-version`) |
84+
85+
Same protocol both ends → forwarded verbatim. Cross-protocol → the token is
86+
normalized (a leading `Bearer ` is stripped/added as needed) and written to the
87+
target slot. The common case — Claude Code (Anthropic-wire) in front of an
88+
OpenAI backend — relocates `x-api-key``Authorization: Bearer` unambiguously.
89+
90+
> **One documented limitation:** an Anthropic *OAuth* token (which must ride
91+
> `Authorization: Bearer`, not `x-api-key`) pushed through forge's *OpenAI*
92+
> endpoint to an Anthropic backend is relocated to `x-api-key` and rejected by
93+
> Anthropic. Coherent setups never hit this — OAuth callers use the Anthropic
94+
> endpoint (`/v1/messages`), which is same-protocol passthrough.
95+
96+
forge forwards **only** the one credential header; it does not forward the rest
97+
of the inbound header set (so client-set `anthropic-beta`, `OpenAI-Organization`,
98+
etc. do not reach the backend — a future `--backend-header` may add this).
99+
100+
### Notes
101+
102+
- **Ambient `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`** are read by the
103+
Anthropic SDK only for *direct* `AnthropicClient()` use (the eval path) — your
104+
deliberate single credential. The **proxy** neutralizes these env vars at
105+
construction so an ambient value can't become a hidden second credential.
106+
- **Keyless passthrough to an auth-required backend:** the proxy discovers the
107+
backend's context length at startup, before any inbound credential exists. If
108+
that endpoint requires auth, pass `--budget-tokens` so startup doesn't need to
109+
call it.
110+
- **DEBUG logging** (proxy `-v`) emits the forwarded credential's header *name*
111+
with the value redacted (`x-api-key: ***`). A raw secret is never logged.
112+
113+
---
114+
18115
## llama-server (recommended)
19116

20117
Upstream: [llama.cpp releases](https://github.com/ggml-org/llama.cpp/releases)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "forge-guardrails"
7-
version = "0.7.6"
7+
version = "0.8.0"
88
description = "A reliability layer for self-hosted LLM tool-calling. Guardrails, context management, and backend adapters for multi-step agentic workflows."
99
requires-python = ">=3.12"
1010
license = "MIT"

scripts/integration_test_proxy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ async def phase_external(
395395
proxy = ProxyServer(
396396
backend_url=f"http://127.0.0.1:{EXTERNAL_BACKEND_PORT}",
397397
port=EXTERNAL_PROXY_PORT,
398-
mode=mode,
398+
backend_capability=mode,
399399
backend_protocol="openai",
400400
)
401401
proxy.start()
@@ -430,7 +430,7 @@ async def phase_managed(
430430
backend_port=MANAGED_BACKEND_PORT,
431431
port=MANAGED_PROXY_PORT,
432432
budget_mode=BudgetMode.BACKEND,
433-
mode=mode,
433+
backend_capability=mode,
434434
extra_flags=extra_flags,
435435
)
436436
proxy.start()
@@ -462,7 +462,7 @@ async def phase_external_vllm(vllm_url: str) -> list[tuple[str, str, str]]:
462462
backend_url=vllm_url,
463463
backend="vllm",
464464
port=VLLM_PROXY_PORT,
465-
mode="native",
465+
backend_capability="native",
466466
backend_protocol="openai",
467467
)
468468
proxy.start()

scripts/smoke_test_proxy.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,28 @@ async def _read_request(
5959

6060

6161
def _write_json_response(body: str) -> bytes:
62+
return _write_response(200, "OK", body)
63+
64+
65+
def _write_response(status: int, reason: str, body: str) -> bytes:
66+
# Connection: close — these mocks serve one request per connection and close
67+
# it, so tell the client not to pool it. The deferred-discovery flow makes
68+
# two backend hits (/props then /v1/chat/completions); without this, httpx
69+
# may reuse the just-closed keep-alive connection and the second hit flakes.
6270
return (
63-
"HTTP/1.1 200 OK\r\n"
71+
f"HTTP/1.1 {status} {reason}\r\n"
6472
"Content-Type: application/json\r\n"
6573
f"Content-Length: {len(body)}\r\n"
74+
"Connection: close\r\n"
6675
"\r\n"
6776
f"{body}"
6877
).encode()
6978

7079

80+
# Credential a gated backend (test 4) requires — mirrors `llama-server --api-key`.
81+
GATED_KEY = "TESTKEY"
82+
83+
7184
# ── Mock backends ─────────────────────────────────────────────────────
7285

7386
async def openai_mock_backend(
@@ -108,6 +121,48 @@ async def openai_mock_backend(
108121
await writer.wait_closed()
109122

110123

124+
async def gated_openai_mock_backend(
125+
reader: asyncio.StreamReader, writer: asyncio.StreamWriter,
126+
) -> None:
127+
"""OpenAI-shape mock that GATES on ``Authorization: Bearer TESTKEY``.
128+
129+
Mirrors ``llama-server --api-key``: a missing/wrong credential gets 401 on
130+
*every* path — crucially including the ``/props`` context-length probe. This
131+
lets the deferred-discovery path (finding #2) be exercised without a real
132+
gated backend: the proxy can't probe at startup (it has no credential then),
133+
so it must defer to the first request and use that request's inbound key.
134+
"""
135+
path, headers, _body = await _read_request(reader)
136+
137+
if headers.get("authorization") != f"Bearer {GATED_KEY}":
138+
body = json.dumps({"error": {"message": "missing/invalid api key", "type": "auth"}})
139+
writer.write(_write_response(401, "Unauthorized", body))
140+
await writer.drain()
141+
writer.close()
142+
await writer.wait_closed()
143+
return
144+
145+
if "/props" in path:
146+
body = json.dumps({"default_generation_settings": {"n_ctx": 8192}})
147+
else:
148+
body = json.dumps({
149+
"id": "chatcmpl-gated",
150+
"object": "chat.completion",
151+
"model": "mock",
152+
"choices": [{
153+
"index": 0,
154+
"message": {"role": "assistant", "content": "OK"},
155+
"finish_reason": "stop",
156+
}],
157+
"usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6},
158+
})
159+
160+
writer.write(_write_json_response(body))
161+
await writer.drain()
162+
writer.close()
163+
await writer.wait_closed()
164+
165+
111166
# Captured request bodies from the Anthropic mock, used by path-1 assertions.
112167
_anthropic_mock_seen: list[dict[str, Any]] = []
113168

@@ -255,6 +310,61 @@ async def test_openai() -> None:
255310
await mock.wait_closed()
256311

257312

313+
# ── Test 1b: Gated passthrough → deferred discovery (finding #2) ─────
314+
315+
async def test_gated_passthrough_deferred_discovery() -> None:
316+
print("\n=== test_gated_passthrough_deferred_discovery (deferred probe vs gated backend) ===")
317+
mock = await asyncio.start_server(gated_openai_mock_backend, "127.0.0.1", 18090)
318+
# Pure passthrough: no --backend-api-key, no --budget-tokens → discovery is
319+
# deferred to the first request. Against a GATED backend this used to crash
320+
# at startup (the unauthenticated /props probe); now the proxy must just start.
321+
proxy = _start_proxy(backend_url="http://127.0.0.1:18090", port=18091)
322+
print("[setup] gated mock=:18090 proxy=:18091 (no backend_api_key, no budget_tokens)")
323+
print("[ok] proxy started against a gated backend (deferred — no startup probe/crash)")
324+
325+
try:
326+
async with httpx.AsyncClient(timeout=10.0) as client:
327+
# Negative: no inbound credential → the deferred /props probe is
328+
# unauthenticated → backend 401 → clean 401, discovery NOT latched.
329+
r0 = await client.post(
330+
"http://127.0.0.1:18091/v1/chat/completions",
331+
json={
332+
"model": "test",
333+
"messages": [{"role": "user", "content": "hi"}],
334+
"stream": False,
335+
},
336+
)
337+
assert r0.status_code == 401, (
338+
f"no-cred: expected 401, got {r0.status_code} {r0.text[:200]}"
339+
)
340+
print("[ok] no-credential first request → 401 (probe rejected, not latched)")
341+
342+
# Positive: same request WITH the credential → the deferred probe
343+
# authenticates with the inbound key, context is discovered, request
344+
# completes. Also proves no-latch-on-failure: the prior 401 didn't
345+
# poison discovery — this retry succeeds.
346+
r1 = await client.post(
347+
"http://127.0.0.1:18091/v1/chat/completions",
348+
headers={"Authorization": f"Bearer {GATED_KEY}"},
349+
json={
350+
"model": "test",
351+
"messages": [{"role": "user", "content": "hi"}],
352+
"stream": False,
353+
},
354+
)
355+
assert r1.status_code == 200, (
356+
f"with-cred: expected 200, got {r1.status_code} {r1.text[:200]}"
357+
)
358+
assert r1.json()["choices"][0]["message"]["content"] == "OK"
359+
print("[ok] credentialed request → deferred discovery succeeded, 200 "
360+
"(retry after the 401 works → no-latch-on-failure)")
361+
362+
finally:
363+
proxy.stop()
364+
mock.close()
365+
await mock.wait_closed()
366+
367+
258368
# ── Test 2: Path 2 (Anthropic inbound → OpenAI backend) ──────────────
259369

260370
async def test_path2_anthropic_to_openai() -> None:
@@ -388,6 +498,12 @@ async def test_path1_anthropic_passthrough() -> None:
388498
cache_marker = {"type": "ephemeral"}
389499
resp = await client.post(
390500
"http://127.0.0.1:18085/v1/messages",
501+
# v0.8.0 one-credential rule: this proxy is pure passthrough (no
502+
# --backend-api-key), so the request must carry a credential —
503+
# forge relocates this single inbound x-api-key to the Anthropic
504+
# backend's native slot. Without it forge fails loud (401), which
505+
# is correct: you can't reach an Anthropic backend with no key.
506+
headers={"x-api-key": "smoke-anthropic-key"},
391507
json={
392508
"model": "claude-mock",
393509
"max_tokens": 1024,
@@ -459,6 +575,7 @@ async def test_path1_anthropic_passthrough() -> None:
459575

460576
async def main() -> None:
461577
await test_openai()
578+
await test_gated_passthrough_deferred_discovery()
462579
await test_path2_anthropic_to_openai()
463580
await test_path1_anthropic_passthrough()
464581
print("\n[PASS] All proxy smoke tests passed.")

0 commit comments

Comments
 (0)