Skip to main content

microclaw_engine/
codex_auth.rs

1use base64::Engine;
2use serde::Deserialize;
3use std::path::{Path, PathBuf};
4
5use microclaw_core::error::MicroClawError;
6
7pub const OPENAI_CODEX_PROVIDER: &str = "openai-codex";
8pub const QWEN_PORTAL_PROVIDER: &str = "qwen-portal";
9
10#[derive(Debug, Deserialize)]
11struct CodexAuthFile {
12    tokens: Option<CodexAuthTokens>,
13    #[serde(rename = "OPENAI_API_KEY")]
14    openai_api_key: Option<String>,
15}
16
17#[derive(Debug, Deserialize)]
18struct CodexAuthTokens {
19    access_token: Option<String>,
20    #[serde(rename = "refresh_token")]
21    _refresh_token: Option<String>,
22    account_id: Option<String>,
23}
24
25#[derive(Debug, Deserialize)]
26struct QwenOauthCredsFile {
27    access_token: Option<String>,
28}
29
30#[derive(Debug, Clone)]
31pub struct CodexAuthResolved {
32    pub bearer_token: String,
33    pub account_id: Option<String>,
34}
35
36pub fn provider_allows_empty_api_key(provider: &str) -> bool {
37    provider.eq_ignore_ascii_case("ollama")
38        || provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER)
39        || provider.eq_ignore_ascii_case(QWEN_PORTAL_PROVIDER)
40}
41
42pub fn is_openai_codex_provider(provider: &str) -> bool {
43    provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER)
44}
45
46pub fn is_qwen_portal_provider(provider: &str) -> bool {
47    provider.eq_ignore_ascii_case(QWEN_PORTAL_PROVIDER)
48}
49
50pub fn default_codex_auth_path() -> PathBuf {
51    let base = std::env::var("CODEX_HOME")
52        .ok()
53        .filter(|v| !v.trim().is_empty())
54        .as_deref()
55        .map(expand_tilde)
56        .unwrap_or_else(|| expand_tilde("~/.codex"));
57    Path::new(&base).join("auth.json")
58}
59
60pub fn default_codex_config_path() -> PathBuf {
61    let base = std::env::var("CODEX_HOME")
62        .ok()
63        .filter(|v| !v.trim().is_empty())
64        .as_deref()
65        .map(expand_tilde)
66        .unwrap_or_else(|| expand_tilde("~/.codex"));
67    Path::new(&base).join("config.toml")
68}
69
70pub fn default_qwen_oauth_creds_path() -> PathBuf {
71    let base = std::env::var("QWEN_HOME")
72        .ok()
73        .filter(|v| !v.trim().is_empty())
74        .as_deref()
75        .map(expand_tilde)
76        .unwrap_or_else(|| expand_tilde("~/.qwen"));
77    Path::new(&base).join("oauth_creds.json")
78}
79
80pub fn codex_auth_file_has_access_token() -> Result<bool, MicroClawError> {
81    if let Ok(token) = std::env::var("OPENAI_CODEX_ACCESS_TOKEN") {
82        if !token.trim().is_empty() {
83            return Ok(true);
84        }
85    }
86
87    let path = default_codex_auth_path();
88    if !path.exists() {
89        return Ok(false);
90    }
91    let content = std::fs::read_to_string(&path).map_err(|e| {
92        MicroClawError::Config(format!(
93            "Failed to read Codex auth file {}: {e}",
94            path.display()
95        ))
96    })?;
97    let parsed: CodexAuthFile = serde_json::from_str(&content).map_err(|e| {
98        MicroClawError::Config(format!(
99            "Failed to parse Codex auth file {}: {e}",
100            path.display()
101        ))
102    })?;
103    let has_access_token = parsed
104        .tokens
105        .as_ref()
106        .and_then(|tokens| tokens.access_token.as_ref())
107        .map(|token| !token.trim().is_empty())
108        .unwrap_or(false);
109    let has_openai_api_key = parsed
110        .openai_api_key
111        .as_ref()
112        .map(|key| !key.trim().is_empty())
113        .unwrap_or(false);
114    Ok(has_access_token || has_openai_api_key)
115}
116
117pub fn resolve_openai_codex_auth(
118    _fallback_api_key: &str,
119) -> Result<CodexAuthResolved, MicroClawError> {
120    if let Ok(token) = std::env::var("OPENAI_CODEX_ACCESS_TOKEN") {
121        let trimmed = token.trim();
122        if !trimmed.is_empty() {
123            return Ok(CodexAuthResolved {
124                bearer_token: trimmed.to_string(),
125                account_id: None,
126            });
127        }
128    }
129
130    let auth_path = default_codex_auth_path();
131    if auth_path.exists() {
132        let content = std::fs::read_to_string(&auth_path).map_err(|e| {
133            MicroClawError::Config(format!(
134                "Failed to read Codex auth file {}: {e}",
135                auth_path.display()
136            ))
137        })?;
138        let parsed: CodexAuthFile = serde_json::from_str(&content).map_err(|e| {
139            MicroClawError::Config(format!(
140                "Failed to parse Codex auth file {}: {e}",
141                auth_path.display()
142            ))
143        })?;
144        if let Some(token) = parsed
145            .tokens
146            .as_ref()
147            .and_then(|tokens| tokens.access_token.as_ref())
148            .map(|token| token.trim())
149            .filter(|token| !token.is_empty())
150        {
151            return Ok(CodexAuthResolved {
152                bearer_token: token.to_string(),
153                account_id: parsed
154                    .tokens
155                    .as_ref()
156                    .and_then(|tokens| tokens.account_id.clone())
157                    .map(|id| id.trim().to_string())
158                    .filter(|id| !id.is_empty()),
159            });
160        }
161
162        if let Some(api_key) = parsed
163            .openai_api_key
164            .as_deref()
165            .map(str::trim)
166            .filter(|key| !key.is_empty())
167        {
168            return Ok(CodexAuthResolved {
169                bearer_token: api_key.to_string(),
170                account_id: None,
171            });
172        }
173    }
174
175    Err(MicroClawError::Config(format!(
176        "OpenAI Codex provider requires ~/.codex/auth.json (access token or OPENAI_API_KEY), or OPENAI_CODEX_ACCESS_TOKEN. Run `codex login` or update Codex config files (expected auth file: {}).",
177        auth_path.display()
178    )))
179}
180
181pub fn qwen_oauth_file_has_access_token() -> Result<bool, MicroClawError> {
182    if let Ok(token) = std::env::var("QWEN_PORTAL_ACCESS_TOKEN") {
183        if !token.trim().is_empty() {
184            return Ok(true);
185        }
186    }
187    if let Ok(token) = std::env::var("QWEN_CODE_ACCESS_TOKEN") {
188        if !token.trim().is_empty() {
189            return Ok(true);
190        }
191    }
192    let path = default_qwen_oauth_creds_path();
193    if !path.exists() {
194        return Ok(false);
195    }
196    let content = std::fs::read_to_string(&path).map_err(|e| {
197        MicroClawError::Config(format!(
198            "Failed to read Qwen oauth creds file {}: {e}",
199            path.display()
200        ))
201    })?;
202    let parsed: QwenOauthCredsFile = serde_json::from_str(&content).map_err(|e| {
203        MicroClawError::Config(format!(
204            "Failed to parse Qwen oauth creds file {}: {e}",
205            path.display()
206        ))
207    })?;
208    Ok(parsed
209        .access_token
210        .as_ref()
211        .map(|v| !v.trim().is_empty())
212        .unwrap_or(false))
213}
214
215pub fn resolve_qwen_portal_auth(
216    fallback_api_key: &str,
217) -> Result<CodexAuthResolved, MicroClawError> {
218    if let Ok(token) = std::env::var("QWEN_PORTAL_ACCESS_TOKEN") {
219        let trimmed = token.trim();
220        if !trimmed.is_empty() {
221            return Ok(CodexAuthResolved {
222                bearer_token: trimmed.to_string(),
223                account_id: None,
224            });
225        }
226    }
227    if let Ok(token) = std::env::var("QWEN_CODE_ACCESS_TOKEN") {
228        let trimmed = token.trim();
229        if !trimmed.is_empty() {
230            return Ok(CodexAuthResolved {
231                bearer_token: trimmed.to_string(),
232                account_id: None,
233            });
234        }
235    }
236    let path = default_qwen_oauth_creds_path();
237    if path.exists() {
238        let content = std::fs::read_to_string(&path).map_err(|e| {
239            MicroClawError::Config(format!(
240                "Failed to read Qwen oauth creds file {}: {e}",
241                path.display()
242            ))
243        })?;
244        let parsed: QwenOauthCredsFile = serde_json::from_str(&content).map_err(|e| {
245            MicroClawError::Config(format!(
246                "Failed to parse Qwen oauth creds file {}: {e}",
247                path.display()
248            ))
249        })?;
250        if let Some(token) = parsed
251            .access_token
252            .as_deref()
253            .map(str::trim)
254            .filter(|v| !v.is_empty())
255        {
256            return Ok(CodexAuthResolved {
257                bearer_token: token.to_string(),
258                account_id: None,
259            });
260        }
261    }
262    let fallback = fallback_api_key.trim();
263    if !fallback.is_empty() {
264        return Ok(CodexAuthResolved {
265            bearer_token: fallback.to_string(),
266            account_id: None,
267        });
268    }
269    Err(MicroClawError::Config(
270        "qwen-portal requires ~/.qwen/oauth_creds.json (access_token), or QWEN_PORTAL_ACCESS_TOKEN, or api_key.".into(),
271    ))
272}
273
274pub fn codex_config_default_openai_base_url() -> Option<String> {
275    let path = default_codex_config_path();
276    let content = std::fs::read_to_string(path).ok()?;
277    parse_codex_config_default_openai_base_url(&content)
278}
279
280fn parse_codex_config_default_openai_base_url(content: &str) -> Option<String> {
281    let mut model_provider: Option<String> = None;
282
283    for raw in content.lines() {
284        let line = raw.split('#').next().unwrap_or("").trim();
285        if line.is_empty() {
286            continue;
287        }
288        if line.starts_with("model_provider") {
289            model_provider = parse_toml_string_assignment(line, "model_provider");
290            if model_provider.is_some() {
291                break;
292            }
293        }
294    }
295
296    let provider = model_provider?;
297    let target = provider.trim();
298    if target.is_empty() {
299        return None;
300    }
301
302    let mut current_provider: Option<String> = None;
303    for raw in content.lines() {
304        let line = raw.split('#').next().unwrap_or("").trim();
305        if line.is_empty() {
306            continue;
307        }
308        if line.starts_with('[') && line.ends_with(']') {
309            let section = &line[1..line.len() - 1];
310            current_provider = section
311                .strip_prefix("model_providers.")
312                .map(|v| v.trim().trim_matches('"').to_string())
313                .filter(|v| !v.is_empty());
314            continue;
315        }
316
317        if current_provider.as_deref() == Some(target) {
318            if let Some(base_url) = parse_toml_string_assignment(line, "base_url") {
319                let normalized = base_url.trim().trim_end_matches('/').to_string();
320                if !normalized.is_empty() {
321                    return Some(normalized);
322                }
323            }
324        }
325    }
326
327    None
328}
329
330fn parse_toml_string_assignment(line: &str, key: &str) -> Option<String> {
331    let (lhs, rhs) = line.split_once('=')?;
332    if lhs.trim() != key {
333        return None;
334    }
335    let value = rhs.trim();
336    if !(value.starts_with('"') && value.ends_with('"') && value.len() >= 2) {
337        return None;
338    }
339    Some(value[1..value.len() - 1].to_string())
340}
341
342fn expand_tilde(input: &str) -> String {
343    if let Some(rest) = input.strip_prefix("~/") {
344        if let Ok(home) = std::env::var("HOME") {
345            return format!("{home}/{rest}");
346        }
347    }
348    if input == "~" {
349        if let Ok(home) = std::env::var("HOME") {
350            return home;
351        }
352    }
353    input.to_string()
354}
355
356#[derive(Debug, Deserialize)]
357struct CodexRefreshResponse {
358    access_token: String,
359    refresh_token: Option<String>,
360}
361
362pub fn refresh_openai_codex_auth_if_needed() -> Result<(), MicroClawError> {
363    let auth_path = default_codex_auth_path();
364    if !auth_path.exists() {
365        return Ok(());
366    }
367    let content = std::fs::read_to_string(&auth_path).map_err(|e| {
368        MicroClawError::Config(format!(
369            "Failed to read Codex auth file {}: {e}",
370            auth_path.display()
371        ))
372    })?;
373    let mut parsed: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
374        MicroClawError::Config(format!(
375            "Failed to parse Codex auth file {}: {e}",
376            auth_path.display()
377        ))
378    })?;
379
380    let tokens = parsed
381        .get("tokens")
382        .and_then(|t| t.as_object())
383        .cloned()
384        .unwrap_or_default();
385    let access = tokens
386        .get("access_token")
387        .and_then(|v| v.as_str())
388        .unwrap_or("")
389        .trim()
390        .to_string();
391    let refresh = tokens
392        .get("refresh_token")
393        .and_then(|v| v.as_str())
394        .unwrap_or("")
395        .trim()
396        .to_string();
397    if access.is_empty() || refresh.is_empty() {
398        return Ok(());
399    }
400    if !is_jwt_expired(&access) {
401        return Ok(());
402    }
403
404    let client = reqwest::blocking::Client::builder()
405        .timeout(std::time::Duration::from_secs(10))
406        .build()?;
407    let body = serde_json::json!({
408        "grant_type": "refresh_token",
409        "refresh_token": refresh,
410        "client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
411    });
412    let resp = client
413        .post("https://auth.openai.com/oauth/token")
414        .header("content-type", "application/json")
415        .body(body.to_string())
416        .send()?;
417    if !resp.status().is_success() {
418        return Ok(());
419    }
420    let parsed_resp: CodexRefreshResponse = resp.json().map_err(|e| {
421        MicroClawError::Config(format!(
422            "Failed to parse OpenAI Codex refresh response: {e}"
423        ))
424    })?;
425    if parsed_resp.access_token.trim().is_empty() {
426        return Ok(());
427    }
428
429    if let Some(tokens_obj) = parsed.get_mut("tokens").and_then(|t| t.as_object_mut()) {
430        tokens_obj.insert(
431            "access_token".to_string(),
432            serde_json::Value::String(parsed_resp.access_token),
433        );
434        if let Some(refresh_token) = parsed_resp.refresh_token {
435            if !refresh_token.trim().is_empty() {
436                tokens_obj.insert(
437                    "refresh_token".to_string(),
438                    serde_json::Value::String(refresh_token),
439                );
440            }
441        }
442    }
443    parsed["last_refresh"] = serde_json::Value::String(chrono::Utc::now().to_rfc3339());
444    std::fs::write(
445        &auth_path,
446        serde_json::to_string_pretty(&parsed).map_err(|e| {
447            MicroClawError::Config(format!("Failed to serialize refreshed Codex auth: {e}"))
448        })?,
449    )?;
450    Ok(())
451}
452
453fn is_jwt_expired(token: &str) -> bool {
454    let parts: Vec<&str> = token.split('.').collect();
455    if parts.len() < 2 {
456        return false;
457    }
458    let mut payload = parts[1].to_string();
459    while !payload.len().is_multiple_of(4) {
460        payload.push('=');
461    }
462    let decoded = base64::engine::general_purpose::URL_SAFE
463        .decode(payload.as_bytes())
464        .ok()
465        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
466    let exp = decoded
467        .as_ref()
468        .and_then(|v| v.get("exp"))
469        .and_then(|v| v.as_i64());
470    match exp {
471        Some(ts) => chrono::Utc::now().timestamp() >= ts,
472        None => false,
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
481        crate::test_support::env_lock()
482    }
483
484    #[test]
485    fn test_provider_allows_empty_api_key() {
486        assert!(provider_allows_empty_api_key("ollama"));
487        assert!(provider_allows_empty_api_key("openai-codex"));
488        assert!(provider_allows_empty_api_key("qwen-portal"));
489        assert!(!provider_allows_empty_api_key("openai"));
490    }
491
492    #[test]
493    fn test_is_openai_codex_provider() {
494        assert!(is_openai_codex_provider("openai-codex"));
495        assert!(is_openai_codex_provider("OPENAI-CODEX"));
496        assert!(!is_openai_codex_provider("openai"));
497    }
498
499    #[test]
500    fn test_is_qwen_portal_provider() {
501        assert!(is_qwen_portal_provider("qwen-portal"));
502        assert!(is_qwen_portal_provider("QWEN-PORTAL"));
503        assert!(!is_qwen_portal_provider("qwen"));
504    }
505
506    #[test]
507    fn test_codex_auth_file_has_access_token_accepts_env_var() {
508        let _guard = env_lock();
509        let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
510        std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", "env-token");
511
512        let has = codex_auth_file_has_access_token().unwrap();
513
514        if let Some(prev) = prev_access {
515            std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
516        } else {
517            std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
518        }
519        assert!(has);
520    }
521
522    #[test]
523    fn test_codex_auth_file_has_access_token_accepts_openai_api_key_only() {
524        let _guard = env_lock();
525        let prev_codex_home = std::env::var("CODEX_HOME").ok();
526        let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
527        std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
528
529        let auth_dir = std::env::temp_dir().join(format!(
530            "microclaw-codex-auth-openai-key-only-{}",
531            std::time::SystemTime::now()
532                .duration_since(std::time::UNIX_EPOCH)
533                .unwrap_or_default()
534                .as_nanos()
535        ));
536        std::fs::create_dir_all(&auth_dir).unwrap();
537        std::fs::write(
538            auth_dir.join("auth.json"),
539            r#"{"OPENAI_API_KEY":"sk-user-stale"}"#,
540        )
541        .unwrap();
542        std::env::set_var("CODEX_HOME", &auth_dir);
543
544        let has = codex_auth_file_has_access_token().unwrap();
545
546        if let Some(prev) = prev_codex_home {
547            std::env::set_var("CODEX_HOME", prev);
548        } else {
549            std::env::remove_var("CODEX_HOME");
550        }
551        if let Some(prev) = prev_access {
552            std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
553        } else {
554            std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
555        }
556        let _ = std::fs::remove_file(auth_dir.join("auth.json"));
557        let _ = std::fs::remove_dir(auth_dir);
558
559        assert!(has);
560    }
561
562    #[test]
563    fn test_resolve_openai_codex_auth_accepts_openai_api_key_only() {
564        let _guard = env_lock();
565        let prev_codex_home = std::env::var("CODEX_HOME").ok();
566        let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
567        std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
568        std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
569
570        let auth_dir = std::env::temp_dir().join(format!(
571            "microclaw-codex-auth-openai-key-reject-{}",
572            std::time::SystemTime::now()
573                .duration_since(std::time::UNIX_EPOCH)
574                .unwrap_or_default()
575                .as_nanos()
576        ));
577        std::fs::create_dir_all(&auth_dir).unwrap();
578        std::fs::write(
579            auth_dir.join("auth.json"),
580            r#"{"OPENAI_API_KEY":"sk-user-stale"}"#,
581        )
582        .unwrap();
583        std::env::set_var("CODEX_HOME", &auth_dir);
584
585        let auth = resolve_openai_codex_auth("").unwrap();
586
587        if let Some(prev) = prev_codex_home {
588            std::env::set_var("CODEX_HOME", prev);
589        } else {
590            std::env::remove_var("CODEX_HOME");
591        }
592        if let Some(prev) = prev_access {
593            std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
594        } else {
595            std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
596        }
597        let _ = std::fs::remove_file(auth_dir.join("auth.json"));
598        let _ = std::fs::remove_dir(auth_dir);
599
600        assert_eq!(auth.bearer_token, "sk-user-stale");
601        assert!(auth.account_id.is_none());
602    }
603
604    #[test]
605    fn test_parse_codex_config_default_openai_base_url() {
606        let content = r#"
607model_provider = "tabcode"
608
609[model_providers.tabcode]
610name = "openai"
611base_url = "https://api.tabcode.cc/openai"
612wire_api = "responses"
613"#;
614        let base = parse_codex_config_default_openai_base_url(content).unwrap();
615        assert_eq!(base, "https://api.tabcode.cc/openai");
616    }
617
618    #[test]
619    fn test_qwen_oauth_file_has_access_token() {
620        let _guard = env_lock();
621        let prev_qwen_home = std::env::var("QWEN_HOME").ok();
622        let prev_qwen_access = std::env::var("QWEN_PORTAL_ACCESS_TOKEN").ok();
623        std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
624        let qwen_dir = std::env::temp_dir().join(format!(
625            "microclaw-qwen-oauth-creds-{}",
626            std::time::SystemTime::now()
627                .duration_since(std::time::UNIX_EPOCH)
628                .unwrap_or_default()
629                .as_nanos()
630        ));
631        std::fs::create_dir_all(&qwen_dir).unwrap();
632        std::fs::write(
633            qwen_dir.join("oauth_creds.json"),
634            r#"{"access_token":"qwen-oauth-token"}"#,
635        )
636        .unwrap();
637        std::env::set_var("QWEN_HOME", &qwen_dir);
638
639        let has = qwen_oauth_file_has_access_token().unwrap();
640
641        if let Some(prev) = prev_qwen_home {
642            std::env::set_var("QWEN_HOME", prev);
643        } else {
644            std::env::remove_var("QWEN_HOME");
645        }
646        if let Some(prev) = prev_qwen_access {
647            std::env::set_var("QWEN_PORTAL_ACCESS_TOKEN", prev);
648        } else {
649            std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
650        }
651        let _ = std::fs::remove_file(qwen_dir.join("oauth_creds.json"));
652        let _ = std::fs::remove_dir(qwen_dir);
653
654        assert!(has);
655    }
656
657    #[test]
658    fn test_resolve_qwen_portal_auth_reads_oauth_creds_file() {
659        let _guard = env_lock();
660        let prev_qwen_home = std::env::var("QWEN_HOME").ok();
661        let prev_qwen_access = std::env::var("QWEN_PORTAL_ACCESS_TOKEN").ok();
662        std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
663
664        let qwen_dir = std::env::temp_dir().join(format!(
665            "microclaw-qwen-oauth-resolve-{}",
666            std::time::SystemTime::now()
667                .duration_since(std::time::UNIX_EPOCH)
668                .unwrap_or_default()
669                .as_nanos()
670        ));
671        std::fs::create_dir_all(&qwen_dir).unwrap();
672        std::fs::write(
673            qwen_dir.join("oauth_creds.json"),
674            r#"{"access_token":"qwen-oauth-token"}"#,
675        )
676        .unwrap();
677        std::env::set_var("QWEN_HOME", &qwen_dir);
678
679        let auth = resolve_qwen_portal_auth("").unwrap();
680
681        if let Some(prev) = prev_qwen_home {
682            std::env::set_var("QWEN_HOME", prev);
683        } else {
684            std::env::remove_var("QWEN_HOME");
685        }
686        if let Some(prev) = prev_qwen_access {
687            std::env::set_var("QWEN_PORTAL_ACCESS_TOKEN", prev);
688        } else {
689            std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
690        }
691        let _ = std::fs::remove_file(qwen_dir.join("oauth_creds.json"));
692        let _ = std::fs::remove_dir(qwen_dir);
693
694        assert_eq!(auth.bearer_token, "qwen-oauth-token");
695        assert!(auth.account_id.is_none());
696    }
697}