1mod 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#[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#[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}