16 Commits

20 changed files with 1330 additions and 353 deletions
+48 -134
View File
@@ -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
View File
@@ -232,7 +232,7 @@ dependencies = [
[[package]]
name = "cdxs"
version = "0.1.2"
version = "0.1.7"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cdxs"
version = "0.1.2"
version = "0.1.7"
edition = "2021"
description = "Codex account switcher CLI"
+12 -2
View File
@@ -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。
+1
View File
@@ -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`
+42 -6
View File
@@ -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
-107
View File
@@ -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"
-48
View File
@@ -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"
}
+133
View File
@@ -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"
+150
View File
@@ -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
View File
@@ -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
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)]
@@ -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,
}
+174
View File
@@ -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())
}
}
+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
+75 -5
View File
@@ -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),
+4
View File
@@ -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
View File
@@ -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
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
}
}
+36 -5
View File
@@ -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
View File
@@ -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)