Compare commits
3 Commits
@@ -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
@@ -232,7 +232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cdxs"
|
||||
version = "0.1.3"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cdxs"
|
||||
version = "0.1.3"
|
||||
version = "0.1.5"
|
||||
edition = "2021"
|
||||
description = "Codex account switcher CLI"
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ Codex home 的解析顺序:
|
||||
- `cdxs show`:列出账号,但不请求配额接口。
|
||||
- `cdxs switch`:不带参数时自动选择可用配额最优的账号。
|
||||
- `cdxs switch <账号>`:切换到指定账号。
|
||||
- `cdxs ping`:以 5 并发 ping 所有 OAuth 账号,运行最低 reasoning 的最小 `codex exec`,触发 Codex 侧额度状态刷新。
|
||||
- `cdxs ping --account <账号>`:指定账号运行最小 `codex exec`;可重复传多个账号。
|
||||
- `cdxs login api --key <key> --base-url <url> --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。
|
||||
- `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`。
|
||||
- `cdxs pull`:从同步服务拉取账号状态,等价于 `cdxs sync pull`。
|
||||
|
||||
+41
-11
@@ -105,15 +105,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 +117,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 +129,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)?;
|
||||
@@ -544,7 +557,8 @@ 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
|
||||
.email
|
||||
.to_ascii_lowercase()
|
||||
@@ -575,6 +589,22 @@ fn find_unique_account_id(store: &Store, query: &str) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+18
@@ -61,6 +61,8 @@ pub enum Commands {
|
||||
},
|
||||
/// 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")]
|
||||
@@ -341,3 +343,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,
|
||||
}
|
||||
|
||||
+5
-1
@@ -33,8 +33,12 @@ fn apply_api_key_provider(codex_home: &Path, account: &Account) -> Result<()> {
|
||||
|
||||
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("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;
|
||||
|
||||
@@ -228,6 +228,12 @@ 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.email.eq_ignore_ascii_case(query)
|
||||
|| account
|
||||
.email
|
||||
|
||||
+11
@@ -89,6 +89,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,
|
||||
|
||||
+166
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user