use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ProviderExtensions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anthropic: Option<AnthropicExt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openai_chat: Option<OpenAiChatExt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openai_responses: Option<OpenAiResponsesExt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini: Option<GeminiExt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bedrock: Option<BedrockExt>,
}
impl ProviderExtensions {
#[must_use]
pub fn with_anthropic(mut self, ext: AnthropicExt) -> Self {
self.anthropic = Some(ext);
self
}
#[must_use]
pub fn with_openai_chat(mut self, ext: OpenAiChatExt) -> Self {
self.openai_chat = Some(ext);
self
}
#[must_use]
pub fn with_openai_responses(mut self, ext: OpenAiResponsesExt) -> Self {
self.openai_responses = Some(ext);
self
}
#[must_use]
pub fn with_gemini(mut self, ext: GeminiExt) -> Self {
self.gemini = Some(ext);
self
}
#[must_use]
pub fn with_bedrock(mut self, ext: BedrockExt) -> Self {
self.bedrock = Some(ext);
self
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.anthropic.is_none()
&& self.openai_chat.is_none()
&& self.openai_responses.is_none()
&& self.gemini.is_none()
&& self.bedrock.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AnthropicExt {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub betas: Vec<String>,
}
impl AnthropicExt {
#[must_use]
pub fn with_betas<I, S>(mut self, betas: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.betas = betas.into_iter().map(Into::into).collect();
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct OpenAiChatExt {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
}
impl OpenAiChatExt {
#[must_use]
pub fn with_cache_key(mut self, key: impl Into<String>) -> Self {
self.cache_key = Some(key.into());
self
}
#[must_use]
pub const fn with_service_tier(mut self, tier: ServiceTier) -> Self {
self.service_tier = Some(tier);
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct OpenAiResponsesExt {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_summary: Option<ReasoningSummary>,
}
impl OpenAiResponsesExt {
#[must_use]
pub fn with_cache_key(mut self, key: impl Into<String>) -> Self {
self.cache_key = Some(key.into());
self
}
#[must_use]
pub const fn with_service_tier(mut self, tier: ServiceTier) -> Self {
self.service_tier = Some(tier);
self
}
#[must_use]
pub const fn with_reasoning_summary(mut self, summary: ReasoningSummary) -> Self {
self.reasoning_summary = Some(summary);
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ServiceTier {
Auto,
Default,
Flex,
Priority,
Scale,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReasoningSummary {
Auto,
Concise,
Detailed,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GeminiExt {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub safety_settings: Vec<GeminiSafetyOverride>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub candidate_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cached_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url_context: Option<UrlContext>,
}
impl GeminiExt {
#[must_use]
pub fn with_safety_override(mut self, category: &str, threshold: &str) -> Self {
self.safety_settings.push(GeminiSafetyOverride {
category: category.to_owned(),
threshold: threshold.to_owned(),
});
self
}
#[must_use]
pub const fn with_candidate_count(mut self, n: u32) -> Self {
self.candidate_count = Some(n);
self
}
#[must_use]
pub fn with_cached_content(mut self, name: impl Into<String>) -> Self {
self.cached_content = Some(name.into());
self
}
#[must_use]
pub const fn with_url_context(mut self) -> Self {
self.url_context = Some(UrlContext::ENABLED);
self
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UrlContext;
impl UrlContext {
pub const ENABLED: Self = Self;
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeminiSafetyOverride {
pub category: String,
pub threshold: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BedrockExt {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guardrail: Option<BedrockGuardrail>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub performance_config_tier: Option<String>,
}
impl BedrockExt {
#[must_use]
pub fn with_guardrail(mut self, guardrail: BedrockGuardrail) -> Self {
self.guardrail = Some(guardrail);
self
}
#[must_use]
pub fn with_performance_config_tier(mut self, tier: impl Into<String>) -> Self {
self.performance_config_tier = Some(tier.into());
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BedrockGuardrail {
pub identifier: String,
pub version: String,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn default_is_empty() {
let ext = ProviderExtensions::default();
assert!(ext.is_empty());
}
#[test]
fn builder_chain_attaches_each_vendor_ext() {
let ext = ProviderExtensions::default()
.with_anthropic(AnthropicExt::default().with_betas(["thinking-2025"]))
.with_openai_chat(OpenAiChatExt::default().with_cache_key("user-42"))
.with_gemini(GeminiExt {
candidate_count: Some(2),
..Default::default()
})
.with_bedrock(BedrockExt {
guardrail: Some(BedrockGuardrail {
identifier: "abc-123".into(),
version: "1".into(),
}),
..Default::default()
});
assert!(!ext.is_empty());
assert_eq!(
ext.anthropic.as_ref().unwrap().betas,
vec!["thinking-2025".to_owned()]
);
assert_eq!(
ext.openai_chat.as_ref().unwrap().cache_key.as_deref(),
Some("user-42")
);
assert_eq!(ext.gemini.as_ref().unwrap().candidate_count, Some(2));
assert_eq!(
ext.bedrock
.as_ref()
.unwrap()
.guardrail
.as_ref()
.unwrap()
.identifier,
"abc-123"
);
}
#[test]
fn provider_extensions_serde_round_trip() {
let ext = ProviderExtensions::default()
.with_anthropic(AnthropicExt::default().with_betas(["computer-use-2025"]))
.with_gemini(GeminiExt {
safety_settings: vec![GeminiSafetyOverride {
category: "HARM_CATEGORY_HATE_SPEECH".into(),
threshold: "BLOCK_LOW_AND_ABOVE".into(),
}],
candidate_count: None,
cached_content: None,
url_context: None,
});
let s = serde_json::to_string(&ext).unwrap();
let back: ProviderExtensions = serde_json::from_str(&s).unwrap();
assert_eq!(ext, back);
}
#[test]
fn empty_serialization_omits_inactive_vendor_keys() {
let ext = ProviderExtensions::default();
let s = serde_json::to_string(&ext).unwrap();
assert_eq!(s, "{}");
}
}