From 3bddaa7cc57cfc8e92afff8de64061415aca2ae3 Mon Sep 17 00:00:00 2001 From: chuan Date: Tue, 26 May 2026 12:24:47 +0800 Subject: [PATCH] Add API key login mode --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 3 +- features.md | 1 + src/account.rs | 81 ++++++++++++++++++++++++---- src/cli.rs | 9 ++++ src/codex_config.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 6 +++ src/paths.rs | 4 ++ src/sync_client.rs | 25 ++++++++- 10 files changed, 247 insertions(+), 15 deletions(-) create mode 100644 src/codex_config.rs diff --git a/Cargo.lock b/Cargo.lock index 953e298..f4a382b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,7 +232,7 @@ dependencies = [ [[package]] name = "cdxs" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 4593e48..b8afd41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cdxs" -version = "0.1.2" +version = "0.1.3" edition = "2021" description = "Codex account switcher CLI" diff --git a/README.md b/README.md index e00b5a7..60ad336 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Codex 的认证和会话状态都来自 `CODEX_HOME`。`cdxs` 做的是在这个目录外面加一层可操作的管理能力: -- 账号管理:导入已有 `auth.json`,通过 OAuth 登录,添加 API Key 账号,切换当前账号,并在需要时刷新 OAuth token。 +- 账号管理:导入已有 `auth.json`,通过 OAuth 或 API Key 登录,切换当前账号,并在需要时刷新 OAuth token。 - 配额查看:查询 OAuth 账号的 Codex 使用配额,并缓存到本地。 - Home 管理:创建命名的 `CODEX_HOME`,并绑定到指定账号。 - 命令运行:用指定账号或 home 启动子进程,只为该进程设置 `CODEX_HOME`。 @@ -43,6 +43,7 @@ Codex home 的解析顺序: - `cdxs show`:列出账号,但不请求配额接口。 - `cdxs switch`:不带参数时自动选择可用配额最优的账号。 - `cdxs switch <账号>`:切换到指定账号。 +- `cdxs login api --key --base-url --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。 - `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`。 - `cdxs pull`:从同步服务拉取账号状态,等价于 `cdxs sync pull`。 - `cdxs push`:推送账号状态到同步服务,等价于 `cdxs sync push`。 diff --git a/features.md b/features.md index bb33c2c..bf1d87b 100644 --- a/features.md +++ b/features.md @@ -2,6 +2,7 @@ - 保存和管理多个 Codex OAuth 账号。 - 保存和管理多个 Codex API Key 账号。 +- 通过 `login api` 保存 API Key 账号,可绑定自定义 API base URL。 - 从现有 Codex `auth.json` 导入账号。 - 通过 OpenAI OAuth 登录并保存 Codex token。 - 将指定账号切换写入 Codex `auth.json`。 diff --git a/src/account.rs b/src/account.rs index 9312d69..8d161b0 100644 --- a/src/account.rs +++ b/src/account.rs @@ -12,7 +12,9 @@ use sha2::{Digest, Sha256}; use crate::auth_file; use crate::config_store::{self, Account, AuthMode, Home, Store, Tokens}; -use crate::{jwt, paths, token}; +use crate::{codex_config, jwt, paths, token}; + +const DEFAULT_API_BASE_URL: &str = "https://api.openai.com/v1"; pub fn import_auth(file: Option, codex_home: Option, switch: bool) -> Result<()> { // Store imported credentials in the main cdxs config, even when the source @@ -30,6 +32,7 @@ pub fn import_auth(file: Option, codex_home: Option, switch: b store.meta.current_account_id = Some(id.clone()); let account = store.find_account(&id).expect("just inserted account"); auth_file::write_account_to_auth(&paths::auth_path(&source_home), &source_home, account)?; + codex_config::apply_account_provider(&source_home, account)?; } store.save(&config_home)?; println!("已导入账号: {email} ({id})"); @@ -47,6 +50,7 @@ pub fn add_api_key(key: String, base_url: Option, switch: bool) -> Resul store.meta.current_account_id = Some(id.clone()); let account = store.find_account(&id).expect("just inserted account"); auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?; + codex_config::apply_account_provider(&home, account)?; } store.save(&home)?; println!("已保存 API Key 账号: {email} ({id})"); @@ -119,7 +123,7 @@ fn print_accounts(store: &Store, json: bool) -> Result<()> { print_account_table_row( current, &account.id, - &account.email, + account_email_display(account), auth_file::account_auth_mode_name(account), account_plan_display(account), &primary_quota, @@ -247,6 +251,7 @@ async fn switch_account_id( .find_account(account_id) .ok_or_else(|| anyhow!("账号不存在: {account_id}"))?; auth_file::write_account_to_auth(&paths::auth_path(target_home), target_home, account)?; + codex_config::apply_account_provider(target_home, account)?; if let Some(account) = store.find_account_mut(account_id) { account.last_used_at = Utc::now().timestamp(); } @@ -305,6 +310,7 @@ pub fn create_home(name: &str, path: PathBuf, account: Option) -> Result if let Some(account_id) = bound_account_id.as_deref() { let account = store.find_account(account_id).expect("checked account"); auth_file::write_account_to_auth(&paths::auth_path(&path), &path, account)?; + codex_config::apply_account_provider(&path, account)?; } store.homes.push(Home { name: name.to_string(), @@ -377,6 +383,7 @@ pub async fn prepare_account_in_home(query: &str, codex_home: PathBuf) -> Result .find_account(&account_id) .ok_or_else(|| anyhow!("账号不存在: {account_id}"))?; auth_file::write_account_to_auth(&paths::auth_path(&codex_home), &codex_home, account)?; + codex_config::apply_account_provider(&codex_home, account)?; (account.id.clone(), account.email.clone()) }; if let Some(account) = store.find_account_mut(&account_id) { @@ -488,14 +495,15 @@ fn api_key_account(key: String, base_url: Option) -> Result { if key.is_empty() { return Err(anyhow!("API Key 不能为空")); } + let base_url = normalize_api_base_url(base_url); let id = stable_id("apikey", key, base_url.as_deref(), None); - let email = format!("api-key-{}", &id[id.len().saturating_sub(8)..]); + let email = api_base_url_display(base_url.as_deref()).to_string(); let now = Utc::now().timestamp(); Ok(Account { id, email, auth_mode: AuthMode::ApiKey, - plan_type: Some("API_KEY".to_string()), + plan_type: None, account_id: None, organization_id: None, tokens: None, @@ -634,13 +642,27 @@ fn format_quota(account: &Account) -> String { } fn account_plan_display(account: &Account) -> &str { - if account.requires_reauth { + if account.auth_mode == AuthMode::ApiKey { + "-" + } else if account.requires_reauth { "reauth" } else { account.plan_type.as_deref().unwrap_or("-") } } +fn account_email_display(account: &Account) -> &str { + if account.auth_mode == AuthMode::ApiKey { + api_base_url_display(account.api_base_url.as_deref()) + } else { + &account.email + } +} + +fn api_base_url_display(base_url: Option<&str>) -> &str { + base_url.unwrap_or(DEFAULT_API_BASE_URL) +} + fn format_quota_cells(account: &Account) -> (String, String) { let Some(quota) = account.quota.as_ref() else { return ("-".to_string(), "-".to_string()); @@ -710,7 +732,7 @@ fn print_account_table_border() { "-".repeat(3), "-".repeat(24), "-".repeat(30), - "-".repeat(8), + "-".repeat(10), "-".repeat(8), "-".repeat(14), "-".repeat(14) @@ -727,11 +749,11 @@ fn print_account_table_row( secondary_quota: &str, ) { println!( - "| {:<1} | {:<22} | {:<28} | {:<6} | {:<6} | {:<12} | {:<12} |", + "| {:<1} | {:<22} | {:<28} | {:<8} | {:<6} | {:<12} | {:<12} |", shorten(marker, 1), shorten(id, 22), shorten(email, 28), - shorten(mode, 6), + shorten(mode, 8), shorten(plan, 6), shorten(primary_quota, 12), shorten(secondary_quota, 12) @@ -752,9 +774,9 @@ fn shorten(value: &str, width: usize) -> String { fn print_account(account: &Account) { println!("id: {}", account.id); - println!("email: {}", account.email); + println!("email: {}", account_email_display(account)); println!("auth_mode: {}", auth_file::account_auth_mode_name(account)); - println!("plan_type: {}", account.plan_type.as_deref().unwrap_or("-")); + println!("plan_type: {}", account_plan_display(account)); println!( "account_id: {}", account.account_id.as_deref().unwrap_or("-") @@ -763,5 +785,44 @@ fn print_account(account: &Account) { "organization_id: {}", account.organization_id.as_deref().unwrap_or("-") ); + if account.auth_mode == AuthMode::ApiKey { + println!( + "api_base_url: {}", + api_base_url_display(account.api_base_url.as_deref()) + ); + println!( + "openai_api_key: {}", + account + .openai_api_key + .as_deref() + .map(mask_api_key) + .unwrap_or_else(|| "-".to_string()) + ); + } println!("requires_reauth: {}", account.requires_reauth); } + +fn normalize_api_base_url(base_url: Option) -> Option { + base_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_end_matches('/').to_string()) +} + +fn mask_api_key(key: &str) -> String { + let chars = key.chars().collect::>(); + if chars.len() <= 8 { + return "".to_string(); + } + let prefix = chars.iter().take(3).collect::(); + let suffix = chars + .iter() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect::(); + format!("{prefix}...{suffix}") +} diff --git a/src/cli.rs b/src/cli.rs index 5122769..1d7dfd9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -132,6 +132,15 @@ pub enum LoginCommands { #[arg(long)] switch: bool, }, + /// Add an OpenAI API key account. + Api { + #[arg(long)] + key: String, + #[arg(long)] + base_url: Option, + #[arg(long)] + switch: bool, + }, } #[derive(Subcommand)] diff --git a/src/codex_config.rs b/src/codex_config.rs new file mode 100644 index 0000000..97bc706 --- /dev/null +++ b/src/codex_config.rs @@ -0,0 +1,129 @@ +//! Codex `config.toml` helpers for account-specific provider settings. + +use std::path::Path; + +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; +use toml::map::Map; +use toml::Value; + +use crate::config_store::{Account, AuthMode}; +use crate::{atomic, paths}; + +const MANAGED_PROVIDER_PREFIX: &str = "cdxs_api_"; + +pub fn apply_account_provider(codex_home: &Path, account: &Account) -> Result<()> { + match account.auth_mode { + AuthMode::Oauth => clear_managed_provider(codex_home), + AuthMode::ApiKey => apply_api_key_provider(codex_home, account), + } +} + +fn apply_api_key_provider(codex_home: &Path, account: &Account) -> Result<()> { + let path = paths::codex_config_path(codex_home); + let mut config = read_config(&path)?; + let mut changed = remove_previous_managed_provider(&mut config); + + if let Some(base_url) = account.api_base_url.as_deref().and_then(normalize_base_url) { + let provider_id = provider_id(account, &base_url); + config.insert( + "model_provider".to_string(), + Value::String(provider_id.clone()), + ); + + let providers = table_entry(&mut config, "model_providers"); + let mut provider = Map::new(); + provider.insert("name".to_string(), Value::String("cdxs api".to_string())); + provider.insert("base_url".to_string(), Value::String(base_url)); + provider.insert("requires_openai_auth".to_string(), Value::Boolean(true)); + providers.insert(provider_id, Value::Table(provider)); + changed = true; + } + + if changed { + write_config(&path, codex_home, &config)?; + } + Ok(()) +} + +fn clear_managed_provider(codex_home: &Path) -> Result<()> { + let path = paths::codex_config_path(codex_home); + if !path.exists() { + return Ok(()); + } + let mut config = read_config(&path)?; + if remove_previous_managed_provider(&mut config) { + write_config(&path, codex_home, &config)?; + } + Ok(()) +} + +fn read_config(path: &Path) -> Result> { + if !path.exists() { + return Ok(Map::new()); + } + let content = std::fs::read_to_string(path) + .with_context(|| format!("读取 Codex config.toml 失败: {}", path.display()))?; + if content.trim().is_empty() { + return Ok(Map::new()); + } + let value: Value = toml::from_str(&content) + .with_context(|| format!("解析 Codex config.toml 失败: {}", path.display()))?; + Ok(value.as_table().cloned().unwrap_or_default()) +} + +fn write_config(path: &Path, codex_home: &Path, config: &Map) -> Result<()> { + atomic::backup_if_exists(path, codex_home, "config.toml")?; + let content = toml::to_string_pretty(config).context("序列化 Codex config.toml 失败")?; + atomic::write_atomic(path, &content) +} + +fn remove_previous_managed_provider(config: &mut Map) -> bool { + let mut changed = false; + let managed_current = config + .get("model_provider") + .and_then(Value::as_str) + .filter(|provider| provider.starts_with(MANAGED_PROVIDER_PREFIX)) + .map(ToOwned::to_owned); + + if managed_current.is_some() { + config.remove("model_provider"); + changed = true; + } + + if let Some(Value::Table(providers)) = config.get_mut("model_providers") { + let before = providers.len(); + providers.retain(|key, _| !key.starts_with(MANAGED_PROVIDER_PREFIX)); + changed |= providers.len() != before; + } + changed +} + +fn table_entry<'a>(config: &'a mut Map, key: &str) -> &'a mut Map { + let needs_table = !matches!(config.get(key), Some(Value::Table(_))); + if needs_table { + config.insert(key.to_string(), Value::Table(Map::new())); + } + config + .get_mut(key) + .and_then(Value::as_table_mut) + .expect("table entry was just inserted") +} + +fn provider_id(account: &Account, base_url: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(account.id.as_bytes()); + hasher.update([0]); + hasher.update(base_url.as_bytes()); + let hex = hex::encode(hasher.finalize()); + format!("{MANAGED_PROVIDER_PREFIX}{}", &hex[..12]) +} + +fn normalize_base_url(value: &str) -> Option { + let value = value.trim().trim_end_matches('/'); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} diff --git a/src/main.rs b/src/main.rs index 8810868..89adc36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod account; mod atomic; mod auth_file; mod cli; +mod codex_config; mod config_store; mod http_client; mod jwt; @@ -40,6 +41,11 @@ async fn main() -> Result<()> { port, switch, }) => oauth::login_oauth(manual, port, switch).await, + Some(LoginCommands::Api { + key, + base_url, + switch, + }) => account::add_api_key(key, base_url, switch), None => oauth::login_oauth(args.manual, args.port, args.switch).await, }, Commands::Import(args) => match args.command { diff --git a/src/paths.rs b/src/paths.rs index 207ddb1..0f1a893 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -27,6 +27,10 @@ pub fn auth_path(codex_home: &std::path::Path) -> PathBuf { codex_home.join("auth.json") } +pub fn codex_config_path(codex_home: &std::path::Path) -> PathBuf { + codex_home.join("config.toml") +} + pub fn expand_home(path: PathBuf) -> PathBuf { // PathBuf does not expand ~ on Windows or Unix, so handle the common cases. let raw = path.to_string_lossy(); diff --git a/src/sync_client.rs b/src/sync_client.rs index 61e0d3e..1ceaa35 100644 --- a/src/sync_client.rs +++ b/src/sync_client.rs @@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize}; use crate::config_store::{Account, AccountSyncState, Store}; +const DEFAULT_API_BASE_URL: &str = "https://api.openai.com/v1"; + #[derive(Debug, Serialize)] struct LoginRequest<'a> { username: &'a str, @@ -163,9 +165,9 @@ pub async fn remote(json: bool) -> Result<()> { println!( "{:<22} {:<34} {:<10} {:<12} {}", shorten(&account.id, 22), - shorten(&account.email, 34), + shorten(account_email_display(account), 34), account_auth_mode_name(account), - account.plan_type.as_deref().unwrap_or("-"), + account_plan_display(account), quota ); } @@ -287,6 +289,25 @@ fn account_auth_mode_name(account: &Account) -> &'static str { } } +fn account_email_display(account: &Account) -> &str { + if account.auth_mode == crate::config_store::AuthMode::ApiKey { + account + .api_base_url + .as_deref() + .unwrap_or(DEFAULT_API_BASE_URL) + } else { + &account.email + } +} + +fn account_plan_display(account: &Account) -> &str { + if account.auth_mode == crate::config_store::AuthMode::ApiKey { + "-" + } else { + account.plan_type.as_deref().unwrap_or("-") + } +} + fn shorten(value: &str, width: usize) -> String { if value.chars().count() <= width { return value.to_string();