diff --git a/Cargo.lock b/Cargo.lock index ab09888..4888a7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,7 +232,7 @@ dependencies = [ [[package]] name = "cdxs" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 6994922..f738f18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cdxs" -version = "0.1.5" +version = "0.1.6" edition = "2021" description = "Codex account switcher CLI" diff --git a/README.md b/README.md index 0ae59f5..ece2cdd 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,12 @@ Codex home 的解析顺序: - `cdxs list`:列出账号,并自动刷新过期配额缓存。 - `cdxs show`:列出账号,但不请求配额接口。 - `cdxs switch`:不带参数时自动选择可用配额最优的账号。 -- `cdxs switch <账号>`:切换到指定账号。 +- `cdxs switch <账号或别名>`:切换到指定账号,可用 API 账号的 `--alias` 别名。 +- `cdxs switch A --model --effort --name OpenAI`:切换时更新该账号默认模型、思考程度;`--name` 仅用于 API 模式的 provider name。 - `cdxs ping`:以 5 并发 ping 所有 OAuth 账号,运行最低 reasoning 的最小 `codex exec`,触发 Codex 侧额度状态刷新。 - `cdxs ping --account <账号>`:指定账号运行最小 `codex exec`;可重复传多个账号。 -- `cdxs login api --key --base-url --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。 +- `cdxs login --api --base-url --alias A --model --name OpenAI --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。 +- `cdxs login api --key --base-url --alias A --model --name OpenAI --switch`:同上,保留旧的子命令形式。 - `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`。 - `cdxs pull`:从同步服务拉取账号状态,等价于 `cdxs sync pull`。 - `cdxs push`:推送账号状态到同步服务,等价于 `cdxs sync push`。 diff --git a/src/account.rs b/src/account.rs index f9cf0cd..6d9416a 100644 --- a/src/account.rs +++ b/src/account.rs @@ -16,6 +16,20 @@ use crate::{codex_config, jwt, paths, token}; const DEFAULT_API_BASE_URL: &str = "https://api.openai.com/v1"; +#[derive(Debug, Clone, Default)] +pub struct ApiKeyOptions { + pub alias: Option, + pub model: Option, + pub provider_name: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct SwitchOptions { + pub model: Option, + pub reasoning_effort: Option, + pub provider_name: Option, +} + pub fn import_auth(file: Option, codex_home: Option, switch: bool) -> Result<()> { // Store imported credentials in the main cdxs config, even when the source // auth file came from another CODEX_HOME. @@ -39,10 +53,15 @@ pub fn import_auth(file: Option, codex_home: Option, switch: b Ok(()) } -pub fn add_api_key(key: String, base_url: Option, switch: bool) -> Result<()> { +pub fn add_api_key( + key: String, + base_url: Option, + options: ApiKeyOptions, + switch: bool, +) -> Result<()> { let home = paths::codex_home(None)?; let mut store = Store::load(&home)?; - let account = api_key_account(key, base_url)?; + let account = api_key_account(key, base_url, options)?; let id = account.id.clone(); let email = account.email.clone(); store.upsert_account(account); @@ -205,6 +224,7 @@ pub async fn switch_account( query: &str, codex_home: Option, apply_fingerprint: bool, + options: SwitchOptions, ) -> Result<()> { if apply_fingerprint { eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。"); @@ -215,10 +235,15 @@ pub async fn switch_account( let target_home = paths::codex_home(codex_home)?; let mut store = Store::load(&config_home)?; let account_id = find_unique_account_id(&store, query)?; + update_account_switch_options(&mut store, &account_id, &options)?; switch_account_id(&mut store, &config_home, &target_home, &account_id).await } -pub async fn switch_auto(codex_home: Option, apply_fingerprint: bool) -> Result<()> { +pub async fn switch_auto( + codex_home: Option, + apply_fingerprint: bool, + options: SwitchOptions, +) -> Result<()> { if apply_fingerprint { eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。"); } @@ -240,6 +265,7 @@ pub async fn switch_auto(codex_home: Option, apply_fingerprint: bool) - ); } let account_id = best_auto_switch_account(&store)?; + update_account_switch_options(&mut store, &account_id, &options)?; switch_account_id(&mut store, &config_home, &target_home, &account_id).await?; if let Some(account) = store.find_account(&account_id) { println!( @@ -424,7 +450,11 @@ fn account_from_auth(auth: auth_file::CodexAuthFile) -> Result { if auth_file::is_api_key_mode(&auth) { let key = auth_file::extract_api_key(&auth) .ok_or_else(|| anyhow!("auth.json 缺少 OPENAI_API_KEY"))?; - return api_key_account(key, auth_file::api_base_url(&auth)); + return api_key_account( + key, + auth_file::api_base_url(&auth), + ApiKeyOptions::default(), + ); } let tokens = auth @@ -488,6 +518,10 @@ fn oauth_account(tokens: Tokens, account_id_hint: Option) -> Result) -> Result) -> Result { +fn api_key_account( + key: String, + base_url: Option, + options: ApiKeyOptions, +) -> Result { let key = key.trim(); if key.is_empty() { return Err(anyhow!("API Key 不能为空")); } let base_url = normalize_api_base_url(base_url); + let alias = normalize_optional_field(options.alias); + let model = normalize_optional_field(options.model); + let provider_name = normalize_optional_field(options.provider_name); let id = stable_id("apikey", key, base_url.as_deref(), None); let email = api_base_url_display(base_url.as_deref()).to_string(); let now = Utc::now().timestamp(); @@ -516,6 +557,10 @@ fn api_key_account(key: String, base_url: Option) -> Result { id, email, auth_mode: AuthMode::ApiKey, + alias, + model, + reasoning_effort: None, + api_provider_name: provider_name, plan_type: None, account_id: None, organization_id: None, @@ -548,7 +593,11 @@ fn stable_id(kind: &str, a: &str, b: Option<&str>, c: Option<&str>) -> String { fn find_unique_account_id(store: &Store, query: &str) -> Result { // Exact ids are always unambiguous. Email and prefix queries can match both // personal and team accounts for the same login email, so reject ambiguity. - if let Some(account) = store.accounts.iter().find(|account| account.id == query) { + if let Some(account) = store + .accounts + .iter() + .find(|account| account.id == query || account.alias.as_deref() == Some(query)) + { return Ok(account.id.clone()); } @@ -559,6 +608,11 @@ fn find_unique_account_id(store: &Store, query: &str) -> Result { .filter(|account| { account_id_matches_query(&account.id, query) || account.email.eq_ignore_ascii_case(query) + || account + .alias + .as_deref() + .map(|alias| alias.eq_ignore_ascii_case(query)) + .unwrap_or(false) || account .email .to_ascii_lowercase() @@ -589,6 +643,36 @@ fn find_unique_account_id(store: &Store, query: &str) -> Result { } } +fn update_account_switch_options( + store: &mut Store, + account_id: &str, + options: &SwitchOptions, +) -> Result<()> { + let Some(account) = store.find_account_mut(account_id) else { + return Err(anyhow!("账号不存在: {account_id}")); + }; + let mut changed = false; + if let Some(model) = normalize_optional_field(options.model.clone()) { + changed |= account.model.as_deref() != Some(model.as_str()); + account.model = Some(model); + } + if let Some(reasoning_effort) = normalize_optional_field(options.reasoning_effort.clone()) { + changed |= account.reasoning_effort.as_deref() != Some(reasoning_effort.as_str()); + account.reasoning_effort = Some(reasoning_effort); + } + if let Some(provider_name) = normalize_optional_field(options.provider_name.clone()) { + if account.auth_mode != AuthMode::ApiKey { + return Err(anyhow!("--name 仅支持 API Key 账号")); + } + changed |= account.api_provider_name.as_deref() != Some(provider_name.as_str()); + account.api_provider_name = Some(provider_name); + } + if changed { + account.updated_at = Utc::now().timestamp(); + } + Ok(()) +} + fn account_id_matches_query(id: &str, query: &str) -> bool { id.starts_with(query) || id @@ -682,7 +766,9 @@ fn account_plan_display(account: &Account) -> &str { } fn account_email_display(account: &Account) -> &str { - if account.auth_mode == AuthMode::ApiKey { + if let Some(alias) = account.alias.as_deref() { + alias + } else if account.auth_mode == AuthMode::ApiKey { api_base_url_display(account.api_base_url.as_deref()) } else { &account.email @@ -804,8 +890,14 @@ fn shorten(value: &str, width: usize) -> String { fn print_account(account: &Account) { println!("id: {}", account.id); + println!("alias: {}", account.alias.as_deref().unwrap_or("-")); println!("email: {}", account_email_display(account)); println!("auth_mode: {}", auth_file::account_auth_mode_name(account)); + println!("model: {}", account.model.as_deref().unwrap_or("-")); + println!( + "reasoning_effort: {}", + account.reasoning_effort.as_deref().unwrap_or("-") + ); println!("plan_type: {}", account_plan_display(account)); println!( "account_id: {}", @@ -820,6 +912,10 @@ fn print_account(account: &Account) { "api_base_url: {}", api_base_url_display(account.api_base_url.as_deref()) ); + println!( + "api_provider_name: {}", + account.api_provider_name.as_deref().unwrap_or("-") + ); println!( "openai_api_key: {}", account @@ -840,6 +936,14 @@ fn normalize_api_base_url(base_url: Option) -> Option { .map(|value| value.trim_end_matches('/').to_string()) } +fn normalize_optional_field(value: Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + fn mask_api_key(key: &str) -> String { let chars = key.chars().collect::>(); if chars.len() <= 8 { diff --git a/src/cli.rs b/src/cli.rs index 06fbdff..698fb73 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -58,6 +58,12 @@ pub enum Commands { codex_home: Option, #[arg(long)] apply_fingerprint: bool, + #[arg(long)] + model: Option, + #[arg(long, alias = "reasoning-effort")] + effort: Option, + #[arg(long)] + name: Option, }, /// Prepare auth, set CODEX_HOME, and execute a command. Run(RunArgs), @@ -102,6 +108,17 @@ pub struct LoginArgs { /// Start OpenAI OAuth login for Codex. #[command(subcommand)] pub command: Option, + /// Add an API key account directly, for example: cdxs login --api sk-... + #[arg(long, value_name = "KEY")] + pub api: Option, + #[arg(long)] + pub base_url: Option, + #[arg(long)] + pub alias: Option, + #[arg(long)] + pub model: Option, + #[arg(long)] + pub name: Option, #[arg(long)] pub manual: bool, #[arg(long, default_value_t = 1455)] @@ -141,6 +158,12 @@ pub enum LoginCommands { #[arg(long)] base_url: Option, #[arg(long)] + alias: Option, + #[arg(long)] + model: Option, + #[arg(long)] + name: Option, + #[arg(long)] switch: bool, }, } @@ -184,6 +207,12 @@ pub enum AccountCommands { #[arg(long)] base_url: Option, #[arg(long)] + alias: Option, + #[arg(long)] + model: Option, + #[arg(long)] + name: Option, + #[arg(long)] switch: bool, }, } diff --git a/src/codex_config.rs b/src/codex_config.rs index bccea81..e79d781 100644 --- a/src/codex_config.rs +++ b/src/codex_config.rs @@ -14,52 +14,64 @@ 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::Oauth => apply_oauth_config(codex_home, account), AuthMode::ApiKey => apply_api_key_provider(codex_home, account), } } -fn apply_api_key_provider(codex_home: &Path, account: &Account) -> Result<()> { +fn apply_oauth_config(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("OpenAI".to_string())); - provider.insert("base_url".to_string(), Value::String(base_url)); - provider.insert( - "wire_api".to_string(), - Value::String("responses".to_string()), - ); - provider.insert("requires_openai_auth".to_string(), Value::Boolean(true)); - providers.insert(provider_id, Value::Table(provider)); - changed = true; + if !path.exists() && account.model.is_none() && account.reasoning_effort.is_none() { + return Ok(()); } - + let mut config = read_config(&path)?; + let mut changed = apply_common_model_settings(&mut config, account); + changed |= remove_previous_managed_provider(&mut config); if changed { write_config(&path, codex_home, &config)?; } Ok(()) } -fn clear_managed_provider(codex_home: &Path) -> Result<()> { +fn apply_api_key_provider(codex_home: &Path, account: &Account) -> 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(()) + remove_previous_managed_provider(&mut config); + + apply_common_model_settings(&mut config, account); + + let base_url = account + .api_base_url + .as_deref() + .and_then(normalize_base_url) + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); + 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( + account + .api_provider_name + .as_deref() + .and_then(normalize_field) + .unwrap_or_else(|| "OpenAI".to_string()), + ), + ); + provider.insert("base_url".to_string(), Value::String(base_url)); + provider.insert( + "wire_api".to_string(), + Value::String("responses".to_string()), + ); + provider.insert("requires_openai_auth".to_string(), Value::Boolean(true)); + providers.insert(provider_id, Value::Table(provider)); + + write_config(&path, codex_home, &config) } fn read_config(path: &Path) -> Result> { @@ -131,3 +143,32 @@ fn normalize_base_url(value: &str) -> Option { Some(value.to_string()) } } + +fn apply_common_model_settings(config: &mut Map, account: &Account) -> bool { + let mut changed = false; + if let Some(model) = account.model.as_deref().and_then(normalize_field) { + config.insert("model".to_string(), Value::String(model)); + changed = true; + } + if let Some(reasoning_effort) = account + .reasoning_effort + .as_deref() + .and_then(normalize_field) + { + config.insert( + "model_reasoning_effort".to_string(), + Value::String(reasoning_effort), + ); + changed = true; + } + changed +} + +fn normalize_field(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} diff --git a/src/config_store.rs b/src/config_store.rs index d2f30a9..dba3ad3 100644 --- a/src/config_store.rs +++ b/src/config_store.rs @@ -56,6 +56,14 @@ pub struct Account { pub email: String, pub auth_mode: AuthMode, #[serde(default)] + pub alias: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub reasoning_effort: Option, + #[serde(default)] + pub api_provider_name: Option, + #[serde(default)] pub plan_type: Option, #[serde(default)] pub account_id: Option, @@ -234,6 +242,11 @@ impl Store { .split_once('_') .map(|(_, suffix)| suffix.starts_with(query)) .unwrap_or(false) + || account + .alias + .as_deref() + .map(|alias| alias.eq_ignore_ascii_case(query)) + .unwrap_or(false) || account.email.eq_ignore_ascii_case(query) || account .email diff --git a/src/main.rs b/src/main.rs index 479bbdf..35325d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,9 +44,36 @@ async fn main() -> Result<()> { Some(LoginCommands::Api { key, base_url, + alias, + model, + name, switch, - }) => account::add_api_key(key, base_url, switch), - None => oauth::login_oauth(args.manual, args.port, args.switch).await, + }) => account::add_api_key( + key, + base_url, + account::ApiKeyOptions { + alias, + model, + provider_name: name, + }, + switch, + ), + None => { + if let Some(key) = args.api { + account::add_api_key( + key, + args.base_url, + account::ApiKeyOptions { + alias: args.alias, + model: args.model, + provider_name: args.name, + }, + args.switch, + ) + } else { + oauth::login_oauth(args.manual, args.port, args.switch).await + } + } }, Commands::Import(args) => match args.command { Some(ImportCommands::Auth { @@ -66,9 +93,17 @@ async fn main() -> Result<()> { auto, codex_home, apply_fingerprint, + model, + effort, + name, } => { + let options = account::SwitchOptions { + model, + reasoning_effort: effort, + provider_name: name, + }; if auto || account.is_none() { - account::switch_auto(codex_home, apply_fingerprint).await + account::switch_auto(codex_home, apply_fingerprint, options).await } else { account::switch_account( account.as_deref().ok_or_else(|| { @@ -76,6 +111,7 @@ async fn main() -> Result<()> { })?, codex_home, apply_fingerprint, + options, ) .await } @@ -109,8 +145,20 @@ async fn main() -> Result<()> { AccountCommands::AddApiKey { key, base_url, + alias, + model, + name, switch, - } => account::add_api_key(key, base_url, switch), + } => account::add_api_key( + key, + base_url, + account::ApiKeyOptions { + alias, + model, + provider_name: name, + }, + switch, + ), }, Commands::Home { command } => match command { HomeCommands::List { json } => account::list_homes(json), diff --git a/src/sync_client.rs b/src/sync_client.rs index 1ceaa35..4debffc 100644 --- a/src/sync_client.rs +++ b/src/sync_client.rs @@ -148,8 +148,8 @@ pub async fn remote(json: bool) -> Result<()> { return Ok(()); } println!( - "{:<22} {:<34} {:<10} {:<12} {}", - "ID", "Email", "Mode", "Plan", "Quota" + "{:<22} {:<16} {:<34} {:<10} {:<18} {:<12} {}", + "ID", "Alias", "Email", "Mode", "Model", "Provider", "Quota" ); for account in &remote_state.accounts { let quota = account @@ -163,11 +163,13 @@ pub async fn remote(json: bool) -> Result<()> { }) .unwrap_or_else(|| "-".to_string()); println!( - "{:<22} {:<34} {:<10} {:<12} {}", + "{:<22} {:<16} {:<34} {:<10} {:<18} {:<12} {}", shorten(&account.id, 22), + shorten(account.alias.as_deref().unwrap_or("-"), 16), shorten(account_email_display(account), 34), account_auth_mode_name(account), - account_plan_display(account), + shorten(account.model.as_deref().unwrap_or("-"), 18), + shorten(account_provider_display(account), 12), quota ); } @@ -308,6 +310,14 @@ fn account_plan_display(account: &Account) -> &str { } } +fn account_provider_display(account: &Account) -> &str { + if account.auth_mode == crate::config_store::AuthMode::ApiKey { + account.api_provider_name.as_deref().unwrap_or("-") + } else { + account_plan_display(account) + } +} + fn shorten(value: &str, width: usize) -> String { if value.chars().count() <= width { return value.to_string();