Skip to content

Bedrock-SigV4认证

沿 Bedrock 请求追踪凭据选择、区域解析、发送字节与 SigV4 签名,解释临时 token、认证重试和并发重新登录的实现及验证方法。

基于rust-v0.150.0
CodexAWS认证

Bedrock-SigV4认证 ​

同一个 Bedrock 账户,直接发送一条简单请求能够成功,换成带会话信息的 Codex 请求却出现签名错误; 已经设置了 AWS_PROFILE,实际请求又仍然使用环境变量中的 Bearer token。这两类问题都不能只靠 “AWS 凭据配置正确”来解释。前者涉及签名覆盖哪些 Header 和字节,后者涉及 Codex 在 AWS SDK 之前 做出的认证来源选择。

本文沿着一次 Responses 请求展开:从选定 Bedrock Provider,到解析凭据、固定请求字节、计算 SigV4,最后追踪失败后的重试与重新登录。SigV4 是用 AWS secret access key 派生的密钥,对请求 内容和时间、区域、服务名共同计算认证签名;它不等于把 access key 放进 Bearer Header。

阅读本文需要了解 Rust 的 enum、Arc、async/await 和基本 HTTP 请求结构。 Provider配置字段解释配置的通用模型, 模型请求构造解释 Prompt 如何变成 Responses 请求, ChatGPT与API-Key认证接入解释共享认证接口。 下面会补足 AWS 专有概念,但不展开 STS、SSO 登录协议或 IAM 策略的完整实现。读完后,应当能够定位 一次请求选择了谁的凭据、签了哪些内容,以及某个失败究竟应重发请求、重新登录还是修正配置。

1. 两种Bedrock端点 ​

1.1 身份与签名域 ​

先区分三个容易混淆的值:配置中的 Provider ID 选择运行时实现,base URL 决定请求去哪里, SigV4 中的 service 决定签名密钥的服务作用域。它们相关,但不能互相替代。

配置中的 Provider ID运行时名称自动生成的 base URLSigV4 service
amazon-bedrockAmazon Bedrockhttps://bedrock-mantle.<region>.api.aws/openai/v1bedrock-mantle
amazon-bedrock-runtimeAmazon Bedrock Runtimehttps://bedrock-runtime.<region>.amazonaws.com/openai/v1bedrock

core/src/config/mod.rs 将内置和用户配置合并后,用 model_provider 找到 ModelProviderInfo。 ModelClient::new 将选中的配置交给下面的工厂。工厂检查 is_amazon_bedrock();该方法识别表中两个 运行时名称,然后由 AmazonBedrockModelProvider::new 区分 Mantle 和 Runtime。

源码文件:codex-rs/model-provider/src/provider.rs

相关函数/类型:create_model_provider

rust
pub fn create_model_provider(
    provider_info: ModelProviderInfo,
    auth_manager: Option<Arc<AuthManager>>,
) -> SharedModelProvider {
    // 运行时按 Provider 身份分派;aws 字段本身不是这里的分派条件。
    if provider_info.is_amazon_bedrock() {
        Arc::new(AmazonBedrockModelProvider::new(provider_info, auth_manager))
    } else {
        Arc::new(ConfiguredModelProvider::new(provider_info, auth_manager))
    }
}

工厂返回 Arc<dyn ModelProvider>,上层可以统一取得 API 参数和认证实现。这里并不是看到任意 aws 字段就启用签名。因此调试自定义 Provider 时,必须同时追踪配置合并后的名称和运行时类型, 不能仅凭 TOML 出现了 [...aws] 就认定请求经过了 Bedrock 签名器。

Mantle 的区域解析和签名配置由同一模块提供。下面的 BEDROCK_MANTLE_SERVICE_NAME 常量值是 bedrock-mantle;is_supported_amazon_bedrock_region 查询源码中的区域白名单。

源码文件:codex-rs/model-provider/src/amazon_bedrock/mantle.rs

相关函数/类型:aws_auth_config / base_url

rust
pub(super) fn aws_auth_config(aws: &ModelProviderAwsAuthInfo) -> AwsAuthConfig {
    AwsAuthConfig {
        profile: aws.profile.clone(),
        region: region_from_config(aws),
        // 常量值为 bedrock-mantle;区域端点与签名域由同一个 endpoint 分支选定。
        service: BEDROCK_MANTLE_SERVICE_NAME.to_string(),
    }
}

// ...

pub(super) fn base_url(region: &str) -> Result<String> {
    if is_supported_amazon_bedrock_region(region) {
        Ok(format!("https://bedrock-mantle.{region}.api.aws/openai/v1"))
    } else {
        Err(CodexErr::Fatal(format!(
            "Amazon Bedrock does not support region `{region}`"
        )))
    }
}

base_url 在本地拒绝白名单之外的区域,失败时还没有发送模型请求。该表有 12 个区域,包括 us-east-1、us-east-2、us-west-2、ap-northeast-1、eu-central-1 等;它描述的是此处代码 允许自动生成端点的范围,不能用来推断所有 AWS 服务的区域支持情况。

Runtime 分支使用另一组映射:

源码文件:codex-rs/model-provider/src/amazon_bedrock/runtime.rs

相关函数/类型:aws_auth_config / base_url

rust
pub(super) fn aws_auth_config(aws: &ModelProviderAwsAuthInfo) -> AwsAuthConfig {
    AwsAuthConfig {
        profile: aws.profile.clone(),
        region: region_from_config(aws),
        // Runtime 的服务名是 bedrock,即使 URL 中出现 bedrock-runtime。
        service: BEDROCK_RUNTIME_SERVICE_NAME.to_string(),
    }
}

// ...

pub(super) fn base_url(region: &str) -> String {
    format!("https://bedrock-runtime.{region}.amazonaws.com/openai/v1")
}

Runtime 的 URL 中虽然写着 bedrock-runtime,用于签名的服务名却是 bedrock。 这里的 base_url 直接插入 region,没有复用 Mantle 的本地区域白名单。字符串能够生成,仍不能证明 远端区域有对应模型、账户已经获得权限,或该模型 ID 适合这个端点。

1.2 地址覆盖边界 ​

内置 Bedrock Provider 将 base_url 初始值设为 None,让运行时根据区域生成地址。 显式设置地址时,下面的短路分支优先返回它。

源码文件:codex-rs/model-provider/src/amazon_bedrock/mod.rs

相关函数/类型:AmazonBedrockModelProvider::runtime_base_url

rust
async fn runtime_base_url(&self) -> Result<Option<String>> {
    // 显式 URL 直接返回;不会从域名反推或重写签名 service。
    if let Some(base_url) = self.info.base_url.clone() {
        return Ok(Some(base_url));
    }
    let auth_source = self.auth_source();
    let managed_auth = self.managed_auth();
    let base_url = match self.endpoint {
        BedrockEndpoint::Mantle => {
            bedrock_mantle_runtime_base_url(auth_source, managed_auth.as_ref(), &self.aws)
                .await?
        }
        BedrockEndpoint::Runtime => {
            bedrock_runtime_base_url(auth_source, managed_auth.as_ref(), &self.aws).await?
        }
    };
    Ok(Some(base_url))
}

这个分支只改变目的地址。它没有根据 URL 里的 bedrock-runtime 或代理域名重新选择 endpoint, 也没有从域名中提取 signing region。比如选择 amazon-bedrock 后只把 URL 改为 Runtime 地址, 认证实现仍按 Mantle 构造 service,还会执行 Mantle 的 Header 过滤。切换两种 AWS 接入方式时,应 先检查 Provider 身份,再核对地址、region 与 service 是否一致。

配置示例:study-bedrock 是示例 profile 名,实际使用时要对应已经配置的 AWS profile;省略 base_url,可以让端点与区域按上述实现关联。

toml
model_provider = "amazon-bedrock"

[model_providers.amazon-bedrock.aws]
profile = "study-bedrock"
region = "us-west-2"

[model_providers.amazon-bedrock.aws.auth_refresh]
command = "aws"
args = ["sso", "login", "--profile", "study-bedrock"]
timeout_ms = 120000

auth_refresh 是可选项:它描述特定认证失败后执行的重新登录命令。这里的配置没有填写 secret, 也没有填写 service。下面的真实配置类型中,AWS 子表只有三个字段。

源码文件:codex-rs/model-provider-info/src/lib.rs

相关函数/类型:ModelProviderAwsAuthInfo / AwsAuthRefreshConfig

rust
pub struct ModelProviderAwsAuthInfo {
    /// AWS profile name to use. When unset, the AWS SDK default chain decides.
    // 这是选定的 AWS profile,不是 access key 的存储位置。
    pub profile: Option<String>,
    /// AWS region to use for provider-specific endpoints.
    pub region: Option<String>,
    /// Optional command used to reauthenticate after a refreshable AWS auth failure.
    pub auth_refresh: Option<AwsAuthRefreshConfig>,
}

// ...

pub struct AwsAuthRefreshConfig {
    /// Executable to invoke directly, without a shell.
    // 重新登录命令与取得 Bearer token 的 auth.command 属于不同协议。
    pub command: String,
    /// Arguments passed to the refresh command.
    #[serde(default)]
    pub args: Vec<RedactedString>,
    /// Maximum time to wait for the refresh command to complete.
    #[serde(default = "default_aws_auth_refresh_timeout_ms")]
    pub timeout_ms: NonZeroU64,
}

ModelProviderAwsAuthInfo 是用户配置,前面看到的 AwsAuthConfig 则是传给签名模块的内部配置。 后者才包含 service,并由端点实现填入。把 service = "bedrock" 写进用户 AWS 子表,不能替代 运行时对签名域的选择。

字段或组合当前代码的约束对请求的影响
aws.profileOption<String>;来源选择检查是否为 Some显式选定 profile,优先于托管凭据和环境 token
aws.region运行时裁掉首尾空白,空串视为未配置参与区域端点与签名上下文构造
aws.auth_refresh.command配置校验和执行时都要求精确等于 aws不通过 shell 执行任意命令字符串
aws.auth_refresh.argsVec<RedactedString>;默认空数组作为 argv 传递,参数要与实际 profile 对应
aws.auth_refresh.timeout_ms非零整数;默认 300000 毫秒约束重新登录子进程执行时间
aws 与 supports_websocketsvalidate 拒绝同时启用此 AWS 签名路径面向 HTTP 请求
aws 与其他认证配置validate 检查 env_key、显式 Bearer、auth、requires_openai_auth 冲突配置校验与后面的运行时来源选择是两个不同环节

merge_configured_model_providers 为两个内置 Bedrock ID 单独开放了 base_url、auth、 http_headers 和 AWS 子表的覆盖;其他非默认字段会被拒绝。理解来源选择函数时,还应保留这个前提: 函数能表示某种运行时分支,并不代表任意相互冲突的配置组合都能通过更早的加载或校验入口。

2. 凭据选择顺序 ​

2.1 七种来源 ​

Bearer token 是直接放入 Authorization: Bearer ... 的认证值;access keys 则包含 access key ID、secret access key,以及临时凭据可选的 session token,供 SigV4 使用。 Bedrock 同时有这两类接入,不能把它们统称为“AWS key”后忽略差别。

Codex 先运行 auth_source,之后才按结果建立具体认证实现。图中从上到下的判断顺序,会直接决定 一次请求是否进入 AWS SDK;“配置了 profile”与“环境中有 AWS_PROFILE”走的也不是同一条分支。

图中的 CommandBearerToken 由 AmazonBedrockModelProvider::api_auth 单独处理;其他来源进入 resolve_auth_method。两个输出都是共享的 AuthProvider,但一个可直接提供认证头,另一个必须 取得完整请求后才能签名。

源码文件:codex-rs/model-provider/src/amazon_bedrock/auth.rs

相关函数/类型:BedrockAuthSource / auth_source

rust
pub(super) enum BedrockAuthSource {
    CommandBearerToken,
    ConfiguredAwsProfile,
    ManagedBearerToken,
    ManagedAccessKeys,
    EnvBearerToken,
    EnvAwsCredentials,
    AwsSdk,
}

// ...

pub(super) fn auth_source(
    provider_info: &ModelProviderInfo,
    auth_manager: Option<&AuthManager>,
    env_var: impl Fn(&'static str) -> std::result::Result<String, std::env::VarError> + Copy,
) -> BedrockAuthSource {
    // 按顺序命中一条来源;来源选择尚未验证凭据能否使用。
    if provider_info.has_command_auth() {
        BedrockAuthSource::CommandBearerToken
    } else if provider_info
        .aws
        .as_ref()
        .is_some_and(|aws| aws.profile.is_some())
    {
        BedrockAuthSource::ConfiguredAwsProfile
    } else if matches!(
        auth_manager.and_then(AuthManager::auth_cached),
        Some(CodexAuth::BedrockApiKey(_))
    ) {
        BedrockAuthSource::ManagedBearerToken
    } else if matches!(
        auth_manager.and_then(AuthManager::auth_cached),
        // 托管 access keys 的优先级高于环境变量里的 Bearer token。
        Some(CodexAuth::BedrockAccessKeys(_))
    ) {
        BedrockAuthSource::ManagedAccessKeys
    } else if non_empty_env_var_from(AWS_BEARER_TOKEN_BEDROCK_ENV_VAR, env_var).is_some() {
        BedrockAuthSource::EnvBearerToken
    } else if non_empty_env_var_from(AWS_ACCESS_KEY_ID_ENV_VAR, env_var).is_some()
        && non_empty_env_var_from(AWS_SECRET_ACCESS_KEY_ENV_VAR, env_var).is_some()
    {
        BedrockAuthSource::EnvAwsCredentials
    } else {
        BedrockAuthSource::AwsSdk
    }
}

逐个判断条件可以得到几个具体结论:

  • 显式 aws.profile 优先于 AuthManager 缓存的 Bedrock API key 和 access keys。
  • 托管凭据优先于进程环境中的 AWS_BEARER_TOKEN_BEDROCK;环境 Bearer 又优先于环境 access keys。
  • 环境 access keys 分支要求 ID 和 secret 两项均非空。只有 ID 时,来源分类落到 AwsSdk;这不 意味着跳过 SDK 自己的环境 provider,也没有说明最终会选中哪个 profile。
  • AWS_PROFILE 本身没有在此被检查。它影响 SDK 默认链内部的 profile 选择,不能压过 Codex 更早 选中的环境 Bearer 分支。
  • Some("") 仍满足显式 profile 的存在性判断;来源选中并不表示 profile 有效,真正的加载失败发生 在后续步骤。

这是一套有序选择规则,不是失败后逐个尝试所有账户的循环。选中的环境 Bearer 消失、托管凭据类型 不再匹配时,后续解析返回明确错误,而不会悄悄换一个身份继续请求。

2.2 来源的具体实现 ​

来源枚举表达“去哪里取”;BedrockAuthMethod 表达“最终拿到了什么”。下面保留所有分支,便于看到 Bearer 和签名上下文各自需要的数据,以及失败发生在哪里。

源码文件:codex-rs/model-provider/src/amazon_bedrock/auth.rs

相关函数/类型:resolve_auth_method

rust
pub(super) async fn resolve_auth_method(
    source: BedrockAuthSource,
    managed_auth: Option<&CodexAuth>,
    aws: &ModelProviderAwsAuthInfo,
    endpoint: BedrockEndpoint,
) -> Result<BedrockAuthMethod> {
    match source {
        BedrockAuthSource::CommandBearerToken => Err(CodexErr::Fatal(
            "Amazon Bedrock command authentication must be resolved by the model provider"
                .to_string(),
        )),
        BedrockAuthSource::ManagedBearerToken => {
            let Some(CodexAuth::BedrockApiKey(auth)) = managed_auth else {
                return Err(CodexErr::Fatal(
                    "selected Codex-managed Amazon Bedrock API key is no longer available"
                        .to_string(),
                ));
            };
            Ok(BedrockAuthMethod::ManagedBearerToken {
                token: auth.api_key.clone(),
                region: auth.region.clone(),
            })
        }
        BedrockAuthSource::EnvBearerToken => {
            let token = non_empty_env_var_from(AWS_BEARER_TOKEN_BEDROCK_ENV_VAR, std::env::var)
                .ok_or_else(|| {
                    CodexErr::Fatal(
                        "selected `AWS_BEARER_TOKEN_BEDROCK` credential is no longer available"
                            .to_string(),
                    )
                })?;
            let region = bearer_token_region(aws, std::env::var)?;
            Ok(BedrockAuthMethod::EnvBearerToken { token, region })
        }
        // 显式 profile 使用限定 profile 的 provider,而不是默认链的顶层顺序。
        BedrockAuthSource::ConfiguredAwsProfile => {
            let config = match endpoint {
                BedrockEndpoint::Mantle => aws_auth_config(aws),
                BedrockEndpoint::Runtime => runtime::aws_auth_config(aws),
            };
            let context = AwsAuthContext::load_profile(config)
                .await
                .map_err(aws_auth_error_to_codex_error)?;
            Ok(BedrockAuthMethod::AwsSdkAuth { context })
        }
        BedrockAuthSource::ManagedAccessKeys => {
            let Some(CodexAuth::BedrockAccessKeys(auth)) = managed_auth else {
                return Err(CodexErr::Fatal(
                    "selected Codex-managed Amazon Bedrock access keys are no longer available"
                        .to_string(),
                ));
            };
            let access_keys = AwsAccessKeys {
                access_key_id: auth.access_key_id.clone(),
                secret_access_key: auth.secret_access_key.clone(),
                session_token: auth.session_token.clone(),
            };
            let config = match endpoint {
                BedrockEndpoint::Mantle => aws_auth_config(aws),
                BedrockEndpoint::Runtime => runtime::aws_auth_config(aws),
            };
            let context = AwsAuthContext::load_with_access_keys(config, access_keys)
                .await
                .map_err(aws_auth_error_to_codex_error)?;
            Ok(BedrockAuthMethod::AwsSdkAuth { context })
        }
        // 这两个来源仍统一交给 SDK;分类的差异还影响外部重新登录资格。
        BedrockAuthSource::EnvAwsCredentials | BedrockAuthSource::AwsSdk => {
            let config = match endpoint {
                BedrockEndpoint::Mantle => aws_auth_config(aws),
                BedrockEndpoint::Runtime => runtime::aws_auth_config(aws),
            };
            let context = AwsAuthContext::load(config)
                .await
                .map_err(aws_auth_error_to_codex_error)?;
            Ok(BedrockAuthMethod::AwsSdkAuth { context })
        }
    }
}

这里最重要的区别是三种签名上下文的构造方式:

来源构造方法身份由谁决定
ConfiguredAwsProfileAwsAuthContext::load_profile明确指定的 AWS profile 及其内部凭据来源
ManagedAccessKeysAwsAuthContext::load_with_access_keysAuthManager 中已选中的 access keys 副本
EnvAwsCredentials / AwsSdkAwsAuthContext::loadAWS SDK 默认凭据链

两个默认链分支调用同一个方法,但分类仍有用途:显式环境 access keys 不能自动加入后文的 AWS 重新登录流程。托管 Bearer 的 region 直接来自托管认证对象,环境 Bearer 的 region 则需另行解析。

resolve_provider_auth 最终把两种 Bearer method 包装为 BearerAuthProvider,把 AwsSdkAuth 包装为 BedrockSigV4AuthProvider。前者没有签名的 body 依赖;后者才进入本文后半部分的字节与签名链路。

2.3 显式profile ​

“选择 profile”不仅是把名字传给 SDK 默认链。若只设置默认链的 profile 名,链中更早的环境凭据仍可 优先命中。Codex 的显式 profile 路径会替换实际取凭据的 provider。

源码文件:codex-rs/aws-auth/src/lib.rs

相关函数/类型:AwsAuthContext::load_profile

rust
pub async fn load_profile(config: AwsAuthConfig) -> Result<Self, AwsAuthError> {
    let profile = config
        .profile
        .as_deref()
        .ok_or(AwsAuthError::MissingProfile)?;
    let credentials_provider = SharedCredentialsProvider::new(
        discovery::profile_credentials_provider(profile, config.region.as_deref()).await,
    );
    let mut context = Self::load(config).await?;
    // 保留 SDK 解析的区域,但替换实际取凭据的 provider。
    context.credentials_provider = credentials_provider;
    Ok(context)
}

Self::load(config) 保留 SDK 配置解析出的 region 与 service;随后的赋值把凭据来源改为 ProfileFileCredentialsProvider。对应的构造代码如下:

源码文件:codex-rs/aws-auth/src/discovery.rs

相关函数/类型:profile_credentials_provider

rust
pub(crate) async fn profile_credentials_provider(
    profile: &str,
    region: Option<&str>,
) -> ProfileFileCredentialsProvider {
    let region = match region {
        Some(region) => Some(Region::new(region.to_string())),
        None => {
            DefaultRegionChain::builder()
                .profile_name(profile)
                .build()
                .region()
                .await
        }
    };
    let provider_config = ProviderConfig::without_region().with_region(region);

    // 只从选定 profile 建立凭据解析器;profile 内部仍可委托其他来源。
    ProfileFileCredentialsProvider::builder()
        .configure(&provider_config)
        .profile_name(profile)
        .build()
}

这里绕过的是默认凭据链顶层的“先试环境变量”步骤。它没有禁止 profile 本身使用 credential_source、角色链、SSO 或外部凭据进程;这些仍由 AWS SDK 的 profile 实现负责。 因此“显式 profile”应理解为固定身份配置入口,不能扩大成“不读取任何环境信息”或“永远只读一对静态 key”。

对于未指定显式 profile 的路径,锁定的 aws-config 1.8.12默认凭据链实现 依次组合环境变量、共享配置/profile、Web Identity、ECS/HTTP 凭据和 EC2 IMDSv2。 SSO 属于 profile 所支持的凭据机制,不能简单放在整条链末尾。某一层返回“没有来源”和返回 “配置无效/加载失败”,也不是同一种情况;后者不保证继续寻找其他身份。

2.4 区域的来源 ​

区域不能只从最终 URL 猜测。不同认证方式使用以下规则:

当前来源region 的取得方式
托管 Bedrock Bearer使用 BedrockApiKeyAuth.region,不会被 AWS 子表 region 覆盖
环境 Bedrock Bearer非空 aws.region → AWS_REGION → AWS_DEFAULT_REGION;都没有则失败
签名上下文优先显式 region;未指定时交给 SDK 的环境/profile/IMDS 区域链
命令 Bearer自动生成地址时通过 SDK 配置解析 region;显式 base URL 可先短路地址生成

环境 Bearer 的顺序在 Codex 自己的函数中直接可见。

源码文件:codex-rs/model-provider/src/amazon_bedrock/auth.rs

相关函数/类型:bearer_token_region

rust
pub(super) fn bearer_token_region(
    aws: &ModelProviderAwsAuthInfo,
    env_var: impl Fn(&'static str) -> std::result::Result<String, std::env::VarError> + Copy,
) -> Result<String> {
    // 这里是环境 Bearer token 的区域规则,不负责托管 Bearer 的区域。
    region_from_config(aws)
        .or_else(|| non_empty_env_var_from(AWS_REGION_ENV_VAR, env_var))
        .or_else(|| non_empty_env_var_from(AWS_DEFAULT_REGION_ENV_VAR, env_var))
        .ok_or_else(|| {
            CodexErr::Fatal(
                "Amazon Bedrock bearer token auth requires \
`model_providers.amazon-bedrock.aws.region`, `AWS_REGION`, or `AWS_DEFAULT_REGION`"
                    .to_string(),
            )
        })
}

region_from_config 和 non_empty_env_var_from 都处理空白字符串。注意这套规则只属于环境 Bearer, 不要套用到托管 Bearer。测试 configured_profile_takes_precedence_over_managed_auth 中,无显式 profile 的托管 Bearer 使用 us-east-1,即便 AWS 子表写的是 us-west-2;加入显式 profile 后, 认证来源改变,才使用新的 profile/region 路径。

3. 凭据与签名时机 ​

3.1 上下文持有什么 ​

配置选定后,仍要区分运行时 Provider、签名上下文和一次请求。下图中虚线表示构造或调用依赖, 实线菱形表示字段持有;图中省略部分字段类型,后面的源码给出完整定义。

AmazonBedrockModelProvider 持有 Provider 配置、端点类别、可选 AuthManager 和共享恢复对象。 api_auth 返回的签名器内部持有下面的 AwsAuthContext;它的字段里没有“当前请求签名”,也没有 一个由 Codex 自己维护的 token 到期计时器。

源码文件:codex-rs/aws-auth/src/lib.rs

相关函数/类型:AwsAuthConfig / AwsAuthContext / AwsAuthContext::load

rust
pub struct AwsAuthConfig {
    pub profile: Option<String>,
    pub region: Option<String>,
    pub service: String,
}

// ...

pub struct AwsAuthContext {
    // 长期持有取凭据的接口;每次签名再取得 Credentials。
    credentials_provider: SharedCredentialsProvider,
    region: String,
    service: String,
}

// ...

pub async fn load(config: AwsAuthConfig) -> Result<Self, AwsAuthError> {
    let sdk_config = config::load_sdk_config(&config).await?;
    let credentials_provider = config::credentials_provider(&sdk_config)?;
    let region = config::resolved_region(&sdk_config)?;

    Ok(Self {
        credentials_provider,
        region,
        service: config.service.trim().to_string(),
    })
}

SharedCredentialsProvider 是取得 Credentials 的共享接口。构造成功只表示找到了 provider 和 region,不表示实际 access keys 已经成功取出,更不表示 AWS 已接受这些凭据。初学者调试时应把 “context 创建成功”和“第一次签名成功”分成两个观察点。

SDK 配置的装配入口很短,但它清楚标出了 Codex 的覆盖范围。

源码文件:codex-rs/aws-auth/src/config.rs

相关函数/类型:load_sdk_config

rust
pub(crate) async fn load_sdk_config(config: &AwsAuthConfig) -> Result<SdkConfig, AwsAuthError> {
    if config.service.trim().is_empty() {
        return Err(AwsAuthError::EmptyService);
    }

    // 未显式覆盖的字段由锁定版本的 AWS SDK 默认链解析。
    let mut loader = aws_config::defaults(BehaviorVersion::latest());
    if let Some(profile) = config.profile.as_ref() {
        loader = loader.profile_name(profile);
    }
    if let Some(region) = config.region.as_ref() {
        loader = loader.region(Region::new(region.clone()));
    }

    Ok(loader.load().await)
}

显式 profile 与 region 被交给 SDK;未覆盖部分使用该依赖版本的默认行为。BehaviorVersion::latest() 取的是已链接 SDK 版本所支持的行为,不会在运行时下载一个新的 AWS SDK。随后 credentials_provider 和 resolved_region 分别拒绝缺失的 provider 与 region。

ModelClient::current_client_setup 在一次请求准备时取得 API Provider 和 api_auth_for_scope; Bedrock 经后者调用自己的 api_auth。所以签名上下文不是只能在 Codex 启动时建立一次的全局单例。 普通 HTTP 重试复用当前请求客户端的认证对象;外层认证恢复后重新执行 setup,则可以建立新的上下文。

3.2 临时access keys ​

AWS 临时凭据是一个整体:ID、secret 和 session token 必须属于同一次凭据发放。session token 不是另一个 Bearer token,而是 AWS 验证这组临时身份时所需的附加数据。

源码文件:codex-rs/aws-auth/src/lib.rs

相关函数/类型:AwsAccessKeys / AwsAuthContext::load_with_access_keys

rust
pub struct AwsAccessKeys {
    pub access_key_id: String,
    pub secret_access_key: String,
    pub session_token: Option<String>,
}

// ...

pub async fn load_with_access_keys(
    config: AwsAuthConfig,
    access_keys: AwsAccessKeys,
) -> Result<Self, AwsAuthError> {
    let mut context = Self::load(config).await?;
    context.credentials_provider =
        SharedCredentialsProvider::new(aws_credential_types::Credentials::new(
            access_keys.access_key_id,
            access_keys.secret_access_key,
            access_keys.session_token,
            // 托管静态值没有提供过期时间元数据,也没有在此实现凭据轮换。
            /*expires_after*/ None,
            "codex-managed-bedrock-access-keys",
        ));
    Ok(context)
}

load_with_access_keys 先取得 region/service,再用调用方给定的静态值替换 provider。 expires_after: None 表示这条路径没有传入到期时间元数据,并不表示现实中的凭据永不过期。 这个静态 provider 也没有在此实现 STS 刷新;重新发送同一组过期值,不会自行产生一组新的临时凭据。

AwsAccessKeys 的自定义 Debug 对 ID、secret、session token 都输出脱敏占位。 AwsAuthContext 的 Debug 仅展示 region/service,不展示 provider 内部凭据。但 AwsSignedRequest.headers 已含认证信息,不能把“输入类型有脱敏 Debug”外推成“任何请求对象都可直接打印”。

3.3 每次签名的取用 ​

取得凭据发生在签名时,而不是 AwsAuthContext::load 的返回点。

源码文件:codex-rs/aws-auth/src/lib.rs

相关函数/类型:AwsAuthContext::sign / AwsAuthContext::sign_at

rust
pub async fn sign(&self, request: AwsRequestToSign) -> Result<AwsSignedRequest, AwsAuthError> {
    self.sign_at(request, SystemTime::now()).await
}

// ...

async fn sign_at(
    &self,
    request: AwsRequestToSign,
    time: SystemTime,
) -> Result<AwsSignedRequest, AwsAuthError> {
    // 等待取凭据完成后才调用签名器;这里不发送模型请求。
    let credentials = self.credentials_provider.provide_credentials().await?;
    signing::sign_request(&credentials, &self.region, &self.service, request, time)
}

调用顺序是取得签名时间、等待 provider 返回凭据,再同步计算签名。每次调用 sign 都会调用 provide_credentials();底层 provider 可能使用缓存、访问 profile 或刷新角色凭据,因此这并不等于 每次都访问网络。Codex 在这层保留的是取得凭据的能力,而非提前复制一份永远不变的 SDK 身份。

还有一个时间边界:SystemTime::now() 在等待 provide_credentials() 之前取值。 如果凭据解析耗时很长,传给签名器的时间已经有所滞后。排查过期签名时,除了凭据本身,也应检查系统 时钟和取凭据延迟;单纯把 HTTP 发送超时调大,不会改写已经捕获的签名时间。

4. 固定请求字节 ​

4.1 会话头的删减 ​

Responses 层会生成 session_id、thread_id 等兼容 Header,也会设置使用连字符的 x-client-request-id。Mantle 的入口不会在验签前保留带下划线的这类 Header。 如果发送方将它们纳入签名,而网关之后去掉它们,双方构造的规范请求就不一致。

源码文件:codex-rs/model-provider/src/amazon_bedrock/auth.rs

相关函数/类型:remove_headers_not_preserved_by_bedrock_mantle

rust
fn remove_headers_not_preserved_by_bedrock_mantle(headers: &mut HeaderMap) {
    // The Bedrock Mantle front door does not preserve legacy OpenAI
    // compatibility headers that use snake_case, such as `session_id` and
    // `thread_id`, before SigV4 verification. Signing that header class makes
    // richer Codex agent requests fail even though raw Responses requests work.
    let headers_to_remove = headers
        .keys()
        // 按头名是否含下划线过滤,覆盖将来新增的同类兼容头。
        .filter(|name| name.as_str().contains('_'))
        .cloned()
        .collect::<Vec<_>>();
    for name in headers_to_remove {
        headers.remove(name);
    }
}

实现按头名中是否包含 _ 过滤,既处理当前两个会话头,也处理将来的同类头。 它先收集名字再删除,避免在遍历 HeaderMap 键时修改同一张表。x-client-request-id 不含下划线, 因此保留。这个过滤只出现在 Mantle 的 SigV4 apply_auth 分支,不能推广到 Runtime 或 Bearer 请求。

这也解释了“简单 HTTP 请求成功,完整代理请求失败”的一种机制:两次请求可能使用同一组凭据, 但 Header 集合不同。排查时应比较被签名的头及其代理处理过程,而不是先假设模型参数有问题。

4.2 请求体的三种表示 ​

签名覆盖的是字节,JSON 在语义上相同并不足够。{"a":1} 和 { "a": 1 } 是等价的 JSON 值, 但 SHA-256 不同。HTTP 客户端用三个 body 变体区分“值”“已编码 JSON”和“原始字节”。

源码文件:codex-rs/http-client/src/request.rs

相关函数/类型:RequestBody / PreparedRequestBody / Request

rust
pub enum RequestBody {
    Json(Value),
    EncodedJson(EncodedJsonBody),
    // 签名后保留确定的字节,避免传输层再次序列化 JSON。
    Raw(Bytes),
}

// ...

pub struct PreparedRequestBody {
    pub headers: HeaderMap,
    pub body: Option<Bytes>,
}

// ...

pub struct Request {
    pub method: Method,
    pub url: String,
    pub headers: HeaderMap,
    pub body: Option<RequestBody>,
    pub compression: RequestCompression,
    pub timeout: Option<Duration>,
}

Request 同时拥有 method、URL、Header 和 body,因而认证接口可以一次取得完整签名输入。 PreparedRequestBody 保存准备后的一对 Header/body;两者必须一起使用,因为 JSON 编码会补 Content-Type,压缩还会影响 Content-Encoding。

源码文件:codex-rs/http-client/src/request.rs

相关函数/类型:Request::prepare_body_for_send

rust
pub fn prepare_body_for_send(&self) -> Result<PreparedRequestBody, String> {
    let headers = self.headers.clone();
    match self.body.as_ref() {
        Some(RequestBody::Raw(raw_body)) => {
            // Raw 已代表最终字节;再请求压缩会破坏这个契约。
            if self.compression != RequestCompression::None {
                return Err("request compression cannot be used with raw bodies".to_string());
            }
            Ok(PreparedRequestBody {
                headers,
                body: Some(raw_body.clone()),
            })
        }
        Some(RequestBody::Json(body)) => {
            let body = EncodedJsonBody::encode(body).map_err(|err| err.to_string())?;
            self.prepare_encoded_json(headers, &body)
        }
        Some(RequestBody::EncodedJson(body)) => self.prepare_encoded_json(headers, body),
        None => Ok(PreparedRequestBody {
            headers,
            body: None,
        }),
    }
}

Raw 分支复用 Bytes,并拒绝“Raw 加二次压缩”;JSON 分支先序列化,EncodedJson 分支复用编码结果。 无 body 时,传给签名器的 body_bytes() 返回空字节串,对应空 payload 的摘要;它不会凭空补一个 {} 或 null。

编码后的处理继续区分已经准备过的字节与还需压缩的 JSON。

源码文件:codex-rs/http-client/src/request.rs

相关函数/类型:Request::prepare_encoded_json

rust
fn prepare_encoded_json(
    &self,
    mut headers: HeaderMap,
    body: &EncodedJsonBody,
) -> Result<PreparedRequestBody, String> {
    // 已准备的 EncodedJson 直接复用引用计数的字节。
    if body.prepared {
        return Ok(PreparedRequestBody {
            headers,
            body: Some(body.bytes.clone()),
        });
    }

    let bytes = if self.compression != RequestCompression::None {
        // 不能让已有的 content-encoding 与第二次压缩发生歧义。
        if headers.contains_key(http::header::CONTENT_ENCODING) {
            return Err(
                "request compression was requested but content-encoding is already set"
                    .to_string(),
            );
        }

        let pre_compression_bytes = body.bytes.len();
        let compression_start = std::time::Instant::now();
        let (compressed, content_encoding) = match self.compression {
            RequestCompression::None => unreachable!("guarded by compression != None"),
            RequestCompression::Zstd => (
                zstd::stream::encode_all(std::io::Cursor::new(body.as_bytes()), 3)
                    .map_err(|err| err.to_string())?,
                HeaderValue::from_static("zstd"),
            ),
        };
        let post_compression_bytes = compressed.len();
        let compression_duration = compression_start.elapsed();

        headers.insert(http::header::CONTENT_ENCODING, content_encoding);

        tracing::debug!(
            pre_compression_bytes,
            post_compression_bytes,
            compression_duration_ms = compression_duration.as_millis(),
            "Compressed request body with zstd"
        );

        Bytes::from(compressed)
    } else {
        body.bytes.clone()
    };

    if !headers.contains_key(http::header::CONTENT_TYPE) {
        headers.insert(
            http::header::CONTENT_TYPE,
            HeaderValue::from_static("application/json"),
        );
    }

    Ok(PreparedRequestBody {
        headers,
        body: Some(bytes),
    })
}

prepared 标记让后续调用直接共享同一份字节。若请求确实需要 Zstd,压缩级别为 3, Content-Encoding: zstd 与压缩结果同时进入返回值;已有 Content-Encoding 时再次要求压缩则失败。 没有指定 Content-Type 时才补 application/json,调用方已有的值不会被无条件覆盖。

这段通用能力不意味着 Bedrock 的正常 Core 请求会压缩。Core 的选择条件如下:

源码文件:codex-rs/core/src/client.rs

相关函数/类型:ModelClientSession::responses_request_compression

rust
fn responses_request_compression(&self, auth: Option<&CodexAuth>) -> Compression {
    if self.client.state.enable_request_compression
        && auth.is_some_and(CodexAuth::uses_codex_backend)
        // Bedrock 不满足该条件;通用传输支持 Zstd 不等于本路径默认启用它。
        && self.client.state.provider.info().is_openai()
    {
        Compression::Zstd
    } else {
        Compression::None
    }
}

Bedrock 不满足 is_openai() 条件,因此这里返回 Compression::None。讨论 Zstd 是为了理解共享 HTTP 层的字节契约,以及其他调用方显式使用压缩时为何仍须先准备、后签名;不能据此推断 Bedrock 服务承诺接受任意压缩请求。

4.3 签名后的所有权 ​

SigV4 实现重写的是整个 apply_auth(Request),不是只往一张 HeaderMap 填值。 下面同时列出它的字段、关键方法和共享认证接口实现。

源码文件:codex-rs/model-provider/src/amazon_bedrock/auth.rs

相关函数/类型:BedrockSigV4AuthProvider / apply_auth

rust
struct BedrockSigV4AuthProvider {
    context: AwsAuthContext,
    endpoint: BedrockEndpoint,
}

// ...

async fn apply_auth(&self, request: Request) -> std::result::Result<Request, AuthError> {
    let mut request = request;
    if self.endpoint == BedrockEndpoint::Mantle {
        remove_headers_not_preserved_by_bedrock_mantle(&mut request.headers);
    }
    // 即将送入签名器的是最终 Header 和 body bytes。
    let prepared = request.prepare_body_for_send().map_err(AuthError::Build)?;
    let signed = self
        .context
        .sign(AwsRequestToSign {
            method: request.method.clone(),
            url: request.url.clone(),
            headers: prepared.headers.clone(),
            body: prepared.body_bytes(),
        })
        .await
        .map_err(aws_auth_error_to_auth_error)?;

    request.url = signed.url;
    request.headers = signed.headers;
    // 签名后回写同一份字节;传输层只能消费这个返回的 Request。
    request.body = prepared.body.map(RequestBody::Raw);
    request.compression = RequestCompression::None;
    Ok(request)
}

// ...

impl AuthProvider for BedrockSigV4AuthProvider {
    fn add_auth_headers(&self, _headers: &mut HeaderMap) {}

    fn apply_auth(&self, request: Request) -> codex_api::AuthProviderFuture<'_> {
        Box::pin(BedrockSigV4AuthProvider::apply_auth(self, request))
    }
}

方法消耗传入的 Request,并将以下结果一起返回:签名后的 URL、保留并补充的 Header、实际参与 摘要计算的 Raw body,以及 compression = None。这四个写回动作共同保证传输层消费的是刚才签名 的内容;只复制 Authorization 而继续发送旧请求,会丢失这个保证。

add_auth_headers 在这里故意为空。因此通过 to_auth_headers() 或仅依赖此方法的遥测看不到 Authorization,并不能证明 SigV4 请求没有认证。只有完整请求形成后,签名器才知道要认证什么。 这也是不能把仅有 Header 的连接路径直接当作 SigV4 支持路径的原因。

4.4 每次尝试的发送 ​

下面的时序从请求客户端开始,区分循环外的 body 准备和循环内的认证,只画认证成功后进入发送的主链。 图里的凭据 provider 是 SDK 接口,既可以返回静态值,也可以执行自己的异步取凭据流程。

关键分界在 apply_auth:成功才把返回值交给 transport,失败则由统一 retry 策略判断。 重试时 body 可共享,但凭据与签名仍重新计算。若两次使用的时间和全部输入相同,签名字节也可以相同; “重新签名”指重新执行计算,不意味着每次 Authorization 必然不同。

源码文件:codex-rs/codex-api/src/endpoint/session.rs

相关函数/类型:EndpointSession::stream_encoded_json_with

rust
pub(crate) async fn stream_encoded_json_with<C>(
    &self,
    method: Method,
    path: &str,
    extra_headers: HeaderMap,
    body: Option<EncodedJsonBody>,
    configure: C,
) -> Result<StreamResponse, ApiError>
where
    C: Fn(&mut Request),
{
    let body = body.map(RequestBody::EncodedJson);
    let mut request = self.make_request(&method, path, &extra_headers, body.as_ref());
    configure(&mut request);
    // 编码/压缩在请求循环前固定,克隆 Request 共享 body 分配。
    let request = request.into_prepared().map_err(TransportError::Build)?;
    let make_request = || request.clone();

    let stream = run_with_request_telemetry(
        self.provider.retry.to_policy(),
        self.request_telemetry.clone(),
        make_request,
        |req| {
            let auth = self.auth.clone();
            let transport = &self.transport;
            async move {
                // 认证放在每次尝试内部;失败时通过问号提前退出,不调用 transport。
                let req = auth.apply_auth(req).await.map_err(TransportError::from)?;
                transport.stream(req).await
            }
        },
    )
    .await?;

    Ok(stream)
}

into_prepared 在进入 run_with_request_telemetry 前执行;make_request 每次克隆的是尚未签名的 基准请求。认证只修改当次拥有的 Request,所以第一次计算出的认证头不会回流污染下次克隆源。 Bytes 的引用计数共享减少重复编码与复制,按值转移 Request 则使当次 Header 的修改保持局部。

ReqwestTransport 的消费端可以用来反向检查这项约定:

源码文件:codex-rs/http-client/src/transport.rs

相关函数/类型:ReqwestTransport::build

rust
fn build(&self, req: Request) -> Result<RequestBuilder, TransportError> {
    // 收到签名器返回的 Raw 请求后,这里只取出相同字节。
    let prepared = req.prepare_body_for_send().map_err(TransportError::Build)?;

    let Request {
        method,
        url,
        headers: _,
        body: _,
        compression: _,
        timeout,
    } = req;

    let mut builder = self.client.request(
        Method::from_bytes(method.as_str().as_bytes()).unwrap_or(Method::GET),
        &url,
    );

    if let Some(timeout) = timeout {
        builder = builder.timeout(timeout);
    }

    builder = builder.headers(prepared.headers);
    if let Some(body) = prepared.body {
        builder = builder.body(body);
    }
    Ok(builder)
}

到这里 body 已是 Raw、compression 已关闭,因此再次 prepare_body_for_send 只取回相同字节。 随后 builder.headers 与 builder.body 使用同一份 prepared 结果。检查自定义认证实现时,可以照此 追踪“认证返回的请求 → transport 收到的请求 → builder 实际使用的字节”,不能只停在签名函数返回成功。

5. SigV4的计算 ​

5.1 签名器的输入 ​

现在可以把上层调用收敛到四个 HTTP 输入:method、URL、Header 集合和 body bytes;另外还有 凭据、region、service 和签名时间。前四项由请求提供,后三项中的区域与服务由上下文持有,时间在每次 sign 时捕获。Credentials 同时携带 access key ID、secret 和可选 session token。

源码文件:codex-rs/aws-auth/src/lib.rs

相关函数/类型:AwsRequestToSign / AwsSignedRequest

rust
pub struct AwsRequestToSign {
    pub method: Method,
    pub url: String,
    pub headers: HeaderMap,
    // 输入持有最终字节;输出只返回 URL/Header,body 由调用方保持。
    pub body: Bytes,
}

// ...

pub struct AwsSignedRequest {
    pub url: String,
    pub headers: HeaderMap,
}

AwsRequestToSign 有 body,AwsSignedRequest 没有 body。原因是签名不会改变 payload;它返回 需要更新的 URL 和 Header,调用方继续持有前面准备好的 bytes。这里的 headers 是保留原头并应用签名 指令后的集合,不能理解成“只包含新增认证头的补丁”。

下面是 Codex 对 AWS 签名库的完整适配函数。

源码文件:codex-rs/aws-auth/src/signing.rs

相关函数/类型:sign_request

rust
pub(crate) fn sign_request(
    credentials: &Credentials,
    region: &str,
    service: &str,
    request: AwsRequestToSign,
    time: SystemTime,
) -> Result<AwsSignedRequest, AwsAuthError> {
    let signable_headers = request
        .headers
        .iter()
        .map(|(name, value)| {
            Ok::<_, AwsAuthError>((
                name.as_str(),
                value.to_str().map_err(AwsAuthError::InvalidHeaderValue)?,
            ))
        })
        .collect::<Result<Vec<_>, _>>()?;
    let signable_request = SignableRequest::new(
        request.method.as_str(),
        request.url.as_str(),
        signable_headers.into_iter(),
        // 真实 payload bytes 进入摘要;这里没有选择 UNSIGNED-PAYLOAD。
        SignableBody::Bytes(request.body.as_ref()),
    )
    .map_err(AwsAuthError::SigningRequest)?;
    // Credentials 包含可选 session token,SDK 会据此生成临时凭据头。
    let identity = credentials.clone().into();

    let signing_params = v4::SigningParams::builder()
        .identity(&identity)
        .region(region)
        .name(service)
        .time(time)
        .settings(SigningSettings::default())
        .build()
        .map_err(|err| AwsAuthError::SigningParams(err.to_string()))?;
    let (instructions, _signature) = sign(signable_request, &signing_params.into())
        .map_err(AwsAuthError::SigningFailure)?
        .into_parts();

    let uri = Uri::from_str(&request.url).map_err(AwsAuthError::InvalidUri)?;
    let mut http_request = Request::builder()
        .method(request.method)
        .uri(uri)
        .body(())
        .map_err(AwsAuthError::BuildHttpRequest)?;
    // 先保留原头,再应用签名指令;返回值不只有新增的认证头。
    *http_request.headers_mut() = request.headers;
    instructions.apply_to_request_http1x(&mut http_request);

    Ok(AwsSignedRequest {
        url: http_request.uri().to_string(),
        headers: http_request.headers().clone(),
    })
}

这个函数中有四个需要跟着代码确认的选择:

  1. Header 值先用 to_str() 转成可签名字符串。存在非 UTF-8 Header 时,会在签名请求构造之前失败。
  2. SignableBody::Bytes 把真实 payload 送入摘要计算;没有使用“无签名 payload”模式。
  3. .region(region) 和 .name(service) 来自上下文,不从 URL 猜测;name 在这里就是 SigV4 service。
  4. 返回的 SigningInstructions 被应用到保留了原 Header 的 HTTP 请求上。代码忽略单独返回的 _signature 字符串,因为真正应发送的认证 Header 已在 instructions 中。

Request::builder().body(()) 也容易误读。这个临时 HTTP 对象仅用于应用 URL/Header 指令;payload 已经通过 SignableBody::Bytes 参与签名。它不是把待发送请求体改成了空值。

5.2 规范请求 ​

Canonical Request(规范请求) 是签名双方对请求的一种确定性文本表示。 Codex 把规范化和 HMAC 实现交给锁定的 aws-sigv4 1.3.7。下面的图对应该依赖中的 CanonicalRequest、StringToSign、generate_signing_key 和 calculate_signature,展示各项输入 在哪一层发生作用。

图中 request body 影响规范请求摘要,region/service 同时影响 scope 与派生密钥;这两条作用路径 不同。即便请求 URL 和 body 完全相同,仅改变 service 也会改变最终签名。

依赖库的 canonical_request.rs 负责规范化, settings.rs 定义 Codex 所采用的默认参数。对本文路径,最重要的默认值如下:

设置默认行为对排查的意义
signature_locationHeadersAuthorization 放在 Header 中,不是生成预签名 URL
payload_checksum_kindNoHeaderpayload 仍参与摘要,只是不额外生成 x-amz-content-sha256 头
percent_encoding_modeDouble已编码路径中的 % 会继续参与规范编码,不能随意解码后重签
uri_path_normalization_modeEnabled签名使用 SDK 的路径规范化规则
session_token_modeInclude临时凭据 token 被加入请求头和规范签名输入
excluded_headers排除 Authorization、User-Agent、X-Ray trace、Transfer-Encoding不是每一个实际发送的 Header 都进入 SignedHeaders

输入没有 Host Header 时,SDK 会从 URI 补出规范化用的 host,并加入 x-amz-date;有 session token 时加入 x-amz-security-token。Header 名转为小写,值按规则规范化,参与签名的名字排序后组成 SignedHeaders。host 出现在 SignedHeaders 中,不要求 Codex 先手工在输入 HeaderMap 里插入它。

一个没有查询串的规范请求具有以下结构:

text
HTTPMethod
CanonicalURI
CanonicalQueryString
CanonicalHeaders

SignedHeaders
Hex(SHA256(payload_bytes))

这里的空行不是装饰。CanonicalHeaders 自己以换行结束,随后还有字段分隔;手工复算时少一个换行 就会得到完全不同的摘要。查询参数也有排序与编码规则,下文的离线算例特意选择无查询串请求,避免把 一个教学脚本误用成支持所有 URL 的通用签名器。

5.3 密钥派生 ​

String to Sign(待签名字符串) 将规范请求摘要与算法、时间和 credential scope 关联起来。 scope 采用日期、region、service、aws4_request 四段。secret access key 不直接出现在请求 Header 中。

text
StringToSign = AWS4-HMAC-SHA256
               + 换行 + 签名时间
               + 换行 + 日期/region/service/aws4_request
               + 换行 + Hex(SHA256(CanonicalRequest))

kDate    = HMAC-SHA256("AWS4" + secret, 日期)
kRegion  = HMAC-SHA256(kDate, region)
kService = HMAC-SHA256(kRegion, service)
kSigning = HMAC-SHA256(kService, "aws4_request")
Signature = Hex(HMAC-SHA256(kSigning, StringToSign))

这对应依赖库 sign/v4.rs 中的 generate_signing_key 和 calculate_signature。HMAC 两个参数的位置不能交换;中间结果 是二进制密钥,不能先把它转成十六进制文本,再当成下一步的 key。

因此,签名错误至少有几种性质不同的原因:body 或 Header 变化会改变规范请求;region/service 错误会改变 scope 和派生密钥;时间错误会改变日期相关输入并触发远端时效检查;临时 token 遗漏则 同时影响临时身份和规范 Header。它们都可能表现为认证失败,但修复位置不同。

5.4 临时token的传播 ​

sign_request 将整个 Credentials 转成 SDK identity,未单独拼装 session token。SDK 根据 session_token_mode 生成相应 Header。以下真实测试用固定时间和公开测试字符串验证这条传播路径。

源码文件:codex-rs/aws-auth/src/lib.rs

相关函数/类型:sign_includes_session_token_when_credentials_have_one

rust
async fn sign_includes_session_token_when_credentials_have_one() {
    // 此处使用公开测试字符串,用来验证临时凭据头的传播。
    let signed = test_context(Some("session-token"))
        .sign_at(
            test_request(),
            UNIX_EPOCH + Duration::from_secs(1_700_000_000),
        )
        .await
        .expect("request should sign");

    assert_eq!(
        signing::header_value(&signed.headers, "x-amz-security-token"),
        Some("session-token".to_string())
    );
}

测试断言的是返回 Header 中确实有 x-amz-security-token,不是仅检查输入结构体有这个字段。 它能够发现 token 在适配层丢失的问题,但不能证明示例字符串是有效的 AWS token,或持有该 token 的 身份具备调用某个模型的权限。前者是客户端数据传播问题,后者属于远端身份与授权验证。

6. 失败后的分流 ​

6.1 本地认证错误 ​

请求失败后,不要马上套用同一个“刷新后重试”。首先区分签名前的配置错误、取凭据时的临时故障、 签名构造失败和收到 AWS HTTP 响应后的拒绝。AwsAuthError 明确列出了这些本地错误。

源码文件:codex-rs/aws-auth/src/lib.rs

相关函数/类型:AwsAuthError / AwsAuthError::is_retryable

rust
pub enum AwsAuthError {
    #[error("AWS service name must not be empty")]
    EmptyService,
    #[error("AWS profile must be configured")]
    MissingProfile,
    #[error("AWS SDK config did not resolve a credentials provider")]
    MissingCredentialsProvider,
    #[error("AWS SDK config did not resolve a region")]
    MissingRegion,
    #[error("failed to load AWS profiles: {0}")]
    ProfileLoad(#[from] aws_config::profile::ProfileFileLoadError),
    #[error("failed to load AWS credentials: {0}")]
    Credentials(#[from] aws_credential_types::provider::error::CredentialsError),
    #[error("request URL is not a valid URI: {0}")]
    InvalidUri(#[source] http::uri::InvalidUri),
    #[error("failed to construct HTTP request for signing: {0}")]
    BuildHttpRequest(#[source] http::Error),
    #[error("request contains a non-UTF8 header value: {0}")]
    InvalidHeaderValue(#[source] http::header::ToStrError),
    #[error("failed to build signable request: {0}")]
    SigningRequest(#[source] aws_sigv4::http_request::SigningError),
    #[error("failed to build SigV4 signing params: {0}")]
    SigningParams(String),
    #[error("SigV4 signing failed: {0}")]
    SigningFailure(#[source] aws_sigv4::http_request::SigningError),
}

// ...

pub fn is_retryable(&self) -> bool {
    match self {
        // 只把两个可恢复的取凭据故障视为 transient;配置和签名构造错误不重试。
        AwsAuthError::Credentials(error) => matches!(
            error,
            aws_credential_types::provider::error::CredentialsError::ProviderTimedOut(_)
                | aws_credential_types::provider::error::CredentialsError::ProviderError(_)
        ),
        AwsAuthError::EmptyService
        | AwsAuthError::MissingProfile
        | AwsAuthError::MissingCredentialsProvider
        | AwsAuthError::MissingRegion
        | AwsAuthError::ProfileLoad(_)
        | AwsAuthError::InvalidUri(_)
        | AwsAuthError::BuildHttpRequest(_)
        | AwsAuthError::InvalidHeaderValue(_)
        | AwsAuthError::SigningRequest(_)
        | AwsAuthError::SigningParams(_)
        | AwsAuthError::SigningFailure(_) => false,
    }
}

is_retryable 只允许 CredentialsError::ProviderTimedOut 和 ProviderError 进入普通重试。 没有来源、无效配置、未知凭据错误,以及 URI/Header/签名参数构造错误,都不会仅靠反复计算同一个请求 自动恢复。注意错误类型能够表达的范围也包含 profile 发现等辅助接口;它不表示每个变体都会从同一条 Responses 主链产生。

错误进入哪个阶段,也影响它向上层的投影。

源码文件:codex-rs/model-provider/src/amazon_bedrock/auth.rs

相关函数/类型:aws_auth_error_to_codex_error / aws_auth_error_to_auth_error

rust
fn aws_auth_error_to_codex_error(error: AwsAuthError) -> CodexErr {
    CodexErr::Fatal(format!("failed to resolve Amazon Bedrock auth: {error}"))
}

// ...

fn aws_auth_error_to_auth_error(error: AwsAuthError) -> AuthError {
    // 请求准备阶段的错误要进入统一 HTTP retry 分类。
    if error.is_retryable() {
        AuthError::Transient(error.to_string())
    } else {
        AuthError::Build(error.to_string())
    }
}

上下文建立失败经 aws_auth_error_to_codex_error 返回 CodexErr::Fatal。 每次请求认证时发生的错误则映射为 AuthError::Transient 或 AuthError::Build; codex-api/src/auth.rs 进一步把它们映射为 TransportError::Network 或 TransportError::Build。 这样共享 HTTP retry 层无需依赖 AWS 类型,也能决定是否再尝试一次。

发生位置例子当前请求行为
client setup / region 解析没有 region、不支持的 Mantle regionsetup 返回错误,还没有进入 EndpointSession 发送循环
每次签名前取凭据provider 超时、暂时不可用Transient → Network;在重试策略允许时再次认证
每次签名前构造非 UTF-8 Header、无效签名参数Build;不调用 transport,也不普通重试
HTTP 已返回401、某些带错误码的 403由 Provider 判断是否进入外层认证恢复
流已经建立后SSE 中途断开不属于此处建立流之前的认证恢复分支

最后一行需要特别保留:stream_responses_api 的认证恢复发生在 stream_request(...).await 返回 ApiError::Transport 时。它不是在整个 SSE 生命周期里捕获所有错误的万能恢复器。 通用流重试和采样重建由Responses重试策略进一步解释。

6.2 AWS拒绝的分类 ​

Provider 的恢复判断不仅看“是不是 401”。配置了合适的 AWS 重新登录能力时,还能识别部分 403 和 带特定前缀的本地取凭据错误。

源码文件:codex-rs/model-provider/src/amazon_bedrock/error.rs

相关函数/类型:is_refreshable_auth_error

rust
pub(super) fn is_refreshable_auth_error(error: &TransportError) -> bool {
    match error {
        TransportError::Build(message) | TransportError::Network(message) => {
            message.starts_with("failed to load AWS credentials:")
        }
        TransportError::Http { status, .. } if *status == StatusCode::UNAUTHORIZED => true,
        TransportError::Http {
            status,
            body: Some(body),
            ..
        } if *status == StatusCode::FORBIDDEN => {
            // 403 只识别下面三类凭据错误;一般 AccessDenied 不走重新登录。
            let body = body.to_ascii_lowercase();
            [
                "expiredtoken",
                "unrecognizedclientexception",
                "invalidclienttokenid",
            ]
            .iter()
            .any(|error_code| body.contains(error_code))
        }
        _ => false,
    }
}

403 中只匹配 ExpiredToken、UnrecognizedClientException、InvalidClientTokenId 三类文本。 一般 AccessDeniedException 不属于这个集合;更换一轮凭据不能被当作修复所有授权拒绝的方法。 401 则被归为可进入认证处理的响应,但能否实际运行 AWS 登录命令,还取决于后文的来源与配置条件。

这里还有两个容易混为一谈的动作:is_refreshable_auth_error 决定恢复资格,map_api_error 决定 显示给用户的错误内容。后者只有在 HTTP 401 且 body 包含大小写匹配的 Signature expired: 时, 才给出签名过期、更新凭据或检查 AWS_BEARER_TOKEN_BEDROCK 的专门提示。 相同文本配上 403、普通无效 token 的 401,都保留通用错误形式;提示文字本身不会执行刷新。

6.3 Core的恢复边界 ​

Core 在 stream_responses_api 的外层循环初始化 provider_auth_recovery_attempted = false。 一次请求建立流失败并被 Provider 判为可恢复后,会进入 handle_unauthorized。以下片段是该函数中 优先处理 Provider 自有恢复的分支。

源码文件:codex-rs/core/src/client.rs

相关函数/类型:handle_unauthorized

rust
if !*provider_auth_recovery_attempted {
    // 先消费一次资格,再等待登录,防止相同循环无限刷新。
    *provider_auth_recovery_attempted = true;
    match provider.recover_from_unauthorized().await {
        Ok(ProviderUnauthorizedRecovery::Recovered) => {
            return Ok(UnauthorizedRecoveryExecution {
                mode: "provider",
                phase: "provider_refresh",
            });
        }
        Ok(ProviderUnauthorizedRecovery::NotConfigured) => {}
        Err(error) => {
            let original = provider.map_api_error(ApiError::Transport(transport));
            warn!(
                error = %error,
                original_error = %original,
                "provider authentication recovery failed"
            );
            return Err(if error.is_retryable() {
                original
            } else {
                error
            });
        }
    }
}

标志在 await 前就设为 true,因此这个外层调用最多消费一次 Provider 恢复资格。 Recovered 只表示恢复动作成功结束,随后还要重新构造并发送模型请求;它不是 AWS 已接受新请求的 证明。NotConfigured 会继续检查 AuthManager 的恢复路径,若没有可用步骤则返回映射后的原始错误。

恢复分支中的错误也有区别:非可重试的恢复错误直接返回;可重试的恢复错误保留原始认证失败给上层。 这可以避免用临时登录失败完全遮住最初的请求上下文,但不会在这里无限循环启动登录程序。

下一轮循环重新运行的是下面的 setup。

源码文件:codex-rs/core/src/client.rs

相关函数/类型:ModelClient::current_client_setup

rust
async fn current_client_setup(&self) -> Result<CurrentClientSetup> {
    let auth = self.state.provider.auth().await;
    // 恢复后的外层循环再次执行 setup,重新建立区域端点与认证对象。
    let api_provider = self.state.provider.api_provider().await?;
    let resolved_auth = self
        .state
        .provider
        .api_auth_for_scope(ProviderAuthScope {
            agent_identity_policy: self.agent_identity_policy,
            session_source: self.state.session_source.clone(),
            agent_identity_session_fallback: self.state.agent_identity_session_fallback.clone(),
        })
        .await?;
    Ok(CurrentClientSetup {
        auth,
        api_provider,
        api_auth: resolved_auth.auth,
        agent_identity_telemetry: resolved_auth.agent_identity_telemetry,
    })
}

这里重新取得 API 参数和 scoped auth。Bedrock 不走 OpenAI 的 first-party auth 分支, api_auth_for_scope 会委托给 Bedrock 的 api_auth,从而重新建立签名上下文。 所以外部 AWS 登录命令更新 profile 或其缓存后,后续 setup/取凭据可以观察到变化。 成功刷新并不会直接修改上一轮 Request 的 Authorization,再把旧请求原封不动补发。

7. 并发重新登录 ​

7.1 恢复资格 ​

假设多个会话同时发现同一个 AWS profile 过期,如果每个会话各启动一次交互登录,会重复打开登录流程, 也会相互竞争凭据文件。Codex 为同一 AWS 配置共享一个恢复对象,不过先有一层来源限制。

源码文件:codex-rs/model-provider/src/amazon_bedrock/mod.rs

相关函数/类型:AmazonBedrockModelProvider::new / uses_aws_auth_recovery

rust
let uses_aws_sdk_auth = matches!(
    auth::auth_source(&provider_info, auth_manager.as_deref(), std::env::var),
    auth::BedrockAuthSource::ConfiguredAwsProfile | auth::BedrockAuthSource::AwsSdk
);
let auth_recovery = if uses_aws_sdk_auth && aws.auth_refresh.is_some() {
    process_shared_state().aws_auth_recovery(&aws)
} else {
    None
};

// ...

fn uses_aws_auth_recovery(&self) -> bool {
    // 已有共享对象仍不足够:当前认证来源也必须允许恢复。
    self.auth_recovery.is_some()
        && matches!(
            self.auth_source(),
            auth::BedrockAuthSource::ConfiguredAwsProfile | auth::BedrockAuthSource::AwsSdk
        )
}

只有 ConfiguredAwsProfile 或 AwsSdk 来源,并且配置了 auth_refresh,构造时才建立共享恢复 对象。运行时还会再次检查当前来源。环境 Bearer、成对环境 access keys、Codex 托管 access keys 和 命令 Bearer 都不自动使用这条 AWS 重新登录流程。

原因可以从数据来源反推:外部 aws sso login 等命令能够更新 profile 或 SDK 将读取的凭据材料, 却不能直接改变当前进程已经继承的环境变量,也不能替换签名器中被复制成静态 provider 的托管值。 这是对代码边界的解释;某一种 AWS 登录方式是否支持自动续期,仍取决于其实际 SDK/provider 实现。

7.2 共享键与生命周期 ​

进程级的 OnceLock<ModelProviderSharedState> 提供索引;索引中的每一项使用完整 AWS 配置作为键, 只保存恢复对象的 Weak 引用。

源码文件:codex-rs/model-provider/src/shared_state.rs

相关函数/类型:ModelProviderSharedState::aws_auth_recovery

rust
pub(crate) struct ModelProviderSharedState {
    aws_auth_recoveries: Mutex<Vec<(ModelProviderAwsAuthInfo, Weak<AwsAuthRecovery>)>>,
}

// ...

pub(crate) fn aws_auth_recovery(
    &self,
    aws: &ModelProviderAwsAuthInfo,
) -> Option<Arc<AwsAuthRecovery>> {
    let config = aws.auth_refresh.as_ref()?;
    let mut recoveries = self
        .aws_auth_recoveries
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    // 全局索引只存 Weak,清除已无人持有的恢复对象。
    recoveries.retain(|(_, recovery)| recovery.strong_count() != 0);

    if let Some(recovery) = recoveries
        .iter()
        // 完整 AWS 配置参与相等比较,包含 profile、region 和刷新命令参数。
        .find(|(cached_aws, _)| cached_aws == aws)
        .and_then(|(_, recovery)| recovery.upgrade())
    {
        return Some(recovery);
    }

    let recovery = Arc::new(AwsAuthRecovery::new(config.clone()));
    recoveries.push((aws.clone(), Arc::downgrade(&recovery)));
    Some(recovery)
}

键包含 profile、region 和 auth_refresh 的 command、args、timeout。相同 profile 但不同 region 或 登录参数不会被强行合并;endpoint 类别不在这个键中,同样的 AWS 配置可被 Mantle/Runtime Provider 共享。查到活对象时升级 Weak,查不到时创建新对象。

Mutex 只保护索引的清理、查找和创建,持锁期间没有异步登录。真正的恢复对象由使用它的 Provider 通过 Arc 持有;最后一个强引用释放后,下一次索引访问会清理失效 Weak。因此进程级索引不意味着 每个历史登录配置都永久拥有一个活跃恢复任务。

7.3 合并等待者 ​

恢复对象内部同时使用 Semaphore 和 generation。前者使同一配置一次只执行一条登录命令, 后者记录已经成功完成了多少轮刷新,用来判断排队期间是否已经有人完成了当前需要的工作。

下图选择两个重叠调用:A、B 都在成功代数 g 时进入;A 先拿到许可,B 等待。

只用锁会让 B 等 A 完成后再重复登录。只用一个“已经刷新过”的永久布尔值,则会阻止将来真正需要的 新一轮登录。成功代数让代码只合并这一次重叠等待,稍后的新调用读到新代数,仍可执行下一轮恢复。

源码文件:codex-rs/model-provider/src/amazon_bedrock/auth_refresh.rs

相关函数/类型:AwsAuthRecovery / AwsAuthRecovery::refresh

rust
pub(crate) struct AwsAuthRecovery {
    config: AwsAuthRefreshConfig,
    refresh_lock: Semaphore,
    generation: AtomicU64,
}

// ...

pub(crate) async fn refresh(&self) -> std::io::Result<()> {
    if self.config.command != "aws" {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "AWS auth refresh command must be `aws`",
        ));
    }

    // 在等待锁之前记录成功代数,才能发现排队期间别人已完成刷新。
    let generation = self.generation.load(Ordering::Acquire);
    let _refresh_lock = self
        .refresh_lock
        .acquire()
        .await
        .map_err(std::io::Error::other)?;
    if self.generation.load(Ordering::Acquire) != generation {
        return Ok(());
    }

    let mut command = Command::new(&self.config.command);
    command
        .args(self.config.args.iter().map(Deref::deref))
        .stdin(if std::io::stdin().is_terminal() {
            Stdio::inherit()
        } else {
            Stdio::null()
        })
        // Keep interactive prompts visible without corrupting app-server's JSON-RPC stdout.
        .stdout(std::io::stderr())
        .stderr(Stdio::inherit())
        .kill_on_drop(true);

    // 超时覆盖命令执行,不包含前面的信号量等待。
    let status = tokio::time::timeout(self.config.timeout(), command.status())
        .await
        .map_err(|_| {
            std::io::Error::other(format!(
                "AWS auth refresh command timed out after {} ms",
                self.config.timeout_ms
            ))
        })?
        .map_err(|error| {
            std::io::Error::other(format!(
                "failed to run AWS auth refresh command `{}`: {error}",
                self.config.command
            ))
        })?;

    if status.success() {
        // 只有成功才推进代数;失败的排队调用者不会被误报为已恢复。
        self.generation.fetch_add(1, Ordering::Release);
        Ok(())
    } else {
        Err(std::io::Error::other(format!(
            "AWS auth refresh command exited with {status}"
        )))
    }
}

generation 在获取许可前读取,在获取后再比较;Release 的递增与 Acquire 的读取用于发布和 观察成功代数。代码只有在子进程成功时才递增,失败不会让等待者错误地认为“别人已经修好了”。 它合并的是进入时观察到旧代数、之后等待过许可的调用,并不保证所有在相近时间遇到旧凭据的请求都 永远只启动一次命令;在成功之后才进入的新调用已经属于下一轮。

子进程使用 Command::new 直接传 argv,没有经过 shell。参数中的引号、管道和重定向字符不会自动 获得 shell 语义。stdin 只有在终端场景下继承,stdout 转发到 stderr,以免交互输出破坏 App Server 的 JSON-RPC stdout;这些 I/O 选择影响的是登录如何与用户交互,并不改变模型请求协议。

7.4 超时与取消 ​

timeout 包裹 command.status(),不包裹前面的 Semaphore::acquire()。 因此配置的 300000 毫秒默认值不是“从调用 refresh 到返回”的绝对总期限:调用还可能先等待正在执行 的其他刷新。成功或错误返回都会释放 permit,因为 guard 在作用域结束时被丢弃。

路径generationpermit / 子进程
登录成功加一返回成功后释放许可,等待者检查新代数
等待期间别人已成功不再增加获取许可后直接返回,不启动新子进程
启动失败、非零退出不变返回 I/O 错误并释放许可
命令执行超时不变timeout 丢弃等待 future;kill_on_drop(true) 管理已启动子进程
等待许可时被取消不变尚未取得许可,也尚未启动本次子进程
持有许可时被取消不变或此前已完成成功分支future/guard 被丢弃;已启动子进程受 kill-on-drop 约束

这里能确认的是 Tokio 管理的直接子进程生命周期;代码没有在这个函数里建立完整进程树回收协议。 跨平台行为还受终端可用性、AWS CLI 安装方式、profile 与登录插件影响,不能由一次本地 stub 命令测试 推断所有系统上的交互登录都相同。

8. 从断言定位问题 ​

8.1 签名与传输断言 ​

先看 aws-auth 的固定输入测试。test_context 使用 AWS 公共示例 access keys,test_request 提供 POST、JSON body、Content-Type 和一个自定义头;时间固定为 Unix 秒 1700000000。

源码文件:codex-rs/aws-auth/src/lib.rs

相关函数/类型:sign_adds_sigv4_headers_and_preserves_existing_headers

rust
async fn sign_adds_sigv4_headers_and_preserves_existing_headers() {
    let signed = test_context(/*session_token*/ None)
        .sign_at(
            test_request(),
            // 固定时间排除墙上时钟变化;测试不访问 AWS 服务。
            UNIX_EPOCH + Duration::from_secs(1_700_000_000),
        )
        .await
        .expect("request should sign");

    assert_eq!(
        signing::header_value(&signed.headers, http::header::CONTENT_TYPE.as_str()),
        Some("application/json".to_string())
    );
    assert_eq!(
        signing::header_value(&signed.headers, "x-test-header"),
        Some("present".to_string())
    );
    assert_eq!(
        signed.url,
        "https://bedrock-runtime.us-east-1.amazonaws.com/v1/responses"
    );
    assert!(
        signing::header_value(&signed.headers, http::header::AUTHORIZATION.as_str())
            .is_some_and(|value| value.starts_with("AWS4-HMAC-SHA256 "))
    );
    assert!(signing::header_value(&signed.headers, "x-amz-date").is_some());
}

断言覆盖原 Header 保留、URL 保留、AWS4 Authorization 前缀和时间头生成。它可以揭露 “只返回新增头而丢掉原头”这样的适配错误,但没有比较完整签名十六进制值,也没有向 AWS 验证该 请求是否可调用模型。临时 token 的 Header 传播则由前面单独列出的测试承担。

共享 API 客户端的错误注入测试更适合检查“认证失败时是否仍发送请求”。FailsOnceAuth::transient() 第一次返回临时认证错误,第二次正常;RecordingTransport 只记录真正进入传输层的请求。

源码文件:codex-rs/codex-api/tests/clients.rs

相关函数/类型:streaming_client_retries_on_transient_auth_error

rust
async fn streaming_client_retries_on_transient_auth_error() -> Result<()> {
    let state = RecordingState::default();
    let transport = RecordingTransport::new(state.clone());
    let auth = FailsOnceAuth::transient();

    let mut provider = provider("openai");
    provider.retry.max_attempts = 2;

    let client = ResponsesClient::new(transport, provider, Arc::new(auth.clone()));
    let body = serde_json::json!({ "model": "gpt-test" });
    let _stream = client
        .stream(
            body,
            HeaderMap::new(),
            Compression::None,
            /*turn_state*/ None,
        )
        .await?;

    // 认证调用两次,而真正进入 transport 的请求只有一次。
    assert_eq!(auth.attempts(), 2);
    assert_eq!(state.take_stream_requests().len(), 1);
    Ok(())
}

两个数必须一起读:认证调用两次,但传输只发生一次。第一次失败消耗了一次尝试机会,却没有发出 未经认证的请求。这个测试验证的是 AuthProvider 与 EndpointSession 的组合契约,替身并不执行真实 SigV4,所以它不能单独证明 AWS 签名算法正确。

与之对应,构造错误即使存在重试预算也不应发送请求。

源码文件:codex-rs/codex-api/tests/clients.rs

相关函数/类型:streaming_client_does_not_retry_auth_build_error

rust
async fn streaming_client_does_not_retry_auth_build_error() -> Result<()> {
    let state = RecordingState::default();
    let transport = RecordingTransport::new(state.clone());
    let auth = FailsOnceAuth::build();

    let mut provider = provider("openai");
    provider.retry.max_attempts = 2;

    let client = ResponsesClient::new(transport, provider, Arc::new(auth.clone()));
    let body = serde_json::json!({ "model": "gpt-test" });
    let result = client
        .stream(
            body,
            HeaderMap::new(),
            Compression::None,
            /*turn_state*/ None,
        )
        .await;
    let err = result
        .err()
        .expect("auth build errors should fail without retry");

    assert!(matches!(
        err,
        ApiError::Transport(TransportError::Build(message))
            if message == "invalid auth configuration"
    ));
    // 即使有重试预算,构造错误也不能触发第二次认证或发送。
    assert_eq!(auth.attempts(), 1);
    assert_eq!(state.take_stream_requests().len(), 0);
    Ok(())
}

断言保留了具体错误消息,并要求一次认证、零次传输。若实现把所有 AuthError 都变成 Network, 这个测试会发现本应立即失败的配置也被重试;若实现忽略认证错误继续发送,记录数也会暴露问题。

还有三组测试应当结合输入和断言阅读:

测试输入与关键断言覆盖边界
bedrock_auth_source_distinguishes_static_environment_credentials用闭包模拟存在的环境变量,与显式 profile、托管凭据组合;比较具体来源枚举验证选择优先级,不验证 profile 文件内容或真实 AWS 身份
bedrock_mantle_sigv4_strips_headers_not_preserved_by_mantle加入两个会话头、一个未来下划线头和 x-client-request-id;断言前三个移除、最后一个保留验证本地过滤规则,不复现远端网关
streaming_client_retries_on_transport_error第一次传输失败;断言重试请求的 body/Header/压缩标志相等,body 指针相同验证编码字节的共享,不能外推 Bedrock 默认启用压缩

并发恢复测试进一步把测试可执行文件安装成一个临时 aws 命令,以文件里的字符数记录启动次数。 下面的断言片段建立在两个相同配置的 Provider 上。

源码文件:codex-rs/model-provider/src/provider.rs

相关函数/类型:shared_bedrock_auth_refresh_is_reused_only_for_matching_configuration

rust
let (first_result, second_result) = tokio::join!(
    first.recover_from_unauthorized(),
    second.recover_from_unauthorized()
);
assert_eq!(
    [first_result, second_result].map(|result| result.expect("provider should recover")),
    [ProviderUnauthorizedRecovery::Recovered; 2]
);
let read_counter = || std::fs::read_to_string(&counter).expect("read counter");
// 并发调用共享一次外部命令;稍后的新调用允许再执行一次。
assert_eq!(read_counter(), "1");

assert_eq!(
    first
        .recover_from_unauthorized()
        .await
        .expect("later generation should recover"),
    ProviderUnauthorizedRecovery::Recovered
);
assert_eq!(read_counter(), "11");
std::fs::remove_file(&counter).expect("refresh invocation counter should be removed");

两个并发恢复都返回成功,但计数器只有一个 1;随后单独调用会得到 11,证明未来恢复没有被 永久禁止。该测试还验证不同 profile 不共享对象、释放全部强引用后 Weak 失效,以及拒绝非 aws 命令。它没有访问真实 SSO 服务,所以不证明远端登录一定成功。

8.2 固定签名算例 ​

下面用公开示例凭据,把签名的每一层展开成一个可以复算的数值。请求采用固定时间、固定路径,没有 查询参数,body 中的 example-model 只是计算用字符串,不发送到任何模型端点。

输入值
method / URIPOST /openai/v1/responses
hostbedrock-mantle.us-east-1.api.aws
region / serviceus-east-1 / bedrock-mantle
时间20231114T221320Z,对应 Unix 秒 1700000000
body{"model":"example-model"}
原始 Headercontent-type: application/json、x-test-header: present

得到的 Canonical Request 如下:

text
POST
/openai/v1/responses

content-type:application/json
host:bedrock-mantle.us-east-1.api.aws
x-amz-date:20231114T221320Z
x-test-header:present

content-type;host;x-amz-date;x-test-header
612775a14514756838f89c9c0148c8073f9cf771717a9b52ba634bb4b31d3e9a

最后一行是 body 摘要,整个文本再次 SHA-256 后得到 788d1b8b3682a71a26e0f5a7dbd3e83c9173c96aa49846a04ddd4d52c397574b。 将该摘要与时间、scope 组成 StringToSign,再执行四次派生和最终 HMAC,就得到完整 Signature。

实验代码:只复现上述无查询串、无路径编码变化、单值 Header 的输入,并使用公开示例 secret。 不要把这个教学脚本替换进真实客户端;实际调用继续由 AWS 签名库处理完整规范化规则。

python
import hashlib
import hmac

def signature(body, service="bedrock-mantle", token=None):
    headers = {
        "content-type": "application/json",
        "host": "bedrock-mantle.us-east-1.api.aws",
        "x-amz-date": "20231114T221320Z",
        "x-test-header": "present",
    }
    if token is not None:
        headers["x-amz-security-token"] = token
    names = ";".join(sorted(headers))
    canonical_headers = "".join(f"{k}:{headers[k]}\n" for k in sorted(headers))
    canonical = "\n".join([
        "POST", "/openai/v1/responses", "", canonical_headers, names,
        hashlib.sha256(body).hexdigest(),
    ])
    scope = f"20231114/us-east-1/{service}/aws4_request"
    to_sign = "\n".join([
        "AWS4-HMAC-SHA256", "20231114T221320Z", scope,
        hashlib.sha256(canonical.encode()).hexdigest(),
    ])
    key = b"AWS4wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"
    for part in ("20231114", "us-east-1", service, "aws4_request"):
        key = hmac.new(key, part.encode(), hashlib.sha256).digest()
    return hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()

body = b'{"model":"example-model"}'
print(signature(body))

输出应为 6fcfb4946f767b40545195d878b3713ba428ec38ab05b121e7cb34419ca107d4。 对相同的四组固定输入,这个复算与 Codex sign_request 调用 AWS SDK 后的输出一致;可以分别改变一个 因素,观察变化发生在哪一层:

只改变一项payload 摘要Canonical Request 摘要Signature 前 16 位
基准输入基准基准6fcfb4946f767b40
body 改成 { "model": "example-model" }改变改变531c0abd3f6c737a
service 改成 bedrock,URL 保持不变不变不变c4e464b9c7d8acc1
增加 session-token不变改变,SignedHeaders 也增加一项3a8099df6afa453e

第二行说明 JSON 语义等价不等于签名字节相同;第三行将 service 的作用从请求摘要中分离出来;第四行 说明 session token 的传播不仅是在最后多加一个无关 Header。service 与 URL 不匹配的那组只用于 隔离计算变量,绝不是可用的端点配置示例。

8.3 故障定位路径 ​

阅读源码时,可以在仓库根目录运行这些局部测试,逐层确认输入、断言和消费者。测试选择保持具体, 避免用一条不区分层次的全量测试命令掩盖关键机制。

sh
just test -p codex-aws-auth --lib
just test -p codex-model-provider -p codex-model-provider-info --lib -E 'test(bedrock) | test(aws)'
just test -p codex-api --test clients -E 'test(streaming_client_)'
just test -p codex-http-client --lib -E 'test(request::tests::)'

第一组检查签名适配和本地错误,第二组检查 Bedrock 特有选择与恢复,第三组检查认证与发送的组合, 第四组检查准备后字节的稳定性。它们合起来仍不能证明真实 AWS 账户权限、网络与登录服务可用;这类 故障还需要远端状态和实际运行条件作为证据。

现象从哪里开始查先区分什么
设置了 AWS_PROFILE,仍使用 Bearerauth_source环境 token 是否更早命中;是否需要显式 aws.profile
地址换成 Runtime 后仍验签失败runtime_base_url 与两种 aws_auth_configbase URL 覆盖是否与 Provider 身份、region/service 一致
简单请求正常,带会话头后失败Mantle Header 过滤与实际 SignedHeaders下划线头是否在签名前移除;代理是否改变被签名的内容
context 创建成功,首次请求失败AwsAuthContext::sign_atprovider 的构造成功与实际取凭据成功不是同一事件
临时 access keys 到期,重试无效load_with_access_keys 与 uses_aws_auth_recovery当前是静态复制值,还是可重新取得凭据的 SDK/profile
403 后没有启动登录is_refreshable_auth_error凭据过期类错误与一般 AccessDenied 是否被区分
两个会话只看到一次 aws 执行AwsAuthRecovery::refresh是否共享同一配置,等待期间成功代数是否改变
登录成功后请求仍失败Core 下一轮 setup 与新请求登录程序成功退出是否真的更新了当前所用身份;新请求是否通过远端授权

可以先选“设置 AWS_PROFILE 仍使用 Bearer”这个现象,沿来源选择函数预测结果,再到来源测试中找到 只设置 AWS_PROFILE 的输入行。然后把条件改成显式 aws.profile,重新判断代码会进入哪个 loader、 取得哪个 region、是否允许 AWS 重新登录。最后从 current_client_setup 复述到 ReqwestTransport 的调用链,标出 body 固定、凭据读取和签名计算三个不同时间点;若能同时解释这三个点,才有依据定位 请求内容被改动、凭据过期和服务作用域错误这三类表面相似的故障。