1use super::{
4 UsageStats, failure::Failure, registry, request::SessionInput, tool_call::WireToolCall,
5};
6use crate::units::BYTES_PER_MIB;
7use crate::{config::Config, sampling::Sampling};
8use anyhow::{Context, Result, ensure};
9use rand::rngs::StdRng;
10use serde::Serialize;
11use serde_json::{Map, Value, json};
12use std::{
13 collections::HashMap,
14 sync::{Arc, Mutex},
15 time::{Duration, Instant},
16};
17
18struct Session {
19 history: Box<str>,
20 sampling: Sampling,
21 context: usize,
22 rng: Option<StdRng>,
23 busy: Option<String>,
24 touched: Instant,
25 reserved: usize,
26 committed_turns: u64,
27 usage: UsageStats,
28}
29
30#[derive(Serialize)]
31struct SessionStats<'a> {
32 committed_turns: u64,
33 history_bytes: usize,
34 reserved_history_bytes: usize,
35 usage: &'a UsageStats,
36}
37
38impl Session {
39 fn stats(&self) -> SessionStats<'_> {
40 SessionStats {
41 committed_turns: self.committed_turns,
42 history_bytes: self.history.len(),
43 reserved_history_bytes: self.reserved,
44 usage: &self.usage,
45 }
46 }
47}
48
49pub(super) struct Store {
50 entries: HashMap<String, Session>,
51 limit: usize,
52 capacity: usize,
53 idle: Duration,
54 pub evictions: u64,
55}
56
57impl Store {
58 pub(super) fn new(config: &Config) -> Self {
59 Self {
60 entries: HashMap::new(),
61 limit: config.limits.max_sessions,
62 capacity: config.limits.session_history_mib * BYTES_PER_MIB,
63 idle: Duration::from_secs(config.limits.session_idle_seconds),
64 evictions: 0,
65 }
66 }
67
68 pub(super) fn bytes(&self) -> usize {
69 self.entries
70 .iter()
71 .map(|(id, session)| {
72 id.capacity() + session.history.len() + session.reserved + size_of::<Session>()
73 })
74 .sum()
75 }
76
77 pub(super) fn count(&self) -> usize {
78 self.entries.len()
79 }
80
81 pub(super) fn stats(&self) -> crate::control::state::SessionStats {
82 crate::control::state::SessionStats {
83 entries: self.count(),
84 bytes: self.bytes(),
85 evictions: self.evictions,
86 }
87 }
88
89 pub(super) fn expire(&mut self) {
90 if self.idle.is_zero() {
91 return;
92 }
93
94 self.entries
95 .retain(|_, session| session.busy.is_some() || session.touched.elapsed() < self.idle);
96 }
97
98 fn make_room(&mut self, extra: usize, create: bool, protected: Option<&str>) -> Result<()> {
99 ensure!(
100 extra <= self.capacity,
101 "session history exceeds its memory budget"
102 );
103
104 while self.bytes() + extra > self.capacity || (create && self.entries.len() >= self.limit) {
105 let oldest = self
106 .entries
107 .iter()
108 .filter(|(id, session)| session.busy.is_none() && Some(id.as_str()) != protected)
109 .min_by_key(|(_, session)| session.touched)
110 .map(|(id, _)| id.clone())
111 .ok_or(Failure(503, "all session storage is in use"))?;
112
113 self.entries.remove(&oldest);
114
115 self.evictions += 1;
116 }
117
118 Ok(())
119 }
120
121 pub(super) fn create(&mut self, body: &Value, config: &Config) -> Result<String> {
122 ensure!(self.limit > 0, "retained sessions are disabled");
123 ensure!(body.is_object(), "session must be an object");
124
125 let sampling = Sampling::from_request(body, &config.defaults.sampling)?;
126 let context = body
127 .get("context_tokens")
128 .map(|v| v.as_u64().context("context_tokens must be an integer"))
129 .transpose()?
130 .unwrap_or(config.limits.context_tokens as u64) as usize;
131
132 ensure!(
133 context > 0 && context <= config.limits.context_tokens,
134 "context_tokens exceeds server capacity"
135 );
136 self.expire();
137
138 let id = registry::id("session");
139
140 self.make_room(size_of::<Session>() + id.capacity() + 2, true, None)?;
141 self.entries.insert(
142 id.clone(),
143 Session {
144 history: "[]".into(),
145 sampling,
146 context,
147 rng: None,
148 busy: None,
149 touched: Instant::now(),
150 reserved: 0,
151 committed_turns: 0,
152 usage: UsageStats::default(),
153 },
154 );
155
156 Ok(id)
157 }
158
159 pub(super) fn show(&mut self, id: &str) -> Result<Value> {
160 self.expire();
161
162 let session = self
163 .entries
164 .get(id)
165 .ok_or(Failure(404, "unknown or expired session"))?;
166
167 Ok(json!({
168 "id": id,
169 "object": "session",
170 "sampling": session.sampling,
171 "context_tokens": session.context,
172 "active_request_id": session.busy,
173 "messages": serde_json::from_str::<Value>(&session.history)?,
174 "stats": session.stats(),
175 }))
176 }
177
178 pub(super) fn delete(&mut self, id: &str) -> Result<()> {
179 self.expire();
180
181 let session = self
182 .entries
183 .get(id)
184 .ok_or(Failure(404, "unknown or expired session"))?;
185
186 ensure!(
187 session.busy.is_none(),
188 Failure(409, "cancel the active request before deleting its session")
189 );
190 self.entries.remove(id);
191
192 Ok(())
193 }
194}
195
196pub(super) struct Turn {
198 pub id: String,
199 store: Arc<Mutex<Store>>,
200 pub input: SessionInput,
201 pub rng: Option<StdRng>,
202}
203
204impl Turn {
205 pub(super) fn begin(
206 store: &Arc<Mutex<Store>>,
207 body: &Value,
208 request_id: &str,
209 request_bytes: usize,
210 ) -> Result<Option<Self>> {
211 let Some(id) = body.get("session_id").filter(|v| !v.is_null()) else {
212 return Ok(None);
213 };
214 let id = id
215 .as_str()
216 .context("session_id must be a string")?
217 .to_owned();
218 let incoming = body["messages"]
219 .as_array()
220 .context("sessions require chat messages")?
221 .clone();
222
223 ensure!(!incoming.is_empty(), "messages must not be empty");
224
225 let mut guard = store.lock().unwrap();
226
227 guard.expire();
228
229 let session = guard
230 .entries
231 .get_mut(&id)
232 .ok_or(Failure(404, "unknown or expired session"))?;
233
234 ensure!(
235 session.busy.is_none(),
236 Failure(409, "session already has an active request")
237 );
238
239 let sampling = Sampling::from_request(body, &session.sampling)?;
240 let restart_rng = body.get("seed").is_some_and(|v| !v.is_null());
241 let mut messages: Vec<Value> = serde_json::from_str(&session.history)?;
242
243 messages.extend(incoming);
244 ensure!(
245 serde_json::to_vec(&messages)?.len() <= request_bytes,
246 "session history and new messages exceed request_bytes"
247 );
248
249 let context = body
250 .get("context_tokens")
251 .map(|v| {
252 v.as_u64()
253 .and_then(|n| usize::try_from(n).ok())
254 .context("context_tokens must be a positive integer")
255 })
256 .transpose()?
257 .unwrap_or(session.context);
258
259 ensure!(
260 context > 0 && context <= session.context,
261 "context_tokens exceeds session capacity"
262 );
263
264 let rng = if restart_rng {
266 None
267 } else {
268 session.rng.clone()
269 };
270 session.busy = Some(request_id.to_owned());
271
272 Ok(Some(Self {
273 id,
274 store: store.clone(),
275 input: SessionInput {
276 messages,
277 sampling,
278 context,
279 },
280 rng,
281 }))
282 }
283
284 pub(super) fn prepare(
285 mut self,
286 text: &str,
287 rng: Option<StdRng>,
288 usage: UsageStats,
289 tool_calls: Option<&[WireToolCall]>,
290 ) -> Result<Commit> {
291 let mut message = json!({"role":"assistant", "content":text});
292
293 if let Some(calls) = tool_calls.filter(|calls| !calls.is_empty()) {
294 if text.is_empty() {
297 message["content"] = Value::Null;
298 }
299
300 message["tool_calls"] = Value::Array(
301 calls
302 .iter()
303 .map(|call| {
304 json!({
305 "id": call.id,
306 "type": "function",
307 "function": {
308 "name": call.name,
309 "arguments": serde_json::from_str::<Value>(
310 &call.arguments,
311 )
312 .unwrap_or_else(|_| Value::Object(Map::new())),
313 },
314 })
315 })
316 .collect::<Vec<Value>>(),
317 );
318 }
319
320 self.input.messages.push(message);
321
322 let history = serde_json::to_string(&self.input.messages)?.into_boxed_str();
323 let mut store = self.store.lock().unwrap();
324 let previous_bytes = store
325 .entries
326 .get(&self.id)
327 .context("session disappeared")?
328 .history
329 .len();
330 let growth = history.len().saturating_sub(previous_bytes);
331
332 store.make_room(growth, false, Some(&self.id))?;
333
334 store
335 .entries
336 .get_mut(&self.id)
337 .expect("pinned session")
338 .reserved = growth;
339
340 drop(store);
341
342 let rng = rng.or_else(|| self.rng.clone());
344
345 Ok(Commit {
346 turn: self,
347 history,
348 rng,
349 usage,
350 })
351 }
352}
353
354impl Drop for Turn {
355 fn drop(&mut self) {
356 if let Some(session) = self.store.lock().unwrap().entries.get_mut(&self.id) {
357 session.busy = None;
358 session.reserved = 0;
359 }
360 }
361}
362
363pub(super) struct Commit {
364 turn: Turn,
365 history: Box<str>,
366 rng: Option<StdRng>,
367 usage: UsageStats,
368}
369
370impl Commit {
371 pub(super) fn publish(self) {
372 let mut store = self.turn.store.lock().unwrap();
373 let session = store
374 .entries
375 .get_mut(&self.turn.id)
376 .expect("pinned session");
377 session.history = self.history;
378 session.sampling = self.turn.input.sampling.clone();
379 session.rng = self.rng;
380 session.committed_turns = session.committed_turns.saturating_add(1);
381
382 session.usage.add(self.usage);
383
384 session.reserved = 0;
385 session.touched = Instant::now();
386 }
387}
388
389#[cfg(test)]
390#[path = "../../tests/unit/server/sessions.rs"]
391mod tests;
392
393#[cfg(test)]
394#[path = "../../tests/unit/server/session_stats.rs"]
395mod stats_tests;