codex_helper_core/
config_auth_sync.rs1use 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
11pub(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 pub add_missing: bool,
44 pub set_active: bool,
46 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#[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 if current_provider_id == "codex_proxy"
110 && !crate::codex_integration::codex_switch_state_exists()
111 {
112 let provider_table = providers_table.get(¤t_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 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 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 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 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(¤t_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}