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 URL | SigV4 service |
|---|---|---|---|
amazon-bedrock | Amazon Bedrock | https://bedrock-mantle.<region>.api.aws/openai/v1 | bedrock-mantle |
amazon-bedrock-runtime | Amazon Bedrock Runtime | https://bedrock-runtime.<region>.amazonaws.com/openai/v1 | bedrock |
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
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
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
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
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,可以让端点与区域按上述实现关联。
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 = 120000auth_refresh 是可选项:它描述特定认证失败后执行的重新登录命令。这里的配置没有填写 secret, 也没有填写 service。下面的真实配置类型中,AWS 子表只有三个字段。
源码文件:codex-rs/model-provider-info/src/lib.rs
相关函数/类型:ModelProviderAwsAuthInfo / AwsAuthRefreshConfig
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.profile | Option<String>;来源选择检查是否为 Some | 显式选定 profile,优先于托管凭据和环境 token |
aws.region | 运行时裁掉首尾空白,空串视为未配置 | 参与区域端点与签名上下文构造 |
aws.auth_refresh.command | 配置校验和执行时都要求精确等于 aws | 不通过 shell 执行任意命令字符串 |
aws.auth_refresh.args | Vec<RedactedString>;默认空数组 | 作为 argv 传递,参数要与实际 profile 对应 |
aws.auth_refresh.timeout_ms | 非零整数;默认 300000 毫秒 | 约束重新登录子进程执行时间 |
aws 与 supports_websockets | validate 拒绝同时启用 | 此 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
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
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 })
}
}
}这里最重要的区别是三种签名上下文的构造方式:
| 来源 | 构造方法 | 身份由谁决定 |
|---|---|---|
ConfiguredAwsProfile | AwsAuthContext::load_profile | 明确指定的 AWS profile 及其内部凭据来源 |
ManagedAccessKeys | AwsAuthContext::load_with_access_keys | AuthManager 中已选中的 access keys 副本 |
EnvAwsCredentials / AwsSdk | AwsAuthContext::load | AWS 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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(),
})
}这个函数中有四个需要跟着代码确认的选择:
- Header 值先用
to_str()转成可签名字符串。存在非 UTF-8 Header 时,会在签名请求构造之前失败。 SignableBody::Bytes把真实 payload 送入摘要计算;没有使用“无签名 payload”模式。.region(region)和.name(service)来自上下文,不从 URL 猜测;name在这里就是 SigV4 service。- 返回的
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_location | Headers | Authorization 放在 Header 中,不是生成预签名 URL |
payload_checksum_kind | NoHeader | payload 仍参与摘要,只是不额外生成 x-amz-content-sha256 头 |
percent_encoding_mode | Double | 已编码路径中的 % 会继续参与规范编码,不能随意解码后重签 |
uri_path_normalization_mode | Enabled | 签名使用 SDK 的路径规范化规则 |
session_token_mode | Include | 临时凭据 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 里插入它。
一个没有查询串的规范请求具有以下结构:
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 中。
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
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
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
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 region | setup 返回错误,还没有进入 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
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
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
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
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
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
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 在作用域结束时被丢弃。
| 路径 | generation | permit / 子进程 |
|---|---|---|
| 登录成功 | 加一 | 返回成功后释放许可,等待者检查新代数 |
| 等待期间别人已成功 | 不再增加 | 获取许可后直接返回,不启动新子进程 |
| 启动失败、非零退出 | 不变 | 返回 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
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
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
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
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 / URI | POST /openai/v1/responses |
| host | bedrock-mantle.us-east-1.api.aws |
| region / service | us-east-1 / bedrock-mantle |
| 时间 | 20231114T221320Z,对应 Unix 秒 1700000000 |
| body | {"model":"example-model"} |
| 原始 Header | content-type: application/json、x-test-header: present |
得到的 Canonical Request 如下:
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 签名库处理完整规范化规则。
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 故障定位路径
阅读源码时,可以在仓库根目录运行这些局部测试,逐层确认输入、断言和消费者。测试选择保持具体, 避免用一条不区分层次的全量测试命令掩盖关键机制。
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,仍使用 Bearer | auth_source | 环境 token 是否更早命中;是否需要显式 aws.profile |
| 地址换成 Runtime 后仍验签失败 | runtime_base_url 与两种 aws_auth_config | base URL 覆盖是否与 Provider 身份、region/service 一致 |
| 简单请求正常,带会话头后失败 | Mantle Header 过滤与实际 SignedHeaders | 下划线头是否在签名前移除;代理是否改变被签名的内容 |
| context 创建成功,首次请求失败 | AwsAuthContext::sign_at | provider 的构造成功与实际取凭据成功不是同一事件 |
| 临时 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 固定、凭据读取和签名计算三个不同时间点;若能同时解释这三个点,才有依据定位 请求内容被改动、凭据过期和服务作用域错误这三类表面相似的故障。
