mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Summary Add first-class Amazon Bedrock Mantle provider support so Codex can keep using its existing Responses API transport with OpenAI-compatible AWS-hosted endpoints such as AOA/Mantle. This is needed for the AWS launch path, where provider traffic should authenticate with AWS credentials instead of OpenAI bearer credentials. Requests are authenticated immediately before transport send, so SigV4 signs the final method, URL, headers, and body bytes that `reqwest` will send. ## What Changed - Added a new `codex-aws-auth` crate for loading AWS SDK config, resolving credentials, and signing finalized HTTP requests with AWS SigV4. - Added a built-in `amazon-bedrock` provider that targets Bedrock Mantle Responses endpoints, defaults to `us-east-1`, supports region/profile overrides, disables WebSockets, and does not require OpenAI auth. - Added Amazon Bedrock auth resolution in `codex-model-provider`: prefer `AWS_BEARER_TOKEN_BEDROCK` when set, otherwise use AWS SDK credentials and SigV4 signing. - Added `AuthProvider::apply_auth` and `Request::prepare_body_for_send` so request-signing providers can sign the exact outbound request after JSON serialization/compression. - Determine the region by taking the `aws.region` config first (required for bearer token codepath), and fallback to SDK default region. ## Testing Amazon Bedrock Mantle Responses paths: - Built the local Codex binary with `cargo build`. - Verified the custom proxy-backed `aws` provider using `env_key = "AWS_BEARER_TOKEN_BEDROCK"` streamed raw `responses` output with `response.output_text.delta`, `response.completed`, and `mantle-env-ok`. - Verified a full `codex exec --profile aws` turn returned `mantle-env-ok`. - Confirmed the custom provider used the bearer env var, not AWS profile auth: bogus `AWS_PROFILE` still passed, empty env var failed locally, and malformed env var reached Mantle and failed with `401 invalid_api_key`. - Verified built-in `amazon-bedrock` with `AWS_BEARER_TOKEN_BEDROCK` set passed despite bogus AWS profiles, returning `amazon-bedrock-env-ok`. - Verified built-in `amazon-bedrock` SDK/SigV4 auth passed with `AWS_BEARER_TOKEN_BEDROCK` unset and temporary AWS session env credentials, returning `amazon-bedrock-sdk-env-ok`.
441 lines
13 KiB
Rust
441 lines
13 KiB
Rust
use super::*;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
|
use pretty_assertions::assert_eq;
|
|
use std::num::NonZeroU64;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn test_deserialize_ollama_model_provider_toml() {
|
|
let azure_provider_toml = r#"
|
|
name = "Ollama"
|
|
base_url = "http://localhost:11434/v1"
|
|
"#;
|
|
let expected_provider = ModelProviderInfo {
|
|
name: "Ollama".into(),
|
|
base_url: Some("http://localhost:11434/v1".into()),
|
|
env_key: None,
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
aws: None,
|
|
wire_api: WireApi::Responses,
|
|
query_params: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
request_max_retries: None,
|
|
stream_max_retries: None,
|
|
stream_idle_timeout_ms: None,
|
|
websocket_connect_timeout_ms: None,
|
|
requires_openai_auth: false,
|
|
supports_websockets: false,
|
|
};
|
|
|
|
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
|
assert_eq!(expected_provider, provider);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deserialize_azure_model_provider_toml() {
|
|
let azure_provider_toml = r#"
|
|
name = "Azure"
|
|
base_url = "https://xxxxx.openai.azure.com/openai"
|
|
env_key = "AZURE_OPENAI_API_KEY"
|
|
query_params = { api-version = "2025-04-01-preview" }
|
|
"#;
|
|
let expected_provider = ModelProviderInfo {
|
|
name: "Azure".into(),
|
|
base_url: Some("https://xxxxx.openai.azure.com/openai".into()),
|
|
env_key: Some("AZURE_OPENAI_API_KEY".into()),
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
aws: None,
|
|
wire_api: WireApi::Responses,
|
|
query_params: Some(maplit::hashmap! {
|
|
"api-version".to_string() => "2025-04-01-preview".to_string(),
|
|
}),
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
request_max_retries: None,
|
|
stream_max_retries: None,
|
|
stream_idle_timeout_ms: None,
|
|
websocket_connect_timeout_ms: None,
|
|
requires_openai_auth: false,
|
|
supports_websockets: false,
|
|
};
|
|
|
|
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
|
assert_eq!(expected_provider, provider);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deserialize_example_model_provider_toml() {
|
|
let azure_provider_toml = r#"
|
|
name = "Example"
|
|
base_url = "https://example.com"
|
|
env_key = "API_KEY"
|
|
http_headers = { "X-Example-Header" = "example-value" }
|
|
env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
|
|
"#;
|
|
let expected_provider = ModelProviderInfo {
|
|
name: "Example".into(),
|
|
base_url: Some("https://example.com".into()),
|
|
env_key: Some("API_KEY".into()),
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
aws: None,
|
|
wire_api: WireApi::Responses,
|
|
query_params: None,
|
|
http_headers: Some(maplit::hashmap! {
|
|
"X-Example-Header".to_string() => "example-value".to_string(),
|
|
}),
|
|
env_http_headers: Some(maplit::hashmap! {
|
|
"X-Example-Env-Header".to_string() => "EXAMPLE_ENV_VAR".to_string(),
|
|
}),
|
|
request_max_retries: None,
|
|
stream_max_retries: None,
|
|
stream_idle_timeout_ms: None,
|
|
websocket_connect_timeout_ms: None,
|
|
requires_openai_auth: false,
|
|
supports_websockets: false,
|
|
};
|
|
|
|
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
|
assert_eq!(expected_provider, provider);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deserialize_chat_wire_api_shows_helpful_error() {
|
|
let provider_toml = r#"
|
|
name = "OpenAI using Chat Completions"
|
|
base_url = "https://api.openai.com/v1"
|
|
env_key = "OPENAI_API_KEY"
|
|
wire_api = "chat"
|
|
"#;
|
|
|
|
let err = toml::from_str::<ModelProviderInfo>(provider_toml).unwrap_err();
|
|
assert!(err.to_string().contains(CHAT_WIRE_API_REMOVED_ERROR));
|
|
}
|
|
|
|
#[test]
|
|
fn test_deserialize_websocket_connect_timeout() {
|
|
let provider_toml = r#"
|
|
name = "OpenAI"
|
|
base_url = "https://api.openai.com/v1"
|
|
websocket_connect_timeout_ms = 15000
|
|
supports_websockets = true
|
|
"#;
|
|
|
|
let provider: ModelProviderInfo = toml::from_str(provider_toml).unwrap();
|
|
assert_eq!(provider.websocket_connect_timeout_ms, Some(15_000));
|
|
}
|
|
|
|
#[test]
|
|
fn test_supports_remote_compaction_for_openai() {
|
|
let provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
|
|
|
|
assert!(provider.supports_remote_compaction());
|
|
}
|
|
|
|
#[test]
|
|
fn test_supports_remote_compaction_for_azure_name() {
|
|
let provider = ModelProviderInfo {
|
|
name: "Azure".into(),
|
|
base_url: Some("https://example.com/openai".into()),
|
|
env_key: Some("AZURE_OPENAI_API_KEY".into()),
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
aws: None,
|
|
wire_api: WireApi::Responses,
|
|
query_params: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
request_max_retries: None,
|
|
stream_max_retries: None,
|
|
stream_idle_timeout_ms: None,
|
|
websocket_connect_timeout_ms: None,
|
|
requires_openai_auth: false,
|
|
supports_websockets: false,
|
|
};
|
|
|
|
assert!(provider.supports_remote_compaction());
|
|
}
|
|
|
|
#[test]
|
|
fn test_supports_remote_compaction_for_non_openai_non_azure_provider() {
|
|
let provider = ModelProviderInfo {
|
|
name: "Example".into(),
|
|
base_url: Some("https://example.com/v1".into()),
|
|
env_key: Some("API_KEY".into()),
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
aws: None,
|
|
wire_api: WireApi::Responses,
|
|
query_params: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
request_max_retries: None,
|
|
stream_max_retries: None,
|
|
stream_idle_timeout_ms: None,
|
|
websocket_connect_timeout_ms: None,
|
|
requires_openai_auth: false,
|
|
supports_websockets: false,
|
|
};
|
|
|
|
assert!(!provider.supports_remote_compaction());
|
|
}
|
|
|
|
#[test]
|
|
fn test_deserialize_provider_auth_config_defaults() {
|
|
let base_dir = tempdir().unwrap();
|
|
let provider_toml = r#"
|
|
name = "Corp"
|
|
|
|
[auth]
|
|
command = "./scripts/print-token"
|
|
args = ["--format=text"]
|
|
"#;
|
|
|
|
let provider: ModelProviderInfo = {
|
|
let _guard = AbsolutePathBufGuard::new(base_dir.path());
|
|
toml::from_str(provider_toml).unwrap()
|
|
};
|
|
|
|
assert_eq!(
|
|
provider.auth,
|
|
Some(ModelProviderAuthInfo {
|
|
command: "./scripts/print-token".to_string(),
|
|
args: vec!["--format=text".to_string()],
|
|
timeout_ms: NonZeroU64::new(5_000).unwrap(),
|
|
refresh_interval_ms: 300_000,
|
|
cwd: AbsolutePathBuf::resolve_path_against_base(".", base_dir.path()),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deserialize_provider_aws_config() {
|
|
let provider_toml = r#"
|
|
name = "Amazon Bedrock"
|
|
base_url = "https://bedrock.example.com/v1"
|
|
|
|
[aws]
|
|
profile = "codex-bedrock"
|
|
region = "us-west-2"
|
|
"#;
|
|
|
|
let provider: ModelProviderInfo = toml::from_str(provider_toml).unwrap();
|
|
|
|
assert_eq!(
|
|
provider.aws,
|
|
Some(ModelProviderAwsAuthInfo {
|
|
profile: Some("codex-bedrock".to_string()),
|
|
region: Some("us-west-2".to_string()),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_amazon_bedrock_provider() {
|
|
assert_eq!(
|
|
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
|
|
ModelProviderInfo {
|
|
name: "Amazon Bedrock".to_string(),
|
|
base_url: Some("https://bedrock-mantle.us-east-1.api.aws/v1".to_string()),
|
|
env_key: None,
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
aws: Some(ModelProviderAwsAuthInfo {
|
|
profile: None,
|
|
region: None,
|
|
}),
|
|
wire_api: WireApi::Responses,
|
|
query_params: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
request_max_retries: None,
|
|
stream_max_retries: None,
|
|
stream_idle_timeout_ms: None,
|
|
websocket_connect_timeout_ms: None,
|
|
requires_openai_auth: false,
|
|
supports_websockets: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_built_in_model_providers_include_amazon_bedrock() {
|
|
let providers = built_in_model_providers(/*openai_base_url*/ None);
|
|
|
|
assert_eq!(
|
|
providers
|
|
.get(AMAZON_BEDROCK_PROVIDER_ID)
|
|
.map(ModelProviderInfo::is_amazon_bedrock),
|
|
Some(true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_configured_model_providers_adds_custom_provider() {
|
|
let custom_provider = ModelProviderInfo {
|
|
name: "Custom".to_string(),
|
|
base_url: Some("https://example.com/v1".to_string()),
|
|
..ModelProviderInfo::default()
|
|
};
|
|
let configured_model_providers =
|
|
std::collections::HashMap::from([("custom".to_string(), custom_provider.clone())]);
|
|
|
|
let mut expected = built_in_model_providers(/*openai_base_url*/ None);
|
|
expected.insert("custom".to_string(), custom_provider);
|
|
|
|
assert_eq!(
|
|
merge_configured_model_providers(
|
|
built_in_model_providers(/*openai_base_url*/ None),
|
|
configured_model_providers,
|
|
),
|
|
Ok(expected)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_configured_model_providers_applies_amazon_bedrock_profile_override() {
|
|
let configured_model_providers = std::collections::HashMap::from([(
|
|
AMAZON_BEDROCK_PROVIDER_ID.to_string(),
|
|
ModelProviderInfo {
|
|
aws: Some(ModelProviderAwsAuthInfo {
|
|
profile: Some("codex-bedrock".to_string()),
|
|
region: Some("us-west-2".to_string()),
|
|
}),
|
|
..ModelProviderInfo::default()
|
|
},
|
|
)]);
|
|
|
|
let mut expected = built_in_model_providers(/*openai_base_url*/ None);
|
|
expected
|
|
.get_mut(AMAZON_BEDROCK_PROVIDER_ID)
|
|
.expect("Amazon Bedrock provider should be built in")
|
|
.aws = Some(ModelProviderAwsAuthInfo {
|
|
profile: Some("codex-bedrock".to_string()),
|
|
region: Some("us-west-2".to_string()),
|
|
});
|
|
|
|
assert_eq!(
|
|
merge_configured_model_providers(
|
|
built_in_model_providers(/*openai_base_url*/ None),
|
|
configured_model_providers,
|
|
),
|
|
Ok(expected)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_configured_model_providers_rejects_amazon_bedrock_non_default_fields() {
|
|
let configured_model_providers = std::collections::HashMap::from([(
|
|
AMAZON_BEDROCK_PROVIDER_ID.to_string(),
|
|
ModelProviderInfo {
|
|
name: "Custom Bedrock".to_string(),
|
|
aws: Some(ModelProviderAwsAuthInfo {
|
|
profile: Some("codex-bedrock".to_string()),
|
|
region: None,
|
|
}),
|
|
..ModelProviderInfo::default()
|
|
},
|
|
)]);
|
|
|
|
assert_eq!(
|
|
merge_configured_model_providers(
|
|
built_in_model_providers(/*openai_base_url*/ None),
|
|
configured_model_providers,
|
|
),
|
|
Err(
|
|
"model_providers.amazon-bedrock only supports changing `aws.profile` and `aws.region`; other non-default provider fields are not supported"
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_configured_model_providers_allows_amazon_bedrock_default_fields() {
|
|
let configured_model_providers = std::collections::HashMap::from([(
|
|
AMAZON_BEDROCK_PROVIDER_ID.to_string(),
|
|
ModelProviderInfo {
|
|
aws: Some(ModelProviderAwsAuthInfo {
|
|
profile: None,
|
|
region: None,
|
|
}),
|
|
wire_api: WireApi::Responses,
|
|
..ModelProviderInfo::default()
|
|
},
|
|
)]);
|
|
|
|
assert_eq!(
|
|
merge_configured_model_providers(
|
|
built_in_model_providers(/*openai_base_url*/ None),
|
|
configured_model_providers,
|
|
),
|
|
Ok(built_in_model_providers(/*openai_base_url*/ None))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_provider_aws_rejects_conflicting_auth() {
|
|
let provider = ModelProviderInfo {
|
|
aws: Some(ModelProviderAwsAuthInfo {
|
|
profile: None,
|
|
region: None,
|
|
}),
|
|
env_key: Some("AWS_BEARER_TOKEN_BEDROCK".to_string()),
|
|
supports_websockets: false,
|
|
..ModelProviderInfo::create_openai_provider(/*base_url*/ None)
|
|
};
|
|
|
|
assert_eq!(
|
|
provider.validate(),
|
|
Err("provider aws cannot be combined with env_key, requires_openai_auth".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_provider_aws_rejects_websockets() {
|
|
let provider = ModelProviderInfo {
|
|
aws: Some(ModelProviderAwsAuthInfo {
|
|
profile: None,
|
|
region: None,
|
|
}),
|
|
requires_openai_auth: false,
|
|
supports_websockets: true,
|
|
..ModelProviderInfo::create_openai_provider(/*base_url*/ None)
|
|
};
|
|
|
|
assert_eq!(
|
|
provider.validate(),
|
|
Err("provider aws cannot be combined with supports_websockets".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deserialize_provider_auth_config_allows_zero_refresh_interval() {
|
|
let base_dir = tempdir().unwrap();
|
|
let provider_toml = r#"
|
|
name = "Corp"
|
|
|
|
[auth]
|
|
command = "./scripts/print-token"
|
|
refresh_interval_ms = 0
|
|
"#;
|
|
|
|
let provider: ModelProviderInfo = {
|
|
let _guard = AbsolutePathBufGuard::new(base_dir.path());
|
|
toml::from_str(provider_toml).unwrap()
|
|
};
|
|
|
|
let auth = provider.auth.expect("auth config should deserialize");
|
|
assert_eq!(auth.refresh_interval_ms, 0);
|
|
assert_eq!(auth.refresh_interval(), None);
|
|
}
|