1use std::{collections::BTreeMap, time::Duration};
2
3use cookie_agent_identity::{ModelKey, ProviderId};
4pub use cookie_agent_models::catalog::PicoUsdPerMillion;
5use cookie_agent_models::{
6 HeaderName, ProviderDefinition, SafeStaticHeaderValue, deserialize_headers,
7 validate_header_limits, validate_header_ownership,
8};
9use indexmap::IndexMap;
10use serde::{Deserialize, Serialize};
11
12use crate::ConfigError;
13use crate::toml_values::{SensitiveProviderValues, zeroize_toml_value};
14use zeroize::Zeroize;
15
16const DEFAULT_HOST: &str = "127.0.0.1";
17const DEFAULT_PORT: u16 = 7419;
18
19#[derive(Clone, Debug, Default, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct McpConfig {
22 #[serde(default)]
23 pub servers: BTreeMap<String, McpServerConfig>,
24}
25
26#[derive(Clone, Debug, Default, Deserialize)]
27pub struct PluginsConfig {
28 #[serde(flatten)]
29 pub plugins: IndexMap<String, PluginConfig>,
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct PluginConfig {
35 pub command: Option<String>,
36 #[serde(default)]
37 pub args: Vec<String>,
38 #[serde(default)]
39 pub env: BTreeMap<String, String>,
40 pub cwd: Option<String>,
41 #[serde(default = "yes")]
42 pub enabled: bool,
43 #[serde(default)]
44 pub producer_messaging: bool,
45 #[serde(default = "default_plugin_interception_timeout_ms")]
46 pub interception_timeout_ms: u64,
47 #[serde(default = "default_plugin_startup_timeout_ms")]
48 pub startup_timeout_ms: u64,
49 #[serde(default = "default_plugin_shutdown_grace_ms")]
50 pub shutdown_grace_ms: u64,
51 #[serde(default = "default_plugin_tool_timeout_ms")]
52 pub tool_timeout_ms: u64,
53}
54
55impl PluginConfig {
56 pub fn validate(&self, name: &str) -> Result<(), ConfigError> {
57 self.invalid_field(name).map_or(Ok(()), |field| {
58 Err(ConfigError::Plugin {
59 plugin: name.to_owned(),
60 reason: format!("field `{field}` is invalid"),
61 })
62 })
63 }
64
65 pub(crate) fn invalid_field(&self, name: &str) -> Option<&'static str> {
66 if name.is_empty() || name.len() > 128 || name.chars().any(char::is_control) {
67 Some("name")
68 } else if self.command.as_ref().is_none_or(String::is_empty) {
69 Some("command")
70 } else if self.cwd.as_ref().is_some_and(String::is_empty) {
71 Some("cwd")
72 } else if self.interception_timeout_ms == 0 {
73 Some("interception_timeout_ms")
74 } else if self.startup_timeout_ms == 0 {
75 Some("startup_timeout_ms")
76 } else if self.shutdown_grace_ms == 0 {
77 Some("shutdown_grace_ms")
78 } else if self.tool_timeout_ms == 0 {
79 Some("tool_timeout_ms")
80 } else {
81 None
82 }
83 }
84}
85
86const fn default_plugin_interception_timeout_ms() -> u64 {
87 2_000
88}
89
90const fn default_plugin_startup_timeout_ms() -> u64 {
91 10_000
92}
93
94const fn default_plugin_shutdown_grace_ms() -> u64 {
95 3_000
96}
97
98const fn default_plugin_tool_timeout_ms() -> u64 {
99 30_000
100}
101
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
103#[serde(deny_unknown_fields)]
104pub struct McpServerConfig {
105 pub command: Option<String>,
106 #[serde(default)]
107 pub args: Vec<String>,
108 #[serde(default)]
109 pub env: BTreeMap<String, String>,
110 pub cwd: Option<String>,
111 pub url: Option<String>,
112 #[serde(default)]
113 pub headers: BTreeMap<String, String>,
114 #[serde(default, skip_serializing_if = "McpOAuthConfig::is_auto")]
115 pub oauth: McpOAuthConfig,
116 #[serde(default = "yes")]
117 pub enabled: bool,
118 #[serde(default)]
119 pub lazy: bool,
120 pub timeout_ms: Option<u64>,
121}
122
123#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
124#[serde(deny_unknown_fields)]
125pub struct McpOAuthSettings {
126 pub client_id: Option<String>,
127 pub client_secret: Option<String>,
128 pub client_metadata_url: Option<String>,
129 #[serde(default)]
130 pub scopes: Vec<String>,
131}
132
133impl std::fmt::Debug for McpOAuthSettings {
134 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 formatter
136 .debug_struct("McpOAuthSettings")
137 .field("client_id", &self.client_id)
138 .field(
139 "client_secret",
140 &self.client_secret.as_ref().map(|_| "[REDACTED]"),
141 )
142 .field("client_metadata_url", &self.client_metadata_url)
143 .field("scopes", &self.scopes)
144 .finish()
145 }
146}
147
148#[derive(Clone, Default, Eq, PartialEq)]
149pub enum McpOAuthConfig {
150 #[default]
151 Auto,
152 Enabled,
153 Disabled,
154 Settings(McpOAuthSettings),
155}
156
157impl McpOAuthConfig {
158 #[must_use]
159 pub const fn is_auto(&self) -> bool {
160 matches!(self, Self::Auto)
161 }
162
163 #[must_use]
164 pub const fn enabled(&self) -> bool {
165 !matches!(self, Self::Disabled)
166 }
167
168 #[must_use]
169 pub fn settings(&self) -> McpOAuthSettings {
170 match self {
171 Self::Settings(settings) => settings.clone(),
172 Self::Auto | Self::Enabled | Self::Disabled => McpOAuthSettings::default(),
173 }
174 }
175}
176
177impl std::fmt::Debug for McpOAuthConfig {
178 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 match self {
180 Self::Auto => formatter.write_str("Auto"),
181 Self::Enabled => formatter.write_str("Enabled"),
182 Self::Disabled => formatter.write_str("Disabled"),
183 Self::Settings(settings) => formatter
184 .debug_struct("Settings")
185 .field("client_id", &settings.client_id)
186 .field(
187 "client_secret",
188 &settings.client_secret.as_ref().map(|_| "[REDACTED]"),
189 )
190 .field("client_metadata_url", &settings.client_metadata_url)
191 .field("scopes", &settings.scopes)
192 .finish(),
193 }
194 }
195}
196
197impl Serialize for McpOAuthConfig {
198 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
199 where
200 S: serde::Serializer,
201 {
202 match self {
203 Self::Auto | Self::Enabled => serializer.serialize_bool(true),
204 Self::Disabled => serializer.serialize_bool(false),
205 Self::Settings(settings) => settings.serialize(serializer),
206 }
207 }
208}
209
210impl<'de> Deserialize<'de> for McpOAuthConfig {
211 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
212 where
213 D: serde::Deserializer<'de>,
214 {
215 #[derive(Deserialize)]
216 #[serde(untagged)]
217 enum OAuthValue {
218 Bool(bool),
219 Settings(McpOAuthSettings),
220 }
221
222 Ok(match OAuthValue::deserialize(deserializer)? {
223 OAuthValue::Bool(true) => Self::Enabled,
224 OAuthValue::Bool(false) => Self::Disabled,
225 OAuthValue::Settings(settings) => Self::Settings(settings),
226 })
227 }
228}
229
230impl McpServerConfig {
231 pub fn validate(&self, name: &str) -> Result<(), ConfigError> {
232 let invalid_name =
233 name.is_empty() || name.len() > 128 || name.chars().any(char::is_control);
234 let transport_count = usize::from(self.command.is_some()) + usize::from(self.url.is_some());
235 let invalid_stdio = self.command.as_ref().is_some_and(String::is_empty)
236 || self.cwd.as_ref().is_some_and(String::is_empty);
237 let invalid_remote = self.url.as_ref().is_some_and(|url| {
238 url::Url::parse(url)
239 .map(|parsed| !matches!(parsed.scheme(), "http" | "https"))
240 .unwrap_or(true)
241 });
242 let mixed = self.command.is_some() && (!self.headers.is_empty() || !self.oauth.is_auto())
243 || self.url.is_some()
244 && (!self.args.is_empty() || !self.env.is_empty() || self.cwd.is_some());
245 let invalid_oauth = match &self.oauth {
246 McpOAuthConfig::Settings(settings) => {
247 settings.client_id.as_ref().is_some_and(String::is_empty)
248 || settings
249 .client_secret
250 .as_ref()
251 .is_some_and(String::is_empty)
252 || settings.client_secret.is_some() && settings.client_id.is_none()
253 || settings.scopes.iter().any(String::is_empty)
254 || settings.client_metadata_url.as_ref().is_some_and(|value| {
255 url::Url::parse(value)
256 .map(|url| {
257 url.scheme() != "https"
258 || url.host_str().is_none()
259 || url.path() == "/"
260 })
261 .unwrap_or(true)
262 })
263 }
264 McpOAuthConfig::Auto | McpOAuthConfig::Enabled | McpOAuthConfig::Disabled => false,
265 };
266 if invalid_name
267 || transport_count != 1
268 || invalid_stdio
269 || invalid_remote
270 || mixed
271 || invalid_oauth
272 || self.timeout_ms == Some(0)
273 {
274 return Err(ConfigError::McpServer {
275 server: name.to_owned(),
276 reason: "configure exactly one of command or url; stdio-only and remote-only fields may not be mixed; OAuth client settings must be valid; and timeout_ms must be positive".into(),
277 });
278 }
279 Ok(())
280 }
281}
282
283#[derive(Clone, Debug, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct EngineConfig {
290 #[serde(default)]
291 pub server: ServerConfig,
292 #[serde(default)]
293 pub tool_output: ToolOutputConfig,
294 #[serde(default)]
295 pub agent_md: AgentMdConfig,
296 #[serde(default)]
297 pub approval: ApprovalConfig,
298 #[serde(default)]
299 pub model_retry: ModelRetryConfig,
300 #[serde(default)]
301 pub context_compaction: ContextCompactionConfig,
302 #[serde(default)]
303 pub session_title: SessionTitleConfig,
304 #[serde(default)]
305 pub delegation: DelegationConfig,
306 #[serde(default)]
307 pub messaging: MessagingConfig,
308 #[serde(skip)]
309 pub pricing: PricingConfig,
310 #[serde(default, deserialize_with = "deserialize_headers")]
311 pub headers: BTreeMap<HeaderName, SafeStaticHeaderValue>,
312 #[serde(default)]
313 pub providers: BTreeMap<ProviderId, ProviderDefinition>,
314}
315
316#[derive(Debug, Deserialize)]
317#[serde(deny_unknown_fields)]
318pub(crate) struct RawRuntimeLayer {
319 pub(crate) server: Option<ServerConfig>,
320 pub(crate) tool_output: Option<ToolOutputConfig>,
321 pub(crate) agent_md: Option<AgentMdConfig>,
322 pub(crate) approval: Option<ApprovalConfig>,
323 pub(crate) model_retry: Option<ModelRetryConfig>,
324 pub(crate) context_compaction: Option<ContextCompactionConfig>,
325 pub(crate) session_title: Option<SessionTitleConfig>,
326 pub(crate) delegation: Option<DelegationConfig>,
327 pub(crate) messaging: Option<MessagingConfig>,
328 #[serde(default)]
329 #[serde(deserialize_with = "deserialize_optional_headers")]
330 pub(crate) headers: Option<BTreeMap<HeaderName, SafeStaticHeaderValue>>,
331 pub(crate) mcp: Option<McpConfig>,
332 pub(crate) plugins: Option<PluginsConfig>,
333 #[serde(default)]
334 pub(crate) providers: SensitiveProviderValues,
335}
336
337#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
338#[serde(deny_unknown_fields)]
339pub struct ModelRetryConfig {
340 #[serde(default = "default_standard_retries")]
341 pub standard_retries: i64,
342 #[serde(default = "default_overload_retries")]
343 pub overload_retries: i64,
344 #[serde(default = "default_backoff_ceiling_ms")]
345 pub backoff_ceiling_ms: u64,
346}
347
348impl Default for ModelRetryConfig {
349 fn default() -> Self {
350 Self {
351 standard_retries: default_standard_retries(),
352 overload_retries: default_overload_retries(),
353 backoff_ceiling_ms: default_backoff_ceiling_ms(),
354 }
355 }
356}
357
358const fn default_standard_retries() -> i64 {
359 3
360}
361
362const fn default_overload_retries() -> i64 {
363 5
364}
365
366const fn default_backoff_ceiling_ms() -> u64 {
367 60_000
368}
369
370#[derive(Clone, Debug, Default, Deserialize)]
371#[serde(deny_unknown_fields)]
372pub struct PricingConfig {
373 #[serde(default)]
374 pub models: BTreeMap<ModelKey, ModelPricing>,
375}
376
377pub use cookie_agent_models::authoring::ModelPricing;
378
379impl Drop for RawRuntimeLayer {
380 fn drop(&mut self) {
381 if let Some(server) = &mut self.server {
382 server.host.zeroize();
383 }
384 for value in self.providers.values_mut() {
385 zeroize_toml_value(value.value_mut());
386 }
387 }
388}
389
390#[derive(Clone, Debug, Deserialize)]
391#[serde(deny_unknown_fields)]
392pub struct ServerConfig {
393 #[serde(default = "default_host")]
394 pub host: String,
395 #[serde(default = "default_port")]
396 pub port: u16,
397}
398impl Default for ServerConfig {
399 fn default() -> Self {
400 Self {
401 host: default_host(),
402 port: default_port(),
403 }
404 }
405}
406fn default_host() -> String {
407 DEFAULT_HOST.into()
408}
409const fn default_port() -> u16 {
410 DEFAULT_PORT
411}
412
413#[derive(Clone, Debug, Deserialize)]
414#[serde(deny_unknown_fields)]
415pub struct ToolOutputConfig {
416 #[serde(default = "default_output_lines")]
417 pub max_lines: usize,
418 #[serde(default = "default_output_bytes")]
419 pub max_bytes: usize,
420}
421
422#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
423#[serde(deny_unknown_fields)]
424pub struct AgentMdConfig {
425 #[serde(default = "yes")]
426 pub enabled: bool,
427}
428impl Default for AgentMdConfig {
429 fn default() -> Self {
430 Self { enabled: true }
431 }
432}
433impl Default for ToolOutputConfig {
434 fn default() -> Self {
435 Self {
436 max_lines: default_output_lines(),
437 max_bytes: default_output_bytes(),
438 }
439 }
440}
441const fn default_output_lines() -> usize {
442 2_000
443}
444const fn default_output_bytes() -> usize {
445 50 * 1024
446}
447
448#[derive(Clone, Debug, Deserialize)]
449#[serde(deny_unknown_fields)]
450pub struct ApprovalConfig {
451 #[serde(default = "default_approval_timeout")]
452 pub timeout_ms: u64,
453}
454
455#[derive(Clone, Debug, Deserialize)]
456#[serde(deny_unknown_fields)]
457pub struct DelegationConfig {
458 #[serde(default = "default_delegation_depth")]
459 pub max_depth: u32,
460 #[serde(default = "default_delegation_concurrency")]
461 pub max_concurrency: Option<u32>,
462 #[serde(default = "default_max_resident_subagents")]
463 pub max_resident_subagents: usize,
464 #[serde(
465 default = "default_idle_eviction_after",
466 deserialize_with = "deserialize_duration"
467 )]
468 pub idle_eviction_after: Duration,
469}
470impl Default for DelegationConfig {
471 fn default() -> Self {
472 Self {
473 max_depth: default_delegation_depth(),
474 max_concurrency: default_delegation_concurrency(),
475 max_resident_subagents: default_max_resident_subagents(),
476 idle_eviction_after: default_idle_eviction_after(),
477 }
478 }
479}
480const fn default_delegation_depth() -> u32 {
481 3
482}
483const fn default_delegation_concurrency() -> Option<u32> {
484 Some(4)
485}
486const fn default_max_resident_subagents() -> usize {
487 20
488}
489const fn default_idle_eviction_after() -> Duration {
490 Duration::from_secs(60 * 60)
491}
492
493#[derive(Clone, Debug, Deserialize)]
494#[serde(deny_unknown_fields)]
495pub struct MessagingConfig {
496 #[serde(default = "default_messaging_enabled")]
497 pub enabled: bool,
498 #[serde(default)]
501 pub max_hops: i32,
502 #[serde(default = "default_max_body_bytes")]
503 pub max_body_bytes: usize,
504 #[serde(default = "default_max_pending_per_session")]
506 pub max_pending_per_session: usize,
507 #[serde(default = "default_max_inflight_per_pair")]
510 pub max_inflight_per_pair: u32,
511}
512impl Default for MessagingConfig {
513 fn default() -> Self {
514 Self {
515 enabled: default_messaging_enabled(),
516 max_hops: 0,
517 max_body_bytes: default_max_body_bytes(),
518 max_pending_per_session: default_max_pending_per_session(),
519 max_inflight_per_pair: default_max_inflight_per_pair(),
520 }
521 }
522}
523const fn default_messaging_enabled() -> bool {
524 true
525}
526const fn default_max_body_bytes() -> usize {
527 32_768
528}
529const fn default_max_pending_per_session() -> usize {
530 32
531}
532const fn default_max_inflight_per_pair() -> u32 {
533 4
534}
535
536fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
537where
538 D: serde::Deserializer<'de>,
539{
540 let value = String::deserialize(deserializer)?;
541 parse_duration(&value).ok_or_else(|| {
542 serde::de::Error::custom("duration must be an integer followed by ms, s, m, h, or d")
543 })
544}
545
546fn parse_duration(value: &str) -> Option<Duration> {
547 let unit_start = value.find(|character: char| !character.is_ascii_digit())?;
548 let amount = value[..unit_start].parse::<u64>().ok()?;
549 let unit = &value[unit_start..];
550 let milliseconds = match unit {
551 "ms" => amount,
552 "s" => amount.checked_mul(1_000)?,
553 "m" => amount.checked_mul(60_000)?,
554 "h" => amount.checked_mul(3_600_000)?,
555 "d" => amount.checked_mul(86_400_000)?,
556 _ => return None,
557 };
558 Some(Duration::from_millis(milliseconds))
559}
560impl Default for ApprovalConfig {
561 fn default() -> Self {
562 Self {
563 timeout_ms: default_approval_timeout(),
564 }
565 }
566}
567const fn default_approval_timeout() -> u64 {
568 30_000
569}
570
571#[derive(Clone, Debug)]
572pub struct ContextCompactionConfig {
573 pub auto_compaction: bool,
574 pub trigger: ContextCompactionTrigger,
575 pub max_summary_bytes: usize,
576 pub keep_recent_tokens: u64,
578}
579
580#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
581#[serde(untagged, deny_unknown_fields)]
582pub enum ContextCompactionTrigger {
583 Percent { percent: u8 },
584 BufferTokens { buffer_tokens: u64 },
585}
586
587#[derive(Deserialize)]
588#[serde(deny_unknown_fields)]
589struct RawContextCompactionConfig {
590 #[serde(default = "yes", rename = "auto")]
591 auto_compaction: bool,
592 trigger: Option<ContextCompactionTrigger>,
593 buffer_tokens: Option<u64>,
594 #[serde(default = "default_summary")]
595 max_summary_bytes: usize,
596 #[serde(default = "default_keep_recent_tokens")]
597 keep_recent_tokens: u64,
598}
599
600impl<'de> Deserialize<'de> for ContextCompactionConfig {
601 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
602 where
603 D: serde::Deserializer<'de>,
604 {
605 let raw = RawContextCompactionConfig::deserialize(deserializer)?;
606 let trigger = match (raw.trigger, raw.buffer_tokens) {
607 (Some(_), Some(_)) => {
608 return Err(serde::de::Error::custom(
609 "trigger and buffer_tokens cannot both be set",
610 ));
611 }
612 (Some(trigger), None) => trigger,
613 (None, Some(buffer_tokens)) => ContextCompactionTrigger::BufferTokens { buffer_tokens },
614 (None, None) => default_compaction_trigger(),
615 };
616 Ok(Self {
617 auto_compaction: raw.auto_compaction,
618 trigger,
619 max_summary_bytes: raw.max_summary_bytes,
620 keep_recent_tokens: raw.keep_recent_tokens,
621 })
622 }
623}
624
625impl Default for ContextCompactionConfig {
626 fn default() -> Self {
627 Self {
628 auto_compaction: true,
629 trigger: default_compaction_trigger(),
630 max_summary_bytes: default_summary(),
631 keep_recent_tokens: default_keep_recent_tokens(),
632 }
633 }
634}
635const fn default_compaction_trigger() -> ContextCompactionTrigger {
636 ContextCompactionTrigger::Percent { percent: 70 }
637}
638const fn default_summary() -> usize {
639 256 * 1024
640}
641const fn default_keep_recent_tokens() -> u64 {
642 16_384
643}
644
645#[derive(Clone, Debug, Deserialize)]
646#[serde(deny_unknown_fields)]
647pub struct SessionTitleConfig {
648 #[serde(default = "default_title_chars")]
649 pub max_chars: usize,
650 #[serde(default = "default_title_messages")]
651 pub max_input_messages: usize,
652 #[serde(default = "yes")]
653 pub generate_on_first_turn: bool,
654 #[serde(default = "yes")]
655 pub fallback_to_input_excerpt: bool,
656}
657impl Default for SessionTitleConfig {
658 fn default() -> Self {
659 Self {
660 max_chars: default_title_chars(),
661 max_input_messages: default_title_messages(),
662 generate_on_first_turn: true,
663 fallback_to_input_excerpt: true,
664 }
665 }
666}
667const fn default_title_chars() -> usize {
668 80
669}
670const fn default_title_messages() -> usize {
671 4
672}
673const fn yes() -> bool {
674 true
675}
676
677pub(crate) fn apply_settings(runtime: &mut EngineConfig, layer: &RawRuntimeLayer) {
678 if let Some(value) = &layer.server {
679 runtime.server = value.clone();
680 }
681 if let Some(value) = &layer.tool_output {
682 runtime.tool_output = value.clone();
683 }
684 if let Some(value) = &layer.agent_md {
685 runtime.agent_md = value.clone();
686 }
687 if let Some(value) = &layer.approval {
688 runtime.approval = value.clone();
689 }
690 if let Some(value) = layer.model_retry {
691 runtime.model_retry = value;
692 }
693 if let Some(value) = &layer.context_compaction {
694 runtime.context_compaction = value.clone();
695 }
696 if let Some(value) = &layer.session_title {
697 runtime.session_title = value.clone();
698 }
699 if let Some(value) = &layer.delegation {
700 runtime.delegation = value.clone();
701 }
702 if let Some(value) = &layer.messaging {
703 runtime.messaging = value.clone();
704 }
705 if let Some(values) = &layer.headers {
706 for (name, value) in values {
707 if value.as_str().is_empty() {
708 runtime.headers.remove(name);
709 } else {
710 runtime.headers.insert(name.clone(), value.clone());
711 }
712 }
713 }
714}
715
716pub(crate) fn validate_runtime(runtime: &EngineConfig) -> Result<(), ConfigError> {
717 validate_header_ownership(&runtime.headers, "global").map_err(ConfigError::HeaderOwnership)?;
718 validate_header_limits(&runtime.headers).map_err(|_| {
719 ConfigError::InvalidRuntime("headers exceed the header count or size limits")
720 })?;
721 for (invalid, reason) in [
722 (
723 runtime.server.host.is_empty() || runtime.server.host.len() > 255,
724 "server.host must contain 1..255 bytes",
725 ),
726 (
727 runtime.tool_output.max_lines == 0,
728 "tool_output.max_lines must be positive",
729 ),
730 (
731 runtime.tool_output.max_bytes == 0,
732 "tool_output.max_bytes must be positive",
733 ),
734 (
735 runtime.approval.timeout_ms == 0,
736 "approval.timeout_ms must be positive",
737 ),
738 (
739 runtime.model_retry.backoff_ceiling_ms == 0,
740 "model_retry.backoff_ceiling_ms must be positive",
741 ),
742 (
743 runtime.delegation.max_depth == 0,
744 "delegation.max_depth must be positive",
745 ),
746 (
747 runtime.delegation.max_concurrency == Some(0),
748 "delegation.max_concurrency must be positive when set",
749 ),
750 ] {
751 if invalid {
752 return Err(ConfigError::InvalidRuntime(reason));
753 }
754 }
755 let context = &runtime.context_compaction;
756 let invalid_trigger = match &context.trigger {
757 ContextCompactionTrigger::Percent { percent } => !(1..=99).contains(percent),
758 ContextCompactionTrigger::BufferTokens { buffer_tokens } => *buffer_tokens == 0,
759 };
760 if invalid_trigger
761 || context.max_summary_bytes == 0
762 || context.max_summary_bytes > 2 * 1024 * 1024
763 {
764 return Err(ConfigError::InvalidRuntime(
765 "context_compaction.trigger requires percent 1..99 or positive buffer_tokens; max_summary_bytes must be 1..2097152",
766 ));
767 }
768 if runtime.session_title.max_chars == 0 || runtime.session_title.max_input_messages == 0 {
769 return Err(ConfigError::InvalidRuntime(
770 "session_title.max_chars and max_input_messages must be positive",
771 ));
772 }
773 for (id, provider) in &runtime.providers {
774 provider
775 .validate_for(id)
776 .map_err(|source| ConfigError::Provider {
777 provider: id.clone(),
778 source,
779 })?;
780 }
781 Ok(())
782}
783
784fn deserialize_optional_headers<'de, D>(
785 deserializer: D,
786) -> Result<Option<BTreeMap<HeaderName, SafeStaticHeaderValue>>, D::Error>
787where
788 D: serde::Deserializer<'de>,
789{
790 deserialize_headers(deserializer).map(Some)
791}