1use super::super::{GenerationIdentity, GenerationStateAccess};
9use crate::{
10 Dialect,
11 capability::{CasResult, StateStore, StateWrite},
12 transform::{
13 TransformError,
14 identity::{IdentityRole, SourceIdentity},
15 },
16 wire::{DeclaredFields, openai::responses as r},
17};
18use serde::{Deserialize, Serialize};
19
20#[derive(Clone, Serialize, Deserialize)]
21struct Snapshot {
22 schema: u16,
23 response_id: String,
24 input: Vec<r::input::InputItem>,
25 output: Vec<r::response::ResponseOutputItem>,
26}
27
28pub(super) struct History {
29 input: Vec<r::input::InputItem>,
30 enabled: bool,
31 cache: Option<ResponsesHistoryCache>,
32}
33
34#[derive(Clone)]
38pub struct ResponsesHistoryCache(std::sync::Arc<std::sync::Mutex<Cache>>);
39
40struct Cache {
41 binding: Option<super::binding::StateBinding>,
42 max_entries: usize,
43 max_bytes: usize,
44 bytes: usize,
45 entries: std::collections::VecDeque<(Snapshot, std::time::SystemTime, usize)>,
46}
47
48impl ResponsesHistoryCache {
49 pub fn new(max_entries: usize, max_bytes: usize) -> Self {
50 Self(std::sync::Arc::new(std::sync::Mutex::new(Cache {
51 binding: None,
52 max_entries,
53 max_bytes,
54 bytes: 0,
55 entries: Default::default(),
56 })))
57 }
58 fn bind<S: StateStore>(
62 &self,
63 binding: &super::binding::StateBinding,
64 state: &GenerationStateAccess<'_, S>,
65 ) -> Result<(), TransformError> {
66 let mut cache = self
67 .0
68 .lock()
69 .map_err(|_| super::invalid("Responses history cache lock poisoned"))?;
70 Self::check(cache.binding.get_or_insert_with(|| binding.clone()), state)
71 }
72 fn check<S: StateStore>(
73 binding: &super::binding::StateBinding,
74 state: &GenerationStateAccess<'_, S>,
75 ) -> Result<(), TransformError> {
76 if binding.serves(state) {
77 Ok(())
78 } else {
79 Err(super::missing(
80 "Responses history cache is bound to another conversation or expired",
81 ))
82 }
83 }
84 fn get<S: StateStore>(
85 &self,
86 id: &str,
87 state: &GenerationStateAccess<'_, S>,
88 ) -> Result<Option<Snapshot>, TransformError> {
89 let cache = self
90 .0
91 .lock()
92 .map_err(|_| super::invalid("Responses history cache lock poisoned"))?;
93 let Some(binding) = &cache.binding else {
95 return Ok(None);
96 };
97 Self::check(binding, state)?;
98 Ok(cache
99 .entries
100 .iter()
101 .find(|(value, expiry, _)| value.response_id == id && *expiry > state.now)
102 .map(|(value, _, _)| value.clone()))
103 }
104 fn save<S: StateStore>(
105 &self,
106 snapshot: Snapshot,
107 state: &GenerationStateAccess<'_, S>,
108 size: usize,
109 ) -> Result<(), TransformError> {
110 let mut cache = self
111 .0
112 .lock()
113 .map_err(|_| super::invalid("Responses history cache lock poisoned"))?;
114
115 if cache.binding.is_none() {
116 return Err(super::missing(
117 "Responses cache has no conversation binding",
118 ));
119 }
120 if size > cache.max_bytes {
121 return Err(super::limit(
122 "Responses snapshot exceeds connection cache budget",
123 ));
124 }
125 if let Some((existing, expiry, _)) = cache
126 .entries
127 .iter()
128 .find(|(v, _, _)| v.response_id == snapshot.response_id)
129 {
130 if *expiry != state.expires_at
131 || serde_json::to_vec(existing)? != serde_json::to_vec(&snapshot)?
132 {
133 return Err(super::conflict("Responses cached response ID changed"));
134 }
135 return Ok(());
136 }
137 while cache.entries.len() >= cache.max_entries
138 || cache.bytes.saturating_add(size) > cache.max_bytes
139 {
140 let (_, _, removed) = cache
141 .entries
142 .pop_front()
143 .ok_or_else(|| super::invalid("Responses cache accounting inconsistent"))?;
144 cache.bytes -= removed;
145 }
146 cache.bytes += size;
147 cache.entries.push_back((snapshot, state.expires_at, size));
148 Ok(())
149 }
150
151 pub async fn expand<S: StateStore>(
154 &self,
155 request: &r::GenerateContentRequestBody,
156 state: &GenerationStateAccess<'_, S>,
157 limits: crate::codec::CodecLimits,
158 ) -> Result<r::GenerateContentRequestBody, TransformError> {
159 self.bind(&super::binding::StateBinding::new(state), state)?;
160 let (_, expanded) = History::prepare_with_cache(request, state, limits, Some(self)).await?;
161 Ok(expanded)
162 }
163
164 pub async fn save_transcript<S: StateStore>(
168 &self,
169 response_id: &str,
170 input: Vec<r::input::InputItem>,
171 store: bool,
172 state: &GenerationStateAccess<'_, S>,
173 limits: crate::codec::CodecLimits,
174 ) -> Result<(), TransformError> {
175 self.bind(&super::binding::StateBinding::new(state), state)?;
176 let snapshot = Snapshot {
177 schema: 1,
178 response_id: response_id.into(),
179 input,
180 output: vec![],
181 };
182 let payload = crate::codec::encode_json(&snapshot, limits).map_err(super::codec_error)?;
183 if store {
184 if payload.len() as u64 > state.store.limits().write_bytes {
185 return Err(super::limit("Responses history write budget exceeded"));
186 }
187 write(state, &key(state, response_id), payload.to_vec()).await?;
188 }
189 self.save(snapshot, state, payload.len())
190 }
191}
192
193fn key<S: StateStore>(state: &GenerationStateAccess<'_, S>, id: &str) -> String {
194 format!(
195 "responses-history:{}:{}:{}:{}",
196 state.conversation_key.len(),
197 state.conversation_key,
198 id.len(),
199 id
200 )
201}
202
203fn items(input: Option<r::input::Input>) -> Vec<r::input::InputItem> {
204 use r::input::*;
205 match input {
206 None => vec![],
207 Some(Input::Items(v)) => v,
208 Some(Input::Text(text)) => vec![InputItem::Easy(
209 EasyInputMessage::builder(MessageContent::Text(text), MessageRole::User).build(),
210 )],
211 }
212}
213
214pub(crate) fn output_item(
215 item: r::response::ResponseOutputItem,
216) -> Result<r::input::InputItem, TransformError> {
217 use r::{input::InputItem as I, response::ResponseOutputItem as O};
218 fn same<A: Serialize, T: serde::de::DeserializeOwned + DeclaredFields>(
221 v: A,
222 ) -> Result<T, TransformError> {
223 Ok(serde_json::from_value::<T>(serde_json::to_value(v)?)?.into_declared())
224 }
225 Ok(match item.into_declared() {
226 O::Message(v) => I::OutputMessage(same(v)?),
227 O::FunctionCall(v) => I::FunctionCall(same(v)?),
228 O::CustomToolCall(v) => I::CustomToolCall(same(v)?),
229 O::CustomToolCallOutput(v) => I::CustomToolCallOutput(same(v)?),
230 O::ComputerCall(v) => I::ComputerCall(v),
231 O::ComputerCallOutput(v) => I::ComputerCallOutput(same(v)?),
232 O::McpApprovalRequest(v) => I::McpApprovalRequest(v),
233 O::McpApprovalResponse(v) => I::McpApprovalResponse(same(v)?),
234 O::McpCall(v) => I::McpCall(v),
235 O::McpListTools(v) => I::McpListTools(v),
236 O::ShellCall(v) => I::ShellCall(same(v)?),
237 O::ShellCallOutput(v) => I::ShellCallOutput(same(v)?),
238 O::ApplyPatchCall(v) => I::ApplyPatchCall(same(v)?),
239 O::ApplyPatchCallOutput(v) => I::ApplyPatchCallOutput(same(v)?),
240 O::ToolSearchCall(v) => I::ToolSearchCall(same(v)?),
241 O::ToolSearchOutput(v) => I::ToolSearchOutput(same(v)?),
242 O::AdditionalTools(v) => I::AdditionalTools(same(v)?),
243 O::Reasoning(v) => I::Reasoning(same(v)?),
244 O::ImageGenerationCall(v) => I::ImageGenerationCall(same(v)?),
245 _ => {
246 return Err(TransformError::unsupported(
247 "previous_response_id.output",
248 "this declared output has no implemented cross-protocol history mapping",
249 ));
250 }
251 })
252}
253
254pub fn response_output_as_input(
257 item: r::response::ResponseOutputItem,
258) -> Result<r::input::InputItem, TransformError> {
259 output_item(item)
260}
261
262impl History {
263 pub fn claim_response_id(
276 &self,
277 identities: &mut GenerationIdentity,
278 source: Dialect,
279 ) -> Result<(), TransformError> {
280 if !self.enabled {
281 return Ok(());
282 }
283 identities
284 .response
285 .resolve_or_allocate(
286 IdentityRole::Response,
287 SourceIdentity::new(source, None, 0),
288 &identities.response_policy,
289 )
290 .map_err(|error| super::invalid(error.to_string()))?;
291 Ok(())
292 }
293 pub fn bind<S: StateStore>(
294 &self,
295 binding: &super::binding::StateBinding,
296 state: &GenerationStateAccess<'_, S>,
297 ) -> Result<(), TransformError> {
298 if let Some(cache) = &self.cache {
299 cache.bind(binding, state)?;
300 }
301 Ok(())
302 }
303 pub async fn prepare_with_cache<S: StateStore>(
304 request: &r::GenerateContentRequestBody,
305 state: &GenerationStateAccess<'_, S>,
306 limits: crate::codec::CodecLimits,
307 cache: Option<&ResponsesHistoryCache>,
308 ) -> Result<(Self, r::GenerateContentRequestBody), TransformError> {
309 if let Some(cache) = cache {
310 cache.get("", state)?;
311 }
312 let mut expanded = request.clone().into_declared();
313 let mut input = vec![];
314 if let Some(id) = request
315 .previous_response_id
316 .as_ref()
317 .and_then(Option::as_ref)
318 {
319 if id.trim().is_empty() {
320 return Err(super::missing("empty previous_response_id"));
321 }
322 if request
323 .conversation
324 .as_ref()
325 .and_then(Option::as_ref)
326 .is_some()
327 {
328 return Err(TransformError::shape(
329 "previous_response_id",
330 "conversation and previous_response_id cannot be combined",
331 ));
332 }
333 let cached = if let Some(cache) = cache {
334 cache.get(id, state)?
335 } else {
336 None
337 };
338 let snapshot = if let Some(snapshot) = cached {
339 snapshot
340 } else {
341 let entry = state.store.get(state.scope, &key(state, id)).await?
342 .ok_or_else(|| super::missing("previous_response_id has no scoped declared history; supply full input with previous_response_id null"))?;
343 if entry.payload.len() as u64
344 > state.store.limits().read_bytes.min(limits.max_body_bytes)
345 {
346 return Err(super::limit("Responses history read budget exceeded"));
347 }
348 if entry.expires_at.is_none_or(|v| v <= state.now) {
349 return Err(super::missing("Responses history expired"));
350 }
351 let snapshot: Snapshot = serde_json::from_slice(&entry.payload)?;
352 snapshot
353 };
354 input = snapshot
355 .input
356 .into_iter()
357 .map(DeclaredFields::into_declared)
358 .collect();
359 for item in snapshot.output {
360 input.push(output_item(item)?);
361 }
362 }
363 input.extend(items(expanded.input.take()));
364 expanded.input = Some(r::input::Input::Items(input.clone()));
365 expanded.previous_response_id = None;
366 crate::codec::encode_json(&expanded, limits).map_err(super::codec_error)?;
368 Ok((
369 Self {
370 input,
371 enabled: request.store.flatten() != Some(false),
372 cache: cache.cloned(),
373 },
374 expanded,
375 ))
376 }
377 pub async fn save<S: StateStore>(
378 &mut self,
379 response: &r::GenerateContentResponseBody,
380 state: &GenerationStateAccess<'_, S>,
381 limits: crate::codec::CodecLimits,
382 ) -> Result<(), TransformError> {
383 if !self.enabled && self.cache.is_none() {
384 return Ok(());
385 }
386 if response.id.is_empty() {
387 return Err(super::invalid("cannot save history without response ID"));
388 }
389 let snapshot = Snapshot {
390 schema: 1,
391 response_id: response.id.clone(),
392 input: self.input.clone(),
393 output: response.clone().into_declared().output,
394 };
395 let payload = serde_json::to_vec(&snapshot)?;
396 if payload.len() as u64 > limits.max_body_bytes {
397 return Err(super::limit("Responses history codec budget exceeded"));
398 }
399 if !self.enabled {
400 if let Some(cache) = &self.cache {
401 cache.save(snapshot, state, payload.len())?;
402 }
403 return Ok(());
404 }
405 if payload.len() as u64 > state.store.limits().write_bytes {
406 return Err(super::limit("Responses history write budget exceeded"));
407 }
408 let size = payload.len();
409 write(state, &key(state, &response.id), payload).await?;
410 if let Some(cache) = &self.cache {
411 cache.save(snapshot, state, size)?;
412 }
413 Ok(())
414 }
415}
416
417async fn write<S: StateStore>(
422 state: &GenerationStateAccess<'_, S>,
423 key: &str,
424 payload: Vec<u8>,
425) -> Result<(), TransformError> {
426 let replacement = || {
427 Some(StateWrite {
428 payload: payload.clone().into(),
429 expires_at: Some(state.expires_at),
430 })
431 };
432 let mut expected = None;
433 for _ in 0..2 {
438 match state
439 .store
440 .compare_exchange(state.scope, key, expected, replacement())
441 .await?
442 {
443 CasResult::Applied(_) => return Ok(()),
444 CasResult::Conflict => {
445 expected = state
446 .store
447 .get(state.scope, key)
448 .await?
449 .map(|entry| entry.version);
450 }
451 }
452 }
453 Err(super::conflict(
454 "Responses history is being written by another invocation",
455 ))
456}