1use crate::limits::ExtensionLimits;
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6use std::time::Duration;
7use thiserror::Error;
8
9#[derive(
11 Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
12)]
13pub enum ContinuationPolicy {
14 InlineToolContinuation,
16 #[default]
18 CallerControlled,
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub enum ReasoningEffort {
24 Low,
26 Medium,
28 High,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34pub enum ResponseFormat {
35 Text,
37 JsonObject,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
43pub struct ExtensionKey(String);
44
45impl ExtensionKey {
46 pub fn try_new(value: impl Into<String>, max_bytes: usize) -> Result<Self, ConfigError> {
48 let s = value.into();
49 if s.is_empty() {
50 return Err(ConfigError::EmptyExtensionKey);
51 }
52 if s.len() > max_bytes {
53 return Err(ConfigError::ExtensionKeyTooLong {
54 bytes: s.len(),
55 max: max_bytes,
56 });
57 }
58 if s.chars().any(|c| c.is_control()) {
59 return Err(ConfigError::ControlCharacter);
60 }
61 if !s.contains('.') {
62 return Err(ConfigError::ExtensionKeyMissingNamespace);
63 }
64 Ok(Self(s))
65 }
66
67 pub fn as_str(&self) -> &str {
69 &self.0
70 }
71}
72
73#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
75pub struct VersionedExtension {
76 pub version: u16,
78 pub value: serde_json::Value,
80}
81
82#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
84pub struct InvocationConfig {
85 pub model: Option<String>,
87 pub temperature: Option<f32>,
89 pub reasoning_effort: Option<ReasoningEffort>,
91 pub max_output_tokens: Option<u32>,
93 pub stop: Vec<String>,
95 pub response_format: Option<ResponseFormat>,
97 pub continuation_policy: ContinuationPolicy,
99 pub deadline: Option<Duration>,
101 pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
103}
104
105impl Default for InvocationConfig {
106 fn default() -> Self {
107 Self {
108 model: None,
109 temperature: None,
110 reasoning_effort: None,
111 max_output_tokens: None,
112 stop: Vec::new(),
113 response_format: None,
114 continuation_policy: ContinuationPolicy::CallerControlled,
115 deadline: None,
116 extensions: BTreeMap::new(),
117 }
118 }
119}
120
121#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
123pub struct SessionConfig {
124 pub specialist_profile: Option<String>,
126 pub mode: Option<String>,
128 pub permission_profile: Option<String>,
130 pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
132}
133
134#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
136pub struct ChannelDefaults {
137 pub model: Option<String>,
139 pub temperature: Option<f32>,
141 pub reasoning_effort: Option<ReasoningEffort>,
143 pub max_output_tokens: Option<u32>,
145 pub stop: Vec<String>,
147 pub response_format: Option<ResponseFormat>,
149 pub continuation_policy: ContinuationPolicy,
151 pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
153}
154
155#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
157pub struct OptionPolicy {
158 pub supported_invocation: BTreeSet<ConfigOption>,
160 pub session_immutable: BTreeSet<ConfigOption>,
162 pub allowed_extension_keys: BTreeSet<ExtensionKey>,
165}
166
167impl OptionPolicy {
168 pub fn direct_llm() -> Self {
170 let mut p = Self::default();
171 p.supported_invocation.extend([
172 ConfigOption::Model,
173 ConfigOption::Temperature,
174 ConfigOption::ReasoningEffort,
175 ConfigOption::MaxOutputTokens,
176 ConfigOption::Stop,
177 ConfigOption::ResponseFormat,
178 ConfigOption::ContinuationPolicy,
179 ConfigOption::Deadline,
180 ConfigOption::Extensions,
181 ]);
182 p
183 }
184
185 pub fn external_agent() -> Self {
187 let mut p = Self::default();
188 p.supported_invocation.extend([
189 ConfigOption::ContinuationPolicy,
190 ConfigOption::Deadline,
191 ConfigOption::Extensions,
192 ]);
193 p
194 }
195
196 pub fn with_extension_keys(mut self, keys: impl IntoIterator<Item = ExtensionKey>) -> Self {
198 self.allowed_extension_keys.extend(keys);
199 self
200 }
201}
202
203#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
205pub enum ConfigOption {
206 Model,
208 Temperature,
210 ReasoningEffort,
212 MaxOutputTokens,
214 Stop,
216 ResponseFormat,
218 ContinuationPolicy,
220 Deadline,
222 Extensions,
224}
225
226#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
228pub struct EffectiveConfig {
229 pub model: Option<String>,
231 pub temperature: Option<f32>,
233 pub reasoning_effort: Option<ReasoningEffort>,
235 pub max_output_tokens: Option<u32>,
237 pub stop: Vec<String>,
239 pub response_format: Option<ResponseFormat>,
241 pub continuation_policy: ContinuationPolicy,
243 pub deadline: Option<Duration>,
245 pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
247 pub session: SessionConfig,
249}
250
251pub fn merge_effective_config(
253 defaults: &ChannelDefaults,
254 session: Option<&SessionConfig>,
255 attached_session: Option<&SessionConfig>,
256 invocation: &InvocationConfig,
257 policy: &OptionPolicy,
258 extension_limits: &ExtensionLimits,
259) -> Result<EffectiveConfig, ConfigError> {
260 validate_extensions(&defaults.extensions, extension_limits, policy)?;
261 if let Some(s) = session {
262 validate_session_labels(s)?;
263 validate_extensions(&s.extensions, extension_limits, policy)?;
264 }
265 if let Some(s) = attached_session {
266 validate_session_labels(s)?;
267 validate_extensions(&s.extensions, extension_limits, policy)?;
268 }
269 validate_extensions(&invocation.extensions, extension_limits, policy)?;
270 validate_invocation_strings(invocation)?;
271
272 if let (Some(requested), Some(attached)) = (session, attached_session) {
274 check_session_match(requested, attached, policy)?;
275 }
276
277 let mut effective = EffectiveConfig {
278 model: defaults.model.clone(),
279 temperature: defaults.temperature,
280 reasoning_effort: defaults.reasoning_effort,
281 max_output_tokens: defaults.max_output_tokens,
282 stop: defaults.stop.clone(),
283 response_format: defaults.response_format.clone(),
284 continuation_policy: defaults.continuation_policy,
285 deadline: None,
286 extensions: defaults.extensions.clone(),
287 session: session.cloned().unwrap_or_default(),
288 };
289
290 if let Some(s) = session {
292 for (k, v) in &s.extensions {
293 effective.extensions.insert(k.clone(), v.clone());
294 }
295 }
296
297 apply_invocation(&mut effective, invocation, policy)?;
298
299 validate_extensions(&effective.extensions, extension_limits, policy)?;
301 Ok(effective)
302}
303
304fn apply_invocation(
305 effective: &mut EffectiveConfig,
306 invocation: &InvocationConfig,
307 policy: &OptionPolicy,
308) -> Result<(), ConfigError> {
309 if invocation.model.is_some() {
310 require_supported(policy, ConfigOption::Model)?;
311 effective.model = invocation.model.clone();
312 }
313 if invocation.temperature.is_some() {
314 require_supported(policy, ConfigOption::Temperature)?;
315 if let Some(t) = invocation.temperature {
316 if !(0.0..=2.0).contains(&t) {
317 return Err(ConfigError::InvalidNumeric("temperature"));
318 }
319 }
320 effective.temperature = invocation.temperature;
321 }
322 if invocation.reasoning_effort.is_some() {
323 require_supported(policy, ConfigOption::ReasoningEffort)?;
324 effective.reasoning_effort = invocation.reasoning_effort;
325 }
326 if invocation.max_output_tokens.is_some() {
327 require_supported(policy, ConfigOption::MaxOutputTokens)?;
328 effective.max_output_tokens = invocation.max_output_tokens;
329 }
330 if !invocation.stop.is_empty() {
331 require_supported(policy, ConfigOption::Stop)?;
332 effective.stop = invocation.stop.clone();
333 }
334 if invocation.response_format.is_some() {
335 require_supported(policy, ConfigOption::ResponseFormat)?;
336 effective.response_format = invocation.response_format.clone();
337 }
338 require_supported(policy, ConfigOption::ContinuationPolicy)?;
340 effective.continuation_policy = invocation.continuation_policy;
341
342 if invocation.deadline.is_some() {
343 require_supported(policy, ConfigOption::Deadline)?;
344 effective.deadline = invocation.deadline;
345 }
346 if !invocation.extensions.is_empty() {
347 require_supported(policy, ConfigOption::Extensions)?;
348 for (k, v) in &invocation.extensions {
349 effective.extensions.insert(k.clone(), v.clone());
350 }
351 }
352 Ok(())
353}
354
355fn require_supported(policy: &OptionPolicy, opt: ConfigOption) -> Result<(), ConfigError> {
356 if policy.supported_invocation.contains(&opt) {
357 Ok(())
358 } else {
359 Err(ConfigError::UnsupportedOption(opt))
360 }
361}
362
363fn check_session_match(
364 requested: &SessionConfig,
365 attached: &SessionConfig,
366 policy: &OptionPolicy,
367) -> Result<(), ConfigError> {
368 if policy.session_immutable.contains(&ConfigOption::Model) {
369 }
371 if requested.specialist_profile != attached.specialist_profile
373 && (requested.specialist_profile.is_some() || attached.specialist_profile.is_some())
374 {
375 return Err(ConfigError::ImmutableSessionMismatch("specialist_profile"));
376 }
377 if requested.mode != attached.mode && (requested.mode.is_some() || attached.mode.is_some()) {
378 return Err(ConfigError::ImmutableSessionMismatch("mode"));
379 }
380 if requested.permission_profile != attached.permission_profile
381 && (requested.permission_profile.is_some() || attached.permission_profile.is_some())
382 {
383 return Err(ConfigError::ImmutableSessionMismatch("permission_profile"));
384 }
385 for (k, v) in &requested.extensions {
386 if let Some(existing) = attached.extensions.get(k) {
387 if existing != v {
388 return Err(ConfigError::ImmutableSessionMismatch("extension"));
389 }
390 }
391 }
392 Ok(())
393}
394
395fn validate_session_labels(session: &SessionConfig) -> Result<(), ConfigError> {
396 for label in [
397 &session.specialist_profile,
398 &session.mode,
399 &session.permission_profile,
400 ]
401 .into_iter()
402 .flatten()
403 {
404 if label.is_empty() || label.len() > 128 || label.chars().any(|c| c.is_control()) {
405 return Err(ConfigError::InvalidSessionLabel);
406 }
407 }
408 Ok(())
409}
410
411fn validate_invocation_strings(invocation: &InvocationConfig) -> Result<(), ConfigError> {
412 if let Some(m) = &invocation.model {
413 if m.is_empty() || m.len() > 256 || m.chars().any(|c| c.is_control()) {
414 return Err(ConfigError::InvalidModel);
415 }
416 }
417 for s in &invocation.stop {
418 if s.is_empty() || s.len() > 64 || s.chars().any(|c| c.is_control()) {
419 return Err(ConfigError::InvalidStop);
420 }
421 }
422 Ok(())
423}
424
425fn validate_extensions(
426 map: &BTreeMap<ExtensionKey, VersionedExtension>,
427 limits: &ExtensionLimits,
428 policy: &OptionPolicy,
429) -> Result<(), ConfigError> {
430 if map.len() > limits.max_keys {
431 return Err(ConfigError::TooManyExtensions {
432 count: map.len(),
433 max: limits.max_keys,
434 });
435 }
436 if !map.is_empty() && policy.allowed_extension_keys.is_empty() {
438 let first = map
439 .keys()
440 .next()
441 .map(|k| k.as_str().to_string())
442 .unwrap_or_default();
443 return Err(ConfigError::UnknownExtension(first));
444 }
445 let mut total = 0usize;
446 for (k, v) in map {
447 if k.as_str().len() > limits.max_key_bytes {
448 return Err(ConfigError::ExtensionKeyTooLong {
449 bytes: k.as_str().len(),
450 max: limits.max_key_bytes,
451 });
452 }
453 if !policy.allowed_extension_keys.contains(k) {
454 return Err(ConfigError::UnknownExtension(k.as_str().to_string()));
455 }
456 let depth = json_depth(&v.value);
457 if depth > limits.max_value_depth {
458 return Err(ConfigError::ExtensionTooDeep {
459 depth,
460 max: limits.max_value_depth,
461 });
462 }
463 let encoded =
464 serde_json::to_vec(&v.value).map_err(|_| ConfigError::ExtensionEncodeFailed)?;
465 total = total
466 .saturating_add(encoded.len())
467 .saturating_add(k.as_str().len());
468 }
469 if total > limits.max_serialized_bytes {
470 return Err(ConfigError::ExtensionsTooLarge {
471 bytes: total,
472 max: limits.max_serialized_bytes,
473 });
474 }
475 Ok(())
476}
477
478fn json_depth(value: &serde_json::Value) -> u32 {
479 match value {
480 serde_json::Value::Array(items) => 1 + items.iter().map(json_depth).max().unwrap_or(0),
481 serde_json::Value::Object(map) => 1 + map.values().map(json_depth).max().unwrap_or(0),
482 _ => 1,
483 }
484}
485
486#[derive(Clone, Debug, Error, PartialEq)]
488pub enum ConfigError {
489 #[error("extension key must be non-empty")]
491 EmptyExtensionKey,
492 #[error("extension key must be namespaced (contain '.')")]
494 ExtensionKeyMissingNamespace,
495 #[error("extension key bytes {bytes} exceeds max {max}")]
497 ExtensionKeyTooLong {
498 bytes: usize,
500 max: usize,
502 },
503 #[error("configuration string must not contain control characters")]
505 ControlCharacter,
506 #[error("extension count {count} exceeds max {max}")]
508 TooManyExtensions {
509 count: usize,
511 max: usize,
513 },
514 #[error("extension depth {depth} exceeds max {max}")]
516 ExtensionTooDeep {
517 depth: u32,
519 max: u32,
521 },
522 #[error("extensions serialized bytes {bytes} exceed max {max}")]
524 ExtensionsTooLarge {
525 bytes: usize,
527 max: usize,
529 },
530 #[error("unknown extension key {0}")]
532 UnknownExtension(String),
533 #[error("extension JSON encode failed")]
535 ExtensionEncodeFailed,
536 #[error("unsupported configuration option: {0:?}")]
538 UnsupportedOption(ConfigOption),
539 #[error("invalid numeric value for {0}")]
541 InvalidNumeric(&'static str),
542 #[error("invalid model string")]
544 InvalidModel,
545 #[error("invalid stop sequence")]
547 InvalidStop,
548 #[error("invalid session configuration label")]
550 InvalidSessionLabel,
551 #[error("immutable session setting mismatch: {0}")]
553 ImmutableSessionMismatch(&'static str),
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 fn open_policy() -> OptionPolicy {
561 let mut p = OptionPolicy::default();
562 p.supported_invocation.extend([
563 ConfigOption::Model,
564 ConfigOption::Temperature,
565 ConfigOption::ContinuationPolicy,
566 ConfigOption::Deadline,
567 ConfigOption::Extensions,
568 ]);
569 p
570 }
571
572 #[test]
573 fn merge_precedence_invocation_over_defaults() {
574 let defaults = ChannelDefaults {
575 model: Some("base".into()),
576 temperature: Some(0.2),
577 continuation_policy: ContinuationPolicy::CallerControlled,
578 ..Default::default()
579 };
580 let inv = InvocationConfig {
581 model: Some("override".into()),
582 temperature: Some(0.7),
583 continuation_policy: ContinuationPolicy::InlineToolContinuation,
584 ..Default::default()
585 };
586 let eff = merge_effective_config(
587 &defaults,
588 None,
589 None,
590 &inv,
591 &open_policy(),
592 &ExtensionLimits::default(),
593 )
594 .unwrap();
595 assert_eq!(eff.model.as_deref(), Some("override"));
596 assert_eq!(eff.temperature, Some(0.7));
597 assert_eq!(
598 eff.continuation_policy,
599 ContinuationPolicy::InlineToolContinuation
600 );
601 }
602
603 #[test]
604 fn immutable_session_mismatch_fails() {
605 let requested = SessionConfig {
606 mode: Some("agent".into()),
607 ..Default::default()
608 };
609 let attached = SessionConfig {
610 mode: Some("ask".into()),
611 ..Default::default()
612 };
613 let err = merge_effective_config(
614 &ChannelDefaults::default(),
615 Some(&requested),
616 Some(&attached),
617 &InvocationConfig {
618 continuation_policy: ContinuationPolicy::CallerControlled,
619 ..Default::default()
620 },
621 &open_policy(),
622 &ExtensionLimits::default(),
623 )
624 .unwrap_err();
625 assert!(matches!(err, ConfigError::ImmutableSessionMismatch("mode")));
626 }
627
628 #[test]
629 fn extension_bounds() {
630 let limits = ExtensionLimits {
631 max_keys: 1,
632 max_key_bytes: 32,
633 max_value_depth: 2,
634 max_serialized_bytes: 64,
635 };
636 let k = ExtensionKey::try_new("ns.a", limits.max_key_bytes).unwrap();
637 let k2 = ExtensionKey::try_new("ns.b", limits.max_key_bytes).unwrap();
638 let mut inv = InvocationConfig::default();
639 inv.extensions.insert(
640 k,
641 VersionedExtension {
642 version: 1,
643 value: serde_json::json!(1),
644 },
645 );
646 inv.extensions.insert(
647 k2,
648 VersionedExtension {
649 version: 1,
650 value: serde_json::json!(2),
651 },
652 );
653 let policy = open_policy();
654 let err = merge_effective_config(
656 &ChannelDefaults::default(),
657 None,
658 None,
659 &inv,
660 &policy,
661 &limits,
662 )
663 .unwrap_err();
664 assert!(matches!(err, ConfigError::TooManyExtensions { .. }));
665 }
666
667 #[test]
669 fn empty_extension_allowlist_denies() {
670 let limits = ExtensionLimits {
671 max_keys: 8,
672 max_key_bytes: 32,
673 max_value_depth: 2,
674 max_serialized_bytes: 256,
675 };
676 let k = ExtensionKey::try_new("ns.secret", limits.max_key_bytes).unwrap();
677 let mut inv = InvocationConfig::default();
678 inv.extensions.insert(
679 k,
680 VersionedExtension {
681 version: 1,
682 value: serde_json::json!({"x": 1}),
683 },
684 );
685 let mut policy = open_policy();
686 policy.allowed_extension_keys.clear();
687 let err = merge_effective_config(
688 &ChannelDefaults::default(),
689 None,
690 None,
691 &inv,
692 &policy,
693 &limits,
694 )
695 .unwrap_err();
696 assert!(matches!(err, ConfigError::UnknownExtension(_)));
697 }
698}