Skip to main content

cookie_agent_models/adapters/
oven.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashMap},
3    env,
4    sync::{Arc, Mutex, OnceLock, PoisonError},
5    time::Duration,
6};
7
8use http::{HeaderMap, HeaderName, HeaderValue};
9use oven_sdk::{
10    AdapterId, ApiEndpoint, HeaderConfig, HeaderContext, HeaderOverrides, HeaderProvider,
11    LanguageModel, ModelCapabilities, ModelConfig, ModelDeclaration, ModelError, ModelId,
12    ProviderConfig, ProviderId, ResourceId, SecretString,
13};
14use oven_sdk_anthropic::{
15    AnthropicAuth, AnthropicCacheControl, AnthropicCacheTtl, AnthropicCompatibleAuth,
16    AnthropicCompatibleModel, AnthropicCompatibleSettings, AnthropicModel,
17    AnthropicProtocolSettings, AnthropicRequestOptions, AnthropicSettings, AnthropicThinking,
18    AnthropicThinkingSupport, AnthropicTimeouts,
19};
20use oven_sdk_azure::{
21    AzureApiRoute, AzureApiVersion, AzureMaxTokensField, AzureOpenAiAuth, AzureOpenAiChatModel,
22    AzureOpenAiChatOptions, AzureOpenAiChatSettings, AzureOpenAiCompletionsConfig,
23    AzureOpenAiResponsesCompaction, AzureOpenAiResponsesModel, AzureOpenAiResponsesOptions,
24    AzureOpenAiResponsesSettings, AzureOpenAiRevision, AzureOpenAiTimeouts, AzureReasoningField,
25    AzureStructuredOutputSupport, AzureSystemMessageRole,
26};
27use oven_sdk_bedrock::{
28    AwsCredentials, BedrockAuth, BedrockConverseSettings, BedrockEventStreamLimits,
29    BedrockGuardrailConfig, BedrockModel, BedrockReasoningWireFormat, BedrockRequestOptions,
30    BedrockS3LocationOptions, BedrockStructuredOutput, BedrockTimeouts,
31};
32use oven_sdk_cohere::{
33    CohereAuth, CohereDocument, CohereModel, CohereRequestOptions, CohereSettings, CohereThinking,
34    CohereTimeouts,
35};
36use oven_sdk_google::{
37    GoogleApiKeyAuth, GoogleGenerateContentSettings, GoogleModel, GoogleProviderTool,
38    GoogleRequestOptions, GoogleSafetySetting, GoogleThinkingConfig, GoogleThinkingSettings,
39    GoogleTimeouts, GoogleToolSettings,
40};
41use oven_sdk_google_vertex::{
42    GoogleVertexMediaSettings, GoogleVertexModel, GoogleVertexProviderTool,
43    GoogleVertexRequestOptions, GoogleVertexResource, GoogleVertexSafetySetting,
44    GoogleVertexSettings, GoogleVertexThinkingConfig, GoogleVertexThinkingMode,
45    GoogleVertexTimeouts, GoogleVertexToolSettings, VertexAuth, google_vertex_native_context_scope,
46};
47use oven_sdk_openai::{
48    CompatibleChatOptions, MaxTokensField, OpenAiAuth, OpenAiChatModel, OpenAiChatOptions,
49    OpenAiChatSettings, OpenAiCompatibleAuth, OpenAiCompatibleChatModel,
50    OpenAiCompatibleChatSettings, OpenAiResponsesCompaction, OpenAiResponsesModel,
51    OpenAiResponsesOptions, OpenAiResponsesSettings, OpenAiTimeouts, ReasoningField,
52    StructuredOutputSupport, SystemMessageRole,
53};
54use serde::{Deserialize, Serialize};
55use serde_json::{Map, Value, json};
56use sha2::{Digest as _, Sha256};
57use thiserror::Error;
58use zeroize::Zeroize as _;
59
60use crate::ConstructedAdapter;
61
62/// Internal fully concrete declaration consumed by one reviewed Oven constructor.
63#[derive(Clone, Serialize)]
64pub struct ConcreteModel {
65    /// Explicit serving-provider identity.
66    pub provider_id: String,
67    /// Exact model, deployment, or resource identifier.
68    pub model_id: String,
69    /// Exact base or full API endpoint required by the selected adapter.
70    pub endpoint: String,
71    /// Explicit resolved authentication. Oven never reads the environment.
72    pub auth: AuthConfig,
73    /// Static caller headers. Values may be interpolated by the config crate only.
74    #[serde(default)]
75    pub headers: BTreeMap<String, String>,
76    /// Complete explicit Oven capabilities, limits, modalities, media, cancellation,
77    /// native compaction, and replay.
78    pub capabilities: ModelCapabilities,
79    /// Common and provider request defaults.
80    #[serde(default)]
81    pub defaults: CommonDefaults,
82    /// Concrete adapter and all structural/request settings.
83    #[serde(flatten)]
84    pub adapter: AdapterConfig,
85}
86
87impl std::fmt::Debug for ConcreteModel {
88    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        formatter
90            .debug_struct("ConcreteModel")
91            .field("provider_id", &self.provider_id)
92            .field("model_id", &self.model_id)
93            .field("endpoint", &"<redacted>")
94            .field("auth", &"<redacted>")
95            .field("header_names", &self.headers.keys().collect::<Vec<_>>())
96            .field("capabilities", &self.capabilities)
97            .field("defaults", &self.defaults)
98            .field("adapter", &self.adapter)
99            .finish()
100    }
101}
102
103impl ConcreteModel {
104    /// Constructs one exact concrete Oven model and immutable request defaults.
105    pub fn build(&self) -> Result<ConstructedAdapter, ModelBuildError> {
106        let provider = self.provider()?;
107        let header_routing_discriminator =
108            (!self.headers.is_empty()).then(|| header_routing_discriminator(&self.headers));
109        let mut capabilities = self.capabilities.clone();
110        capabilities.compaction = match &self.adapter {
111            AdapterConfig::OpenaiResponses { settings, .. }
112                if matches!(
113                    settings.compaction,
114                    OpenAiResponsesCompactionConfig::OpenAiResponsesCompact
115                ) =>
116            {
117                oven_sdk::CompactionCapability::Native
118            }
119            AdapterConfig::AzureResponses { settings, .. }
120                if matches!(
121                    settings.compaction,
122                    AzureResponsesCompactionConfig::AzureResponsesCompact { .. }
123                ) =>
124            {
125                oven_sdk::CompactionCapability::Native
126            }
127            _ => oven_sdk::CompactionCapability::Unsupported,
128        };
129        let declaration = ModelDeclaration::new(ModelId::new(self.model_id.clone()), capabilities)?;
130        let (model, provider_options): (Arc<dyn LanguageModel>, BTreeMap<String, Value>) =
131            match &self.adapter {
132                AdapterConfig::Anthropic { settings, options } => (
133                    Arc::new(AnthropicModel::new(ModelConfig::new(
134                        provider.with_auth(self.anthropic_auth()?),
135                        declaration,
136                        settings.to_oven()?,
137                    ))?),
138                    namespace("anthropic", options.to_oven()?)?,
139                ),
140                AdapterConfig::AnthropicCompatible { settings, options } => (
141                    Arc::new(AnthropicCompatibleModel::new(ModelConfig::new(
142                        provider.with_auth(self.anthropic_compatible_auth()?),
143                        declaration,
144                        settings.to_oven()?,
145                    ))?),
146                    namespace("anthropic", options.to_oven()?)?,
147                ),
148                AdapterConfig::OpenaiChat { settings, options }
149                    if matches!(self.auth, AuthConfig::None) =>
150                {
151                    (
152                        Arc::new(OpenAiChatModel::new_no_auth(ModelConfig::new(
153                            provider.with_auth(()),
154                            declaration,
155                            settings.to_openai_chat(header_routing_discriminator.as_deref()),
156                        ))?),
157                        namespace("openai", json!({ "chat": options }))?,
158                    )
159                }
160                AdapterConfig::OpenaiChat { settings, options } => (
161                    Arc::new(OpenAiChatModel::new(ModelConfig::new(
162                        provider.with_auth(self.openai_auth()?),
163                        declaration,
164                        settings.to_openai_chat(header_routing_discriminator.as_deref()),
165                    ))?),
166                    namespace("openai", json!({ "chat": options }))?,
167                ),
168                AdapterConfig::OpenaiResponses { settings, options } => (
169                    Arc::new(OpenAiResponsesModel::new(ModelConfig::new(
170                        provider.with_auth(self.openai_auth()?),
171                        declaration,
172                        settings.to_openai_responses(header_routing_discriminator.as_deref()),
173                    ))?),
174                    namespace("openai", json!({ "responses": options }))?,
175                ),
176                AdapterConfig::CompatibleResponses {
177                    adapter_id,
178                    settings,
179                    options,
180                } => (
181                    Arc::new(OpenAiResponsesModel::new_compatible(
182                        ModelConfig::new(
183                            provider.with_auth(self.compatible_auth()?),
184                            declaration,
185                            settings.to_openai_responses(header_routing_discriminator.as_deref()),
186                        ),
187                        oven_sdk::AdapterId::new(adapter_id.clone()),
188                    )?),
189                    namespace("openai", json!({ "responses": options }))?,
190                ),
191                AdapterConfig::OpenaiCompatible { settings, options } => (
192                    Arc::new(OpenAiCompatibleChatModel::new(ModelConfig::new(
193                        provider.with_auth(self.compatible_auth()?),
194                        declaration,
195                        settings.to_oven(header_routing_discriminator.as_deref()),
196                    ))?),
197                    namespace(
198                        "openai_compatible",
199                        CompatibleChatOptions {
200                            extra_body: options.extra_body.clone(),
201                        },
202                    )?,
203                ),
204                AdapterConfig::Google { settings, options } => (
205                    Arc::new(GoogleModel::new(ModelConfig::new(
206                        provider.with_auth(self.google_auth()?),
207                        declaration,
208                        settings.to_oven(),
209                    ))?),
210                    namespace("google", options.to_oven())?,
211                ),
212                AdapterConfig::Vertex { settings, options } => {
213                    let provider = provider.with_auth(self.vertex_auth()?);
214                    let settings = settings.to_oven(&provider, &declaration)?;
215                    (
216                        Arc::new(GoogleVertexModel::new(ModelConfig::new(
217                            provider,
218                            declaration,
219                            settings,
220                        ))?),
221                        namespace("google_vertex", options.to_oven())?,
222                    )
223                }
224                AdapterConfig::Bedrock { settings, options } => (
225                    Arc::new(BedrockModel::new(ModelConfig::new(
226                        provider.with_auth(self.bedrock_auth()?),
227                        declaration,
228                        settings.to_oven(),
229                    ))?),
230                    namespace("bedrock", options.to_oven())?,
231                ),
232                AdapterConfig::AzureChat { settings, options } => (
233                    Arc::new(AzureOpenAiChatModel::new(ModelConfig::new(
234                        provider.with_auth(self.azure_auth()?),
235                        declaration,
236                        settings.to_chat()?,
237                    ))?),
238                    namespace("azure_openai", json!({ "chat": options }))?,
239                ),
240                AdapterConfig::AzureResponses { settings, options } => (
241                    Arc::new(AzureOpenAiResponsesModel::new(ModelConfig::new(
242                        provider.with_auth(self.azure_auth()?),
243                        declaration,
244                        settings.to_responses()?,
245                    ))?),
246                    namespace("azure_openai", json!({ "responses": options }))?,
247                ),
248                AdapterConfig::Cohere { settings, options } => (
249                    Arc::new(CohereModel::new(ModelConfig::new(
250                        provider.with_auth(self.cohere_auth()?),
251                        declaration,
252                        settings.to_oven(),
253                    ))?),
254                    namespace("cohere", options.to_oven())?,
255                ),
256            };
257        Ok(ConstructedAdapter {
258            model,
259            provider_options,
260        })
261    }
262
263    fn provider(&self) -> Result<CommonProvider, ModelBuildError> {
264        Ok(CommonProvider {
265            id: ProviderId::new(self.provider_id.clone()),
266            api: ApiEndpoint::parse(&self.endpoint)?,
267            headers: header_config(&self.headers)?,
268        })
269    }
270
271    fn anthropic_auth(&self) -> Result<AnthropicAuth, ModelBuildError> {
272        match &self.auth {
273            AuthConfig::None => Ok(AnthropicAuth::None),
274            AuthConfig::ApiKey { value } => Ok(AnthropicAuth::ApiKey(secret(value))),
275            _ => Err(wrong_auth("anthropic", "none or api_key")),
276        }
277    }
278
279    fn anthropic_compatible_auth(&self) -> Result<AnthropicCompatibleAuth, ModelBuildError> {
280        match &self.auth {
281            AuthConfig::None => Ok(AnthropicCompatibleAuth::None),
282            AuthConfig::ApiKey { value } => Ok(AnthropicCompatibleAuth::ApiKey(secret(value))),
283            AuthConfig::Bearer { token } => Ok(AnthropicCompatibleAuth::Bearer(secret(token))),
284            _ => Err(wrong_auth(
285                "Anthropic-compatible",
286                "none, api_key, or bearer",
287            )),
288        }
289    }
290
291    fn openai_auth(&self) -> Result<OpenAiAuth, ModelBuildError> {
292        match &self.auth {
293            AuthConfig::Openai {
294                api_key,
295                organization,
296                project,
297            } => Ok(OpenAiAuth {
298                api_key: secret(api_key),
299                organization: organization.clone(),
300                project: project.clone(),
301            }),
302            _ => Err(wrong_auth("OpenAI", "openai")),
303        }
304    }
305
306    fn compatible_auth(&self) -> Result<OpenAiCompatibleAuth, ModelBuildError> {
307        match &self.auth {
308            AuthConfig::None => Ok(OpenAiCompatibleAuth::none()),
309            AuthConfig::Bearer { token } => Ok(OpenAiCompatibleAuth::bearer(secret(token))),
310            AuthConfig::HeaderApiKey { name, value } => Ok(OpenAiCompatibleAuth::headers(
311                Arc::new(SecretHeaderProvider {
312                    name: name.clone(),
313                    value: secret(value),
314                }),
315            )),
316            _ => Err(wrong_auth(
317                "OpenAI-compatible",
318                "none, bearer, or header_api_key",
319            )),
320        }
321    }
322
323    fn google_auth(&self) -> Result<GoogleApiKeyAuth, ModelBuildError> {
324        match &self.auth {
325            AuthConfig::ApiKey { value } => Ok(GoogleApiKeyAuth::new(value.clone())),
326            _ => Err(wrong_auth("Google", "api_key")),
327        }
328    }
329
330    fn vertex_auth(&self) -> Result<VertexAuth, ModelBuildError> {
331        match &self.auth {
332            AuthConfig::AccessToken { token } => Ok(VertexAuth::AccessToken(secret(token))),
333            _ => Err(wrong_auth("Vertex", "access_token")),
334        }
335    }
336
337    fn bedrock_auth(&self) -> Result<BedrockAuth, ModelBuildError> {
338        match &self.auth {
339            AuthConfig::AwsStatic {
340                access_key_id,
341                secret_access_key,
342                session_token,
343            } => Ok(BedrockAuth::Static(AwsCredentials {
344                access_key_id: access_key_id.clone(),
345                secret_access_key: secret_access_key.clone(),
346                session_token: session_token.clone(),
347            })),
348            _ => Err(wrong_auth("Bedrock", "aws_static")),
349        }
350    }
351
352    fn azure_auth(&self) -> Result<AzureOpenAiAuth, ModelBuildError> {
353        match &self.auth {
354            AuthConfig::ApiKey { value } => Ok(AzureOpenAiAuth::ApiKey(secret(value))),
355            AuthConfig::Bearer { token } => {
356                let token = token.clone();
357                Ok(AzureOpenAiAuth::Entra(Arc::new(move || {
358                    let token = token.clone();
359                    Box::pin(async move { Ok(token) })
360                })))
361            }
362            _ => Err(wrong_auth("Azure OpenAI", "api_key or bearer")),
363        }
364    }
365
366    fn cohere_auth(&self) -> Result<CohereAuth, ModelBuildError> {
367        match &self.auth {
368            AuthConfig::Bearer { token } => Ok(CohereAuth::bearer(secret(token))),
369            _ => Err(wrong_auth("Cohere", "bearer")),
370        }
371    }
372}
373
374#[derive(Clone)]
375struct CommonProvider {
376    id: ProviderId,
377    api: ApiEndpoint,
378    headers: HeaderConfig,
379}
380
381impl CommonProvider {
382    fn with_auth<A>(&self, auth: A) -> ProviderConfig<A> {
383        ProviderConfig::new(
384            self.id.clone(),
385            self.api.clone(),
386            auth,
387            self.headers.clone(),
388        )
389        .expect("common provider identity was validated")
390    }
391}
392
393/// Resolved authentication forms supported by static TOML configuration.
394#[derive(Clone, Deserialize, Serialize)]
395#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
396pub enum AuthConfig {
397    /// No adapter-injected authentication.
398    None,
399    /// API-key authentication.
400    ApiKey { value: String },
401    /// Bearer authentication.
402    Bearer { token: String },
403    /// Caller-selected reviewed API-key header authentication.
404    HeaderApiKey { name: String, value: String },
405    /// Official OpenAI bearer authentication and account headers.
406    Openai {
407        api_key: String,
408        organization: Option<String>,
409        project: Option<String>,
410    },
411    /// Static OAuth access token.
412    AccessToken { token: String },
413    /// Static AWS credentials.
414    AwsStatic {
415        access_key_id: String,
416        secret_access_key: String,
417        session_token: Option<String>,
418    },
419}
420
421impl Drop for AuthConfig {
422    fn drop(&mut self) {
423        match self {
424            Self::None => {}
425            Self::ApiKey { value } => value.zeroize(),
426            Self::Bearer { token } | Self::AccessToken { token } => token.zeroize(),
427            Self::HeaderApiKey { value, .. } => value.zeroize(),
428            Self::Openai { api_key, .. } => api_key.zeroize(),
429            Self::AwsStatic {
430                access_key_id,
431                secret_access_key,
432                session_token,
433            } => {
434                access_key_id.zeroize();
435                secret_access_key.zeroize();
436                if let Some(session_token) = session_token {
437                    session_token.zeroize();
438                }
439            }
440        }
441    }
442}
443
444#[derive(Clone)]
445struct SecretHeaderProvider {
446    name: String,
447    value: SecretString,
448}
449
450impl HeaderProvider for SecretHeaderProvider {
451    fn headers(&self, _context: &HeaderContext) -> Result<HeaderOverrides, ModelError> {
452        let name = HeaderName::from_bytes(self.name.as_bytes())
453            .map_err(|_| ModelError::invalid_request("invalid API-key header name"))?;
454        let value = HeaderValue::from_str(self.value.expose_secret())
455            .map_err(|_| ModelError::invalid_request("invalid API-key header value"))?;
456        Ok(HeaderOverrides::new(HeaderMap::from_iter([(name, value)])))
457    }
458}
459
460#[derive(Clone)]
461pub(crate) struct TemplateHeaderProvider {
462    templates: Vec<(HeaderName, HeaderTemplate)>,
463}
464
465#[derive(Clone)]
466enum HeaderTemplate {
467    Static(Option<HeaderValue>),
468    Dynamic(Vec<HeaderTemplateSegment>),
469}
470
471#[derive(Clone)]
472enum HeaderTemplateSegment {
473    Literal(String),
474    SessionId,
475    ParentSessionId,
476    Env {
477        name: String,
478        default: Option<String>,
479    },
480}
481
482impl TemplateHeaderProvider {
483    pub(crate) fn new(values: &BTreeMap<String, String>) -> Result<Self, ModelBuildError> {
484        let templates = values
485            .iter()
486            .map(|(name, value)| {
487                let header_name = HeaderName::from_bytes(name.as_bytes())
488                    .map_err(|_| ModelBuildError::HeaderName(name.clone()))?;
489                let template = parse_header_template(value)
490                    .map_err(|()| ModelBuildError::HeaderTemplate(name.clone()))?;
491                let template = match template {
492                    ParsedHeaderTemplate::Static(value) => HeaderTemplate::Static(
493                        (!value.is_empty())
494                            .then(|| HeaderValue::from_str(&value))
495                            .transpose()
496                            .map_err(|_| ModelBuildError::HeaderValue(name.clone()))?,
497                    ),
498                    ParsedHeaderTemplate::Dynamic(segments) => HeaderTemplate::Dynamic(segments),
499                };
500                Ok((header_name, template))
501            })
502            .collect::<Result<_, ModelBuildError>>()?;
503        Ok(Self { templates })
504    }
505
506    pub(crate) fn resolved_headers(
507        &self,
508        context: &HeaderContext,
509    ) -> Result<HeaderMap, &'static str> {
510        let mut headers = HeaderMap::new();
511        for (name, template) in &self.templates {
512            let value = match template {
513                HeaderTemplate::Static(value) => value.clone(),
514                HeaderTemplate::Dynamic(segments) => {
515                    let value = resolve_header_template(segments, context)?;
516                    (!value.is_empty())
517                        .then(|| HeaderValue::from_str(&value))
518                        .transpose()
519                        .map_err(|_| "invalid configured header value")?
520                }
521            };
522            if let Some(value) = value {
523                headers.insert(name.clone(), value);
524            }
525        }
526        Ok(headers)
527    }
528}
529
530impl HeaderProvider for TemplateHeaderProvider {
531    fn headers(&self, context: &HeaderContext) -> Result<HeaderOverrides, ModelError> {
532        self.resolved_headers(context)
533            .map(HeaderOverrides::new)
534            .map_err(ModelError::invalid_request)
535    }
536}
537
538enum ParsedHeaderTemplate {
539    Static(String),
540    Dynamic(Vec<HeaderTemplateSegment>),
541}
542
543fn parse_header_template(template: &str) -> Result<ParsedHeaderTemplate, ()> {
544    let mut segments = Vec::new();
545    let mut literal = String::new();
546    let mut cursor = 0;
547    let mut dynamic = false;
548    let push_literal = |segments: &mut Vec<HeaderTemplateSegment>, literal: &mut String| {
549        if !literal.is_empty() {
550            segments.push(HeaderTemplateSegment::Literal(std::mem::take(literal)));
551        }
552    };
553    while cursor < template.len() {
554        let remaining = &template[cursor..];
555        if let Some(text) = remaining.strip_prefix("$$") {
556            literal.push('$');
557            cursor = template.len() - text.len();
558        } else if let Some(text) = remaining.strip_prefix("${session_id}") {
559            push_literal(&mut segments, &mut literal);
560            segments.push(HeaderTemplateSegment::SessionId);
561            dynamic = true;
562            cursor = template.len() - text.len();
563        } else if let Some(text) = remaining.strip_prefix("${parent_session_id}") {
564            push_literal(&mut segments, &mut literal);
565            segments.push(HeaderTemplateSegment::ParentSessionId);
566            dynamic = true;
567            cursor = template.len() - text.len();
568        } else if let Some(expression) = remaining.strip_prefix("${env:") {
569            let end = expression.find('}').ok_or(())?;
570            let expression_body = &expression[..end];
571            let (name, default) = expression_body
572                .split_once(":-")
573                .map_or((expression_body, None), |(name, default)| {
574                    (name, Some(default))
575                });
576            if !valid_env_name(name) {
577                return Err(());
578            }
579            push_literal(&mut segments, &mut literal);
580            segments.push(HeaderTemplateSegment::Env {
581                name: name.to_owned(),
582                default: default.map(str::to_owned),
583            });
584            dynamic = true;
585            cursor += 6 + end + 1;
586        } else {
587            let character = remaining.chars().next().expect("nonempty remainder");
588            literal.push(character);
589            cursor += character.len_utf8();
590        }
591    }
592    if !dynamic {
593        return Ok(ParsedHeaderTemplate::Static(literal));
594    }
595    push_literal(&mut segments, &mut literal);
596    Ok(ParsedHeaderTemplate::Dynamic(segments))
597}
598
599fn resolve_header_template(
600    segments: &[HeaderTemplateSegment],
601    context: &HeaderContext,
602) -> Result<String, &'static str> {
603    let mut output = String::new();
604    for segment in segments {
605        match segment {
606            HeaderTemplateSegment::Literal(literal) => output.push_str(literal),
607            HeaderTemplateSegment::SessionId => output.push_str(&context.session_id),
608            HeaderTemplateSegment::ParentSessionId => {
609                output.push_str(context.parent_session_id.as_deref().unwrap_or_default());
610            }
611            HeaderTemplateSegment::Env { name, default } => match env::var(name) {
612                Ok(value) => output.push_str(&value),
613                Err(env::VarError::NotPresent) => {
614                    output.push_str(
615                        default
616                            .as_deref()
617                            .ok_or("missing header environment variable")?,
618                    );
619                }
620                Err(env::VarError::NotUnicode(_)) => {
621                    return Err("header environment variable is not UTF-8");
622                }
623            },
624        }
625    }
626    Ok(output)
627}
628
629fn valid_env_name(name: &str) -> bool {
630    !name.is_empty()
631        && (name.as_bytes()[0].is_ascii_uppercase() || name.as_bytes()[0] == b'_')
632        && name
633            .bytes()
634            .skip(1)
635            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
636}
637
638/// Common normalized request defaults.
639#[derive(Clone, Debug, Default, Deserialize, Serialize)]
640#[serde(deny_unknown_fields)]
641pub struct CommonDefaults {
642    pub max_output_tokens: Option<u64>,
643    pub temperature: Option<f64>,
644    pub top_p: Option<f64>,
645    pub reasoning_effort: Option<String>,
646    #[serde(default)]
647    pub include_raw: bool,
648}
649
650/// All retained concrete Oven adapters. MiniMax and Claude Platform on AWS are intentionally absent.
651///
652/// Rust follows Oven's `adapter` terminology; the user-facing TOML discriminator
653/// deliberately uses the British spelling `adaptor`.
654#[derive(Clone, Debug, Deserialize, Serialize)]
655#[serde(tag = "adaptor", rename_all = "kebab-case", deny_unknown_fields)]
656pub enum AdapterConfig {
657    Anthropic {
658        settings: AnthropicSettingsConfig,
659        #[serde(default)]
660        options: AnthropicOptionsConfig,
661    },
662    AnthropicCompatible {
663        settings: AnthropicCompatibleSettingsConfig,
664        #[serde(default)]
665        options: AnthropicOptionsConfig,
666    },
667    OpenaiChat {
668        settings: OpenAiChatSettingsConfig,
669        #[serde(default)]
670        options: OpenAiChatOptionsConfig,
671    },
672    OpenaiResponses {
673        #[serde(default)]
674        settings: OpenAiResponsesSettingsConfig,
675        #[serde(default)]
676        options: OpenAiResponsesOptionsConfig,
677    },
678    OpenaiCompatible {
679        settings: CompatibleSettingsConfig,
680        #[serde(default)]
681        options: CompatibleOptionsConfig,
682    },
683    CompatibleResponses {
684        adapter_id: String,
685        #[serde(default)]
686        settings: OpenAiResponsesSettingsConfig,
687        #[serde(default)]
688        options: OpenAiResponsesOptionsConfig,
689    },
690    Google {
691        settings: GoogleSettingsConfig,
692        #[serde(default)]
693        options: GoogleOptionsConfig,
694    },
695    Vertex {
696        settings: VertexSettingsConfig,
697        #[serde(default)]
698        options: VertexOptionsConfig,
699    },
700    Bedrock {
701        settings: BedrockSettingsConfig,
702        #[serde(default)]
703        options: BedrockOptionsConfig,
704    },
705    AzureChat {
706        settings: AzureChatSettingsConfig,
707        #[serde(default)]
708        options: AzureChatOptionsConfig,
709    },
710    AzureResponses {
711        settings: AzureResponsesSettingsConfig,
712        #[serde(default)]
713        options: AzureResponsesOptionsConfig,
714    },
715    Cohere {
716        settings: CohereSettingsConfig,
717        #[serde(default)]
718        options: CohereOptionsConfig,
719    },
720}
721
722/// Concrete model construction error with redacted formatting.
723#[derive(Error)]
724pub enum ModelBuildError {
725    #[error("invalid model configuration: {0}")]
726    Oven(#[source] Box<ModelError>),
727    #[error("invalid HTTP header name `{0}`")]
728    HeaderName(String),
729    #[error("invalid HTTP header value for `{0}`")]
730    HeaderValue(String),
731    #[error("invalid HTTP header template for `{0}`")]
732    HeaderTemplate(String),
733    #[error("{adapter} adapter requires auth.type = {expected}")]
734    WrongAuth {
735        adapter: &'static str,
736        expected: &'static str,
737    },
738    #[error("could not encode provider request defaults")]
739    ProviderOptions(#[source] serde_json::Error),
740}
741
742impl From<ModelError> for ModelBuildError {
743    fn from(error: ModelError) -> Self {
744        Self::Oven(Box::new(error))
745    }
746}
747
748impl std::fmt::Debug for ModelBuildError {
749    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750        formatter
751            .debug_tuple("ModelBuildError")
752            .field(&self.to_string())
753            .finish()
754    }
755}
756
757fn wrong_auth(adapter: &'static str, expected: &'static str) -> ModelBuildError {
758    ModelBuildError::WrongAuth { adapter, expected }
759}
760
761fn secret(value: &str) -> SecretString {
762    SecretString::new(value.to_owned())
763}
764
765fn header_config(values: &BTreeMap<String, String>) -> Result<HeaderConfig, ModelBuildError> {
766    // TODO: Once cookie-agent can advance Oven without a release-integrity
767    // ripple, consume dynamic HeaderOverrides with into_map instead of cloning.
768    Ok(HeaderConfig {
769        static_headers: HeaderOverrides::new(HeaderMap::new()),
770        dynamic_headers: if values.is_empty() {
771            None
772        } else {
773            Some(Arc::new(TemplateHeaderProvider::new(values)?))
774        },
775    })
776}
777
778fn header_routing_discriminator(values: &BTreeMap<String, String>) -> String {
779    let mut digest = Sha256::new();
780    digest.update(b"cookie-agent/header-templates/v1\0");
781    for (name, value) in values {
782        digest.update(name.as_bytes());
783        digest.update(b"\0");
784        digest.update(value.as_bytes());
785        digest.update(b"\0");
786    }
787    format!("{:x}", digest.finalize())
788}
789
790fn combined_routing_discriminator(routing: Option<&str>, headers: Option<&str>) -> Option<String> {
791    match (routing, headers) {
792        (Some(routing), Some(headers)) => {
793            let mut digest = Sha256::new();
794            digest.update(b"cookie-agent/request-routing/v1\0");
795            digest.update(
796                serde_json::to_vec(&(routing, headers)).expect("routing strings serialize"),
797            );
798            Some(format!("{:x}", digest.finalize()))
799        }
800        (routing, headers) => routing.or(headers).map(str::to_owned),
801    }
802}
803
804fn namespace(
805    name: &str,
806    value: impl Serialize,
807) -> Result<BTreeMap<String, Value>, ModelBuildError> {
808    let value = serde_json::to_value(value).map_err(ModelBuildError::ProviderOptions)?;
809    Ok([(name.to_owned(), value)].into_iter().collect())
810}
811
812#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
813#[serde(deny_unknown_fields)]
814pub struct TimeoutsConfig {
815    #[serde(default = "default_connect")]
816    pub connect_seconds: u64,
817    #[serde(default = "default_headers")]
818    pub headers_seconds: u64,
819    #[serde(default = "default_credentials")]
820    pub credentials_seconds: u64,
821    #[serde(default = "default_stream_idle")]
822    pub stream_idle_seconds: u64,
823}
824
825impl Default for TimeoutsConfig {
826    fn default() -> Self {
827        Self {
828            connect_seconds: default_connect(),
829            headers_seconds: default_headers(),
830            credentials_seconds: default_credentials(),
831            stream_idle_seconds: default_stream_idle(),
832        }
833    }
834}
835
836const fn default_connect() -> u64 {
837    10
838}
839const fn default_headers() -> u64 {
840    // Headers bound time-to-first-byte, which is also where slow providers spend
841    // their thinking time on large contexts; keep it well above long TTFTs.
842    300
843}
844const fn default_credentials() -> u64 {
845    30
846}
847const fn default_stream_idle() -> u64 {
848    120
849}
850
851/// One HTTP client per connect timeout, shared by every compiled model.
852/// Building a client loads the platform's root certificates, which, done once
853/// per model, dominated startup; a `reqwest::Client` is a cheap handle to one
854/// shared connection pool. `None` if the client cannot be built, in which case
855/// an adapter builds its own and reports the failure itself.
856fn shared_http_client(connect: Duration) -> Option<reqwest::Client> {
857    static CLIENTS: OnceLock<Mutex<HashMap<Duration, reqwest::Client>>> = OnceLock::new();
858    let mut clients = CLIENTS
859        .get_or_init(Default::default)
860        .lock()
861        .unwrap_or_else(PoisonError::into_inner);
862    if let Some(client) = clients.get(&connect) {
863        return Some(client.clone());
864    }
865    let client = reqwest::Client::builder()
866        .connect_timeout(connect)
867        .build()
868        .ok()?;
869    clients.insert(connect, client.clone());
870    Some(client)
871}
872
873impl TimeoutsConfig {
874    fn shared_client(self) -> Option<reqwest::Client> {
875        shared_http_client(Duration::from_secs(self.connect_seconds))
876    }
877    fn anthropic(self) -> AnthropicTimeouts {
878        AnthropicTimeouts {
879            headers: Duration::from_secs(self.headers_seconds),
880            credentials: Duration::from_secs(self.credentials_seconds),
881            stream_idle: Duration::from_secs(self.stream_idle_seconds),
882        }
883    }
884    fn openai(self) -> OpenAiTimeouts {
885        OpenAiTimeouts {
886            connect: Duration::from_secs(self.connect_seconds),
887            headers: Duration::from_secs(self.headers_seconds),
888            stream_idle: Duration::from_secs(self.stream_idle_seconds),
889        }
890    }
891    fn google(self) -> GoogleTimeouts {
892        GoogleTimeouts {
893            connect: Duration::from_secs(self.connect_seconds),
894            headers: Duration::from_secs(self.headers_seconds),
895            stream_idle: Duration::from_secs(self.stream_idle_seconds),
896        }
897    }
898    fn vertex(self) -> GoogleVertexTimeouts {
899        GoogleVertexTimeouts {
900            connect: Duration::from_secs(self.connect_seconds),
901            headers: Duration::from_secs(self.headers_seconds),
902            credentials: Duration::from_secs(self.credentials_seconds),
903            stream_idle: Duration::from_secs(self.stream_idle_seconds),
904        }
905    }
906    fn bedrock(self) -> BedrockTimeouts {
907        BedrockTimeouts {
908            connect: Duration::from_secs(self.connect_seconds),
909            headers: Duration::from_secs(self.headers_seconds),
910            credentials: Duration::from_secs(self.credentials_seconds),
911            stream_idle: Duration::from_secs(self.stream_idle_seconds),
912        }
913    }
914    fn azure(self) -> AzureOpenAiTimeouts {
915        AzureOpenAiTimeouts {
916            connect: Duration::from_secs(self.connect_seconds),
917            headers: Duration::from_secs(self.headers_seconds),
918            credentials: Duration::from_secs(self.credentials_seconds),
919            stream_idle: Duration::from_secs(self.stream_idle_seconds),
920        }
921    }
922    fn cohere(self) -> CohereTimeouts {
923        CohereTimeouts {
924            connect: Duration::from_secs(self.connect_seconds),
925            headers: Duration::from_secs(self.headers_seconds),
926            stream_idle: Duration::from_secs(self.stream_idle_seconds),
927        }
928    }
929}
930
931#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
932#[serde(rename_all = "snake_case")]
933pub enum AnthropicThinkingSupportConfig {
934    None,
935    Extended,
936    Adaptive,
937    Both,
938}
939
940impl From<AnthropicThinkingSupportConfig> for AnthropicThinkingSupport {
941    fn from(value: AnthropicThinkingSupportConfig) -> Self {
942        match value {
943            AnthropicThinkingSupportConfig::None => Self::None,
944            AnthropicThinkingSupportConfig::Extended => Self::Extended,
945            AnthropicThinkingSupportConfig::Adaptive => Self::Adaptive,
946            AnthropicThinkingSupportConfig::Both => Self::Both,
947        }
948    }
949}
950
951#[derive(Clone, Debug, Deserialize, Serialize)]
952#[serde(deny_unknown_fields)]
953pub struct AnthropicSettingsConfig {
954    #[serde(default)]
955    pub timeouts: TimeoutsConfig,
956    pub thinking: AnthropicThinkingSupportConfig,
957    pub thinking_default_active: bool,
958    pub thinking_disable_allowed: bool,
959    #[serde(default)]
960    pub thinking_disable_forbidden_efforts: BTreeSet<String>,
961    pub effort: bool,
962    pub assistant_prefill: bool,
963    pub reject_non_default_sampling: bool,
964    pub native_context_discriminator: Option<String>,
965}
966
967impl AnthropicSettingsConfig {
968    fn to_oven(&self) -> Result<AnthropicSettings, ModelBuildError> {
969        Ok(AnthropicSettings {
970            client: self.timeouts.shared_client().ok_or_else(|| {
971                ModelError::transport("could not construct Anthropic HTTP client")
972            })?,
973            timeouts: self.timeouts.anthropic(),
974            protocol: AnthropicProtocolSettings {
975                thinking: self.thinking.into(),
976                thinking_default_active: self.thinking_default_active,
977                thinking_disable_allowed: self.thinking_disable_allowed,
978                thinking_disable_forbidden_efforts: self.thinking_disable_forbidden_efforts.clone(),
979                effort: self.effort,
980                assistant_prefill: self.assistant_prefill,
981                reject_non_default_sampling: self.reject_non_default_sampling,
982            },
983            native_context_discriminator: self
984                .native_context_discriminator
985                .as_ref()
986                .map(|value| ResourceId::new(value.clone()).map_err(ModelBuildError::from))
987                .transpose()?,
988        })
989    }
990}
991
992#[derive(Clone, Debug, Deserialize, Serialize)]
993#[serde(deny_unknown_fields)]
994pub struct AnthropicCompatibleSettingsConfig {
995    pub adapter_id: String,
996    #[serde(flatten)]
997    pub protocol: AnthropicSettingsConfig,
998}
999
1000impl AnthropicCompatibleSettingsConfig {
1001    fn to_oven(&self) -> Result<AnthropicCompatibleSettings, ModelBuildError> {
1002        let settings = self.protocol.to_oven()?;
1003        Ok(AnthropicCompatibleSettings {
1004            adapter_id: AdapterId::new(self.adapter_id.clone()),
1005            client: settings.client,
1006            timeouts: settings.timeouts,
1007            protocol: settings.protocol,
1008            native_context_discriminator: settings.native_context_discriminator,
1009        })
1010    }
1011}
1012
1013#[derive(Clone, Debug, Deserialize, Serialize)]
1014#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
1015pub enum AnthropicThinkingConfig {
1016    Disabled,
1017    Enabled {
1018        budget_tokens: u64,
1019        display: Option<String>,
1020    },
1021    Adaptive {
1022        display: Option<String>,
1023    },
1024}
1025
1026impl From<AnthropicThinkingConfig> for AnthropicThinking {
1027    fn from(value: AnthropicThinkingConfig) -> Self {
1028        match value {
1029            AnthropicThinkingConfig::Disabled => Self::Disabled,
1030            AnthropicThinkingConfig::Enabled {
1031                budget_tokens,
1032                display,
1033            } => Self::Enabled {
1034                budget_tokens,
1035                display,
1036            },
1037            AnthropicThinkingConfig::Adaptive { display } => Self::Adaptive { display },
1038        }
1039    }
1040}
1041
1042#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1043#[serde(rename_all = "snake_case")]
1044pub enum AnthropicCacheTtlConfig {
1045    FiveMinutes,
1046    OneHour,
1047}
1048
1049#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1050#[serde(deny_unknown_fields)]
1051pub struct AnthropicCacheStrategyConfig {
1052    pub system: Option<AnthropicCacheTtlConfig>,
1053    pub tools: Option<AnthropicCacheTtlConfig>,
1054    pub rolling: Option<AnthropicCacheTtlConfig>,
1055}
1056
1057impl From<AnthropicCacheTtlConfig> for AnthropicCacheTtl {
1058    fn from(value: AnthropicCacheTtlConfig) -> Self {
1059        match value {
1060            AnthropicCacheTtlConfig::FiveMinutes => Self::FiveMinutes,
1061            AnthropicCacheTtlConfig::OneHour => Self::OneHour,
1062        }
1063    }
1064}
1065
1066#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1067#[serde(deny_unknown_fields)]
1068pub struct AnthropicOptionsConfig {
1069    pub thinking: Option<AnthropicThinkingConfig>,
1070    pub effort: Option<String>,
1071    /// Legacy request-level marker emitted after tool and message markers.
1072    pub cache_ttl: Option<AnthropicCacheTtlConfig>,
1073    pub cache_strategy: Option<AnthropicCacheStrategyConfig>,
1074    pub user_id: Option<String>,
1075    #[serde(default)]
1076    pub betas: Vec<String>,
1077}
1078
1079impl AnthropicOptionsConfig {
1080    fn to_oven(&self) -> Result<Value, ModelBuildError> {
1081        let options = AnthropicRequestOptions {
1082            thinking: self.thinking.clone().map(Into::into),
1083            effort: self.effort.clone(),
1084            cache_control: self
1085                .cache_ttl
1086                .map(|ttl| AnthropicCacheControl { ttl: ttl.into() }),
1087            user_id: self.user_id.clone(),
1088            betas: self.betas.clone(),
1089        };
1090        let mut value = serde_json::to_value(options).map_err(ModelBuildError::ProviderOptions)?;
1091        if let Some(strategy) = &self.cache_strategy {
1092            value["cache_strategy"] =
1093                serde_json::to_value(strategy).map_err(ModelBuildError::ProviderOptions)?;
1094        }
1095        Ok(value)
1096    }
1097}
1098
1099#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1100#[serde(rename_all = "snake_case")]
1101pub enum SystemRoleConfig {
1102    System,
1103    Developer,
1104    Omit,
1105}
1106
1107impl From<SystemRoleConfig> for SystemMessageRole {
1108    fn from(value: SystemRoleConfig) -> Self {
1109        match value {
1110            SystemRoleConfig::System => Self::System,
1111            SystemRoleConfig::Developer => Self::Developer,
1112            SystemRoleConfig::Omit => Self::Omit,
1113        }
1114    }
1115}
1116
1117impl From<SystemRoleConfig> for AzureSystemMessageRole {
1118    fn from(value: SystemRoleConfig) -> Self {
1119        match value {
1120            SystemRoleConfig::System => Self::System,
1121            SystemRoleConfig::Developer => Self::Developer,
1122            SystemRoleConfig::Omit => Self::Omit,
1123        }
1124    }
1125}
1126
1127#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1128#[serde(rename_all = "snake_case")]
1129pub enum MaxTokensFieldConfig {
1130    MaxTokens,
1131    MaxCompletionTokens,
1132    Omit,
1133}
1134
1135impl From<MaxTokensFieldConfig> for MaxTokensField {
1136    fn from(value: MaxTokensFieldConfig) -> Self {
1137        match value {
1138            MaxTokensFieldConfig::MaxTokens => Self::MaxTokens,
1139            MaxTokensFieldConfig::MaxCompletionTokens => Self::MaxCompletionTokens,
1140            MaxTokensFieldConfig::Omit => Self::Omit,
1141        }
1142    }
1143}
1144
1145impl From<MaxTokensFieldConfig> for AzureMaxTokensField {
1146    fn from(value: MaxTokensFieldConfig) -> Self {
1147        match value {
1148            MaxTokensFieldConfig::MaxTokens => Self::MaxTokens,
1149            MaxTokensFieldConfig::MaxCompletionTokens => Self::MaxCompletionTokens,
1150            MaxTokensFieldConfig::Omit => Self::Omit,
1151        }
1152    }
1153}
1154
1155#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1156#[serde(rename_all = "snake_case")]
1157pub enum StructuredOutputConfig {
1158    Unsupported,
1159    JsonObject,
1160    JsonSchema,
1161}
1162
1163impl From<StructuredOutputConfig> for StructuredOutputSupport {
1164    fn from(value: StructuredOutputConfig) -> Self {
1165        match value {
1166            StructuredOutputConfig::Unsupported => Self::Unsupported,
1167            StructuredOutputConfig::JsonObject => Self::JsonObject,
1168            StructuredOutputConfig::JsonSchema => Self::JsonSchema,
1169        }
1170    }
1171}
1172
1173impl From<StructuredOutputConfig> for AzureStructuredOutputSupport {
1174    fn from(value: StructuredOutputConfig) -> Self {
1175        match value {
1176            StructuredOutputConfig::Unsupported => Self::Unsupported,
1177            StructuredOutputConfig::JsonObject => Self::JsonObject,
1178            StructuredOutputConfig::JsonSchema => Self::JsonSchema,
1179        }
1180    }
1181}
1182
1183#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1184#[serde(rename_all = "snake_case")]
1185pub enum ReasoningFieldConfig {
1186    None,
1187    ReasoningContent,
1188    Reasoning,
1189}
1190
1191impl From<ReasoningFieldConfig> for ReasoningField {
1192    fn from(value: ReasoningFieldConfig) -> Self {
1193        match value {
1194            ReasoningFieldConfig::None => Self::None,
1195            ReasoningFieldConfig::ReasoningContent => Self::ReasoningContent,
1196            ReasoningFieldConfig::Reasoning => Self::Reasoning,
1197        }
1198    }
1199}
1200
1201impl From<ReasoningFieldConfig> for AzureReasoningField {
1202    fn from(value: ReasoningFieldConfig) -> Self {
1203        match value {
1204            ReasoningFieldConfig::None => Self::None,
1205            ReasoningFieldConfig::ReasoningContent => Self::ReasoningContent,
1206            ReasoningFieldConfig::Reasoning => Self::Reasoning,
1207        }
1208    }
1209}
1210
1211#[derive(Clone, Debug, Deserialize, Serialize)]
1212#[serde(deny_unknown_fields)]
1213pub struct OpenAiChatSettingsConfig {
1214    pub system_message_role: SystemRoleConfig,
1215    pub max_tokens_field: MaxTokensFieldConfig,
1216    pub stream_usage: bool,
1217    pub structured_output: StructuredOutputConfig,
1218    pub reasoning_field: ReasoningFieldConfig,
1219    pub routing_discriminator: Option<String>,
1220    #[serde(default)]
1221    pub timeouts: TimeoutsConfig,
1222}
1223
1224impl OpenAiChatSettingsConfig {
1225    fn to_openai_chat(&self, header_discriminator: Option<&str>) -> OpenAiChatSettings {
1226        OpenAiChatSettings {
1227            system_message_role: self.system_message_role.into(),
1228            max_tokens_field: self.max_tokens_field.into(),
1229            stream_usage: self.stream_usage,
1230            structured_output: self.structured_output.into(),
1231            reasoning_field: self.reasoning_field.into(),
1232            routing_discriminator: combined_routing_discriminator(
1233                self.routing_discriminator.as_deref(),
1234                header_discriminator,
1235            ),
1236            client: self.timeouts.shared_client(),
1237            timeouts: self.timeouts.openai(),
1238        }
1239    }
1240}
1241
1242#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1243#[serde(deny_unknown_fields)]
1244pub struct OpenAiResponsesSettingsConfig {
1245    pub routing_discriminator: Option<String>,
1246    #[serde(default)]
1247    pub compaction: OpenAiResponsesCompactionConfig,
1248    #[serde(default)]
1249    pub timeouts: TimeoutsConfig,
1250}
1251
1252impl OpenAiResponsesSettingsConfig {
1253    fn to_openai_responses(&self, header_discriminator: Option<&str>) -> OpenAiResponsesSettings {
1254        OpenAiResponsesSettings {
1255            routing_discriminator: combined_routing_discriminator(
1256                self.routing_discriminator.as_deref(),
1257                header_discriminator,
1258            ),
1259            compaction: self.compaction.into(),
1260            client: self.timeouts.shared_client(),
1261            timeouts: self.timeouts.openai(),
1262        }
1263    }
1264}
1265
1266#[derive(Clone, Copy, Debug, Default, Serialize)]
1267pub enum OpenAiResponsesCompactionConfig {
1268    #[serde(rename = "unsupported")]
1269    #[default]
1270    Unsupported,
1271    #[serde(rename = "openai-responses-compact")]
1272    OpenAiResponsesCompact,
1273}
1274
1275impl<'de> Deserialize<'de> for OpenAiResponsesCompactionConfig {
1276    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1277    where
1278        D: serde::Deserializer<'de>,
1279    {
1280        match String::deserialize(deserializer)?.as_str() {
1281            "unsupported" => Ok(Self::Unsupported),
1282            "openai-responses-compact" => Ok(Self::OpenAiResponsesCompact),
1283            "v1" => Err(serde::de::Error::custom(
1284                "compaction = \"v1\" was renamed to compaction = \"openai-responses-compact\"",
1285            )),
1286            _ => Err(serde::de::Error::custom(
1287                "compaction must be \"unsupported\" or \"openai-responses-compact\"",
1288            )),
1289        }
1290    }
1291}
1292
1293impl From<OpenAiResponsesCompactionConfig> for OpenAiResponsesCompaction {
1294    fn from(value: OpenAiResponsesCompactionConfig) -> Self {
1295        match value {
1296            OpenAiResponsesCompactionConfig::Unsupported => Self::Unsupported,
1297            OpenAiResponsesCompactionConfig::OpenAiResponsesCompact => Self::V1,
1298        }
1299    }
1300}
1301
1302#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1303#[serde(deny_unknown_fields)]
1304pub struct OpenAiChatOptionsConfig {
1305    pub user: Option<String>,
1306    pub reasoning_effort: Option<String>,
1307    pub service_tier: Option<String>,
1308    pub verbosity: Option<String>,
1309    pub parallel_tool_calls: Option<bool>,
1310}
1311
1312impl From<&OpenAiChatOptionsConfig> for OpenAiChatOptions {
1313    fn from(value: &OpenAiChatOptionsConfig) -> Self {
1314        Self {
1315            user: value.user.clone(),
1316            reasoning_effort: value.reasoning_effort.clone(),
1317            service_tier: value.service_tier.clone(),
1318            verbosity: value.verbosity.clone(),
1319            parallel_tool_calls: value.parallel_tool_calls,
1320            prompt_cache_key: None,
1321            prompt_cache_options: None,
1322            prompt_cache_retention: None,
1323        }
1324    }
1325}
1326
1327#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1328#[serde(deny_unknown_fields)]
1329pub struct OpenAiResponsesOptionsConfig {
1330    #[serde(default)]
1331    pub include: Vec<String>,
1332    pub user: Option<String>,
1333    pub service_tier: Option<String>,
1334    pub verbosity: Option<String>,
1335    pub reasoning_summary: Option<String>,
1336    pub reasoning_mode: Option<String>,
1337    pub truncation: Option<String>,
1338    pub parallel_tool_calls: Option<bool>,
1339}
1340
1341impl From<&OpenAiResponsesOptionsConfig> for OpenAiResponsesOptions {
1342    fn from(value: &OpenAiResponsesOptionsConfig) -> Self {
1343        Self {
1344            include: value.include.clone(),
1345            user: value.user.clone(),
1346            service_tier: value.service_tier.clone(),
1347            verbosity: value.verbosity.clone(),
1348            reasoning_summary: value.reasoning_summary.clone(),
1349            reasoning_mode: value.reasoning_mode.clone(),
1350            truncation: value.truncation.clone(),
1351            parallel_tool_calls: value.parallel_tool_calls,
1352            prompt_cache_key: None,
1353            prompt_cache_options: None,
1354            prompt_cache_retention: None,
1355        }
1356    }
1357}
1358
1359#[derive(Clone, Debug, Deserialize, Serialize)]
1360#[serde(deny_unknown_fields)]
1361pub struct CompatibleSettingsConfig {
1362    pub adapter_id: String,
1363    pub system_message_role: SystemRoleConfig,
1364    pub max_tokens_field: MaxTokensFieldConfig,
1365    pub stream_usage: bool,
1366    pub structured_output: StructuredOutputConfig,
1367    pub reasoning_field: ReasoningFieldConfig,
1368    #[serde(default)]
1369    pub query: BTreeMap<String, String>,
1370    #[serde(default = "default_request_id_headers")]
1371    pub request_id_headers: Vec<String>,
1372    #[serde(default)]
1373    pub strict_sse_content_type: bool,
1374    pub routing_discriminator: Option<String>,
1375    #[serde(default)]
1376    pub timeouts: TimeoutsConfig,
1377}
1378
1379fn default_request_id_headers() -> Vec<String> {
1380    vec!["x-request-id".into()]
1381}
1382
1383impl CompatibleSettingsConfig {
1384    fn to_oven(&self, header_discriminator: Option<&str>) -> OpenAiCompatibleChatSettings {
1385        OpenAiCompatibleChatSettings {
1386            adapter_id: AdapterId::new(self.adapter_id.clone()),
1387            system_message_role: self.system_message_role.into(),
1388            max_tokens_field: self.max_tokens_field.into(),
1389            stream_usage: self.stream_usage,
1390            structured_output: self.structured_output.into(),
1391            reasoning_field: self.reasoning_field.into(),
1392            query: self
1393                .query
1394                .iter()
1395                .map(|(key, value)| (key.clone(), value.clone()))
1396                .collect(),
1397            request_id_headers: self.request_id_headers.clone(),
1398            strict_sse_content_type: self.strict_sse_content_type,
1399            routing_discriminator: combined_routing_discriminator(
1400                self.routing_discriminator.as_deref(),
1401                header_discriminator,
1402            ),
1403            client: self.timeouts.shared_client(),
1404            timeouts: self.timeouts.openai(),
1405        }
1406    }
1407}
1408
1409#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1410#[serde(deny_unknown_fields)]
1411pub struct CompatibleOptionsConfig {
1412    #[serde(default)]
1413    pub extra_body: Map<String, Value>,
1414}
1415
1416#[derive(Clone, Debug, Deserialize, Serialize)]
1417#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
1418pub enum GoogleThinkingSettingsConfig {
1419    Unsupported,
1420    Budget {
1421        effort_budgets: BTreeMap<String, i64>,
1422    },
1423    Level {
1424        effort_levels: BTreeMap<String, String>,
1425    },
1426}
1427
1428impl GoogleThinkingSettingsConfig {
1429    fn to_oven(&self) -> GoogleThinkingSettings {
1430        match self {
1431            Self::Unsupported => GoogleThinkingSettings::Unsupported,
1432            Self::Budget { effort_budgets } => GoogleThinkingSettings::Budget {
1433                effort_budgets: effort_budgets.clone(),
1434            },
1435            Self::Level { effort_levels } => GoogleThinkingSettings::Level {
1436                effort_levels: effort_levels.clone(),
1437            },
1438        }
1439    }
1440}
1441
1442#[derive(Clone, Debug, Deserialize, Serialize)]
1443#[serde(deny_unknown_fields)]
1444pub struct GoogleSettingsConfig {
1445    pub model_resource: String,
1446    #[serde(default)]
1447    pub timeouts: TimeoutsConfig,
1448    pub thinking: GoogleThinkingSettingsConfig,
1449    pub strict_functions: bool,
1450    pub mixed_client_and_provider_tools: bool,
1451    pub current_turn_signature_sentinel: bool,
1452}
1453
1454impl GoogleSettingsConfig {
1455    fn to_oven(&self) -> GoogleGenerateContentSettings {
1456        GoogleGenerateContentSettings {
1457            model_resource: self.model_resource.clone(),
1458            timeouts: self.timeouts.google(),
1459            thinking: self.thinking.to_oven(),
1460            tools: GoogleToolSettings {
1461                strict_functions: self.strict_functions,
1462                mixed_client_and_provider_tools: self.mixed_client_and_provider_tools,
1463                current_turn_signature_sentinel: self.current_turn_signature_sentinel,
1464            },
1465        }
1466    }
1467}
1468
1469#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1470#[serde(deny_unknown_fields)]
1471pub struct GoogleThinkingOptionsConfig {
1472    pub thinking_budget: Option<i64>,
1473    pub thinking_level: Option<String>,
1474    pub include_thoughts: Option<bool>,
1475}
1476
1477#[derive(Clone, Debug, Deserialize, Serialize)]
1478#[serde(deny_unknown_fields)]
1479pub struct SafetySettingConfig {
1480    pub category: String,
1481    pub threshold: String,
1482}
1483
1484#[derive(Clone, Debug, Deserialize, Serialize)]
1485#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
1486pub enum GoogleProviderToolConfig {
1487    GoogleSearch,
1488    UrlContext,
1489    CodeExecution,
1490    FileSearch { stores: Vec<String> },
1491    GoogleMaps,
1492}
1493
1494impl GoogleProviderToolConfig {
1495    fn to_oven(&self) -> GoogleProviderTool {
1496        match self {
1497            Self::GoogleSearch => GoogleProviderTool::GoogleSearch,
1498            Self::UrlContext => GoogleProviderTool::UrlContext,
1499            Self::CodeExecution => GoogleProviderTool::CodeExecution,
1500            Self::FileSearch { stores } => GoogleProviderTool::FileSearch {
1501                stores: stores.clone(),
1502            },
1503            Self::GoogleMaps => GoogleProviderTool::GoogleMaps,
1504        }
1505    }
1506}
1507
1508#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1509#[serde(deny_unknown_fields)]
1510pub struct GoogleOptionsConfig {
1511    pub top_k: Option<u32>,
1512    #[serde(default)]
1513    pub stop_sequences: Vec<String>,
1514    pub seed: Option<i32>,
1515    pub presence_penalty: Option<f64>,
1516    pub frequency_penalty: Option<f64>,
1517    pub thinking: Option<GoogleThinkingOptionsConfig>,
1518    #[serde(default)]
1519    pub provider_tools: Vec<GoogleProviderToolConfig>,
1520    pub service_tier: Option<String>,
1521    pub cached_content: Option<String>,
1522    #[serde(default)]
1523    pub safety_settings: Vec<SafetySettingConfig>,
1524}
1525
1526impl GoogleOptionsConfig {
1527    fn to_oven(&self) -> GoogleRequestOptions {
1528        GoogleRequestOptions {
1529            top_k: self.top_k,
1530            stop_sequences: self.stop_sequences.clone(),
1531            seed: self.seed,
1532            presence_penalty: self.presence_penalty,
1533            frequency_penalty: self.frequency_penalty,
1534            thinking_config: self.thinking.as_ref().map(|thinking| GoogleThinkingConfig {
1535                thinking_budget: thinking.thinking_budget,
1536                thinking_level: thinking.thinking_level.clone(),
1537                include_thoughts: thinking.include_thoughts,
1538            }),
1539            provider_tools: self
1540                .provider_tools
1541                .iter()
1542                .map(GoogleProviderToolConfig::to_oven)
1543                .collect(),
1544            service_tier: self.service_tier.clone(),
1545            cached_content: self.cached_content.clone(),
1546            safety_settings: self
1547                .safety_settings
1548                .iter()
1549                .map(|setting| GoogleSafetySetting {
1550                    category: setting.category.clone(),
1551                    threshold: setting.threshold.clone(),
1552                })
1553                .collect(),
1554        }
1555    }
1556}
1557
1558#[derive(Clone, Debug, Deserialize, Serialize)]
1559#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
1560pub enum VertexResourceConfig {
1561    PublisherModel { publisher: String, model: String },
1562    Endpoint { endpoint: String },
1563}
1564
1565impl VertexResourceConfig {
1566    fn to_oven(&self) -> GoogleVertexResource {
1567        match self {
1568            Self::PublisherModel { publisher, model } => GoogleVertexResource::PublisherModel {
1569                publisher: publisher.clone(),
1570                model: model.clone(),
1571            },
1572            Self::Endpoint { endpoint } => GoogleVertexResource::Endpoint {
1573                endpoint: endpoint.clone(),
1574            },
1575        }
1576    }
1577}
1578
1579#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1580#[serde(rename_all = "snake_case")]
1581pub enum VertexThinkingModeConfig {
1582    Unsupported,
1583    Budget,
1584    Level,
1585}
1586
1587impl From<VertexThinkingModeConfig> for GoogleVertexThinkingMode {
1588    fn from(value: VertexThinkingModeConfig) -> Self {
1589        match value {
1590            VertexThinkingModeConfig::Unsupported => Self::Unsupported,
1591            VertexThinkingModeConfig::Budget => Self::Budget,
1592            VertexThinkingModeConfig::Level => Self::Level,
1593        }
1594    }
1595}
1596
1597#[derive(Clone, Debug, Deserialize, Serialize)]
1598#[serde(deny_unknown_fields)]
1599pub struct VertexMediaConfig {
1600    pub max_images: usize,
1601    pub max_https_images: usize,
1602    pub max_documents: usize,
1603    pub max_audio: usize,
1604    pub max_videos: usize,
1605    pub max_https_videos: usize,
1606    pub max_inline_image_bytes: usize,
1607    pub max_inline_pdf_bytes: usize,
1608    pub max_inline_text_bytes: usize,
1609    pub url_schemes: Vec<String>,
1610}
1611
1612#[derive(Clone, Debug, Deserialize, Serialize)]
1613#[serde(deny_unknown_fields)]
1614pub struct VertexSettingsConfig {
1615    pub project: String,
1616    pub location: String,
1617    pub resource: VertexResourceConfig,
1618    pub thinking: VertexThinkingModeConfig,
1619    pub provider_tools: bool,
1620    pub mixed_client_and_provider_tools: bool,
1621    pub strict_functions: bool,
1622    pub stream_function_call_arguments: bool,
1623    pub media: VertexMediaConfig,
1624    #[serde(default)]
1625    pub timeouts: TimeoutsConfig,
1626}
1627
1628impl VertexSettingsConfig {
1629    fn to_oven(
1630        &self,
1631        provider: &ProviderConfig<VertexAuth>,
1632        declaration: &ModelDeclaration,
1633    ) -> Result<GoogleVertexSettings, ModelBuildError> {
1634        let resource = self.resource.to_oven();
1635        let native_context_scope = google_vertex_native_context_scope(
1636            provider.id.clone(),
1637            declaration.id.clone(),
1638            &provider.api,
1639            &self.project,
1640            &self.location,
1641            &resource,
1642        )?;
1643        Ok(GoogleVertexSettings {
1644            project: self.project.clone(),
1645            location: self.location.clone(),
1646            resource,
1647            thinking: self.thinking.into(),
1648            tools: GoogleVertexToolSettings {
1649                provider_tools: self.provider_tools,
1650                mixed_client_and_provider_tools: self.mixed_client_and_provider_tools,
1651                strict_functions: self.strict_functions,
1652            },
1653            stream_function_call_arguments: self.stream_function_call_arguments,
1654            media: GoogleVertexMediaSettings {
1655                max_images: self.media.max_images,
1656                max_https_images: self.media.max_https_images,
1657                max_documents: self.media.max_documents,
1658                max_audio: self.media.max_audio,
1659                max_videos: self.media.max_videos,
1660                max_https_videos: self.media.max_https_videos,
1661                max_inline_image_bytes: self.media.max_inline_image_bytes,
1662                max_inline_pdf_bytes: self.media.max_inline_pdf_bytes,
1663                max_inline_text_bytes: self.media.max_inline_text_bytes,
1664                url_schemes: self.media.url_schemes.clone(),
1665            },
1666            native_context_scope,
1667            client: self.timeouts.shared_client(),
1668            timeouts: self.timeouts.vertex(),
1669        })
1670    }
1671}
1672
1673#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1674#[serde(deny_unknown_fields)]
1675pub struct VertexThinkingOptionsConfig {
1676    pub thinking_budget: Option<i64>,
1677    pub thinking_level: Option<String>,
1678    pub include_thoughts: Option<bool>,
1679}
1680
1681#[derive(Clone, Debug, Deserialize, Serialize)]
1682#[serde(deny_unknown_fields)]
1683pub struct VertexSafetySettingConfig {
1684    pub category: String,
1685    pub threshold: String,
1686    pub method: Option<String>,
1687}
1688
1689#[derive(Clone, Debug, Deserialize, Serialize)]
1690#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
1691pub enum VertexProviderToolConfig {
1692    GoogleSearch,
1693    UrlContext,
1694    CodeExecution,
1695    VertexRagStore {
1696        rag_corpus: String,
1697        top_k: Option<u32>,
1698    },
1699    GoogleMaps,
1700}
1701
1702impl VertexProviderToolConfig {
1703    fn to_oven(&self) -> GoogleVertexProviderTool {
1704        match self {
1705            Self::GoogleSearch => GoogleVertexProviderTool::GoogleSearch,
1706            Self::UrlContext => GoogleVertexProviderTool::UrlContext,
1707            Self::CodeExecution => GoogleVertexProviderTool::CodeExecution,
1708            Self::VertexRagStore { rag_corpus, top_k } => {
1709                GoogleVertexProviderTool::VertexRagStore {
1710                    rag_corpus: rag_corpus.clone(),
1711                    top_k: *top_k,
1712                }
1713            }
1714            Self::GoogleMaps => GoogleVertexProviderTool::GoogleMaps,
1715        }
1716    }
1717}
1718
1719#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1720#[serde(deny_unknown_fields)]
1721pub struct VertexOptionsConfig {
1722    pub top_k: Option<f64>,
1723    #[serde(default)]
1724    pub stop_sequences: Vec<String>,
1725    pub seed: Option<i32>,
1726    pub presence_penalty: Option<f64>,
1727    pub frequency_penalty: Option<f64>,
1728    pub thinking: Option<VertexThinkingOptionsConfig>,
1729    #[serde(default)]
1730    pub provider_tools: Vec<VertexProviderToolConfig>,
1731    pub cached_content: Option<String>,
1732    #[serde(default)]
1733    pub safety_settings: Vec<VertexSafetySettingConfig>,
1734    pub shared_request_type: Option<String>,
1735    pub request_type: Option<String>,
1736}
1737
1738impl VertexOptionsConfig {
1739    fn to_oven(&self) -> GoogleVertexRequestOptions {
1740        GoogleVertexRequestOptions {
1741            top_k: self.top_k,
1742            stop_sequences: self.stop_sequences.clone(),
1743            seed: self.seed,
1744            presence_penalty: self.presence_penalty,
1745            frequency_penalty: self.frequency_penalty,
1746            thinking_config: self
1747                .thinking
1748                .as_ref()
1749                .map(|thinking| GoogleVertexThinkingConfig {
1750                    thinking_budget: thinking.thinking_budget,
1751                    thinking_level: thinking.thinking_level.clone(),
1752                    include_thoughts: thinking.include_thoughts,
1753                }),
1754            provider_tools: self
1755                .provider_tools
1756                .iter()
1757                .map(VertexProviderToolConfig::to_oven)
1758                .collect(),
1759            cached_content: self.cached_content.clone(),
1760            safety_settings: self
1761                .safety_settings
1762                .iter()
1763                .map(|setting| GoogleVertexSafetySetting {
1764                    category: setting.category.clone(),
1765                    threshold: setting.threshold.clone(),
1766                    method: setting.method.clone(),
1767                })
1768                .collect(),
1769            shared_request_type: self.shared_request_type.clone(),
1770            request_type: self.request_type.clone(),
1771        }
1772    }
1773}
1774
1775#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1776#[serde(rename_all = "snake_case")]
1777pub enum BedrockReasoningFormatConfig {
1778    Unsupported,
1779    AnthropicThinking,
1780    OpenaiReasoningEffort,
1781    BedrockReasoningConfig,
1782}
1783
1784impl From<BedrockReasoningFormatConfig> for BedrockReasoningWireFormat {
1785    fn from(value: BedrockReasoningFormatConfig) -> Self {
1786        match value {
1787            BedrockReasoningFormatConfig::Unsupported => Self::Unsupported,
1788            BedrockReasoningFormatConfig::AnthropicThinking => Self::AnthropicThinking,
1789            BedrockReasoningFormatConfig::OpenaiReasoningEffort => Self::OpenAiReasoningEffort,
1790            BedrockReasoningFormatConfig::BedrockReasoningConfig => Self::BedrockReasoningConfig,
1791        }
1792    }
1793}
1794
1795#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1796#[serde(rename_all = "snake_case")]
1797pub enum BedrockStructuredOutputConfig {
1798    Unsupported,
1799    JsonSchema,
1800}
1801
1802impl From<BedrockStructuredOutputConfig> for BedrockStructuredOutput {
1803    fn from(value: BedrockStructuredOutputConfig) -> Self {
1804        match value {
1805            BedrockStructuredOutputConfig::Unsupported => Self::Unsupported,
1806            BedrockStructuredOutputConfig::JsonSchema => Self::JsonSchema,
1807        }
1808    }
1809}
1810
1811#[derive(Clone, Debug, Deserialize, Serialize)]
1812#[serde(deny_unknown_fields)]
1813pub struct BedrockSettingsConfig {
1814    pub region: String,
1815    pub reasoning_wire_format: BedrockReasoningFormatConfig,
1816    pub signed_reasoning: bool,
1817    pub structured_output: BedrockStructuredOutputConfig,
1818    pub max_event_message_bytes: usize,
1819    #[serde(default)]
1820    pub timeouts: TimeoutsConfig,
1821}
1822
1823impl BedrockSettingsConfig {
1824    fn to_oven(&self) -> BedrockConverseSettings {
1825        BedrockConverseSettings {
1826            region: self.region.clone(),
1827            reasoning_wire_format: self.reasoning_wire_format.into(),
1828            signed_reasoning: self.signed_reasoning,
1829            structured_output: self.structured_output.into(),
1830            event_stream: BedrockEventStreamLimits::new(self.max_event_message_bytes),
1831            timeouts: self.timeouts.bedrock(),
1832            client: self.timeouts.shared_client(),
1833        }
1834    }
1835}
1836
1837#[derive(Clone, Debug, Deserialize, Serialize)]
1838#[serde(deny_unknown_fields)]
1839pub struct BedrockGuardrailConfigInput {
1840    pub guardrail_identifier: String,
1841    pub guardrail_version: String,
1842    pub trace: Option<String>,
1843    pub stream_processing_mode: Option<String>,
1844}
1845
1846#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1847#[serde(deny_unknown_fields)]
1848pub struct BedrockOptionsConfig {
1849    pub additional_model_request_fields: Option<Value>,
1850    #[serde(default)]
1851    pub additional_model_response_field_paths: Vec<String>,
1852    pub service_tier: Option<String>,
1853    pub performance_latency: Option<String>,
1854    #[serde(default)]
1855    pub request_metadata: BTreeMap<String, String>,
1856    pub guardrail: Option<BedrockGuardrailConfigInput>,
1857    pub s3_bucket_owner: Option<String>,
1858    #[serde(default)]
1859    pub stop_sequences: Vec<String>,
1860    pub reasoning_type: Option<String>,
1861    pub reasoning_budget_tokens: Option<u64>,
1862    pub reasoning_display: Option<String>,
1863    pub max_reasoning_effort: Option<String>,
1864}
1865
1866impl BedrockOptionsConfig {
1867    fn to_oven(&self) -> BedrockRequestOptions {
1868        BedrockRequestOptions {
1869            cache: None,
1870            additional_model_request_fields: self.additional_model_request_fields.clone(),
1871            additional_model_response_field_paths: self
1872                .additional_model_response_field_paths
1873                .clone(),
1874            service_tier: self.service_tier.clone(),
1875            performance_latency: self.performance_latency.clone(),
1876            request_metadata: self.request_metadata.clone(),
1877            guardrail: self
1878                .guardrail
1879                .as_ref()
1880                .map(|guardrail| BedrockGuardrailConfig {
1881                    guardrail_identifier: guardrail.guardrail_identifier.clone(),
1882                    guardrail_version: guardrail.guardrail_version.clone(),
1883                    trace: guardrail.trace.clone(),
1884                    stream_processing_mode: guardrail.stream_processing_mode.clone(),
1885                }),
1886            s3: self
1887                .s3_bucket_owner
1888                .as_ref()
1889                .map(|owner| BedrockS3LocationOptions {
1890                    bucket_owner: Some(owner.clone()),
1891                }),
1892            stop_sequences: self.stop_sequences.clone(),
1893            reasoning_type: self.reasoning_type.clone(),
1894            reasoning_budget_tokens: self.reasoning_budget_tokens,
1895            reasoning_display: self.reasoning_display.clone(),
1896            max_reasoning_effort: self.max_reasoning_effort.clone(),
1897        }
1898    }
1899}
1900
1901#[derive(Clone, Debug, Deserialize, Serialize)]
1902#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1903pub enum AzureRouteConfig {
1904    V1,
1905    V1Preview,
1906    Dated { version: String },
1907}
1908
1909impl AzureRouteConfig {
1910    fn to_oven(&self) -> Result<AzureApiRoute, ModelBuildError> {
1911        Ok(match self {
1912            Self::V1 => AzureApiRoute::V1,
1913            Self::V1Preview => AzureApiRoute::V1Preview,
1914            Self::Dated { version } => AzureApiRoute::Dated(AzureApiVersion::new(version.clone())?),
1915        })
1916    }
1917}
1918
1919#[derive(Clone, Debug, Deserialize, Serialize)]
1920#[serde(deny_unknown_fields)]
1921pub struct AzureRevisionConfig {
1922    pub model: String,
1923    pub version: String,
1924    pub deployment_type: String,
1925}
1926
1927impl AzureRevisionConfig {
1928    fn to_oven(&self) -> AzureOpenAiRevision {
1929        AzureOpenAiRevision {
1930            model: self.model.clone(),
1931            version: self.version.clone(),
1932            deployment_type: self.deployment_type.clone(),
1933        }
1934    }
1935}
1936
1937#[derive(Clone, Debug, Deserialize, Serialize)]
1938#[serde(deny_unknown_fields)]
1939pub struct AzureChatSettingsConfig {
1940    pub route: AzureRouteConfig,
1941    pub revision: Option<AzureRevisionConfig>,
1942    #[serde(default)]
1943    pub timeouts: TimeoutsConfig,
1944    pub system_role: SystemRoleConfig,
1945    pub max_tokens_field: MaxTokensFieldConfig,
1946    pub stream_usage: bool,
1947    pub structured_output: StructuredOutputConfig,
1948    pub reasoning_field: ReasoningFieldConfig,
1949    pub omit_reasoning_sampling: bool,
1950}
1951
1952impl AzureChatSettingsConfig {
1953    fn to_chat(&self) -> Result<AzureOpenAiChatSettings, ModelBuildError> {
1954        Ok(AzureOpenAiChatSettings {
1955            route: self.route.to_oven()?,
1956            revision: self.revision.as_ref().map(AzureRevisionConfig::to_oven),
1957            timeouts: self.timeouts.azure(),
1958            completions: AzureOpenAiCompletionsConfig {
1959                system_role: self.system_role.into(),
1960                max_tokens_field: self.max_tokens_field.into(),
1961                stream_usage: self.stream_usage,
1962                structured_output: self.structured_output.into(),
1963                reasoning_field: self.reasoning_field.into(),
1964                omit_reasoning_sampling: self.omit_reasoning_sampling,
1965            },
1966        })
1967    }
1968}
1969
1970#[derive(Clone, Debug, Deserialize, Serialize)]
1971#[serde(deny_unknown_fields)]
1972pub struct AzureResponsesSettingsConfig {
1973    pub route: AzureRouteConfig,
1974    pub revision: Option<AzureRevisionConfig>,
1975    #[serde(default)]
1976    pub compaction: AzureResponsesCompactionConfig,
1977    #[serde(default)]
1978    pub timeouts: TimeoutsConfig,
1979}
1980
1981impl AzureResponsesSettingsConfig {
1982    fn to_responses(&self) -> Result<AzureOpenAiResponsesSettings, ModelBuildError> {
1983        Ok(AzureOpenAiResponsesSettings {
1984            route: self.route.to_oven()?,
1985            revision: self.revision.as_ref().map(AzureRevisionConfig::to_oven),
1986            timeouts: self.timeouts.azure(),
1987            compaction: self.compaction.to_oven(),
1988        })
1989    }
1990}
1991
1992#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1993#[serde(tag = "kind", deny_unknown_fields)]
1994pub enum AzureResponsesCompactionConfig {
1995    #[serde(rename = "unsupported")]
1996    #[default]
1997    Unsupported,
1998    #[serde(rename = "azure-responses-compact")]
1999    AzureResponsesCompact { routing_discriminator: String },
2000}
2001
2002impl AzureResponsesCompactionConfig {
2003    fn to_oven(&self) -> AzureOpenAiResponsesCompaction {
2004        match self {
2005            Self::Unsupported => AzureOpenAiResponsesCompaction::Unsupported,
2006            Self::AzureResponsesCompact {
2007                routing_discriminator,
2008            } => AzureOpenAiResponsesCompaction::V1 {
2009                routing_discriminator: routing_discriminator.clone(),
2010            },
2011        }
2012    }
2013}
2014
2015#[derive(Clone, Debug, Default, Deserialize, Serialize)]
2016#[serde(deny_unknown_fields)]
2017pub struct AzureChatOptionsConfig {
2018    pub user: Option<String>,
2019    pub reasoning_effort: Option<String>,
2020    pub service_tier: Option<String>,
2021    pub verbosity: Option<String>,
2022    pub parallel_tool_calls: Option<bool>,
2023}
2024
2025impl From<&AzureChatOptionsConfig> for AzureOpenAiChatOptions {
2026    fn from(value: &AzureChatOptionsConfig) -> Self {
2027        Self {
2028            user: value.user.clone(),
2029            reasoning_effort: value.reasoning_effort.clone(),
2030            service_tier: value.service_tier.clone(),
2031            verbosity: value.verbosity.clone(),
2032            parallel_tool_calls: value.parallel_tool_calls,
2033            prompt_cache_key: None,
2034            prompt_cache_options: None,
2035            prompt_cache_retention: None,
2036        }
2037    }
2038}
2039
2040#[derive(Clone, Debug, Default, Deserialize, Serialize)]
2041#[serde(deny_unknown_fields)]
2042pub struct AzureResponsesOptionsConfig {
2043    #[serde(default)]
2044    pub include: Vec<String>,
2045    pub user: Option<String>,
2046    pub service_tier: Option<String>,
2047    pub verbosity: Option<String>,
2048    pub reasoning_summary: Option<String>,
2049    pub reasoning_mode: Option<String>,
2050    pub truncation: Option<String>,
2051    pub parallel_tool_calls: Option<bool>,
2052}
2053
2054impl From<&AzureResponsesOptionsConfig> for AzureOpenAiResponsesOptions {
2055    fn from(value: &AzureResponsesOptionsConfig) -> Self {
2056        Self {
2057            include: value.include.clone(),
2058            user: value.user.clone(),
2059            service_tier: value.service_tier.clone(),
2060            verbosity: value.verbosity.clone(),
2061            reasoning_summary: value.reasoning_summary.clone(),
2062            reasoning_mode: value.reasoning_mode.clone(),
2063            truncation: value.truncation.clone(),
2064            parallel_tool_calls: value.parallel_tool_calls,
2065            prompt_cache_key: None,
2066            prompt_cache_options: None,
2067            prompt_cache_retention: None,
2068        }
2069    }
2070}
2071
2072#[derive(Clone, Debug, Deserialize, Serialize)]
2073#[serde(deny_unknown_fields)]
2074pub struct CohereThinkingConfig {
2075    pub enabled: bool,
2076    pub token_budget: Option<u64>,
2077}
2078
2079impl CohereThinkingConfig {
2080    fn to_oven(&self) -> CohereThinking {
2081        CohereThinking {
2082            enabled: self.enabled,
2083            token_budget: self.token_budget,
2084        }
2085    }
2086}
2087
2088#[derive(Clone, Debug, Default, Deserialize, Serialize)]
2089#[serde(deny_unknown_fields)]
2090pub struct CohereSettingsConfig {
2091    #[serde(default)]
2092    pub timeouts: TimeoutsConfig,
2093    pub strict_tools: bool,
2094    pub safety_mode: Option<String>,
2095    pub thinking: Option<CohereThinkingConfig>,
2096    #[serde(default)]
2097    pub reasoning_effort: BTreeMap<String, CohereThinkingConfig>,
2098    pub top_k: Option<u32>,
2099    pub seed: Option<u64>,
2100    pub frequency_penalty: Option<f64>,
2101    pub presence_penalty: Option<f64>,
2102    #[serde(default)]
2103    pub stop_sequences: Vec<String>,
2104    pub priority: Option<i64>,
2105}
2106
2107impl CohereSettingsConfig {
2108    fn to_oven(&self) -> CohereSettings {
2109        CohereSettings {
2110            timeouts: self.timeouts.cohere(),
2111            strict_tools: self.strict_tools,
2112            safety_mode: self.safety_mode.clone(),
2113            thinking: self.thinking.as_ref().map(CohereThinkingConfig::to_oven),
2114            reasoning_effort: self
2115                .reasoning_effort
2116                .iter()
2117                .map(|(effort, thinking)| (effort.clone(), thinking.to_oven()))
2118                .collect(),
2119            top_k: self.top_k,
2120            seed: self.seed,
2121            frequency_penalty: self.frequency_penalty,
2122            presence_penalty: self.presence_penalty,
2123            stop_sequences: self.stop_sequences.clone(),
2124            priority: self.priority,
2125        }
2126    }
2127}
2128
2129#[derive(Clone, Debug, Deserialize, Serialize)]
2130#[serde(deny_unknown_fields)]
2131pub struct CohereDocumentConfig {
2132    pub id: Option<String>,
2133    pub data: Map<String, Value>,
2134}
2135
2136#[derive(Clone, Debug, Default, Deserialize, Serialize)]
2137#[serde(deny_unknown_fields)]
2138pub struct CohereOptionsConfig {
2139    #[serde(default)]
2140    pub documents: Vec<CohereDocumentConfig>,
2141    pub citation_mode: Option<String>,
2142    pub image_detail: Option<String>,
2143}
2144
2145impl CohereOptionsConfig {
2146    fn to_oven(&self) -> CohereRequestOptions {
2147        CohereRequestOptions {
2148            documents: self
2149                .documents
2150                .iter()
2151                .map(|document| CohereDocument {
2152                    id: document.id.clone(),
2153                    data: document.data.clone(),
2154                })
2155                .collect(),
2156            citation_mode: self.citation_mode.clone(),
2157            image_detail: self.image_detail.clone(),
2158        }
2159    }
2160}
2161
2162#[cfg(test)]
2163mod tests {
2164    use super::*;
2165
2166    #[test]
2167    fn auth_routing_discriminator_composes_with_effective_header_templates() {
2168        let settings = OpenAiResponsesSettingsConfig {
2169            routing_discriminator: Some("header:x-api-key".into()),
2170            ..OpenAiResponsesSettingsConfig::default()
2171        };
2172        let one = header_routing_discriminator(&BTreeMap::from([(
2173            "x-route".into(),
2174            "route-one-secret".into(),
2175        )]));
2176        let two = header_routing_discriminator(&BTreeMap::from([(
2177            "x-route".into(),
2178            "route-two-secret".into(),
2179        )]));
2180        let first = settings
2181            .to_openai_responses(Some(&one))
2182            .routing_discriminator
2183            .unwrap();
2184        assert_ne!(
2185            first,
2186            settings
2187                .to_openai_responses(Some(&two))
2188                .routing_discriminator
2189                .unwrap()
2190        );
2191        assert_ne!(
2192            first,
2193            settings
2194                .to_openai_responses(None)
2195                .routing_discriminator
2196                .unwrap()
2197        );
2198        assert!(!first.contains("route-one-secret"));
2199        let other_auth = OpenAiResponsesSettingsConfig {
2200            routing_discriminator: Some("header:api-key".into()),
2201            ..settings
2202        };
2203        assert_ne!(
2204            first,
2205            other_auth
2206                .to_openai_responses(Some(&one))
2207                .routing_discriminator
2208                .unwrap()
2209        );
2210    }
2211
2212    #[test]
2213    fn session_header_templates_resolve_from_request_context() {
2214        let headers = TemplateHeaderProvider::new(&BTreeMap::from([
2215            ("x-session-id".into(), "${session_id}".into()),
2216            ("x-session-parent-id".into(), "${parent_session_id}".into()),
2217        ]))
2218        .unwrap();
2219        let root = headers.headers(&HeaderContext::new("root")).unwrap();
2220        assert_eq!(
2221            root.as_map().get("x-session-id").unwrap(),
2222            &HeaderValue::from_static("root")
2223        );
2224        assert!(!root.as_map().contains_key("x-session-parent-id"));
2225        let child = headers
2226            .headers(&HeaderContext::new("child").with_parent_session_id("root"))
2227            .unwrap();
2228        assert_eq!(child.as_map()["x-session-parent-id"], "root");
2229    }
2230
2231    #[test]
2232    fn literal_header_templates_are_prevalidated_and_stored_as_values() {
2233        let headers = TemplateHeaderProvider::new(&BTreeMap::from([
2234            ("x-literal".into(), "fixed$$value".into()),
2235            ("x-empty".into(), String::new()),
2236        ]))
2237        .unwrap();
2238        assert!(
2239            headers
2240                .templates
2241                .iter()
2242                .all(|(_, template)| matches!(template, HeaderTemplate::Static(_)))
2243        );
2244        let resolved = headers.headers(&HeaderContext::new("unused")).unwrap();
2245        assert_eq!(resolved.as_map()["x-literal"], "fixed$value");
2246        assert!(!resolved.as_map().contains_key("x-empty"));
2247    }
2248
2249    #[test]
2250    fn malformed_header_templates_fail_during_model_construction() {
2251        let error = TemplateHeaderProvider::new(&BTreeMap::from([(
2252            "x-invalid".into(),
2253            "${env:UNFINISHED".into(),
2254        )]))
2255        .err()
2256        .expect("invalid template");
2257        assert!(matches!(error, ModelBuildError::HeaderTemplate(name) if name == "x-invalid"));
2258    }
2259
2260    #[test]
2261    fn responses_compaction_settings_use_explicit_adapter_names() {
2262        let openai: OpenAiResponsesSettingsConfig = serde_json::from_value(json!({
2263            "routing_discriminator": null,
2264            "compaction": "openai-responses-compact"
2265        }))
2266        .expect("OpenAI compaction setting");
2267        assert!(matches!(
2268            openai.compaction,
2269            OpenAiResponsesCompactionConfig::OpenAiResponsesCompact
2270        ));
2271        let azure: AzureResponsesCompactionConfig = serde_json::from_value(json!({
2272            "kind": "azure-responses-compact",
2273            "routing_discriminator": "stable-route"
2274        }))
2275        .expect("Azure compaction setting");
2276        assert!(matches!(
2277            azure,
2278            AzureResponsesCompactionConfig::AzureResponsesCompact { .. }
2279        ));
2280    }
2281
2282    #[test]
2283    fn legacy_v1_compaction_settings_fail_with_migration_message() {
2284        let error = serde_json::from_value::<OpenAiResponsesCompactionConfig>(json!("v1"))
2285            .expect_err("legacy OpenAI setting must fail");
2286        assert!(error.to_string().contains("openai-responses-compact"));
2287        let error = serde_json::from_value::<AzureResponsesCompactionConfig>(json!({
2288            "kind": "v1",
2289            "routing_discriminator": "stable-route"
2290        }))
2291        .expect_err("legacy Azure setting must fail");
2292        assert!(error.to_string().contains("azure-responses-compact"));
2293    }
2294}