Compare commits
16 Commits
+48
-134
@@ -2,13 +2,20 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- ".github/workflows/release.yml"
|
||||
- "scripts/publish.ps1"
|
||||
- "scripts/publish.sh"
|
||||
- "scripts/docker/Dockerfile.release"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
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
|
||||
|
||||
jobs:
|
||||
@@ -28,14 +35,21 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
current_tag="$(git describe --tags --exact-match)"
|
||||
if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
|
||||
current_tag="${GITHUB_REF_NAME}"
|
||||
else
|
||||
current_tag="$(git describe --tags --abbrev=0)"
|
||||
fi
|
||||
version="${current_tag#v}"
|
||||
tag_target="$(git rev-list -n 1 "${current_tag}^{}")"
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${current_tag}^{}^" 2>/dev/null || true)"
|
||||
api_base="${GITEA_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
{
|
||||
echo "## Changes"
|
||||
echo
|
||||
echo "Built from commit: \`${GITHUB_SHA}\`"
|
||||
echo
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
echo "Changes since \`${previous_tag}\`:"
|
||||
echo
|
||||
@@ -56,7 +70,7 @@ jobs:
|
||||
|
||||
body="$(python3 -c 'import json, pathlib; print(json.dumps(pathlib.Path("RELEASE_NOTES.md").read_text()))')"
|
||||
payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":%s,"draft":false,"prerelease":false}' \
|
||||
"${current_tag}" "${GITHUB_SHA}" "${current_tag}" "${body}")"
|
||||
"${current_tag}" "${tag_target}" "${current_tag}" "${body}")"
|
||||
|
||||
status="$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
@@ -82,155 +96,55 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-windows-x64:
|
||||
name: Build windows-x64
|
||||
runs-on: windows-latest
|
||||
needs: prepare-release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust target
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.cargo" | Out-Null
|
||||
@"
|
||||
[source.crates-io]
|
||||
replace-with = "mirror"
|
||||
|
||||
[source.mirror]
|
||||
registry = "$env:CARGO_REGISTRY"
|
||||
"@ | Set-Content -Path "$env:USERPROFILE\.cargo\config.toml" -Encoding utf8
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
|
||||
- name: Build release binary
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = git describe --tags --exact-match
|
||||
$version = $tag -replace '^v', ''
|
||||
./scripts/publish-binaries.ps1 -Version $version -Target win -Clean
|
||||
|
||||
- name: Upload release asset
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
$tag = git describe --tags --exact-match
|
||||
$release = Invoke-RestMethod `
|
||||
-Headers @{ Authorization = "token $env:GITEA_TOKEN" } `
|
||||
-Uri "$env:GITEA_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY/releases/tags/$tag"
|
||||
$file = Get-ChildItem -Path dist -File | Select-Object -First 1
|
||||
curl.exe -fsS -X POST `
|
||||
-H "Authorization: token $env:GITEA_TOKEN" `
|
||||
-F "attachment=@$($file.FullName)" `
|
||||
"$env:GITEA_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY/releases/$($release.id)/assets?name=$($file.Name)"
|
||||
|
||||
build-linux-x64:
|
||||
name: Build linux-x64
|
||||
build-release-assets:
|
||||
name: Build release assets
|
||||
runs-on: ubuntu-latest
|
||||
needs: prepare-release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build release binary
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag="$(git describe --tags --exact-match)"
|
||||
version="${tag#v}"
|
||||
target_name="linux-x64"
|
||||
rust_target="x86_64-unknown-linux-gnu"
|
||||
platform="linux/amd64"
|
||||
image_tag="cdxs-build:${target_name}-${GITHUB_RUN_ID:-manual}"
|
||||
container_name="cdxs-build-${target_name}-${GITHUB_RUN_ID:-manual}"
|
||||
if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
|
||||
release_tag="${GITHUB_REF_NAME}"
|
||||
else
|
||||
release_tag="$(git describe --tags --abbrev=0)"
|
||||
fi
|
||||
echo "RELEASE_TAG=${release_tag}" >> "${GITHUB_ENV}"
|
||||
echo "VERSION=${release_tag#v}" >> "${GITHUB_ENV}"
|
||||
|
||||
mkdir -p dist
|
||||
docker buildx build \
|
||||
--platform "${platform}" \
|
||||
--target builder \
|
||||
--build-arg "RUST_TARGET=${rust_target}" \
|
||||
--build-arg "RUST_IMAGE=${RUST_IMAGE}" \
|
||||
--build-arg "CARGO_REGISTRY=${CARGO_REGISTRY}" \
|
||||
--load \
|
||||
-t "${image_tag}" \
|
||||
-f scripts/docker/Dockerfile.release \
|
||||
.
|
||||
- name: Build release assets
|
||||
shell: bash
|
||||
run: bash scripts/publish.sh --version "${VERSION}" --rust-image "${RUST_IMAGE}" --apt-mirror "${APT_MIRROR}" --clean
|
||||
|
||||
container_id="$(docker create --name "${container_name}" "${image_tag}")"
|
||||
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' EXIT
|
||||
docker cp "${container_id}:/out/cdxs" "dist/cdxs-${version}-${target_name}"
|
||||
|
||||
- name: Upload release asset
|
||||
- name: Upload release assets
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag="$(git describe --tags --exact-match)"
|
||||
api_base="${GITEA_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/tags/${tag}" > release.json
|
||||
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/tags/${RELEASE_TAG}" > release.json
|
||||
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
|
||||
file="$(find dist -maxdepth 1 -type f | head -n 1)"
|
||||
name="$(basename "${file}")"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
"${api_base}/releases/${release_id}/assets?name=${name}"
|
||||
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/${release_id}/assets" > assets.json
|
||||
|
||||
build-linux-arm64:
|
||||
name: Build linux-arm64
|
||||
runs-on: ubuntu-latest
|
||||
needs: prepare-release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build release binary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag="$(git describe --tags --exact-match)"
|
||||
version="${tag#v}"
|
||||
target_name="linux-arm64"
|
||||
rust_target="aarch64-unknown-linux-gnu"
|
||||
platform="linux/arm64"
|
||||
image_tag="cdxs-build:${target_name}-${GITHUB_RUN_ID:-manual}"
|
||||
container_name="cdxs-build-${target_name}-${GITHUB_RUN_ID:-manual}"
|
||||
|
||||
mkdir -p dist
|
||||
docker buildx build \
|
||||
--platform "${platform}" \
|
||||
--target builder \
|
||||
--build-arg "RUST_TARGET=${rust_target}" \
|
||||
--build-arg "RUST_IMAGE=${RUST_IMAGE}" \
|
||||
--build-arg "CARGO_REGISTRY=${CARGO_REGISTRY}" \
|
||||
--load \
|
||||
-t "${image_tag}" \
|
||||
-f scripts/docker/Dockerfile.release \
|
||||
.
|
||||
|
||||
container_id="$(docker create --name "${container_name}" "${image_tag}")"
|
||||
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' EXIT
|
||||
docker cp "${container_id}:/out/cdxs" "dist/cdxs-${version}-${target_name}"
|
||||
|
||||
- name: Upload release asset
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag="$(git describe --tags --exact-match)"
|
||||
api_base="${GITEA_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/tags/${tag}" > release.json
|
||||
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
|
||||
file="$(find dist -maxdepth 1 -type f | head -n 1)"
|
||||
name="$(basename "${file}")"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
"${api_base}/releases/${release_id}/assets?name=${name}"
|
||||
for file in dist/*; do
|
||||
name="$(basename "${file}")"
|
||||
existing_asset_id="$(python3 -c 'import json, sys; target = sys.argv[1]; print(next((str(asset["id"]) for asset in json.load(open("assets.json")) if asset.get("name") == target), ""))' "${name}")"
|
||||
if [[ -n "${existing_asset_id}" ]]; then
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${api_base}/releases/${release_id}/assets/${existing_asset_id}"
|
||||
fi
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
"${api_base}/releases/${release_id}/assets?name=${name}"
|
||||
done
|
||||
|
||||
Generated
+1
-1
@@ -232,7 +232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cdxs"
|
||||
version = "0.1.2"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cdxs"
|
||||
version = "0.1.2"
|
||||
version = "0.1.7"
|
||||
edition = "2021"
|
||||
description = "Codex account switcher CLI"
|
||||
|
||||
|
||||
@@ -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`。
|
||||
@@ -41,12 +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 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。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -3,26 +3,62 @@ FROM --platform=$BUILDPLATFORM ${RUST_IMAGE} AS builder
|
||||
|
||||
ARG RUST_TARGET=x86_64-unknown-linux-gnu
|
||||
ARG CARGO_REGISTRY=sparse+https://rsproxy.cn/index/
|
||||
ARG APT_MIRROR=https://mirrors.ustc.edu.cn/debian
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p /usr/local/cargo \
|
||||
&& printf '[source.crates-io]\nreplace-with = "mirror"\n\n[source.mirror]\nregistry = "%s"\n' "${CARGO_REGISTRY}" > /usr/local/cargo/config.toml
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
gcc-aarch64-linux-gnu \
|
||||
libc6-dev-arm64-cross \
|
||||
RUN find /etc/apt/sources.list.d -type f \( -name "*.list" -o -name "*.sources" \) -print0 2>/dev/null \
|
||||
| xargs -0 -r sed -i \
|
||||
-e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|https://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|https://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|https://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|https://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
-e "s|https://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
&& if [ -f /etc/apt/sources.list ]; then \
|
||||
sed -i \
|
||||
-e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|https://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|https://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|https://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|https://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
-e "s|https://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
/etc/apt/sources.list; \
|
||||
fi \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& case "${RUST_TARGET}" in \
|
||||
aarch64-unknown-linux-gnu) \
|
||||
apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libc6-dev-arm64-cross ;; \
|
||||
x86_64-pc-windows-gnu) \
|
||||
apt-get install -y --no-install-recommends binutils-mingw-w64-x86-64 gcc-mingw-w64-x86-64-posix mingw-w64-x86-64-dev ;; \
|
||||
esac \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN rustup target add ${RUST_TARGET}
|
||||
|
||||
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
||||
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
||||
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_AR=x86_64-w64-mingw32-ar
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
|
||||
RUN cargo build --release --target ${RUST_TARGET} \
|
||||
&& mkdir -p /out \
|
||||
&& cp target/${RUST_TARGET}/release/cdxs /out/cdxs
|
||||
&& if [ "${RUST_TARGET}" = "x86_64-pc-windows-gnu" ]; then \
|
||||
cp target/${RUST_TARGET}/release/cdxs.exe /out/cdxs.exe; \
|
||||
else \
|
||||
cp target/${RUST_TARGET}/release/cdxs /out/cdxs; \
|
||||
fi
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[string]$OutputDir = "dist",
|
||||
[ValidateSet("all", "win", "linux-x64", "linux-arm64")]
|
||||
[string]$Target = "all",
|
||||
[string]$RustImage = "rust:1-bookworm",
|
||||
[switch]$SkipDockerLinux,
|
||||
[switch]$Clean
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not $Version) {
|
||||
$cargoToml = Get-Content -Raw -Path "Cargo.toml"
|
||||
if ($cargoToml -notmatch '(?m)^version\s*=\s*"([^"]+)"') {
|
||||
throw "Cannot read package version from Cargo.toml"
|
||||
}
|
||||
$Version = $Matches[1]
|
||||
}
|
||||
|
||||
$dist = Join-Path $repoRoot $OutputDir
|
||||
if ($Clean -and (Test-Path $dist)) {
|
||||
Remove-Item -LiteralPath $dist -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||
|
||||
if ($SkipDockerLinux) {
|
||||
$Target = "win"
|
||||
}
|
||||
|
||||
function Copy-ReleaseFile {
|
||||
param(
|
||||
[string]$SourceFile,
|
||||
[string]$OutputName
|
||||
)
|
||||
|
||||
if (-not (Test-Path $SourceFile)) {
|
||||
throw "Missing release binary: $SourceFile"
|
||||
}
|
||||
|
||||
$outputPath = Join-Path $dist $OutputName
|
||||
Copy-Item -LiteralPath $SourceFile -Destination $outputPath -Force
|
||||
Write-Host "Wrote $outputPath"
|
||||
}
|
||||
|
||||
if ($Target -eq "all" -or $Target -eq "win") {
|
||||
Write-Host "Building Windows x64"
|
||||
cargo build --release --target x86_64-pc-windows-msvc
|
||||
Copy-ReleaseFile `
|
||||
-SourceFile (Join-Path $repoRoot "target/x86_64-pc-windows-msvc/release/cdxs.exe") `
|
||||
-OutputName "cdxs-$Version-windows-x64.exe"
|
||||
}
|
||||
|
||||
$linuxTargets = @(
|
||||
@{ Name = "linux-x64"; RustTarget = "x86_64-unknown-linux-gnu"; Platform = "linux/amd64" },
|
||||
@{ Name = "linux-arm64"; RustTarget = "aarch64-unknown-linux-gnu"; Platform = "linux/arm64" }
|
||||
) | Where-Object { $Target -eq "all" -or $Target -eq $_.Name }
|
||||
|
||||
foreach ($linuxTarget in $linuxTargets) {
|
||||
$containerName = "cdxs-build-$($linuxTarget.Name)-$([guid]::NewGuid().ToString('N'))"
|
||||
$imageTag = "cdxs-build:$($linuxTarget.Name)"
|
||||
$tmpOutDir = Join-Path $dist ".tmp-$($linuxTarget.Name)"
|
||||
New-Item -ItemType Directory -Force -Path $tmpOutDir | Out-Null
|
||||
|
||||
Write-Host "Building $($linuxTarget.Name) with Docker platform $($linuxTarget.Platform)"
|
||||
$buildArgs = @(
|
||||
"buildx", "build",
|
||||
"--platform", $linuxTarget.Platform,
|
||||
"--target", "builder",
|
||||
"--build-arg", "RUST_TARGET=$($linuxTarget.RustTarget)",
|
||||
"--build-arg", "RUST_IMAGE=$RustImage",
|
||||
"--load",
|
||||
"-t", $imageTag,
|
||||
"-f", "scripts/docker/Dockerfile.release",
|
||||
"."
|
||||
)
|
||||
docker @buildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker build failed for $($linuxTarget.Name)"
|
||||
}
|
||||
docker image inspect $imageTag | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker image was not created: $imageTag"
|
||||
}
|
||||
$containerId = docker create --name $containerName $imageTag
|
||||
if ($LASTEXITCODE -ne 0 -or -not $containerId) {
|
||||
throw "Docker create failed for $imageTag"
|
||||
}
|
||||
try {
|
||||
docker cp "${containerName}:/out/cdxs" (Join-Path $tmpOutDir "cdxs")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker cp failed for $($linuxTarget.Name)"
|
||||
}
|
||||
} finally {
|
||||
docker rm -f $containerName | Out-Null
|
||||
}
|
||||
|
||||
Copy-ReleaseFile `
|
||||
-SourceFile (Join-Path $tmpOutDir "cdxs") `
|
||||
-OutputName "cdxs-$Version-$($linuxTarget.Name)"
|
||||
Remove-Item -LiteralPath $tmpOutDir -Recurse -Force
|
||||
}
|
||||
|
||||
Write-Host "Binary release artifacts are in $dist"
|
||||
@@ -1,48 +0,0 @@
|
||||
param(
|
||||
[string]$Registry = "docker.pchuan.top",
|
||||
[string]$ImageName = "cdxs",
|
||||
[string]$Tag = "",
|
||||
[switch]$NoLatest,
|
||||
[string]$Platform = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not $Tag) {
|
||||
$cargoToml = Get-Content -Raw -Path "Cargo.toml"
|
||||
if ($cargoToml -notmatch '(?m)^version\s*=\s*"([^"]+)"') {
|
||||
throw "Cannot read package version from Cargo.toml"
|
||||
}
|
||||
$Tag = $Matches[1]
|
||||
}
|
||||
|
||||
$image = "$Registry/$ImageName"
|
||||
$versionTag = "${image}:$Tag"
|
||||
$latestTag = "${image}:latest"
|
||||
|
||||
Write-Host "Building $versionTag"
|
||||
$buildArgs = @("build", "-t", $versionTag)
|
||||
if (-not $NoLatest) {
|
||||
$buildArgs += @("-t", $latestTag)
|
||||
}
|
||||
if ($Platform) {
|
||||
$buildArgs += @("--platform", $Platform)
|
||||
}
|
||||
$buildArgs += "."
|
||||
docker @buildArgs
|
||||
|
||||
Write-Host "Pushing $versionTag"
|
||||
docker push $versionTag
|
||||
|
||||
if (-not $NoLatest) {
|
||||
Write-Host "Pushing $latestTag"
|
||||
docker push $latestTag
|
||||
}
|
||||
|
||||
Write-Host "Published $versionTag"
|
||||
if (-not $NoLatest) {
|
||||
Write-Host "Published $latestTag"
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[string]$OutputDir = "dist",
|
||||
[ValidateSet("all", "win", "linux-x64", "linux-arm64")]
|
||||
[string]$Target = "all",
|
||||
[string]$RustImage = "docker.m.daocloud.io/library/rust:1-bookworm",
|
||||
[string]$CargoRegistry = "sparse+https://rsproxy.cn/index/",
|
||||
[string]$AptMirror = "https://mirrors.ustc.edu.cn/debian",
|
||||
[switch]$DockerWindows,
|
||||
[switch]$Clean
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not $Version) {
|
||||
$cargoToml = Get-Content -Raw -Path "Cargo.toml"
|
||||
if ($cargoToml -notmatch '(?m)^version\s*=\s*"([^"]+)"') {
|
||||
throw "Cannot read package version from Cargo.toml"
|
||||
}
|
||||
$Version = $Matches[1]
|
||||
}
|
||||
|
||||
$dist = Join-Path $repoRoot $OutputDir
|
||||
if ($Clean -and (Test-Path $dist)) {
|
||||
Remove-Item -LiteralPath $dist -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||
|
||||
function Copy-ReleaseFile {
|
||||
param(
|
||||
[string]$SourceFile,
|
||||
[string]$OutputName
|
||||
)
|
||||
|
||||
if (-not (Test-Path $SourceFile)) {
|
||||
throw "Missing release binary: $SourceFile"
|
||||
}
|
||||
|
||||
$outputPath = Join-Path $dist $OutputName
|
||||
Copy-Item -LiteralPath $SourceFile -Destination $outputPath -Force
|
||||
Write-Host "Wrote $outputPath"
|
||||
}
|
||||
|
||||
function Build-DockerRelease {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string]$RustTarget,
|
||||
[string]$Platform,
|
||||
[string]$ContainerBinary = "cdxs",
|
||||
[string]$OutputName = "cdxs-$Version-$Name"
|
||||
)
|
||||
|
||||
$safeRunId = if ($env:GITHUB_RUN_ID) { $env:GITHUB_RUN_ID } else { [guid]::NewGuid().ToString("N") }
|
||||
$imageTag = "cdxs-build:$Name-$safeRunId"
|
||||
$containerName = "cdxs-build-$Name-$safeRunId"
|
||||
|
||||
Write-Host "Building $Name with Docker platform $Platform"
|
||||
docker buildx build `
|
||||
--platform $Platform `
|
||||
--target builder `
|
||||
--build-arg "RUST_TARGET=$RustTarget" `
|
||||
--build-arg "RUST_IMAGE=$RustImage" `
|
||||
--build-arg "CARGO_REGISTRY=$CargoRegistry" `
|
||||
--build-arg "APT_MIRROR=$AptMirror" `
|
||||
--load `
|
||||
-t $imageTag `
|
||||
-f "scripts/docker/Dockerfile.release" `
|
||||
.
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker build failed for $Name"
|
||||
}
|
||||
|
||||
$containerId = docker create --name $containerName $imageTag
|
||||
if ($LASTEXITCODE -ne 0 -or -not $containerId) {
|
||||
throw "Docker create failed for $imageTag"
|
||||
}
|
||||
|
||||
try {
|
||||
$tmpOutDir = Join-Path $dist ".tmp-$Name"
|
||||
New-Item -ItemType Directory -Force -Path $tmpOutDir | Out-Null
|
||||
docker cp "${containerName}:/out/$ContainerBinary" (Join-Path $tmpOutDir $ContainerBinary)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker cp failed for $Name"
|
||||
}
|
||||
Copy-ReleaseFile `
|
||||
-SourceFile (Join-Path $tmpOutDir $ContainerBinary) `
|
||||
-OutputName $OutputName
|
||||
} finally {
|
||||
docker rm -f $containerName | Out-Null
|
||||
if (Test-Path $tmpOutDir) {
|
||||
Remove-Item -LiteralPath $tmpOutDir -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($Target -eq "all" -or $Target -eq "win") {
|
||||
if ($DockerWindows) {
|
||||
Build-DockerRelease `
|
||||
-Name "windows-x64" `
|
||||
-RustTarget "x86_64-pc-windows-gnu" `
|
||||
-Platform "linux/amd64" `
|
||||
-ContainerBinary "cdxs.exe" `
|
||||
-OutputName "cdxs-$Version-windows-x64.exe"
|
||||
} else {
|
||||
Write-Host "Building Windows x64"
|
||||
cargo build --release --target x86_64-pc-windows-msvc
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Windows build failed"
|
||||
}
|
||||
Copy-ReleaseFile `
|
||||
-SourceFile (Join-Path $repoRoot "target/x86_64-pc-windows-msvc/release/cdxs.exe") `
|
||||
-OutputName "cdxs-$Version-windows-x64.exe"
|
||||
}
|
||||
}
|
||||
|
||||
if ($Target -eq "all" -or $Target -eq "linux-x64") {
|
||||
Build-DockerRelease `
|
||||
-Name "linux-x64" `
|
||||
-RustTarget "x86_64-unknown-linux-gnu" `
|
||||
-Platform "linux/amd64"
|
||||
}
|
||||
|
||||
if ($Target -eq "all" -or $Target -eq "linux-arm64") {
|
||||
Build-DockerRelease `
|
||||
-Name "linux-arm64" `
|
||||
-RustTarget "aarch64-unknown-linux-gnu" `
|
||||
-Platform "linux/arm64"
|
||||
}
|
||||
|
||||
Write-Host "Release artifacts are in $dist"
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
version=""
|
||||
output_dir="dist"
|
||||
target="all"
|
||||
rust_image="${RUST_IMAGE:-docker.m.daocloud.io/library/rust:1-bookworm}"
|
||||
cargo_registry="${CARGO_REGISTRY:-sparse+https://rsproxy.cn/index/}"
|
||||
apt_mirror="${APT_MIRROR:-https://mirrors.ustc.edu.cn/debian}"
|
||||
clean=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/publish.sh [options]
|
||||
|
||||
Build release binaries with Docker buildx.
|
||||
|
||||
Options:
|
||||
--version VERSION Version used in output filenames.
|
||||
--output-dir DIR Output directory. Default: dist
|
||||
--target TARGET all, win, linux-x64, linux-arm64. Default: all
|
||||
--rust-image IMAGE Rust Docker image.
|
||||
--cargo-registry URL Cargo registry mirror.
|
||||
--apt-mirror URL Debian apt mirror.
|
||||
--clean Remove output directory before building.
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--version)
|
||||
version="${2:?missing value for --version}"
|
||||
shift 2
|
||||
;;
|
||||
--output-dir)
|
||||
output_dir="${2:?missing value for --output-dir}"
|
||||
shift 2
|
||||
;;
|
||||
--target)
|
||||
target="${2:?missing value for --target}"
|
||||
shift 2
|
||||
;;
|
||||
--rust-image)
|
||||
rust_image="${2:?missing value for --rust-image}"
|
||||
shift 2
|
||||
;;
|
||||
--cargo-registry)
|
||||
cargo_registry="${2:?missing value for --cargo-registry}"
|
||||
shift 2
|
||||
;;
|
||||
--apt-mirror)
|
||||
apt_mirror="${2:?missing value for --apt-mirror}"
|
||||
shift 2
|
||||
;;
|
||||
--clean)
|
||||
clean=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "${target}" in
|
||||
all|win|linux-x64|linux-arm64) ;;
|
||||
*)
|
||||
echo "Invalid --target: ${target}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
if [[ -z "${version}" ]]; then
|
||||
version="$(sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
|
||||
if [[ -z "${version}" ]]; then
|
||||
echo "Cannot read package version from Cargo.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${clean}" -eq 1 ]]; then
|
||||
rm -rf "${output_dir}"
|
||||
fi
|
||||
mkdir -p "${output_dir}"
|
||||
|
||||
build_asset() {
|
||||
local target_name="$1"
|
||||
local rust_target="$2"
|
||||
local platform="$3"
|
||||
local container_binary="$4"
|
||||
local output_name="$5"
|
||||
local run_id="${GITHUB_RUN_ID:-$(date +%s)-$$}"
|
||||
local image_tag="cdxs-build:${target_name}-${run_id}"
|
||||
local container_name="cdxs-build-${target_name}-${run_id}"
|
||||
|
||||
echo "Building ${target_name} with Docker platform ${platform}"
|
||||
|
||||
local proxy_build_args=()
|
||||
local proxy_var
|
||||
for proxy_var in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy; do
|
||||
if [[ -n "${!proxy_var:-}" ]]; then
|
||||
proxy_build_args+=(--build-arg "${proxy_var}=${!proxy_var}")
|
||||
fi
|
||||
done
|
||||
|
||||
docker buildx build \
|
||||
--platform "${platform}" \
|
||||
--target builder \
|
||||
--build-arg "RUST_TARGET=${rust_target}" \
|
||||
--build-arg "RUST_IMAGE=${rust_image}" \
|
||||
--build-arg "CARGO_REGISTRY=${cargo_registry}" \
|
||||
--build-arg "APT_MIRROR=${apt_mirror}" \
|
||||
"${proxy_build_args[@]}" \
|
||||
--load \
|
||||
-t "${image_tag}" \
|
||||
-f scripts/docker/Dockerfile.release \
|
||||
.
|
||||
|
||||
local container_id
|
||||
container_id="$(docker create --name "${container_name}" "${image_tag}")"
|
||||
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' RETURN
|
||||
docker cp "${container_id}:/out/${container_binary}" "${output_dir}/${output_name}"
|
||||
docker rm -f "${container_name}" >/dev/null
|
||||
trap - RETURN
|
||||
echo "Wrote ${output_dir}/${output_name}"
|
||||
}
|
||||
|
||||
if [[ "${target}" == "all" || "${target}" == "win" ]]; then
|
||||
build_asset windows-x64 x86_64-pc-windows-gnu linux/amd64 cdxs.exe "cdxs-${version}-windows-x64.exe"
|
||||
fi
|
||||
|
||||
if [[ "${target}" == "all" || "${target}" == "linux-x64" ]]; then
|
||||
build_asset linux-x64 x86_64-unknown-linux-gnu linux/amd64 cdxs "cdxs-${version}-linux-x64"
|
||||
fi
|
||||
|
||||
if [[ "${target}" == "all" || "${target}" == "linux-arm64" ]]; then
|
||||
build_asset linux-arm64 aarch64-unknown-linux-gnu linux/arm64 cdxs "cdxs-${version}-linux-arm64"
|
||||
fi
|
||||
|
||||
echo "Release artifacts are in ${output_dir}"
|
||||
+356
-27
@@ -8,11 +8,36 @@ use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
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";
|
||||
|
||||
#[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
|
||||
@@ -30,23 +55,33 @@ pub fn import_auth(file: Option<PathBuf>, codex_home: Option<PathBuf>, 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})");
|
||||
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());
|
||||
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})");
|
||||
@@ -88,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)?);
|
||||
@@ -101,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 {
|
||||
@@ -118,10 +226,10 @@ 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.email,
|
||||
&account_id_display(&account.id),
|
||||
account_email_display(account),
|
||||
auth_file::account_auth_mode_name(account),
|
||||
account.plan_type.as_deref().unwrap_or("-"),
|
||||
account_plan_display(account),
|
||||
&primary_quota,
|
||||
&secondary_quota,
|
||||
);
|
||||
@@ -130,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)?;
|
||||
@@ -188,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。");
|
||||
@@ -198,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。");
|
||||
}
|
||||
@@ -223,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!(
|
||||
@@ -247,6 +380,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 +439,7 @@ pub fn create_home(name: &str, path: PathBuf, account: Option<String>) -> 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 +512,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) {
|
||||
@@ -404,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
|
||||
@@ -468,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,
|
||||
@@ -483,19 +627,31 @@ 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 = 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()),
|
||||
alias,
|
||||
model,
|
||||
reasoning_effort: None,
|
||||
api_provider_name: provider_name,
|
||||
plan_type: None,
|
||||
account_id: None,
|
||||
organization_id: None,
|
||||
tokens: None,
|
||||
@@ -527,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());
|
||||
}
|
||||
|
||||
@@ -536,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()
|
||||
@@ -567,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
|
||||
@@ -633,6 +845,38 @@ fn format_quota(account: &Account) -> String {
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn account_plan_display(account: &Account) -> &str {
|
||||
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 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 {
|
||||
&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());
|
||||
@@ -702,7 +946,7 @@ fn print_account_table_border() {
|
||||
"-".repeat(3),
|
||||
"-".repeat(24),
|
||||
"-".repeat(30),
|
||||
"-".repeat(8),
|
||||
"-".repeat(10),
|
||||
"-".repeat(8),
|
||||
"-".repeat(14),
|
||||
"-".repeat(14)
|
||||
@@ -719,11 +963,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)
|
||||
@@ -744,9 +988,15 @@ fn shorten(value: &str, width: usize) -> String {
|
||||
|
||||
fn print_account(account: &Account) {
|
||||
println!("id: {}", account.id);
|
||||
println!("email: {}", account.email);
|
||||
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!("plan_type: {}", account.plan_type.as_deref().unwrap_or("-"));
|
||||
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: {}",
|
||||
account.account_id.as_deref().unwrap_or("-")
|
||||
@@ -755,5 +1005,84 @@ 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!(
|
||||
"api_provider_name: {}",
|
||||
account.api_provider_name.as_deref().unwrap_or("-")
|
||||
);
|
||||
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<String>) -> Option<String> {
|
||||
base_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.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 {
|
||||
return "<stored>".to_string();
|
||||
}
|
||||
let prefix = chars.iter().take(3).collect::<String>();
|
||||
let suffix = chars
|
||||
.iter()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
format!("{prefix}...{suffix}")
|
||||
}
|
||||
|
||||
+81
@@ -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)]
|
||||
@@ -132,6 +156,21 @@ pub enum LoginCommands {
|
||||
#[arg(long)]
|
||||
switch: bool,
|
||||
},
|
||||
/// Add an OpenAI API key account.
|
||||
Api {
|
||||
#[arg(long)]
|
||||
key: String,
|
||||
#[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)]
|
||||
@@ -173,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 {
|
||||
@@ -332,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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 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 => apply_oauth_config(codex_home, account),
|
||||
AuthMode::ApiKey => apply_api_key_provider(codex_home, account),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_oauth_config(codex_home: &Path, account: &Account) -> Result<()> {
|
||||
let path = paths::codex_config_path(codex_home);
|
||||
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 apply_api_key_provider(codex_home: &Path, account: &Account) -> Result<()> {
|
||||
let path = paths::codex_config_path(codex_home);
|
||||
let mut config = read_config(&path)?;
|
||||
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>> {
|
||||
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<String, Value>) -> 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<String, Value>) -> 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<String, Value>, key: &str) -> &'a mut Map<String, Value> {
|
||||
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<String> {
|
||||
let value = value.trim().trim_end_matches('/');
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+75
-5
@@ -7,6 +7,7 @@ mod account;
|
||||
mod atomic;
|
||||
mod auth_file;
|
||||
mod cli;
|
||||
mod codex_config;
|
||||
mod config_store;
|
||||
mod http_client;
|
||||
mod jwt;
|
||||
@@ -23,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]
|
||||
@@ -40,7 +41,39 @@ async fn main() -> Result<()> {
|
||||
port,
|
||||
switch,
|
||||
}) => oauth::login_oauth(manual, port, switch).await,
|
||||
None => oauth::login_oauth(args.manual, args.port, args.switch).await,
|
||||
Some(LoginCommands::Api {
|
||||
key,
|
||||
base_url,
|
||||
alias,
|
||||
model,
|
||||
name,
|
||||
switch,
|
||||
}) => 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 {
|
||||
@@ -55,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(|| {
|
||||
@@ -70,6 +116,7 @@ async fn main() -> Result<()> {
|
||||
})?,
|
||||
codex_home,
|
||||
apply_fingerprint,
|
||||
options,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -83,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,
|
||||
@@ -92,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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
+23
-3
@@ -29,7 +29,8 @@ struct RateLimitInfo {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct UsageResponse {
|
||||
plan_type: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_plan_type")]
|
||||
plan_type: Option<Option<String>>,
|
||||
rate_limit: Option<RateLimitInfo>,
|
||||
}
|
||||
|
||||
@@ -318,7 +319,7 @@ fn apply_quota_result(store: &mut Store, account_id: &str, quota: FetchQuotaResu
|
||||
.find_account_mut(account_id)
|
||||
.expect("quota result references an existing account");
|
||||
if let Some(plan) = quota.plan_type {
|
||||
account.plan_type = Some(plan);
|
||||
account.plan_type = plan;
|
||||
}
|
||||
account.quota = Some(quota.quota);
|
||||
account.updated_at = Utc::now().timestamp();
|
||||
@@ -326,7 +327,7 @@ fn apply_quota_result(store: &mut Store, account_id: &str, quota: FetchQuotaResu
|
||||
|
||||
struct FetchQuotaResult {
|
||||
quota: Quota,
|
||||
plan_type: Option<String>,
|
||||
plan_type: Option<Option<String>>,
|
||||
}
|
||||
|
||||
async fn fetch_quota(access_token: &str, account_id: Option<&str>) -> Result<FetchQuotaResult> {
|
||||
@@ -388,6 +389,25 @@ fn parse_quota(usage: &UsageResponse) -> Quota {
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_optional_plan_type<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||
Ok(match value {
|
||||
None => None,
|
||||
Some(serde_json::Value::Null) => Some(None),
|
||||
Some(serde_json::Value::String(plan)) => Some(Some(plan)),
|
||||
Some(value) => {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"expected plan_type to be string or null, got {value}"
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn remaining_percent(window: &WindowInfo) -> i32 {
|
||||
100 - window.used_percent.unwrap_or(0).clamp(0, 100)
|
||||
}
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
|
||||
+36
-5
@@ -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,
|
||||
@@ -146,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
|
||||
@@ -161,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.email, 34),
|
||||
shorten(account.alias.as_deref().unwrap_or("-"), 16),
|
||||
shorten(account_email_display(account), 34),
|
||||
account_auth_mode_name(account),
|
||||
account.plan_type.as_deref().unwrap_or("-"),
|
||||
shorten(account.model.as_deref().unwrap_or("-"), 18),
|
||||
shorten(account_provider_display(account), 12),
|
||||
quota
|
||||
);
|
||||
}
|
||||
@@ -287,6 +291,33 @@ 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 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();
|
||||
|
||||
+8
-12
@@ -69,24 +69,20 @@ pub async fn refresh_account(store: &mut Store, account_id: &str) -> Result<()>
|
||||
account.tokens = Some(tokens);
|
||||
account.requires_reauth = false;
|
||||
account.updated_at = Utc::now().timestamp();
|
||||
if let Some(plan) = auth
|
||||
.as_ref()
|
||||
.and_then(|auth| auth.chatgpt_plan_type.clone())
|
||||
{
|
||||
account.plan_type = Some(plan);
|
||||
}
|
||||
if account.account_id.is_none() {
|
||||
account.account_id = auth.as_ref().and_then(|auth| auth.account_id.clone());
|
||||
}
|
||||
if account.organization_id.is_none() {
|
||||
account.organization_id =
|
||||
auth.as_ref().and_then(|auth| auth.organization_id.clone());
|
||||
if let Some(auth) = auth {
|
||||
account.plan_type = auth.chatgpt_plan_type;
|
||||
account.account_id = auth.account_id;
|
||||
account.organization_id = auth.organization_id;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(account) = store.find_account_mut(account_id) {
|
||||
account.requires_reauth = true;
|
||||
account.plan_type = None;
|
||||
account.account_id = None;
|
||||
account.organization_id = None;
|
||||
account.quota = None;
|
||||
account.updated_at = Utc::now().timestamp();
|
||||
}
|
||||
Err(error)
|
||||
|
||||
Reference in New Issue
Block a user