Skip to main content

codex_helper_core/
config_auth_sync.rs

1use super::*;
2
3pub(crate) fn read_file_if_exists(path: &Path) -> Result<Option<String>> {
4    if !path.exists() {
5        return Ok(None);
6    }
7    let s = stdfs::read_to_string(path).with_context(|| format!("failed to read {:?}", path))?;
8    Ok(Some(s))
9}
10
11/// Try to infer a unique API key from ~/.codex/auth.json when the provider
12/// does not declare an explicit `env_key`.
13///
14/// This mirrors the common Codex CLI layout where `auth.json` contains a
15/// single `*_API_KEY` field (e.g. `OPENAI_API_KEY`) plus metadata fields
16/// like `tokens` / `last_refresh`. We only consider string values whose
17/// key ends with `_API_KEY`, and only succeed when there is exactly one
18/// such candidate; otherwise we return None and let the caller error out.
19pub(crate) fn infer_env_key_from_auth_json(
20    auth_json: &Option<JsonValue>,
21) -> Option<(String, String)> {
22    let json = auth_json.as_ref()?;
23    let obj = json.as_object()?;
24
25    let mut candidates: Vec<(String, String)> = obj
26        .iter()
27        .filter_map(|(k, v)| v.as_str().map(|s| (k, s)))
28        .filter(|(k, v)| k.ends_with("_API_KEY") && !v.trim().is_empty())
29        .map(|(k, v)| (k.to_string(), v.to_string()))
30        .collect();
31
32    if candidates.len() == 1 {
33        candidates.pop()
34    } else {
35        None
36    }
37}
38
39#[allow(dead_code)]
40#[derive(Debug, Clone, Copy)]
41pub struct SyncCodexAuthFromCodexOptions {
42    /// Add missing providers found in ~/.codex/config.toml into ~/.codex-helper/config.
43    pub add_missing: bool,
44    /// Also set codex-helper active station to match Codex CLI's current model_provider.
45    pub set_active: bool,
46    /// Override existing inline secrets and non-codex-source upstreams (use with care).
47    pub force: bool,
48}
49
50#[allow(dead_code)]
51#[derive(Debug, Default)]
52pub struct SyncCodexAuthFromCodexReport {
53    pub updated: usize,
54    pub added: usize,
55    pub active_set: bool,
56    pub warnings: Vec<String>,
57}
58
59/// Sync Codex auth env vars from ~/.codex/config.toml + auth.json without changing routing config.
60///
61/// Default behavior:
62/// - Only updates upstreams that are strongly associated with a Codex CLI provider:
63///   - config key equals provider_id; or
64///   - upstream.tags.provider_id equals provider_id.
65/// - Does NOT change `active` / `enabled` / `level` unless `options.set_active = true`.
66/// - Does NOT write secrets to disk; only syncs env var names (e.g. `OPENAI_API_KEY`).
67#[allow(dead_code)]
68pub fn sync_codex_auth_from_codex_cli(
69    cfg: &mut ProxyConfig,
70    options: SyncCodexAuthFromCodexOptions,
71) -> Result<SyncCodexAuthFromCodexReport> {
72    fn is_non_empty(s: &Option<String>) -> bool {
73        s.as_deref().is_some_and(|v| !v.trim().is_empty())
74    }
75
76    let cfg_text_opt = crate::codex_integration::codex_config_text_for_import()?;
77    let cfg_text = match cfg_text_opt {
78        Some(s) if !s.trim().is_empty() => s,
79        _ => anyhow::bail!("未找到 ~/.codex/config.toml 或文件为空,无法同步 Codex 账号信息"),
80    };
81
82    let value: TomlValue = cfg_text.parse()?;
83    let table = value
84        .as_table()
85        .cloned()
86        .ok_or_else(|| anyhow::anyhow!("Codex config root must be table"))?;
87
88    let current_provider_id = table
89        .get("model_provider")
90        .and_then(|v| v.as_str())
91        .unwrap_or("openai")
92        .to_string();
93
94    let providers_table = table
95        .get("model_providers")
96        .and_then(|v| v.as_table())
97        .cloned()
98        .unwrap_or_default();
99
100    let auth_json_path = codex_auth_path();
101    let auth_json: Option<JsonValue> = match read_file_if_exists(&auth_json_path)? {
102        Some(s) if !s.trim().is_empty() => serde_json::from_str(&s).ok(),
103        _ => None,
104    };
105    let inferred_env_key = infer_env_key_from_auth_json(&auth_json).map(|(k, _)| k);
106
107    // Avoid syncing from a self-forwarding Codex config unless we have switch state to recover
108    // the original provider view.
109    if current_provider_id == "codex_proxy"
110        && !crate::codex_integration::codex_switch_state_exists()
111    {
112        let provider_table = providers_table.get(&current_provider_id);
113        let is_local_helper = provider_table
114            .and_then(|t| t.get("base_url"))
115            .and_then(|v| v.as_str())
116            .map(|u| u.contains("127.0.0.1") || u.contains("localhost"))
117            .unwrap_or(false);
118        if is_local_helper {
119            anyhow::bail!(
120                "检测到 ~/.codex/config.toml 的当前 model_provider 指向本地代理 codex-helper,且未找到 codex-helper switch state;\
121无法安全同步账号信息。请先手动检查 ~/.codex/config.toml 后重试。"
122            );
123        }
124    }
125
126    #[derive(Debug, Clone)]
127    struct ProviderSpec {
128        provider_id: String,
129        requires_openai_auth: bool,
130        base_url: Option<String>,
131        env_key: Option<String>,
132        alias: Option<String>,
133    }
134
135    let mut providers = Vec::new();
136    for (provider_id, provider_val) in providers_table.iter() {
137        let Some(provider_table) = provider_val.as_table() else {
138            continue;
139        };
140
141        let requires_openai_auth = provider_table
142            .get("requires_openai_auth")
143            .and_then(|v| v.as_bool())
144            .unwrap_or(provider_id == "openai");
145
146        let base_url = provider_table
147            .get("base_url")
148            .and_then(|v| v.as_str())
149            .map(|s| s.to_string())
150            .or_else(|| {
151                if provider_id == "openai" {
152                    Some("https://api.openai.com/v1".to_string())
153                } else {
154                    None
155                }
156            });
157
158        // Skip local codex-helper proxy entry to avoid accidental loops.
159        if provider_id == "codex_proxy"
160            && base_url
161                .as_deref()
162                .is_some_and(|u| u.contains("127.0.0.1") || u.contains("localhost"))
163        {
164            continue;
165        }
166
167        let env_key = provider_table
168            .get("env_key")
169            .and_then(|v| v.as_str())
170            .map(|s| s.to_string())
171            .filter(|s| !s.trim().is_empty())
172            .or_else(|| inferred_env_key.clone());
173
174        let alias = provider_table
175            .get("name")
176            .and_then(|v| v.as_str())
177            .map(|s| s.to_string())
178            .filter(|s| !s.trim().is_empty())
179            .filter(|s| s != provider_id);
180
181        providers.push(ProviderSpec {
182            provider_id: provider_id.to_string(),
183            requires_openai_auth,
184            base_url,
185            env_key,
186            alias,
187        });
188    }
189
190    let mut report = SyncCodexAuthFromCodexReport::default();
191
192    for pvd in providers.iter() {
193        let pid = pvd.provider_id.as_str();
194
195        // Target configs:
196        // 1) config key equals provider_id; 2) any upstream tagged with provider_id.
197        let mut target_cfg_keys = Vec::new();
198        if cfg.codex.contains_station(pid) {
199            target_cfg_keys.push(pid.to_string());
200        }
201
202        for (cfg_key, svc) in cfg.codex.stations() {
203            if svc
204                .upstreams
205                .iter()
206                .any(|u| u.tags.get("provider_id").map(|s| s.as_str()) == Some(pid))
207                && !target_cfg_keys.iter().any(|k| k == cfg_key)
208            {
209                target_cfg_keys.push(cfg_key.clone());
210            }
211        }
212
213        if target_cfg_keys.is_empty() {
214            if options.add_missing {
215                let Some(base_url) = pvd.base_url.as_deref().filter(|s| !s.trim().is_empty())
216                else {
217                    report.warnings.push(format!(
218                        "skip add provider '{pid}': base_url is missing in ~/.codex/config.toml"
219                    ));
220                    continue;
221                };
222
223                let mut tags = HashMap::new();
224                tags.insert("source".into(), "codex-config".into());
225                tags.insert("provider_id".into(), pid.to_string());
226                tags.insert(
227                    "requires_openai_auth".into(),
228                    pvd.requires_openai_auth.to_string(),
229                );
230
231                let mut upstream = UpstreamConfig {
232                    base_url: base_url.to_string(),
233                    auth: UpstreamAuth::default(),
234                    tags,
235                    supported_models: HashMap::new(),
236                    model_mapping: HashMap::new(),
237                };
238                if !pvd.requires_openai_auth {
239                    if let Some(env_key) = pvd.env_key.as_deref().filter(|s| !s.trim().is_empty()) {
240                        upstream.auth.auth_token_env = Some(env_key.to_string());
241                    } else {
242                        report.warnings.push(format!(
243                            "added provider '{pid}' but auth env_key is missing (no env_key and auth.json can't infer a unique *_API_KEY)"
244                        ));
245                    }
246                }
247
248                let service = ServiceConfig {
249                    name: pid.to_string(),
250                    alias: pvd.alias.clone(),
251                    enabled: true,
252                    level: 1,
253                    upstreams: vec![upstream],
254                };
255
256                cfg.codex.stations_mut().insert(pid.to_string(), service);
257                report.added += 1;
258            }
259            continue;
260        }
261
262        // No secrets needed for providers that rely on the client Authorization.
263        if pvd.requires_openai_auth {
264            continue;
265        }
266
267        let Some(desired_env) = pvd.env_key.as_deref().filter(|s| !s.trim().is_empty()) else {
268            report.warnings.push(format!(
269                "skip provider '{pid}': env_key is missing and auth.json can't infer a unique *_API_KEY"
270            ));
271            continue;
272        };
273
274        for cfg_key in target_cfg_keys {
275            let Some(service) = cfg.codex.station_mut(&cfg_key) else {
276                continue;
277            };
278
279            let single_upstream = service.upstreams.len() == 1;
280            let mut updated_in_this_config = false;
281            for upstream in service.upstreams.iter_mut() {
282                let tag_pid = upstream.tags.get("provider_id").map(|s| s.as_str());
283                let should_touch = if tag_pid == Some(pid) {
284                    true
285                } else if cfg_key == pid {
286                    // Strong signal: config key matches provider id.
287                    // Touch upstreams that look like Codex-imported entries or single-upstream configs.
288                    let src = upstream.tags.get("source").map(|s| s.as_str());
289                    src == Some("codex-config") || single_upstream
290                } else {
291                    false
292                };
293
294                if !should_touch && !options.force {
295                    continue;
296                }
297
298                if !options.force
299                    && (is_non_empty(&upstream.auth.auth_token)
300                        || is_non_empty(&upstream.auth.api_key))
301                {
302                    report.warnings.push(format!(
303                        "skip '{cfg_key}': upstream has inline secret; use --force to override"
304                    ));
305                    continue;
306                }
307
308                if upstream.auth.auth_token_env.as_deref() != Some(desired_env) {
309                    upstream.auth.auth_token_env = Some(desired_env.to_string());
310                    if options.force {
311                        upstream.auth.auth_token = None;
312                        upstream.auth.api_key = None;
313                    }
314                    report.updated += 1;
315                    updated_in_this_config = true;
316                }
317            }
318
319            if !updated_in_this_config && cfg_key == pid {
320                report.warnings.push(format!(
321                    "no upstream updated for provider '{pid}' in config '{cfg_key}' (no matching upstream tags)"
322                ));
323            }
324        }
325    }
326
327    if options.set_active
328        && current_provider_id != "codex_proxy"
329        && cfg.codex.contains_station(&current_provider_id)
330        && cfg.codex.active.as_deref() != Some(current_provider_id.as_str())
331    {
332        cfg.codex.active = Some(current_provider_id);
333        report.active_set = true;
334    }
335
336    Ok(report)
337}