1use std::fmt::Write as _;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result, bail};
5use base64::Engine;
6use directories::ProjectDirs;
7use serde::{Deserialize, Serialize};
8
9pub const OPENROUTER_ENV_KEY: &str = "OPENROUTER_API_KEY";
10pub const OPENAI_ENV_KEY: &str = "OPENAI_API_KEY";
11pub const OPENCODE_ENV_KEY: &str = "OPENCODE_API_KEY";
12
13const DEFAULT_SYSTEM_PROMPT: &str = include_str!("../assets/system-prompt-base.md");
14
15#[derive(Debug, Default, Deserialize)]
16struct Config {
17 #[serde(default)]
18 provider: Provider,
19 #[serde(default)]
20 host: Option<HostSettings>,
21}
22
23#[derive(Debug, Default, Deserialize)]
24struct Provider {
25 #[serde(default)]
26 openrouter_key: String,
27 #[serde(default)]
28 openai_key: String,
29 #[serde(default)]
30 opencode_key: String,
31 #[serde(default)]
32 host_token: Option<String>,
33 #[serde(default)]
34 openai_codex: Option<CodexCredentials>,
35}
36
37#[derive(Debug, Clone, Deserialize, Serialize)]
38pub struct CodexCredentials {
39 pub access: String,
40 pub refresh: String,
41 pub expires: i64,
42 pub account_id: String,
43}
44
45#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
47pub struct NamedTunnelConfig {
48 pub tunnel_id: String,
49 pub hostname: String,
50 pub credentials_path: PathBuf,
51 pub config_path: PathBuf,
52}
53
54#[derive(Debug, Default, Deserialize)]
55struct HostSettings {
56 #[serde(default)]
57 named_tunnel: Option<NamedTunnelConfig>,
58}
59
60#[derive(Debug, Clone, Default)]
63pub struct SavedCreds {
64 pub openrouter_key: Option<String>,
65 pub openai_key: Option<String>,
66 pub opencode_key: Option<String>,
67 pub codex: Option<CodexCredentials>,
68 pub host_token: Option<String>,
71}
72
73pub fn project_dirs() -> Result<ProjectDirs> {
75 ProjectDirs::from("", "", "nexus-chat").context("could not resolve home directory")
76}
77
78pub fn config_path() -> Result<PathBuf> {
79 Ok(project_dirs()?.config_dir().join("config.toml"))
80}
81
82pub fn load_banner() -> Option<String> {
85 let path = project_dirs().ok()?.config_dir().join("banner.txt");
86 let art = std::fs::read_to_string(path).ok()?;
87 (!art.trim().is_empty()).then(|| art.trim_end().to_string())
88}
89
90pub fn system_prompt_path() -> Result<PathBuf> {
94 Ok(project_dirs()?.config_dir().join("system_prompt.md"))
95}
96
97pub fn load_system_prompt() -> Result<String> {
99 let path = system_prompt_path()?;
100 if !path.exists() {
101 if let Some(dir) = path.parent() {
102 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
103 }
104 std::fs::write(&path, DEFAULT_SYSTEM_PROMPT)
105 .with_context(|| format!("writing {}", path.display()))?;
106 return Ok(DEFAULT_SYSTEM_PROMPT.to_string());
107 }
108 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))
109}
110
111pub fn load_creds_offline() -> SavedCreds {
117 let (mut openrouter_key, mut openai_key, mut opencode_key, codex) =
118 load_config_all().unwrap_or_default();
119 let host_token = load_host_token().ok().flatten();
120 if openrouter_key.is_empty()
121 && let Ok(v) = std::env::var(OPENROUTER_ENV_KEY)
122 && !v.trim().is_empty()
123 {
124 openrouter_key = v.trim().to_string();
125 }
126 if openai_key.is_empty()
127 && let Ok(v) = std::env::var(OPENAI_ENV_KEY)
128 && !v.trim().is_empty()
129 {
130 openai_key = v.trim().to_string();
131 }
132 if opencode_key.is_empty()
133 && let Ok(v) = std::env::var(OPENCODE_ENV_KEY)
134 && !v.trim().is_empty()
135 {
136 opencode_key = v.trim().to_string();
137 }
138 SavedCreds {
139 openrouter_key: (!openrouter_key.is_empty()).then_some(openrouter_key),
140 openai_key: (!openai_key.is_empty()).then_some(openai_key),
141 opencode_key: (!opencode_key.is_empty()).then_some(opencode_key),
142 codex,
143 host_token,
144 }
145}
146
147pub async fn load_all_providers() -> Result<SavedCreds> {
154 let path = config_path()?;
155 if !path.exists() {
156 write_provider_config("", "", "", None)?; }
158 let mut saved = load_creds_offline();
159 if let Some(creds) = saved.codex.take() {
160 let creds = refresh_codex_if_needed(creds).await?;
161 save_codex_credentials(&creds)?;
162 saved.codex = Some(creds);
163 }
164 Ok(saved)
165}
166
167pub fn first_configured(saved: &SavedCreds) -> Option<(&'static str, String)> {
172 saved
173 .openrouter_key
174 .clone()
175 .map(|k| ("openrouter", k))
176 .or_else(|| saved.openai_key.clone().map(|k| ("openai", k)))
177 .or_else(|| saved.opencode_key.clone().map(|k| ("opencode", k)))
178 .or_else(|| saved.codex.as_ref().map(|c| ("codex", c.access.clone())))
179}
180
181pub fn codex_account_id(access_token: &str) -> Result<String> {
182 let payload = access_token
183 .split('.')
184 .nth(1)
185 .context("invalid Codex access token")?;
186 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
187 .decode(payload)
188 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
189 .context("decoding Codex access token")?;
190 let v: serde_json::Value = serde_json::from_slice(&payload).context("parsing Codex token")?;
191 let account = v
192 .get("https://api.openai.com/auth")
193 .and_then(|a| a.get("chatgpt_account_id"))
194 .and_then(|a| a.as_str())
195 .context("Codex token missing ChatGPT account id")?;
196 Ok(account.to_string())
197}
198
199async fn refresh_codex_if_needed(creds: CodexCredentials) -> Result<CodexCredentials> {
200 if chrono::Utc::now().timestamp_millis() < creds.expires - 60_000 {
201 return Ok(creds);
202 }
203 let resp = reqwest::Client::new()
204 .post("https://auth.openai.com/oauth/token")
205 .header("Content-Type", "application/x-www-form-urlencoded")
206 .form(&[
207 ("grant_type", "refresh_token"),
208 ("refresh_token", creds.refresh.as_str()),
209 ("client_id", "app_EMoamEEZ73f0CkXaXp7hrann"),
210 ])
211 .send()
212 .await
213 .context("refreshing OpenAI Codex token")?
214 .error_for_status()
215 .context("OpenAI Codex token refresh failed")?
216 .json::<serde_json::Value>()
217 .await
218 .context("parsing OpenAI Codex token refresh")?;
219 let access = resp
220 .get("access_token")
221 .and_then(|v| v.as_str())
222 .context("missing access_token")?
223 .to_string();
224 let refresh = resp
225 .get("refresh_token")
226 .and_then(|v| v.as_str())
227 .unwrap_or(&creds.refresh)
228 .to_string();
229 let expires_in = resp
230 .get("expires_in")
231 .and_then(serde_json::Value::as_i64)
232 .context("missing expires_in")?;
233 Ok(CodexCredentials {
234 account_id: codex_account_id(&access)?,
235 access,
236 refresh,
237 expires: chrono::Utc::now().timestamp_millis() + expires_in * 1000,
238 })
239}
240
241pub fn save_codex_credentials(creds: &CodexCredentials) -> Result<()> {
242 let (openrouter_key, openai_key, opencode_key, _) = load_config_all().unwrap_or_default();
243 write_provider_config(&openrouter_key, &openai_key, &opencode_key, Some(creds))
244}
245
246pub fn load_openrouter_key_only() -> Option<String> {
247 if let Ok(v) = std::env::var(OPENROUTER_ENV_KEY) {
248 let v = v.trim();
249 if !v.is_empty() {
250 return Some(v.to_string());
251 }
252 }
253 load_config_all()
254 .ok()
255 .and_then(|(openrouter_key, ..)| (!openrouter_key.is_empty()).then_some(openrouter_key))
256}
257
258pub fn save_provider_key(flavor: &str, key: &str) -> Result<()> {
262 let (mut openrouter_key, mut openai_key, mut opencode_key, codex) =
263 load_config_all().unwrap_or_default();
264 match flavor {
265 "openrouter" => openrouter_key = key.to_string(),
266 "openai" => openai_key = key.to_string(),
267 "opencode" => opencode_key = key.to_string(),
268 _ => {}
269 }
270 write_provider_config(&openrouter_key, &openai_key, &opencode_key, codex.as_ref())
271}
272
273fn load_config_all() -> Result<(String, String, String, Option<CodexCredentials>)> {
275 let path = config_path()?;
276 if !path.exists() {
277 return Ok((String::new(), String::new(), String::new(), None));
278 }
279 let text =
280 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
281 let cfg: Config =
282 toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
283 Ok((
284 cfg.provider.openrouter_key,
285 cfg.provider.openai_key,
286 cfg.provider.opencode_key,
287 cfg.provider.openai_codex,
288 ))
289}
290
291pub fn load_host_token() -> Result<Option<String>> {
294 let path = config_path()?;
295 if !path.exists() {
296 return Ok(None);
297 }
298 let text =
299 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
300 let cfg: Config =
301 toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
302 Ok(cfg
303 .provider
304 .host_token
305 .filter(|token| !token.trim().is_empty()))
306}
307
308pub fn save_host_token(token: &str) -> Result<()> {
311 let path = config_path()?;
312 if let Some(dir) = path.parent() {
313 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
314 }
315 let mut value = if path.exists() {
316 let text = std::fs::read_to_string(&path)
317 .with_context(|| format!("reading {}", path.display()))?;
318 toml::from_str::<toml::Value>(&text)
319 .with_context(|| format!("parsing {}", path.display()))?
320 } else {
321 toml::Value::Table(toml::map::Map::new())
322 };
323 let table = value
324 .as_table_mut()
325 .context("config root is not a TOML table")?;
326 let provider = table
327 .entry("provider".to_string())
328 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
329 let provider = provider
330 .as_table_mut()
331 .context("config provider is not a TOML table")?;
332 provider.insert(
333 "host_token".to_string(),
334 toml::Value::String(token.to_string()),
335 );
336 let body = toml::to_string_pretty(&value).context("serializing config")?;
337 std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
338 Ok(())
339}
340
341pub fn ensure_host_token() -> Result<String> {
343 if let Some(token) = load_host_token()? {
344 return Ok(token);
345 }
346 let token = uuid::Uuid::new_v4().to_string();
347 save_host_token(&token)?;
348 Ok(token)
349}
350
351pub fn load_named_tunnel() -> Result<Option<NamedTunnelConfig>> {
353 let path = config_path()?;
354 if !path.exists() {
355 return Ok(None);
356 }
357 let text =
358 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
359 let cfg: Config =
360 toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
361 Ok(cfg.host.and_then(|host| host.named_tunnel))
362}
363
364pub fn save_named_tunnel(tunnel: &NamedTunnelConfig) -> Result<()> {
367 let path = config_path()?;
368 if let Some(dir) = path.parent() {
369 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
370 }
371 let mut value = if path.exists() {
372 let text = std::fs::read_to_string(&path)
373 .with_context(|| format!("reading {}", path.display()))?;
374 toml::from_str::<toml::Value>(&text)
375 .with_context(|| format!("parsing {}", path.display()))?
376 } else {
377 toml::Value::Table(toml::map::Map::new())
378 };
379 let root = value
380 .as_table_mut()
381 .context("config root is not a TOML table")?;
382 let host = root
383 .entry("host".to_string())
384 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
385 .as_table_mut()
386 .context("config host is not a TOML table")?;
387 host.insert(
388 "named_tunnel".to_string(),
389 toml::Value::try_from(tunnel).context("serializing named tunnel config")?,
390 );
391 let body = toml::to_string_pretty(&value).context("serializing config")?;
392 std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
393 Ok(())
394}
395
396#[allow(clippy::too_many_lines)]
398pub async fn login_openai_codex_device(
399 status: tokio::sync::mpsc::UnboundedSender<String>,
400) -> Result<CodexCredentials> {
401 let client = reqwest::Client::new();
402 let device = client
403 .post("https://auth.openai.com/api/accounts/deviceauth/usercode")
404 .json(&serde_json::json!({ "client_id": "app_EMoamEEZ73f0CkXaXp7hrann" }))
405 .send()
406 .await
407 .context("starting OpenAI Codex device login")?
408 .error_for_status()
409 .context("OpenAI Codex device login failed")?
410 .json::<serde_json::Value>()
411 .await
412 .context("parsing OpenAI Codex device login")?;
413 let device_auth_id = device
414 .get("device_auth_id")
415 .and_then(|v| v.as_str())
416 .context("missing device_auth_id")?
417 .to_string();
418 let user_code = device
419 .get("user_code")
420 .and_then(|v| v.as_str())
421 .context("missing user_code")?
422 .to_string();
423 let interval = device
424 .get("interval")
425 .and_then(|v| v.as_f64().or_else(|| v.as_str()?.parse::<f64>().ok()))
426 .unwrap_or(5.0)
427 .max(1.0);
428 let url = "https://auth.openai.com/codex/device";
429 let prefilled_url = format!("{url}?user_code={user_code}");
430 let _ =
432 arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(user_code.clone()));
433 let _ = status.send(format!(
434 "{user_code} ← copied to clipboard; enter at {url}"
435 ));
436 let _ = open::that(&prefilled_url);
437
438 let deadline = std::time::Instant::now() + std::time::Duration::from_mins(15);
439 let code = loop {
440 if std::time::Instant::now() >= deadline {
441 bail!("OpenAI Codex device login timed out");
442 }
443 tokio::time::sleep(std::time::Duration::from_secs_f64(interval)).await;
444 let resp = client
445 .post("https://auth.openai.com/api/accounts/deviceauth/token")
446 .json(&serde_json::json!({ "device_auth_id": device_auth_id, "user_code": user_code }))
447 .send()
448 .await
449 .context("polling OpenAI Codex device login")?;
450 if resp.status().is_success() {
451 let v = resp
452 .json::<serde_json::Value>()
453 .await
454 .context("parsing OpenAI Codex device token")?;
455 let authorization_code = v
456 .get("authorization_code")
457 .and_then(|v| v.as_str())
458 .context("missing authorization_code")?
459 .to_string();
460 let code_verifier = v
461 .get("code_verifier")
462 .and_then(|v| v.as_str())
463 .context("missing code_verifier")?
464 .to_string();
465 break (authorization_code, code_verifier);
466 }
467 if resp.status().as_u16() != 403 && resp.status().as_u16() != 404 {
468 let status_code = resp.status();
469 let body = resp.text().await.unwrap_or_default();
470 bail!("OpenAI Codex device login failed ({status_code}): {body}");
471 }
472 };
473
474 let token = client
475 .post("https://auth.openai.com/oauth/token")
476 .header("Content-Type", "application/x-www-form-urlencoded")
477 .form(&[
478 ("grant_type", "authorization_code"),
479 ("client_id", "app_EMoamEEZ73f0CkXaXp7hrann"),
480 ("code", code.0.as_str()),
481 ("code_verifier", code.1.as_str()),
482 (
483 "redirect_uri",
484 "https://auth.openai.com/deviceauth/callback",
485 ),
486 ])
487 .send()
488 .await
489 .context("exchanging OpenAI Codex device code")?
490 .error_for_status()
491 .context("OpenAI Codex device code exchange failed")?
492 .json::<serde_json::Value>()
493 .await
494 .context("parsing OpenAI Codex token")?;
495 let access = token
496 .get("access_token")
497 .and_then(|v| v.as_str())
498 .context("missing access_token")?
499 .to_string();
500 let refresh = token
501 .get("refresh_token")
502 .and_then(|v| v.as_str())
503 .context("missing refresh_token")?
504 .to_string();
505 let expires_in = token
506 .get("expires_in")
507 .and_then(serde_json::Value::as_i64)
508 .context("missing expires_in")?;
509 let creds = CodexCredentials {
510 account_id: codex_account_id(&access)?,
511 access,
512 refresh,
513 expires: chrono::Utc::now().timestamp_millis() + expires_in * 1000,
514 };
515 save_codex_credentials(&creds)?;
516 Ok(creds)
517}
518
519fn write_provider_config(
520 openrouter_key: &str,
521 openai_key: &str,
522 opencode_key: &str,
523 codex: Option<&CodexCredentials>,
524) -> Result<()> {
525 let path = config_path()?;
526 if let Some(dir) = path.parent() {
527 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
528 }
529 let escape = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
530 let mut body = format!(
531 "[provider]\n\
532 # OpenRouter key (or set ${OPENROUTER_ENV_KEY})\nopenrouter_key = \"{}\"\n\
533 # OpenAI API key (or set ${OPENAI_ENV_KEY})\nopenai_key = \"{}\"\n\
534 # OpenCode Go key (or set ${OPENCODE_ENV_KEY})\nopencode_key = \"{}\"\n",
535 escape(openrouter_key),
536 escape(openai_key),
537 escape(opencode_key),
538 );
539 if let Ok(Some(token)) = load_host_token() {
540 let _ = writeln!(body, "host_token = \"{}\"", escape(&token));
541 }
542 if let Some(c) = codex {
543 body.push_str("\n[provider.openai_codex]\n");
544 let _ = writeln!(body, "access = \"{}\"", escape(&c.access));
545 let _ = writeln!(body, "refresh = \"{}\"", escape(&c.refresh));
546 let _ = writeln!(body, "expires = {}", c.expires);
547 let _ = writeln!(body, "account_id = \"{}\"", escape(&c.account_id));
548 }
549 if let Ok(Some(tunnel)) = load_named_tunnel() {
550 body.push_str("\n[host.named_tunnel]\n");
551 let _ = writeln!(body, "tunnel_id = \"{}\"", escape(&tunnel.tunnel_id));
552 let _ = writeln!(body, "hostname = \"{}\"", escape(&tunnel.hostname));
553 let _ = writeln!(
554 body,
555 "credentials_path = \"{}\"",
556 escape(&tunnel.credentials_path.display().to_string())
557 );
558 let _ = writeln!(
559 body,
560 "config_path = \"{}\"",
561 escape(&tunnel.config_path.display().to_string())
562 );
563 }
564 std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
565 Ok(())
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn parses_keys() {
574 let cfg: Config = toml::from_str(
575 "[provider]\nopenrouter_key = \"sk-or-abc\"\nopenai_key = \"sk-proj-abc\"\n",
576 )
577 .unwrap();
578 assert_eq!(cfg.provider.openrouter_key, "sk-or-abc");
579 assert_eq!(cfg.provider.openai_key, "sk-proj-abc");
580 }
581
582 #[test]
583 fn missing_keys_default_empty() {
584 let cfg: Config = toml::from_str("[provider]\n").unwrap();
585 assert!(cfg.provider.openrouter_key.is_empty());
586 assert!(cfg.provider.openai_key.is_empty());
587 }
588
589 fn codex_creds(access: &str) -> CodexCredentials {
590 CodexCredentials {
591 access: access.to_string(),
592 refresh: "r".to_string(),
593 expires: 0,
594 account_id: "a".to_string(),
595 }
596 }
597
598 #[test]
599 fn first_configured_follows_fixed_priority() {
600 let saved = SavedCreds {
601 openrouter_key: Some("sk-or-abc".into()),
602 openai_key: Some("sk-proj-abc".into()),
603 opencode_key: Some("oc-token".into()),
604 codex: Some(codex_creds("codex-token")),
605 ..Default::default()
606 };
607 assert_eq!(
608 first_configured(&saved),
609 Some(("openrouter", "sk-or-abc".to_string()))
610 );
611 }
612
613 #[test]
614 fn first_configured_falls_through_to_whatever_is_set() {
615 let saved = SavedCreds {
616 openrouter_key: None,
617 openai_key: None,
618 opencode_key: Some("oc-token".into()),
619 codex: Some(codex_creds("codex-token")),
620 ..Default::default()
621 };
622 assert_eq!(
623 first_configured(&saved),
624 Some(("opencode", "oc-token".to_string()))
625 );
626 }
627
628 #[test]
629 fn first_configured_none_when_nothing_saved() {
630 assert_eq!(first_configured(&SavedCreds::default()), None);
631 }
632}