Skip to content

feat(server): gate forward_auth targets on the caller's credential - #1

Merged
Azerothian merged 2 commits into
mainfrom
feat/caller-credential-gating
Aug 27, 2026
Merged

feat(server): gate forward_auth targets on the caller's credential#1
Azerothian merged 2 commits into
mainfrom
feat/caller-credential-gating

Conversation

@Azerothian

Copy link
Copy Markdown

Why

A forward_auth client sends the caller's own provider credential upstream, but it does so
unconditionally. A caller with no credential is sent to the provider anyway and gets a 401
mid-task, and 401 is not fallback-eligible, so the request does not degrade to another target.
Separately, a route containing any Anthropic-forwarding target is callable only through
/v1/messages, so adding such a target to a mixed route rejects every OpenAI-format caller of it.

Together these force a subscription-login upstream into its own dedicated route. This change makes
one route able to offer a forwarding upstream to the clients that have a login and keyed upstreams
to everyone else.

What changed

Caller-credential gating. Metadata gains caller_wire_format, the API family the caller
arrived through — distinct from the existing wire_format, which pins the outbound backend
format. Backend::accepts_caller decides eligibility: a backend with its own key always serves; a
forwarding backend serves only when the caller's format is in its provider family and that
family's credential header is present and non-empty. The format half is the security property —
authorization alone does not say which provider issued a token, so an OpenAI caller's bearer must
never reach an Anthropic upstream. Companion headers (chatgpt-account-id, x-openai-fedramp)
identify an account, not a credential, and cannot make a target eligible alone. An absent caller
format falls back to the credential check, so embedded hosts that serve no HTTP API keep working.

RoutedLlmClient gains a defaulted caller_eligibility, so no other client implementation changes.
ClientRouter::eligible_candidates filters once and both call sites use it: call_first_available,
the single funnel for routing-time and answer calls, so the gate is generic across every algorithm;
and serve_decision_dependency, so /v1/routing/decide predicts the target that would actually
answer. Skips log at INFO with the candidate and provider — never the token.

When every candidate is gated, the request fails with a new
LlmClientError::MissingCallerCredential mapped to 401 / authentication_error /
missing_caller_credential, with no upstream call.

Route::caller_auth is narrowed from "any callable target forwards this kind" to "every one
does". A route whose targets all forward keeps today's fast descriptive 400 when called through
the other provider's API; a mixed route skips that pre-flight check and lets the per-candidate
filter decide.

Stage-router reserve targets. stage_router's tiers are a fixed scored pair, so making a
forwarding target the capable tier would evict the existing capable model from the route.
reserve_targets adds fall-through-only targets built into the FallThrough set between the tiers
([capable, ...reserves, efficient]), so a turn that escalated to the capable tier reaches a
reserve before dropping back to the efficient one. StageTargets and StageClassifier are
untouched — the signals and the optional judge still score exactly two names, and a reserve only
ever serves a request the chosen tier could not. Wired through StageTierConfig for both
stage_router and composite, including routing_target_names() so reserve clients reach the
ClientRouter.

Behaviour changes

  • A route whose targets all use forward_auth, called without the provider's credential, now
    returns Switchyard's 401 / missing_caller_credential instead of relaying the provider's own
    unauthenticated response.
  • A mixed route no longer rejects the other provider's callers outright; the caller-format check
    moves to a per-candidate filter there.

Tests

  • backend.rs — keyed backend accepts everyone; forwarding backend needs its own provider's
    credential (absent, empty, and no-metadata cases); cross-provider credential rejected in both
    directions; companion-headers-only rejected; unknown caller format gated on the credential alone.
  • run.rs — a gated candidate is skipped without being called and the next one serves; every
    candidate gated returns MissingCallerCredential with zero calls.
  • stage.rs — reserves fall through between the tiers, in target-set order.
  • server.rs — a mixed stage_router route (Anthropic forwarding capable target, keyed reserve,
    keyed efficient target) serves an anonymous OpenAI caller, an OpenAI caller carrying a Codex
    bearer, and an anonymous Anthropic caller all locally with zero Anthropic calls, then serves an
    Anthropic caller carrying an OAuth bearer upstream with the beta header filtered to oauth-*; a
    single-target forwarding passthrough called without a credential — and with an empty one — returns
    401 with zero upstream calls.

Verification

cargo test --workspace          # 0 failures
cargo clippy --workspace --all-targets   # clean
uv run ruff check .             # all checks passed
uv run pytest tests/            # 115 passed

Docs

docs/reference/toml_schema.md (gating rule + reserve_targets on both route types),
docs/routing_algorithms/stage_router_routing.md (reserve-targets section),
docs/getting_started.md, crates/switchyard-server/README.md,
crates/libsy-llm-client/README.md, and CHANGELOG.md under Added and Changed.

Signed-off-by: Matthew Mckenzie <matthew.m.mckenzie@gmail.com>
@Azerothian Azerothian closed this Aug 27, 2026
@Azerothian Azerothian reopened this Aug 27, 2026
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

🚀 View preview at
https://CrunchyMonkies.github.io/Switchyard/pr-preview/pr-1/

Built to branch gh-pages at 2026-08-27 10:17 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

Signed-off-by: Matthew Mckenzie <matthew.m.mckenzie@gmail.com>
@Azerothian
Azerothian merged commit b249700 into main Aug 27, 2026
19 checks passed
@Azerothian
Azerothian deleted the feat/caller-credential-gating branch August 27, 2026 10:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant