Skip to content

Tags: antoinezambelli/forge

Tags

v0.9.5

Toggle v0.9.5's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Support equivalent dual auth and prepare 0.9.5 (#154)

v0.9.4

Toggle v0.9.4's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Add Anthropic reasoning capture and release 0.9.4 (#153)

* fix(anthropic): capture native tool-call reasoning

* chore(release): prepare 0.9.4

v0.9.3

Toggle v0.9.3's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Fix Proxy command ownership and release 0.9.3 (#149)

* test: support Anthropic 1.0 wire capture

* fix(proxy): enforce exclusive command ownership

* test(proxy): serialize Windows uninstall retry

* fix(proxy): make Windows ownership cleanup self-contained

v0.9.2

Toggle v0.9.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Fix standalone Proxy release pipeline and publish 0.9.2 (#146)

* fix proxy release artifact pipeline

* fix proxy candidate lifecycle checks

* update standalone linux compatibility floor

* Fix Python shared library in Linux standalone build

v0.9.1

Toggle v0.9.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat(proxy): ship standalone developer sidecar (#145)

* feat(proxy): add standalone installer

* docs(proxy): document standalone installation

* release: prepare 0.9.1

* ci(proxy): make cross-platform installer gates portable

* ci(proxy): scope release gates to pointer bumps

* fix(proxy): make release gates portable

v0.9.0

Toggle v0.9.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
refactor(proxy): consolidate the Forge 0.9 sidecar contract (#139)

* Refactor proxy contracts for Forge 0.9.0

* test(proxy): ignore volatile vLLM catalog metadata

* test(proxy): add generic OpenAI live battery

* docs: add common Forge 0.9 migrations

* docs: finalize Forge 0.9 release notes

v0.8.3

Toggle v0.8.3's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(proxy): preserve request-local Anthropic model routing (#138)

* fix-anthropic-proxy-model-routing: preserve request model

* Use shallow copy for Anthropic request body

* release: bump to v0.8.3

v0.8.2

Toggle v0.8.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
release: v0.8.2 — expanded model evals and dashboard refresh (#133)

* feat(eval): add Gemma-4 large (26B-A4B, 31B) to batch_eval roster + serving recipe

Wire the two 32GB-tier Gemma-4 models into batch_eval for the rig-02
forge-native eval sweep (26 non-compaction scenarios):

- _GGUF_FILES: gemma-4-26B-A4B-it-UD-Q4_K_M, gemma-4-31B-it-Q4_K_M
  (auto-generates their llamaserver native+prompt configs).
- _SERVER_EXTRA_FLAGS: per-model serving recipe — --reasoning-format auto,
  --ctx-checkpoints 1 (SWA checkpoint RAM, llama.cpp #21690), q8 KV, -fa,
  --samplers temp;top_p;top_k. No --reasoning-budget (absent as every other
  config here); no -c (context from --budget-mode forge-full). Sampling values
  resolve client-side from recommended_sampling -> sampling_defaults (already
  registers both stems at temp1.0/top_p0.95/top_k64).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BmJdi2BHK1LZRLen6HF4

* eval: wire 120B tier (gpt-oss/Qwen3.5/Nemotron) for rig-03

Baseline provenance point for the rig-03 120B campaign. Adds the three
multi-shard first-shard GGUFs to _GGUF_FILES, strips the -NNNNN-of-NNNNN
suffix in the config loop so the model keys on its clean stem, and adds
the UMA/Vulkan serving recipes (reasoning-format auto, q8 KV + -fa,
--no-prefill-assistant, --no-mmap) to _SERVER_EXTRA_FLAGS.

This is the code state that produced the medium/low-effort baseline rows
(gpt-oss reforged 74.2 / bare 47.8; nemotron reforged 74.5 / bare 55.0;
qwen reforged 91.3 / bare 66.5) in eval_results_rig-03.jsonl. The
reasoning-effort axis wiring lands as a separate commit on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UWVT8JXuhSxfhaeA2upHZc

* eval: add reasoning-effort axis (gpt-oss@high, nemotron@high)

Adds a reasoning_level dimension so effort variants of the same GGUF stem
run and render as parallel configs instead of clobbering the baseline
(silent-skip). Effort is applied via bespoke configs — NOT by mutating the
sampling registry: a new BatchConfig.sampling_override carries an explicit
param set built DRY from get_sampling_defaults(stem) with chat_template_kwargs
swapped to high; _build_client passes it with recommended_sampling=False.
LlamafileClient is untouched (its constructor already accepts these kwargs).

reasoning_level ("default"/"high") threads through _run_key, _count_completed_runs
and the JSONL row exactly like reasoning_replay, and joins report's _config_tuple
dedup identity (UNLIKE reasoning_replay — parallel effort levels must coexist,
not supersede). report renders the model's native vendor term via a stem-keyed
_REASONING_VOCAB (gpt-oss default->medium, nemotron default->low, both +high;
qwen/instruct models untagged). The @high configs live in a dedicated
"reasoning-high" CONFIG_SET, kept out of "all"/llamaserver-native.

Storage stays generic; pre-axis rows (no field) read as "default" — no backfill.
Verified: dry-run shows both @high configs 0/50 -> RUN 50 (no silent-skip); the
existing 7800 baseline rows aggregate identically through the report pipeline
(6 ConfigKeys, per-arm scores unchanged, 1300 rows each).

Effort dicts resolved from the GGUF chat templates: gpt-oss {reasoning_effort:high};
nemotron {enable_thinking:True, low_effort:False, force_nonempty_content:True}
(template defaults undefined low_effort to False, so omit==False).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UWVT8JXuhSxfhaeA2upHZc

* eval: add rig-03 120B-tier results (15,600 runs, 12 arms)

Native llamaserver eval on the 120B tier, 26 non-compaction scenarios x 50
runs per arm. Tracked via LFS (eval_results*.jsonl). Disjoint from the 32B-tier
dump — merge by concatenation; row identity carries model/ablation/
reasoning_level/reasoning_replay so arms never collide.

Arms (1300 rows each):
  gpt-oss-120b        bare/medium, bare/high, reforged/medium, reforged/high
  Nemotron-3-Super    bare/low, reforged/low
  Qwen3.5-122B        {reforged, bare} x {none, keep-last, full} replay

Notes for downstream analysis:
- Nemotron@high is absent by design: the backend could not serve that model's
  high-effort mode with stable liveness (requests wedged at 0 iterations /
  300s). Those rows were infra artifacts, not model results, and were purged
  rather than scored.
- gpt-oss ran at 128k, Nemotron and Qwen at 256k (forge-full auto-fit).
- Score semantics: correct = accuracy truthy, non-None, no validate_error.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QKtkSFbMGRKPzFvTTa2ZFE

* eval: add full reasoning-replay arms (19,500 runs, 15 arms)

Adds three arms to the rig-03 120B-tier results:
  gpt-oss-120b  reforged  medium  full-replay
  gpt-oss-120b  reforged  high    full-replay
  Nemotron-3-Super-120B  reforged  low  full-replay

Nemotron ran at manual 128k; its none-replay baseline is 256k.

Also purges one Timeout row (nemotron full, grounded_synthesis run 41)
and refills it via resume.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QKtkSFbMGRKPzFvTTa2ZFE

* eval: consolidate rig-01/03/04 into eval_results_v0.8.2.jsonl (74,100 runs, 57 arms)

Folds the three rig campaigns into one gen-3 result set named for the release
that will publish it, replacing the per-rig files. 74,100 rows / 57 arms, all
stamped gen: 3, every arm 26 scenarios x 50 runs with no gaps or duplicates.

Contents beyond what v0.7.5 published:
- Qwen3.6-35B-A3B + 27B: full reasoning-replay arms (rig-04), restoring the
  arms the gen-2 pre-knob rows could no longer carry. 35B reforged/native
  reproduces at 95.2% vs the 94.8% measured under v0.7.4.
- gemma-4-26B-A4B + 31B: none/keep-last/full ladders, native (rig-04).
- LFM2.5-8B-A1B + Mellum2-12B-A2.5B (Instruct, Thinking): keep-last/full
  (rig-01), pairing with the none arms already in v0.7.5. Note these three
  ladders span forge versions -- none was measured under 0.7.5, the replay
  arms under 0.8.x.
- gpt-oss-120b at default and high effort, NVIDIA-Nemotron-3-Super-120B-A12B,
  Qwen3.5-122B-A10B (rig-03).

The gen-3 stamp is unchanged on purpose. The epoch boundary is the
reasoning_replay default flip in v0.7.5; nothing since alters the default
policy, the scenario set, the scoring, or the harness loop. The one behavioral
change on the eval path (v0.8.1 tool-call rescue from malformed-500 bodies)
never fired -- no rescue warnings in any run log, and tool_errors/run is
identical to four decimals across both eras for the models measured in each.

report.py: scope dedup supersession by whether a row carries an explicit
reasoning_replay policy. Legacy pre-knob rows (no field) are still superseded
by any newer-gen sweep of the config, so phantom ':full' twins stay cleaned up;
explicit policy rows are now superseded only by a newer gen of the SAME policy,
so an arm that only ever ran in one gen carries forward with a badge instead of
being destroyed by a sweep that ran different policies.

eval_rigs.json: add rig-04 (RTX 5090, 32GB).
batch_eval.py: correct two comments that credited the Gemma-4 large campaign to
rig-02; it ran on rig-04.

One data fix carried in: a Nemotron-3-Super grounded_synthesis re-run had been
appended as a second run 50 while run 41 was absent, because _count_completed_runs
derives run indices from row counts rather than tracking which index is missing.
Renumbered to 41; the arm is contiguous 1..50 again.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BmJdi2BHK1LZRLen6HF4

* release: prepare v0.8.2

* docs(eval): update dashboard regeneration examples

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>

v0.8.1

Toggle v0.8.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(llamafile): rescue tool calls from malformed tool-call 500s inste…

…ad of leaking error JSON (#127)

* fix(llamafile): retry malformed tool-call 500s instead of leaking error JSON

llama.cpp returns a 500 when its tool-call parser rejects malformed or
incomplete model output (e.g. a call missing a required parameter, or a
duplicated/incomplete call). That is a transient sampling artifact, not a
backend fault, and is recoverable by re-sampling.

Detect this specific 500 (body contains "Failed to parse input" plus a
tool-call marker) and surface it as a retryable text response carrying a
clean nudge to re-emit a single well-formed call, so the inference retry
loop recovers instead of echoing the raw 500 JSON into the conversation.
Every other 500 still cascades as a BackendError. Applies to both the
streaming and non-streaming send paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* feat(llamafile): rescue complete tool calls from malformed-500 bodies

The 27B write-stutter: the model emits a skeletal path-only tool_call
block, then (often) the same call complete with content. llama.cpp 500s
the whole generation on the first block, discarding the valid call, and
blind nudge-retries exhaust (observed 0/13 runs, sticky).

Flow on a malformed-tool-call 500 (all other 500s still cascade):
- parse all <tool_call> blocks out of the error body (llama.cpp embeds
  the raw rejected generation in the message)
- rescue: return blocks that strictly parse, name a tool in the request's
  tools array, and carry every required param (schema-coerced); dedupe
  exact repeats; count on client.rescued_tool_calls; log [rescue]
- else nudge: stutter-naming retry text only when blocks were actually
  seen; bare parse-500s keep the generic text verbatim

Verified against a captured production 500 body: salvages the complete
17k-char write behind two skeleton blocks.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019ZUd3AHScRUcgCJn9owysD

* refactor(llamafile): return re-parsed 500 calls to the tool channel

Supersedes the salvage-vs-nudge split: on a malformed-tool-call 500,
leniently re-parse ALL blocks naming a known tool and return them as
real ToolCalls — skeletons included. Downstream machinery decides:
complete calls execute; a skeleton is rejected by the runner's
fn(**args) dispatch as [ToolError] TypeError naming the missing param,
anchored to the call on the tool channel (the corrective signal the
model was trained on), under the tool-error budget instead of the
5-strike retry wall. The stutter retry text now fires only for blocks
that are visible but unparseable/unknown; bare parse-500s keep the
generic text.

Verified on both captured production bodies: mixed stutter -> skeleton
+ complete 17k-char write returned; skeleton-only -> one path-only call
for dispatch to reject.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019ZUd3AHScRUcgCJn9owysD

* chore(release): bump to 0.8.1 with changelog

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>

v0.8.0

Toggle v0.8.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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]>