oxios_kernel/token_maxing/
maxer.rs1use std::collections::HashSet;
24use std::sync::Arc;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::time::Duration;
27
28use parking_lot::RwLock;
29use tokio::time::timeout;
30
31use oxios_ouroboros::{Directive, ExecEnv};
32
33use crate::agent_lifecycle::AgentLifecycleManager;
34use crate::resilience::FailureClass;
35use crate::state_store::StateStore;
36
37use super::TokenMaxingConfig;
38use super::planner::{PlannedTask, WorkPlanner};
39use super::quota_tracker::{Availability, QuotaTracker};
40use super::session::{MaxerStatus, MaxingStart, StopReason, TokenMaxingSession};
41
42const MAX_TASK_SECS: u64 = 600;
44const COOLDOWN_POLL_SECS: u64 = 60;
47const MAX_HISTORY: usize = 50;
49
50pub struct TokenMaxer {
54 lifecycle: AgentLifecycleManager,
55 tracker: Arc<QuotaTracker>,
56 planner: WorkPlanner,
57 state_store: Arc<StateStore>,
58 state: RwLock<MaxerRuntimeState>,
59 cancel: Arc<AtomicBool>,
60}
61
62#[derive(Default)]
63struct MaxerRuntimeState {
64 running: bool,
65 current: Option<TokenMaxingSession>,
66 current_provider: Option<String>,
67 current_task: Option<String>,
68 history: Vec<TokenMaxingSession>,
69}
70
71impl TokenMaxer {
72 #[allow(clippy::too_many_arguments)]
73 pub fn new(
74 lifecycle: AgentLifecycleManager,
75 tracker: Arc<QuotaTracker>,
76 planner: WorkPlanner,
77 state_store: Arc<StateStore>,
78 ) -> Self {
79 Self {
80 lifecycle,
81 tracker,
82 planner,
83 state_store,
84 state: RwLock::new(MaxerRuntimeState::default()),
85 cancel: Arc::new(AtomicBool::new(false)),
86 }
87 }
88
89 pub fn launch(self: &Arc<Self>, start: MaxingStart) -> anyhow::Result<String> {
93 let cfg = self.tracker.config();
94 if !cfg.enabled {
95 anyhow::bail!("token-maxing is disabled (token-maxing.enabled = false)");
96 }
97 let eligible = cfg
98 .providers
99 .iter()
100 .filter(|p| cfg.is_eligible(&p.provider) && !p.models.is_empty());
101 if eligible.count() == 0 {
102 anyhow::bail!(
103 "no eligible token-maxing provider: each needs \
104 billing_model = \"subscription\" and a non-empty models list"
105 );
106 }
107
108 let (window, manual) = match &start {
109 MaxingStart::Scheduled(w) => (Some(w.clone()), false),
110 MaxingStart::Manual => (None, true),
111 };
112 let session = TokenMaxingSession::start(window, manual);
113 let id = session.id.clone();
114
115 {
116 let mut st = self.state.write();
117 if st.running {
118 anyhow::bail!("a token-maxing session is already running");
119 }
120 st.running = true;
121 st.current = Some(session);
122 }
123 self.cancel.store(false, Ordering::Relaxed);
124
125 let me = Arc::clone(self);
126 tokio::spawn(async move {
127 me.run_loop(start).await;
128 });
129 Ok(id)
130 }
131
132 pub fn stop(&self) {
134 self.cancel.store(true, Ordering::Relaxed);
135 }
136
137 pub fn status(&self) -> MaxerStatus {
139 let st = self.state.read();
140 let current = st.current.as_ref();
141 MaxerStatus {
142 running: st.running,
143 current_session_id: current.map(|s| s.id.clone()),
144 current_provider: st.current_provider.clone(),
145 current_task: st.current_task.clone(),
146 manual: current.map(|s| s.manual).unwrap_or(false),
147 window: current.and_then(|s| s.window.clone()),
148 tokens_this_session: current.map(|s| s.totals.tokens).unwrap_or(0),
149 tasks_this_session: current.map(|s| s.totals.tasks).unwrap_or(0),
150 providers: self.tracker.snapshots(),
151 }
152 }
153
154 pub fn sessions(&self) -> Vec<TokenMaxingSession> {
156 self.state.read().history.clone()
157 }
158
159 pub fn session(&self, id: &str) -> Option<TokenMaxingSession> {
161 let st = self.state.read();
162 if let Some(c) = &st.current
163 && c.id == id
164 {
165 return Some(c.clone());
166 }
167 st.history.iter().find(|s| s.id == id).cloned()
168 }
169
170 fn cancelled(&self) -> bool {
171 self.cancel.load(Ordering::Relaxed)
172 }
173
174 async fn run_loop(self: Arc<Self>, start: MaxingStart) {
176 let (window, manual) = match &start {
177 MaxingStart::Scheduled(w) => (Some(w.clone()), false),
178 MaxingStart::Manual => (None, true),
179 };
180 let mut session = TokenMaxingSession::start(window, manual);
181 self.persist(&session).await;
182
183 let mut done_goals: HashSet<String> = HashSet::new();
184
185 let stop_reason = loop {
186 if self.cancelled() {
187 break StopReason::Cancelled;
188 }
189 if !session.within_window() {
190 break StopReason::WindowEnded;
191 }
192
193 let cfg = self.tracker.config();
194
195 let provider = match self.pick_provider(&cfg) {
197 Some(p) => p,
198 None => {
199 self.sleep_cancellable(COOLDOWN_POLL_SECS).await;
201 continue;
202 }
203 };
204 let model = match self.pick_model(&cfg, &provider, &session) {
205 Some(m) => m,
206 None => {
207 self.sleep_cancellable(COOLDOWN_POLL_SECS).await;
208 continue;
209 }
210 };
211
212 let task = match self.planner.next_task(&done_goals).await {
213 Some(t) => t,
214 None => break StopReason::NoWork,
215 };
216 done_goals.insert(task.goal.clone());
217
218 {
219 let mut st = self.state.write();
220 st.current_provider = Some(provider.clone());
221 st.current_task = Some(task.source_name.clone());
222 }
223
224 self.dispatch(&mut session, task, provider, model).await;
225 self.persist(&session).await;
226 self.state.write().current = Some(session.clone());
227 };
228
229 session.finalize(stop_reason);
230 self.persist(&session).await;
231
232 let mut st = self.state.write();
233 st.running = false;
234 st.current_provider = None;
235 st.current_task = None;
236 st.current = None;
237 st.history.push(session);
238 if st.history.len() > MAX_HISTORY {
239 let drop_n = st.history.len() - MAX_HISTORY;
240 st.history.drain(0..drop_n);
241 }
242 }
243
244 async fn dispatch(
246 &self,
247 session: &mut TokenMaxingSession,
248 task: PlannedTask,
249 provider: String,
250 model: String,
251 ) {
252 let env = ExecEnv {
253 cspace_hint: Some("standard".into()),
256 model_override: Some(model.clone()),
257 mount_paths: task.mount_paths.clone(),
258 ..Default::default()
259 };
260 let directive = Directive {
261 goal: task.goal.clone(),
262 ..Default::default()
263 };
264
265 let t0 = std::time::Instant::now();
266 let result = timeout(
267 Duration::from_secs(MAX_TASK_SECS),
268 self.lifecycle.execute_directive(&directive, &env),
269 )
270 .await;
271 let dur = t0.elapsed().as_secs_f64();
272
273 let source = task.source;
274 let source_name = task.source_name;
275 let goal = task.goal;
276
277 match result {
278 Ok(Ok(r)) => {
279 let tokens = r.tokens_input + r.tokens_output;
280 let _ = self.tracker.reserve(&provider, tokens);
282 if let Some(class) = r.failure_class {
284 self.tracker.record_failure(&provider, class, None);
285 }
286 session.record_task(
287 source,
288 source_name,
289 goal,
290 provider,
291 model,
292 r.success,
293 tokens,
294 dur,
295 truncate(&r.output, 800),
296 );
297 }
298 Ok(Err(e)) => {
299 tracing::warn!(error = %e, "token-maxing task failed");
300 session.record_task(
301 source,
302 source_name,
303 goal,
304 provider,
305 model,
306 false,
307 0,
308 dur,
309 format!("error: {e}"),
310 );
311 }
312 Err(_) => {
313 tracing::warn!("token-maxing task timed out — cooling provider");
314 self.tracker
317 .record_failure(&provider, FailureClass::Transient, None);
318 session.record_task(
319 source,
320 source_name,
321 goal,
322 provider,
323 model,
324 false,
325 0,
326 dur,
327 "timed out".into(),
328 );
329 }
330 }
331 }
332
333 fn pick_provider(&self, cfg: &TokenMaxingConfig) -> Option<String> {
336 let snaps = self.tracker.snapshots();
337 if let Some(s) = snaps.iter().find(|s| {
338 matches!(s.availability, Availability::Available { .. })
339 && self.has_model(cfg, &s.provider)
340 }) {
341 return Some(s.provider.clone());
342 }
343 snaps
344 .iter()
345 .find(|s| {
346 matches!(s.availability, Availability::Draining { .. })
347 && self.has_model(cfg, &s.provider)
348 })
349 .map(|s| s.provider.clone())
350 }
351
352 fn has_model(&self, cfg: &TokenMaxingConfig, provider: &str) -> bool {
353 cfg.get(provider)
354 .map(|p| !p.models.is_empty())
355 .unwrap_or(false)
356 }
357
358 fn pick_model(
360 &self,
361 cfg: &TokenMaxingConfig,
362 provider: &str,
363 session: &TokenMaxingSession,
364 ) -> Option<String> {
365 let p = cfg.get(provider)?;
366 if p.models.is_empty() {
367 return None;
368 }
369 let used = session
370 .tasks
371 .iter()
372 .filter(|t| t.provider == provider)
373 .count();
374 Some(p.models[used % p.models.len()].clone())
375 }
376
377 async fn persist(&self, session: &TokenMaxingSession) {
378 if let Err(e) = self
379 .state_store
380 .save_json("token-maxing", &session.id, session)
381 .await
382 {
383 tracing::warn!(error = %e, "failed to persist token-maxing session");
384 }
385 }
386
387 async fn sleep_cancellable(&self, secs: u64) {
388 tokio::time::sleep(Duration::from_secs(secs)).await;
389 }
390}
391
392fn truncate(s: &str, max: usize) -> String {
393 if s.len() <= max {
394 s.to_string()
395 } else {
396 let mut end = max;
398 while end > 0 && !s.is_char_boundary(end) {
399 end -= 1;
400 }
401 format!("{}…", &s[..end])
402 }
403}