Skip to content

refactor(anthropic): SigV4 request signing for the Anthropic dialect, in rig-bedrock - #2387

Open
awsmadi wants to merge 6 commits into
0xPlaygrounds:mainfrom
awsmadi:feat/anthropic-sigv4
Open

awsmadi wants to merge 6 commits into
0xPlaygrounds:mainfrom
awsmadi:feat/anthropic-sigv4

Conversation

@awsmadi

@awsmadi awsmadi commented Aug 19, 2026

Copy link
Copy Markdown

Update: the integration now lives in rig-bedrock, not rig-core, and the
sigv4 feature is gone. Everything below this line is the original description
and describes the earlier shape; see Update: moved to
rig-bedrock
at the end for what changed and
which parts of it are superseded.

Fixes #2386

Adds optional AWS SigV4 authentication to the Anthropic provider, so it can
reach an Anthropic-compatible endpoint that sits behind AWS. Behind a
default-off sigv4 feature; nothing changes for anyone who doesn't opt in.

Implementation

AnthropicCompatibleProvider gains one method with a default:

fn sigv4_region(&self) -> Option<&str> { None }

None means no signing is attempted, so every existing implementor is
unaffected and none needed editing
. AnthropicExt overrides it;
AnthropicKey gains a SigV4 { region } variant alongside ApiKey(String).

Going through the trait rather than a concrete field is deliberate — completions
run through GenericCompletionModel<Ext, T>, so a concrete field isn't
reachable from the generic path. This follows the provider-integration
checklist in CONTRIBUTING.md: wire-dialect differences belong in the trait's
hooks.

Signing happens in both request builders, immediately before the body is
attached and never earlier. The SigV4 payload hash covers the exact bytes sent,
so signing has to follow every mutation of the body — and the SigV4 variant
emits no static header, because the signature covers the clock as well as the
body and can't be computed once at client construction.

Selecting SigV4 in a build without the feature is a hard error, not a silently
unsigned request: an unsigned request 401s with a message about a missing API
key, which points at the wrong problem.

Two things worth a reviewer's attention

The sigv4 feature enables two aws-config features, and both are
load-bearing.
The workspace pins aws-config with default-features = false
(rig-bedrock supplies its own), while load_defaults walks the full credential
chain:

feature without it
default-https-client a http_client is required
rt-tokio An async sleep implementation is required for retry to work

Both fail at runtime, not compile time, so cargo check can't catch either
— I only found them by running the tests. Note also that feature unification
means enabling sigv4 gives rig-bedrock's aws-config those features too.
That's additive, but it's a real consequence and I'd rather flag it than have it
discovered later.

aws-credential-types and aws-sigv4 are new workspace dependencies, added
as bare-major floors per the documented policy in the root manifest, since this
code uses only long-stable API from both. aws-config was already there.

Verification

cargo clippy -p rig-core --features sigv4 --all-targets    0 warnings
cargo test   -p rig-core --features sigv4 --lib            1551 passed, 0 failed
cargo test   -p rig-core --lib                             1550 passed, 0 failed

The last line is the control that matters for existing users — it's what proves
the default-off path is unaffected.

The +1 with the feature on is signed_headers_never_include_host, which asserts
the signer doesn't emit a host header: the HTTP client sets its own, and
sending both breaks the signature. It carries an anti-vacuity assertion too,
because "no host header" passes trivially if nothing was produced at all.

Not included

No cassette-backed regression test. Signing is request-shaping rather than a
response-parsing behaviour, so the assertion that matters is on the emitted
header list, which is what the included test does. Happy to add one if you'd
prefer the coverage there instead.

Also in this diff: output_config

OutputConfig.format becomes Option<OutputFormat>, and OutputConfig gains a
sibling effort: Option<String> carrying Anthropic's adaptive-thinking effort
level. This is unrelated to the signing change; it lets a caller set an effort
level through additional_params with or without a structured-output schema.
Lifting a caller-supplied output_config out of additional_params is what
makes the two coexist: that field is #[serde(flatten)], so leaving it in place
would emit a second output_config key next to the typed one, and a duplicate
JSON key means one of the two is silently dropped.

The sigv4 feature is native-only

The AWS dependencies are declared under
[target.'cfg(not(target_arch = "wasm32"))'.dependencies], so --all-features
still compiles for wasm32. Selecting SigV4 on that target hits the same hard
error as a build with the feature off, because the credential chain has no
socket to use there.


Update: moved to rig-bedrock

Per your review: rig-core stays minimal, and neither tokio nor the AWS libraries
belong in it. rig-bedrock over a new rig-sigv4 crate, since it needs no new
crate published and already carries the AWS credential chain.

The first attempt at this only gated the dependencies, declaring them optional
under [target.'cfg(not(target_arch = "wasm32"))'.dependencies]. That missed the
point — they were still in rig-core's manifest, so cargo tree still resolved
them. They are now gone outright.

What moved

providers/anthropic/sigv4.rs moved to crates/rig-bedrock/src/anthropic/sigv4.rs,
and a new crates/rig-bedrock/src/anthropic.rs holds what used to be spread
through rig-core's Anthropic client: the AnthropicKey enum with its
SigV4 { region } variant and sigv4() constructor, the signing region on the
provider value, and the Client/ClientBuilder aliases.

The provider is rig_bedrock::anthropic::AnthropicOnAws. It implements rig-core's
Provider, HasCompletion and AnthropicCompatibleProvider, and builds its
models from GenericCompletionModel, so the Anthropic request and response
mapping is reused rather than duplicated:

let client = rig_bedrock::anthropic::Client::builder()
    .api_key(AnthropicKey::sigv4("us-east-1"))
    .base_url("https://your-endpoint.example/anthropic")
    .http_client(http)
    .build()?;

crates/rig-core/Cargo.toml, providers/anthropic/client.rs and
providers/anthropic/mod.rs are byte-identical to main again.

There is no default endpoint any more. Signing a request to api.anthropic.com
is never what the caller meant, and a wrong regional default would produce a 403
that reads like a credential problem, so the base URL comes from
ClientBuilder::base_url or ANTHROPIC_BASE_URL and building without one fails
immediately.

What stays in rig-core

One defaulted method on AnthropicCompatibleProvider, carrying no dependency:

fn signed_headers(&self, method: &str, uri: &http::Uri, body: &[u8])
    -> impl Future<Output = Result<Vec<(String, String)>, CompletionError>> + WasmCompatSend
{ let _ = (method, uri, body); async { Ok(Vec::new()) } }

Handing back headers rather than taking a region is what keeps the AWS side out:
rig-core never learns that AWS exists, it just applies whatever the provider
returned.

Why a hook here at all, given that HttpClientExt is also a place where the body
is final — and a later one. A SigV4HttpClient<H> decorator would see a fully
built http::Request<T>, with T: Into<bytes::Bytes> making the payload
hashable, and would need no rig-core change, would sign at one site instead of
two, and would cover verify().

What rules it out is the transport type's position in Provider::from_env and
Provider::from_val. Both are
fn(..., http: H) -> ProviderClientResult<Client<Self, H>>, so the transport the
caller passes in is the transport the returned client is typed on, and
Client<Self, SigV4HttpClient<H>> is not the declared return type. The decorator
cannot be installed inside them. Moving the wrapping out to the caller would make
AnthropicKey::sigv4(region) plus a forgotten wrapper a silently unsigned
request, which is the one failure this design exists to prevent.

The cost of the hook, stated plainly: it has two call sites in rig-core that must
stay in step, and a third Anthropic request path added there later will not sign,
with nothing failing when that happens. If you would rather pay the other price —
one signing site, verify() working, and the wrapper as the caller's
responsibility — that is a reasonable call, and it is a change to
from_env/from_val rather than to this provider.

The default returns nothing, so the macro-generated compatible providers (zai,
minimax, moonshot, xiaomimimo) are still untouched. sigv4_region() could not
stay — a region is only useful to code that can sign, and that code is no longer
there.

default_max_tokens_for_model also becomes pub. The endpoint serves Anthropic's
own models, so the relocated provider needs Anthropic's own output limits.

To be accurate about the alternative: there is another path to the same value.
<Anthropic as AnthropicCompatibleProvider>::default_max_tokens(model) returns
exactly this and is already public, so rig-bedrock could reach it without
exporting anything new. The reason not to is that it makes a claim about models
— Anthropic's published output limits — reachable only through one particular
client type, so every Anthropic-dialect provider would have to depend on the
Anthropic client to learn its own defaults. Happy to invert that if you prefer
the smaller public surface; it is a two-line change on this side.

Dependency counts

cargo tree -p rig-core -e normal, AWS and tokio entries:

features before after
default 0 / 0 0 / 0
--all-features 63 / 15 0 / 0

The 63 AWS entries spanned 18 distinct crates, the 15 tokio entries 3.
--all-features bounds every feature combination, since features only add, so
zero there is zero everywhere. The whole --all-features tree drops from 554
lines to 297.

rig-bedrock gains aws-credential-types and aws-sigv4 (already in the
workspace table), http, and bytes as a dev-dependency. It also enables
aws-config's default-https-client and rt-tokio — the same two load-bearing
features flagged above, now on the crate that was already going to pay for them,
so the feature-unification consequence I noted earlier no longer applies.

sigv4.rs keeps tokio::sync::OnceCell for the cached SdkConfig;
std::sync::OnceLock is not a substitute, because initialization awaits
load_defaults and OnceLock has no async initializer. tokio was already a
full dependency of rig-bedrock, so this stops being a problem the moment the
file lands here.

One dead item did not make the trip: a static WARNED: OnceLock<()> that was set
and never read. The message it claimed to produce once is an error return, so it
was produced on every failure anyway.

Verification

On the toolchain rust-toolchain.toml pins (rustc 1.94.0):

cargo check  -p rig-core                                                      ok
cargo check  -p rig-core --all-features                                       ok
cargo check  -p rig-core --all-features --target wasm32-unknown-unknown       ok
cargo check  -p rig-bedrock                                                   ok
cargo check  -p rig-bedrock --all-features                                    ok
cargo fmt -- --check                                                          ok
cargo clippy -p rig-core    --all-features --all-targets            0 warnings
cargo clippy -p rig-bedrock --all-features --all-targets            0 warnings

Tests, --lib:

target before after
rig-core, default features 1638 1638
rig-core, --all-features 1697 1696
rig-bedrock 113 126

signed_headers_never_include_host moved with the file and still passes; that is
the -1 above and one of the +13.

Two of the new tests drive a real completion and a real stream through
rig-core and assert the signature arrived on the request the transport saw. Every
piece here can be individually correct while the hook is simply never called, and
an unsigned request fails with a 403 that reads like a credential problem rather
than a wiring one. Both were checked against a negative control: stubbing out
either call site in rig-core fails the matching test, and only that one.

Superseded above

  • "The sigv4 feature enables two aws-config features" — there is no
    sigv4 feature any more, and the feature-unification note no longer applies:
    the features are enabled directly on rig-bedrock's own aws-config.
  • "aws-credential-types and aws-sigv4 are new workspace dependencies"
    still true, but they are consumed by rig-bedrock now, not rig-core.
  • "The sigv4 feature is native-only" — the
    cfg(not(target_arch = "wasm32")) table is gone. rig-core's --all-features
    wasm32 build passes with nothing to gate. rig-bedrock does not build for
    wasm32, before this change or after, because the AWS SDK reaches tokio's net
    feature and mio has a hard compile_error! there — which is the other half of
    the argument for putting the credential chain in this crate.
  • "Also in this diff: output_config" — unchanged and still in the diff.
  • "Signing has to happen inside rig-core's request builders, because
    rig-bedrock cannot reach into them"
    — wrong, and replaced above. The body is
    also final at the HttpClientExt boundary, strictly later. The real obstacle is
    the transport type in from_env/from_val's return position.
  • "verify() cannot be closed without a second signing hook in rig-core"
    wrong for the same reason. A transport decorator would close it.

Update: review fixes

A self-review after the move found two bugs, both in the signing path.

The signed host did not match the host on the wire. The hook took the URI as
a &str and the signer recovered the host by splitting on "://" and "/",
re-parsing an authority the request builder had already parsed. Two cases came
out wrong:

  • An explicit default port. base_url("https://host:443/anthropic") signed
    host:443, but the transport sends Host: host. hyper_util's client omits a
    port equal to the scheme default, and rig-reqwest passes the URI to reqwest as
    a string, so the url crate drops the port while re-parsing.
  • Userinfo. https://user@host/ signed user@host.

Either way the canonical request cannot be reproduced, and every call fails 401
"the request signature we calculated does not match" — the same opaque failure
already documented in this diff for the duplicated host header, and equally
invisible to a test that only asserts the Authorization header's shape.

The hook now takes &http::Uri and the host is read off the parsed authority.
This changes the signature of a public trait method,
AnthropicCompatibleProvider::signed_headers. It has no implementors outside this
repository yet, which seemed like the moment to get it right; say the word if you
would rather keep &str and normalise inside rig-bedrock.

Two clients could not use two AWS identities. The resolved credential chain
was cached in a process-wide static, so the first client to sign fixed the
identity every later client signed with. A process building one client under one
profile or assumed role and a second expecting another would sign both as
whichever resolved first — at best a 403 naming an unexpected principal, at worst
a call authorized and billed against the wrong account. The cache is now a field
on the provider value, so it is per client, and clones share it. Resolution is
still deferred to the first request because Provider::build is synchronous;
that residual is documented where the cache is defined.

Also: AnthropicKey no longer derives Debug, which printed the API key
verbatim. Nothing logged it, so it was latent rather than a live leak.

Verification reran clean — fmt, both clippy invocations at 0 warnings, all
four check variants including --target wasm32-unknown-unknown, and
cargo tree -p rig-core -e normal --all-features still at zero AWS and zero
tokio crates.

The two new signature tests were checked red before the fix and green after: the
host derivation reported bedrock-mantle.us-east-1.api.aws:443 where the
transport sends bedrock-mantle.us-east-1.api.aws, and the two signatures
differed. A control asserts a non-default port does still reach the signature,
so the equality test cannot pass by ignoring the host. Pinning that needed the
signing instant to become a parameter, since a signature covers the time.

@awsmadi
awsmadi force-pushed the feat/anthropic-sigv4 branch from ead61fa to c83b0d1 Compare September 1, 2026 00:22
@awsmadi

awsmadi commented Sep 1, 2026

Copy link
Copy Markdown
Author

Rebased onto current main — the branch was 23 commits behind and conflicting. One conflict, in crates/rig-core/Cargo.toml, where main has since removed most of this crate's features (reqwest, rustls, websocket*, native-tls, reqwest-middleware*, socks) and changed default to ["derive"]. I kept your restructured block and re-added only the sigv4 feature rather than restoring the removed ones.

That surfaced one real break with no textual conflict: sigv4.rs used tokio::sync::OnceCell, and tokio is only a dev-dependency of rig-core, so the library no longer compiled. std::sync::OnceLock can't substitute — initialisation awaits load_defaults, and OnceLock has no async initialiser. So tokio is now an optional dependency with just the sync feature, gated behind sigv4.

Verified both directions, since "optional" is easy to claim and easy to get wrong:

  • cargo check -p rig-core --features sigv4 — clean, 0 warnings
  • cargo check -p rig-core (default) — clean, 0 warnings
  • cargo tree -p rig-core -e normal0 tokio entries by default, 1 with --features sigv4

CI still hasn't run on this PR; its workflow runs remain at action_required, so a maintainer approval is needed before there's any check result to read.

@gold-silver-copper

Copy link
Copy Markdown
Contributor

Currently we are trying to make rig-core a lot more minimal, so I believe that rig-core is the wrong place for this integration. I think rig-bedrock is a better place for the integration, or perhaps a separate rig-sigv4 crate. I don't want to add tokio or the various aws libraries as rig-core dependencies. What do you think?

@awsmadi

awsmadi commented Sep 1, 2026

Copy link
Copy Markdown
Author

Agreed — rig-bedrock is the right home. I'll move it there.

Your objection to tokio in rig-core is more than a preference call, and the rebase demonstrated it: sigv4.rs uses tokio::sync::OnceCell to cache the resolved SdkConfig, and rig-core carries tokio only as a dev-dependency — so once the feature minimisation landed on main, the library stopped compiling. I had worked around it by adding tokio as an optional dependency gated behind the sigv4 feature. That made it build, but it is exactly the dependency you are trying not to acquire, so it was the wrong fix to the right signal.

rig-bedrock avoids it rather than gating it: aws-config, aws-credential-types and the AWS credential chain already belong there, so the integration stops being an exception in the dependency graph and becomes ordinary. std::sync::OnceLock is not a substitute in rig-core either — initialisation awaits load_defaults, and OnceLock has no async initialiser — which is another way of saying the code wants a runtime that rig-core should not have.

I'll restructure and push to this branch rather than opening a new PR, unless you'd prefer a fresh one against rig-bedrock. I took rig-bedrock over the separate rig-sigv4 crate because it was your first suggestion and needs no new crate published; say the word if you'd rather have the standalone crate and I'll do that instead.

@awsmadi awsmadi changed the title feat(anthropic): optional SigV4 request signing behind a defaulted trait method refactor(anthropic): SigV4 request signing for the Anthropic dialect, in rig-bedrock Sep 11, 2026
@awsmadi

awsmadi commented Sep 14, 2026

Copy link
Copy Markdown
Author

Moved in 8041855: the integration now lives in rig-bedrock (src/anthropic/sigv4.rs), and crates/rig-core/Cargo.toml is no longer in this PR's diff at all, so no tokio and no AWS crates were added to it. aws-config, aws-credential-types and aws-sigv4 sit in rig-bedrock, which already carried the credential chain; all the signing needs from rig-core is one defaulted trait method returning headers, built from http and futures, so the payload hash can cover the final request body. The branch is 17 commits behind main and conflicting, so it needs a rebase before CI can say anything useful.

…ait method

Lets the Anthropic provider talk to an Anthropic-compatible endpoint that sits
behind AWS SigV4, without changing anything for anyone who does not opt in.

Why not rig-bedrock
-------------------
rig-bedrock speaks the Bedrock Runtime InvokeModel API. This is the other
shape: an endpoint that accepts Anthropic's own /v1/messages request body, and
therefore carries Anthropic-shaped parameters, but authenticates with SigV4
instead of an x-api-key header. Different wire protocols against different
endpoints, so neither substitutes for the other.

What changed
------------
`AnthropicCompatibleProvider` gains one method with a default:

    fn sigv4_region(&self) -> Option<&str> { None }

Returning `None` — the default — means no signing is attempted, so every
existing implementor is unaffected and none needs editing. `AnthropicExt`
overrides it; `AnthropicKey` gains a `SigV4 { region }` variant alongside
`ApiKey(String)`.

Going through the trait rather than a concrete field is deliberate: completions
run through `GenericCompletionModel<Ext, T>`, so a concrete field would not be
reachable from the generic path. This follows the provider-integration
checklist in CONTRIBUTING.md, which asks for wire-dialect differences to live
in the trait's hooks.

Signing happens in both request builders, immediately before the body is
attached and never earlier: the SigV4 payload hash covers the exact bytes
sent, so signing has to follow every mutation of the body. The SigV4 variant
emits no static header, because the signature covers the clock as well as the
body and so cannot be computed once at client construction.

Feature-gating
--------------
Behind a default-off `sigv4` feature. Default-off on purpose: the AWS
credential chain has no place in a wasm build, nor in builds that only ever
talk to api.anthropic.com.

Selecting SigV4 in a build without the feature is a hard error, not a silently
unsigned request — an unsigned request would 401 with a message about a
missing API key, which points at the wrong problem.

`#[cfg_attr(not(feature = "sigv4"), allow(unused_mut))]` on the request builder
keeps the no-feature build warning-free; without it the `mut` that only signing
needs would warn in every default build.

Two aws-config features are load-bearing, and both fail at RUNTIME rather than
at compile time, so `cargo check` cannot catch either. The workspace pins
aws-config with `default-features = false` (rig-bedrock supplies its own), while
`load_defaults` walks the full credential chain:

  default-https-client — without it: "a http_client is required"
  rt-tokio             — without it: "An async sleep implementation is required
                          for retry to work"

aws-config was already a workspace dependency; aws-credential-types and
aws-sigv4 are added as bare-major floors, consistent with the documented policy
in the root manifest, since this code uses only long-stable API from both.

Verification
------------
    cargo clippy -p rig-core --features sigv4 --all-targets   0 warnings
    cargo test   -p rig-core --features sigv4 --lib           1551 passed, 0 failed
    cargo test   -p rig-core --lib                            1550 passed, 0 failed

The last line is the control that matters for existing users: it proves the
default-off path is unaffected. The +1 with the feature on is the new
`signed_headers_never_include_host` test, which asserts the signer does not
emit a `host` header — the HTTP client sets its own, and sending both breaks
the signature. That test carries its own anti-vacuity assertion, because
"no host header" passes trivially if nothing was produced at all.
`cargo fmt -- --check` is a required CI step and it was failing on three
lines this branch added: one `for` header in completion.rs and two error
closures in sigv4.rs. The same three diffs are present at the pre-merge
tip of this branch, so they predate the merge; main itself is clean.

Whitespace only, applied by `cargo fmt` on the toolchain
rust-toolchain.toml pins (1.94.0). No behavior change.
CI runs `cargo check --package rig-core --all-features --target
wasm32-unknown-unknown` (ci.yaml, the rig-core leg of the wasm matrix).
`--all-features` means the new `sigv4` feature is on for that build, and
it failed to compile: 59 errors, from `compile_error!` in mio ("This wasm
target is unsupported by mio") and follow-on type errors in socket2.

The chain is sigv4 -> aws-config/default-https-client ->
aws-smithy-http-client -> hyper-rustls -> hyper-util -> tokio's `net`
feature -> mio/socket2. Present at the pre-merge tip of this branch too,
so it is not a merge artifact; main passes the same command.

Dropping `default-https-client` would only move the failure to runtime,
where `load_defaults` reports "a http_client is required". There is no
socket for the AWS credential chain to use on wasm either way, so the
dependencies are now declared in a `cfg(not(target_arch = "wasm32"))`
table and the module and its two call sites are gated to match. Native
targets are unchanged; a wasm caller that selects SigV4 gets the existing
hard error, whose message now names both ways of reaching it.
…-bedrock

Review feedback on this PR asked for exactly this: rig-core is being kept
minimal, and neither tokio nor the AWS libraries belong in it. rig-bedrock was
named as the better home over a new rig-sigv4 crate, because it needs no new
crate published and already carries the AWS credential chain.

An earlier attempt on this branch gated the dependencies instead, declaring them
optional and behind a `cfg(not(target_arch = "wasm32"))` table. That is not what
was asked for. The dependencies were still in rig-core's manifest, so
`cargo tree -p rig-core -e normal --all-features` still resolved 18 AWS crates
and tokio. They are gone now.

What moved
----------
Everything that needs an AWS dependency:

  crates/rig-core/src/providers/anthropic/sigv4.rs
    -> crates/rig-bedrock/src/anthropic/sigv4.rs

plus a new `crates/rig-bedrock/src/anthropic.rs` holding what used to be spread
through rig-core's Anthropic client: the `AnthropicKey` enum with its
`SigV4 { region }` variant and `sigv4()` constructor, the signing region on the
provider value, and the `Client`/`ClientBuilder` aliases. The provider is
`rig_bedrock::anthropic::AnthropicOnAws`. It implements rig-core's `Provider`,
`HasCompletion` and `AnthropicCompatibleProvider`, and builds its models from
`GenericCompletionModel`, so the Anthropic request and response mapping is
reused rather than duplicated.

`crates/rig-core/Cargo.toml`, `providers/anthropic/client.rs` and
`providers/anthropic/mod.rs` are byte-identical to main again.

Unlike before, there is no default endpoint. Signing a request to
api.anthropic.com is never what the caller meant, and a wrong regional default
would produce a 403 that reads like a credential problem, so the base URL comes
from `ClientBuilder::base_url` or `ANTHROPIC_BASE_URL` and building without one
fails immediately.

What stays in rig-core, and why
-------------------------------
One defaulted method on `AnthropicCompatibleProvider`, carrying no dependency:

    fn signed_headers(&self, method: &str, uri: &str, body: &[u8])
        -> impl Future<Output = Result<Vec<(String, String)>, CompletionError>>
           + WasmCompatSend
    { let _ = (method, uri, body); async { Ok(Vec::new()) } }

Signing has to happen inside rig-core's two Anthropic request builders, because
that is where the body becomes final and a SigV4 payload hash covers the exact
bytes sent. rig-bedrock cannot reach into those builders, so rig-core has to
ask. Handing back headers rather than taking a region is what keeps the AWS side
out: rig-core never learns that AWS exists, it just applies whatever the
provider returned. The default returns nothing, so the macro-generated
compatible providers (zai, minimax, moonshot, xiaomimimo) are untouched, and the
`--all-features` wasm32 build needs no gating because nothing is left to gate.

The previous hook, `sigv4_region() -> Option<&str>`, could not stay. A region is
only useful to code that can sign, and that code is no longer here.

`default_max_tokens_for_model` also becomes `pub`. The endpoint serves
Anthropic's own models, so the relocated provider needs Anthropic's own output
limits. Without it, `max_tokens` would become mandatory on every request through
the new client, or the provider would have to invent a flat limit that truncates
Opus.

Dependencies
------------
rig-bedrock gains `aws-credential-types` and `aws-sigv4`, both already in the
workspace table, plus `http`, and `bytes` as a dev-dependency. `aws-config`
gains `default-https-client` and `rt-tokio`: load-bearing rather than cosmetic,
and each one fails at runtime rather than at compile time, so the reasons now
sit in the manifest beside them. tokio was already a full dependency here, which
is why `sigv4.rs` keeps using `tokio::sync::OnceCell` to cache the resolved
`SdkConfig`. `std::sync::OnceLock` is not a substitute: initialization awaits
`load_defaults`, and `OnceLock` has no async initializer.

One dead item did not make the trip, a `static WARNED: OnceLock<()>` that was
set and never read. The message it claimed to produce once is an error return,
so it was produced on every failure regardless.

Verification
------------
`cargo tree -p rig-core -e normal`, AWS and tokio entries:

                        before      after
  default features      0 / 0       0 / 0
  --all-features        63 / 15     0 / 0

The 63 AWS entries spanned 18 distinct crates, the 15 tokio entries 3.
`--all-features` bounds every feature combination, since features only add, so
zero there is zero everywhere. The whole `--all-features` tree drops from 554
lines to 297.

On the toolchain rust-toolchain.toml pins (rustc 1.94.0):

  cargo check -p rig-core                                       ok
  cargo check -p rig-core --all-features                        ok
  cargo check -p rig-core --all-features --target wasm32-...    ok
  cargo check -p rig-bedrock                                    ok
  cargo check -p rig-bedrock --all-features                     ok
  cargo check -p rig --features bedrock,... --all-targets        ok
  cargo fmt -- --check                                          ok
  cargo clippy -p rig-core --all-features --all-targets          0 warnings
  cargo clippy -p rig-bedrock --all-features --all-targets       0 warnings

Tests, `--lib`:

  rig-core, default features     1638 -> 1638
  rig-core, --all-features       1697 -> 1696   (the moved test)
  rig-bedrock                     113 ->  120   (+1 moved, +6 new)

`signed_headers_never_include_host` moved with the file and still passes.

Two of the six new tests drive a real completion and a real stream through
rig-core and assert the signature arrived on the request the transport saw.
Every piece here can be individually correct while the hook is simply never
called, and an unsigned request fails with a 403 that reads like a credential
problem rather than a wiring one. Both were checked against a negative control:
stubbing out either call site in rig-core fails the matching test, and only that
one.

rig-bedrock does not build for wasm32, before this change or after. The AWS SDK
reaches tokio's `net` feature and mio has a hard `compile_error!` there. That is
inherent to a crate whose job is calling AWS, and it is the other half of the
argument for putting the credential chain here rather than in a crate that has
to build for the browser.
…oint

`VerifyClient::verify` is a blanket implementation over every rig-core
`Client`. It sends a plain GET to `Provider::VERIFY_PATH` with the client's
default headers, and a SigV4 client deliberately has none: the signature covers
the body and the clock, so it cannot be precomputed at construction. Signing is
applied at the two Anthropic request builders, which `verify` does not go
through, so it returns 403 rather than a credential verdict.

This is not new — the same was true when the integration lived in rig-core and
`AnthropicKey::SigV4` produced no header there either. It was just easier to
miss without a provider of its own to document it against.

Closing it would mean a second signing hook in rig-core, this time covering
arbitrary client requests rather than the two Anthropic request builders. That is
more surface in the crate this change was about emptying than one verification
convenience is worth, so the limitation is documented and a small completion is
the suggested substitute.

Docs only. `cargo fmt -- --check`, `cargo doc -p rig-bedrock --no-deps` (no
warnings, so the intra-doc links resolve) and
`cargo clippy -p rig-bedrock --all-features --all-targets` all clean.
Addresses five review findings on the SigV4 integration. They land together
because the trait signature, its implementation and the credential cache cross
the same two files, and no subset of them compiles on its own.

The signing hook took the request URI as a string, and the signer recovered the
host by splitting on "://" and "/". That re-parsed an authority the builder had
already parsed, and got two cases wrong:

  - An explicit default port. base_url("https://host:443/anthropic") signed
    "host:443", but the transport sends "Host: host". hyper_util's client omits
    a port matching the scheme default, and rig-reqwest hands the URI to
    reqwest as a string, so the url crate drops the port while re-parsing it.
  - Userinfo. "https://user@host/" signed "user@host".

Either way the canonical request cannot be reproduced and every call fails 401
"the request signature we calculated does not match" -- the same opaque failure
already recorded here for the duplicated host header, and equally invisible to
a test that only asserts the Authorization header's shape.

The hook now takes &http::Uri, so the host is read off the parsed authority and
the re-parse is gone. This changes the public signature of
AnthropicCompatibleProvider::signed_headers, which has no implementors outside
this repository yet.

Also:

  - The resolved AWS credential chain moved off a process-wide static and onto
    the provider value. Two clients built under different profiles or roles
    used to sign as whichever chain resolved first, which at best is a 403
    naming an unexpected principal and at worst a call billed to the wrong
    account. Resolution is still deferred to the first request, because
    Provider::build is synchronous; that residual is documented where the cache
    is defined.
  - AnthropicKey no longer derives Debug, which printed the API key verbatim.
    Nothing logged it, so this was latent.
  - The module doc claimed signing had to live in rig-core's request builders
    because rig-bedrock could not reach the final body. That is wrong: an
    HttpClientExt decorator sees a fully built request, strictly later. The
    actual obstacle is that Provider::from_env and from_val return
    Client<Self, H> for the caller's H, so a decorator cannot be installed
    inside them, and moving it to the caller makes a forgotten wrapper a
    silently unsigned request. The doc now says that, states the cost of the
    design chosen instead -- two call sites that must stay in step -- and stops
    claiming a second rig-core hook would be needed to fix verify().
  - default_max_tokens_for_model stays public, with the real reason. The old
    one was falsifiable: rig-bedrock could reach the same value through
    <Anthropic as AnthropicCompatibleProvider>::default_max_tokens.

Tests: the host derivation is pinned case by case against the rule read out of
hyper_util and the url crate, and the port's effect on the signature is
asserted end to end. That needed the signing instant to become a parameter,
since a signature covers the time. Both new signature tests failed before this
change and pass after; a control asserts a non-default port still does reach
the signature, so the equality test cannot pass by ignoring the host.
@awsmadi
awsmadi force-pushed the feat/anthropic-sigv4 branch from 40351ea to 547ff27 Compare September 14, 2026 17:48
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.

feat: optional AWS SigV4 auth for the Anthropic provider

2 participants