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}