gate4agent_types/
session_options.rs1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use thiserror::Error;
4
5pub const SESSION_OPTION_ID_MAX_BYTES: usize = 128;
6pub const SESSION_OPTION_VALUE_MAX_BYTES: usize = 512;
7pub const SESSION_OPTION_VALUES_MAX: usize = 64;
8
9#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
10#[serde(untagged)]
11pub enum SessionOptionValue {
12 String(String),
13 Boolean(bool),
14}
15
16impl SessionOptionValue {
17 pub fn as_str(&self) -> Option<&str> {
18 match self {
19 Self::String(value) => Some(value),
20 Self::Boolean(_) => None,
21 }
22 }
23
24 pub fn as_bool(&self) -> Option<bool> {
25 match self {
26 Self::Boolean(value) => Some(*value),
27 Self::String(_) => None,
28 }
29 }
30}
31
32impl From<String> for SessionOptionValue {
33 fn from(value: String) -> Self {
34 Self::String(value)
35 }
36}
37
38impl From<&str> for SessionOptionValue {
39 fn from(value: &str) -> Self {
40 Self::String(value.to_owned())
41 }
42}
43
44impl From<bool> for SessionOptionValue {
45 fn from(value: bool) -> Self {
46 Self::Boolean(value)
47 }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
51pub struct SessionOptionSelection {
52 pub model: String,
53 #[serde(default)]
54 pub values: BTreeMap<String, SessionOptionValue>,
55}
56
57impl SessionOptionSelection {
58 pub fn new(model: impl Into<String>) -> Self {
59 Self {
60 model: model.into(),
61 values: BTreeMap::new(),
62 }
63 }
64
65 pub fn with_value(
66 mut self,
67 id: impl Into<String>,
68 value: impl Into<SessionOptionValue>,
69 ) -> Self {
70 self.values.insert(id.into(), value.into());
71 self
72 }
73
74 pub fn validate(&self) -> Result<(), SessionOptionValidationError> {
75 validate_value("model", &self.model)?;
76 if self.values.len() > SESSION_OPTION_VALUES_MAX {
77 return Err(SessionOptionValidationError::TooManyValues {
78 count: self.values.len(),
79 max: SESSION_OPTION_VALUES_MAX,
80 });
81 }
82 for (id, value) in &self.values {
83 if id == "model" {
84 return Err(SessionOptionValidationError::ReservedModelOption);
85 }
86 if id.is_empty()
87 || id.len() > SESSION_OPTION_ID_MAX_BYTES
88 || id.chars().any(char::is_control)
89 {
90 return Err(SessionOptionValidationError::InvalidOptionId);
91 }
92 if let SessionOptionValue::String(value) = value {
93 validate_value("option value", value)?;
94 }
95 }
96 Ok(())
97 }
98}
99
100fn validate_value(field: &'static str, value: &str) -> Result<(), SessionOptionValidationError> {
101 if value.trim().is_empty()
102 || value.len() > SESSION_OPTION_VALUE_MAX_BYTES
103 || value.chars().any(char::is_control)
104 {
105 return Err(SessionOptionValidationError::InvalidValue { field });
106 }
107 Ok(())
108}
109
110#[derive(Clone, Debug, Error, Eq, PartialEq)]
111pub enum SessionOptionValidationError {
112 #[error("session-option {field} is empty, contains controls, or exceeds its bound")]
113 InvalidValue { field: &'static str },
114 #[error("session-option ID is empty, contains controls, or exceeds its bound")]
115 InvalidOptionId,
116 #[error("session-option selection cannot store a second model field")]
117 ReservedModelOption,
118 #[error("session-option selection has {count} values; the limit is {max}")]
119 TooManyValues { count: usize, max: usize },
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn selection_round_trips_as_a_typed_wire_value() {
128 let selection = SessionOptionSelection::new("opus")
129 .with_value("effort", "xhigh")
130 .with_value("fastMode", true);
131 let encoded = serde_json::to_string(&selection).unwrap();
132 assert_eq!(
133 serde_json::from_str::<SessionOptionSelection>(&encoded).unwrap(),
134 selection
135 );
136 selection.validate().unwrap();
137 assert!(SessionOptionSelection::new("bad\nmodel")
138 .validate()
139 .is_err());
140 }
141}