5 Commits

11 changed files with 705 additions and 89 deletions
+1 -28
View File
@@ -15,32 +15,14 @@ on:
env:
GITEA_SERVER_URL: https://git.pchuan.top
RUST_IMAGE: docker.m.daocloud.io/library/rust:1-bookworm
CARGO_REGISTRY: sparse+https://rsproxy.cn/index/
APT_MIRROR: https://mirrors.ustc.edu.cn/debian
CARGO_TERM_COLOR: always
HTTP_PROXY: http://172.17.0.1:1082
HTTPS_PROXY: http://172.17.0.1:1082
ALL_PROXY: http://172.17.0.1:1082
NO_PROXY: localhost,127.0.0.1,::1,172.17.0.1,git.pchuan.top,.pchuan.top
http_proxy: http://172.17.0.1:1082
https_proxy: http://172.17.0.1:1082
all_proxy: http://172.17.0.1:1082
no_proxy: localhost,127.0.0.1,::1,172.17.0.1,git.pchuan.top,.pchuan.top
jobs:
prepare-release:
name: Prepare release
runs-on: ubuntu-latest
steps:
- name: Configure network proxy
shell: bash
run: |
set -euo pipefail
git config --global http.proxy "${HTTPS_PROXY}"
git config --global https.proxy "${HTTPS_PROXY}"
git config --global http.noProxy "${NO_PROXY}"
- name: Checkout
uses: actions/checkout@v4
with:
@@ -119,15 +101,6 @@ jobs:
runs-on: ubuntu-latest
needs: prepare-release
steps:
- name: Configure network proxy
shell: bash
run: |
set -euo pipefail
git config --global http.proxy "${HTTPS_PROXY}"
git config --global https.proxy "${HTTPS_PROXY}"
git config --global http.noProxy "${NO_PROXY}"
- name: Checkout
uses: actions/checkout@v4
with:
@@ -148,7 +121,7 @@ jobs:
- name: Build release assets
shell: bash
run: bash scripts/publish.sh --version "${VERSION}" --rust-image "${RUST_IMAGE}" --cargo-registry "${CARGO_REGISTRY}" --apt-mirror "${APT_MIRROR}" --clean
run: bash scripts/publish.sh --version "${VERSION}" --rust-image "${RUST_IMAGE}" --apt-mirror "${APT_MIRROR}" --clean
- name: Upload release assets
shell: bash
Generated
+1 -1
View File
@@ -232,7 +232,7 @@ dependencies = [
[[package]]
name = "cdxs"
version = "0.1.3"
version = "0.1.7"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cdxs"
version = "0.1.3"
version = "0.1.7"
edition = "2021"
description = "Codex account switcher CLI"
+11 -2
View File
@@ -41,13 +41,22 @@ Codex home 的解析顺序:
- `cdxs list`:列出账号,并自动刷新过期配额缓存。
- `cdxs show`:列出账号,但不请求配额接口。
- `cdxs alias set <别名> <账号>`:给任意 OAuth 或 API Key 账号设置别名。
- `cdxs alias list`:列出所有账号别名。
- `cdxs alias remove <别名或账号>`:删除指定账号的别名。
- `cdxs switch`:不带参数时自动选择可用配额最优的账号。
- `cdxs switch <账号>`:切换到指定账号。
- `cdxs login api --key <key> --base-url <url> --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API
- `cdxs switch <账号或别名>`:切换到指定账号OAuth 和 API Key 账号都可通过 alias 切换
- `cdxs switch A --model <model> --effort <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 <url> --alias A --model <model> --name OpenAI --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。
- `cdxs login api --key <key> --base-url <url> --alias A --model <model> --name OpenAI --switch`:同上,保留旧的子命令形式。
- `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`
- `cdxs pull`:从同步服务拉取账号状态,等价于 `cdxs sync pull`
- `cdxs push`:推送账号状态到同步服务,等价于 `cdxs sync push`
账号 alias 保存在账号记录里,因此会随 `cdxs push` 推送到同步服务,并随 `cdxs pull` 从同步服务拉取回来。
## 同步服务
内置同步服务只保存每个用户的账号状态,并提供登录、拉取、推送接口。它同步的是可迁移的 `cdxs` 账号状态,不同步整个 Codex home。
+277 -17
View File
@@ -8,6 +8,7 @@ use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::auth_file;
@@ -16,6 +17,28 @@ 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<String>,
pub model: Option<String>,
pub provider_name: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct SwitchOptions {
pub model: Option<String>,
pub reasoning_effort: Option<String>,
pub provider_name: Option<String>,
}
#[derive(Debug, Serialize)]
struct AliasRow {
alias: String,
account_id: String,
email: String,
auth_mode: String,
}
pub fn import_auth(file: Option<PathBuf>, codex_home: Option<PathBuf>, switch: bool) -> Result<()> {
// Store imported credentials in the main cdxs config, even when the source
// auth file came from another CODEX_HOME.
@@ -39,12 +62,20 @@ pub fn import_auth(file: Option<PathBuf>, codex_home: Option<PathBuf>, switch: b
Ok(())
}
pub fn add_api_key(key: String, base_url: Option<String>, switch: bool) -> Result<()> {
pub fn add_api_key(
key: String,
base_url: Option<String>,
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();
if let Some(alias) = account.alias.as_deref() {
ensure_alias_available(&store, alias, &id)?;
}
store.upsert_account(account);
if switch {
store.meta.current_account_id = Some(id.clone());
@@ -92,6 +123,84 @@ pub fn show_accounts(json: bool) -> Result<()> {
print_accounts(&store, json)
}
pub fn list_aliases(json: bool) -> Result<()> {
let home = paths::codex_home(None)?;
let store = Store::load(&home)?;
let mut rows = store
.accounts
.iter()
.filter_map(|account| {
account.alias.as_ref().map(|alias| AliasRow {
alias: alias.clone(),
account_id: account.id.clone(),
email: alias_target_display(account).to_string(),
auth_mode: auth_file::account_auth_mode_name(account).to_string(),
})
})
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.alias
.to_ascii_lowercase()
.cmp(&right.alias.to_ascii_lowercase())
.then_with(|| left.account_id.cmp(&right.account_id))
});
if json {
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
if rows.is_empty() {
println!("没有设置 alias。");
return Ok(());
}
println!("{:<18} {:<22} {:<10} Account", "Alias", "ID", "Mode");
for row in rows {
println!(
"{:<18} {:<22} {:<10} {}",
shorten(&row.alias, 18),
shorten(&row.account_id, 22),
shorten(&row.auth_mode, 10),
row.email
);
}
Ok(())
}
pub fn set_alias(alias: &str, query: &str) -> Result<()> {
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let alias = normalize_alias(alias)?;
let account_id = find_unique_account_id(&store, query)?;
ensure_alias_available(&store, &alias, &account_id)?;
let account = store
.find_account_mut(&account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
let changed = account.alias.as_deref() != Some(alias.as_str());
account.alias = Some(alias.clone());
if changed {
account.updated_at = Utc::now().timestamp();
}
store.save(&home)?;
println!("已设置 alias: {alias} -> {account_id}");
Ok(())
}
pub fn remove_alias(query: &str) -> Result<()> {
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let account_id = find_unique_account_id(&store, query)?;
let account = store
.find_account_mut(&account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
let Some(alias) = account.alias.take() else {
return Err(anyhow!("账号未设置 alias: {account_id}"));
};
account.updated_at = Utc::now().timestamp();
store.save(&home)?;
println!("已删除 alias: {alias} ({account_id})");
Ok(())
}
fn print_accounts(store: &Store, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(&store.accounts)?);
@@ -105,15 +214,10 @@ fn print_accounts(store: &Store, json: bool) -> Result<()> {
print_account_table_row("", "ID", "Email", "Mode", "Plan", "5h", "Week");
print_account_table_border();
let current_account_id = store.meta.current_account_id.as_deref();
let mut accounts = store.accounts.iter().collect::<Vec<_>>();
accounts.sort_by_key(|account| {
if Some(account.id.as_str()) == current_account_id {
0
} else {
1
}
});
for account in accounts {
let mut accounts = store.accounts.iter().enumerate().collect::<Vec<_>>();
accounts
.sort_by_key(|(index, account)| account_list_sort_key(account, *index, current_account_id));
for (_, account) in accounts {
let current = if store.meta.current_account_id.as_deref() == Some(account.id.as_str()) {
"*"
} else {
@@ -122,7 +226,7 @@ fn print_accounts(store: &Store, json: bool) -> Result<()> {
let (primary_quota, secondary_quota) = format_quota_cells(account);
print_account_table_row(
current,
&account.id,
&account_id_display(&account.id),
account_email_display(account),
auth_file::account_auth_mode_name(account),
account_plan_display(account),
@@ -134,6 +238,24 @@ fn print_accounts(store: &Store, json: bool) -> Result<()> {
Ok(())
}
fn account_list_sort_key(
account: &Account,
index: usize,
current_account_id: Option<&str>,
) -> (u8, u8, usize) {
let current_rank = if Some(account.id.as_str()) == current_account_id {
0
} else {
1
};
let mode_rank = if account.auth_mode == AuthMode::ApiKey {
1
} else {
0
};
(current_rank, mode_rank, index)
}
pub fn current_account(json: bool) -> Result<()> {
let home = paths::codex_home(None)?;
let store = Store::load(&home)?;
@@ -192,6 +314,7 @@ pub async fn switch_account(
query: &str,
codex_home: Option<PathBuf>,
apply_fingerprint: bool,
options: SwitchOptions,
) -> Result<()> {
if apply_fingerprint {
eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。");
@@ -202,10 +325,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<PathBuf>, apply_fingerprint: bool) -> Result<()> {
pub async fn switch_auto(
codex_home: Option<PathBuf>,
apply_fingerprint: bool,
options: SwitchOptions,
) -> Result<()> {
if apply_fingerprint {
eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。");
}
@@ -227,6 +355,7 @@ pub async fn switch_auto(codex_home: Option<PathBuf>, 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!(
@@ -411,7 +540,11 @@ fn account_from_auth(auth: auth_file::CodexAuthFile) -> Result<Account> {
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
@@ -475,6 +608,10 @@ fn oauth_account(tokens: Tokens, account_id_hint: Option<String>) -> Result<Acco
id,
email,
auth_mode: AuthMode::Oauth,
alias: None,
model: None,
reasoning_effort: None,
api_provider_name: None,
plan_type,
account_id,
organization_id,
@@ -490,12 +627,19 @@ fn oauth_account(tokens: Tokens, account_id_hint: Option<String>) -> Result<Acco
})
}
fn api_key_account(key: String, base_url: Option<String>) -> Result<Account> {
fn api_key_account(
key: String,
base_url: Option<String>,
options: ApiKeyOptions,
) -> Result<Account> {
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();
@@ -503,6 +647,10 @@ fn api_key_account(key: String, base_url: Option<String>) -> Result<Account> {
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,
@@ -535,7 +683,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<String> {
// 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());
}
@@ -544,7 +696,13 @@ fn find_unique_account_id(store: &Store, query: &str) -> Result<String> {
.accounts
.iter()
.filter(|account| {
account.email.eq_ignore_ascii_case(query)
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()
@@ -575,6 +733,52 @@ fn find_unique_account_id(store: &Store, query: &str) -> Result<String> {
}
}
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
.split_once('_')
.map(|(_, suffix)| suffix.starts_with(query))
.unwrap_or(false)
}
fn account_id_display(id: &str) -> String {
let Some((kind, suffix)) = id.split_once('_') else {
return shorten(id, 12);
};
let short_suffix = suffix.chars().take(6).collect::<String>();
format!("{kind}_{short_suffix}")
}
fn best_auto_switch_account(store: &Store) -> Result<String> {
store
.accounts
@@ -652,6 +856,16 @@ fn account_plan_display(account: &Account) -> &str {
}
fn account_email_display(account: &Account) -> &str {
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
}
}
fn alias_target_display(account: &Account) -> &str {
if account.auth_mode == AuthMode::ApiKey {
api_base_url_display(account.api_base_url.as_deref())
} else {
@@ -774,8 +988,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: {}",
@@ -790,6 +1010,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
@@ -810,6 +1034,42 @@ fn normalize_api_base_url(base_url: Option<String>) -> Option<String> {
.map(|value| value.trim_end_matches('/').to_string())
}
fn normalize_alias(alias: &str) -> Result<String> {
let alias = alias.trim();
if alias.is_empty() {
return Err(anyhow!("alias 不能为空"));
}
Ok(alias.to_string())
}
fn normalize_optional_field(value: Option<String>) -> Option<String> {
value
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn ensure_alias_available(store: &Store, alias: &str, account_id: &str) -> Result<()> {
for account in &store.accounts {
if account.id == account_id {
continue;
}
if account
.alias
.as_deref()
.map(|existing| existing.eq_ignore_ascii_case(alias))
.unwrap_or(false)
{
return Err(anyhow!("alias 已被使用: {alias} -> {}", account.id));
}
if account.id.eq_ignore_ascii_case(alias) || account.email.eq_ignore_ascii_case(alias) {
return Err(anyhow!("alias 与已有账号冲突: {alias} -> {}", account.id));
}
}
Ok(())
}
fn mask_api_key(key: &str) -> String {
let chars = key.chars().collect::<Vec<_>>();
if chars.len() <= 8 {
+72
View File
@@ -45,6 +45,11 @@ pub enum Commands {
)]
account: String,
},
/// Manage account aliases.
Alias {
#[command(subcommand)]
command: AliasCommands,
},
/// Switch Codex auth.json to a saved account.
Switch {
#[arg(
@@ -58,9 +63,17 @@ pub enum Commands {
codex_home: Option<PathBuf>,
#[arg(long)]
apply_fingerprint: bool,
#[arg(long)]
model: Option<String>,
#[arg(long, alias = "reasoning-effort")]
effort: Option<String>,
#[arg(long)]
name: Option<String>,
},
/// Prepare auth, set CODEX_HOME, and execute a command.
Run(RunArgs),
/// Run a minimal Codex exec to refresh Codex-side quota state.
Ping(PingArgs),
/// Refresh and display Codex quota.
Quota {
#[arg(value_name = "ACCOUNT_ID_OR_EMAIL")]
@@ -100,6 +113,17 @@ pub struct LoginArgs {
/// Start OpenAI OAuth login for Codex.
#[command(subcommand)]
pub command: Option<LoginCommands>,
/// Add an API key account directly, for example: cdxs login --api sk-...
#[arg(long, value_name = "KEY")]
pub api: Option<String>,
#[arg(long)]
pub base_url: Option<String>,
#[arg(long)]
pub alias: Option<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub manual: bool,
#[arg(long, default_value_t = 1455)]
@@ -139,6 +163,12 @@ pub enum LoginCommands {
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
alias: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
switch: bool,
},
}
@@ -182,10 +212,36 @@ pub enum AccountCommands {
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
alias: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
switch: bool,
},
}
#[derive(Subcommand)]
pub enum AliasCommands {
/// List account aliases.
List {
#[arg(long)]
json: bool,
},
/// Set or replace an alias for any saved account.
Set {
alias: String,
#[arg(value_name = "ACCOUNT_ID_OR_EMAIL")]
account: String,
},
/// Remove an alias by alias name or account selector.
Remove {
#[arg(value_name = "ALIAS_OR_ACCOUNT")]
alias: String,
},
}
#[derive(Subcommand)]
pub enum HomeCommands {
List {
@@ -341,3 +397,19 @@ pub struct RunArgs {
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
pub command: Vec<String>,
}
#[derive(Args)]
pub struct PingArgs {
#[arg(long, value_name = "ACCOUNT_ID_OR_EMAIL")]
pub account: Vec<String>,
#[arg(long, default_value_t = 5)]
pub concurrency: usize,
#[arg(long)]
pub codex_home: Option<PathBuf>,
#[arg(long, default_value = "gpt-5.4")]
pub model: String,
#[arg(long, default_value = "none")]
pub reasoning_effort: String,
#[arg(long, default_value = "hello")]
pub prompt: String,
}
+73 -28
View File
@@ -14,48 +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("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 !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<Map<String, Value>> {
@@ -127,3 +143,32 @@ fn normalize_base_url(value: &str) -> Option<String> {
Some(value.to_string())
}
}
fn apply_common_model_settings(config: &mut Map<String, Value>, 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<String> {
let value = value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
+19
View File
@@ -56,6 +56,14 @@ pub struct Account {
pub email: String,
pub auth_mode: AuthMode,
#[serde(default)]
pub alias: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub api_provider_name: Option<String>,
#[serde(default)]
pub plan_type: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
@@ -228,6 +236,17 @@ impl Store {
let query_lower = query.to_ascii_lowercase();
self.accounts.iter().find(|account| {
account.id == query
|| account.id.starts_with(query)
|| account
.id
.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
+70 -6
View File
@@ -24,8 +24,8 @@ use anyhow::Result;
use clap::Parser;
use crate::cli::{
AccountCommands, Cli, Commands, HomeCommands, ImportCommands, LoginCommands, ServerCommands,
ServerUserCommands, SessionCommands, SessionVisibilityCommands, SyncCommands,
AccountCommands, AliasCommands, Cli, Commands, HomeCommands, ImportCommands, LoginCommands,
ServerCommands, ServerUserCommands, SessionCommands, SessionVisibilityCommands, SyncCommands,
};
#[tokio::main]
@@ -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 {
@@ -61,14 +88,27 @@ async fn main() -> Result<()> {
Commands::Pull { force } => sync_client::pull(force).await,
Commands::Push { force } => sync_client::push(force).await,
Commands::Remove { account } => account::remove_account(&account),
Commands::Alias { command } => match command {
AliasCommands::List { json } => account::list_aliases(json),
AliasCommands::Set { alias, account } => account::set_alias(&alias, &account),
AliasCommands::Remove { alias } => account::remove_alias(&alias),
},
Commands::Switch {
account,
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 +116,7 @@ async fn main() -> Result<()> {
})?,
codex_home,
apply_fingerprint,
options,
)
.await
}
@@ -89,6 +130,17 @@ async fn main() -> Result<()> {
)
.await
}
Commands::Ping(args) => {
run_cmd::ping_codex(
args.account,
args.concurrency,
args.codex_home,
args.model,
args.reasoning_effort,
args.prompt,
)
.await
}
Commands::Quota { accounts, json } => quota::quota_command(accounts, json).await,
Commands::Account { command } => match command {
AccountCommands::List { json, force } => account::list_accounts(json, force).await,
@@ -98,8 +150,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),
+166 -2
View File
@@ -1,11 +1,15 @@
//! Execute a child command with a prepared Codex authentication context.
use std::path::PathBuf;
use std::process::Command;
use std::process::{Command, Stdio};
use anyhow::{anyhow, Context, Result};
use crate::{account, paths};
use crate::{
account,
config_store::{AuthMode, Store},
paths,
};
pub async fn run_with_account_or_home(
account_query: Option<String>,
@@ -46,3 +50,163 @@ pub async fn run_with_account_or_home(
}
Ok(())
}
pub async fn ping_codex(
account_queries: Vec<String>,
concurrency: usize,
codex_home: Option<PathBuf>,
model: String,
reasoning_effort: String,
prompt: String,
) -> Result<()> {
ping_codex_many(
account_queries,
concurrency,
codex_home,
model,
reasoning_effort,
prompt,
)
.await
}
async fn ping_codex_many(
account_queries: Vec<String>,
concurrency: usize,
codex_home: Option<PathBuf>,
model: String,
reasoning_effort: String,
prompt: String,
) -> Result<()> {
let main_home = paths::codex_home(None)?;
let base_home = paths::codex_home(codex_home)?;
let store = Store::load(&main_home)?;
let account_ids = if account_queries.is_empty() {
store
.accounts
.iter()
.filter(|account| account.auth_mode == AuthMode::Oauth)
.map(|account| account.id.clone())
.collect::<Vec<_>>()
} else {
account_queries
.iter()
.map(|query| {
store
.find_account(query)
.ok_or_else(|| anyhow!("账号不存在: {query}"))
.map(|account| account.id.clone())
})
.collect::<Result<Vec<_>>>()?
};
if account_ids.is_empty() {
return Err(anyhow!("没有可 ping 的 OAuth 账号"));
}
let mut jobs = Vec::new();
for account_id in &account_ids {
let ping_home = base_home
.join("cdxs-ping")
.join(safe_path_component(account_id));
account::prepare_account_in_home(account_id, ping_home.clone()).await?;
jobs.push((account_id.clone(), ping_home));
}
let concurrency = concurrency.max(1);
let mut failures = Vec::new();
for chunk in jobs.chunks(concurrency) {
let mut handles = Vec::new();
for (account_id, ping_home) in chunk.iter().cloned() {
let model = model.clone();
let reasoning_effort = reasoning_effort.clone();
let prompt = prompt.clone();
handles.push(tokio::spawn(async move {
let result = run_codex_ping(ping_home, model, reasoning_effort, prompt, true).await;
(account_id, result)
}));
}
for handle in handles {
match handle.await {
Ok((account_id, Ok(()))) => println!("ping ok: {account_id}"),
Ok((account_id, Err(error))) => {
eprintln!("ping failed: {account_id}: {error}");
failures.push(account_id);
}
Err(error) => {
eprintln!("ping task failed: {error}");
failures.push("<unknown>".to_string());
}
}
}
}
if !failures.is_empty() {
return Err(anyhow!("部分账号 ping 失败: {} 个", failures.len()));
}
Ok(())
}
async fn run_codex_ping(
codex_home: PathBuf,
model: String,
reasoning_effort: String,
prompt: String,
quiet: bool,
) -> Result<()> {
let command_args = vec![
"codex".to_string(),
"exec".to_string(),
"--ignore-user-config".to_string(),
"-c".to_string(),
format!("model_reasoning_effort=\"{reasoning_effort}\""),
"--model".to_string(),
model,
"--skip-git-repo-check".to_string(),
prompt,
];
let status = tokio::task::spawn_blocking(move || {
let mut child = codex_exec_command(&command_args);
child.env("CODEX_HOME", &codex_home);
if quiet {
child.stdout(Stdio::null()).stderr(Stdio::null());
}
child
.status()
.with_context(|| format!("启动命令失败: {}", command_args.join(" ")))
})
.await
.context("ping task join failed")??;
if !status.success() {
return Err(anyhow!("命令退出失败: status={status}"));
}
Ok(())
}
fn safe_path_component(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
ch
} else {
'_'
}
})
.collect()
}
fn codex_exec_command(args: &[String]) -> Command {
#[cfg(windows)]
{
let mut command = Command::new("cmd.exe");
command.arg("/C").args(args);
command
}
#[cfg(not(windows))]
{
let mut command = Command::new(&args[0]);
command.args(&args[1..]);
command
}
}
+14 -4
View File
@@ -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();