talos_session/
runtime_state.rs1use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SessionRuntimeIdentity {
14 pub provider: String,
15 pub model: String,
16 pub variant: Option<String>,
17}
18
19impl SessionRuntimeIdentity {
20 #[must_use]
21 pub fn new(provider: &str, model: &str, variant: Option<&str>) -> Self {
22 Self {
23 provider: provider.to_string(),
24 model: model.to_string(),
25 variant: normalize_variant_id(variant).map(str::to_string),
26 }
27 }
28
29 #[must_use]
30 pub fn display_name(&self) -> String {
31 match self.variant.as_deref() {
32 Some(variant) => format!("{}/{}@{variant}", self.provider, self.model),
33 None => format!("{}/{}", self.provider, self.model),
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct SessionRuntimeActivation {
41 pub version: u8,
42 pub activation_id: String,
43 pub generation: u64,
44 pub previous: SessionRuntimeIdentity,
45 pub target: SessionRuntimeIdentity,
46}
47
48impl SessionRuntimeActivation {
49 #[must_use]
50 pub fn new(
51 generation: u64,
52 previous: SessionRuntimeIdentity,
53 target: SessionRuntimeIdentity,
54 ) -> Self {
55 let canonical = serde_json::to_vec(&(generation, &previous, &target))
56 .expect("runtime activation identity contains only serializable values");
57 let digest = Sha256::digest(canonical);
58 let suffix: String = digest
59 .iter()
60 .take(16)
61 .map(|byte| format!("{byte:02x}"))
62 .collect();
63 Self {
64 version: 1,
65 activation_id: format!("model-activation-g{generation}-{suffix}"),
66 generation,
67 previous,
68 target,
69 }
70 }
71
72 #[must_use]
73 pub fn is_valid(&self) -> bool {
74 self.version == 1
75 && self.activation_id
76 == Self::new(self.generation, self.previous.clone(), self.target.clone())
77 .activation_id
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum SessionRuntimeActivationStatus {
85 PendingMarker,
86 Committed,
87}
88
89impl SessionRuntimeActivationStatus {
90 pub(crate) const fn as_str(self) -> &'static str {
91 match self {
92 Self::PendingMarker => "pending_marker",
93 Self::Committed => "committed",
94 }
95 }
96
97 pub(crate) fn parse(value: &str) -> Option<Self> {
98 match value {
99 "pending_marker" => Some(Self::PendingMarker),
100 "committed" => Some(Self::Committed),
101 _ => None,
102 }
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct SessionRuntimeState {
109 pub activation: SessionRuntimeActivation,
110 pub status: SessionRuntimeActivationStatus,
111}
112
113fn normalize_variant_id(variant: Option<&str>) -> Option<&str> {
114 let variant = variant.map(str::trim).filter(|value| !value.is_empty())?;
115 (!variant.eq_ignore_ascii_case("default")).then_some(variant)
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn default_spellings_share_one_identity() {
124 assert_eq!(
125 SessionRuntimeIdentity::new("openai", "o3", None),
126 SessionRuntimeIdentity::new("openai", "o3", Some(" DEFAULT "))
127 );
128 }
129
130 #[test]
131 fn activation_validation_detects_tampering() {
132 let mut activation = SessionRuntimeActivation::new(
133 7,
134 SessionRuntimeIdentity::new("openai", "o3", None),
135 SessionRuntimeIdentity::new("openai", "o3", Some("high-reasoning")),
136 );
137 assert!(activation.is_valid());
138 activation.target.variant = Some("low-reasoning".to_string());
139 assert!(!activation.is_valid());
140 }
141}