kcode_k1_codex_runtime_observations/
lib.rs1use kcode_k1_codex_conversations::{Error, ErrorKind};
4use kcode_k1_codex_events::{
5 Inbound, ScopedKind, TokenUsage, TokenUsageScope, TokenUsageUpdated, decode,
6 decode_token_usage_updated,
7};
8use kcode_k1_codex_runtime_session::Session;
9use serde_json::Value;
10use std::collections::HashMap;
11use tokio::sync::mpsc;
12
13type Sink = mpsc::UnboundedSender<RuntimeObservation>;
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct UsageObservation {
17 pub model: String,
18 pub usage: TokenUsage,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum RuntimeObservation {
23 Usage(UsageObservation),
24 TurnCompleted(TokenUsageScope),
25}
26
27#[derive(Default)]
28pub struct ObservationRoutes {
29 sinks: HashMap<String, Sink>,
30}
31
32impl ObservationRoutes {
33 pub fn subscribe(
34 &mut self,
35 key: String,
36 ) -> Result<mpsc::UnboundedReceiver<RuntimeObservation>, Error> {
37 if self.sinks.get(&key).is_some_and(|sink| !sink.is_closed()) {
38 return Err(Error::new(
39 ErrorKind::Busy,
40 "observation subscription is already live",
41 ));
42 }
43 let (sink, receiver) = mpsc::unbounded_channel();
44 self.sinks.insert(key, sink);
45 Ok(receiver)
46 }
47
48 pub fn observe(&mut self, session: &Session, message: &Value) {
49 if let TokenUsageUpdated::Valid(usage) = decode_token_usage_updated(message.clone()) {
50 let thread = usage.scope.thread_id.clone();
51 if let Some((key, model)) = owner_model(session, &thread) {
52 self.send(
53 &key,
54 RuntimeObservation::Usage(UsageObservation { model, usage }),
55 );
56 }
57 }
58 if let Inbound::Scoped(event) = decode(message.clone())
59 && matches!(event.kind, ScopedKind::TurnCompleted(_))
60 && let Some((key, _)) = owner_model(session, &event.scope.thread_id)
61 {
62 self.send(
63 &key,
64 RuntimeObservation::TurnCompleted(TokenUsageScope {
65 thread_id: event.scope.thread_id,
66 turn_id: event.scope.turn_id,
67 }),
68 );
69 }
70 }
71
72 fn send(&mut self, key: &str, observation: RuntimeObservation) {
73 if self
74 .sinks
75 .get(key)
76 .is_some_and(|sink| sink.send(observation).is_err())
77 {
78 self.sinks.remove(key);
79 }
80 }
81}
82
83fn owner_model(session: &Session, thread: &str) -> Option<(String, String)> {
84 let key = session.owner(thread)?;
85 Some((key.to_owned(), session.bound_profile(key)?.model.clone()))
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use kcode_k1_codex_conversations::Config;
92 use serde_json::json;
93 use std::sync::Arc;
94
95 fn session() -> Session {
96 let mut session = Session::default();
97 let (events, _) = mpsc::unbounded_channel();
98 session
99 .begin_start(
100 "key",
101 Arc::new(Config {
102 executable: "codex".into(),
103 working_directory: "/work".into(),
104 model: "model".into(),
105 reasoning_effort: None,
106 base_instructions: String::new(),
107 tools: Vec::new(),
108 }),
109 events,
110 )
111 .unwrap();
112 session.set_thread("key", "thread").unwrap();
113 session
114 }
115
116 fn counts(value: i64) -> Value {
117 json!({
118 "inputTokens": value,
119 "cachedInputTokens": value,
120 "outputTokens": value,
121 "reasoningOutputTokens": value,
122 "totalTokens": value
123 })
124 }
125
126 fn usage(thread: &str) -> Value {
127 json!({
128 "method": "thread/tokenUsage/updated",
129 "params": {
130 "threadId": thread,
131 "turnId": "turn",
132 "tokenUsage": {
133 "total": counts(10),
134 "last": counts(1),
135 "modelContextWindow": 128
136 }
137 }
138 })
139 }
140
141 #[test]
142 fn subscription_rejects_live_sink_and_replaces_closed_sink() {
143 let mut routes = ObservationRoutes::default();
144 let first = routes.subscribe("key".into()).unwrap();
145 assert_eq!(
146 routes.subscribe("key".into()).unwrap_err().kind,
147 ErrorKind::Busy
148 );
149 drop(first);
150 assert!(routes.subscribe("key".into()).is_ok());
151 }
152
153 #[test]
154 fn routes_valid_usage_with_bound_model_and_preserves_duplicates() {
155 let mut routes = ObservationRoutes::default();
156 let mut receiver = routes.subscribe("key".into()).unwrap();
157 let session = session();
158 routes.observe(&session, &usage("thread"));
159 routes.observe(&session, &usage("thread"));
160 let first = receiver.try_recv().unwrap();
161 let second = receiver.try_recv().unwrap();
162 assert_eq!(first, second);
163 assert!(
164 matches!(first, RuntimeObservation::Usage(UsageObservation { ref model, .. }) if model == "model")
165 );
166 }
167
168 #[test]
169 fn routes_valid_scoped_completion() {
170 let mut routes = ObservationRoutes::default();
171 let mut receiver = routes.subscribe("key".into()).unwrap();
172 let session = session();
173 routes.observe(
174 &session,
175 &json!({
176 "method": "turn/completed",
177 "params": {"threadId": "thread", "turn": {"id": "turn", "status": "completed"}}
178 }),
179 );
180 assert!(
181 matches!(receiver.try_recv(), Ok(RuntimeObservation::TurnCompleted(TokenUsageScope { ref thread_id, .. })) if thread_id == "thread")
182 );
183 }
184
185 #[test]
186 fn ignores_unknown_threads_and_malformed_messages() {
187 let mut routes = ObservationRoutes::default();
188 let mut receiver = routes.subscribe("key".into()).unwrap();
189 let session = session();
190 routes.observe(&session, &usage("other"));
191 routes.observe(
192 &session,
193 &json!({"method": "thread/tokenUsage/updated", "params": {}}),
194 );
195 assert!(receiver.try_recv().is_err());
196 }
197
198 #[test]
199 fn closed_sink_is_removed_after_delivery_attempt() {
200 let mut routes = ObservationRoutes::default();
201 let receiver = routes.subscribe("key".into()).unwrap();
202 drop(receiver);
203 routes.observe(&session(), &usage("thread"));
204 assert!(routes.sinks.is_empty());
205 }
206}