codex_convert_proxy/proxy/context.rs
1//! Proxy context data structures.
2//!
3//! The per-request `ProxyContext` composes several single-responsibility
4//! sub-structs so each cluster of related fields can be reasoned about (and
5//! initialised) independently:
6//!
7//! - [`RouteInfo`] — backend selection + path rewriting (populated in
8//! `request_filter`).
9//! - [`ConversionFlags`] — boolean state machine that drives whether and how
10//! bodies are converted.
11//! - [`ConversionBuffers`] — buffered request/response bodies + stream parse
12//! cursor.
13//! - [`UpstreamDiagnostics`] — observed upstream status / content-type / chunk
14//! count, kept for logging and bypass decisions.
15//! - [`FollowUpContext`] — conversation messages we'll persist after the
16//! upstream completes (for `previous_response_id` expansion).
17//!
18//! The root keeps only fields that don't fit any cluster: `start_time`,
19//! `model`, and the streaming `StreamState`.
20
21use std::time::Instant;
22
23use crate::convert::{ResponseRequestContext, StreamState};
24use crate::config::BackendInfo;
25use crate::types::chat_api::ChatMessage;
26use crate::types::response_api::ResponseRequest;
27
28/// Backend selection and request-path information.
29#[derive(Debug, Default)]
30pub struct RouteInfo {
31 /// Selected backend connection info.
32 pub selected_backend: Option<BackendInfo>,
33 /// Backend / provider name (cached for diagnostics after the backend
34 /// reference is no longer convenient to hold).
35 pub provider_name: Option<String>,
36 /// Request path after optional `path_prefix` stripping.
37 pub normalized_path: Option<String>,
38 /// Rewritten upstream path (includes backend's base_path).
39 pub rewritten_path: Option<String>,
40}
41
42/// Boolean state machine that records what kind of conversion is in flight.
43#[derive(Debug, Default)]
44pub struct ConversionFlags {
45 /// True if the original request targets `/v1/responses` (or `/responses`)
46 /// and must be converted to a Chat-API request.
47 pub is_conversion_request: bool,
48 /// True if the client requested streaming (`stream: true`).
49 pub is_streaming: bool,
50 /// True if the upstream is producing a streaming response. Usually equal
51 /// to `is_streaming` but retained as a separate flag for clarity around
52 /// upstream content-type detection.
53 pub is_stream_response: bool,
54 /// True only after `response_filter` confirmed the upstream is producing
55 /// SSE we should convert. Drives `response_body_filter`'s streaming path.
56 pub should_convert_stream_response: bool,
57}
58
59/// Buffered request/response bodies and the stream-parse cursor.
60#[derive(Debug, Default)]
61pub struct ConversionBuffers {
62 /// Collected request body bytes (cleared after conversion).
63 pub request_body: Vec<u8>,
64 /// Collected response body bytes (for non-streaming conversion + stream
65 /// frame accumulation).
66 pub response_body: Vec<u8>,
67 /// Offset in `response_body` that has already been parsed, so SSE event
68 /// reparsing across chunks is bounded.
69 pub stream_body_parsed_offset: usize,
70}
71
72/// Upstream observations for diagnostics + bypass decisions.
73#[derive(Debug, Default)]
74pub struct UpstreamDiagnostics {
75 /// Status code captured in `response_filter`.
76 pub upstream_status: Option<u16>,
77 /// `content-type` captured in `response_filter`.
78 pub upstream_content_type: Option<String>,
79 /// Count of valid upstream stream chunks parsed (drives "skip
80 /// response.completed" bypass when zero).
81 pub stream_chunks_parsed: usize,
82}
83
84/// Information needed to persist a completed turn for subsequent
85/// `previous_response_id` lookups.
86#[derive(Debug, Default)]
87pub struct FollowUpContext {
88 /// Conversation messages collected before the upstream response (so the
89 /// assistant reply can be appended to them and stored).
90 pub pending_conversation_messages: Option<Vec<ChatMessage>>,
91 /// Effective instructions used for this turn (after history expansion).
92 pub pending_instructions: Option<String>,
93}
94
95/// Proxy context attached to each request session.
96#[derive(Debug)]
97pub struct ProxyContext {
98 /// Request start time for duration tracking.
99 pub start_time: Instant,
100 /// Model name parsed from request.
101 pub model: Option<String>,
102 /// Stream state for SSE conversion (also used as a carrier for
103 /// `ResponseRequestContext` in non-streaming conversions; this dual use is
104 /// scheduled to be decoupled in a follow-up commit).
105 pub stream_state: Option<StreamState>,
106
107 pub route: RouteInfo,
108 pub flags: ConversionFlags,
109 pub buffers: ConversionBuffers,
110 pub diagnostics: UpstreamDiagnostics,
111 pub follow_up: FollowUpContext,
112}
113
114impl ProxyContext {
115 /// Create a new proxy context.
116 pub fn new() -> Self {
117 Self {
118 start_time: Instant::now(),
119 model: None,
120 stream_state: None,
121 route: RouteInfo::default(),
122 flags: ConversionFlags::default(),
123 buffers: ConversionBuffers::default(),
124 diagnostics: UpstreamDiagnostics::default(),
125 follow_up: FollowUpContext::default(),
126 }
127 }
128
129 /// Populate `model` + streaming flags from a parsed `ResponseRequest`.
130 ///
131 /// Conversion path: callers already deserialize the body as `ResponseRequest`
132 /// to perform the conversion, so we accept the parsed struct directly to
133 /// avoid re-parsing the JSON.
134 pub fn init_from_response_request(&mut self, req: &ResponseRequest) {
135 if self.model.is_none() {
136 self.model = Some(req.model.clone());
137 }
138 self.flags.is_streaming = req.stream;
139 if req.stream {
140 self.flags.is_stream_response = true;
141 }
142
143 if self.flags.is_conversion_request {
144 let model = self.model.clone().unwrap_or_else(|| "unknown".to_string());
145 let context = self
146 .stream_state
147 .as_ref()
148 .and_then(|s| s.request_context.clone());
149 self.stream_state = Some(StreamState::new(
150 format!("resp_{}", uuid::Uuid::new_v4()),
151 model,
152 context,
153 ));
154 }
155 }
156
157 /// Pass-through path: extract just `model` + `stream` from a serde_json::Value.
158 ///
159 /// Used when the body is not a Responses API request (e.g., direct Chat
160 /// Completions pass-through), so a full typed parse would be wrong.
161 pub fn init_from_passthrough_json(&mut self, json: &serde_json::Value) {
162 if self.model.is_none()
163 && let Some(model) = json.get("model").and_then(|v| v.as_str())
164 {
165 self.model = Some(model.to_string());
166 }
167 if let Some(stream) = json.get("stream").and_then(|v| v.as_bool()) {
168 self.flags.is_streaming = stream;
169 if stream {
170 self.flags.is_stream_response = true;
171 }
172 }
173 }
174
175 /// Set the response request context from a parsed ResponseRequest.
176 /// This should be called during request_body_filter processing.
177 pub fn set_response_request_context(&mut self, context: ResponseRequestContext) {
178 // If stream_state already exists, update its request_context
179 if let Some(ref mut state) = self.stream_state {
180 state.request_context = Some(context);
181 } else {
182 // Create stream_state with the context
183 let model = self.model.clone().unwrap_or_else(|| "unknown".to_string());
184 self.stream_state = Some(StreamState::new(
185 format!("resp_{}", uuid::Uuid::new_v4()),
186 model,
187 Some(context),
188 ));
189 }
190 }
191}
192
193impl Default for ProxyContext {
194 fn default() -> Self {
195 Self::new()
196 }
197}