1use crate::error::Error;
16use crate::keys;
17use crate::map::HeaderMap;
18use crate::name::HeaderName;
19use crate::schema::{from_inbound_or, opt_from_inbound, optional, put, put_opt, required, HeaderSchema};
20use crate::vars::TemplateVars;
21use serde::{Deserialize, Serialize};
22use smol_str::SmolStr;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct CodexCliHeaders {
26 #[serde(rename = "User-Agent")]
28 pub user_agent: SmolStr,
29 #[serde(rename = "Authorization")]
30 pub authorization: SmolStr,
31 #[serde(rename = "Host", skip_serializing_if = "Option::is_none")]
37 pub host: Option<SmolStr>,
38 #[serde(rename = "Accept", skip_serializing_if = "Option::is_none")]
39 pub accept: Option<SmolStr>,
40 #[serde(rename = "Connection", skip_serializing_if = "Option::is_none")]
41 pub connection: Option<SmolStr>,
42 #[serde(rename = "Upgrade", skip_serializing_if = "Option::is_none")]
43 pub upgrade: Option<SmolStr>,
44 #[serde(rename = "originator", skip_serializing_if = "Option::is_none")]
45 pub originator: Option<SmolStr>,
46 #[serde(rename = "chatgpt-account-id", skip_serializing_if = "Option::is_none")]
47 pub chatgpt_account_id: Option<SmolStr>,
48 #[serde(rename = "version", skip_serializing_if = "Option::is_none")]
49 pub version: Option<SmolStr>,
50
51 #[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
54 pub content_type: Option<SmolStr>,
55 #[serde(rename = "Content-Length", skip_serializing_if = "Option::is_none")]
56 pub content_length: Option<SmolStr>,
57
58 #[serde(rename = "session_id", skip_serializing_if = "Option::is_none")]
60 pub session_id: Option<SmolStr>,
61 #[serde(rename = "thread_id", skip_serializing_if = "Option::is_none")]
62 pub thread_id: Option<SmolStr>,
63 #[serde(rename = "x-client-request-id", skip_serializing_if = "Option::is_none")]
64 pub client_request_id: Option<SmolStr>,
65 #[serde(rename = "x-codex-window-id", skip_serializing_if = "Option::is_none")]
66 pub codex_window_id: Option<SmolStr>,
67 #[serde(rename = "x-codex-beta-features", skip_serializing_if = "Option::is_none")]
68 pub codex_beta_features: Option<SmolStr>,
69 #[serde(rename = "x-codex-turn-metadata", skip_serializing_if = "Option::is_none")]
70 pub codex_turn_metadata: Option<SmolStr>,
71 #[serde(rename = "OpenAI-Beta", skip_serializing_if = "Option::is_none")]
72 pub openai_beta: Option<SmolStr>,
73 #[serde(rename = "X-Request-Id", skip_serializing_if = "Option::is_none")]
74 pub request_id: Option<SmolStr>,
75 #[serde(rename = "Sec-WebSocket-Extensions", skip_serializing_if = "Option::is_none")]
76 pub sec_websocket_extensions: Option<SmolStr>,
77 #[serde(rename = "Sec-WebSocket-Key", skip_serializing_if = "Option::is_none")]
78 pub sec_websocket_key: Option<SmolStr>,
79 #[serde(rename = "Sec-WebSocket-Version", skip_serializing_if = "Option::is_none")]
80 pub sec_websocket_version: Option<SmolStr>,
81
82 #[serde(rename = "Cookie", skip_serializing_if = "Option::is_none")]
84 pub cookie: Option<SmolStr>,
85}
86
87impl HeaderSchema for CodexCliHeaders {
88 fn parse(map: &HeaderMap) -> Result<Self, Error> {
89 Ok(Self {
90 user_agent: required(map, &keys::USER_AGENT)?,
91 authorization: required(map, &keys::AUTHORIZATION)?,
92 host: optional(map, &keys::HOST),
93 accept: optional(map, &keys::ACCEPT),
94 connection: optional(map, &keys::CONNECTION),
95 upgrade: optional(map, &keys::UPGRADE),
96 originator: optional(map, &keys::ORIGINATOR),
97 chatgpt_account_id: optional(map, &keys::CHATGPT_ACCOUNT_ID),
98 version: optional(map, &keys::VERSION),
99 content_type: optional(map, &keys::CONTENT_TYPE),
100 content_length: optional(map, &keys::CONTENT_LENGTH),
101 session_id: optional(map, &keys::SESSION_ID_LOWER),
102 thread_id: optional(map, &keys::THREAD_ID),
103 client_request_id: optional(map, &keys::X_CLIENT_REQUEST_ID),
104 codex_window_id: optional(map, &keys::X_CODEX_WINDOW_ID),
105 codex_beta_features: optional(map, &keys::X_CODEX_BETA_FEATURES),
106 codex_turn_metadata: optional(map, &keys::X_CODEX_TURN_METADATA),
107 openai_beta: optional(map, &keys::OPENAI_BETA),
108 request_id: optional(map, &keys::X_REQUEST_ID),
109 sec_websocket_extensions: optional(map, &keys::SEC_WEBSOCKET_EXTENSIONS),
110 sec_websocket_key: optional(map, &keys::SEC_WEBSOCKET_KEY),
111 sec_websocket_version: optional(map, &keys::SEC_WEBSOCKET_VERSION),
112 cookie: optional(map, &keys::COOKIE),
113 })
114 }
115 fn dump(&self) -> HeaderMap {
116 let mut m = HeaderMap::new();
117 put(&mut m, &keys::USER_AGENT, &self.user_agent);
118 put(&mut m, &keys::AUTHORIZATION, &self.authorization);
119 put_opt(&mut m, &keys::HOST, &self.host);
120 put_opt(&mut m, &keys::ACCEPT, &self.accept);
121 put_opt(&mut m, &keys::CONNECTION, &self.connection);
122 put_opt(&mut m, &keys::UPGRADE, &self.upgrade);
123 put_opt(&mut m, &keys::ORIGINATOR, &self.originator);
124 put_opt(&mut m, &keys::CHATGPT_ACCOUNT_ID, &self.chatgpt_account_id);
125 put_opt(&mut m, &keys::VERSION, &self.version);
126 put_opt(&mut m, &keys::CONTENT_TYPE, &self.content_type);
127 put_opt(&mut m, &keys::CONTENT_LENGTH, &self.content_length);
128 put_opt(&mut m, &keys::SESSION_ID_LOWER, &self.session_id);
129 put_opt(&mut m, &keys::THREAD_ID, &self.thread_id);
130 put_opt(&mut m, &keys::X_CLIENT_REQUEST_ID, &self.client_request_id);
131 put_opt(&mut m, &keys::X_CODEX_WINDOW_ID, &self.codex_window_id);
132 put_opt(&mut m, &keys::X_CODEX_BETA_FEATURES, &self.codex_beta_features);
133 put_opt(&mut m, &keys::X_CODEX_TURN_METADATA, &self.codex_turn_metadata);
134 put_opt(&mut m, &keys::OPENAI_BETA, &self.openai_beta);
135 put_opt(&mut m, &keys::X_REQUEST_ID, &self.request_id);
136 put_opt(&mut m, &keys::SEC_WEBSOCKET_EXTENSIONS, &self.sec_websocket_extensions);
137 put_opt(&mut m, &keys::SEC_WEBSOCKET_KEY, &self.sec_websocket_key);
138 put_opt(&mut m, &keys::SEC_WEBSOCKET_VERSION, &self.sec_websocket_version);
139 put_opt(&mut m, &keys::COOKIE, &self.cookie);
140 m
141 }
142 fn known_names() -> &'static [&'static HeaderName] {
143 static NAMES: [&HeaderName; 23] = [
144 &keys::USER_AGENT,
145 &keys::AUTHORIZATION,
146 &keys::HOST,
147 &keys::ACCEPT,
148 &keys::CONNECTION,
149 &keys::UPGRADE,
150 &keys::ORIGINATOR,
151 &keys::CHATGPT_ACCOUNT_ID,
152 &keys::VERSION,
153 &keys::CONTENT_TYPE,
154 &keys::CONTENT_LENGTH,
155 &keys::SESSION_ID_LOWER,
156 &keys::THREAD_ID,
157 &keys::X_CLIENT_REQUEST_ID,
158 &keys::X_CODEX_WINDOW_ID,
159 &keys::X_CODEX_BETA_FEATURES,
160 &keys::X_CODEX_TURN_METADATA,
161 &keys::OPENAI_BETA,
162 &keys::X_REQUEST_ID,
163 &keys::SEC_WEBSOCKET_EXTENSIONS,
164 &keys::SEC_WEBSOCKET_KEY,
165 &keys::SEC_WEBSOCKET_VERSION,
166 &keys::COOKIE,
167 ];
168 &NAMES
169 }
170}
171
172impl CodexCliHeaders {
173 pub fn build(vars: &TemplateVars, inbound: &HeaderMap) -> Self {
178 Self {
179 user_agent: from_inbound_or(inbound, &keys::USER_AGENT, || {
180 "codex_exec/0.130.0 (Ubuntu 24.4.0; x86_64) unknown (codex_exec; 0.130.0)".into()
181 }),
182 authorization: from_inbound_or(inbound, &keys::AUTHORIZATION, || "<missing>".into()),
183 host: None,
184 accept: Some(from_inbound_or(inbound, &keys::ACCEPT, || "text/event-stream".into())),
185 connection: opt_from_inbound(inbound, &keys::CONNECTION),
186 upgrade: opt_from_inbound(inbound, &keys::UPGRADE),
187 originator: Some(from_inbound_or(inbound, &keys::ORIGINATOR, || "codex_exec".into())),
188 chatgpt_account_id: vars
189 .account_id
190 .clone()
191 .or_else(|| opt_from_inbound(inbound, &keys::CHATGPT_ACCOUNT_ID)),
192 version: Some(from_inbound_or(inbound, &keys::VERSION, || "0.130.0".into())),
193 content_type: opt_from_inbound(inbound, &keys::CONTENT_TYPE),
194 content_length: opt_from_inbound(inbound, &keys::CONTENT_LENGTH),
195 session_id: vars
196 .session_id
197 .clone()
198 .or_else(|| opt_from_inbound(inbound, &keys::SESSION_ID_LOWER)),
199 thread_id: opt_from_inbound(inbound, &keys::THREAD_ID),
200 client_request_id: opt_from_inbound(inbound, &keys::X_CLIENT_REQUEST_ID).or_else(|| vars.request_id.clone()),
201 codex_window_id: opt_from_inbound(inbound, &keys::X_CODEX_WINDOW_ID),
202 codex_beta_features: opt_from_inbound(inbound, &keys::X_CODEX_BETA_FEATURES),
203 codex_turn_metadata: opt_from_inbound(inbound, &keys::X_CODEX_TURN_METADATA),
204 openai_beta: opt_from_inbound(inbound, &keys::OPENAI_BETA),
205 request_id: vars
206 .request_id
207 .clone()
208 .or_else(|| opt_from_inbound(inbound, &keys::X_REQUEST_ID)),
209 sec_websocket_extensions: opt_from_inbound(inbound, &keys::SEC_WEBSOCKET_EXTENSIONS),
210 sec_websocket_key: opt_from_inbound(inbound, &keys::SEC_WEBSOCKET_KEY),
211 sec_websocket_version: opt_from_inbound(inbound, &keys::SEC_WEBSOCKET_VERSION),
212 cookie: opt_from_inbound(inbound, &keys::COOKIE),
213 }
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 fn responses_sample() -> CodexCliHeaders {
222 CodexCliHeaders {
223 user_agent: "codex_exec/0.130.0 (Ubuntu 24.4.0; x86_64) unknown (codex_exec; 0.130.0)".into(),
224 authorization: "<redacted>".into(),
225 host: Some("chatgpt.com".into()),
226 accept: Some("text/event-stream".into()),
227 connection: None,
228 upgrade: None,
229 originator: Some("codex_exec".into()),
230 chatgpt_account_id: Some("<redacted>".into()),
231 version: Some("0.130.0".into()),
232 content_type: Some("application/json".into()),
233 content_length: Some("45273".into()),
234 session_id: Some("019e271b-4023-7081-be3e-7a69d97138a2".into()),
235 thread_id: Some("019e271b-4023-7081-be3e-7a69d97138a2".into()),
236 client_request_id: Some("019e271b-4023-7081-be3e-7a69d97138a2".into()),
237 codex_window_id: Some("019e271b-4023-7081-be3e-7a69d97138a2:0".into()),
238 codex_beta_features: Some("terminal_resize_reflow".into()),
239 codex_turn_metadata: Some("{\"session_id\":\"019e271b\"}".into()),
240 openai_beta: None,
241 request_id: None,
242 sec_websocket_extensions: None,
243 sec_websocket_key: None,
244 sec_websocket_version: None,
245 cookie: None,
246 }
247 }
248
249 #[test]
250 fn responses_round_trip() {
251 let h = responses_sample();
252 assert_eq!(CodexCliHeaders::parse(&h.dump()).unwrap(), h);
253 }
254
255 #[test]
256 fn missing_required_errors() {
257 let m = HeaderMap::new();
258 assert!(matches!(CodexCliHeaders::parse(&m), Err(Error::MissingHeader { .. })));
259 }
260
261 #[test]
262 fn build_with_empty_inbound_uses_defaults() {
263 let h = CodexCliHeaders::build(&TemplateVars::default(), &HeaderMap::new());
264 assert_eq!(
265 h.user_agent.as_str(),
266 "codex_exec/0.130.0 (Ubuntu 24.4.0; x86_64) unknown (codex_exec; 0.130.0)"
267 );
268 assert_eq!(h.authorization.as_str(), "<missing>");
269 assert!(
270 h.host.is_none(),
271 "no inbound Host => no persona-default Host (would leak to wire)"
272 );
273 assert_eq!(h.accept.as_deref(), Some("text/event-stream"));
274 assert_eq!(h.originator.as_deref(), Some("codex_exec"));
275 assert!(h.chatgpt_account_id.is_none());
276 assert_eq!(h.version.as_deref(), Some("0.130.0"));
277 assert!(h.content_type.is_none());
278 assert!(h.session_id.is_none());
279 assert!(h.thread_id.is_none());
280 }
281
282 #[test]
283 fn build_passes_through_inbound() {
284 let mut inbound = HeaderMap::new();
285 inbound.insert(&keys::USER_AGENT, "codex_exec/9.9.9");
286 inbound.insert(&keys::AUTHORIZATION, "Bearer abc");
287 inbound.insert(&keys::OPENAI_BETA, "responses=v1");
288 inbound.insert(&keys::HOST, "chatgpt.com");
289 inbound.insert(&keys::X_CLIENT_REQUEST_ID, "codex-req");
290 let h = CodexCliHeaders::build(&TemplateVars::default(), &inbound);
291 assert_eq!(h.user_agent.as_str(), "codex_exec/9.9.9");
292 assert_eq!(h.authorization.as_str(), "Bearer abc");
293 assert_eq!(h.openai_beta.as_deref(), Some("responses=v1"));
294 assert_eq!(h.host.as_deref(), None);
295 assert_eq!(h.client_request_id.as_deref(), Some("codex-req"));
296 }
297
298 #[test]
299 fn build_uses_vars_for_correlation() {
300 let vars = TemplateVars {
301 session_id: Some("ses_xyz".into()),
302 request_id: Some("req_42".into()),
303 account_id: Some("acct_z".into()),
304 ..Default::default()
305 };
306 let h = CodexCliHeaders::build(&vars, &HeaderMap::new());
307 assert_eq!(h.session_id.as_deref(), Some("ses_xyz"));
308 assert_eq!(h.client_request_id.as_deref(), Some("req_42"));
309 assert_eq!(h.request_id.as_deref(), Some("req_42"));
310 assert_eq!(h.chatgpt_account_id.as_deref(), Some("acct_z"));
311 }
312}