Skip to main content

gproxy_protocol/adapt/generate/stream/
history.rs

1//! Scoped Responses continuation snapshots contain only declared native history.
2//!
3//! A snapshot is keyed by the response ID the gateway itself gave the client,
4//! never by the upstream's: many OpenAI-compatible upstreams repeat one ID for
5//! every response, and a key shared by two conversations would hand one of them
6//! the other's history.
7
8use 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/// Connection-owned cache for `store=false` Responses continuation. Clone this
35/// handle only within one client connection; dropping all handles loses its
36/// cached content. No cache entries are written to StateStore for store=false.
37#[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    /// The first invocation to use this cache binds it to its conversation.
59    /// A cache offered under another conversation, or after that binding
60    /// expired, is refused rather than served.
61    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        // A never-bound empty cache has no content.
94        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    /// Expand a Responses request for a connection-owned orchestration layer.
152    /// No generation is sent and previous instructions are not inherited.
153    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    /// Save the actual ordered transcript of a gateway response. This also
165    /// supports warmup and interrupted responses without inventing a native
166    /// generation or duplicating the output of earlier HTTP segments.
167    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    // The native input/output definitions differ. Select the concrete history
219    // variant explicitly; never deserialize through the ambiguous untagged union.
220    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
254/// Preserve the concrete Responses history variant when replaying completed
255/// output. In particular, do not deserialize through the untagged input union.
256pub 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    /// Gives the client response a gateway-generated ID when this history
264    /// will be stored, so the snapshot key belongs to this response alone.
265    /// The ID is allocated before the bridge sees the upstream's, at the
266    /// position the bridge resolves the response under (`source`, index 0);
267    /// the upstream ID then only attaches to it and never reaches the client.
268    /// The allocator builds the ID from the invocation's random response
269    /// namespace, e.g. `resp_<32 hex>_h_r_0`.
270    ///
271    /// The upstream ID is not kept anywhere: a Chat, Claude or Gemini upstream
272    /// holds no state a later turn could name, and the next turn is rebuilt
273    /// from this snapshot. A `store=false` request stores nothing, so its
274    /// response keeps the upstream's ID as it did before.
275    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        // Apply the byte budget to the complete expanded request.
367        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
417/// Writes a snapshot under its key, replacing whatever is there. The key names
418/// a response ID this gateway generated for one response alone, so an existing
419/// entry can only be this same snapshot, left by an earlier attempt whose
420/// acknowledgement was lost; overwriting it keeps the retry idempotent.
421async 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    // The first try assumes the key is absent, which it is unless a retry
434    // follows a write that landed. The second replaces that entry; a third
435    // outcome means another writer holds a gateway-generated ID, which is a
436    // host bug worth surfacing rather than looping on.
437    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}