mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
feat(auth,model): AWS SigV4 credential grammar and AdapterKind::Bedrock
CredentialRef gains the opaque `aws_sigv4` source: no static secret — the adapter signs requests from the AWS default credential chain at request time. The marker flows generically through fabro-auth (ResolvedSecret::AwsSigv4 → ApiKeyHeader::AwsSigv4) without fabro-auth learning anything about AWS. AdapterKind::Bedrock lands with the lean AWS dependency set (sigv4 + credential chain + eventstream decode only; transport stays on fabro-http) and a temporary not-yet-wired factory stub that the adapter commit later in this series replaces. Ported from PR #459 onto the post-codec-refactor layout: the route vocabulary additions (CodecKind::BedrockConverse as Bedrock's default codec) and the AdapterKindOptions-era registry arms are the port's adaptation, not part of the original. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Scott Werner <scott@sublayer.com>
This commit is contained in:
parent
dbf4829b47
commit
ffabfb5708
11 changed files with 790 additions and 72 deletions
661
Cargo.lock
generated
661
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
16
Cargo.toml
16
Cargo.toml
|
|
@ -33,6 +33,22 @@ fs2 = "0.4"
|
|||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
tokio-util = "0.7"
|
||||
# AWS building blocks for the native Bedrock adapter. Lean stack: request
|
||||
# signing + credential chain + event-stream decode only. Transport for the
|
||||
# actual Bedrock inference calls stays on fabro-http; the full
|
||||
# aws-sdk-bedrockruntime (and its parallel hyper stack) is not pulled in.
|
||||
# aws-config keeps its DEFAULT features on purpose: `rt-tokio` supplies the
|
||||
# TokioSleep impl the credential chain's retry requires (without it,
|
||||
# resolving the default chain panics with "an async sleep implementation is
|
||||
# required"), and `sso`/`credentials-process` make from_default_chain's
|
||||
# documented SSO/credential-process support real. `rustls` pins the TLS
|
||||
# backend for credential-resolution HTTP.
|
||||
aws-config = { version = "1", features = ["behavior-version-latest", "rustls"] }
|
||||
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
|
||||
aws-sigv4 = "1"
|
||||
aws-smithy-eventstream = "0.60"
|
||||
aws-smithy-runtime-api = "1"
|
||||
aws-smithy-types = "1"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap_complete = "4"
|
||||
jsonschema = { version = "0.42", default-features = false }
|
||||
|
|
|
|||
|
|
@ -45,7 +45,13 @@ pub struct OAuthConfig {
|
|||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum ApiKeyHeader {
|
||||
Bearer(String),
|
||||
Custom { name: String, value: String },
|
||||
Custom {
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
/// No static header: the request is authenticated by AWS SigV4 signing,
|
||||
/// with credentials resolved from the AWS default chain at request time.
|
||||
AwsSigv4,
|
||||
}
|
||||
|
||||
fn redact_for_debug(value: &str) -> String {
|
||||
|
|
@ -69,6 +75,7 @@ impl std::fmt::Debug for ApiKeyHeader {
|
|||
.field("name", name)
|
||||
.field("value", &redact_for_debug(value))
|
||||
.finish(),
|
||||
Self::AwsSigv4 => f.write_str("AwsSigv4"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ pub(crate) enum ResolvedSecret {
|
|||
credential: Box<OAuthCredential>,
|
||||
vault_name: String,
|
||||
},
|
||||
/// Opaque AWS SigV4 source: no static secret; the adapter signs requests
|
||||
/// using the AWS default credential chain.
|
||||
AwsSigv4,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -333,6 +336,9 @@ impl CredentialResolver {
|
|||
Err(err) => Err(vault_lookup_error(provider, name, err)),
|
||||
},
|
||||
CredentialRef::Env(name) => Ok((self.env_lookup)(name).map(ResolvedSecret::ApiKey)),
|
||||
// AWS SigV4 is an opaque source: it always "resolves" (the adapter
|
||||
// signs at request time from the AWS chain), no vault/env lookup.
|
||||
CredentialRef::AwsSigv4 => Ok(Some(ResolvedSecret::AwsSigv4)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,6 +385,21 @@ impl CredentialResolver {
|
|||
) -> Result<ApiCredential, ResolveError> {
|
||||
let base_url = Self::provider_base_url_for_catalog(provider_id, catalog);
|
||||
match secret {
|
||||
// Opaque AWS SigV4 source: carry the marker so the adapter signs
|
||||
// with the AWS chain; no static secret resolved here.
|
||||
ResolvedSecret::AwsSigv4 => Ok(ApiCredential {
|
||||
provider: provider_id.clone(),
|
||||
auth_header: Some(ApiKeyHeader::AwsSigv4),
|
||||
extra_headers: self.resolved_extra_headers_for_catalog(
|
||||
vault,
|
||||
provider_id,
|
||||
catalog,
|
||||
)?,
|
||||
base_url,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
}),
|
||||
ResolvedSecret::ApiKey(key) => {
|
||||
let provider = catalog
|
||||
.provider(provider_id)
|
||||
|
|
@ -579,6 +600,37 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sigv4_provider_resolves_to_aws_sigv4_credential() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
// No env credentials configured: SigV4 must still resolve.
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
let catalog = catalog_with(
|
||||
r#"
|
||||
[providers.bedrock]
|
||||
adapter = "bedrock"
|
||||
base_url = "https://bedrock-runtime.eu-west-1.amazonaws.com"
|
||||
|
||||
[providers.bedrock.auth]
|
||||
credentials = ["aws_sigv4"]
|
||||
"#,
|
||||
);
|
||||
|
||||
let resolved = resolver
|
||||
.resolve(
|
||||
ProviderId::from("bedrock"),
|
||||
CredentialUsage::ApiRequest,
|
||||
&catalog,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ResolvedCredential::Api(api) = resolved;
|
||||
assert_eq!(api.provider, ProviderId::from("bedrock"));
|
||||
assert_eq!(api.auth_header, Some(ApiKeyHeader::AwsSigv4));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_returns_not_configured_for_missing_provider() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ impl ApiKeyStrategy {
|
|||
.iter()
|
||||
.filter_map(|credential_ref| match credential_ref {
|
||||
CredentialRef::Env(name) => Some(name.clone()),
|
||||
CredentialRef::Vault(_) => None,
|
||||
CredentialRef::Vault(_) | CredentialRef::AwsSigv4 => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String {
|
|||
.iter()
|
||||
.filter_map(|credential| match credential {
|
||||
CredentialRef::Env(name) => Some(name.as_str()),
|
||||
CredentialRef::Vault(_) => None,
|
||||
CredentialRef::Vault(_) | CredentialRef::AwsSigv4 => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ base64.workspace = true
|
|||
bytes.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tracing.workspace = true
|
||||
aws-config.workspace = true
|
||||
aws-credential-types.workspace = true
|
||||
aws-sigv4.workspace = true
|
||||
aws-smithy-eventstream.workspace = true
|
||||
aws-smithy-runtime-api.workspace = true
|
||||
aws-smithy-types.workspace = true
|
||||
fabro-http.workspace = true
|
||||
fabro-auth = { path = "../fabro-auth" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@ fn apply_primary_auth_header(
|
|||
extra_headers.insert(name, value);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
// SigV4 is not a static header; only the Bedrock adapter consumes
|
||||
// the marker (it signs at request time).
|
||||
Some(ApiKeyHeader::AwsSigv4) | None => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +207,23 @@ fn build_openai_compatible(config: AdapterConfig) -> Result<Arc<dyn ProviderAdap
|
|||
Ok(Arc::new(build_openai_compatible_adapter(config)?))
|
||||
}
|
||||
|
||||
/// Placeholder until the Bedrock adapter lands later in this series; the
|
||||
/// adapter kind exists first so the auth/config grammar can be wired and
|
||||
/// tested ahead of it.
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Adapter factories share the by-value AdapterFactory signature."
|
||||
)]
|
||||
fn build_bedrock(config: AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error> {
|
||||
Err(Error::Configuration {
|
||||
message: format!(
|
||||
"provider '{}': the bedrock adapter is not yet wired",
|
||||
config.provider_id
|
||||
),
|
||||
source: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the factory for a known adapter kind.
|
||||
#[must_use]
|
||||
pub fn factory_for(adapter_kind: AdapterKind) -> AdapterFactory {
|
||||
|
|
@ -213,6 +232,7 @@ pub fn factory_for(adapter_kind: AdapterKind) -> AdapterFactory {
|
|||
AdapterKind::OpenAi => build_openai,
|
||||
AdapterKind::Gemini => build_gemini,
|
||||
AdapterKind::OpenAiCompatible => build_openai_compatible,
|
||||
AdapterKind::Bedrock => build_bedrock,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ pub enum AdapterKind {
|
|||
#[serde(rename = "openai_compatible")]
|
||||
#[strum(to_string = "openai_compatible")]
|
||||
OpenAiCompatible,
|
||||
Bedrock,
|
||||
}
|
||||
|
||||
impl AdapterKind {
|
||||
|
|
@ -87,6 +88,16 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bedrock_adapter_kind_roundtrips() {
|
||||
assert_eq!(AdapterKind::Bedrock.as_str(), "bedrock");
|
||||
assert_eq!(
|
||||
"bedrock".parse::<AdapterKind>().unwrap(),
|
||||
AdapterKind::Bedrock
|
||||
);
|
||||
assert!(AdapterKind::VARIANTS.contains(&AdapterKind::Bedrock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_profile_kind_round_trips_as_settings_strings() {
|
||||
for (kind, expected) in [
|
||||
|
|
|
|||
|
|
@ -171,11 +171,20 @@ pub struct CostRates {
|
|||
pub cache_input_cost_per_mtok: Option<f64>,
|
||||
}
|
||||
|
||||
/// Where a provider's credential comes from.
|
||||
///
|
||||
/// `Vault`/`Env` reference a stored secret resolved to an auth header.
|
||||
/// `AwsSigv4` is an opaque source: the credential comes from the AWS default
|
||||
/// credential chain and the request is SigV4-signed rather than carrying a
|
||||
/// static secret. Folding this into the credential list (instead of a separate
|
||||
/// `scheme` field) keeps the "where do credentials come from" decision in one
|
||||
/// place and makes invalid combinations unrepresentable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(into = "String", try_from = "String")]
|
||||
pub enum CredentialRef {
|
||||
Vault(String),
|
||||
Env(String),
|
||||
AwsSigv4,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredentialRef {
|
||||
|
|
@ -183,6 +192,7 @@ impl std::fmt::Display for CredentialRef {
|
|||
match self {
|
||||
Self::Vault(name) => write!(f, "vault:{name}"),
|
||||
Self::Env(name) => write!(f, "env:{name}"),
|
||||
Self::AwsSigv4 => write!(f, "aws_sigv4"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -209,6 +219,9 @@ impl FromStr for CredentialRef {
|
|||
}
|
||||
return Ok(Self::Env(name.to_string()));
|
||||
}
|
||||
if value == "aws_sigv4" {
|
||||
return Ok(Self::AwsSigv4);
|
||||
}
|
||||
Err(CredentialRefParseError::Invalid)
|
||||
}
|
||||
}
|
||||
|
|
@ -223,7 +236,7 @@ impl TryFrom<String> for CredentialRef {
|
|||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CredentialRefParseError {
|
||||
#[error("credential reference must be `vault:<name>` or `env:<NAME>`")]
|
||||
#[error("credential reference must be `vault:<name>`, `env:<NAME>`, or `aws_sigv4`")]
|
||||
Invalid,
|
||||
#[error("credential reference is missing a name after `vault:`")]
|
||||
EmptyVault,
|
||||
|
|
@ -234,6 +247,9 @@ pub enum CredentialRefParseError {
|
|||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProviderAuthConfig {
|
||||
/// Ordered credential sources; the first that resolves wins. Static secrets
|
||||
/// use `env:<NAME>` / `vault:<NAME>`; AWS SigV4 (Bedrock) uses `aws_sigv4`,
|
||||
/// which resolves opaquely from the AWS credential chain.
|
||||
pub credentials: Vec<CredentialRef>,
|
||||
#[serde(default)]
|
||||
pub header: ApiKeyHeaderPolicy,
|
||||
|
|
@ -511,7 +527,7 @@ impl CatalogProvider {
|
|||
.iter()
|
||||
.find_map(|credential_ref| match credential_ref {
|
||||
CredentialRef::Vault(name) => Some(name.as_str()),
|
||||
CredentialRef::Env(_) => None,
|
||||
CredentialRef::Env(_) | CredentialRef::AwsSigv4 => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1367,7 +1383,9 @@ struct AdapterDefaults {
|
|||
|
||||
fn adapter_defaults(adapter: AdapterKind) -> AdapterDefaults {
|
||||
match adapter {
|
||||
AdapterKind::Anthropic => AdapterDefaults {
|
||||
// Bedrock hosts Anthropic-family models, so it shares the Anthropic
|
||||
// agent profile and billing policy by default.
|
||||
AdapterKind::Anthropic | AdapterKind::Bedrock => AdapterDefaults {
|
||||
agent_profile: AgentProfileKind::Anthropic,
|
||||
billing_policy: BillingPolicy::Anthropic,
|
||||
},
|
||||
|
|
@ -1832,6 +1850,56 @@ mod tests {
|
|||
toml::from_str(source).expect("fixture should parse as an LLM settings layer")
|
||||
}
|
||||
|
||||
const BEDROCK_SIGV4_LAYER: &str = r#"
|
||||
[providers.bedrock]
|
||||
adapter = "bedrock"
|
||||
base_url = "https://bedrock-runtime.eu-west-1.amazonaws.com"
|
||||
|
||||
[providers.bedrock.auth]
|
||||
credentials = ["aws_sigv4"]
|
||||
|
||||
[models."bedrock-sonnet"]
|
||||
provider = "bedrock"
|
||||
api_id = "anthropic.claude-sonnet-4-6"
|
||||
display_name = "Bedrock Sonnet"
|
||||
family = "claude-4"
|
||||
default = true
|
||||
|
||||
[models."bedrock-sonnet".limits]
|
||||
context_window = 200000
|
||||
max_output = 64000
|
||||
|
||||
[models."bedrock-sonnet".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn provider_parses_bedrock_base_url_and_sigv4_credential() {
|
||||
let catalog = Catalog::from_settings(&minimal_settings(BEDROCK_SIGV4_LAYER)).unwrap();
|
||||
let provider = catalog.provider(&ProviderId::from("bedrock")).unwrap();
|
||||
assert_eq!(
|
||||
provider.base_url.as_deref(),
|
||||
Some("https://bedrock-runtime.eu-west-1.amazonaws.com")
|
||||
);
|
||||
assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![
|
||||
CredentialRef::AwsSigv4
|
||||
]);
|
||||
// Bedrock inherits the Anthropic agent profile and billing by default.
|
||||
assert_eq!(provider.agent_profile, AgentProfileKind::Anthropic);
|
||||
assert_eq!(provider.billing_policy, BillingPolicy::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_sigv4_credential_round_trips() {
|
||||
assert_eq!(
|
||||
"aws_sigv4".parse::<CredentialRef>().unwrap(),
|
||||
CredentialRef::AwsSigv4
|
||||
);
|
||||
assert_eq!(CredentialRef::AwsSigv4.to_string(), "aws_sigv4");
|
||||
}
|
||||
|
||||
// ---- Catalog struct tests ----
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ pub enum CodecKind {
|
|||
#[strum(to_string = "openai_compatible")]
|
||||
OpenAiCompatible,
|
||||
GeminiGenerate,
|
||||
/// Amazon Bedrock's unified Converse/ConverseStream dialect: one
|
||||
/// model-agnostic envelope AWS translates to each hosted family's
|
||||
/// native format server-side.
|
||||
BedrockConverse,
|
||||
}
|
||||
|
||||
impl CodecKind {
|
||||
|
|
@ -53,6 +57,7 @@ impl CodecKind {
|
|||
AdapterKind::OpenAi => Self::OpenAiResponses,
|
||||
AdapterKind::Gemini => Self::GeminiGenerate,
|
||||
AdapterKind::OpenAiCompatible => Self::OpenAiCompatible,
|
||||
AdapterKind::Bedrock => Self::BedrockConverse,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +95,7 @@ mod tests {
|
|||
(CodecKind::OpenAiResponses, "openai_responses"),
|
||||
(CodecKind::OpenAiCompatible, "openai_compatible"),
|
||||
(CodecKind::GeminiGenerate, "gemini_generate"),
|
||||
(CodecKind::BedrockConverse, "bedrock_converse"),
|
||||
] {
|
||||
assert_eq!(kind.as_str(), expected);
|
||||
assert_eq!(kind.to_string(), expected);
|
||||
|
|
@ -103,6 +109,7 @@ mod tests {
|
|||
(AdapterKind::OpenAi, CodecKind::OpenAiResponses),
|
||||
(AdapterKind::Gemini, CodecKind::GeminiGenerate),
|
||||
(AdapterKind::OpenAiCompatible, CodecKind::OpenAiCompatible),
|
||||
(AdapterKind::Bedrock, CodecKind::BedrockConverse),
|
||||
] {
|
||||
assert_eq!(CodecKind::default_for(adapter), expected);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue