use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
const OPENAI_PROMPT_CACHE_KEY_LIMIT: usize = 64;
#[must_use]
pub fn format_openai_prompt_cache_key(affinity_id: &str) -> Option<String> {
let mut key = String::from("sw_");
for ch in affinity_id.trim().chars() {
if key.len() >= OPENAI_PROMPT_CACHE_KEY_LIMIT {
break;
}
key.push(
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
ch
} else {
'_'
},
);
}
(key.len() > "sw_".len()).then_some(key)
}
#[must_use]
pub fn supports_automatic_openai_prompt_cache_key(model_name: &str) -> bool {
let model = model_name.trim().to_ascii_lowercase();
model.starts_with("gpt-")
|| model.starts_with("chatgpt-")
|| model.starts_with("o1")
|| model.starts_with("o3")
|| model.starts_with("o4")
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ModelSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stop_sequences: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub logit_bias: BTreeMap<String, i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking: Option<ThinkingSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_replay: Option<ProviderReplaySettings>,
#[serde(default, skip_serializing_if = "ProviderSettings::is_empty")]
pub provider_settings: ProviderSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_options: Option<Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra_headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub extra_body: Map<String, Value>,
}
impl ModelSettings {
#[must_use]
pub fn merge(&self, overlay: &Self) -> Self {
Self {
max_tokens: overlay.max_tokens.or(self.max_tokens),
temperature: overlay.temperature.or(self.temperature),
top_p: overlay.top_p.or(self.top_p),
top_k: overlay.top_k.or(self.top_k),
timeout_ms: overlay.timeout_ms.or(self.timeout_ms),
parallel_tool_calls: overlay.parallel_tool_calls.or(self.parallel_tool_calls),
tool_choice: overlay
.tool_choice
.clone()
.or_else(|| self.tool_choice.clone()),
seed: overlay.seed.or(self.seed),
stop_sequences: if overlay.stop_sequences.is_empty() {
self.stop_sequences.clone()
} else {
overlay.stop_sequences.clone()
},
presence_penalty: overlay.presence_penalty.or(self.presence_penalty),
frequency_penalty: overlay.frequency_penalty.or(self.frequency_penalty),
logit_bias: if overlay.logit_bias.is_empty() {
self.logit_bias.clone()
} else {
let mut logit_bias = self.logit_bias.clone();
logit_bias.extend(overlay.logit_bias.clone());
logit_bias
},
thinking: overlay.thinking.clone().or_else(|| self.thinking.clone()),
service_tier: overlay
.service_tier
.clone()
.or_else(|| self.service_tier.clone()),
provider_settings: self.provider_settings.merge(&overlay.provider_settings),
provider_replay: overlay
.provider_replay
.clone()
.or_else(|| self.provider_replay.clone()),
provider_options: overlay
.provider_options
.clone()
.or_else(|| self.provider_options.clone()),
extra_headers: if overlay.extra_headers.is_empty() {
self.extra_headers.clone()
} else {
overlay_headers_case_insensitive(&self.extra_headers, &overlay.extra_headers)
},
extra_body: if overlay.extra_body.is_empty() {
self.extra_body.clone()
} else {
let mut body = self.extra_body.clone();
body.extend(overlay.extra_body.clone());
body
},
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ProviderSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openai_chat: Option<OpenAiChatSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openai_responses: Option<OpenAiResponsesSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anthropic: Option<AnthropicSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub google: Option<GoogleSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bedrock: Option<BedrockSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex: Option<CodexSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<GatewaySettings>,
}
impl ProviderSettings {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.openai_chat.is_none()
&& self.openai_responses.is_none()
&& self.anthropic.is_none()
&& self.google.is_none()
&& self.bedrock.is_none()
&& self.codex.is_none()
&& self.gateway.is_none()
}
#[must_use]
pub fn merge(&self, overlay: &Self) -> Self {
Self {
openai_chat: merge_openai_chat(self.openai_chat.as_ref(), overlay.openai_chat.as_ref()),
openai_responses: merge_openai_responses(
self.openai_responses.as_ref(),
overlay.openai_responses.as_ref(),
),
anthropic: merge_anthropic(self.anthropic.as_ref(), overlay.anthropic.as_ref()),
google: merge_google(self.google.as_ref(), overlay.google.as_ref()),
bedrock: merge_bedrock(self.bedrock.as_ref(), overlay.bedrock.as_ref()),
codex: merge_codex(self.codex.as_ref(), overlay.codex.as_ref()),
gateway: merge_gateway(self.gateway.as_ref(), overlay.gateway.as_ref()),
}
}
}
fn merge_option<T, F>(base: Option<&T>, overlay: Option<&T>, merge: F) -> Option<T>
where
T: Clone,
F: FnOnce(&T, &T) -> T,
{
match (base, overlay) {
(Some(base), Some(overlay)) => Some(merge(base, overlay)),
(Some(base), None) => Some(base.clone()),
(None, Some(overlay)) => Some(overlay.clone()),
(None, None) => None,
}
}
fn overlay_vec<T: Clone>(base: &[T], overlay: &[T]) -> Vec<T> {
if overlay.is_empty() {
base.to_vec()
} else {
overlay.to_vec()
}
}
fn overlay_headers_case_insensitive(
base: &BTreeMap<String, String>,
overlay: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut merged = base.clone();
for (key, value) in overlay {
merged.retain(|existing, _| !existing.eq_ignore_ascii_case(key));
merged.insert(key.clone(), value.clone());
}
merged
}
fn merge_openai_chat(
base: Option<&OpenAiChatSettings>,
overlay: Option<&OpenAiChatSettings>,
) -> Option<OpenAiChatSettings> {
merge_option(base, overlay, |base, overlay| OpenAiChatSettings {
user: overlay.user.clone().or_else(|| base.user.clone()),
store: overlay.store.or(base.store),
logprobs: overlay.logprobs.or(base.logprobs),
top_logprobs: overlay.top_logprobs.or(base.top_logprobs),
prediction: overlay
.prediction
.clone()
.or_else(|| base.prediction.clone()),
prompt_cache_key: overlay
.prompt_cache_key
.clone()
.or_else(|| base.prompt_cache_key.clone()),
prompt_cache_retention: overlay
.prompt_cache_retention
.clone()
.or_else(|| base.prompt_cache_retention.clone()),
})
}
fn merge_openai_responses(
base: Option<&OpenAiResponsesSettings>,
overlay: Option<&OpenAiResponsesSettings>,
) -> Option<OpenAiResponsesSettings> {
merge_option(base, overlay, |base, overlay| OpenAiResponsesSettings {
store: overlay.store.or(base.store),
user: overlay.user.clone().or_else(|| base.user.clone()),
truncation: overlay
.truncation
.clone()
.or_else(|| base.truncation.clone()),
text_verbosity: overlay
.text_verbosity
.clone()
.or_else(|| base.text_verbosity.clone()),
context_management: overlay
.context_management
.clone()
.or_else(|| base.context_management.clone()),
include: overlay_vec(&base.include, &overlay.include),
prompt_cache_key: overlay
.prompt_cache_key
.clone()
.or_else(|| base.prompt_cache_key.clone()),
prompt_cache_retention: overlay
.prompt_cache_retention
.clone()
.or_else(|| base.prompt_cache_retention.clone()),
})
}
fn merge_anthropic(
base: Option<&AnthropicSettings>,
overlay: Option<&AnthropicSettings>,
) -> Option<AnthropicSettings> {
merge_option(base, overlay, |base, overlay| AnthropicSettings {
metadata: overlay.metadata.clone().or_else(|| base.metadata.clone()),
betas: overlay_vec(&base.betas, &overlay.betas),
context_management: overlay
.context_management
.clone()
.or_else(|| base.context_management.clone()),
container: overlay.container.clone().or_else(|| base.container.clone()),
service_tier: overlay
.service_tier
.clone()
.or_else(|| base.service_tier.clone()),
})
}
fn merge_google(
base: Option<&GoogleSettings>,
overlay: Option<&GoogleSettings>,
) -> Option<GoogleSettings> {
merge_option(base, overlay, |base, overlay| GoogleSettings {
safety_settings: overlay
.safety_settings
.clone()
.or_else(|| base.safety_settings.clone()),
cached_content: overlay
.cached_content
.clone()
.or_else(|| base.cached_content.clone()),
labels: overlay.labels.clone().or_else(|| base.labels.clone()),
response_logprobs: overlay.response_logprobs.or(base.response_logprobs),
logprobs: overlay.logprobs.or(base.logprobs),
service_tier: overlay
.service_tier
.clone()
.or_else(|| base.service_tier.clone()),
})
}
fn merge_bedrock(
base: Option<&BedrockSettings>,
overlay: Option<&BedrockSettings>,
) -> Option<BedrockSettings> {
merge_option(base, overlay, |base, overlay| BedrockSettings {
guardrail_config: overlay
.guardrail_config
.clone()
.or_else(|| base.guardrail_config.clone()),
performance_config: overlay
.performance_config
.clone()
.or_else(|| base.performance_config.clone()),
request_metadata: overlay
.request_metadata
.clone()
.or_else(|| base.request_metadata.clone()),
additional_model_response_field_paths: overlay_vec(
&base.additional_model_response_field_paths,
&overlay.additional_model_response_field_paths,
),
prompt_variables: overlay
.prompt_variables
.clone()
.or_else(|| base.prompt_variables.clone()),
additional_model_request_fields: overlay
.additional_model_request_fields
.clone()
.or_else(|| base.additional_model_request_fields.clone()),
inference_profile: overlay
.inference_profile
.clone()
.or_else(|| base.inference_profile.clone()),
})
}
fn merge_codex(
base: Option<&CodexSettings>,
overlay: Option<&CodexSettings>,
) -> Option<CodexSettings> {
merge_option(base, overlay, |base, overlay| CodexSettings {
session_id: overlay
.session_id
.clone()
.or_else(|| base.session_id.clone()),
thread_id: overlay.thread_id.clone().or_else(|| base.thread_id.clone()),
})
}
fn merge_gateway(
base: Option<&GatewaySettings>,
overlay: Option<&GatewaySettings>,
) -> Option<GatewaySettings> {
merge_option(base, overlay, |base, overlay| GatewaySettings {
x_session_id: overlay
.x_session_id
.clone()
.or_else(|| base.x_session_id.clone()),
extra_headers: overlay_headers_case_insensitive(
&base.extra_headers,
&overlay.extra_headers,
),
})
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct OpenAiChatSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logprobs: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_logprobs: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prediction: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_retention: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct OpenAiResponsesSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub truncation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_verbosity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_management: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_retention: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct AnthropicSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub betas: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_management: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct GoogleSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub safety_settings: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cached_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_logprobs: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logprobs: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct BedrockSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guardrail_config: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub performance_config: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_model_response_field_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_variables: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_model_request_fields: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inference_profile: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct CodexSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct GatewaySettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x_session_id: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra_headers: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ProviderReplaySettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send_item_ids: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_encrypted_reasoning: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum ToolChoice {
Auto,
None,
Required,
Tools {
names: Vec<String>,
},
ToolOrOutput {
function_tools: Vec<String>,
},
Tool {
name: String,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ThinkingSettings {
pub effort: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_thoughts: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceTier {
Auto,
Default,
Flex,
Priority,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_overlay_fields() {
let base = ModelSettings {
max_tokens: Some(128),
temperature: Some(0.2),
stop_sequences: vec!["base".to_string()],
..ModelSettings::default()
};
let overlay = ModelSettings {
temperature: Some(0.7),
stop_sequences: vec!["overlay".to_string()],
..ModelSettings::default()
};
let merged = base.merge(&overlay);
assert_eq!(merged.max_tokens, Some(128));
assert_eq!(merged.temperature, Some(0.7));
assert_eq!(merged.stop_sequences, vec!["overlay"]);
}
}