feat: add a built-in Amazon Bedrock model provider (#18744)

## Why

Codex needs a first-class `amazon-bedrock` model provider so users can
select Bedrock without copying a full provider definition into
`config.toml`. The provider has Codex-owned defaults for the pieces that
should stay consistent across users: the display `name`, Bedrock
`base_url`, and `wire_api`.

At the same time, users still need a way to choose the AWS credential
profile used by their local environment. This change makes
`amazon-bedrock` a partially modifiable built-in provider: code owns the
provider identity and endpoint defaults, while user config can set
`model_providers.amazon-bedrock.aws.profile`.

For example:

```toml
model_provider = "amazon-bedrock"

[model_providers.amazon-bedrock.aws]
profile = "codex-bedrock"
```

## What Changed

- Added `amazon-bedrock` to the built-in model provider map with:
  - `name = "Amazon Bedrock"`
  - `base_url = "https://bedrock-mantle.us-east-1.api.aws/v1"`
  - `wire_api = "responses"`
- Added AWS provider auth config with a profile-only shape:
`model_providers.<id>.aws.profile`.
- Kept AWS auth config restricted to `amazon-bedrock`; custom providers
that set `aws` are rejected.
- Allowed `model_providers.amazon-bedrock` through reserved-provider
validation so it can act as a partial override.
- During config loading, only `aws.profile` is copied from the
user-provided `amazon-bedrock` entry onto the built-in provider. Other
Bedrock provider fields remain hard-coded by the built-in definition.
- Updated the generated config schema for the new provider AWS profile
config.
This commit is contained in:
Celia Chen authored and GitHub committed 2026-04-21 00:54:05 +00:00
1 parent 9a2b34213b
commit cefcfe43b9
16 files changed
+461 -11

No files matched your search

+110 -1
View File
@@ -34,6 +34,9 @@ const MAX_REQUEST_MAX_RETRIES: u64 = 100;
const OPENAI_PROVIDER_NAME: &str = "OpenAI";
pub const OPENAI_PROVIDER_ID: &str = "openai";
const AMAZON_BEDROCK_PROVIDER_NAME: &str = "Amazon Bedrock";
pub const AMAZON_BEDROCK_PROVIDER_ID: &str = "amazon-bedrock";
pub const AMAZON_BEDROCK_DEFAULT_BASE_URL: &str = "https://bedrock-mantle.us-east-1.api.aws/v1";
const CHAT_WIRE_API_REMOVED_ERROR: &str = "`wire_api = \"chat\"` is no longer supported.\nHow to fix: set `wire_api = \"responses\"` in your provider config.\nMore info: https://github.com/openai/codex/discussions/7782";
pub const LEGACY_OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat";
pub const OLLAMA_CHAT_PROVIDER_REMOVED_ERROR: &str = "`ollama-chat` is no longer supported.\nHow to fix: replace `ollama-chat` with `ollama` in `model_provider`, `oss_provider`, or `--local-provider`.\nMore info: https://github.com/openai/codex/discussions/7782";
@@ -71,10 +74,11 @@ impl<'de> Deserialize<'de> for WireApi {
}
/// Serializable representation of a provider definition.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ModelProviderInfo {
/// Friendly display name.
#[serde(default)]
pub name: String,
/// Base URL for the provider's OpenAI-compatible API.
pub base_url: Option<String>,
@@ -90,6 +94,8 @@ pub struct ModelProviderInfo {
pub experimental_bearer_token: Option<String>,
/// Command-backed bearer-token configuration for this provider.
pub auth: Option<ModelProviderAuthInfo>,
/// AWS SigV4 auth configuration for this provider.
pub aws: Option<ModelProviderAwsAuthInfo>,
/// Which wire protocol this provider expects.
#[serde(default)]
pub wire_api: WireApi,
@@ -124,8 +130,46 @@ pub struct ModelProviderInfo {
pub supports_websockets: bool,
}
/// AWS SigV4 auth configuration for a model provider.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ModelProviderAwsAuthInfo {
/// AWS profile name to use. When unset, the AWS SDK default chain decides.
pub profile: Option<String>,
}
impl ModelProviderInfo {
pub fn validate(&self) -> std::result::Result<(), String> {
if self.aws.is_some() {
if self.supports_websockets {
// TODO(celia-oai): Support AWS SigV4 signing for WebSocket
// upgrade requests before allowing AWS-authenticated providers
// to enable Responses-over-WebSocket.
return Err("provider aws cannot be combined with supports_websockets".to_string());
}
let mut conflicts = Vec::new();
if self.env_key.is_some() {
conflicts.push("env_key");
}
if self.experimental_bearer_token.is_some() {
conflicts.push("experimental_bearer_token");
}
if self.auth.is_some() {
conflicts.push("auth");
}
if self.requires_openai_auth {
conflicts.push("requires_openai_auth");
}
if !conflicts.is_empty() {
return Err(format!(
"provider aws cannot be combined with {}",
conflicts.join(", ")
));
}
}
let Some(auth) = self.auth.as_ref() else {
return Ok(());
};
@@ -269,6 +313,7 @@ impl ModelProviderInfo {
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
aws: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: Some(
@@ -297,10 +342,38 @@ impl ModelProviderInfo {
}
}
pub fn create_amazon_bedrock_provider(
aws: Option<ModelProviderAwsAuthInfo>,
) -> ModelProviderInfo {
ModelProviderInfo {
name: AMAZON_BEDROCK_PROVIDER_NAME.into(),
base_url: Some(AMAZON_BEDROCK_DEFAULT_BASE_URL.into()),
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
aws: Some(aws.unwrap_or(ModelProviderAwsAuthInfo { profile: 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,
}
}
pub fn is_openai(&self) -> bool {
self.name == OPENAI_PROVIDER_NAME
}
pub fn is_amazon_bedrock(&self) -> bool {
self.name == AMAZON_BEDROCK_PROVIDER_NAME
}
pub fn supports_remote_compaction(&self) -> bool {
self.is_openai() || is_azure_responses_provider(&self.name, self.base_url.as_deref())
}
@@ -322,6 +395,7 @@ pub fn built_in_model_providers(
) -> HashMap<String, ModelProviderInfo> {
use ModelProviderInfo as P;
let openai_provider = P::create_openai_provider(openai_base_url);
let amazon_bedrock_provider = P::create_amazon_bedrock_provider(/*aws*/ None);
// We do not want to be in the business of adjucating which third-party
// providers are bundled with Codex CLI, so we only include the OpenAI and
@@ -329,6 +403,7 @@ pub fn built_in_model_providers(
// `model_providers` in config.toml to add their own providers.
[
(OPENAI_PROVIDER_ID, openai_provider),
(AMAZON_BEDROCK_PROVIDER_ID, amazon_bedrock_provider),
(
OLLAMA_OSS_PROVIDER_ID,
create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Responses),
@@ -343,6 +418,39 @@ pub fn built_in_model_providers(
.collect()
}
/// Merge configured providers into the built-in provider catalog.
///
/// Configured providers extend the built-in set. Built-in providers are not
/// generally overridable, but the built-in Amazon Bedrock provider allows the
/// user to set `aws.profile`.
pub fn merge_configured_model_providers(
mut model_providers: HashMap<String, ModelProviderInfo>,
configured_model_providers: HashMap<String, ModelProviderInfo>,
) -> Result<HashMap<String, ModelProviderInfo>, String> {
for (key, mut provider) in configured_model_providers {
if key == AMAZON_BEDROCK_PROVIDER_ID {
let aws_override = provider.aws.take();
if provider != ModelProviderInfo::default() {
return Err(format!(
"model_providers.{AMAZON_BEDROCK_PROVIDER_ID} only supports changing \
`aws.profile`; other non-default provider fields are not supported"
));
}
if let Some(profile) = aws_override.and_then(|aws| aws.profile)
&& let Some(built_in) = model_providers.get_mut(AMAZON_BEDROCK_PROVIDER_ID)
&& let Some(aws) = built_in.aws.as_mut()
{
aws.profile = Some(profile);
}
} else {
model_providers.entry(key).or_insert(provider);
}
}
Ok(model_providers)
}
pub fn create_oss_provider(default_provider_port: u16, wire_api: WireApi) -> ModelProviderInfo {
// These CODEX_OSS_ environment variables are experimental: we may
// switch to reading values from config.toml instead.
@@ -370,6 +478,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
aws: None,
wire_api,
query_params: None,
http_headers: None,
@@ -18,6 +18,7 @@ base_url = "http://localhost:11434/v1"
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
aws: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -49,6 +50,7 @@ query_params = { api-version = "2025-04-01-preview" }
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(),
@@ -83,6 +85,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
aws: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: Some(maplit::hashmap! {
@@ -145,6 +148,7 @@ fn test_supports_remote_compaction_for_azure_name() {
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
aws: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -169,6 +173,7 @@ fn test_supports_remote_compaction_for_non_openai_non_azure_provider() {
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
aws: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
@@ -212,6 +217,190 @@ args = ["--format=text"]
);
}
#[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"
"#;
let provider: ModelProviderInfo = toml::from_str(provider_toml).unwrap();
assert_eq!(
provider.aws,
Some(ModelProviderAwsAuthInfo {
profile: Some("codex-bedrock".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 }),
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()),
}),
..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()),
});
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()),
}),
..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`; 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 }),
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 }),
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 }),
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();