Skip to main content

tokn_headers/schemas/overlays/
codex.rs

1//! Codex (ChatGPT account) transport overlay.
2//!
3//! Headers required when targeting the ChatGPT-account Codex backend on top
4//! of a base persona.
5//!
6//! SCOPE: this overlay models **outbound** headers the router injects /
7//! validates when forwarding to `chatgpt.com`. The codex-cli-native
8//! inbound headers (`originator`, `version`, `session_id`, `thread_id`,
9//! `x-codex-*`) are modelled directly on `CodexCliHeaders`.
10
11use crate::error::Error;
12use crate::keys;
13use crate::map::HeaderMap;
14use crate::name::HeaderName;
15use crate::schema::{from_inbound_or, opt_from_inbound, optional, put, put_opt, required, HeaderSchema};
16use crate::vars::TemplateVars;
17use serde::{Deserialize, Serialize};
18use smol_str::SmolStr;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct CodexOverlay {
22  #[serde(rename = "OpenAI-Beta")]
23  pub openai_beta: SmolStr,
24  #[serde(rename = "OpenAI-Intent")]
25  pub openai_intent: Option<SmolStr>,
26  #[serde(rename = "chatgpt-account-id")]
27  pub chatgpt_account_id: Option<SmolStr>,
28  #[serde(rename = "X-Session-Id")]
29  pub session_id: Option<SmolStr>,
30}
31
32impl HeaderSchema for CodexOverlay {
33  fn parse(map: &HeaderMap) -> Result<Self, Error> {
34    Ok(Self {
35      openai_beta: required(map, &keys::OPENAI_BETA)?,
36      openai_intent: optional(map, &keys::OPENAI_INTENT),
37      chatgpt_account_id: optional(map, &keys::CHATGPT_ACCOUNT_ID),
38      session_id: optional(map, &keys::X_SESSION_ID),
39    })
40  }
41  fn dump(&self) -> HeaderMap {
42    let mut m = HeaderMap::new();
43    put(&mut m, &keys::OPENAI_BETA, &self.openai_beta);
44    put_opt(&mut m, &keys::OPENAI_INTENT, &self.openai_intent);
45    put_opt(&mut m, &keys::CHATGPT_ACCOUNT_ID, &self.chatgpt_account_id);
46    put_opt(&mut m, &keys::X_SESSION_ID, &self.session_id);
47    m
48  }
49  fn known_names() -> &'static [&'static HeaderName] {
50    static NAMES: [&HeaderName; 4] = [
51      &keys::OPENAI_BETA,
52      &keys::OPENAI_INTENT,
53      &keys::CHATGPT_ACCOUNT_ID,
54      &keys::X_SESSION_ID,
55    ];
56    &NAMES
57  }
58}
59
60impl CodexOverlay {
61  /// Build a [`CodexOverlay`] from inbound transport headers and
62  /// correlation [`TemplateVars`].
63  pub fn build(vars: &TemplateVars, inbound: &HeaderMap) -> Self {
64    Self {
65      openai_beta: from_inbound_or(inbound, &keys::OPENAI_BETA, || "responses=v1".into()),
66      openai_intent: opt_from_inbound(inbound, &keys::OPENAI_INTENT),
67      chatgpt_account_id: vars
68        .account_id
69        .clone()
70        .or_else(|| opt_from_inbound(inbound, &keys::CHATGPT_ACCOUNT_ID)),
71      session_id: vars
72        .session_id
73        .clone()
74        .or_else(|| opt_from_inbound(inbound, &keys::X_SESSION_ID)),
75    }
76  }
77
78  /// Apply this overlay onto an outbound [`HeaderMap`]. `OpenAI-Beta` is
79  /// always overridden (the gateway requires it). Optional fields are filled
80  /// in only when the overlay has a value AND the header is not already
81  /// present on the map.
82  pub fn apply_to(&self, map: &mut HeaderMap, _vars: &TemplateVars) {
83    map.insert(&keys::OPENAI_BETA, self.openai_beta.to_string());
84    if let Some(v) = &self.openai_intent {
85      if !map.contains_key(&keys::OPENAI_INTENT) {
86        map.insert(&keys::OPENAI_INTENT, v.to_string());
87      }
88    }
89    if let Some(v) = &self.chatgpt_account_id {
90      if !map.contains_key(&keys::CHATGPT_ACCOUNT_ID) {
91        map.insert(&keys::CHATGPT_ACCOUNT_ID, v.to_string());
92      }
93    }
94    if let Some(v) = &self.session_id {
95      if !map.contains_key(&keys::X_SESSION_ID) {
96        map.insert(&keys::X_SESSION_ID, v.to_string());
97      }
98    }
99  }
100}
101
102#[cfg(test)]
103mod tests {
104  use super::*;
105
106  #[test]
107  fn round_trip() {
108    let h = CodexOverlay {
109      openai_beta: "responses=v1".into(),
110      openai_intent: Some("assistants".into()),
111      chatgpt_account_id: Some("acct_99".into()),
112      session_id: Some("ses_codex".into()),
113    };
114    assert_eq!(CodexOverlay::parse(&h.dump()).unwrap(), h);
115  }
116
117  #[test]
118  fn build_with_empty_inbound_uses_defaults() {
119    let h = CodexOverlay::build(&TemplateVars::default(), &HeaderMap::new());
120    assert_eq!(h.openai_beta.as_str(), "responses=v1");
121    assert!(h.openai_intent.is_none());
122    assert!(h.chatgpt_account_id.is_none());
123    assert!(h.session_id.is_none());
124  }
125
126  #[test]
127  fn build_passes_through_inbound() {
128    let mut inbound = HeaderMap::new();
129    inbound.insert(&keys::OPENAI_BETA, "responses=v2");
130    inbound.insert(&keys::OPENAI_INTENT, "assistants");
131    let h = CodexOverlay::build(&TemplateVars::default(), &inbound);
132    assert_eq!(h.openai_beta.as_str(), "responses=v2");
133    assert_eq!(h.openai_intent.as_deref(), Some("assistants"));
134  }
135
136  #[test]
137  fn build_uses_vars_for_correlation() {
138    let vars = TemplateVars {
139      session_id: Some("ses_xyz".into()),
140      account_id: Some("acct_abc".into()),
141      ..Default::default()
142    };
143    let h = CodexOverlay::build(&vars, &HeaderMap::new());
144    assert_eq!(h.session_id.as_deref(), Some("ses_xyz"));
145    assert_eq!(h.chatgpt_account_id.as_deref(), Some("acct_abc"));
146  }
147
148  #[test]
149  fn apply_to_overrides_managed_fields_and_skips_optionals_when_none() {
150    let mut map = HeaderMap::new();
151    map.insert(&keys::OPENAI_BETA, "stale=v0");
152    map.insert(&keys::X_SESSION_ID, "preexisting");
153
154    let overlay = CodexOverlay {
155      openai_beta: "responses=v1".into(),
156      openai_intent: None,
157      chatgpt_account_id: Some("acct_abc".into()),
158      session_id: Some("ses_xyz".into()),
159    };
160    overlay.apply_to(&mut map, &TemplateVars::default());
161
162    assert_eq!(map.get(&keys::OPENAI_BETA).unwrap().as_str(), "responses=v1");
163    // session_id was already present — overlay must not clobber it.
164    assert_eq!(map.get(&keys::X_SESSION_ID).unwrap().as_str(), "preexisting");
165    // chatgpt-account-id absent originally — overlay fills it in.
166    assert_eq!(map.get(&keys::CHATGPT_ACCOUNT_ID).unwrap().as_str(), "acct_abc");
167    // None-valued optional not inserted.
168    assert!(!map.contains_key(&keys::OPENAI_INTENT));
169  }
170}