Skip to main content

cookie_agent_models/authoring/
mod.rs

1//! Strict provider authoring data transfer objects.
2
3mod cache;
4mod model;
5
6pub use model::{
7    AdaptorOptions, AuthoredCapabilities, ModelPricing, RequestEndpoint, VariantDefinition,
8    WireModelId,
9};
10
11pub use cache::{
12    AnthropicCacheConfig, BedrockCacheConfig, CacheTtl, OpenAiCacheConfig, OpenAiCacheMode,
13    OpenAiCompatibleCacheConfig, OpenAiPromptCacheRetention, OpenAiPromptCacheTtl,
14    ProviderCacheConfig, RollingCacheTtl,
15};
16
17use std::{
18    collections::{BTreeMap, BTreeSet},
19    fmt,
20    net::IpAddr,
21};
22
23use cookie_agent_identity::{
24    AdapterId, AuthFieldName, AuthMethodId, AuthParameterId, ConfiguredModelDefault,
25    ProviderModelId, SafeCode, SetupFieldId, VariantId,
26};
27use http::HeaderName as HttpHeaderName;
28use serde::{Deserialize, Deserializer, Serialize, de};
29use url::Url;
30use zeroize::Zeroize;
31
32pub use crate::model_types::{
33    CancellationCapability, CompactionCapability, FiniteF32, MediaCapability, MediaKind, MimeType,
34    Modality, ModelCapabilities, NativeCompactionConfig, ProviderOptions as CustomProviderOptions,
35    ReasoningBehavior, ReplayCapability, RequestDefaults, ToolChoice,
36};
37
38const MAX_ENDPOINT_BYTES: usize = 2048;
39const MAX_SETUP_STRING_BYTES: usize = 8192;
40const MAX_HEADER_ENTRIES: usize = 64;
41const MAX_HEADER_NAME_BYTES: usize = 128;
42const MAX_HEADER_VALUE_BYTES: usize = 8192;
43const MAX_HEADER_AGGREGATE_BYTES: usize = 65_536;
44
45/// Secret-bearing string. Formatting and serialization are deliberately unavailable.
46#[derive(Clone, Eq, PartialEq)]
47pub struct SecretString(String);
48
49impl SecretString {
50    pub fn new(value: impl Into<String>) -> Result<Self, AuthoringError> {
51        let value = value.into();
52        if value.is_empty() {
53            Err(AuthoringError::EmptySecret)
54        } else {
55            Ok(Self(value))
56        }
57    }
58
59    pub(crate) fn expose(&self) -> &str {
60        &self.0
61    }
62}
63
64impl fmt::Debug for SecretString {
65    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        formatter.write_str("SecretString(<redacted>)")
67    }
68}
69
70impl<'de> Deserialize<'de> for SecretString {
71    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
72    where
73        D: Deserializer<'de>,
74    {
75        Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
76    }
77}
78
79impl Drop for SecretString {
80    fn drop(&mut self) {
81        self.0.zeroize();
82    }
83}
84
85/// Absolute, query-free endpoint URL accepted in provider configuration.
86#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
87#[serde(transparent)]
88pub struct EndpointUrl(String);
89
90impl EndpointUrl {
91    pub fn new(value: impl Into<String>) -> Result<Self, AuthoringError> {
92        let value = value.into();
93        if value.is_empty() || value.len() > MAX_ENDPOINT_BYTES {
94            return Err(AuthoringError::Endpoint);
95        }
96        let parsed = Url::parse(&value).map_err(|_| AuthoringError::Endpoint)?;
97        if !parsed.has_host()
98            || !parsed.username().is_empty()
99            || parsed.password().is_some()
100            || parsed.query().is_some()
101            || parsed.fragment().is_some()
102        {
103            return Err(AuthoringError::Endpoint);
104        }
105        let secure = parsed.scheme() == "https";
106        let loopback_http = parsed.scheme() == "http"
107            && parsed.host_str().is_some_and(|host| {
108                host == "localhost" || host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
109            });
110        if !secure && !loopback_http {
111            return Err(AuthoringError::Endpoint);
112        }
113        Ok(Self(value))
114    }
115
116    #[must_use]
117    pub fn as_str(&self) -> &str {
118        &self.0
119    }
120}
121
122impl<'de> Deserialize<'de> for EndpointUrl {
123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124    where
125        D: Deserializer<'de>,
126    {
127        Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
128    }
129}
130
131#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
132#[serde(transparent)]
133pub struct BoundedSetupString(String);
134
135impl BoundedSetupString {
136    pub fn new(value: impl Into<String>) -> Result<Self, AuthoringError> {
137        let value = value.into();
138        if value.len() <= MAX_SETUP_STRING_BYTES && !value.chars().any(char::is_control) {
139            Ok(Self(value))
140        } else {
141            Err(AuthoringError::SetupValue)
142        }
143    }
144    #[must_use]
145    pub fn as_str(&self) -> &str {
146        &self.0
147    }
148}
149
150impl<'de> Deserialize<'de> for BoundedSetupString {
151    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
152    where
153        D: Deserializer<'de>,
154    {
155        Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
156    }
157}
158
159#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
160#[serde(untagged)]
161pub enum SafeSetupValue {
162    String(BoundedSetupString),
163    Code(SafeCode),
164    Integer(i64),
165    Bool(bool),
166}
167
168pub type ConfigSetupValue = SafeSetupValue;
169
170#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
171#[serde(transparent)]
172pub struct SafeAuthParameterValue(String);
173
174impl SafeAuthParameterValue {
175    pub fn new(value: impl Into<String>) -> Result<Self, AuthoringError> {
176        let value = value.into();
177        if !value.is_empty()
178            && value.len() <= 512
179            && !value.chars().any(char::is_control)
180            && !value.contains("${env:")
181        {
182            Ok(Self(value))
183        } else {
184            Err(AuthoringError::AuthParameter)
185        }
186    }
187    #[must_use]
188    pub fn as_str(&self) -> &str {
189        &self.0
190    }
191}
192
193impl<'de> Deserialize<'de> for SafeAuthParameterValue {
194    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
195    where
196        D: Deserializer<'de>,
197    {
198        Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
199    }
200}
201
202#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
203#[serde(transparent)]
204pub struct HeaderName(String);
205
206impl HeaderName {
207    pub fn new(value: impl Into<String>) -> Result<Self, AuthoringError> {
208        let value = value.into();
209        if value.is_empty() || value.len() > MAX_HEADER_NAME_BYTES || !value.is_ascii() {
210            return Err(AuthoringError::HeaderName);
211        }
212        let canonical = value.to_ascii_lowercase();
213        HttpHeaderName::from_bytes(canonical.as_bytes()).map_err(|_| AuthoringError::HeaderName)?;
214        Ok(Self(canonical))
215    }
216    #[must_use]
217    pub fn as_str(&self) -> &str {
218        &self.0
219    }
220}
221
222impl<'de> Deserialize<'de> for HeaderName {
223    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
224    where
225        D: Deserializer<'de>,
226    {
227        Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
228    }
229}
230
231#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
232#[serde(transparent)]
233pub struct SafeStaticHeaderValue(String);
234
235impl SafeStaticHeaderValue {
236    pub fn new(value: impl Into<String>) -> Result<Self, AuthoringError> {
237        let value = value.into();
238        if value.len() <= MAX_HEADER_VALUE_BYTES
239            && !value
240                .chars()
241                .any(|c| c.is_control() || ('\u{7f}'..='\u{9f}').contains(&c))
242        {
243            Ok(Self(value))
244        } else {
245            Err(AuthoringError::HeaderValue)
246        }
247    }
248    #[must_use]
249    pub fn as_str(&self) -> &str {
250        &self.0
251    }
252}
253
254impl<'de> Deserialize<'de> for SafeStaticHeaderValue {
255    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
256    where
257        D: Deserializer<'de>,
258    {
259        Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
260    }
261}
262
263#[derive(Clone, Debug, Deserialize)]
264#[serde(deny_unknown_fields)]
265pub struct AuthOverride {
266    pub method: AuthMethodId,
267    pub values: BTreeMap<AuthFieldName, SecretString>,
268}
269
270#[derive(Clone, Debug, Deserialize)]
271#[serde(deny_unknown_fields)]
272pub struct AuthDefinition {
273    pub method: AuthMethodId,
274    #[serde(default)]
275    pub parameters: BTreeMap<AuthParameterId, SafeAuthParameterValue>,
276    pub values: BTreeMap<AuthFieldName, SecretString>,
277}
278
279#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
280#[serde(deny_unknown_fields)]
281pub struct PartialRequestDefaults {
282    pub temperature: Option<FiniteF32>,
283    pub top_p: Option<FiniteF32>,
284    pub max_output_tokens: Option<u64>,
285    pub stop: Option<Vec<String>>,
286    pub seed: Option<i64>,
287    pub tool_choice: Option<ToolChoice>,
288}
289
290#[derive(Clone, Debug, Deserialize)]
291#[serde(deny_unknown_fields)]
292pub struct ManagedModelOverride {
293    pub enabled: Option<bool>,
294    pub model_id: Option<WireModelId>,
295    pub display_name: Option<String>,
296    #[serde(default, rename = "generation_options")]
297    pub defaults: PartialRequestDefaults,
298    #[serde(default, rename = "adaptor_options")]
299    pub options: AdaptorOptions,
300    pub pricing: Option<ModelPricing>,
301    #[serde(default)]
302    pub variants: BTreeMap<VariantId, VariantDefinition>,
303    pub default_variant: Option<ConfiguredModelDefault>,
304    #[serde(default)]
305    pub compaction: NativeCompactionConfig,
306    #[serde(default, deserialize_with = "deserialize_headers")]
307    pub headers: BTreeMap<HeaderName, SafeStaticHeaderValue>,
308}
309
310#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
311#[serde(rename_all = "lowercase")]
312pub enum ManagedModelShape {
313    Chat,
314    Responses,
315}
316
317#[derive(Clone, Debug, Deserialize)]
318#[serde(deny_unknown_fields)]
319pub struct ModelsDevProvider {
320    pub base_url: Option<EndpointUrl>,
321    #[serde(default)]
322    pub setup: BTreeMap<SetupFieldId, ConfigSetupValue>,
323    pub api_key: Option<SecretString>,
324    pub auth_override: Option<AuthOverride>,
325    pub cache: Option<ProviderCacheConfig>,
326    #[serde(default, deserialize_with = "deserialize_headers")]
327    pub headers: BTreeMap<HeaderName, SafeStaticHeaderValue>,
328    #[serde(default, rename = "models")]
329    pub model_overrides: BTreeMap<ProviderModelId, ManagedModelOverride>,
330}
331
332#[derive(Clone, Debug, Deserialize)]
333#[serde(deny_unknown_fields)]
334pub struct CustomModelDefinition {
335    #[serde(default = "yes")]
336    pub enabled: bool,
337    pub model_id: Option<WireModelId>,
338    pub display_name: String,
339    pub capabilities: AuthoredCapabilities,
340    #[serde(default, rename = "generation_options")]
341    pub defaults: PartialRequestDefaults,
342    #[serde(default, rename = "adaptor_options")]
343    pub options: AdaptorOptions,
344    pub pricing: Option<ModelPricing>,
345    #[serde(default)]
346    pub variants: BTreeMap<VariantId, VariantDefinition>,
347    pub default_variant: Option<ConfiguredModelDefault>,
348    #[serde(default, deserialize_with = "deserialize_headers")]
349    pub headers: BTreeMap<HeaderName, SafeStaticHeaderValue>,
350}
351
352#[derive(Clone, Debug, Deserialize)]
353#[serde(deny_unknown_fields)]
354pub struct CustomProvider {
355    pub endpoint: EndpointUrl,
356    pub adaptor: AdapterId,
357    pub cache: Option<ProviderCacheConfig>,
358    #[serde(default)]
359    pub setup: BTreeMap<SetupFieldId, ConfigSetupValue>,
360    pub auth: AuthDefinition,
361    #[serde(default, deserialize_with = "deserialize_headers")]
362    pub headers: BTreeMap<HeaderName, SafeStaticHeaderValue>,
363    pub models: BTreeMap<ProviderModelId, CustomModelDefinition>,
364}
365
366#[derive(Clone, Debug, Deserialize)]
367#[serde(tag = "source", rename_all = "snake_case")]
368pub enum ProviderDefinition {
369    ModelsDev(ModelsDevProvider),
370    Custom(CustomProvider),
371}
372
373impl ProviderDefinition {
374    pub fn validate_for(
375        &self,
376        _provider_id: &cookie_agent_identity::ProviderId,
377    ) -> Result<(), AuthoringError> {
378        match self {
379            Self::ModelsDev(provider) => {
380                if provider.api_key.is_some() && provider.auth_override.is_some() {
381                    return Err(AuthoringError::AuthConflict);
382                }
383                if provider.base_url.is_some()
384                    && provider.api_key.is_none()
385                    && provider.auth_override.is_none()
386                {
387                    return Err(AuthoringError::BaseUrlWithoutAuth);
388                }
389                validate_display_overrides(provider)?;
390            }
391            Self::Custom(provider) => {
392                validate_custom(provider)?;
393            }
394        }
395        Ok(())
396    }
397}
398
399fn validate_display_overrides(provider: &ModelsDevProvider) -> Result<(), AuthoringError> {
400    validate_header_ownership(&provider.headers, "provider")?;
401    for (model_id, model) in &provider.model_overrides {
402        validate_header_ownership(&model.headers, format!("model `{model_id}`"))?;
403        for (variant_id, directive) in &model.variants {
404            validate_header_ownership(
405                directive.headers(),
406                format!("model `{model_id}` variant `{variant_id}`"),
407            )?;
408        }
409    }
410    for value in provider
411        .model_overrides
412        .values()
413        .filter_map(|model| model.display_name.as_deref())
414    {
415        validate_display_name(value)?;
416    }
417    Ok(())
418}
419
420fn validate_custom(provider: &CustomProvider) -> Result<(), AuthoringError> {
421    if provider.models.is_empty() {
422        return Err(AuthoringError::EmptyModels);
423    }
424    validate_auth_shape(&provider.auth, provider.adaptor.as_str())?;
425    validate_provider_cache(provider.cache.as_ref(), provider.adaptor.as_str())
426        .map_err(AuthoringError::Cache)?;
427    validate_header_ownership(&provider.headers, "provider")?;
428    for (model_id, model) in &provider.models {
429        validate_header_ownership(&model.headers, format!("model `{model_id}`"))?;
430        for (variant_id, directive) in &model.variants {
431            validate_header_ownership(
432                directive.headers(),
433                format!("model `{model_id}` variant `{variant_id}`"),
434            )?;
435        }
436        validate_display_name(&model.display_name)?;
437        let adapter = crate::adapters::OvenAdapterFamily::parse(provider.adaptor.as_str())
438            .ok_or(AuthoringError::Adaptor)?;
439        let capabilities = model.capabilities.resolve(adapter);
440        validate_capabilities(&capabilities)?;
441        validate_defaults(&model.defaults.resolve(), &capabilities)?;
442        if let Some(ConfiguredModelDefault::Named(id)) = &model.default_variant
443            && !model.variants.contains_key(id)
444        {
445            return Err(AuthoringError::DefaultVariant);
446        }
447    }
448    Ok(())
449}
450
451pub(crate) fn validate_provider_cache(
452    cache: Option<&ProviderCacheConfig>,
453    adaptor: &str,
454) -> Result<(), String> {
455    let Some(cache) = cache else {
456        return Ok(());
457    };
458    let result = match adaptor {
459        "anthropic" | "anthropic-compatible" => cache.anthropic().map(|_| ()),
460        "aws-bedrock-converse" => cache.bedrock().map(|_| ()),
461        "openai-chat" | "openai-responses" | "azure-openai-chat" | "azure-openai-responses" => {
462            cache.openai().map(|_| ())
463        }
464        "openai-compatible" => cache.openai_compatible().map(|_| ()),
465        "google-gemini" | "google-vertex-gemini" => {
466            return Err("Google caching is always implicit".into());
467        }
468        "cohere-v2-chat" => {
469            return Err(
470                "the provider adaptor does not support authored cache configuration".into(),
471            );
472        }
473        _ => return Err("unsupported adaptor".into()),
474    };
475    result.map_err(|error| format!("invalid cache config for `{adaptor}`: {error}"))
476}
477
478fn validate_display_name(value: &str) -> Result<(), AuthoringError> {
479    if value.is_empty()
480        || value.len() > 512
481        || value.trim().is_empty()
482        || value.chars().any(char::is_control)
483    {
484        Err(AuthoringError::DisplayName)
485    } else {
486        Ok(())
487    }
488}
489
490fn validate_capabilities(value: &ModelCapabilities) -> Result<(), AuthoringError> {
491    if value.input.is_empty()
492        || value.output.is_empty()
493        || value.context_tokens == 0
494        || value.output_tokens == 0
495        || value.output_tokens > value.context_tokens
496        || value.parallel_tool_calls && !value.tool_calling
497        || value.compaction != CompactionCapability::Unsupported
498    {
499        return Err(AuthoringError::Capabilities);
500    }
501    for kind in [
502        MediaKind::Image,
503        MediaKind::Audio,
504        MediaKind::Pdf,
505        MediaKind::Video,
506    ] {
507        let modality = match kind {
508            MediaKind::Image => Modality::Image,
509            MediaKind::Audio => Modality::Audio,
510            MediaKind::Pdf => Modality::Pdf,
511            MediaKind::Video => Modality::Video,
512        };
513        let declared = value.input.contains(&modality);
514        let media = value.media.get(&kind);
515        if value.output.contains(&modality)
516            || declared != media.is_some()
517            || media
518                .is_some_and(|m| m.mime_types.is_empty() || m.max_bytes == 0 || m.max_count == 0)
519        {
520            return Err(AuthoringError::Capabilities);
521        }
522    }
523    Ok(())
524}
525
526fn validate_defaults(
527    value: &RequestDefaults,
528    capabilities: &ModelCapabilities,
529) -> Result<(), AuthoringError> {
530    if value
531        .max_output_tokens
532        .is_some_and(|n| n == 0 || n > capabilities.output_tokens)
533        || value.temperature.is_some() && !capabilities.temperature
534        || value.top_p.is_some() && !capabilities.top_p
535        || value.seed.is_some() && !capabilities.seed
536        || value.tool_choice.is_some() && !capabilities.tool_calling
537    {
538        Err(AuthoringError::Defaults)
539    } else {
540        Ok(())
541    }
542}
543
544fn validate_auth_shape(auth: &AuthDefinition, adaptor: &str) -> Result<(), AuthoringError> {
545    let method = auth.method.as_str();
546    let allowed: BTreeSet<&str> = match adaptor {
547        "openai-compatible" => ["bearer-api-key-v1", "api-key-header-v1", "no-auth-v1"]
548            .into_iter()
549            .collect(),
550        "openai-chat" | "openai-responses" => {
551            ["bearer-api-key-v1", "no-auth-v1"].into_iter().collect()
552        }
553        "anthropic" | "anthropic-compatible" => ["anthropic-api-key-v1"].into_iter().collect(),
554        "google-gemini" => ["google-api-key-header-v1"].into_iter().collect(),
555        "google-vertex-gemini" => ["oauth-access-token-v1"].into_iter().collect(),
556        "aws-bedrock-converse" => ["aws-sigv4-credentials-v1"].into_iter().collect(),
557        "azure-openai-chat" | "azure-openai-responses" => {
558            ["azure-api-key-v1"].into_iter().collect()
559        }
560        "cohere-v2-chat" => ["bearer-api-key-v1"].into_iter().collect(),
561        _ => return Err(AuthoringError::Adaptor),
562    };
563    if !allowed.contains(method) {
564        return Err(AuthoringError::AuthMethod);
565    }
566    let fields: BTreeSet<&str> = auth.values.keys().map(AuthFieldName::as_str).collect();
567    let exact = match method {
568        "no-auth-v1" => fields.is_empty() && auth.parameters.is_empty(),
569        "bearer-api-key-v1"
570        | "anthropic-api-key-v1"
571        | "google-api-key-header-v1"
572        | "azure-api-key-v1" => fields == BTreeSet::from(["api_key"]) && auth.parameters.is_empty(),
573        "oauth-access-token-v1" => {
574            fields == BTreeSet::from(["access_token"]) && auth.parameters.is_empty()
575        }
576        "api-key-header-v1" => {
577            fields == BTreeSet::from(["api_key"])
578                && auth.parameters.len() == 1
579                && auth
580                    .parameters
581                    .get(&AuthParameterId::new("header_name").expect("static id"))
582                    .is_some_and(|v| matches!(v.as_str(), "x-api-key" | "api-key"))
583        }
584        "aws-sigv4-credentials-v1" => {
585            (fields == BTreeSet::from(["access_key_id", "secret_access_key"])
586                || fields
587                    == BTreeSet::from(["access_key_id", "secret_access_key", "session_token"]))
588                && auth.parameters.is_empty()
589        }
590        _ => false,
591    };
592    if exact {
593        Ok(())
594    } else {
595        Err(AuthoringError::AuthShape)
596    }
597}
598
599pub fn validate_header_ownership(
600    headers: &BTreeMap<HeaderName, SafeStaticHeaderValue>,
601    scope: impl Into<String>,
602) -> Result<(), AuthoringError> {
603    const TRANSPORT_OWNED: &[&str] = &[
604        "host",
605        "content-length",
606        "transfer-encoding",
607        "connection",
608        "proxy-authorization",
609    ];
610    const PROTOCOL_OWNED: &[&str] = &[
611        "accept",
612        "content-type",
613        "anthropic-version",
614        "anthropic-beta",
615    ];
616    let scope = scope.into();
617    for name in headers.keys() {
618        let owner = if TRANSPORT_OWNED.contains(&name.as_str()) {
619            Some("HTTP transport")
620        } else if PROTOCOL_OWNED.contains(&name.as_str()) || name.as_str().starts_with("x-amz-") {
621            Some("protocol adapter")
622        } else {
623            None
624        };
625        if let Some(owner) = owner {
626            return Err(AuthoringError::HeaderOwned {
627                scope,
628                header: name.as_str().to_owned(),
629                owner,
630            });
631        }
632    }
633    Ok(())
634}
635
636pub fn deserialize_headers<'de, D>(
637    deserializer: D,
638) -> Result<BTreeMap<HeaderName, SafeStaticHeaderValue>, D::Error>
639where
640    D: Deserializer<'de>,
641{
642    struct Visitor;
643    impl<'de> de::Visitor<'de> for Visitor {
644        type Value = BTreeMap<HeaderName, SafeStaticHeaderValue>;
645        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
646            f.write_str("a bounded static header map")
647        }
648        fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
649        where
650            A: de::MapAccess<'de>,
651        {
652            let mut result = BTreeMap::new();
653            let mut aggregate = 0usize;
654            while let Some((name, value)) = map.next_entry::<HeaderName, SafeStaticHeaderValue>()? {
655                aggregate = aggregate.saturating_add(name.as_str().len() + value.as_str().len());
656                if result.len() == MAX_HEADER_ENTRIES || aggregate > MAX_HEADER_AGGREGATE_BYTES {
657                    return Err(de::Error::custom("static header limits exceeded"));
658                }
659                if result.insert(name, value).is_some() {
660                    return Err(de::Error::custom("duplicate case-insensitive header name"));
661                }
662            }
663            Ok(result)
664        }
665    }
666    deserializer.deserialize_map(Visitor)
667}
668
669fn deserialize_optional_model_headers<'de, D>(
670    deserializer: D,
671) -> Result<Option<BTreeMap<HeaderName, SafeStaticHeaderValue>>, D::Error>
672where
673    D: Deserializer<'de>,
674{
675    deserialize_headers(deserializer).map(Some)
676}
677
678pub fn validate_header_limits(
679    headers: &BTreeMap<HeaderName, SafeStaticHeaderValue>,
680) -> Result<(), AuthoringError> {
681    let aggregate = headers.iter().fold(0usize, |total, (name, value)| {
682        total.saturating_add(name.as_str().len() + value.as_str().len())
683    });
684    if headers.len() > MAX_HEADER_ENTRIES || aggregate > MAX_HEADER_AGGREGATE_BYTES {
685        Err(AuthoringError::HeaderLimits)
686    } else {
687        Ok(())
688    }
689}
690
691const fn yes() -> bool {
692    true
693}
694
695#[derive(Clone, Debug, thiserror::Error, Eq, PartialEq)]
696pub enum AuthoringError {
697    #[error("secret values must be nonempty")]
698    EmptySecret,
699    #[error("invalid endpoint URL")]
700    Endpoint,
701    #[error("invalid setup value")]
702    SetupValue,
703    #[error("invalid safe auth parameter")]
704    AuthParameter,
705    #[error("invalid static header name")]
706    HeaderName,
707    #[error("invalid static header value")]
708    HeaderValue,
709    #[error("api_key and auth_override are mutually exclusive")]
710    AuthConflict,
711    #[error("authored base_url requires same-definition auth")]
712    BaseUrlWithoutAuth,
713    #[error("authored base_url is forbidden by this provider recipe")]
714    BaseUrlForbidden,
715    #[error("api_key is ambiguous for this provider recipe")]
716    AmbiguousApiKey,
717    #[error("invalid managed setup fields")]
718    SetupShape,
719    #[error("custom models must be nonempty")]
720    EmptyModels,
721    #[error("invalid display name")]
722    DisplayName,
723    #[error(
724        "invalid model capabilities; tool_calling = false requires parallel_tool_calls = false"
725    )]
726    Capabilities,
727    #[error("invalid request defaults")]
728    Defaults,
729    #[error("invalid adaptor-specific model options")]
730    Options,
731    #[error("default variant does not name an authored variant")]
732    DefaultVariant,
733    #[error("unsupported adaptor")]
734    Adaptor,
735    #[error("unsupported auth method")]
736    AuthMethod,
737    #[error("invalid auth parameter or credential fields")]
738    AuthShape,
739    #[error("{scope} header `{header}` is owned by the {owner}")]
740    HeaderOwned {
741        scope: String,
742        header: String,
743        owner: &'static str,
744    },
745    #[error("static header limits exceeded")]
746    HeaderLimits,
747    #[error("{0}")]
748    Cache(String),
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754
755    fn anthropic_compatible_provider(method: &str) -> CustomProvider {
756        CustomProvider {
757            endpoint: EndpointUrl::new("https://api.example.invalid/v1").expect("valid endpoint"),
758            adaptor: AdapterId::new("anthropic-compatible").expect("valid adaptor ID"),
759            cache: None,
760            setup: BTreeMap::new(),
761            auth: AuthDefinition {
762                method: AuthMethodId::new(method).expect("valid auth method ID"),
763                parameters: BTreeMap::new(),
764                values: BTreeMap::from([(
765                    AuthFieldName::new("api_key").expect("valid auth field name"),
766                    SecretString::new("secret").expect("nonempty secret"),
767                )]),
768            },
769            headers: BTreeMap::new(),
770            models: BTreeMap::new(),
771        }
772    }
773
774    #[test]
775    fn anthropic_compatible_accepts_only_anthropic_api_key_auth() {
776        let supported = anthropic_compatible_provider("anthropic-api-key-v1");
777        assert_eq!(
778            validate_auth_shape(&supported.auth, supported.adaptor.as_str()),
779            Ok(())
780        );
781        let unsupported = anthropic_compatible_provider("bearer-api-key-v1");
782        assert_eq!(
783            validate_auth_shape(&unsupported.auth, unsupported.adaptor.as_str()),
784            Err(AuthoringError::AuthMethod)
785        );
786    }
787
788    fn cache(value: serde_json::Value) -> ProviderCacheConfig {
789        serde_json::from_value(value).expect("valid provider cache envelope")
790    }
791
792    #[test]
793    fn provider_cache_shapes_are_validated_against_adaptors() {
794        let cases = [
795            (
796                "anthropic",
797                serde_json::json!({"system":"1h","tools":"1h","rolling":"5m"}),
798            ),
799            (
800                "aws-bedrock-converse",
801                serde_json::json!({"system":"5m","tools":"5m","rolling":"off"}),
802            ),
803            (
804                "openai-responses",
805                serde_json::json!({"mode":"explicit","ttl":"30m","system":true}),
806            ),
807            (
808                "openai-compatible",
809                serde_json::json!({"prompt_cache_key":"tenant-${session_id}"}),
810            ),
811        ];
812        for (adaptor, value) in cases {
813            assert_eq!(
814                validate_provider_cache(Some(&cache(value)), adaptor),
815                Ok(())
816            );
817        }
818
819        let error = validate_provider_cache(
820            Some(&cache(serde_json::json!({"system":"1h"}))),
821            "openai-chat",
822        )
823        .unwrap_err();
824        assert!(error.contains("1h"), "{error}");
825        let error = validate_provider_cache(
826            Some(&cache(serde_json::json!({"system":true}))),
827            "anthropic",
828        )
829        .unwrap_err();
830        assert!(error.contains("boolean"), "{error}");
831        assert_eq!(
832            validate_provider_cache(Some(&cache(serde_json::json!({}))), "google-gemini"),
833            Err("Google caching is always implicit".into())
834        );
835    }
836
837    #[test]
838    fn provider_cache_removed_and_unknown_fields_are_rejected() {
839        for value in [
840            serde_json::json!({"enabled":false}),
841            serde_json::json!({"messages":[]}),
842            serde_json::json!({"unknown":true}),
843        ] {
844            assert!(serde_json::from_value::<ProviderCacheConfig>(value).is_err());
845        }
846        let error = validate_provider_cache(
847            Some(&cache(serde_json::json!({"prompt_cache_key":"x"}))),
848            "openai-chat",
849        )
850        .unwrap_err();
851        assert!(error.contains("prompt_cache_key"), "{error}");
852    }
853
854    #[test]
855    fn compatible_prompt_cache_key_accepts_default_disable_and_session_template() {
856        for value in [
857            serde_json::json!({}),
858            serde_json::json!({"prompt_cache_key":""}),
859            serde_json::json!({"prompt_cache_key":"prefix-${session_id}"}),
860        ] {
861            assert_eq!(
862                validate_provider_cache(Some(&cache(value)), "openai-compatible"),
863                Ok(())
864            );
865        }
866        let error = validate_provider_cache(
867            Some(&cache(serde_json::json!({"prompt_cache_key":"${other}"}))),
868            "openai-compatible",
869        )
870        .unwrap_err();
871        assert!(error.contains("${session_id}"), "{error}");
872    }
873}