bedrock: Add native support for Bedrock Mantle models (#60480)

Adds native support for AWS Bedrock's Mantle endpoint
(`bedrock-mantle`), which serves models with no `Converse`/`Invoke`
support on `bedrock-runtime`, such as GPT-5.5, GPT-5.4, and Grok 4.3 but
more importantly **open-weight** models

Closes #60471 

## What's changed

- Renamed the existing `Model` enum in the `bedrock` crate to
`ConverseModel`, and added a new `MantleModel` enum for Mantle-only
models. Mantle models reuse the existing OpenAI-compatible Chat
Completions/Responses request and response plumbing
(`into_open_ai`/`into_open_ai_response`,
`OpenAiEventMapper`/`OpenAiResponseEventMapper`) already used by the
native OpenAI and OpenAI-compatible providers, rather than introducing
new marshalling code.
- Added a `BedrockMantleModel` language model that routes requests to
the `bedrock-mantle` endpoint, dispatching to Chat Completions or the
Responses API depending on the model. Mantle models appear in the model
picker alongside Converse models under the same Bedrock provider.
- Added region gating: `bedrock-mantle` is only available in a subset of
AWS Regions, so using a Mantle model outside of them surfaces a clear
error naming the current Region and the supported ones, instead of an
opaque HTTP failure.
- Implemented Bedrock bearer token authentication for Mantle requests: a
configured Bedrock API key is used as-is, and every other auth method
(IAM credentials, named profile, SSO, automatic) derives a short-term
token by locally SigV4-presigning a `CallWithBearerToken` request. This
requires no extra network round trip and no token caching, since
re-signing locally is cheap.
- Added a specific error for the 403 you get when your credentials have
`bedrock:CallWithBearerToken` but not the separate
`bedrock-mantle:CallWithBearerToken` permission Mantle models require,
since this is the most common misconfiguration.
- Added a `mantle_available_models` setting so custom models served
through `bedrock-mantle` can be configured, the same way other providers
support custom models via `available_models`.
- Documented Mantle models and the new setting in the Amazon Bedrock
section of [Use a
Gateway](https://zed.dev/docs/ai/use-a-gateway#amazon-bedrock).

## Testing

- Added unit tests covering: the local SigV4 bearer-token signing
(including a byte-for-byte cross-check against a reference
implementation), Mantle endpoint URL construction, the
Mantle-supported-regions list, thinking-effort normalization, and the
settings-to-model protocol mapping.
- `cargo test -p bedrock -p language_models -p settings_content -p
settings` passes.
- `./script/clippy` passes with no new warnings.

Release Notes:

- Added native support for AWS Bedrock's Mantle endpoint, enabling
GPT-5.5, GPT-5.4, and Grok 4.3 through the Amazon Bedrock provider.
This commit is contained in:
Anant Goel 2026-07-07 15:20:34 -07:00 committed by GitHub
parent 5d7d1d3f09
commit d12b980ee0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1250 additions and 137 deletions

1
Cargo.lock generated
View file

@ -9811,6 +9811,7 @@ dependencies = [
"async-lock",
"aws-config",
"aws-credential-types",
"aws-sigv4",
"aws_http_client",
"base64 0.22.1",
"bedrock",

View file

@ -543,6 +543,7 @@ aws-credential-types = { version = "1.2.8", features = [
aws-sdk-bedrockruntime = { version = "1.112.0", features = [
"behavior-version-latest",
] }
aws-sigv4 = { version = "1.4.0", features = ["http1"] }
aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] }
aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] }
backtrace = "0.3"

View file

@ -46,7 +46,7 @@ pub struct BedrockModelCacheConfiguration {
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)]
pub enum Model {
pub enum ConverseModel {
// Anthropic Claude 4+ models
#[serde(
rename = "claude-fable-5",
@ -227,7 +227,7 @@ pub enum Model {
},
}
impl Model {
impl ConverseModel {
pub fn default_fast(_region: &str) -> Self {
Self::ClaudeHaiku4_5
}
@ -817,34 +817,162 @@ impl Model {
}
}
/// The wire protocol used to talk to a [`MantleModel`] on the `bedrock-mantle` endpoint.
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)]
pub enum MantleProtocol {
/// The OpenAI Chat Completions API (`/chat/completions`).
#[default]
ChatCompletions,
/// The OpenAI Responses API (`/responses`).
Responses,
}
/// Models only reachable through the `bedrock-mantle` endpoint's
/// OpenAI-compatible APIs, i.e. with no `Converse`/`Invoke` support on
/// `bedrock-runtime`.
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, EnumIter)]
pub enum MantleModel {
#[serde(rename = "gpt-5.5")]
Gpt5_5,
#[serde(rename = "gpt-5.4")]
Gpt5_4,
#[serde(rename = "grok-4.3")]
Grok4_3,
#[serde(rename = "custom")]
Custom {
name: String,
display_name: Option<String>,
max_tokens: u64,
max_output_tokens: Option<u64>,
protocol: MantleProtocol,
supports_tools: bool,
supports_images: bool,
supports_thinking: bool,
},
}
impl MantleModel {
/// The model id Zed uses internally (also used as the `name` in settings).
pub fn id(&self) -> &str {
match self {
Self::Gpt5_5 => "gpt-5.5",
Self::Gpt5_4 => "gpt-5.4",
Self::Grok4_3 => "grok-4.3",
Self::Custom { name, .. } => name,
}
}
/// The model id as expected in Bedrock Mantle request bodies, e.g. `openai.gpt-5.5`.
pub fn request_id(&self) -> &str {
match self {
Self::Gpt5_5 => "openai.gpt-5.5",
Self::Gpt5_4 => "openai.gpt-5.4",
Self::Grok4_3 => "xai.grok-4.3",
Self::Custom { name, .. } => name,
}
}
pub fn display_name(&self) -> &str {
match self {
Self::Gpt5_5 => "GPT-5.5",
Self::Gpt5_4 => "GPT-5.4",
Self::Grok4_3 => "Grok 4.3",
Self::Custom {
display_name, name, ..
} => display_name.as_deref().unwrap_or(name.as_str()),
}
}
/// Which OpenAI-compatible API this model must be called through.
pub fn protocol(&self) -> MantleProtocol {
match self {
Self::Gpt5_5 | Self::Gpt5_4 | Self::Grok4_3 => MantleProtocol::Responses,
Self::Custom { protocol, .. } => *protocol,
}
}
pub fn max_token_count(&self) -> u64 {
match self {
Self::Gpt5_5 | Self::Gpt5_4 => 272_000,
Self::Grok4_3 => 1_000_000,
Self::Custom { max_tokens, .. } => *max_tokens,
}
}
pub fn max_output_tokens(&self) -> u64 {
match self {
// AWS doesn't document a hard cap for GPT-5.5/5.4 on Mantle.
Self::Gpt5_5 | Self::Gpt5_4 => 128_000,
Self::Grok4_3 => 131_072,
Self::Custom {
max_output_tokens, ..
} => max_output_tokens.unwrap_or(4_096),
}
}
pub fn supports_tools(&self) -> bool {
match self {
Self::Gpt5_5 | Self::Gpt5_4 | Self::Grok4_3 => true,
Self::Custom { supports_tools, .. } => *supports_tools,
}
}
pub fn supports_images(&self) -> bool {
match self {
Self::Gpt5_5 | Self::Gpt5_4 | Self::Grok4_3 => true,
Self::Custom {
supports_images, ..
} => *supports_images,
}
}
pub fn supports_thinking(&self) -> bool {
match self {
Self::Gpt5_5 | Self::Gpt5_4 | Self::Grok4_3 => true,
Self::Custom {
supports_thinking, ..
} => *supports_thinking,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builtin_mantle_models_use_responses_protocol() {
assert_eq!(MantleModel::Gpt5_5.protocol(), MantleProtocol::Responses);
assert_eq!(MantleModel::Gpt5_4.protocol(), MantleProtocol::Responses);
assert_eq!(MantleModel::Grok4_3.protocol(), MantleProtocol::Responses);
}
#[test]
fn test_us_region_inference_ids() -> anyhow::Result<()> {
assert_eq!(
Model::ClaudeSonnet4_5.cross_region_inference_id("us-east-1", false)?,
ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-east-1", false)?,
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
);
assert_eq!(
Model::ClaudeSonnet4.cross_region_inference_id("us-west-2", false)?,
ConverseModel::ClaudeSonnet4.cross_region_inference_id("us-west-2", false)?,
"us.anthropic.claude-sonnet-4-20250514-v1:0"
);
assert_eq!(
Model::ClaudeFable5.cross_region_inference_id("us-east-1", false)?,
ConverseModel::ClaudeFable5.cross_region_inference_id("us-east-1", false)?,
"us.anthropic.claude-fable-5"
);
assert_eq!(
Model::ClaudeSonnet5.cross_region_inference_id("us-east-1", false)?,
ConverseModel::ClaudeSonnet5.cross_region_inference_id("us-east-1", false)?,
"us.anthropic.claude-sonnet-5"
);
assert_eq!(
Model::NovaPro.cross_region_inference_id("us-east-2", false)?,
ConverseModel::NovaPro.cross_region_inference_id("us-east-2", false)?,
"us.amazon.nova-pro-v1:0"
);
assert_eq!(
Model::DeepSeekR1.cross_region_inference_id("us-east-1", false)?,
ConverseModel::DeepSeekR1.cross_region_inference_id("us-east-1", false)?,
"us.deepseek.r1-v1:0"
);
Ok(())
@ -853,27 +981,27 @@ mod tests {
#[test]
fn test_eu_region_inference_ids() -> anyhow::Result<()> {
assert_eq!(
Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1", false)?,
ConverseModel::ClaudeSonnet4.cross_region_inference_id("eu-west-1", false)?,
"eu.anthropic.claude-sonnet-4-20250514-v1:0"
);
assert_eq!(
Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?,
ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?,
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0"
);
assert_eq!(
Model::NovaLite.cross_region_inference_id("eu-north-1", false)?,
ConverseModel::NovaLite.cross_region_inference_id("eu-north-1", false)?,
"eu.amazon.nova-lite-v1:0"
);
assert_eq!(
Model::ClaudeOpus4_6.cross_region_inference_id("eu-west-1", false)?,
ConverseModel::ClaudeOpus4_6.cross_region_inference_id("eu-west-1", false)?,
"eu.anthropic.claude-opus-4-6-v1"
);
assert_eq!(
Model::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?,
ConverseModel::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?,
"eu.anthropic.claude-opus-4-7"
);
assert_eq!(
Model::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?,
ConverseModel::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?,
"eu.anthropic.claude-opus-4-8"
);
Ok(())
@ -882,19 +1010,19 @@ mod tests {
#[test]
fn test_inference_profile_only_models_fall_back_to_global() -> anyhow::Result<()> {
assert_eq!(
Model::ClaudeFable5.cross_region_inference_id("eu-west-1", false)?,
ConverseModel::ClaudeFable5.cross_region_inference_id("eu-west-1", false)?,
"global.anthropic.claude-fable-5"
);
assert_eq!(
Model::ClaudeSonnet5.cross_region_inference_id("eu-west-1", false)?,
ConverseModel::ClaudeSonnet5.cross_region_inference_id("eu-west-1", false)?,
"global.anthropic.claude-sonnet-5"
);
assert_eq!(
Model::ClaudeFable5.cross_region_inference_id("ap-southeast-2", false)?,
ConverseModel::ClaudeFable5.cross_region_inference_id("ap-southeast-2", false)?,
"global.anthropic.claude-fable-5"
);
assert_eq!(
Model::ClaudeSonnet5.cross_region_inference_id("ap-northeast-1", false)?,
ConverseModel::ClaudeSonnet5.cross_region_inference_id("ap-northeast-1", false)?,
"global.anthropic.claude-sonnet-5"
);
Ok(())
@ -903,11 +1031,11 @@ mod tests {
#[test]
fn test_apac_region_inference_ids() -> anyhow::Result<()> {
assert_eq!(
Model::ClaudeSonnet4_5.cross_region_inference_id("ap-south-1", false)?,
ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-south-1", false)?,
"apac.anthropic.claude-sonnet-4-5-20250929-v1:0"
);
assert_eq!(
Model::NovaLite.cross_region_inference_id("ap-south-1", false)?,
ConverseModel::NovaLite.cross_region_inference_id("ap-south-1", false)?,
"apac.amazon.nova-lite-v1:0"
);
Ok(())
@ -916,23 +1044,23 @@ mod tests {
#[test]
fn test_au_region_inference_ids() -> anyhow::Result<()> {
assert_eq!(
Model::ClaudeHaiku4_5.cross_region_inference_id("ap-southeast-2", false)?,
ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-southeast-2", false)?,
"au.anthropic.claude-haiku-4-5-20251001-v1:0"
);
assert_eq!(
Model::ClaudeSonnet4_5.cross_region_inference_id("ap-southeast-4", false)?,
ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-southeast-4", false)?,
"au.anthropic.claude-sonnet-4-5-20250929-v1:0"
);
assert_eq!(
Model::ClaudeOpus4_6.cross_region_inference_id("ap-southeast-2", false)?,
ConverseModel::ClaudeOpus4_6.cross_region_inference_id("ap-southeast-2", false)?,
"au.anthropic.claude-opus-4-6-v1"
);
assert_eq!(
Model::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?,
ConverseModel::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?,
"au.anthropic.claude-opus-4-7"
);
assert_eq!(
Model::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?,
ConverseModel::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?,
"au.anthropic.claude-opus-4-8"
);
Ok(())
@ -941,15 +1069,15 @@ mod tests {
#[test]
fn test_jp_region_inference_ids() -> anyhow::Result<()> {
assert_eq!(
Model::ClaudeHaiku4_5.cross_region_inference_id("ap-northeast-1", false)?,
ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-northeast-1", false)?,
"jp.anthropic.claude-haiku-4-5-20251001-v1:0"
);
assert_eq!(
Model::ClaudeSonnet4_5.cross_region_inference_id("ap-northeast-3", false)?,
ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-northeast-3", false)?,
"jp.anthropic.claude-sonnet-4-5-20250929-v1:0"
);
assert_eq!(
Model::Nova2Lite.cross_region_inference_id("ap-northeast-1", false)?,
ConverseModel::Nova2Lite.cross_region_inference_id("ap-northeast-1", false)?,
"jp.amazon.nova-2-lite-v1:0"
);
Ok(())
@ -958,7 +1086,7 @@ mod tests {
#[test]
fn test_ca_region_inference_ids() -> anyhow::Result<()> {
assert_eq!(
Model::NovaLite.cross_region_inference_id("ca-central-1", false)?,
ConverseModel::NovaLite.cross_region_inference_id("ca-central-1", false)?,
"ca.amazon.nova-lite-v1:0"
);
Ok(())
@ -967,11 +1095,11 @@ mod tests {
#[test]
fn test_gov_region_inference_ids() -> anyhow::Result<()> {
assert_eq!(
Model::ClaudeSonnet4_5.cross_region_inference_id("us-gov-east-1", false)?,
ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-gov-east-1", false)?,
"us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0"
);
assert_eq!(
Model::ClaudeSonnet4_5.cross_region_inference_id("us-gov-west-1", false)?,
ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-gov-west-1", false)?,
"us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0"
);
Ok(())
@ -980,45 +1108,45 @@ mod tests {
#[test]
fn test_global_inference_ids() -> anyhow::Result<()> {
assert_eq!(
Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", true)?,
ConverseModel::ClaudeSonnet4.cross_region_inference_id("us-east-1", true)?,
"global.anthropic.claude-sonnet-4-20250514-v1:0"
);
assert_eq!(
Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", true)?,
ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", true)?,
"global.anthropic.claude-sonnet-4-5-20250929-v1:0"
);
assert_eq!(
Model::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?,
ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?,
"global.anthropic.claude-haiku-4-5-20251001-v1:0"
);
assert_eq!(
Model::ClaudeOpus4_6.cross_region_inference_id("us-east-1", true)?,
ConverseModel::ClaudeOpus4_6.cross_region_inference_id("us-east-1", true)?,
"global.anthropic.claude-opus-4-6-v1"
);
assert_eq!(
Model::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?,
ConverseModel::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?,
"global.anthropic.claude-opus-4-7"
);
assert_eq!(
Model::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?,
ConverseModel::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?,
"global.anthropic.claude-opus-4-8"
);
assert_eq!(
Model::ClaudeFable5.cross_region_inference_id("us-east-1", true)?,
ConverseModel::ClaudeFable5.cross_region_inference_id("us-east-1", true)?,
"global.anthropic.claude-fable-5"
);
assert_eq!(
Model::ClaudeSonnet5.cross_region_inference_id("us-east-1", true)?,
ConverseModel::ClaudeSonnet5.cross_region_inference_id("us-east-1", true)?,
"global.anthropic.claude-sonnet-5"
);
assert_eq!(
Model::Nova2Lite.cross_region_inference_id("us-east-1", true)?,
ConverseModel::Nova2Lite.cross_region_inference_id("us-east-1", true)?,
"global.amazon.nova-2-lite-v1:0"
);
// Models without global support fall back to regional
assert_eq!(
Model::NovaPro.cross_region_inference_id("us-east-1", true)?,
ConverseModel::NovaPro.cross_region_inference_id("us-east-1", true)?,
"us.amazon.nova-pro-v1:0"
);
Ok(())
@ -1028,27 +1156,27 @@ mod tests {
fn test_models_without_cross_region() -> anyhow::Result<()> {
// Models without cross-region support return their request_id directly
assert_eq!(
Model::Gemma3_4B.cross_region_inference_id("us-east-1", false)?,
ConverseModel::Gemma3_4B.cross_region_inference_id("us-east-1", false)?,
"google.gemma-3-4b-it"
);
assert_eq!(
Model::MistralLarge3.cross_region_inference_id("eu-west-1", false)?,
ConverseModel::MistralLarge3.cross_region_inference_id("eu-west-1", false)?,
"mistral.mistral-large-3-675b-instruct"
);
assert_eq!(
Model::Qwen3VL235B.cross_region_inference_id("ap-south-1", false)?,
ConverseModel::Qwen3VL235B.cross_region_inference_id("ap-south-1", false)?,
"qwen.qwen3-vl-235b-a22b"
);
assert_eq!(
Model::GptOss120B.cross_region_inference_id("us-east-1", false)?,
ConverseModel::GptOss120B.cross_region_inference_id("us-east-1", false)?,
"openai.gpt-oss-120b-1:0"
);
assert_eq!(
Model::MiniMaxM2.cross_region_inference_id("us-east-1", false)?,
ConverseModel::MiniMaxM2.cross_region_inference_id("us-east-1", false)?,
"minimax.minimax-m2"
);
assert_eq!(
Model::KimiK2Thinking.cross_region_inference_id("us-east-1", false)?,
ConverseModel::KimiK2Thinking.cross_region_inference_id("us-east-1", false)?,
"moonshot.kimi-k2-thinking"
);
Ok(())
@ -1056,7 +1184,7 @@ mod tests {
#[test]
fn test_custom_model_inference_ids() -> anyhow::Result<()> {
let custom_model = Model::Custom {
let custom_model = ConverseModel::Custom {
name: "custom.my-model-v1:0".to_string(),
max_tokens: 100000,
display_name: Some("My Custom Model".to_string()),
@ -1078,78 +1206,90 @@ mod tests {
#[test]
fn test_friendly_id_vs_request_id() {
assert_eq!(Model::ClaudeSonnet4_5.id(), "claude-sonnet-4-5");
assert_eq!(Model::NovaLite.id(), "nova-lite");
assert_eq!(Model::DeepSeekR1.id(), "deepseek-r1");
assert_eq!(Model::Llama4Scout17B.id(), "llama-4-scout-17b");
assert_eq!(Model::ClaudeFable5.id(), "claude-fable-5");
assert_eq!(Model::ClaudeSonnet5.id(), "claude-sonnet-5");
assert_eq!(ConverseModel::ClaudeSonnet4_5.id(), "claude-sonnet-4-5");
assert_eq!(ConverseModel::NovaLite.id(), "nova-lite");
assert_eq!(ConverseModel::DeepSeekR1.id(), "deepseek-r1");
assert_eq!(ConverseModel::Llama4Scout17B.id(), "llama-4-scout-17b");
assert_eq!(ConverseModel::ClaudeFable5.id(), "claude-fable-5");
assert_eq!(ConverseModel::ClaudeSonnet5.id(), "claude-sonnet-5");
assert_eq!(
Model::ClaudeSonnet4_5.request_id(),
ConverseModel::ClaudeSonnet4_5.request_id(),
"anthropic.claude-sonnet-4-5-20250929-v1:0"
);
assert_eq!(Model::NovaLite.request_id(), "amazon.nova-lite-v1:0");
assert_eq!(Model::DeepSeekR1.request_id(), "deepseek.r1-v1:0");
assert_eq!(
Model::Llama4Scout17B.request_id(),
ConverseModel::NovaLite.request_id(),
"amazon.nova-lite-v1:0"
);
assert_eq!(ConverseModel::DeepSeekR1.request_id(), "deepseek.r1-v1:0");
assert_eq!(
ConverseModel::Llama4Scout17B.request_id(),
"meta.llama4-scout-17b-instruct-v1:0"
);
assert_eq!(Model::ClaudeFable5.request_id(), "anthropic.claude-fable-5");
assert_eq!(
Model::ClaudeSonnet5.request_id(),
ConverseModel::ClaudeFable5.request_id(),
"anthropic.claude-fable-5"
);
assert_eq!(
ConverseModel::ClaudeSonnet5.request_id(),
"anthropic.claude-sonnet-5"
);
// Thinking aliases deserialize to the same model
assert_eq!(Model::ClaudeSonnet4.id(), "claude-sonnet-4");
assert_eq!(ConverseModel::ClaudeSonnet4.id(), "claude-sonnet-4");
assert_eq!(
Model::from_id("claude-sonnet-4-thinking").unwrap().id(),
ConverseModel::from_id("claude-sonnet-4-thinking")
.unwrap()
.id(),
"claude-sonnet-4"
);
assert_eq!(
Model::from_id("claude-fable-5-thinking").unwrap().id(),
ConverseModel::from_id("claude-fable-5-thinking")
.unwrap()
.id(),
"claude-fable-5"
);
assert_eq!(
Model::from_id("claude-sonnet-5-thinking").unwrap().id(),
ConverseModel::from_id("claude-sonnet-5-thinking")
.unwrap()
.id(),
"claude-sonnet-5"
);
}
#[test]
fn test_thinking_modes() {
assert!(Model::ClaudeHaiku4_5.supports_thinking());
assert!(Model::ClaudeSonnet4.supports_thinking());
assert!(Model::ClaudeSonnet4_5.supports_thinking());
assert!(Model::ClaudeOpus4_6.supports_thinking());
assert!(Model::ClaudeFable5.supports_thinking());
assert!(ConverseModel::ClaudeHaiku4_5.supports_thinking());
assert!(ConverseModel::ClaudeSonnet4.supports_thinking());
assert!(ConverseModel::ClaudeSonnet4_5.supports_thinking());
assert!(ConverseModel::ClaudeOpus4_6.supports_thinking());
assert!(ConverseModel::ClaudeFable5.supports_thinking());
assert!(!Model::ClaudeSonnet4.supports_adaptive_thinking());
assert!(Model::ClaudeOpus4_6.supports_adaptive_thinking());
assert!(Model::ClaudeSonnet4_6.supports_adaptive_thinking());
assert!(Model::ClaudeFable5.supports_adaptive_thinking());
assert!(Model::ClaudeSonnet5.supports_adaptive_thinking());
assert!(!Model::ClaudeOpus4_7.supports_xhigh_adaptive_thinking());
assert!(Model::ClaudeFable5.supports_xhigh_adaptive_thinking());
assert!(Model::ClaudeSonnet5.supports_xhigh_adaptive_thinking());
assert!(Model::ClaudeOpus4_8.supports_xhigh_adaptive_thinking());
assert!(!ConverseModel::ClaudeSonnet4.supports_adaptive_thinking());
assert!(ConverseModel::ClaudeOpus4_6.supports_adaptive_thinking());
assert!(ConverseModel::ClaudeSonnet4_6.supports_adaptive_thinking());
assert!(ConverseModel::ClaudeFable5.supports_adaptive_thinking());
assert!(ConverseModel::ClaudeSonnet5.supports_adaptive_thinking());
assert!(!ConverseModel::ClaudeOpus4_7.supports_xhigh_adaptive_thinking());
assert!(ConverseModel::ClaudeFable5.supports_xhigh_adaptive_thinking());
assert!(ConverseModel::ClaudeSonnet5.supports_xhigh_adaptive_thinking());
assert!(ConverseModel::ClaudeOpus4_8.supports_xhigh_adaptive_thinking());
assert_eq!(BedrockAdaptiveThinkingEffort::XHigh.as_str(), "xhigh");
assert_eq!(
Model::ClaudeSonnet4.thinking_mode(),
ConverseModel::ClaudeSonnet4.thinking_mode(),
BedrockModelMode::Thinking {
budget_tokens: Some(4096)
}
);
assert_eq!(
Model::ClaudeOpus4_6.thinking_mode(),
ConverseModel::ClaudeOpus4_6.thinking_mode(),
BedrockModelMode::AdaptiveThinking {
effort: BedrockAdaptiveThinkingEffort::High
}
);
assert_eq!(
Model::ClaudeHaiku4_5.thinking_mode(),
ConverseModel::ClaudeHaiku4_5.thinking_mode(),
BedrockModelMode::Thinking {
budget_tokens: Some(4096)
}
@ -1158,44 +1298,44 @@ mod tests {
#[test]
fn test_max_token_count() {
assert_eq!(Model::ClaudeSonnet4_5.max_token_count(), 1_000_000);
assert_eq!(Model::ClaudeOpus4_6.max_token_count(), 1_000_000);
assert_eq!(Model::ClaudeFable5.max_token_count(), 1_000_000);
assert_eq!(Model::ClaudeSonnet5.max_token_count(), 1_000_000);
assert_eq!(Model::Llama4Scout17B.max_token_count(), 128_000);
assert_eq!(Model::NovaPremier.max_token_count(), 1_000_000);
assert_eq!(ConverseModel::ClaudeSonnet4_5.max_token_count(), 1_000_000);
assert_eq!(ConverseModel::ClaudeOpus4_6.max_token_count(), 1_000_000);
assert_eq!(ConverseModel::ClaudeFable5.max_token_count(), 1_000_000);
assert_eq!(ConverseModel::ClaudeSonnet5.max_token_count(), 1_000_000);
assert_eq!(ConverseModel::Llama4Scout17B.max_token_count(), 128_000);
assert_eq!(ConverseModel::NovaPremier.max_token_count(), 1_000_000);
}
#[test]
fn test_max_output_tokens() {
assert_eq!(Model::ClaudeSonnet4_5.max_output_tokens(), 64_000);
assert_eq!(Model::ClaudeOpus4_6.max_output_tokens(), 128_000);
assert_eq!(Model::ClaudeFable5.max_output_tokens(), 128_000);
assert_eq!(Model::ClaudeSonnet5.max_output_tokens(), 128_000);
assert_eq!(Model::ClaudeOpus4_1.max_output_tokens(), 32_000);
assert_eq!(Model::Gemma3_4B.max_output_tokens(), 8_192);
assert_eq!(ConverseModel::ClaudeSonnet4_5.max_output_tokens(), 64_000);
assert_eq!(ConverseModel::ClaudeOpus4_6.max_output_tokens(), 128_000);
assert_eq!(ConverseModel::ClaudeFable5.max_output_tokens(), 128_000);
assert_eq!(ConverseModel::ClaudeSonnet5.max_output_tokens(), 128_000);
assert_eq!(ConverseModel::ClaudeOpus4_1.max_output_tokens(), 32_000);
assert_eq!(ConverseModel::Gemma3_4B.max_output_tokens(), 8_192);
}
#[test]
fn test_supports_tool_use() {
assert!(Model::ClaudeSonnet4_5.supports_tool_use());
assert!(Model::ClaudeFable5.supports_tool_use());
assert!(Model::NovaPro.supports_tool_use());
assert!(Model::MistralLarge3.supports_tool_use());
assert!(!Model::Gemma3_4B.supports_tool_use());
assert!(Model::Qwen3_32B.supports_tool_use());
assert!(Model::MiniMaxM2.supports_tool_use());
assert!(Model::KimiK2_5.supports_tool_use());
assert!(Model::DeepSeekR1.supports_tool_use());
assert!(!Model::Llama4Scout17B.supports_tool_use());
assert!(ConverseModel::ClaudeSonnet4_5.supports_tool_use());
assert!(ConverseModel::ClaudeFable5.supports_tool_use());
assert!(ConverseModel::NovaPro.supports_tool_use());
assert!(ConverseModel::MistralLarge3.supports_tool_use());
assert!(!ConverseModel::Gemma3_4B.supports_tool_use());
assert!(ConverseModel::Qwen3_32B.supports_tool_use());
assert!(ConverseModel::MiniMaxM2.supports_tool_use());
assert!(ConverseModel::KimiK2_5.supports_tool_use());
assert!(ConverseModel::DeepSeekR1.supports_tool_use());
assert!(!ConverseModel::Llama4Scout17B.supports_tool_use());
}
#[test]
fn test_supports_caching() {
assert!(Model::ClaudeSonnet4_5.supports_caching());
assert!(Model::ClaudeOpus4_6.supports_caching());
assert!(Model::ClaudeFable5.supports_caching());
assert!(!Model::Llama4Scout17B.supports_caching());
assert!(!Model::NovaPro.supports_caching());
assert!(ConverseModel::ClaudeSonnet4_5.supports_caching());
assert!(ConverseModel::ClaudeOpus4_6.supports_caching());
assert!(ConverseModel::ClaudeFable5.supports_caching());
assert!(!ConverseModel::Llama4Scout17B.supports_caching());
assert!(!ConverseModel::NovaPro.supports_caching());
}
}

View file

@ -18,6 +18,7 @@ anthropic = { workspace = true, features = ["schemars"] }
anyhow.workspace = true
aws-config = { workspace = true, features = ["behavior-version-latest"] }
aws-credential-types = { workspace = true, features = ["hardcoded-credentials"] }
aws-sigv4.workspace = true
aws_http_client.workspace = true
base64.workspace = true
bedrock = { workspace = true, features = ["schemars"] }

File diff suppressed because it is too large Load diff

View file

@ -95,6 +95,7 @@ impl settings::Settings for AllLanguageModelSettings {
.collect(),
bedrock: AmazonBedrockSettings {
available_models: bedrock.available_models.unwrap_or_default(),
mantle_available_models: bedrock.mantle_available_models.unwrap_or_default(),
custom_headers: custom_headers_from(
"Amazon Bedrock",
bedrock.custom_headers,

View file

@ -814,7 +814,8 @@ impl OpenAiResponseEventMapper {
}
events
}
ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => {
ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. }
| ResponsesStreamEvent::ReasoningDelta { delta, .. } => {
if delta.is_empty() {
Vec::new()
} else {
@ -973,6 +974,7 @@ impl OpenAiResponseEventMapper {
| ResponsesStreamEvent::ContentPartDone { .. }
| ResponsesStreamEvent::ReasoningSummaryTextDone { .. }
| ResponsesStreamEvent::ReasoningSummaryPartDone { .. }
| ResponsesStreamEvent::ReasoningDone { .. }
| ResponsesStreamEvent::Created { .. }
| ResponsesStreamEvent::InProgress { .. }
| ResponsesStreamEvent::Unknown => Vec::new(),
@ -1371,6 +1373,22 @@ mod tests {
));
}
#[test]
fn responses_stream_maps_mantle_reasoning_delta() {
let event = serde_json::from_value::<ResponsesStreamEvent>(json!({
"type": "response.reasoning.delta",
"delta": "checking the contract terms"
}))
.unwrap();
let mapped = map_response_events(vec![event]);
assert!(matches!(
&mapped[0],
LanguageModelCompletionEvent::Thinking { text, signature: None }
if text == "checking the contract terms"
));
}
#[test]
fn response_usage_deserializes_cached_tokens() -> Result<()> {
let usage: ResponseUsage = serde_json::from_value(json!({

View file

@ -310,6 +310,23 @@ pub enum StreamEvent {
output_index: usize,
summary_index: usize,
},
#[serde(rename = "response.reasoning.delta")]
ReasoningDelta {
#[serde(default)]
item_id: Option<String>,
#[serde(default)]
output_index: Option<usize>,
delta: String,
},
#[serde(rename = "response.reasoning.done")]
ReasoningDone {
#[serde(default)]
item_id: Option<String>,
#[serde(default)]
output_index: Option<usize>,
#[serde(default)]
text: Option<String>,
},
#[serde(rename = "response.function_call_arguments.delta")]
FunctionCallArgumentsDelta {
item_id: String,

View file

@ -114,6 +114,10 @@ pub struct AnthropicAvailableModel {
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct AmazonBedrockSettingsContent {
pub available_models: Option<Vec<BedrockAvailableModel>>,
/// Custom models served through the `bedrock-mantle` endpoint's
/// OpenAI-compatible APIs, in addition to the built-in Mantle models
/// (GPT-5.5, GPT-5.4, Grok 4.3).
pub mantle_available_models: Option<Vec<BedrockMantleAvailableModel>>,
pub custom_headers: Option<HashMap<String, String>>,
pub endpoint_url: Option<String>,
pub region: Option<String>,
@ -139,6 +143,32 @@ pub struct BedrockAvailableModel {
pub mode: Option<ModelMode>,
}
#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct BedrockMantleAvailableModel {
/// The model id as expected in Bedrock Mantle request bodies, e.g. `openai.gpt-5.5`.
pub name: String,
pub display_name: Option<String>,
pub max_tokens: u64,
pub max_output_tokens: Option<u64>,
/// The OpenAI-compatible API this model must be called through.
pub protocol: BedrockMantleProtocolContent,
pub supports_tools: Option<bool>,
pub supports_images: Option<bool>,
/// Whether this custom Mantle model supports OpenAI reasoning effort parameters.
pub supports_thinking: Option<bool>,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub enum BedrockMantleProtocolContent {
/// The OpenAI Chat Completions API (`/chat/completions`).
#[serde(rename = "chat_completions")]
ChatCompletions,
/// The OpenAI Responses API (`/responses`).
#[serde(rename = "responses")]
Responses,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub enum BedrockAuthMethodContent {
#[serde(rename = "named_profile")]

View file

@ -213,6 +213,38 @@ Some AWS environments require a guardrail on every Bedrock API call. Add `guardr
}
```
### Bedrock Mantle Models {#bedrock-mantle-models}
Some models, such as GPT-5.5, GPT-5.4, and Grok 4.3, aren't available through Bedrock's Converse API and are only reachable through `bedrock-mantle`, AWS's OpenAI-compatible inference endpoint. Zed routes these models through `bedrock-mantle` automatically; they appear alongside the rest of the Bedrock models in the model picker once you're authenticated, with no extra configuration required.
Mantle models require IAM permissions for the `bedrock-mantle` endpoint (for example via the `AmazonBedrockMantleInferenceAccess` managed policy) in addition to whatever permissions your existing Bedrock credentials already have, and `bedrock-mantle` is only available in [some AWS Regions](https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html#regions). Zed surfaces an error naming the current Region and the supported ones if you try to use a Mantle model outside of them.
#### Custom Bedrock Mantle Models {#bedrock-mantle-custom-models}
You can add custom models served through `bedrock-mantle` with `mantle_available_models`:
```json [settings]
{
"language_models": {
"bedrock": {
"mantle_available_models": [
{
"name": "openai.gpt-oss-120b",
"display_name": "GPT-OSS 120B",
"max_tokens": 128000,
"protocol": "chat_completions",
"supports_tools": true,
"supports_images": false,
"supports_thinking": true
}
]
}
}
}
```
`protocol` selects which OpenAI-compatible API the model is called through, and must be either `chat_completions` or `responses`. Set `supports_thinking` to `true` for custom Mantle models that accept OpenAI reasoning effort parameters; Zed will then expose `low`, `medium`, `high`, and `xhigh` in the thinking effort picker, while disabling thinking sends `none`.
## OpenAI-Compatible Gateways {#openai-compatible}
If your gateway exposes an OpenAI-compatible API, configure it with [Use API Access](./use-api-access.md#openai-compatible).