Skip to main content

codex_helper_core/
codex_patch_plan.rs

1use anyhow::{Result, anyhow};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
5#[serde(rename_all = "kebab-case")]
6pub enum CodexPatchMode {
7    /// Keep the historical codex-helper patch behavior.
8    #[default]
9    Default,
10    /// Keep Codex/ChatGPT account auth for app/mobile features while model traffic goes through
11    /// codex-helper.
12    ChatGptBridge,
13    /// Use a minimal ChatGPT-looking auth facade to expose Codex hosted image generation while
14    /// request credentials still come from codex-helper routing/upstream configuration.
15    ImagegenBridge,
16    /// Advertise the local relay as the official OpenAI Responses provider so Codex can use
17    /// first-party HTTP features that helper can safely forward, starting with remote compaction
18    /// v1. Request credentials still come from codex-helper routing/upstream configuration.
19    #[serde(
20        rename = "official-relay",
21        alias = "official_relay",
22        alias = "official-relay-bridge",
23        alias = "official_relay_bridge"
24    )]
25    OfficialRelayBridge,
26    /// Combine official relay provider identity for remote compaction with the image generation
27    /// ChatGPT auth facade. Request credentials still come from codex-helper routing/upstream
28    /// configuration.
29    #[serde(
30        rename = "official-imagegen",
31        alias = "official_imagegen",
32        alias = "official-imagegen-bridge",
33        alias = "official_imagegen_bridge"
34    )]
35    OfficialImagegenBridge,
36}
37
38impl CodexPatchMode {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Default => "default",
42            Self::ChatGptBridge => "chatgpt-bridge",
43            Self::ImagegenBridge => "imagegen-bridge",
44            Self::OfficialRelayBridge => "official-relay",
45            Self::OfficialImagegenBridge => "official-imagegen",
46        }
47    }
48
49    pub fn as_preset_str(self) -> &'static str {
50        match self {
51            Self::Default => "default",
52            Self::ChatGptBridge => "chatgpt-bridge",
53            Self::ImagegenBridge => "imagegen-bridge",
54            Self::OfficialRelayBridge => "official-relay",
55            Self::OfficialImagegenBridge => "official-imagegen",
56        }
57    }
58
59    pub fn is_default(self) -> bool {
60        matches!(self, Self::Default)
61    }
62
63    pub fn strips_codex_client_auth(self) -> bool {
64        matches!(
65            self,
66            Self::ChatGptBridge
67                | Self::ImagegenBridge
68                | Self::OfficialRelayBridge
69                | Self::OfficialImagegenBridge
70        )
71    }
72
73    pub fn enables_official_relay_features(self) -> bool {
74        matches!(
75            self,
76            Self::OfficialRelayBridge | Self::OfficialImagegenBridge
77        )
78    }
79
80    pub fn enables_imagegen_facade(self) -> bool {
81        matches!(self, Self::ImagegenBridge | Self::OfficialImagegenBridge)
82    }
83}
84
85impl std::fmt::Display for CodexPatchMode {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str(self.as_str())
88    }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
92#[serde(rename_all = "kebab-case")]
93pub enum CodexCompactionStrategy {
94    /// Keep the preset-derived behavior and do not rewrite Codex's existing
95    /// `remote_compaction_v2` feature flag.
96    #[default]
97    Auto,
98    /// Force Codex to see a helper-shaped provider so the client chooses local compaction.
99    Local,
100    /// Force remote compaction v1 through `/responses/compact`.
101    #[serde(alias = "remote_v1")]
102    RemoteV1,
103    /// Force remote compaction v2 through `/responses` with `compaction_trigger`.
104    #[serde(alias = "remote_v2")]
105    RemoteV2,
106}
107
108impl CodexCompactionStrategy {
109    pub fn as_str(self) -> &'static str {
110        match self {
111            Self::Auto => "auto",
112            Self::Local => "local",
113            Self::RemoteV1 => "remote-v1",
114            Self::RemoteV2 => "remote-v2",
115        }
116    }
117
118    pub fn is_auto(&self) -> bool {
119        matches!(self, Self::Auto)
120    }
121
122    pub fn provider_identity_for_mode(self, mode: CodexPatchMode) -> CodexPatchProviderIdentity {
123        match self {
124            Self::Auto => {
125                if mode.enables_official_relay_features() {
126                    CodexPatchProviderIdentity::OfficialOpenAi
127                } else {
128                    CodexPatchProviderIdentity::HelperRelay
129                }
130            }
131            Self::Local => CodexPatchProviderIdentity::HelperRelay,
132            Self::RemoteV1 | Self::RemoteV2 => CodexPatchProviderIdentity::OfficialOpenAi,
133        }
134    }
135
136    pub fn remote_compaction_v2_feature_patch(self) -> CodexFeatureBoolPatch {
137        match self {
138            Self::Auto => CodexFeatureBoolPatch::Preserve,
139            Self::Local | Self::RemoteV1 => CodexFeatureBoolPatch::Set(false),
140            Self::RemoteV2 => CodexFeatureBoolPatch::Set(true),
141        }
142    }
143}
144
145impl std::fmt::Display for CodexCompactionStrategy {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.write_str(self.as_str())
148    }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
152#[serde(rename_all = "kebab-case")]
153pub enum CodexHostedImageGenerationMode {
154    /// Keep Codex's own feature flag and request tools unchanged.
155    #[default]
156    Auto,
157    /// Explicitly enable Codex hosted image generation in the client config.
158    Enabled,
159    /// Hide Codex hosted image generation from the client config and outgoing Responses requests.
160    Disabled,
161}
162
163impl CodexHostedImageGenerationMode {
164    pub fn as_str(self) -> &'static str {
165        match self {
166            Self::Auto => "auto",
167            Self::Enabled => "enabled",
168            Self::Disabled => "disabled",
169        }
170    }
171
172    pub fn is_auto(&self) -> bool {
173        matches!(self, Self::Auto)
174    }
175
176    pub fn feature_patch(self) -> CodexFeatureBoolPatch {
177        match self {
178            Self::Auto => CodexFeatureBoolPatch::Preserve,
179            Self::Enabled => CodexFeatureBoolPatch::Set(true),
180            Self::Disabled => CodexFeatureBoolPatch::Set(false),
181        }
182    }
183
184    pub fn filters_request_tools(self) -> bool {
185        matches!(self, Self::Disabled)
186    }
187}
188
189impl std::fmt::Display for CodexHostedImageGenerationMode {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        f.write_str(self.as_str())
192    }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
196pub struct CodexSwitchOptions {
197    /// Advertise `model_providers.codex_proxy.supports_websockets = true` so Codex may choose
198    /// Responses WebSocket transport. This is intentionally separate from `CodexPatchMode` to
199    /// avoid mode-combination explosion.
200    #[serde(default, skip_serializing_if = "bool_is_false")]
201    pub responses_websocket: bool,
202    /// Choose Codex's compaction path without adding more client presets.
203    #[serde(default, skip_serializing_if = "CodexCompactionStrategy::is_auto")]
204    pub compaction: CodexCompactionStrategy,
205}
206
207impl CodexSwitchOptions {
208    pub fn validate_for_mode(self, mode: CodexPatchMode) -> Result<()> {
209        if matches!(
210            self.compaction,
211            CodexCompactionStrategy::RemoteV1 | CodexCompactionStrategy::RemoteV2
212        ) && !mode.enables_official_relay_features()
213        {
214            return Err(anyhow!(
215                "remote compaction strategies require --preset official-relay or --preset official-imagegen"
216            ));
217        }
218
219        let provider_identity = self.compaction.provider_identity_for_mode(mode);
220        if self.responses_websocket
221            && provider_identity != CodexPatchProviderIdentity::OfficialOpenAi
222        {
223            return Err(anyhow!(
224                "Responses WebSocket transport requires remote compaction identity; use --preset official-relay or --preset official-imagegen without --compaction local"
225            ));
226        }
227        Ok(())
228    }
229}
230
231fn bool_is_false(value: &bool) -> bool {
232    !*value
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum CodexPatchProviderIdentity {
237    HelperRelay,
238    OfficialOpenAi,
239}
240
241impl CodexPatchProviderIdentity {
242    pub fn provider_name(self) -> &'static str {
243        match self {
244            Self::HelperRelay => "codex-helper",
245            Self::OfficialOpenAi => "OpenAI",
246        }
247    }
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum CodexTomlBoolPatch {
252    Remove,
253    Set(bool),
254}
255
256impl CodexTomlBoolPatch {
257    pub fn value(self) -> Option<bool> {
258        match self {
259            Self::Remove => None,
260            Self::Set(value) => Some(value),
261        }
262    }
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum CodexFeatureBoolPatch {
267    Preserve,
268    Set(bool),
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub struct CodexProviderPatchPlan {
273    identity: CodexPatchProviderIdentity,
274    requires_openai_auth: CodexTomlBoolPatch,
275    supports_websockets: CodexTomlBoolPatch,
276}
277
278impl CodexProviderPatchPlan {
279    pub fn identity(self) -> CodexPatchProviderIdentity {
280        self.identity
281    }
282
283    pub fn provider_name(self) -> &'static str {
284        self.identity.provider_name()
285    }
286
287    pub fn requires_openai_auth(self) -> CodexTomlBoolPatch {
288        self.requires_openai_auth
289    }
290
291    pub fn supports_websockets(self) -> CodexTomlBoolPatch {
292        self.supports_websockets
293    }
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum CodexAuthPatchPlan {
298    RestoreOriginalIfHelperPatched,
299    PatchChatGptBridge,
300    PatchImagegenFacade,
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum CodexSwitchOnEffectOrder {
305    ConfigAuthState,
306    StateConfigAuth,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct CodexPatchPlan {
311    mode: CodexPatchMode,
312    options: CodexSwitchOptions,
313    provider: CodexProviderPatchPlan,
314    remote_compaction_v2: CodexFeatureBoolPatch,
315    auth: CodexAuthPatchPlan,
316    effect_order: CodexSwitchOnEffectOrder,
317}
318
319impl CodexPatchPlan {
320    pub fn for_switch_on(mode: CodexPatchMode, options: CodexSwitchOptions) -> Result<Self> {
321        options.validate_for_mode(mode)?;
322
323        let provider = CodexProviderPatchPlan {
324            identity: options.compaction.provider_identity_for_mode(mode),
325            requires_openai_auth: match mode {
326                CodexPatchMode::ChatGptBridge => CodexTomlBoolPatch::Set(true),
327                CodexPatchMode::Default
328                | CodexPatchMode::ImagegenBridge
329                | CodexPatchMode::OfficialRelayBridge
330                | CodexPatchMode::OfficialImagegenBridge => CodexTomlBoolPatch::Remove,
331            },
332            supports_websockets: match mode {
333                CodexPatchMode::Default | CodexPatchMode::ImagegenBridge => {
334                    CodexTomlBoolPatch::Remove
335                }
336                CodexPatchMode::ChatGptBridge => CodexTomlBoolPatch::Set(false),
337                CodexPatchMode::OfficialRelayBridge | CodexPatchMode::OfficialImagegenBridge => {
338                    CodexTomlBoolPatch::Set(options.responses_websocket)
339                }
340            },
341        };
342
343        let auth = match mode {
344            CodexPatchMode::Default | CodexPatchMode::OfficialRelayBridge => {
345                CodexAuthPatchPlan::RestoreOriginalIfHelperPatched
346            }
347            CodexPatchMode::ChatGptBridge => CodexAuthPatchPlan::PatchChatGptBridge,
348            CodexPatchMode::ImagegenBridge | CodexPatchMode::OfficialImagegenBridge => {
349                CodexAuthPatchPlan::PatchImagegenFacade
350            }
351        };
352
353        let effect_order = match auth {
354            CodexAuthPatchPlan::RestoreOriginalIfHelperPatched => {
355                CodexSwitchOnEffectOrder::ConfigAuthState
356            }
357            CodexAuthPatchPlan::PatchChatGptBridge | CodexAuthPatchPlan::PatchImagegenFacade => {
358                CodexSwitchOnEffectOrder::StateConfigAuth
359            }
360        };
361
362        Ok(Self {
363            mode,
364            options,
365            provider,
366            remote_compaction_v2: options.compaction.remote_compaction_v2_feature_patch(),
367            auth,
368            effect_order,
369        })
370    }
371
372    pub fn mode(self) -> CodexPatchMode {
373        self.mode
374    }
375
376    pub fn options(self) -> CodexSwitchOptions {
377        self.options
378    }
379
380    pub fn provider(self) -> CodexProviderPatchPlan {
381        self.provider
382    }
383
384    pub fn remote_compaction_v2_feature(self) -> CodexFeatureBoolPatch {
385        self.remote_compaction_v2
386    }
387
388    pub fn auth(self) -> CodexAuthPatchPlan {
389        self.auth
390    }
391
392    pub fn effect_order(self) -> CodexSwitchOnEffectOrder {
393        self.effect_order
394    }
395
396    pub fn requires_bridge_runtime_ready(self) -> bool {
397        self.mode.strips_codex_client_auth() && self.mode != CodexPatchMode::ChatGptBridge
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn codex_patch_mode_serializes_current_official_preset_names() {
407        assert_eq!(
408            serde_json::to_string(&CodexPatchMode::OfficialRelayBridge).unwrap(),
409            "\"official-relay\""
410        );
411        assert_eq!(
412            serde_json::to_string(&CodexPatchMode::OfficialImagegenBridge).unwrap(),
413            "\"official-imagegen\""
414        );
415    }
416
417    #[test]
418    fn codex_patch_mode_reads_legacy_official_bridge_state() {
419        assert_eq!(
420            serde_json::from_str::<CodexPatchMode>("\"official-relay-bridge\"").unwrap(),
421            CodexPatchMode::OfficialRelayBridge
422        );
423        assert_eq!(
424            serde_json::from_str::<CodexPatchMode>("\"official-imagegen-bridge\"").unwrap(),
425            CodexPatchMode::OfficialImagegenBridge
426        );
427    }
428}