From 4dd76eae2320078a419d21afbc5dd66171a3d56e Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Thu, 21 May 2026 21:39:28 -0700 Subject: [PATCH] fix: promote dev to master --- .github/scripts/electron-smoke.ps1 | 107 ++++++ .github/workflows/promote-dev-to-master.yml | 1 + .github/workflows/release.yml | 10 + CHANGELOG.md | 14 + Dockerfile | 5 + README.md | 25 +- config/default.yaml | 2 +- docker-compose.yml | 5 +- shared/account-transfer-client.test.ts | 61 ++++ shared/account-transfer-client.ts | 67 ++++ shared/hooks/use-accounts.ts | 52 +-- shared/hooks/use-api-keys.ts | 3 + shared/hooks/use-error-logs.test.ts | 17 +- shared/hooks/use-error-logs.ts | 24 +- shared/hooks/use-general-settings.ts | 2 + shared/i18n/translations.ts | 28 ++ shared/types.ts | 12 + shared/utils/__tests__/format-credits.test.ts | 61 ++++ shared/utils/format.ts | 27 ++ src/auth/account-registry.ts | 30 +- src/auth/api-key-catalog.ts | 2 +- src/auth/api-key-pool.ts | 68 +++- src/auth/cf-path-block-tracker.ts | 55 +++ src/auth/quota-utils.ts | 47 ++- src/auth/types.ts | 16 +- src/config-loader.ts | 7 + src/config-schema.ts | 6 +- src/index.ts | 18 +- src/logs/error-log.ts | 13 + src/middleware/cors.ts | 2 +- src/proxy/codex-api.ts | 3 + src/proxy/codex-types.ts | 32 +- src/proxy/cookie-jar.ts | 21 +- src/proxy/error-classification.ts | 16 + src/proxy/rate-limit-headers.ts | 7 + src/proxy/upstream-router-bootstrap.ts | 15 + src/routes/accounts.ts | 56 +-- src/routes/admin/error-logs.ts | 6 + src/routes/admin/settings.ts | 14 + src/routes/api-keys.ts | 4 + src/routes/chat.ts | 43 ++- src/routes/embeddings.ts | 182 ++++++++++ src/routes/responses.ts | 30 +- src/routes/shared/proxy-egress-log.ts | 6 + src/routes/shared/proxy-error-handler.ts | 47 ++- src/routes/shared/proxy-handler.ts | 36 +- src/routes/shared/proxy-session-context.ts | 8 + src/routes/shared/proxy-session-helpers.ts | 43 +++ src/services/account-mutation.ts | 4 + src/services/account-transfer-formats.ts | 318 ++++++++++++++++++ src/translation/codex-to-anthropic.ts | 63 +++- tests/e2e/quota-refresh.test.ts | 28 +- tests/integration/proxy-handler.test.ts | 58 +++- tests/integration/usage-passthrough.test.ts | 27 +- .../auth/account-pool-has-available.test.ts | 63 ++++ tests/unit/auth/account-pool-quota.test.ts | 65 ++++ tests/unit/auth/api-key-pool.test.ts | 62 +++- tests/unit/auth/cf-path-block-tracker.test.ts | 45 +++ tests/unit/auth/quota-utils.test.ts | 65 ++++ tests/unit/cache-reporting.test.ts | 79 +++-- tests/unit/ci/electron-smoke-script.test.ts | 67 +++- tests/unit/config-loader.test.ts | 16 + tests/unit/config-schema.test.ts | 3 +- tests/unit/middleware/cors.test.ts | 2 +- tests/unit/proxy/cookie-jar.test.ts | 235 +++++++------ tests/unit/proxy/error-classification.test.ts | 24 ++ .../proxy/upstream-router-bootstrap.test.ts | 46 +++ .../routes/accounts-import-export.test.ts | 107 ++++++ tests/unit/routes/admin/error-logs.test.ts | 48 ++- tests/unit/routes/api-keys.test.ts | 45 ++- tests/unit/routes/embeddings.test.ts | 170 ++++++++++ tests/unit/routes/general-settings.test.ts | 47 ++- .../responses-passthrough-metadata.test.ts | 191 +++++++++++ .../routes/shared/proxy-egress-log.test.ts | 23 ++ .../routes/shared/proxy-error-handler.test.ts | 47 +++ .../proxy-handler-implicit-resume.test.ts | 93 +++++ .../routes/shared/proxy-rate-limit.test.ts | 2 + .../shared/proxy-session-context.test.ts | 1 + .../unit/routes/upstream-auth-bypass.test.ts | 31 +- .../services/account-transfer-formats.test.ts | 141 ++++++++ .../translation/anthropic-to-codex.test.ts | 60 ++++ .../codex-to-anthropic-read-pages.test.ts | 100 ++++++ .../translation/codex-to-anthropic.test.ts | 33 +- .../web/account-list-quota-refresh.test.ts | 26 ++ tests/unit/web/anthropic-setup.test.ts | 20 ++ tests/unit/web/errors-page.test.ts | 12 + tests/unit/web/pool-overview-stats.test.ts | 149 ++++++++ web/src/App.tsx | 7 + web/src/components/AccountCard.tsx | 46 ++- .../components/AccountImportExport.test.tsx | 45 +++ web/src/components/AccountImportExport.tsx | 35 +- web/src/components/AccountList.tsx | 12 +- web/src/components/AnthropicSetup.tsx | 22 +- web/src/components/ApiKeyManager.test.tsx | 57 ++++ web/src/components/ApiKeyManager.tsx | 62 +++- web/src/components/PoolOverview.tsx | 116 +++++++ web/src/pages/ErrorsPage.tsx | 18 +- 97 files changed, 4037 insertions(+), 355 deletions(-) create mode 100644 .github/scripts/electron-smoke.ps1 create mode 100644 shared/account-transfer-client.test.ts create mode 100644 shared/account-transfer-client.ts create mode 100644 shared/utils/__tests__/format-credits.test.ts create mode 100644 src/auth/cf-path-block-tracker.ts create mode 100644 src/proxy/upstream-router-bootstrap.ts create mode 100644 src/routes/embeddings.ts create mode 100644 src/services/account-transfer-formats.ts create mode 100644 tests/unit/auth/cf-path-block-tracker.test.ts create mode 100644 tests/unit/proxy/upstream-router-bootstrap.test.ts create mode 100644 tests/unit/routes/embeddings.test.ts create mode 100644 tests/unit/routes/responses-passthrough-metadata.test.ts create mode 100644 tests/unit/services/account-transfer-formats.test.ts create mode 100644 tests/unit/translation/codex-to-anthropic-read-pages.test.ts create mode 100644 tests/unit/web/account-list-quota-refresh.test.ts create mode 100644 tests/unit/web/anthropic-setup.test.ts create mode 100644 tests/unit/web/pool-overview-stats.test.ts create mode 100644 web/src/components/AccountImportExport.test.tsx create mode 100644 web/src/components/ApiKeyManager.test.tsx create mode 100644 web/src/components/PoolOverview.tsx diff --git a/.github/scripts/electron-smoke.ps1 b/.github/scripts/electron-smoke.ps1 new file mode 100644 index 00000000..6bc7f2da --- /dev/null +++ b/.github/scripts/electron-smoke.ps1 @@ -0,0 +1,107 @@ +param() + +$ErrorActionPreference = "Stop" + +$LogBase = if ($env:SMOKE_LOG) { $env:SMOKE_LOG } else { [System.IO.Path]::GetTempFileName() } +$StdoutLog = "$LogBase.out" +$StderrLog = "$LogBase.err" +$TimeoutSecs = if ($env:SMOKE_TIMEOUT) { [int]$env:SMOKE_TIMEOUT } else { 90 } +$Process = $null + +function Get-SmokeLog { + $parts = @() + if (Test-Path $StdoutLog) { $parts += Get-Content -Raw -Path $StdoutLog } + if (Test-Path $StderrLog) { $parts += Get-Content -Raw -Path $StderrLog } + return ($parts -join "`n") +} + +function Fail-Smoke { + param([string]$Message) + Write-Host "::error::$Message" + Write-Host "--- smoke log ---" + $text = Get-SmokeLog + if ($text) { Write-Host $text } else { Write-Host "(no log captured)" } + exit 1 +} + +try { + if (-not $env:RELEASE_DIR) { + Fail-Smoke "RELEASE_DIR not set" + } + + if (-not (Test-Path -Path $env:RELEASE_DIR -PathType Container)) { + Fail-Smoke "RELEASE_DIR=$env:RELEASE_DIR does not exist" + } + + $UnpackedDir = Join-Path $env:RELEASE_DIR "win-unpacked" + if (-not (Test-Path -Path $UnpackedDir -PathType Container)) { + Fail-Smoke "Windows unpacked directory not found under $UnpackedDir" + } + + $Binary = Get-ChildItem -Path $UnpackedDir -Filter "*.exe" -File | + Sort-Object Name | + Select-Object -First 1 + if (-not $Binary) { + Fail-Smoke "Windows exe not found under $UnpackedDir" + } + + Write-Host "Launching: $($Binary.FullName) --no-sandbox" + Write-Host "Log: $LogBase" + Write-Host "Timeout: ${TimeoutSecs}s" + + $Process = Start-Process ` + -FilePath $Binary.FullName ` + -ArgumentList "--no-sandbox" ` + -RedirectStandardOutput $StdoutLog ` + -RedirectStandardError $StderrLog ` + -PassThru + + $Deadline = (Get-Date).AddSeconds($TimeoutSecs) + $Port = $null + while ((Get-Date) -lt $Deadline) { + $text = Get-SmokeLog + $match = [regex]::Matches($text, "Server started on port (\d+)") | Select-Object -Last 1 + if ($match) { + $Port = $match.Groups[1].Value + break + } + + if ($Process.HasExited) { + Fail-Smoke "App process exited before starting the server (pid=$($Process.Id))" + } + + Start-Sleep -Seconds 1 + } + + if (-not $Port) { + Fail-Smoke "App did not log 'Server started on port N' within ${TimeoutSecs}s" + } + + Write-Host "App reports server on port $Port" + + $HealthOk = $false + for ($i = 1; $i -le 30; $i++) { + try { + $Response = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/health" -UseBasicParsing -TimeoutSec 5 + if ([int]$Response.StatusCode -ge 200 -and [int]$Response.StatusCode -lt 300) { + $HealthOk = $true + if ($i -gt 1) { Write-Host "Health probe succeeded after $i attempt(s)" } + break + } + } catch { + Start-Sleep -Seconds 1 + } + } + + if (-not $HealthOk) { + Fail-Smoke "Health probe failed: GET http://127.0.0.1:$Port/health (gave up after 30s)" + } + + Write-Host "Smoke OK on Windows" +} finally { + if ($Process -and -not $Process.HasExited) { + Write-Host "Stopping app (pid=$($Process.Id))" + Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue + } + Get-Process "Codex Proxy" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +} diff --git a/.github/workflows/promote-dev-to-master.yml b/.github/workflows/promote-dev-to-master.yml index 6f6c6204..54bafc6c 100644 --- a/.github/workflows/promote-dev-to-master.yml +++ b/.github/workflows/promote-dev-to-master.yml @@ -67,6 +67,7 @@ jobs: else echo "ok=false" >> "$GITHUB_OUTPUT" echo "::warning::master has commits not in dev — manual rebase needed (likely a hotfix landed directly on master)" + exit 1 fi - name: Check soak (>= 24h since latest dev commit) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3669bb4..1aaef999 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,6 +86,7 @@ jobs: - name: Clean stale release assets continue-on-error: true + shell: bash run: | TAG="${{ inputs.tag || github.ref_name }}" ASSETS=$(gh release view "$TAG" --json assets -q '.assets[].name' 2>/dev/null || echo "") @@ -134,7 +135,16 @@ jobs: # Electron renderer can spawn a window in a headless runner. sudo apt-get install -y libfuse2 xvfb + - name: Smoke test packaged binary (win) + if: matrix.platform == 'win' + shell: pwsh + env: + RELEASE_DIR: packages/electron/release + SMOKE_LOG: ${{ runner.temp }}/electron-smoke.log + run: ./.github/scripts/electron-smoke.ps1 + - name: Smoke test packaged binary (${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }}) + if: matrix.platform != 'win' shell: bash env: RELEASE_DIR: packages/electron/release diff --git a/CHANGELOG.md b/CHANGELOG.md index 54346859..df810dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ ### Added +- Dashboard 新增 Plus / Pro / PAYG 账号的 credit 余额可视化与账号池总览(Phase 1,零新增上游 traffic):`CodexUsageResponse.credits` 此前被打成 `unknown` 全量丢弃,现在 `toQuota()` 把 `has_credits / unlimited / overage_limit_reached / balance` 解析进新 `CodexQuota.credits` 槽,并把 `balance` 从 upstream 的十进制字符串转成 number(malformed payload 防御性返回 null 而不是 NaN);`AccountRegistry.updateCachedQuota` 在收到不带 credits 字段的 quota(passive header path)时保留之前已知的 credit 余额,防止每次 `/codex/responses` 响应抹掉初始 warmup 拿到的余额;新增 `usage_stats.credits_per_usd` 配置项(默认 25,即 1000 credits = $40),dashboard 端按此换算 USD 显示。前端 `AccountCard` 在 `has_credits=true` 或 `unlimited=true` 的账号上新增一行 Credit Balance(balance + USD 换算 或 "Unlimited" 标签 或 overage 红字提示),Plus 账号 has_credits=false 时整行不渲染;新增 `PoolOverview` 卡片在 `/` 主页 AccountList 上方汇总账号池:active / quota_exhausted 数量、所有携带 credits 的账号余额合计与 USD 折算、secondary used% 最高的账号 + 重置时间。新增 `formatCredits` / `creditsToUsd` / `formatUsd` 共享格式化工具,shared 类型 `AccountQuota.credits` 透传到前端。新增 `tests/unit/auth/quota-utils.test.ts` 5 个 credits 用例、`tests/unit/auth/account-pool-quota.test.ts` 2 个 updateCachedQuota credits-preserve 用例、`shared/utils/__tests__/format-credits.test.ts` 11 个格式化工具用例、`tests/unit/web/pool-overview-stats.test.ts` 7 个聚合算法用例、`tests/unit/config-schema.test.ts` 补 `credits_per_usd` 默认值断言。i18n 中英新增 8 个新键(`src/proxy/codex-types.ts`、`src/auth/types.ts`、`src/auth/quota-utils.ts`、`src/auth/account-registry.ts`、`src/config-schema.ts`、`src/proxy/codex-api.ts`、`shared/types.ts`、`shared/utils/format.ts`、`shared/i18n/translations.ts`、`web/src/App.tsx`、`web/src/components/AccountCard.tsx`、`web/src/components/PoolOverview.tsx`) +- 第三方 API key 增加 capability 分层与 OpenAI-compatible embeddings 代理:旧 key 自动按 `chat` 迁移,新增/导入/导出支持 `capabilities`,`/v1/embeddings` 只使用显式标记 `embeddings` 的 OpenAI/OpenRouter/custom key 并直连上游 `/embeddings`;Dashboard API Keys 表单新增 Chat / Embeddings 复选框和手动模型输入,避免静态 catalog 卡住 embedding/custom model。启动时无持久 key 也会创建 runtime router,后续在面板添加的 key 无需重启即可参与 chat 直连;chat 直连模型在配置 `proxy_api_key` 时补齐代理 key 校验。新增单元、前端表单和真实 OpenRouter embeddings 3 连 E2E 验证(`src/auth/api-key-pool.ts`、`src/routes/api-keys.ts`、`src/routes/embeddings.ts`、`src/proxy/upstream-router-bootstrap.ts`、`src/routes/chat.ts`、`web/src/components/ApiKeyManager.tsx`、`tests/unit/routes/embeddings.test.ts`、`web/src/components/ApiKeyManager.test.tsx`) +- Account transfer compatibility with Cockpit Tools / Sub2API / CPA: normalized quota windows now include `remaining_percent` while preserving `used_percent`; successful `GET /auth/accounts/:id/quota` calls update cached quota; account import accepts Cockpit Tools portable objects, direct `accessToken` / `refresh_token`, Sub2API exports, raw arrays, and `text/plain` token lines; account export adds `format=cockpit_tools|sub2api|cpa` alongside existing `full|minimal`; Dashboard import/export now sends JSON or token-line files without forcing `{ accounts: [...] }` and exposes the export format selector. Docs now record the `/backend-api/wham/usage` active quota path and compatibility formats (`src/auth/quota-utils.ts`, `src/proxy/rate-limit-headers.ts`, `src/routes/accounts.ts`, `src/services/account-transfer-formats.ts`, `shared/account-transfer-client.ts`, `shared/hooks/use-accounts.ts`, `web/src/components/AccountImportExport.tsx`, `docs/architecture/auth/quota.md`, `docs/architecture/auth/import-export.md`, `docs/todo/cockpit-tools-quota-import-export.md`). - 支持 Dashboard 配置模型映射与本地自定义模型目录:`data/local.yaml` 可把客户端模型名映射到 Codex 模型、带 provider 前缀的第三方模型或已有 `model_routing` 目标;Dashboard → Settings → 模型映射可直接增删 alias 并热加载后端;`model.custom_models` 可把自定义 Codex-compatible ID 加入 `/v1/models/catalog` 并支持 `-fast` / `-high` 等后缀。ModelStore 会用本地 alias 覆盖静态 `config/models.yaml` alias,UpstreamRouter 会在内置 Claude/Gemini 自动路由前解析 alias,并在直连 provider 请求中把 outgoing `model` 改写为映射目标。新增 schema / model-store / upstream-router / route direct guard / Dashboard 组件测试覆盖配置默认值、静态 alias 覆盖、custom catalog、provider target 路由、四类直连接口(Chat / Messages / Responses / Gemini)的目标模型透传和 UI 持久化(`src/config-schema.ts`、`src/models/model-store.ts`、`src/proxy/upstream-router.ts`、`src/routes/admin/settings.ts`、`src/routes/chat.ts`、`src/routes/messages.ts`、`src/routes/responses.ts`、`src/routes/gemini.ts`、`web/src/components/ModelAliasSettings.tsx`、`tests/unit/config-schema.test.ts`、`tests/unit/models/model-store.test.ts`、`tests/unit/proxy/upstream-router.test.ts`、`tests/unit/routes/general-settings.test.ts`、`tests/unit/routes/upstream-auth-bypass.test.ts`、`web/src/components/ModelAliasSettings.test.tsx`) - Stream-close 事件结构化落盘到 Errors tab + 审计 log:`premature stream close` / `stream-client-abort` / `stream-client-disconnect` / `stream-error` 此前只走 `console.warn` 进 `dev-YYYY-MM-DD.log`,需要 grep 才能定位,且生产模式没有 tee;新增 `src/logs/stream-close-event.ts` 把这些事件同时写到 `data/error-log.jsonl`(Errors tab 按签名分组 + 角标计数)和 `logStore`(`/admin/logs` 审计流)。覆盖 7 个调用点:`proxy-handler.ts` 两处 client abort + 一处 `UpstreamPrematureCloseError`(带 eventCount / hadReasoning / responseId / variantHash)、`response-processor.ts` 两处(`client-write-failed` 带 writtenChunks/Bytes/lastSentEvent;`upstream-error` 带 upstreamStatus)、`responses.ts` 两处 `streamPassthrough` 内部 EOF(rid / accountEntryId / variantHash 通过 `FormatAdapter.streamTranslator` 的 `streamContext` option 由 `response-processor` 透传,其它 adapter 兼容性接收并忽略)。顺手修 `error-log.ts:readAppVersion` 在 config 未加载时崩溃(unit-test 路径会撞到),改为 try/catch 兜底回退 "unknown"。新增 `tests/unit/logs/stream-close-event.test.ts` 6 个单测覆盖 4 种 kind + 缺失 rid 兜底 + numeric upstreamStatus → audit status 透传 + direct upstream provider/path;Errors tab 展开分组时会显示 sample context。下次复现 premature close 直接看 Errors tab 按 `StreamUpstreamPrematureClose` 分组拉 rid + account + closeCode,不用再 grep dev 日志(`src/logs/stream-close-event.ts`、`src/logs/error-log.ts`、`src/routes/shared/proxy-handler.ts`、`src/routes/shared/response-processor.ts`、`src/routes/responses.ts`、`tests/unit/logs/stream-close-event.test.ts`) - Opt-in 上游请求/响应 dumper:新增 `src/utils/debug-dump.ts`,环境变量 `CODEX_PROXY_DEBUG_DUMP=1` 启用时把每次上游请求 + 流式 chunk + 终止状态 + 错误写入 `/tmp/codex-proxy-dump-.jsonl`(一行一事件);未启用时所有 hook 是 `if (debugDumpEnabled())` 守护下的纯 boolean check,零开销。在 `src/routes/shared/proxy-handler.ts` 加 1 个 hook(`request`,含 rid/tag/entryId/conv/implicitResumeActive/resumeReason/payload),在 `src/routes/shared/response-processor.ts` 加 3 个 hook(`upstream-chunk` 截断到 16KB、`stream-finish` 含 chunks/bytes/sawTerminal、`stream-error` 含 status/msg/body 截断到 4KB)。**privacy 警告**:dump 文件包含完整 request payload(含用户 prompt)和上游响应,路径在启动时打印一次提示 sensitive 性质。日常排查"账号轮换重试风暴" / "premature stream close" 等偶发错误时 opt-in 启用,问题复现后再 opt-out @@ -19,6 +22,17 @@ ### Fixed +- Release/promotion readiness fixes: `promote-dev-to-master.yml` now fails the workflow when `master` cannot fast-forward to `dev` instead of reporting a skipped success; `release.yml` runs stale asset cleanup under bash on every matrix runner and uses a dedicated PowerShell smoke script for Windows packaged Electron builds. Dashboard pool credit totals now use the persisted `usage_stats.credits_per_usd` value exposed by `/admin/general-settings`, and Anthropic cache-reporting tests now match the current contract where `input_tokens` excludes cached input while `cache_read_input_tokens` reports the hit count (`.github/workflows/promote-dev-to-master.yml`, `.github/workflows/release.yml`, `.github/scripts/electron-smoke.ps1`, `src/routes/admin/settings.ts`, `shared/hooks/use-general-settings.ts`, `web/src/App.tsx`, `web/src/components/PoolOverview.tsx`, `tests/unit/cache-reporting.test.ts`). +- Dashboard 的"刷新配额"按钮现在真正刷新账号 quota:`GET /auth/accounts/:id/quota` 此前只把 `/codex/usage` 结果返回给 caller、不写回 `pool.cachedQuota`。所以当 OpenAI 做 promo / window grant 把 `secondary_window.used_percent` 重置为 0% 时,proxy 仍然显示重置前的 98–100%,要等下一次真实 `/codex/responses` 请求才被动靠 `x-codex-*` 响应头追上。同一条死路也让 Pro / PAYG 账号 `credits` 块永远进不了 cachedQuota(header path 根本不带 credits)。修复:`/auth/accounts/:id/quota` 拉取上游后立刻 `pool.updateCachedQuota(id, toQuota(usage))` 写回;前端 `AccountList.onRefreshQuota` 直接调用单账号 `GET /quota`,失败时输出 warning 后刷新列表缓存(`src/routes/accounts.ts`、`web/src/components/AccountList.tsx`) +- `AccountRegistry.isAuthenticated()` 现在尊重 `quota.skip_exhausted` 配置:此前不论该开关如何配置,`isAuthenticated` 都会把 `cachedQuota` 已耗尽的活跃账号一律视作不可用,导致 `quota.skip_exhausted=false` 的部署在所有号都 `limit_reached=true` 但仍可被 `AccountLifecycle.acquire()` 取走的情况下,被 `accountPool.isAuthenticated()` 守门的路由(`/v1/chat/completions` / `/v1/messages` / `/v1/responses` / model 列表 / health)全部 401。现在 `isAuthenticated` 与 `hasAvailableAccounts` 用同一套规则:`skipExhausted ? !hasReachedCachedQuota(entry) : true`。配置默认值不变(默认仍跳过)。新增 5 个单测覆盖空池 / 默认 skip / 默认非 skip / `skip_exhausted=false` 配额耗尽路径 / disabled 账号 0 容忍(`src/auth/account-registry.ts`、`tests/unit/auth/account-pool-has-available.test.ts`) +- Claude Code 2.1.84 计费头 strip 行为新增 rotation 变体回归覆盖:实测 Claude Code 把 `x-anthropic-billing-header: cc_version=...; cc_entrypoint=cli; cch=<5hex>;` 作为 `system[0]` 独立块下发,`cc_version` 后缀和 `cch` 每请求 reroll;新增 `it.each` 覆盖 5 个真实 `cc_version` 后缀(c8e / 76b / f51 / 5b4 / 4f3)与"两次不同 cch 产出同一份 `instructions`"的不变性断言,防止后续改 strip(如改 `startsWith` → 正则、或加 inline 清洗)意外让 `cch` 漏进 `instructions` 污染上游 prompt cache(`tests/unit/translation/anthropic-to-codex.test.ts`) +- Dashboard Errors tab now has a real clear action: `DELETE /admin/error-logs` removes current + backup JSONL files and the read cursor so repeated `StreamUpstreamPrematureClose` groups can be cleared from the page instead of only marked read. Anthropic setup defaults now map Opus 4.7 → `gpt-5.5` and Sonnet 4.6 → `gpt-5.4`, and the built-in Anthropic API-key catalog lists Claude Opus 4.7 (`src/logs/error-log.ts`, `src/routes/admin/error-logs.ts`, `shared/hooks/use-error-logs.ts`, `web/src/pages/ErrorsPage.tsx`, `web/src/components/AnthropicSetup.tsx`, `src/auth/api-key-catalog.ts`). +- Claude Code 的 `Read` 工具参数里如果 `pages` 传成空字符串或空白字符串,会在 Codex → Anthropic 转换时被自动剔除,避免 GPT-5.5 反复触发 `Read tool validation error: Invalid pages parameter: ""` 并重试隔离工作树;对应单测覆盖流式与非流式两条路径,以及非空 PDF 页码范围保留(`src/translation/codex-to-anthropic.ts`、`tests/unit/translation/codex-to-anthropic-read-pages.test.ts`)。 +- Dashboard egress log details now include Codex request `reasoning` and optional `service_tier`, so `/admin/logs` can show the actual reasoning effort sent upstream instead of only `model` / `stream` / `useWebSocket` (`src/routes/shared/proxy-egress-log.ts`, `tests/unit/routes/shared/proxy-egress-log.test.ts`). +- implicit-resume 区分"真 missing tool call"与"client 自包含 full replay":`evaluateImplicitResume` 现在新增 `inlineFunctionCallIds` 入参,由 `buildProxySessionContext` 通过 `getInlineFunctionCallIds(codexRequest.input)` 在调用前收集 input 里所有 `function_call` 项的 call_id。当 input 里的 function_call_output 全部能在 input 内找到对应 function_call(典型 Codex CLI `/compact` 或客户端 fallback 自包含 replay 场景),返回 `reason: "self_contained_replay"` 而不是 `missing_tool_calls`,proxy 走正常透传不再触发 payload guard 413。混合场景(部分 inline 部分 storage 都找不到)仍判 `missing_tool_calls` 防真 runaway。新增 4 个 `evaluateImplicitResume`/`getInlineFunctionCallIds`/`isSelfContainedReplay` 单测覆盖纯 inline、混合、空 output、incremental turn 四类(`src/routes/shared/proxy-session-helpers.ts`、`src/routes/shared/proxy-session-context.ts`、`tests/unit/routes/shared/proxy-handler-implicit-resume.test.ts`、`tests/unit/routes/shared/proxy-session-context.test.ts`)。 +- 放宽 `/v1/responses` 的 missing-tool-call full-history replay guard 阈值:从 250KB / 80 items 提到 2MB / 1000 items。原阈值会把 Codex CLI `/compact` 之后正常的 client-driven full replay(实测 300-800KB / 100-800 items)误判成 runaway 风暴,连续 413 卡死对话。新阈值仍能挡真正失控的 multi-MB 重试循环,但不再误伤合法 fallback;测试侧把 padding 从 80 提到 1010 items 让 guard 仍能在新阈值下触发(`src/routes/shared/proxy-handler.ts`、`tests/integration/proxy-handler.test.ts`)。 +- 修复个别账号被 Cloudflare Bot Management `__cf_bm` cookie 反噬导致 `/codex/responses` 全 404、`/codex/usage` 仍正常的"配额没限却用不了"假死状态:根因是 proxy 此前在 warmup `GET /codex/usage` 时通过 `captureCookies` 把 CF 偶发下发的 `__cf_bm` 收进 jar,而 `__cf_bm` 是绑死 (IP + UA + TLS fingerprint + 时序) 的 30 分钟会话指纹 cookie——一旦 fingerprint 漂(proxy pool 切出口 IP / cookie 过期 / 时序变化),CF 在重保护路径就用空 body 404(CF "stealth deny" 模式)拒绝该 cookie 持有者,而轻保护路径继续放行,造成 `cachedQuota.rate_limit.limit_reached=false / used 78%` 但 `/codex/responses` 14 连 404 的诊断矛盾;24 个号里只这一个号偶然命中过 CF 下发,其它 cookie jar 全空的号反而都正常。修复两层:(1) `src/proxy/cookie-jar.ts` 加 `CAPTURABLE_COOKIE_NAMES = {cf_clearance}` 白名单,`captureRaw` 主动丢弃 `__cf_bm` 等非白名单 cookie,从源头不让毒 cookie 入 jar;admin API 的手动 `set()` 不受白名单约束方便调试。(2) `src/proxy/error-classification.ts` 新增 `isCfPathBlockError`(404 + trimmed body 为空);`src/auth/cf-path-block-tracker.ts` 用 1 小时滑动窗口计数器追踪每个 entryId 的连续 CF block 次数;`src/routes/shared/proxy-error-handler.ts` 在 generic respond 之前加新分支——命中 CF block 时清这个号的 cookie jar、记录计数、`releaseBeforeRetry: true` 让请求 fail over 到不同号,同号 1h 内累计 ≥ 3 次自动 `markStatus("disabled")` 并 `appendErrorLog({ name: "CfPathBlockAutoDisable" })` 进 Errors tab;`src/services/account-mutation.ts` 在 dashboard re-enable 时 `resetCfPathBlock` 清计数避免历史欠账。新增 `tests/unit/auth/cf-path-block-tracker.test.ts`(4 个,计数 / 窗口过期 / reset / peek)、`tests/unit/proxy/error-classification.test.ts` `isCfPathBlockError` 一节(4 个分支)、`tests/unit/routes/shared/proxy-error-handler.test.ts` CF block retry/disable 路径(2 个,含非空 body 不误判),`tests/unit/proxy/cookie-jar.test.ts` 改写为白名单语义(+2,旧 `session_id` / `expired` 用例改用 `cf_clearance` 测通用 Max-Age 解析)。Full suite 2258 全绿(`src/proxy/cookie-jar.ts`、`src/proxy/error-classification.ts`、`src/auth/cf-path-block-tracker.ts`、`src/routes/shared/proxy-error-handler.ts`、`src/routes/shared/proxy-handler.ts`、`src/services/account-mutation.ts`、`tests/unit/proxy/cookie-jar.test.ts`、`tests/unit/proxy/error-classification.test.ts`、`tests/unit/auth/cf-path-block-tracker.test.ts`、`tests/unit/routes/shared/proxy-error-handler.test.ts`) +- `/v1/responses` passthrough streaming / non-streaming paths now collect `function_call.call_id` from `response.output_item.done` and forward it through response metadata so implicit resume can validate following `function_call_output` turns instead of falling back to full-history replay. Oversized missing-tool-call replays are guarded with 413, and regression coverage now proves the issue red/green across the Responses format adapter (`src/routes/responses.ts`, `src/routes/shared/proxy-handler.ts`, `tests/unit/routes/responses-passthrough-metadata.test.ts`, `tests/integration/proxy-handler.test.ts`). - Release bump workflows now require runtime file changes in addition to meaningful commit subjects before tagging a beta or stable build. This prevents squash-promotion history divergence from re-counting old dev commits, and prevents workflow/docs/test-only fixes from producing empty Electron releases (`.github/workflows/bump-electron.yml`, `.github/workflows/bump-electron-beta.yml`, `tests/unit/ci/package-boundary.test.ts`). - Release bump workflows now skip the release-notes workflow hotfix subject itself, so promoting the stable-notes CI fix to `master` does not create an empty desktop release on the next scheduled bump (`.github/workflows/bump-electron.yml`, `.github/workflows/bump-electron-beta.yml`, `tests/unit/ci/package-boundary.test.ts`). - 修复 stable release notes 在手动 squash promotion 后只写 `fix: promote dev release fixes to master`、漏掉 dev 原始 PR 的问题:`release.yml` 改为调用 `.github/scripts/generate-release-notes.sh`,stable tag 若只有 promotion 内容且运行时代码树与 `origin/dev` 一致(忽略 README/package 版本文件),会回退使用 dev history 生成说明;新增单测覆盖正常 stable tag 与 squash promotion 两条路径(`.github/workflows/release.yml`、`.github/scripts/generate-release-notes.sh`、`tests/unit/ci/release-notes-script.test.ts`)。 diff --git a/Dockerfile b/Dockerfile index 78e7c50e..62ea9f4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,11 @@ RUN npm ci && npm run build # ── Stage 2: Application ──────────────────────────────────────────── FROM node:20-slim +# The checked-in default is loopback-only for local source installs. Containers +# need to listen on all interfaces inside the network namespace so published +# ports and Docker health checks can reach the service. +ENV CODEX_PROXY_HOST=0.0.0.0 + # curl: needed by full-update.ts # unzip: needed by full-update.ts to extract Codex.app # gosu: needed by entrypoint to drop from root to node user diff --git a/README.md b/README.md index c859db62..e5df7a43 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ curl http://localhost:8080/v1/chat/completions \ - **多账号轮换** — `least_used`(最少使用优先)、`round_robin`(轮询)、`sticky`(粘性)三种策略 - **Plan Routing** — 不同 plan(free/plus/team/business)的账号自动路由到各自支持的模型 - **Token 自动续期** — JWT 到期前自动刷新,指数退避重试 -- **配额被动采集** — 从上游响应头和 WebSocket rate limit 事件更新账号额度;`quota.refresh_interval_minutes` 仅控制用量快照记录,`0` 表示关闭快照定时器。 +- **配额采集** — 默认从上游响应头和 WebSocket rate limit 事件被动更新账号额度;用户手动查询单账号额度时会调用 `/backend-api/wham/usage`,并把 `remaining_percent = 100 - used_percent` 写入缓存。 - **封禁检测** — 上游 403 自动标记 banned;401 token 吊销自动过期并切换账号 - **API Key Provider 池** — 支持通过 Dashboard 管理第三方 API Key、模型列表、导入导出和启停状态。 - **Web 控制面板** — 账号管理、用量统计、批量操作,中英双语;远程访问需 Dashboard 登录门 @@ -572,14 +572,16 @@ model: ### 局域网访问 -源码/容器默认配置监听 `::`(IPv6 unspecified,通常也覆盖本机访问);Electron 启动时会传入 `127.0.0.1`,除非 `data/local.yaml` 显式覆盖。建议需要仅本机访问时写入: +源码默认配置仅监听 `127.0.0.1`;Electron 也会传入 `127.0.0.1`,除非 `data/local.yaml` 显式覆盖。Docker 镜像会通过 `CODEX_PROXY_HOST=0.0.0.0` 在容器内监听所有接口,`docker-compose.yml` 默认仍只把宿主机端口绑定到 `127.0.0.1`。 + +需要仅本机访问时写入: ```yaml server: host: "127.0.0.1" ``` -如需局域网内其他设备访问,在 `data/local.yaml` 中添加: +如需局域网内其他设备访问,在 `data/local.yaml` 中添加,并把 `docker-compose.yml` 的端口映射从 `127.0.0.1:${PORT:-8080}:8080` 改成 `${PORT:-8080}:8080`: ```yaml server: @@ -711,6 +713,7 @@ curl -N http://localhost:8080/official-agent/threads/{threadId}/turns \ | 环境变量 | 覆盖配置 | |---------|---------| | `PORT` | `server.port` | +| `CODEX_PROXY_HOST` | `server.host`(仅当 `data/local.yaml` 未显式设置 `server.host` 时生效) | | `CODEX_PLATFORM` | `client.platform` | | `CODEX_ARCH` | `client.arch` | | `HTTPS_PROXY` | `tls.proxy_url` | @@ -745,10 +748,10 @@ curl -N http://localhost:8080/official-agent/threads/{threadId}/turns \ | 端点 | 方法 | 说明 | |------|------|------| | `/auth/login` | GET | OAuth 登录入口 | -| `/auth/accounts` | GET | 账号列表(`?quota=true` / `?quota=fresh`) | +| `/auth/accounts` | GET | 账号列表(含缓存额度) | | `/auth/accounts` | POST | 添加单个账号(token 或 refreshToken) | -| `/auth/accounts/import` | POST | 批量导入账号 | -| `/auth/accounts/export` | GET | 导出账号(`?format=minimal` 精简格式) | +| `/auth/accounts/import` | POST | 批量导入账号(JSON / `text/plain` token 行) | +| `/auth/accounts/export` | GET | 导出账号(`?format=full|minimal|cockpit_tools|sub2api|cpa`) | | `/auth/accounts/batch-delete` | POST | 批量删除账号 | | `/auth/accounts/batch-status` | POST | 批量修改账号状态 | | `/auth/accounts/health-check` | POST | 批量检测账号可用性 | @@ -782,6 +785,10 @@ curl -s http://localhost:8080/auth/accounts/export \ curl -s "http://localhost:8080/auth/accounts/export?format=minimal" \ -H "Authorization: Bearer your-api-key" > backup-minimal.json +# 导出第三方兼容格式 +curl -s "http://localhost:8080/auth/accounts/export?format=sub2api" \ + -H "Authorization: Bearer your-api-key" > sub2api-accounts.json + # 批量导入(支持 token、refreshToken,或两者同时传) curl -X POST http://localhost:8080/auth/accounts/import \ -H "Content-Type: application/json" \ @@ -795,6 +802,12 @@ curl -X POST http://localhost:8080/auth/accounts/import \ }' # 返回: { "added": 2, "updated": 1, "failed": 0, "errors": [] } +# text/plain token 行导入(每行 access token 或 refresh token) +curl -X POST http://localhost:8080/auth/accounts/import \ + -H "Content-Type: text/plain" \ + -H "Authorization: Bearer your-api-key" \ + --data-binary $'eyJhbGciOi...\noaistb_rt_...\n' + # 备份恢复一键操作(导出后直接导入到另一个实例) curl -X POST http://localhost:8080/auth/accounts/import \ -H "Content-Type: application/json" \ diff --git a/config/default.yaml b/config/default.yaml index 35fefcb0..abe6b643 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -27,7 +27,7 @@ auth: oauth_auth_endpoint: https://auth.openai.com/oauth/authorize oauth_token_endpoint: https://auth.openai.com/oauth/token server: - host: "::" + host: "127.0.0.1" port: 8080 proxy_api_key: null trust_proxy: false diff --git a/docker-compose.yml b/docker-compose.yml index ae5440f5..9a2eb8bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,9 @@ services: extra_hosts: - "host.docker.internal:host-gateway" ports: - - "${PORT:-8080}:8080" + # Loopback-only on the host by default; change to "${PORT:-8080}:8080" + # if you intentionally want LAN access. + - "127.0.0.1:${PORT:-8080}:8080" - "1455:1455" # Optional Ollama-compatible bridge. Enable ollama.enabled and use # ollama.host=0.0.0.0 inside the container before uncommenting. @@ -22,6 +24,7 @@ services: environment: - NODE_ENV=production - PORT=8080 + - CODEX_PROXY_HOST=0.0.0.0 # -- Automatic updates (uncomment to enable) -- # watchtower: diff --git a/shared/account-transfer-client.test.ts b/shared/account-transfer-client.test.ts new file mode 100644 index 00000000..8a2004d7 --- /dev/null +++ b/shared/account-transfer-client.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + accountExportDownloadName, + buildAccountExportUrl, + prepareAccountImportRequest, +} from "./account-transfer-client"; + +function makeFile(name: string, text: string, type = "") { + return { + name, + type, + text: async () => text, + }; +} + +describe("account transfer browser client helpers", () => { + it("builds export URLs and download names for compatibility formats", () => { + expect(buildAccountExportUrl(["acct-1", "acct-2"], "sub2api")) + .toBe("/auth/accounts/export?ids=acct-1%2Cacct-2&format=sub2api"); + expect(buildAccountExportUrl(undefined, "full")).toBe("/auth/accounts/export"); + expect(accountExportDownloadName("cockpit_tools", "2026-05-18")) + .toBe("accounts-export-cockpit-tools-2026-05-18.json"); + }); + + it("keeps JSON import payloads intact instead of forcing accounts arrays", async () => { + const request = await prepareAccountImportRequest(makeFile( + "sub2api.json", + JSON.stringify({ type: "sub2api-data", accounts: [{ credentials: { access_token: "token" } }] }), + "application/json", + )); + + expect(request).toEqual({ + ok: true, + contentType: "application/json", + body: JSON.stringify({ type: "sub2api-data", accounts: [{ credentials: { access_token: "token" } }] }), + }); + }); + + it("prepares text/plain token line imports", async () => { + const request = await prepareAccountImportRequest(makeFile( + "tokens.txt", + "plain.access.token\nrt_text_only\n", + "text/plain", + )); + + expect(request).toEqual({ + ok: true, + contentType: "text/plain", + body: "plain.access.token\nrt_text_only\n", + }); + }); + + it("rejects malformed .json files before sending them", async () => { + const request = await prepareAccountImportRequest(makeFile("broken.json", "{not json", "application/json")); + + expect(request).toEqual({ + ok: false, + error: "Invalid JSON file", + }); + }); +}); diff --git a/shared/account-transfer-client.ts b/shared/account-transfer-client.ts new file mode 100644 index 00000000..e45d348d --- /dev/null +++ b/shared/account-transfer-client.ts @@ -0,0 +1,67 @@ +export type AccountExportFormat = "full" | "minimal" | "cockpit_tools" | "sub2api" | "cpa"; + +export const ACCOUNT_EXPORT_FORMATS: AccountExportFormat[] = [ + "full", + "minimal", + "cockpit_tools", + "sub2api", + "cpa", +]; + +export interface AccountImportFile { + name: string; + type?: string; + text(): Promise; +} + +export type PreparedAccountImportRequest = + | { ok: true; contentType: "application/json" | "text/plain"; body: string } + | { ok: false; error: string }; + +function isLikelyJsonFile(file: AccountImportFile): boolean { + const name = file.name.toLowerCase(); + const type = file.type?.toLowerCase() ?? ""; + return name.endsWith(".json") || type.includes("json"); +} + +function isJsonText(text: string): boolean { + const trimmed = text.trimStart(); + return trimmed.startsWith("{") || trimmed.startsWith("["); +} + +export function buildAccountExportUrl( + selectedIds: string[] | undefined, + format: AccountExportFormat = "full", +): string { + const params = new URLSearchParams(); + if (selectedIds && selectedIds.length > 0) params.set("ids", selectedIds.join(",")); + if (format !== "full") params.set("format", format); + const qs = params.toString(); + return `/auth/accounts/export${qs ? `?${qs}` : ""}`; +} + +export function accountExportDownloadName( + format: AccountExportFormat, + date = new Date().toISOString().slice(0, 10), +): string { + const suffix = format === "full" ? "" : `-${format.replaceAll("_", "-")}`; + return `accounts-export${suffix}-${date}.json`; +} + +export async function prepareAccountImportRequest( + file: AccountImportFile, +): Promise { + const body = await file.text(); + if (!body.trim()) return { ok: false, error: "No importable content" }; + + if (isLikelyJsonFile(file) || isJsonText(body)) { + try { + JSON.parse(body) as unknown; + return { ok: true, contentType: "application/json", body }; + } catch { + if (isLikelyJsonFile(file)) return { ok: false, error: "Invalid JSON file" }; + } + } + + return { ok: true, contentType: "text/plain", body }; +} diff --git a/shared/hooks/use-accounts.ts b/shared/hooks/use-accounts.ts index 74db5bb1..1e142f2b 100644 --- a/shared/hooks/use-accounts.ts +++ b/shared/hooks/use-accounts.ts @@ -1,5 +1,11 @@ import { useState, useEffect, useCallback, useRef } from "preact/hooks"; import type { Account } from "../types"; +import { + accountExportDownloadName, + buildAccountExportUrl, + prepareAccountImportRequest, + type AccountExportFormat, +} from "../account-transfer-client"; export interface PersistenceHealth { ok: boolean; @@ -18,11 +24,10 @@ export function useAccounts() { const [persistenceHealth, setPersistenceHealth] = useState({ ok: true }); const addCleanupRef = useRef<(() => void) | null>(null); - const loadAccounts = useCallback(async (fresh = false) => { + const loadAccounts = useCallback(async () => { setRefreshing(true); try { - const url = fresh ? "/auth/accounts?quota=fresh" : "/auth/accounts?quota=true"; - const resp = await fetch(url); + const resp = await fetch("/auth/accounts?quota=true"); const data = await resp.json(); setList(data.accounts || []); if (data.persistence_health && typeof data.persistence_health === "object") { @@ -207,20 +212,14 @@ export function useAccounts() { setList((prev) => prev.map((a) => a.id === accountId ? { ...a, ...patch } : a)); }, []); - const exportAccounts = useCallback(async (selectedIds?: string[], format?: "full" | "minimal") => { - const params = new URLSearchParams(); - if (selectedIds && selectedIds.length > 0) params.set("ids", selectedIds.join(",")); - if (format === "minimal") params.set("format", "minimal"); - const qs = params.toString() ? `?${params.toString()}` : ""; - const resp = await fetch(`/auth/accounts/export${qs}`); - const data = await resp.json() as { accounts: Array<{ id: string }> }; + const exportAccounts = useCallback(async (selectedIds?: string[], format: AccountExportFormat = "full") => { + const resp = await fetch(buildAccountExportUrl(selectedIds, format)); + const data = await resp.json() as unknown; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - const date = new Date().toISOString().slice(0, 10); - const suffix = format === "minimal" ? "-minimal" : ""; - a.download = `accounts-export${suffix}-${date}.json`; + a.download = accountExportDownloadName(format); a.style.display = "none"; document.body.appendChild(a); a.click(); @@ -235,30 +234,13 @@ export function useAccounts() { failed: number; errors: string[]; }> => { - const text = await file.text(); - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid JSON file"] }; - } - // Support both { accounts: [...] } (export format) and raw array - const parsedObject = parsed && typeof parsed === "object" - ? parsed as { accounts?: unknown } - : null; - const accounts = Array.isArray(parsed) - ? parsed - : Array.isArray(parsedObject?.accounts) - ? parsedObject.accounts - : null; - if (!accounts) { - return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid format: expected { accounts: [...] }"] }; - } + const prepared = await prepareAccountImportRequest(file); + if (!prepared.ok) return { success: false, added: 0, updated: 0, failed: 0, errors: [prepared.error] }; const resp = await fetch("/auth/accounts/import", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ accounts }), + headers: { "Content-Type": prepared.contentType }, + body: prepared.body, }); const result = await resp.json(); if (resp.ok) { @@ -335,7 +317,7 @@ export function useAccounts() { addInfo, addError, persistenceHealth, - refresh: useCallback(() => loadAccounts(true), [loadAccounts]), + refresh: loadAccounts, patchLocal, startAdd, cancelAdd, diff --git a/shared/hooks/use-api-keys.ts b/shared/hooks/use-api-keys.ts index 04bcc4c6..f471a14d 100644 --- a/shared/hooks/use-api-keys.ts +++ b/shared/hooks/use-api-keys.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "preact/hooks"; export type ApiKeyProvider = "anthropic" | "openai" | "gemini" | "openrouter" | "custom"; +export type ApiKeyCapability = "chat" | "embeddings"; export interface ApiKeyEntry { id: string; @@ -9,6 +10,7 @@ export interface ApiKeyEntry { apiKey: string; // masked baseUrl: string; label: string | null; + capabilities: ApiKeyCapability[]; status: "active" | "disabled" | "error"; addedAt: string; lastUsedAt: string | null; @@ -72,6 +74,7 @@ export function useApiKeys() { apiKey: string; baseUrl?: string; label?: string | null; + capabilities?: ApiKeyCapability[]; }): Promise<{ ok: boolean; error?: string }> => { try { const resp = await fetch("/auth/api-keys", { diff --git a/shared/hooks/use-error-logs.test.ts b/shared/hooks/use-error-logs.test.ts index fee47368..c79883bb 100644 --- a/shared/hooks/use-error-logs.test.ts +++ b/shared/hooks/use-error-logs.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { formatRelativeTime } from "./use-error-logs.js"; +import { clearErrorLogsRequest, formatRelativeTime } from "./use-error-logs.js"; describe("formatRelativeTime", () => { const now = new Date("2026-05-10T12:00:00Z").getTime(); @@ -28,3 +28,18 @@ describe("formatRelativeTime", () => { expect(formatRelativeTime("not-a-date", now)).toBe("not-a-date"); }); }); + +describe("clearErrorLogsRequest", () => { + it("sends a collection DELETE to the error log endpoint", async () => { + const fetchImpl = async ( + input: string, + init: RequestInit, + ): Promise> => { + expect(input).toBe("/admin/error-logs"); + expect(init).toEqual({ method: "DELETE" }); + return { ok: true }; + }; + + await expect(clearErrorLogsRequest(fetchImpl)).resolves.toBe(true); + }); +}); diff --git a/shared/hooks/use-error-logs.ts b/shared/hooks/use-error-logs.ts index 1fcd0cba..edafa00b 100644 --- a/shared/hooks/use-error-logs.ts +++ b/shared/hooks/use-error-logs.ts @@ -30,6 +30,15 @@ export interface ErrorLogCount { const POLL_MS = 30_000; +type ErrorLogsFetch = (input: string, init: RequestInit) => Promise>; + +export async function clearErrorLogsRequest( + fetchImpl: ErrorLogsFetch = (input, init) => fetch(input, init), +): Promise { + const res = await fetchImpl("/admin/error-logs", { method: "DELETE" }); + return res.ok; +} + export function useErrorLogs() { const [groups, setGroups] = useState([]); const [count, setCount] = useState({ total: 0, unread: 0 }); @@ -68,6 +77,19 @@ export function useErrorLogs() { } }, [load]); + const clearAll = useCallback(async () => { + try { + const ok = await clearErrorLogsRequest(); + if (!ok) { + setError("Failed to clear error logs"); + return; + } + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to clear error logs"); + } + }, [load]); + useEffect(() => { void load(); timerRef.current = setInterval(() => void load(), POLL_MS); @@ -76,7 +98,7 @@ export function useErrorLogs() { }; }, [load]); - return { groups, count, loading, error, refresh: load, markAllSeen }; + return { groups, count, loading, error, refresh: load, markAllSeen, clearAll }; } /** diff --git a/shared/hooks/use-general-settings.ts b/shared/hooks/use-general-settings.ts index 33046cc0..148f5e21 100644 --- a/shared/hooks/use-general-settings.ts +++ b/shared/hooks/use-general-settings.ts @@ -23,6 +23,7 @@ export interface GeneralSettingsData { logs_capture_body: boolean; logs_llm_only: boolean; usage_history_retention_days: number | null; + credits_per_usd: number; } interface GeneralSettingsSaveResponse extends GeneralSettingsData { @@ -90,6 +91,7 @@ export function useGeneralSettings(apiKey: string | null) { logs_capture_body: result.logs_capture_body, logs_llm_only: result.logs_llm_only, usage_history_retention_days: result.usage_history_retention_days, + credits_per_usd: result.credits_per_usd, }); setRestartRequired(result.restart_required); setSaved(true); diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index 784759b0..4bbddf02 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -31,6 +31,14 @@ export const translations = { secondaryRateLimit: "Weekly Limit", reviewRateLimit: "Review Limit", additionalRateLimit: "Additional Limit", + creditsBalance: "Credit Balance", + creditsUnlimited: "Unlimited", + creditsOverageReached: "Overage limit reached", + poolOverview: "Pool Overview", + poolActiveAccounts: "Active", + poolExhaustedAccounts: "Quota Exhausted", + poolTotalCredits: "Total Credits", + poolTopUsage: "Highest Weekly Usage", limitReached: "Limit Reached", used: "Used", ok: "OK", @@ -225,6 +233,7 @@ export const translations = { errorsTabDesc: "Uncaught crashes from the proxy backend, Electron main, and the dashboard renderer. Stored locally — nothing leaves this machine.", errorsRefresh: "Refresh", errorsMarkSeen: "Mark all read", + errorsClear: "Clear all", errorsBadge: "errors", errorsBadgeTooltip: "Unread errors — click to view", errorsNone: "No errors recorded.", @@ -367,7 +376,12 @@ export const translations = { editLabel: "Edit label", labelPlaceholder: "e.g. Team Alpha, Personal", downloadTemplate: "Download Template", + exportFormat: "Export format", + exportFull: "Full", exportMinimal: "Export (RT only)", + exportCockpitTools: "Cockpit Tools", + exportSub2Api: "Sub2API", + exportCpa: "CPA", pasteRefreshToken: "Paste refresh token", addByRt: "Add by RT", addingByRt: "Exchanging...", @@ -434,6 +448,14 @@ export const translations = { secondaryRateLimit: "\u5468\u9650\u5236", reviewRateLimit: "Review \u989d\u5ea6", additionalRateLimit: "\u989d\u5916\u989d\u5ea6", + creditsBalance: "Credit \u4f59\u989d", + creditsUnlimited: "\u65e0\u9650", + creditsOverageReached: "\u8d85\u9650\u62cd\u540e\u5df2\u5230\u4e0a\u9650", + poolOverview: "\u8d26\u53f7\u6c60\u603b\u89c8", + poolActiveAccounts: "\u6d3b\u8dc3", + poolExhaustedAccounts: "\u989d\u5ea6\u8017\u5c3d", + poolTotalCredits: "Credit \u603b\u4f59\u989d", + poolTopUsage: "\u5468\u9650\u4f7f\u7528\u7387\u6700\u9ad8\u8d26\u53f7", limitReached: "\u5df2\u8fbe\u4e0a\u9650", used: "\u5df2\u4f7f\u7528", ok: "\u6b63\u5e38", @@ -629,6 +651,7 @@ export const translations = { errorsTabDesc: "Proxy 后端、Electron 主进程、Dashboard 渲染进程的 uncaught 崩溃。仅本地存储,不上传任何数据。", errorsRefresh: "刷新", errorsMarkSeen: "全部标记已读", + errorsClear: "清空错误", errorsBadge: "条错误", errorsBadgeTooltip: "未读错误 — 点击查看", errorsNone: "暂无错误记录。", @@ -771,7 +794,12 @@ export const translations = { editLabel: "编辑标签", labelPlaceholder: "如 Team Alpha、个人", downloadTemplate: "下载模板", + exportFormat: "导出格式", + exportFull: "完整", exportMinimal: "导出(仅 RT)", + exportCockpitTools: "Cockpit Tools", + exportSub2Api: "Sub2API", + exportCpa: "CPA", pasteRefreshToken: "粘贴 Refresh Token", addByRt: "添加", addingByRt: "刷新中...", diff --git a/shared/types.ts b/shared/types.ts index ac517bf8..29cff77c 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,11 +1,21 @@ export interface AccountQuotaWindow { used_percent?: number | null; + remaining_percent?: number | null; limit_reached?: boolean; reset_at?: number | null; limit_window_seconds?: number | null; } +export interface AccountQuotaCredits { + has_credits: boolean; + unlimited: boolean; + overage_limit_reached: boolean; + /** Numeric balance parsed from upstream's decimal-string field. */ + balance: number; +} + export interface AccountQuota { + plan_type?: string; rate_limit?: AccountQuotaWindow; secondary_rate_limit?: AccountQuotaWindow | null; code_review_rate_limit?: (AccountQuotaWindow & { allowed?: boolean }) | null; @@ -15,6 +25,8 @@ export interface AccountQuota { allowed?: boolean; secondary_rate_limit?: AccountQuotaWindow | null; }> | null; + /** Credit accounting from /codex/usage. Null for Plus, present for Pro / PAYG. */ + credits?: AccountQuotaCredits | null; } export interface QuotaWarning { diff --git a/shared/utils/__tests__/format-credits.test.ts b/shared/utils/__tests__/format-credits.test.ts new file mode 100644 index 00000000..0680da36 --- /dev/null +++ b/shared/utils/__tests__/format-credits.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { formatCredits, creditsToUsd, formatUsd } from "../format"; + +describe("formatCredits", () => { + it("renders zero as plain '0'", () => { + expect(formatCredits(0)).toBe("0"); + }); + + it("strips trailing zeros for round numbers", () => { + expect(formatCredits(5)).toBe("5"); + expect(formatCredits(247.5)).toBe("247.5"); + }); + + it("rounds small decimals to two places", () => { + expect(formatCredits(12.345)).toBe("12.35"); + expect(formatCredits(0.05)).toBe("0.05"); + }); + + it("uses k suffix above 1000", () => { + expect(formatCredits(3196)).toBe("3.2k"); + expect(formatCredits(7000)).toBe("7k"); + }); + + it("returns '0' for non-finite input", () => { + expect(formatCredits(NaN)).toBe("0"); + expect(formatCredits(Infinity)).toBe("0"); + }); +}); + +describe("creditsToUsd", () => { + it("converts at the default rate (25 credits = $1)", () => { + expect(creditsToUsd(25, 25)).toBe(1); + expect(creditsToUsd(1000, 25)).toBe(40); + }); + + it("returns null when rate is zero or negative (USD display disabled)", () => { + expect(creditsToUsd(500, 0)).toBeNull(); + expect(creditsToUsd(500, -1)).toBeNull(); + }); + + it("returns null for non-finite inputs", () => { + expect(creditsToUsd(NaN, 25)).toBeNull(); + expect(creditsToUsd(100, NaN)).toBeNull(); + }); +}); + +describe("formatUsd", () => { + it("formats with $ sign and two decimals", () => { + expect(formatUsd(0)).toBe("$0.00"); + expect(formatUsd(12.345)).toBe("$12.35"); + }); + + it("uses k suffix above $1000", () => { + expect(formatUsd(1234.56)).toBe("$1.2k"); + expect(formatUsd(40000)).toBe("$40k"); + }); + + it("handles negatives", () => { + expect(formatUsd(-12.34)).toBe("-$12.34"); + }); +}); diff --git a/shared/utils/format.ts b/shared/utils/format.ts index b5ff9810..36178eee 100644 --- a/shared/utils/format.ts +++ b/shared/utils/format.ts @@ -4,6 +4,33 @@ export function formatNumber(n: number): string { return String(n); } +/** Format a Codex credit balance: "0", "12.34", "1.2k". Always strip trailing zeros. */ +export function formatCredits(credits: number): string { + if (!Number.isFinite(credits)) return "0"; + if (credits >= 1000) return (credits / 1000).toFixed(1).replace(/\.0$/, "") + "k"; + if (credits === 0) return "0"; + // Two decimals for small numbers, but trim trailing zeros so 5.00 → "5". + return credits.toFixed(2).replace(/\.?0+$/, ""); +} + +/** Convert credits to USD using the configured per-USD rate. + * Returns null when conversion is disabled (creditsPerUsd <= 0). */ +export function creditsToUsd(credits: number, creditsPerUsd: number): number | null { + if (!Number.isFinite(credits) || !Number.isFinite(creditsPerUsd) || creditsPerUsd <= 0) { + return null; + } + return credits / creditsPerUsd; +} + +/** Format a USD amount with $ sign and two decimals. "$12.34" / "$1.2k". */ +export function formatUsd(usd: number): string { + if (!Number.isFinite(usd)) return "$0"; + const sign = usd < 0 ? "-" : ""; + const abs = Math.abs(usd); + if (abs >= 1000) return sign + "$" + (abs / 1000).toFixed(1).replace(/\.0$/, "") + "k"; + return sign + "$" + abs.toFixed(2); +} + export function formatWindowDuration(seconds: number, isZh: boolean): string { if (seconds >= 86400) { const days = Math.floor(seconds / 86400); diff --git a/src/auth/account-registry.ts b/src/auth/account-registry.ts index 7f0c1f73..1276bb59 100644 --- a/src/auth/account-registry.ts +++ b/src/auth/account-registry.ts @@ -293,13 +293,23 @@ export class AccountRegistry { isAuthenticated(): boolean { const now = new Date(); + // Mirror hasAvailableAccounts: skip_exhausted defaults to true per schema. + // Using !== false (vs === true) lets call sites with minimal config mocks + // observe the same default behavior as production. + const skipExhausted = getConfig().quota?.skip_exhausted !== false; for (const entry of this.accounts.values()) { this.refreshStatus(entry, now); - // "Authenticated" used to imply "has a usable account". After retiring - // status="rate_limited", we treat any cachedQuota-exhausted account as - // unusable too — otherwise an all-exhausted pool would falsely report - // authenticated and produce confusing 4xx on requests. - if (entry.status === "active" && !hasReachedCachedQuota(entry)) return true; + // "Authenticated" implies "has a usable account". Gate the cachedQuota + // check on quota.skip_exhausted to stay consistent with hasAvailableAccounts + // and AccountLifecycle.acquire(): when skip_exhausted=false the operator + // has opted into still acquiring quota-exhausted accounts, so they remain + // usable and we must report authenticated. + if ( + entry.status === "active" && + (!skipExhausted || !hasReachedCachedQuota(entry)) + ) { + return true; + } } return false; } @@ -450,7 +460,15 @@ export class AccountRegistry { updateCachedQuota(entryId: string, quota: CodexQuota): void { const entry = this.accounts.get(entryId); if (!entry) return; - entry.cachedQuota = quota; + // Preserve previously known credits when the incoming quota lacks them. + // The passive header-driven path (rateLimitToQuota in proxy-rate-limit.ts) + // does not carry credit balance — only /codex/usage body (toQuota) does. + // Without this merge, every /codex/responses call would wipe credits. + if (quota.credits == null && entry.cachedQuota?.credits != null) { + entry.cachedQuota = { ...quota, credits: entry.cachedQuota.credits }; + } else { + entry.cachedQuota = quota; + } entry.quotaFetchedAt = new Date().toISOString(); this.schedulePersist(); } diff --git a/src/auth/api-key-catalog.ts b/src/auth/api-key-catalog.ts index 2c9c10cc..21dc7806 100644 --- a/src/auth/api-key-catalog.ts +++ b/src/auth/api-key-catalog.ts @@ -18,7 +18,7 @@ export interface ProviderMeta { } const ANTHROPIC_MODELS: CatalogModel[] = [ - { id: "claude-opus-4-6", displayName: "Claude Opus 4.6" }, + { id: "claude-opus-4-7", displayName: "Claude Opus 4.7" }, { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, { id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5" }, ]; diff --git a/src/auth/api-key-pool.ts b/src/auth/api-key-pool.ts index 53fa5391..1b4825aa 100644 --- a/src/auth/api-key-pool.ts +++ b/src/auth/api-key-pool.ts @@ -22,6 +22,8 @@ import { isBuiltinProvider, PROVIDER_CATALOG } from "./api-key-catalog.js"; // ── Types ────────────────────────────────────────────────────────── export type ApiKeyStatus = "active" | "disabled" | "error"; +export const API_KEY_CAPABILITIES = ["chat", "embeddings"] as const; +export type ApiKeyCapability = typeof API_KEY_CAPABILITIES[number]; export interface ApiKeyEntry { id: string; @@ -30,17 +32,22 @@ export interface ApiKeyEntry { apiKey: string; baseUrl: string; label: string | null; + capabilities: ApiKeyCapability[]; status: ApiKeyStatus; addedAt: string; lastUsedAt: string | null; } +export type PersistedApiKeyEntry = Omit & { + capabilities?: ApiKeyCapability[]; +}; + interface ApiKeysFile { - keys: ApiKeyEntry[]; + keys: PersistedApiKeyEntry[]; } export interface ApiKeyPersistence { - load(): ApiKeyEntry[]; + load(): PersistedApiKeyEntry[]; save(keys: ApiKeyEntry[]): void; } @@ -52,7 +59,7 @@ function getApiKeysFile(): string { export function createFsApiKeyPersistence(): ApiKeyPersistence { return { - load(): ApiKeyEntry[] { + load(): PersistedApiKeyEntry[] { try { const file = getApiKeysFile(); if (!existsSync(file)) return []; @@ -87,7 +94,7 @@ export class ApiKeyPool { constructor(persistence?: ApiKeyPersistence) { this.persistence = persistence ?? createFsApiKeyPersistence(); - this.entries = this.persistence.load(); + this.entries = this.persistence.load().map(normalizeEntry); } // ── Query ────────────────────────────────────────────────────── @@ -102,7 +109,25 @@ export class ApiKeyPool { /** Get all active entries for a given model (exact match). */ getByModel(model: string): ApiKeyEntry[] { - return this.entries.filter((e) => e.model === model && e.status === "active"); + return this.getByModelAndCapability(model, "chat"); + } + + /** Get all active entries for a given model and declared capability. */ + getByModelAndCapability(model: string, capability: ApiKeyCapability): ApiKeyEntry[] { + return this.entries.filter((e) => + e.model === model && + e.status === "active" && + e.capabilities.includes(capability), + ); + } + + /** Pick and mark the least recently used active entry for a model/capability. */ + acquireByModelAndCapability(model: string, capability: ApiKeyCapability): ApiKeyEntry | undefined { + const entries = this.getByModelAndCapability(model, capability); + if (entries.length === 0) return undefined; + const entry = pickLeastRecentlyUsed(entries); + this.markUsed(entry.id); + return entry; } /** Get all active entries for a given provider. */ @@ -128,6 +153,7 @@ export class ApiKeyPool { apiKey: string; baseUrl?: string; label?: string | null; + capabilities?: ApiKeyCapability[]; }): ApiKeyEntry { const baseUrl = input.baseUrl ?? (isBuiltinProvider(input.provider) ? PROVIDER_CATALOG[input.provider].defaultBaseUrl : ""); @@ -139,6 +165,7 @@ export class ApiKeyPool { apiKey: input.apiKey, baseUrl, label: input.label ?? null, + capabilities: normalizeCapabilities(input.capabilities), status: "active", addedAt: new Date().toISOString(), lastUsedAt: null, @@ -187,6 +214,7 @@ export class ApiKeyPool { apiKey: string; baseUrl?: string; label?: string | null; + capabilities?: ApiKeyCapability[]; }>): { added: number; failed: number; errors: string[] } { let added = 0; const errors: string[] = []; @@ -218,6 +246,7 @@ export class ApiKeyPool { apiKey: string; baseUrl: string; label: string | null; + capabilities: ApiKeyCapability[]; }> { return this.entries.map((e) => ({ provider: e.provider, @@ -225,6 +254,7 @@ export class ApiKeyPool { apiKey: e.apiKey, baseUrl: e.baseUrl, label: e.label, + capabilities: e.capabilities, })); } @@ -243,3 +273,31 @@ function maskKey(key: string): string { if (key.length <= 8) return "****"; return key.slice(0, 4) + "****" + key.slice(-4); } + +function isApiKeyCapability(value: unknown): value is ApiKeyCapability { + return value === "chat" || value === "embeddings"; +} + +function normalizeCapabilities(value: unknown): ApiKeyCapability[] { + if (!Array.isArray(value)) return ["chat"]; + const capabilities = value.filter(isApiKeyCapability); + const deduped = [...new Set(capabilities)]; + return deduped.length > 0 ? deduped : ["chat"]; +} + +function normalizeEntry(entry: PersistedApiKeyEntry): ApiKeyEntry { + return { + ...entry, + capabilities: normalizeCapabilities(entry.capabilities), + }; +} + +function pickLeastRecentlyUsed(entries: ApiKeyEntry[]): ApiKeyEntry { + let best = entries[0]; + for (let i = 1; i < entries.length; i++) { + const entry = entries[i]; + if (!entry.lastUsedAt) return entry; + if (!best.lastUsedAt || entry.lastUsedAt < best.lastUsedAt) best = entry; + } + return best; +} diff --git a/src/auth/cf-path-block-tracker.ts b/src/auth/cf-path-block-tracker.ts new file mode 100644 index 00000000..33489b95 --- /dev/null +++ b/src/auth/cf-path-block-tracker.ts @@ -0,0 +1,55 @@ +/** + * Tracks consecutive Cloudflare path-block 404s per account entry. + * + * Background: Cloudflare's Bot Management can answer a "trust this client" + * mismatch with an empty-body 404 on the guarded path (e.g. + * /codex/responses) while leaving lighter paths (e.g. /codex/usage) + * reachable. The proxy reacts by clearing the account's cookie jar and + * retrying on a different account; this tracker watches for the + * pathological case where cookie clearing doesn't help (repeated CF + * blocks even with empty jar). When a configurable threshold is hit + * inside a sliding window, the account is disabled so it stops poisoning + * affinity routing for the same conversation. + * + * Stale entries (no increment within the window) auto-reset on the next + * increment, so an isolated CF blip never adds up over days into a + * spurious disable. + */ + +const STALE_MS = 60 * 60 * 1000; // 1h sliding window + +interface BlockState { + count: number; + lastAt: number; +} + +const counts = new Map(); + +/** + * Record one CF path-block 404 for the given entryId and return the + * resulting consecutive-block count (within the sliding window). + */ +export function recordCfPathBlock(entryId: string, now: number = Date.now()): number { + const prev = counts.get(entryId); + const count = !prev || now - prev.lastAt > STALE_MS ? 1 : prev.count + 1; + counts.set(entryId, { count, lastAt: now }); + return count; +} + +/** Reset the counter for an entry (e.g. on manual re-activation). */ +export function resetCfPathBlock(entryId: string): void { + counts.delete(entryId); +} + +/** Current count for an entry, without mutating. Returns 0 if absent or stale. */ +export function peekCfPathBlock(entryId: string, now: number = Date.now()): number { + const prev = counts.get(entryId); + if (!prev) return 0; + if (now - prev.lastAt > STALE_MS) return 0; + return prev.count; +} + +/** Visible for tests. */ +export function _resetAllCfPathBlocks(): void { + counts.clear(); +} diff --git a/src/auth/quota-utils.ts b/src/auth/quota-utils.ts index 47496f6b..fc033c69 100644 --- a/src/auth/quota-utils.ts +++ b/src/auth/quota-utils.ts @@ -3,8 +3,29 @@ * Converts CodexUsageResponse (raw backend) → CodexQuota (normalized). */ -import type { CodexQuota } from "./types.js"; -import type { CodexUsageRateLimit, CodexUsageResponse } from "../proxy/codex-api.js"; +import type { CodexQuota, CodexQuotaCredits } from "./types.js"; +import type { CodexUsageCredits, CodexUsageRateLimit, CodexUsageResponse } from "../proxy/codex-api.js"; + +function normalizeCredits(raw: CodexUsageCredits | null | undefined): CodexQuotaCredits | null { + if (!raw) return null; + // balance must be parseable — upstream always sends a decimal string, + // but defensively reject malformed payloads so the dashboard never + // shows NaN credits. + if (typeof raw.balance !== "string") return null; + const balance = Number(raw.balance); + if (!Number.isFinite(balance)) return null; + return { + has_credits: Boolean(raw.has_credits), + unlimited: Boolean(raw.unlimited), + overage_limit_reached: Boolean(raw.overage_limit_reached), + balance, + }; +} + +function remainingPercent(used: number | null | undefined): number | null { + if (typeof used !== "number" || !Number.isFinite(used)) return null; + return Math.max(0, Math.min(100, Math.round(100 - Math.max(0, Math.min(100, used))))); +} function isReviewLimitId(value: string | null | undefined): boolean { const normalized = (value ?? "").trim().toLowerCase().replace(/[-\s]+/g, "_"); @@ -18,10 +39,12 @@ function isReviewLimitId(value: string | null | undefined): boolean { function quotaFromRateLimit(rateLimit: CodexUsageRateLimit | null | undefined) { if (!rateLimit) return null; + const usedPercent = rateLimit.primary_window?.used_percent ?? null; return { allowed: rateLimit.allowed, limit_reached: rateLimit.limit_reached, - used_percent: rateLimit.primary_window?.used_percent ?? null, + used_percent: usedPercent, + remaining_percent: remainingPercent(usedPercent), reset_at: rateLimit.primary_window?.reset_at ?? null, limit_window_seconds: rateLimit.primary_window?.limit_window_seconds ?? null, }; @@ -30,9 +53,11 @@ function quotaFromRateLimit(rateLimit: CodexUsageRateLimit | null | undefined) { function secondaryQuotaFromRateLimit(rateLimit: CodexUsageRateLimit | null | undefined) { const secondary = rateLimit?.secondary_window; if (!secondary) return null; + const usedPercent = secondary.used_percent ?? null; return { limit_reached: secondary.used_percent != null ? secondary.used_percent >= 100 : Boolean(rateLimit?.limit_reached), - used_percent: secondary.used_percent ?? null, + used_percent: usedPercent, + remaining_percent: remainingPercent(usedPercent), reset_at: secondary.reset_at ?? null, limit_window_seconds: secondary.limit_window_seconds ?? null, }; @@ -40,6 +65,7 @@ function secondaryQuotaFromRateLimit(rateLimit: CodexUsageRateLimit | null | und export function toQuota(usage: CodexUsageResponse): CodexQuota { const sw = usage.rate_limit.secondary_window; + const primaryUsedPercent = usage.rate_limit.primary_window?.used_percent ?? null; const additional = usage.additional_rate_limits ?? []; const rateLimitsByLimitId: NonNullable = {}; for (const item of additional) { @@ -66,21 +92,16 @@ export function toQuota(usage: CodexUsageResponse): CodexQuota { rate_limit: { allowed: usage.rate_limit.allowed, limit_reached: usage.rate_limit.limit_reached, - used_percent: usage.rate_limit.primary_window?.used_percent ?? null, + used_percent: primaryUsedPercent, + remaining_percent: remainingPercent(primaryUsedPercent), reset_at: usage.rate_limit.primary_window?.reset_at ?? null, limit_window_seconds: usage.rate_limit.primary_window?.limit_window_seconds ?? null, }, - secondary_rate_limit: sw - ? { - limit_reached: sw.used_percent != null ? sw.used_percent >= 100 : usage.rate_limit.limit_reached, - used_percent: sw.used_percent ?? null, - reset_at: sw.reset_at ?? null, - limit_window_seconds: sw.limit_window_seconds ?? null, - } - : null, + secondary_rate_limit: secondaryQuotaFromRateLimit(usage.rate_limit), code_review_rate_limit: codeReviewRateLimit, rate_limits_by_limit_id: Object.keys(rateLimitsByLimitId).length > 0 ? rateLimitsByLimitId : null, + credits: normalizeCredits(usage.credits), }; } diff --git a/src/auth/types.ts b/src/auth/types.ts index 41b3b759..a2def7b7 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -94,11 +94,21 @@ export interface AccountInfo { /** A single rate limit window (primary or secondary). */ export interface CodexQuotaWindow { used_percent: number | null; + remaining_percent?: number | null; reset_at: number | null; limit_window_seconds: number | null; } -/** Official Codex quota from /backend-api/codex/usage */ +/** Normalized credit accounting for an account. */ +export interface CodexQuotaCredits { + has_credits: boolean; + unlimited: boolean; + overage_limit_reached: boolean; + /** Numeric balance parsed from the upstream decimal-string field. */ + balance: number; +} + +/** Official Codex quota from /backend-api/wham/usage or /backend-api/codex/usage. */ export interface CodexQuota { plan_type: string; rate_limit: CodexQuotaWindow & { @@ -113,9 +123,12 @@ export interface CodexQuota { allowed: boolean; limit_reached: boolean; used_percent: number | null; + remaining_percent?: number | null; reset_at: number | null; limit_window_seconds: number | null; } | null; + /** Credit accounting (Pro / PAYG only — null for Plus). */ + credits?: CodexQuotaCredits | null; /** All metered quota buckets returned by Codex app's /wham/usage additional_rate_limits. */ rate_limits_by_limit_id?: Record).port = parsed; } } + const serverHostEnv = process.env.CODEX_PROXY_HOST?.trim(); + const localServer = localOverrides?.server as Record | undefined; + const localHasServerHost = localServer !== undefined && "host" in localServer; + if (serverHostEnv && !localHasServerHost) { + if (!raw.server) raw.server = {}; + (raw.server as Record).host = serverHostEnv; + } const ollamaEnabledEnv = process.env.OLLAMA_BRIDGE_ENABLED?.trim().toLowerCase(); const ollamaHostEnv = process.env.OLLAMA_BRIDGE_HOST?.trim(); const ollamaPortEnv = process.env.OLLAMA_BRIDGE_PORT?.trim(); diff --git a/src/config-schema.ts b/src/config-schema.ts index 213db50f..661a2cb3 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -104,7 +104,7 @@ export const ConfigSchema = z.object({ oauth_token_endpoint: z.string().default("https://auth.openai.com/oauth/token"), }), server: z.object({ - host: z.string().default("0.0.0.0"), + host: z.string().default("127.0.0.1"), port: z.number().min(1).max(65535).default(8080), proxy_api_key: z.string().nullable().default(null), trust_proxy: z.boolean().default(false), @@ -126,6 +126,10 @@ export const ConfigSchema = z.object({ snapshot_interval_minutes: z.number().int().min(0).default(5), /** null means keep usage history forever. */ history_retention_days: z.number().int().positive().nullable().default(null), + /** Conversion rate for displaying Codex credits as USD on the dashboard. + * Default 25 matches the public rate card (1000 credits = $40 → $0.04/credit). + * Set to 0 to suppress USD rendering and only show raw credit numbers. */ + credits_per_usd: z.number().min(0).default(25), }).default({}), session: z.object({ ttl_minutes: z.number().min(1).default(1440), diff --git a/src/index.ts b/src/index.ts index a7fe9a6c..cfbe74b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,13 +38,13 @@ import { startQuotaRefresh, stopQuotaRefresh } from "./auth/usage-refresher.js"; import { UsageStatsStore } from "./auth/usage-stats.js"; import { startSessionCleanup, stopSessionCleanup } from "./auth/dashboard-session.js"; import { createDashboardAuthRoutes } from "./routes/dashboard-login.js"; -import { UpstreamRouter } from "./proxy/upstream-router.js"; import { OpenAIUpstream } from "./proxy/openai-upstream.js"; import { AnthropicUpstream } from "./proxy/anthropic-upstream.js"; import { GeminiUpstream } from "./proxy/gemini-upstream.js"; import { ApiKeyPool } from "./auth/api-key-pool.js"; import { createApiKeyRoutes } from "./routes/api-keys.js"; -import { createAdapterForEntry } from "./proxy/adapter-factory.js"; +import { createEmbeddingsRoutes } from "./routes/embeddings.js"; +import { createRuntimeUpstreamRouter } from "./proxy/upstream-router-bootstrap.js"; import { startOllamaBridge, stopOllamaBridge } from "./ollama/server.js"; import { createOfficialAgentRoutes } from "./routes/official-agent.js"; import { installUncaughtErrorHandlers } from "./logs/error-log.js"; @@ -160,16 +160,8 @@ export async function startServer(options?: StartOptions): Promise // Initialize API key pool for runtime-managed third-party keys const apiKeyPool = new ApiKeyPool(); const hasApiKeys = apiKeyPool.getAll().length > 0; - - const upstreamRouter = (adapters.size > 0 || hasApiKeys) - ? new UpstreamRouter(adapters, cfg.model_routing, "codex") - : undefined; - - // Attach API key pool to router for dynamic model resolution - if (upstreamRouter) { - upstreamRouter.setApiKeyPool(apiKeyPool, createAdapterForEntry); - if (hasApiKeys) console.log(`[Init] API key pool: ${apiKeyPool.getAll().length} key(s) loaded`); - } + const upstreamRouter = createRuntimeUpstreamRouter(adapters, cfg.model_routing, apiKeyPool); + if (hasApiKeys) console.log(`[Init] API key pool: ${apiKeyPool.getAll().length} key(s) loaded`); // Mount routes const authRoutes = createAuthRoutes(accountPool, refreshScheduler); @@ -179,6 +171,7 @@ export async function startServer(options?: StartOptions): Promise const geminiRoutes = createGeminiRoutes(accountPool, cookieJar, proxyPool, upstreamRouter); const responsesRoutes = createResponsesRoutes(accountPool, cookieJar, proxyPool, upstreamRouter); const apiKeyRoutes = createApiKeyRoutes(apiKeyPool); + const embeddingsRoutes = createEmbeddingsRoutes(accountPool, apiKeyPool); const proxyRoutes = createProxyRoutes(proxyPool, accountPool); const usageStats = new UsageStatsStore(); usageStats.recoverBaseline(accountPool); @@ -188,6 +181,7 @@ export async function startServer(options?: StartOptions): Promise app.route("/", authRoutes); app.route("/", accountRoutes); app.route("/", apiKeyRoutes); + app.route("/", embeddingsRoutes); app.route("/", chatRoutes); app.route("/", messagesRoutes); app.route("/", geminiRoutes); diff --git a/src/logs/error-log.ts b/src/logs/error-log.ts index 118c2567..dfc0fbd4 100644 --- a/src/logs/error-log.ts +++ b/src/logs/error-log.ts @@ -27,6 +27,7 @@ import { readFileSync, renameSync, statSync, + unlinkSync, writeFileSync, } from "fs"; import { resolve } from "path"; @@ -214,6 +215,18 @@ export function readErrorLog(limit?: number): ErrorLogEntry[] { return combined; } +/** Remove all persisted error log entries and the read cursor. */ +export function clearErrorLog(): void { + for (const file of [LOG_FILE, BACKUP_FILE, CURSOR_FILE]) { + try { + const path = resolve(getDataDir(), file); + if (existsSync(path)) unlinkSync(path); + } catch { + // Clearing is best-effort; a failed delete must not break the admin UI. + } + } +} + function firstStackFrame(stack: string | undefined): string { if (!stack) return ""; for (const line of stack.split("\n")) { diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index af93bd19..13c6c6fe 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -21,7 +21,7 @@ export const cors: MiddlewareHandler = async (c, next) => { "Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", "Access-Control-Allow-Headers": "*", "Access-Control-Max-Age": "86400", - Vary: "Origin", + Vary: "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", }, }); } diff --git a/src/proxy/codex-api.ts b/src/proxy/codex-api.ts index 42db1cf1..04f243c7 100644 --- a/src/proxy/codex-api.ts +++ b/src/proxy/codex-api.ts @@ -49,6 +49,9 @@ export type { CodexUsageRateWindow, CodexUsageRateLimit, CodexUsageResponse, + CodexUsageCredits, + CodexUsageSpendControl, + CodexUsageRateLimitReachedType, } from "./codex-types.js"; // Re-export SSE utilities for consumers that used them via CodexApi diff --git a/src/proxy/codex-types.ts b/src/proxy/codex-types.ts index 497eb699..f5e33903 100644 --- a/src/proxy/codex-types.ts +++ b/src/proxy/codex-types.ts @@ -121,13 +121,41 @@ export interface CodexUsageAdditionalRateLimit { rate_limit: CodexUsageRateLimit | null; } +/** Credit accounting block from /backend-api/codex/usage. + * Populated for Pro / Pay-As-You-Go accounts; for Plus accounts the + * block is present but has_credits=false and balance="0". */ +export interface CodexUsageCredits { + has_credits: boolean; + unlimited: boolean; + overage_limit_reached: boolean; + /** Decimal string. Upstream returns "0", "12.345", etc. */ + balance: string; + /** Approximate remaining messages, tuple of [low, high]. */ + approx_local_messages?: [number, number]; + approx_cloud_messages?: [number, number]; +} + +/** Per-account spend control (if user set a hard limit). */ +export interface CodexUsageSpendControl { + reached: boolean; + individual_limit: number | string | null; +} + +/** Diagnostic about which limit type was hit when limit_reached=true. */ +export interface CodexUsageRateLimitReachedType { + type: string; + details: string | null; +} + export interface CodexUsageResponse { plan_type: string; rate_limit: CodexUsageRateLimit; code_review_rate_limit: CodexUsageRateLimit | null; additional_rate_limits?: CodexUsageAdditionalRateLimit[] | null; - credits: unknown; - promo: unknown; + credits?: CodexUsageCredits | null; + spend_control?: CodexUsageSpendControl | null; + rate_limit_reached_type?: CodexUsageRateLimitReachedType | null; + promo?: unknown; } export class CodexApiError extends Error { diff --git a/src/proxy/cookie-jar.ts b/src/proxy/cookie-jar.ts index 315fc35d..b58b82c3 100644 --- a/src/proxy/cookie-jar.ts +++ b/src/proxy/cookie-jar.ts @@ -36,8 +36,26 @@ interface CookieFileV2 { accounts: Record>; } +/** + * Cookies allowed to be auto-captured from Set-Cookie response headers. + * + * Why a whitelist: `__cf_bm` is Cloudflare's Bot Management *session* cookie + * (not a challenge-pass token). When captured and replayed on later requests + * it acts as a "trust this is the same client" tag bound to (IP + UA + TLS + * fingerprint + timing) at issue time. If any of those drift (proxy pool + * rotates egress IP, ~30 min lifetime expires) CF returns 404 — empty-body + * "path not found" — on heavily-guarded paths like /codex/responses, while + * /codex/usage stays reachable. The cookie makes the account *worse off* + * than sending no cookie. Only `cf_clearance` (positive challenge-pass) is + * useful to replay. + * + * Manual set() via the admin API is NOT subject to this whitelist — + * operators can still inject arbitrary cookies for debugging. + */ +const CAPTURABLE_COOKIE_NAMES = new Set(["cf_clearance"]); + /** Critical cookie names that trigger immediate persistence on change */ -const CRITICAL_COOKIES = new Set(["cf_clearance", "__cf_bm"]); +const CRITICAL_COOKIES = new Set(["cf_clearance"]); export class CookieJar { private cookies: Map> = new Map(); @@ -125,6 +143,7 @@ export class CookieJar { const name = pair.slice(0, eq).trim(); const value = pair.slice(eq + 1).trim(); if (!name) continue; + if (!CAPTURABLE_COOKIE_NAMES.has(name)) continue; // Parse expiry from attributes let expires: number | null = null; diff --git a/src/proxy/error-classification.ts b/src/proxy/error-classification.ts index 61472b94..5e949f73 100644 --- a/src/proxy/error-classification.ts +++ b/src/proxy/error-classification.ts @@ -95,6 +95,22 @@ export function isUnansweredFunctionCallError(err: unknown): boolean { return haystack.includes("no tool output found for function call"); } +/** + * Detects Cloudflare path-level bot blocks that surface as empty-body 404s. + * + * Cloudflare's Bot Management can "hide" a guarded path (e.g. /codex/responses) + * by returning 404 with no body when the session's __cf_bm cookie or + * fingerprint no longer matches what it issued — this is its standard + * "stealth deny" pattern (more deniable than 403). The distinguishing + * signal is the empty body: real Codex 404s from upstream always carry a + * JSON error payload. + */ +export function isCfPathBlockError(err: unknown): boolean { + if (!isCodexLike(err)) return false; + if (err.status !== 404) return false; + return err.body.trim().length === 0; +} + /** Check if a CodexApiError indicates the model is not supported on the account's plan. */ export function isModelNotSupportedError(err: CodexLikeError): boolean { if (err.status < 400 || err.status >= 500 || err.status === 429) return false; diff --git a/src/proxy/rate-limit-headers.ts b/src/proxy/rate-limit-headers.ts index 70825e4b..e64c12a4 100644 --- a/src/proxy/rate-limit-headers.ts +++ b/src/proxy/rate-limit-headers.ts @@ -78,11 +78,16 @@ export function rateLimitToQuota( ): CodexQuota { const primary = rl.primary; const secondary = rl.secondary; + const remainingPercent = (used: number | null | undefined): number | null => + typeof used === "number" && Number.isFinite(used) + ? Math.max(0, Math.min(100, Math.round(100 - Math.max(0, Math.min(100, used))))) + : null; return { plan_type: planType ?? "unknown", rate_limit: { used_percent: primary?.used_percent ?? null, + remaining_percent: remainingPercent(primary?.used_percent), reset_at: primary?.reset_at ?? null, limit_window_seconds: primary?.window_minutes != null ? primary.window_minutes * 60 : null, allowed: true, @@ -91,6 +96,7 @@ export function rateLimitToQuota( secondary_rate_limit: secondary ? { used_percent: secondary.used_percent, + remaining_percent: remainingPercent(secondary.used_percent), reset_at: secondary.reset_at, limit_window_seconds: secondary.window_minutes != null ? secondary.window_minutes * 60 : null, limit_reached: secondary.used_percent >= 100, @@ -103,6 +109,7 @@ export function rateLimitToQuota( rl.code_review.limit_reached ?? (rl.code_review.primary?.used_percent ?? 0) >= 100, used_percent: rl.code_review.primary?.used_percent ?? null, + remaining_percent: remainingPercent(rl.code_review.primary?.used_percent), reset_at: rl.code_review.primary?.reset_at ?? null, limit_window_seconds: rl.code_review.primary?.window_minutes != null diff --git a/src/proxy/upstream-router-bootstrap.ts b/src/proxy/upstream-router-bootstrap.ts new file mode 100644 index 00000000..e9979301 --- /dev/null +++ b/src/proxy/upstream-router-bootstrap.ts @@ -0,0 +1,15 @@ +import type { ApiKeyPool } from "../auth/api-key-pool.js"; +import type { UpstreamAdapter } from "./upstream-adapter.js"; +import { createAdapterForEntry } from "./adapter-factory.js"; +import { UpstreamRouter, type AdapterFactory } from "./upstream-router.js"; + +export function createRuntimeUpstreamRouter( + adapters: Map, + modelRouting: Record, + apiKeyPool: ApiKeyPool, + adapterFactory: AdapterFactory = createAdapterForEntry, +): UpstreamRouter { + const router = new UpstreamRouter(adapters, modelRouting, "codex"); + router.setApiKeyPool(apiKeyPool, adapterFactory); + return router; +} diff --git a/src/routes/accounts.ts b/src/routes/accounts.ts index 3326e269..86d8a1a8 100644 --- a/src/routes/accounts.ts +++ b/src/routes/accounts.ts @@ -17,8 +17,15 @@ import { isBanError, isTokenInvalidError } from "../proxy/error-classification.j import { clearWarnings, getActiveWarnings, getWarningsLastUpdated } from "../auth/quota-warnings.js"; import { probeAccount, batchHealthCheck } from "../auth/health-check.js"; import { AccountImportService } from "../services/account-import.js"; +import type { ImportEntry } from "../services/account-import.js"; import { AccountQueryService } from "../services/account-query.js"; import { AccountMutationService } from "../services/account-mutation.js"; +import { + buildAccountExportPayload, + parseAccountExportFormat, + parseAccountImportPayload, + parseAccountImportText, +} from "../services/account-transfer-formats.js"; const BatchIdsSchema = z.object({ ids: z.array(z.string()).min(1) }); const HealthCheckSchema = z.object({ @@ -28,17 +35,6 @@ const HealthCheckSchema = z.object({ }).optional(); const BatchStatusSchema = z.object({ ids: z.array(z.string()).min(1), status: z.enum(["active", "disabled"]) }); const LabelSchema = z.object({ label: z.string().max(64).nullable() }); -const emptyStringToUndefined = (v: unknown) => - typeof v === "string" && v.trim() === "" ? undefined : v; -const emptyStringToNull = (v: unknown) => - typeof v === "string" && v.trim() === "" ? null : v; -const BulkImportEntrySchema = z.object({ - token: z.preprocess(emptyStringToUndefined, z.string().min(1).optional()), - refreshToken: z.preprocess(emptyStringToNull, z.string().min(1).nullable().optional()), - label: z.string().max(64).nullable().optional(), -}).refine((d) => Boolean(d.token) || Boolean(d.refreshToken), { message: "Either token or refreshToken is required" }); -const BulkImportSchema = z.object({ accounts: z.array(BulkImportEntrySchema).min(1) }); - export function createAccountRoutes(pool: AccountPool, scheduler: RefreshScheduler, cookieJar?: CookieJar, proxyPool?: ProxyPool): Hono { const app = new Hono(); const importSvc = new AccountImportService(pool, scheduler, { @@ -83,16 +79,29 @@ export function createAccountRoutes(pool: AccountPool, scheduler: RefreshSchedul app.get("/auth/accounts/export", (c) => { const ids = c.req.query("ids")?.split(",").filter(Boolean); - if (c.req.query("format") === "minimal") return c.json({ accounts: querySvc.exportMinimal(ids) }); - return c.json({ accounts: querySvc.exportFull(ids) }); + const format = parseAccountExportFormat(c.req.query("format")); + if (!format) { + c.status(400); + return c.json({ error: "Unsupported export format" }); + } + return c.json(buildAccountExportPayload(querySvc.exportFull(ids), format)); }); app.post("/auth/accounts/import", async (c) => { - let body: unknown; - try { body = await c.req.json(); } catch { c.status(400); return c.json({ error: "Malformed JSON request body" }); } - const parsed = BulkImportSchema.safeParse(body); - if (!parsed.success) { c.status(400); return c.json({ error: "Invalid request", details: parsed.error.issues }); } - return c.json({ success: true, ...(await importSvc.importMany(parsed.data.accounts)) }); + const contentType = c.req.header("content-type") ?? ""; + let entries: ImportEntry[]; + if (contentType.includes("text/plain")) { + entries = parseAccountImportText(await c.req.text()); + } else { + let body: unknown; + try { body = await c.req.json(); } catch { c.status(400); return c.json({ error: "Malformed JSON request body" }); } + entries = parseAccountImportPayload(body); + } + if (entries.length === 0) { + c.status(400); + return c.json({ error: "Invalid request", details: [{ message: "No importable accounts found" }] }); + } + return c.json({ success: true, ...(await importSvc.importMany(entries)) }); }); app.post("/auth/accounts/batch-delete", async (c) => { @@ -154,7 +163,7 @@ export function createAccountRoutes(pool: AccountPool, scheduler: RefreshSchedul return c.json({ success: true }); }); - app.get("/auth/accounts", async (c) => { + app.get("/auth/accounts", (c) => { const accounts = querySvc.listFresh(); return c.json({ accounts, @@ -187,7 +196,14 @@ export function createAccountRoutes(pool: AccountPool, scheduler: RefreshSchedul if (entry.status !== "active") { c.status(409); return c.json({ error: `Account is ${entry.status}, cannot query quota` }); } try { const usage = await new CodexApi(entry.token, entry.accountId, cookieJar, id, proxyPool?.resolveProxyUrl(id)).getUsage(); - return c.json({ quota: toQuota(usage), raw: usage }); + const quota = toQuota(usage); + // Persist the fresh quota so the dashboard reflects upstream reality + // (especially after OpenAI does a window reset / promo refresh) without + // waiting for the next proxied /codex/responses request to passively + // refill cachedQuota via response headers. Also the only path that + // carries the credits block — the header path doesn't include it. + pool.updateCachedQuota(id, quota); + return c.json({ quota, raw: usage }); } catch (err) { // Auto-mark invalidated/banned accounts if (isTokenInvalidError(err)) { diff --git a/src/routes/admin/error-logs.ts b/src/routes/admin/error-logs.ts index 0bcd2a9e..820de4d0 100644 --- a/src/routes/admin/error-logs.ts +++ b/src/routes/admin/error-logs.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { z } from "zod"; import { appendErrorLog, + clearErrorLog, groupErrorLog, getUnreadCount, readErrorLog, @@ -59,6 +60,11 @@ export function createErrorLogRoutes(): Hono { return c.json({ ok: true, cursor }); }); + app.delete("/admin/error-logs", (c) => { + clearErrorLog(); + return c.json({ ok: true }); + }); + app.post("/admin/error-logs/report", async (c) => { const raw = await c.req.json().catch(() => null); if (raw === null) { diff --git a/src/routes/admin/settings.ts b/src/routes/admin/settings.ts index ef024b0e..3356674d 100644 --- a/src/routes/admin/settings.ts +++ b/src/routes/admin/settings.ts @@ -153,6 +153,7 @@ export function createSettingsRoutes(): Hono { logs_capture_body: config.logs.capture_body, logs_llm_only: config.logs.llm_only, usage_history_retention_days: config.usage_stats.history_retention_days, + credits_per_usd: config.usage_stats.credits_per_usd, }); }); @@ -191,6 +192,7 @@ export function createSettingsRoutes(): Hono { logs_capture_body?: boolean; logs_llm_only?: boolean; usage_history_retention_days?: number | null; + credits_per_usd?: number; }; // --- validation --- @@ -273,6 +275,13 @@ export function createSettingsRoutes(): Hono { } } + if (body.credits_per_usd !== undefined) { + if (!Number.isFinite(body.credits_per_usd) || body.credits_per_usd < 0) { + c.status(400); + return c.json({ error: "credits_per_usd must be a number >= 0" }); + } + } + const oldPort = config.server.port; const oldDefaultModel = config.model.default; @@ -361,6 +370,10 @@ export function createSettingsRoutes(): Hono { if (!data.usage_stats) data.usage_stats = {}; (data.usage_stats as Record).history_retention_days = body.usage_history_retention_days; } + if (body.credits_per_usd !== undefined) { + if (!data.usage_stats) data.usage_stats = {}; + (data.usage_stats as Record).credits_per_usd = body.credits_per_usd; + } }); reloadAllConfigs(); @@ -398,6 +411,7 @@ export function createSettingsRoutes(): Hono { logs_capture_body: updated.logs?.capture_body ?? false, logs_llm_only: updated.logs?.llm_only ?? true, usage_history_retention_days: updated.usage_stats.history_retention_days, + credits_per_usd: updated.usage_stats.credits_per_usd, restart_required: restartRequired, }); }); diff --git a/src/routes/api-keys.ts b/src/routes/api-keys.ts index efc1317e..690ca15d 100644 --- a/src/routes/api-keys.ts +++ b/src/routes/api-keys.ts @@ -6,11 +6,13 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { z } from "zod"; +import { API_KEY_CAPABILITIES } from "../auth/api-key-pool.js"; import type { ApiKeyEntry, ApiKeyPool } from "../auth/api-key-pool.js"; import { PROVIDER_CATALOG } from "../auth/api-key-catalog.js"; const VALID_PROVIDERS = ["anthropic", "openai", "gemini", "openrouter", "custom"] as const; const ModelsSchema = z.array(z.string().trim().min(1)).min(1).transform((models) => [...new Set(models)]); +const CapabilitiesSchema = z.array(z.enum(API_KEY_CAPABILITIES)).min(1).transform((capabilities) => [...new Set(capabilities)]).optional(); const ApiKeyBindingSchema = z.object({ provider: z.enum(VALID_PROVIDERS), @@ -18,6 +20,7 @@ const ApiKeyBindingSchema = z.object({ apiKey: z.string().min(1), baseUrl: z.string().url().optional(), label: z.string().max(64).nullable().optional(), + capabilities: CapabilitiesSchema, }).refine( (d) => d.provider !== "custom" || Boolean(d.baseUrl), { message: "baseUrl is required for custom providers" }, @@ -78,6 +81,7 @@ function addEntries(pool: ApiKeyPool, items: ApiKeyBindingInput[]): { apiKey: item.apiKey, baseUrl: item.baseUrl, label: item.label, + capabilities: item.capabilities, })); } catch (err) { errors.push(err instanceof Error ? err.message : String(err)); diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 6a34ba8b..947924a1 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import type { Context } from "hono"; import { ChatCompletionRequestSchema } from "../types/openai.js"; import type { AccountPool } from "../auth/account-pool.js"; import type { CookieJar } from "../proxy/cookie-jar.js"; @@ -72,6 +73,27 @@ function formatModelNotFound(model: string) { }; } +function checkProxyApiKey(c: Context, accountPool: AccountPool) { + const config = getConfig(); + if (!config.server.proxy_api_key) return null; + + const authHeader = c.req.header("Authorization"); + const providedKey = authHeader?.replace("Bearer ", ""); + if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) { + c.status(401); + return c.json({ + error: { + message: "Invalid proxy API key", + type: "invalid_request_error", + param: null, + code: "invalid_api_key", + }, + }); + } + + return null; +} + export function createChatRoutes( accountPool: AccountPool, cookieJar?: CookieJar, @@ -145,6 +167,9 @@ export function createChatRoutes( }); if (routeMatch.kind === "api-key" || routeMatch.kind === "adapter") { + const authError = checkProxyApiKey(c, accountPool); + if (authError) return authError; + const directModel = routeMatch.resolvedModel ?? req.model; const directReq = { ...proxyReq, @@ -172,22 +197,8 @@ export function createChatRoutes( return handleProxyRequest({ c, accountPool, cookieJar, req: proxyReq, fmt, proxyPool }); } - const config = getConfig(); - if (config.server.proxy_api_key) { - const authHeader = c.req.header("Authorization"); - const providedKey = authHeader?.replace("Bearer ", ""); - if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) { - c.status(401); - return c.json({ - error: { - message: "Invalid proxy API key", - type: "invalid_request_error", - param: null, - code: "invalid_api_key", - }, - }); - } - } + const authError = checkProxyApiKey(c, accountPool); + if (authError) return authError; return handleProxyRequest({ c, accountPool, cookieJar, req: proxyReq, fmt, proxyPool }); }); diff --git a/src/routes/embeddings.ts b/src/routes/embeddings.ts new file mode 100644 index 00000000..cceb4969 --- /dev/null +++ b/src/routes/embeddings.ts @@ -0,0 +1,182 @@ +/** + * OpenAI-compatible embeddings route. + * + * Embeddings are only supported through runtime API keys whose capabilities + * explicitly include "embeddings". Codex accounts are not used for this path. + */ + +import { Hono } from "hono"; +import type { Context } from "hono"; +import { z } from "zod"; +import type { AccountPool } from "../auth/account-pool.js"; +import type { ApiKeyEntry, ApiKeyPool } from "../auth/api-key-pool.js"; +import type { ApiKeyProvider } from "../auth/api-key-catalog.js"; +import { getConfig } from "../config.js"; +import { withFetchDispatcher } from "../proxy/fetch-dispatcher.js"; + +const EmbeddingInputSchema = z.union([ + z.string(), + z.array(z.string()).min(1), + z.array(z.number()).min(1), + z.array(z.array(z.number()).min(1)).min(1), +]); + +const EmbeddingsRequestSchema = z.object({ + model: z.string().trim().min(1), + input: EmbeddingInputSchema, + encoding_format: z.enum(["float", "base64"]).optional(), + dimensions: z.number().int().positive().optional(), + user: z.string().optional(), +}).passthrough(); + +type EmbeddingsRequest = z.infer; + +function openAIError(message: string, code: string, param: string | null = null) { + return { + error: { + message, + type: "invalid_request_error", + param, + code, + }, + }; +} + +function invalidProxyApiKeyResponse(c: Context): Response { + c.status(401); + return c.json(openAIError("Invalid proxy API key", "invalid_api_key")); +} + +function extractBearerToken(header: string | undefined): string | null { + if (!header?.startsWith("Bearer ")) return null; + return header.slice("Bearer ".length); +} + +function checkProxyApiKey(c: Context, accountPool: AccountPool): Response | null { + const config = getConfig(); + if (!config.server.proxy_api_key) return null; + + const providedKey = c.req.header("x-api-key") ?? extractBearerToken(c.req.header("Authorization")); + if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) { + return invalidProxyApiKeyResponse(c); + } + + return null; +} + +function supportsOpenAIEmbeddings(provider: ApiKeyProvider): boolean { + return provider === "openai" || provider === "openrouter" || provider === "custom"; +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +function modelCandidates(model: string): string[] { + const trimmed = model.trim(); + const colonIdx = trimmed.indexOf(":"); + if (colonIdx <= 0) return [trimmed]; + + const providerPrefix = trimmed.slice(0, colonIdx); + if ( + providerPrefix === "openai" || + providerPrefix === "openrouter" || + providerPrefix === "custom" || + providerPrefix === "anthropic" || + providerPrefix === "gemini" + ) { + return [trimmed, trimmed.slice(colonIdx + 1)]; + } + + return [trimmed]; +} + +function resolveEmbeddingEntry(pool: ApiKeyPool, model: string): { entry: ApiKeyEntry; upstreamModel: string } | null { + for (const candidate of modelCandidates(model)) { + const entry = pool.acquireByModelAndCapability(candidate, "embeddings"); + if (entry) return { entry, upstreamModel: candidate }; + } + return null; +} + +async function parseEmbeddingsRequest(c: Context): Promise< + { ok: true; data: EmbeddingsRequest } | { ok: false; response: Response } +> { + let body: unknown; + try { + body = await c.req.json(); + } catch { + c.status(400); + return { ok: false, response: c.json(openAIError("Malformed JSON request body", "invalid_json")) }; + } + + const parsed = EmbeddingsRequestSchema.safeParse(body); + if (!parsed.success) { + c.status(400); + return { + ok: false, + response: c.json(openAIError(`Invalid request: ${parsed.error.message}`, "invalid_request")), + }; + } + + return { ok: true, data: parsed.data }; +} + +function buildUpstreamRequestBody(req: EmbeddingsRequest, upstreamModel: string): EmbeddingsRequest { + return { + ...req, + model: upstreamModel, + }; +} + +function copyResponseHeaders(upstream: Response): Headers { + const headers = new Headers(); + const contentType = upstream.headers.get("Content-Type") ?? upstream.headers.get("content-type"); + headers.set("Content-Type", contentType ?? "application/json"); + return headers; +} + +export function createEmbeddingsRoutes(accountPool: AccountPool, apiKeyPool: ApiKeyPool): Hono { + const app = new Hono(); + + app.post("/v1/embeddings", async (c) => { + const authError = checkProxyApiKey(c, accountPool); + if (authError) return authError; + + const parsed = await parseEmbeddingsRequest(c); + if (!parsed.ok) return parsed.response; + + const resolved = resolveEmbeddingEntry(apiKeyPool, parsed.data.model); + if (!resolved) { + c.status(404); + return c.json(openAIError(`Model '${parsed.data.model}' not found for embeddings`, "model_not_found", "model")); + } + + if (!supportsOpenAIEmbeddings(resolved.entry.provider)) { + c.status(400); + return c.json(openAIError( + `Provider '${resolved.entry.provider}' does not support OpenAI-compatible embeddings`, + "unsupported_provider", + "model", + )); + } + + const upstream = await fetch(`${normalizeBaseUrl(resolved.entry.baseUrl)}/embeddings`, withFetchDispatcher({ + method: "POST", + headers: { + "Authorization": `Bearer ${resolved.entry.apiKey}`, + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify(buildUpstreamRequestBody(parsed.data, resolved.upstreamModel)), + signal: c.req.raw.signal, + })); + + return new Response(await upstream.text(), { + status: upstream.status, + headers: copyResponseHeaders(upstream), + }); + }); + + return app; +} diff --git a/src/routes/responses.ts b/src/routes/responses.ts index 817a70c8..7fac58de 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -203,6 +203,7 @@ export async function* streamPassthrough( tupleSchema?: Record | null, streamContext?: StreamTranslatorContext, onResponseCompleted?: (id?: string) => void, + onResponseMetadata?: (metadata: { functionCallIds?: string[] }) => void, ): AsyncGenerator { // When tupleSchema is present, buffer text deltas and reconvert on completion. // This means the client receives zero incremental text — all text arrives at once @@ -210,6 +211,7 @@ export async function* streamPassthrough( let tupleTextBuffer = tupleSchema ? "" : null; let sawTerminal = false; let responseId: string | null = null; + const streamFunctionCallIds = new Set(); const stream = api.parseStream(response); let upstreamDone = false; @@ -300,6 +302,15 @@ export async function* streamPassthrough( // Re-emit raw SSE event yield `event: ${raw.event}\ndata: ${JSON.stringify(raw.data)}\n\n`; + // Collect function_call IDs so session affinity can validate tool-output continuations + if (raw.event === "response.output_item.done") { + const data = raw.data; + if (isRecord(data) && isRecord(data.item) && data.item.type === "function_call") { + const callId = data.item.call_id; + if (typeof callId === "string" && callId) streamFunctionCallIds.add(callId); + } + } + // Extract usage and responseId for account pool bookkeeping if ( raw.event === "response.created" || @@ -316,6 +327,9 @@ export async function* streamPassthrough( } if (raw.event === "response.completed") { onResponseCompleted?.(typeof resp.id === "string" ? resp.id : undefined); + if (streamFunctionCallIds.size > 0) { + onResponseMetadata?.({ functionCallIds: [...streamFunctionCallIds] }); + } } } } @@ -353,6 +367,7 @@ export async function collectPassthrough( response: Response, _model: string, tupleSchema?: Record | null, + onResponseMetadata?: (metadata: { functionCallIds?: string[] }) => void, ): Promise<{ response: unknown; usage: { input_tokens: number; output_tokens: number; cached_tokens?: number; image_input_tokens?: number; image_output_tokens?: number }; @@ -362,6 +377,7 @@ export async function collectPassthrough( let usage: { input_tokens: number; output_tokens: number; cached_tokens?: number; image_input_tokens?: number; image_output_tokens?: number } = { input_tokens: 0, output_tokens: 0 }; let responseId: string | null = null; const outputItems: unknown[] = []; + const collectFunctionCallIds = new Set(); let textDeltas = ""; try { @@ -380,9 +396,15 @@ export async function collectPassthrough( if (raw.event === "response.output_item.done" && isRecord(data.item)) { outputItems.push(data.item); + if (data.item.type === "function_call" && typeof data.item.call_id === "string" && data.item.call_id) { + collectFunctionCallIds.add(data.item.call_id as string); + } } if (raw.event === "response.completed" && resp) { + if (collectFunctionCallIds.size > 0) { + onResponseMetadata?.({ functionCallIds: [...collectFunctionCallIds] }); + } // Codex hosted search 经常完整流出 output_item.done/text delta, // 但 completed.response.output 为空。这里用流式事件回填最终 JSON。 if (Array.isArray(resp.output) && resp.output.length === 0) { @@ -485,10 +507,10 @@ const PASSTHROUGH_FORMAT: FormatAdapter = { }, }), formatStreamError: (status, msg) => buildResponsesStreamError(status, msg), - streamTranslator: ({ api, response, model, onUsage, onResponseId, onResponseCompleted, tupleSchema, streamContext }) => - streamPassthrough(api, response, model, onUsage, onResponseId, tupleSchema, streamContext, onResponseCompleted), - collectTranslator: ({ api, response, model, tupleSchema }) => - collectPassthrough(api, response, model, tupleSchema), + streamTranslator: ({ api, response, model, onUsage, onResponseId, onResponseCompleted, tupleSchema, streamContext, onResponseMetadata }) => + streamPassthrough(api, response, model, onUsage, onResponseId, tupleSchema, streamContext, onResponseCompleted, onResponseMetadata), + collectTranslator: ({ api, response, model, tupleSchema, onResponseMetadata }) => + collectPassthrough(api, response, model, tupleSchema, onResponseMetadata), }; // ── Shared auth check ───────────────────────────────────────────── diff --git a/src/routes/shared/proxy-egress-log.ts b/src/routes/shared/proxy-egress-log.ts index 15003fd4..35d73628 100644 --- a/src/routes/shared/proxy-egress-log.ts +++ b/src/routes/shared/proxy-egress-log.ts @@ -27,6 +27,12 @@ export function recordProxyEgressLog(options: RecordProxyEgressLogOptions): void model: options.request.codexRequest.model, stream: options.request.codexRequest.stream, useWebSocket: options.request.codexRequest.useWebSocket, + ...(options.request.codexRequest.reasoning + ? { reasoning: options.request.codexRequest.reasoning } + : {}), + ...(options.request.codexRequest.service_tier !== undefined && options.request.codexRequest.service_tier !== null + ? { service_tier: options.request.codexRequest.service_tier } + : {}), }, }); } diff --git a/src/routes/shared/proxy-error-handler.ts b/src/routes/shared/proxy-error-handler.ts index 64a4919e..fe9bc7c7 100644 --- a/src/routes/shared/proxy-error-handler.ts +++ b/src/routes/shared/proxy-error-handler.ts @@ -9,12 +9,19 @@ import type { AccountPool } from "../../auth/account-pool.js"; import { extractRetryAfterSec, isBanError, + isCfPathBlockError, isQuotaExhaustedError, isTokenInvalidError, isModelNotSupportedError, } from "../../proxy/error-classification.js"; import type { CodexApiError } from "../../proxy/codex-types.js"; import type { StatusCode } from "hono/utils/http-status"; +import type { CookieJar } from "../../proxy/cookie-jar.js"; +import { recordCfPathBlock } from "../../auth/cf-path-block-tracker.js"; +import { appendErrorLog } from "../../logs/error-log.js"; + +/** Consecutive CF path-blocks before the account is auto-disabled. */ +const CF_PATH_BLOCK_DISABLE_THRESHOLD = 3; /** Clamp an HTTP status to a valid error StatusCode, defaulting to 502 for non-error codes. */ export function toErrorStatus(status: number): StatusCode { @@ -54,6 +61,7 @@ export function handleCodexApiError( model: string, tag: string, modelRetried: boolean, + cookieJar?: CookieJar, ): ErrorAction { const email = pool.getEntry(entryId)?.email ?? "?"; @@ -119,7 +127,44 @@ export function handleCodexApiError( return { action: "retry", status: 401, message: err.message }; } - // 6. Generic error — return to client (preserve original body for passthrough) + // 6. Cloudflare path block (empty-body 404). CF's Bot Management can + // "hide" the /codex/responses path by returning 404 with no body when + // the captured __cf_bm cookie no longer matches the request + // fingerprint. Clear the cookie jar (so the next attempt is a clean, + // fingerprint-only request) and retry on a different account. After + // the threshold is reached within the sliding window, disable the + // account so session affinity stops pinning a dying conversation to + // it. + if (isCfPathBlockError(err)) { + cookieJar?.clear(entryId); + const blockCount = recordCfPathBlock(entryId); + if (blockCount >= CF_PATH_BLOCK_DISABLE_THRESHOLD) { + pool.markStatus(entryId, "disabled"); + console.warn( + `[${tag}] Account ${entryId} (${email}) | Cloudflare path-block 404 ×${blockCount} — auto-disabling account`, + ); + appendErrorLog({ + source: "server", + error: { + name: "CfPathBlockAutoDisable", + message: `Account auto-disabled after ${blockCount} consecutive Cloudflare path-block 404s on /codex/responses`, + }, + context: { entryId, email, model, tag, blockCount }, + }); + } else { + console.warn( + `[${tag}] Account ${entryId} (${email}) | Cloudflare path-block 404 ×${blockCount}, cleared cookies and retrying...`, + ); + } + return { + action: "retry", + releaseBeforeRetry: true, + status: 502, + message: "Upstream blocked the request (Cloudflare path-block)", + }; + } + + // 7. Generic error — return to client (preserve original body for passthrough) const status = toErrorStatus(err.status); return { action: "respond", status, message: err.message, errorBody: err.body }; } diff --git a/src/routes/shared/proxy-handler.ts b/src/routes/shared/proxy-handler.ts index 7170717a..9357a8ff 100644 --- a/src/routes/shared/proxy-handler.ts +++ b/src/routes/shared/proxy-handler.ts @@ -100,7 +100,7 @@ export async function handleProxyRequest(options: HandleProxyRequestOptions): Pr implicitResume.logSkippedWarnings(); implicitResume.activate(); - logRequestDiagnostics({ + const diagnostics = logRequestDiagnostics({ tag: fmt.tag, entryId, requestId, @@ -116,6 +116,38 @@ export async function handleProxyRequest(options: HandleProxyRequestOptions): Pr preferredEntryId: sessionContext.preferredEntryId, }); + // Guard: when implicit resume fails due to missing tool calls, block runaway + // full-history replays that would burn massive token budgets silently. + // Relaxed thresholds: legitimate client-driven full replays (e.g. after + // Codex CLI /compact) regularly hit 300-800KB / 100-800 items, and the + // previous 250KB / 80-item gate was 413'ing them. Real runaway loops + // typically blow past several MB before the issue becomes obvious. + const PAYLOAD_GUARD_BYTES = 2_000_000; + const PAYLOAD_GUARD_ITEMS = 1000; + if ( + implicitResume.evaluation.reason === "missing_tool_calls" || + implicitResume.evaluation.reason === "unanswered_tool_calls" + ) { + const inputItemCount = req.codexRequest.input?.length ?? 0; + if (diagnostics.payloadBytes > PAYLOAD_GUARD_BYTES || inputItemCount > PAYLOAD_GUARD_ITEMS) { + console.warn( + `[${fmt.tag}] ⛔ Payload guard: blocking ${(diagnostics.payloadBytes / 1024).toFixed(0)}KB / ${inputItemCount} items ` + + `full-history replay (resume=${implicitResume.evaluation.reason}). ` + + `Client should compact the conversation.`, + ); + releaseAccount(accountPool, entryId, undefined, released); + return respondWithProxyError({ + c, req, fmt, + status: 413, + message: + `Context too large for full-history replay ` + + `(${(diagnostics.payloadBytes / 1024).toFixed(0)}KB, ${inputItemCount} items). ` + + `Implicit resume failed: ${implicitResume.evaluation.reason}. ` + + `Please compact or restart the conversation.`, + }); + } + } + const abortController = new AbortController(); c.req.raw.signal.addEventListener("abort", () => abortController.abort(), { once: true }); @@ -224,7 +256,7 @@ export async function handleProxyRequest(options: HandleProxyRequestOptions): Pr } const decision = handleCodexApiError( - err, accountPool, entryId, req.codexRequest.model, fmt.tag, modelRetried, + err, accountPool, entryId, req.codexRequest.model, fmt.tag, modelRetried, cookieJar, ); const errorRetryTransition = applyProxyErrorRetryTransition({ diff --git a/src/routes/shared/proxy-session-context.ts b/src/routes/shared/proxy-session-context.ts index a0c08237..bb7c4f58 100644 --- a/src/routes/shared/proxy-session-context.ts +++ b/src/routes/shared/proxy-session-context.ts @@ -5,6 +5,7 @@ import { buildVariantIdentity, getContinuationInputStartIndex, getFunctionCallOutputIds, + getInlineFunctionCallIds, IMPLICIT_RESUME_MAX_AGE_MS, normalizeInstructions, resolvePromptCacheIdentity, @@ -74,6 +75,12 @@ export function buildProxySessionContext( const implicitStoredFunctionCallIds = implicitPrevRespId ? affinityMap.lookupFunctionCallIds(implicitPrevRespId) : []; + // Function_call entries inlined in the full request input — used by + // evaluateImplicitResume to detect self-contained replays where matching + // pairs already exist in the payload and resume is not applicable. + const inlineFunctionCallIds = implicitPrevRespId + ? getInlineFunctionCallIds(codexRequest.input) + : []; const preferredEntryId = explicitPrevRespId ? affinityMap.lookup(explicitPrevRespId) @@ -108,6 +115,7 @@ export function buildProxySessionContext( storedInstructions: implicitStoredInstructions, requiredFunctionCallOutputIds, storedFunctionCallIds: implicitStoredFunctionCallIds, + inlineFunctionCallIds, }, }; } diff --git a/src/routes/shared/proxy-session-helpers.ts b/src/routes/shared/proxy-session-helpers.ts index e1a74607..dc1cd6b6 100644 --- a/src/routes/shared/proxy-session-helpers.ts +++ b/src/routes/shared/proxy-session-helpers.ts @@ -78,6 +78,11 @@ export interface ImplicitResumeOpts { storedInstructions: string | null; requiredFunctionCallOutputIds?: string[]; storedFunctionCallIds?: string[]; + /** call_ids of `function_call` items inlined in the request input itself. + * When a function_call_output references a call_id present here, the + * client is doing a self-contained full-history replay and we should NOT + * treat the absence of that id in session-affinity as "missing tool calls". */ + inlineFunctionCallIds?: string[]; } /** Reason why implicit resume was rejected, or null if it would activate. @@ -102,7 +107,21 @@ export function evaluateImplicitResume(opts: ImplicitResumeOpts): return { active: false, reason: "instr_diff" }; } const storedFunctionCallIds = new Set(opts.storedFunctionCallIds ?? []); + const inlineFunctionCallIds = new Set(opts.inlineFunctionCallIds ?? []); const requiredFunctionCallOutputIds = opts.requiredFunctionCallOutputIds ?? []; + + // Self-contained replay: every function_call_output in the input is paired + // with a function_call also inlined in the input (typical of Codex CLI + // /compact or error-recovery fallback). Implicit resume is not applicable — + // upstream will satisfy the outputs from the inlined calls directly. Bail + // before the missing_tool_calls check so we don't 413 a legitimate replay. + if ( + requiredFunctionCallOutputIds.length > 0 && + requiredFunctionCallOutputIds.every((id) => inlineFunctionCallIds.has(id)) + ) { + return { active: false, reason: "self_contained_replay" }; + } + const missingCallIds = requiredFunctionCallOutputIds.filter((id) => !storedFunctionCallIds.has(id)); if (missingCallIds.length > 0) { return { active: false, reason: "missing_tool_calls", missingCallIds }; @@ -149,3 +168,27 @@ export function getFunctionCallOutputIds(input: CodexResponsesRequest["input"]): !("role" in item) && item.type === "function_call_output") .map((item) => item.call_id); } + +/** Collect call_ids of `function_call` items inlined in the request input. + * Codex CLI emits these when doing a client-side full-history replay (e.g. + * after /compact or error recovery): the input carries the historical + * function_call entries paired with their function_call_output entries, so + * the proxy must not try to validate those outputs against session-affinity's + * stored ids — they reference function_calls that live in the input itself, + * not in any prior upstream response we tracked. */ +export function getInlineFunctionCallIds(input: CodexResponsesRequest["input"]): string[] { + return input + .filter((item): item is { type: "function_call"; call_id: string; name: string; arguments: string } => + !("role" in item) && item.type === "function_call" && typeof item.call_id === "string") + .map((item) => item.call_id); +} + +/** True when every function_call_output in the input is paired with a + * function_call also inlined in the input (i.e. the client is sending a + * self-contained full-history replay, not an incremental continuation). */ +export function isSelfContainedReplay(input: CodexResponsesRequest["input"]): boolean { + const outputs = getFunctionCallOutputIds(input); + if (outputs.length === 0) return false; + const inlineCalls = new Set(getInlineFunctionCallIds(input)); + return outputs.every((id) => inlineCalls.has(id)); +} diff --git a/src/services/account-mutation.ts b/src/services/account-mutation.ts index a4de4914..6a670727 100644 --- a/src/services/account-mutation.ts +++ b/src/services/account-mutation.ts @@ -4,6 +4,7 @@ */ import type { AccountPool } from "../auth/account-pool.js"; +import { resetCfPathBlock } from "../auth/cf-path-block-tracker.js"; export interface DeleteResult { deleted: number; @@ -57,6 +58,9 @@ export class AccountMutationService { const entry = this.pool.getEntry(id); if (entry) { this.pool.markStatus(id, status); + // Re-enabling clears any in-memory CF block streak so the account + // gets a fresh allowance against the auto-disable threshold. + if (status === "active") resetCfPathBlock(id); updated++; } else { notFound.push(id); diff --git a/src/services/account-transfer-formats.ts b/src/services/account-transfer-formats.ts new file mode 100644 index 00000000..72eb27be --- /dev/null +++ b/src/services/account-transfer-formats.ts @@ -0,0 +1,318 @@ +import type { AccountEntry, CodexQuotaWindow } from "../auth/types.js"; +import { decodeJwtPayload } from "../auth/jwt-utils.js"; +import type { ImportEntry } from "./account-import.js"; + +export type AccountExportFormat = "full" | "minimal" | "cockpit_tools" | "sub2api" | "cpa"; + +type JsonRecord = Record; + +interface Sub2ApiAccountItem { + name: string; + platform: "openai"; + type: "oauth"; + credentials: JsonRecord; + concurrency: number; + priority: number; +} + +interface Sub2ApiExportPayload { + exported_at: string; + proxies: []; + accounts: Sub2ApiAccountItem[]; + type: "sub2api-data"; + version: 1; +} + +interface PortableCodexToken { + access_token: string; + refresh_token?: string; + account_id?: string; + last_refresh?: string; + email?: string; + type: "codex"; + expired?: string; +} + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : null; +} + +function normalizeString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function readPath(value: unknown, path: readonly string[]): unknown { + let current: unknown = value; + for (const key of path) { + const record = toRecord(current); + if (!record || !(key in record)) return undefined; + current = record[key]; + } + return current; +} + +function firstString(value: unknown, paths: readonly (readonly string[])[]): string | undefined { + for (const path of paths) { + const found = normalizeString(readPath(value, path)); + if (found) return found; + } + return undefined; +} + +function normalizeLabel(value: unknown): string | null | undefined { + const label = normalizeString(value); + if (label === undefined) return undefined; + return label.length > 64 ? label.slice(0, 64) : label; +} + +function labelFromValue(value: unknown): string | null | undefined { + return normalizeLabel( + firstString(value, [ + ["label"], + ["name"], + ["account_name"], + ["accountName"], + ["account_note"], + ["accountNote"], + ["note"], + ]), + ); +} + +function looksLikeRefreshToken(value: string): boolean { + const normalized = value.trim(); + return normalized.startsWith("oaistb_rt_") || normalized.startsWith("rt_"); +} + +function normalizeBearer(value: string): string { + const trimmed = value.trim(); + return trimmed.toLowerCase().startsWith("bearer ") + ? trimmed.slice("bearer ".length).trim() + : trimmed; +} + +function candidateFromString(value: string): ImportEntry | null { + const token = normalizeBearer(value); + if (!token) return null; + if (looksLikeRefreshToken(token)) return { refreshToken: token }; + return { token }; +} + +function candidateFromValue(value: unknown, fallbackLabel?: string | null): ImportEntry | null { + if (typeof value === "string") return candidateFromString(value); + + const record = toRecord(value); + if (!record) return null; + + const token = firstString(record, [ + ["token"], + ["access_token"], + ["accessToken"], + ["tokens", "access_token"], + ["tokens", "accessToken"], + ["credentials", "access_token"], + ["credentials", "accessToken"], + ["credentials", "token"], + ]); + const refreshToken = firstString(record, [ + ["refreshToken"], + ["refresh_token"], + ["tokens", "refreshToken"], + ["tokens", "refresh_token"], + ["credentials", "refreshToken"], + ["credentials", "refresh_token"], + ]); + const label = labelFromValue(record) ?? fallbackLabel ?? undefined; + + if (!token && !refreshToken) return null; + + return { + ...(token ? { token: normalizeBearer(token) } : {}), + ...(refreshToken ? { refreshToken } : {}), + ...(label !== undefined ? { label } : {}), + }; +} + +function isSub2ApiAccount(value: unknown): boolean { + const record = toRecord(value); + if (!record) return false; + const platform = normalizeString(record.platform)?.toLowerCase(); + const type = normalizeString(record.type)?.toLowerCase(); + return platform === "openai" && type === "oauth"; +} + +function parseSub2ApiPayload(value: JsonRecord): ImportEntry[] | null { + const accounts = Array.isArray(value.accounts) ? value.accounts : null; + if (!accounts) return null; + const looksLikeSub2Api = + normalizeString(value.type) === "sub2api-data" || + "proxies" in value || + accounts.some((item) => toRecord(item)?.credentials); + if (!looksLikeSub2Api) return null; + + const entries: ImportEntry[] = []; + for (const item of accounts) { + if (!isSub2ApiAccount(item)) continue; + const record = toRecord(item); + const credentials = record ? toRecord(record.credentials) : null; + if (!credentials) continue; + const label = normalizeLabel(record?.name); + const entry = candidateFromValue(credentials, label); + if (entry) entries.push(entry); + } + return entries; +} + +export function parseAccountImportPayload(payload: unknown): ImportEntry[] { + if (Array.isArray(payload)) { + return payload.flatMap((item) => { + const entry = candidateFromValue(item); + return entry ? [entry] : []; + }); + } + + const record = toRecord(payload); + if (!record) { + const entry = candidateFromValue(payload); + return entry ? [entry] : []; + } + + const sub2api = parseSub2ApiPayload(record); + if (sub2api) return sub2api; + + if (Array.isArray(record.accounts)) { + return record.accounts.flatMap((item) => { + const entry = candidateFromValue(item); + return entry ? [entry] : []; + }); + } + + const entry = candidateFromValue(record); + return entry ? [entry] : []; +} + +export function parseAccountImportText(text: string): ImportEntry[] { + const trimmed = text.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return parseAccountImportPayload(JSON.parse(trimmed) as unknown); + } catch { + // Fall through to JSON-lines / token-lines parsing. + } + } + + const entries: ImportEntry[] = []; + for (const line of text.split(/\r?\n/)) { + const normalized = line.trim(); + if (!normalized) continue; + try { + const parsed = JSON.parse(normalized) as unknown; + entries.push(...parseAccountImportPayload(parsed)); + } catch { + const entry = candidateFromString(normalized); + if (entry) entries.push(entry); + } + } + return entries; +} + +export function parseAccountExportFormat(value: string | undefined): AccountExportFormat | null { + if (!value || value === "full") return "full"; + if ( + value === "minimal" || + value === "cockpit_tools" || + value === "sub2api" || + value === "cpa" + ) { + return value; + } + return null; +} + +function isoFromUnixSeconds(value: unknown): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const date = new Date(value * 1000); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +function accessTokenExpiry(entry: AccountEntry): string | undefined { + return isoFromUnixSeconds(decodeJwtPayload(entry.token)?.exp); +} + +function quotaWindowRemaining(window: CodexQuotaWindow | null | undefined): number | undefined { + return typeof window?.remaining_percent === "number" ? window.remaining_percent : undefined; +} + +function toPortableToken(entry: AccountEntry): PortableCodexToken { + return { + access_token: entry.token, + ...(entry.refreshToken ? { refresh_token: entry.refreshToken } : {}), + ...(entry.accountId ? { account_id: entry.accountId } : {}), + last_refresh: entry.quotaFetchedAt ?? entry.addedAt, + ...(entry.email ? { email: entry.email } : {}), + type: "codex", + ...(accessTokenExpiry(entry) ? { expired: accessTokenExpiry(entry) } : {}), + }; +} + +function toSub2ApiAccount(entry: AccountEntry): Sub2ApiAccountItem { + const credentials: JsonRecord = { + access_token: entry.token, + ...(entry.refreshToken ? { refresh_token: entry.refreshToken } : {}), + ...(entry.email ? { email: entry.email } : {}), + ...(entry.accountId ? { chatgpt_account_id: entry.accountId } : {}), + ...(entry.userId ? { chatgpt_user_id: entry.userId } : {}), + ...(entry.planType ? { plan_type: entry.planType } : {}), + ...(accessTokenExpiry(entry) ? { expires_at: accessTokenExpiry(entry) } : {}), + }; + const primaryRemaining = quotaWindowRemaining(entry.cachedQuota?.rate_limit); + if (primaryRemaining !== undefined) credentials.quota_remaining_percent = primaryRemaining; + + return { + name: entry.label?.trim() || entry.email || entry.id, + platform: "openai", + type: "oauth", + credentials, + concurrency: 0, + priority: 0, + }; +} + +export function buildAccountExportPayload( + entries: AccountEntry[], + format: AccountExportFormat, +): unknown { + if (format === "full") return { accounts: entries }; + if (format === "minimal") { + return { + accounts: entries + .filter((entry) => entry.refreshToken) + .map((entry) => ({ + refreshToken: entry.refreshToken, + ...(entry.label ? { label: entry.label } : {}), + })), + }; + } + if (format === "cockpit_tools") { + return entries.map(toPortableToken); + } + if (format === "sub2api") { + const payload: Sub2ApiExportPayload = { + exported_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + proxies: [], + accounts: entries.map(toSub2ApiAccount), + type: "sub2api-data", + version: 1, + }; + return payload; + } + + const portable = entries.map(toPortableToken); + return portable.length === 1 ? portable[0] : portable; +} diff --git a/src/translation/codex-to-anthropic.ts b/src/translation/codex-to-anthropic.ts index cc8a1038..6e36070e 100644 --- a/src/translation/codex-to-anthropic.ts +++ b/src/translation/codex-to-anthropic.ts @@ -28,6 +28,31 @@ interface ResponseMetadata { functionCallIds?: string[]; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function sanitizeToolInput(toolName: string, input: Record): Record { + if (toolName !== "Read") return input; + if (typeof input.pages !== "string" || input.pages.trim() !== "") return input; + + const sanitized: Record = { ...input }; + delete sanitized.pages; + return sanitized; +} + +function sanitizeFunctionCallArguments(toolName: string, argumentsJson: string): string { + try { + const parsed: unknown = JSON.parse(argumentsJson); + if (!isRecord(parsed)) return argumentsJson; + + const sanitized = sanitizeToolInput(toolName, parsed); + return sanitized === parsed ? argumentsJson : JSON.stringify(sanitized); + } catch { + return argumentsJson; + } +} + function resolveCacheUsage( inputTokens: number, cachedTokens: number | undefined, @@ -79,7 +104,8 @@ export async function* streamCodexToAnthropic( let textBlockStarted = false; let thinkingBlockStarted = false; const functionCallIds = new Set(); - const callIdsWithDeltas = new Set(); + const callIdsWithForwardedDeltas = new Set(); + const functionCallNames = new Map(); const publishFunctionCallId = (callId: string): void => { if (functionCallIds.has(callId)) return; @@ -172,6 +198,7 @@ export async function* streamCodexToAnthropic( hasToolCalls = true; hasContent = true; publishFunctionCallId(evt.functionCallStart.callId); + functionCallNames.set(evt.functionCallStart.callId, evt.functionCallStart.name); yield* closeThinkingIfOpen(); yield* closeTextIfOpen(); @@ -191,7 +218,14 @@ export async function* streamCodexToAnthropic( } if (evt.functionCallDelta) { - callIdsWithDeltas.add(evt.functionCallDelta.callId); + // Drop Read deltas and buffer until functionCallDone, where the full + // arguments can be sanitized atomically (e.g. stripping empty `pages`). + // Partial JSON cannot be safely rewritten mid-stream. + if (functionCallNames.get(evt.functionCallDelta.callId) === "Read") { + continue; + } + + callIdsWithForwardedDeltas.add(evt.functionCallDelta.callId); yield formatSSE("content_block_delta", { type: "content_block_delta", index: contentIndex, @@ -203,11 +237,17 @@ export async function* streamCodexToAnthropic( if (evt.functionCallDone) { publishFunctionCallId(evt.functionCallDone.callId); // Emit full arguments if no deltas were streamed - if (!callIdsWithDeltas.has(evt.functionCallDone.callId)) { + if (!callIdsWithForwardedDeltas.has(evt.functionCallDone.callId)) { yield formatSSE("content_block_delta", { type: "content_block_delta", index: contentIndex, - delta: { type: "input_json_delta", partial_json: evt.functionCallDone.arguments }, + delta: { + type: "input_json_delta", + partial_json: sanitizeFunctionCallArguments( + evt.functionCallDone.name, + evt.functionCallDone.arguments, + ), + }, }); } // Close this tool_use block @@ -268,17 +308,16 @@ export async function* streamCodexToAnthropic( yield* closeThinkingIfOpen(); yield* closeTextIfOpen(); - // 4. message_delta with stop_reason and usage - // cache_creation_input_tokens: tokens not served from cache (will be cached for next turn) - // cache_read_input_tokens: tokens served from cache (Codex cached_tokens) + // Codex API: input_tokens = total (cached + uncached), cached_tokens = cached subset + // Anthropic API: input_tokens = uncached only, cache_read_input_tokens = cached + // cacheCreationTokens here = inputTokens - cacheReadTokens = uncached portion const { cacheReadTokens, cacheCreationTokens } = resolveCacheUsage(inputTokens, cachedTokens, usageHint); yield formatSSE("message_delta", { type: "message_delta", delta: { stop_reason: hasToolCalls ? "tool_use" : "end_turn" }, usage: { - input_tokens: inputTokens, + input_tokens: cacheCreationTokens, output_tokens: outputTokens, - ...(cacheCreationTokens > 0 ? { cache_creation_input_tokens: cacheCreationTokens } : {}), ...(cacheReadTokens > 0 ? { cache_read_input_tokens: cacheReadTokens } : {}), }, }); @@ -333,7 +372,8 @@ export async function collectCodexToAnthropicResponse( functionCallIds.add(evt.functionCallDone.callId); let parsedInput: Record = {}; try { - parsedInput = JSON.parse(evt.functionCallDone.arguments) as Record; + const parsed: unknown = JSON.parse(evt.functionCallDone.arguments); + parsedInput = isRecord(parsed) ? sanitizeToolInput(evt.functionCallDone.name, parsed) : {}; } catch { /* use empty object */ } toolUseBlocks.push({ type: "tool_use", @@ -370,9 +410,8 @@ export async function collectCodexToAnthropicResponse( const { cacheReadTokens: cacheRead, cacheCreationTokens: cacheCreation } = resolveCacheUsage(inputTokens, cachedTokens, usageHint); const usage: AnthropicUsage = { - input_tokens: inputTokens, + input_tokens: cacheCreation, output_tokens: outputTokens, - ...(cacheCreation > 0 ? { cache_creation_input_tokens: cacheCreation } : {}), ...(cacheRead > 0 ? { cache_read_input_tokens: cacheRead } : {}), }; diff --git a/tests/e2e/quota-refresh.test.ts b/tests/e2e/quota-refresh.test.ts index 8c729cb8..b9c94f4d 100644 --- a/tests/e2e/quota-refresh.test.ts +++ b/tests/e2e/quota-refresh.test.ts @@ -3,12 +3,12 @@ * * Tests: * - GET /auth/accounts returns cached quota from background refresh - * - GET /auth/accounts?quota=fresh forces live upstream fetch + * - GET /auth/accounts?quota=fresh returns cached quota without live upstream fetch * - GET /auth/quota/warnings returns active warnings * - Accounts with exhausted quota are skipped by acquire() */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; import "@helpers/e2e-setup.js"; import { createValidJwt } from "@helpers/jwt.js"; @@ -20,6 +20,7 @@ import { AccountPool } from "@src/auth/account-pool.js"; import { RefreshScheduler } from "@src/auth/refresh-scheduler.js"; import type { CodexQuota } from "@src/auth/types.js"; import { updateWarnings, clearWarnings, getActiveWarnings } from "@src/auth/quota-warnings.js"; +import { CodexApi } from "@src/proxy/codex-api.js"; let app: Hono; let pool: AccountPool; @@ -103,6 +104,29 @@ describe("E2E: quota auto-refresh", () => { pool.removeAccount(id); }); + it("GET /auth/accounts?quota=fresh returns cached quota without upstream call", async () => { + const id = pool.addAccount(createValidJwt({ + accountId: "acct-quota-fresh", + email: "quotafresh@test.com", + planType: "plus", + })); + pool.updateCachedQuota(id, makeQuota(77)); + const getUsageSpy = vi.spyOn(CodexApi.prototype, "getUsage").mockRejectedValue(new Error("unexpected usage call")); + + try { + const res = await app.request("/auth/accounts?quota=fresh"); + expect(res.status).toBe(200); + + const body = await res.json() as { accounts: Array<{ id: string; quota?: CodexQuota }> }; + const acct = body.accounts.find((a) => a.id === id); + expect(acct?.quota?.rate_limit.used_percent).toBe(77); + expect(getUsageSpy).not.toHaveBeenCalled(); + } finally { + getUsageSpy.mockRestore(); + pool.removeAccount(id); + } + }); + it("GET /auth/quota/warnings returns empty when no warnings", async () => { const res = await app.request("/auth/quota/warnings"); expect(res.status).toBe(200); diff --git a/tests/integration/proxy-handler.test.ts b/tests/integration/proxy-handler.test.ts index a02fe81b..6d6c9496 100644 --- a/tests/integration/proxy-handler.test.ts +++ b/tests/integration/proxy-handler.test.ts @@ -303,8 +303,62 @@ describe("proxy-handler integration", () => { "different-variant", )).toBeNull(); }); - - // 3. Streaming success + + it("blocks oversized full-history replay when implicit resume is missing tool calls", async () => { + const createSpy = vi.fn(async () => new Response("data: {}\n\n")); + mockCreateResponse = createSpy; + + const req: ProxyRequest = { + ...createDefaultRequest(), + codexRequest: { + ...createDefaultRequest().codexRequest, + prompt_cache_key: "thread-large-missing-tools", + input: [ + { role: "user", content: "first" }, + { type: "function_call", call_id: "call_expected", name: "read_file", arguments: "{}" }, + { type: "function_call_output", call_id: "call_missing", output: "{}" }, + ...Array.from({ length: 1010 }, (_, index) => ({ + role: "user" as const, + content: `padding message ${index}`, + })), + ], + }, + }; + const promptCacheIdentity = resolvePromptCacheIdentity(req.codexRequest, req.clientConversationId); + const variantHash = computeVariantHash( + req.codexRequest.instructions, + req.codexRequest.tools, + buildVariantIdentity(req.codexRequest, promptCacheIdentity), + ); + getSessionAffinityMap().record( + "resp_prev_missing_tools", + "e1", + "thread-large-missing-tools", + undefined, + "You are helpful", + 21, + ["call_expected"], + variantHash, + ); + + const accountPool = createMockAccountPool(); + const fmt = createMockFormatAdapter(); + const { app } = buildTestApp({ accountPool, fmt, req }); + + const res = await app.request("/test", { method: "POST" }); + expect(res.status).toBe(413); + expect(createSpy).not.toHaveBeenCalled(); + expect(accountPool.release).toHaveBeenCalledWith("e1", undefined); + + const body = await res.json(); + expect(body).toMatchObject({ + error: "api_error", + status: 413, + }); + expect(body.message).toContain("Implicit resume failed: missing_tool_calls"); + }); + + // 3. Streaming success it("returns text/event-stream with SSE chunks for streaming", async () => { const accountPool = createMockAccountPool(); const fmt = createMockFormatAdapter(); diff --git a/tests/integration/usage-passthrough.test.ts b/tests/integration/usage-passthrough.test.ts index 19a4d124..4d19db92 100644 --- a/tests/integration/usage-passthrough.test.ts +++ b/tests/integration/usage-passthrough.test.ts @@ -105,18 +105,21 @@ describe("usage passthrough", () => { }); }); - describe("Anthropic format", () => { - it("cache_read_input_tokens from cached_tokens", async () => { - mockEvents = createUsageEvents({ cached_tokens: 30 }); - const { response } = await collectCodexToAnthropicResponse( - fakeCodexApi, fakeResponse, "gpt-5.3-codex", - ); - - expect(response.usage.input_tokens).toBe(100); - expect(response.usage.output_tokens).toBe(50); - expect(response.usage.cache_read_input_tokens).toBe(30); - }); - }); + describe("Anthropic format", () => { + it("maps cached_tokens without double-counting Anthropic input_tokens", async () => { + mockEvents = createUsageEvents({ cached_tokens: 30 }); + const { response } = await collectCodexToAnthropicResponse( + fakeCodexApi, fakeResponse, "gpt-5.3-codex", + ); + + // Codex reports input_tokens as total prompt tokens and cached_tokens as + // a subset. Anthropic reports uncached input_tokens plus cache_read. + expect(response.usage.input_tokens).toBe(70); + expect(response.usage.output_tokens).toBe(50); + expect(response.usage.cache_read_input_tokens).toBe(30); + expect(response.usage.input_tokens + response.usage.cache_read_input_tokens!).toBe(100); + }); + }); describe("Gemini format", () => { it("cachedContentTokenCount from cached_tokens", async () => { diff --git a/tests/unit/auth/account-pool-has-available.test.ts b/tests/unit/auth/account-pool-has-available.test.ts index d0af29ad..bced521a 100644 --- a/tests/unit/auth/account-pool-has-available.test.ts +++ b/tests/unit/auth/account-pool-has-available.test.ts @@ -184,3 +184,66 @@ describe("AccountPool.hasAvailableAccounts", () => { expect(pool.hasAvailableAccounts()).toBe(false); }); }); + +describe("AccountPool.isAuthenticated", () => { + let pool: AccountPool; + + beforeEach(() => { + vi.mocked(isTokenExpired).mockReturnValue(false); + pool = new AccountPool({ rotationStrategy: "least_used" }); + }); + + it("returns false for empty pool", () => { + expect(pool.isAuthenticated()).toBe(false); + }); + + it("returns true when an active non-exhausted account exists", () => { + pool.addAccount("token-a"); + expect(pool.isAuthenticated()).toBe(true); + }); + + it("returns false when only quota-exhausted accounts exist and skip_exhausted=true (default)", () => { + const id = pool.addAccount("token-a"); + pool.updateCachedQuota(id, makeQuota({ + rate_limit: { + allowed: false, + limit_reached: true, + used_percent: 100, + reset_at: Math.floor(Date.now() / 1000) + 3600, + limit_window_seconds: 3600, + }, + })); + expect(pool.isAuthenticated()).toBe(false); + }); + + it("returns true when only quota-exhausted accounts exist and skip_exhausted=false (P1 fix)", async () => { + const { getConfig } = await import("@src/config.js"); + vi.mocked(getConfig).mockReturnValueOnce({ + auth: { + jwt_token: null, + rotation_strategy: "least_used", + rate_limit_backoff_seconds: 60, + max_concurrent_per_account: 3, + }, + quota: { skip_exhausted: false }, + } as ReturnType); + + const id = pool.addAccount("token-a"); + pool.updateCachedQuota(id, makeQuota({ + rate_limit: { + allowed: false, + limit_reached: true, + used_percent: 100, + reset_at: Math.floor(Date.now() / 1000) + 3600, + limit_window_seconds: 3600, + }, + })); + expect(pool.isAuthenticated()).toBe(true); + }); + + it("returns false when only disabled accounts exist, regardless of skip_exhausted", () => { + const id = pool.addAccount("token-a"); + pool.markStatus(id, "disabled"); + expect(pool.isAuthenticated()).toBe(false); + }); +}); diff --git a/tests/unit/auth/account-pool-quota.test.ts b/tests/unit/auth/account-pool-quota.test.ts index 2470cfa5..421ac44b 100644 --- a/tests/unit/auth/account-pool-quota.test.ts +++ b/tests/unit/auth/account-pool-quota.test.ts @@ -57,6 +57,71 @@ describe("AccountPool quota methods", () => { // Should not throw pool.updateCachedQuota("nonexistent", makeQuota()); }); + + it("preserves existing credits when new quota lacks them (header-driven passive update)", () => { + const id = pool.addAccount(createValidJwt({ accountId: "credits-1", planType: "pro" })); + // First write: full quota WITH credits (from /codex/usage body, toQuota path). + pool.updateCachedQuota(id, makeQuota({ + credits: { has_credits: true, unlimited: false, overage_limit_reached: false, balance: 99.5 }, + })); + // Second write: header-driven quota without credits (rateLimitToQuota path). + // Must preserve the previously known balance, not wipe it. + pool.updateCachedQuota(id, makeQuota({ + rate_limit: { + allowed: true, + limit_reached: false, + used_percent: 60, + reset_at: Math.floor(Date.now() / 1000) + 1800, + limit_window_seconds: 3600, + }, + })); + const entry = pool.getEntry(id); + expect(entry?.cachedQuota?.credits).toEqual({ + has_credits: true, + unlimited: false, + overage_limit_reached: false, + balance: 99.5, + }); + expect(entry?.cachedQuota?.rate_limit.used_percent).toBe(60); + }); + + it("preserves existing credits when new quota explicitly carries null credits", () => { + const id = pool.addAccount(createValidJwt({ accountId: "credits-null", planType: "pro" })); + pool.updateCachedQuota(id, makeQuota({ + credits: { has_credits: true, unlimited: false, overage_limit_reached: false, balance: 99.5 }, + })); + pool.updateCachedQuota(id, makeQuota({ + credits: null, + rate_limit: { + allowed: true, + limit_reached: false, + used_percent: 70, + reset_at: Math.floor(Date.now() / 1000) + 1800, + limit_window_seconds: 3600, + }, + })); + + const entry = pool.getEntry(id); + expect(entry?.cachedQuota?.credits).toEqual({ + has_credits: true, + unlimited: false, + overage_limit_reached: false, + balance: 99.5, + }); + expect(entry?.cachedQuota?.rate_limit.used_percent).toBe(70); + }); + + it("overwrites credits when new quota explicitly provides them", () => { + const id = pool.addAccount(createValidJwt({ accountId: "credits-2", planType: "pro" })); + pool.updateCachedQuota(id, makeQuota({ + credits: { has_credits: true, unlimited: false, overage_limit_reached: false, balance: 100 }, + })); + pool.updateCachedQuota(id, makeQuota({ + credits: { has_credits: true, unlimited: false, overage_limit_reached: false, balance: 42 }, + })); + const entry = pool.getEntry(id); + expect(entry?.cachedQuota?.credits?.balance).toBe(42); + }); }); describe("applyRateLimit429 (replaces markQuotaExhausted)", () => { diff --git a/tests/unit/auth/api-key-pool.test.ts b/tests/unit/auth/api-key-pool.test.ts index 691bd916..6d1e9f86 100644 --- a/tests/unit/auth/api-key-pool.test.ts +++ b/tests/unit/auth/api-key-pool.test.ts @@ -36,6 +36,7 @@ describe("ApiKeyPool", () => { expect(entry.status).toBe("active"); expect(entry.label).toBeNull(); expect(entry.lastUsedAt).toBeNull(); + expect(entry.capabilities).toEqual(["chat"]); }); it("uses default baseUrl for builtin providers", () => { @@ -88,6 +89,58 @@ describe("ApiKeyPool", () => { expect(results[0].id).toBe(e1.id); }); + it("filters active entries by model and capability", () => { + const chat = pool.add({ + provider: "openai", + model: "text-embedding-3-small", + apiKey: "chat-key", + capabilities: ["chat"], + }); + const embeddings = pool.add({ + provider: "openai", + model: "text-embedding-3-small", + apiKey: "embedding-key", + capabilities: ["embeddings"], + }); + const disabled = pool.add({ + provider: "openai", + model: "text-embedding-3-small", + apiKey: "disabled-key", + capabilities: ["embeddings"], + }); + pool.setStatus(disabled.id, "disabled"); + + expect(pool.getByModelAndCapability("text-embedding-3-small", "chat").map((entry) => entry.id)).toEqual([chat.id]); + expect(pool.getByModelAndCapability("text-embedding-3-small", "embeddings").map((entry) => entry.id)).toEqual([embeddings.id]); + }); + + it("loads legacy persisted entries without capabilities as chat-only", () => { + const persistence = createMemoryPersistence(); + const pool1 = new ApiKeyPool(persistence); + pool1.add({ provider: "openai", model: "gpt-5.4", apiKey: "k1" }); + + const legacyEntry = pool1.getAll()[0]; + const legacyPersistence: ApiKeyPersistence = { + load: () => [{ + id: legacyEntry.id, + provider: legacyEntry.provider, + model: legacyEntry.model, + apiKey: legacyEntry.apiKey, + baseUrl: legacyEntry.baseUrl, + label: legacyEntry.label, + status: legacyEntry.status, + addedAt: legacyEntry.addedAt, + lastUsedAt: legacyEntry.lastUsedAt, + }], + save: () => {}, + }; + + const pool2 = new ApiKeyPool(legacyPersistence); + expect(pool2.getAll()[0].capabilities).toEqual(["chat"]); + expect(pool2.getByModelAndCapability("gpt-5.4", "chat")).toHaveLength(1); + expect(pool2.getByModelAndCapability("gpt-5.4", "embeddings")).toHaveLength(0); + }); + it("getByProvider returns active entries for that provider", () => { pool.add({ provider: "anthropic", model: "claude-opus-4-6", apiKey: "k1" }); pool.add({ provider: "anthropic", model: "claude-sonnet-4-6", apiKey: "k2" }); @@ -180,7 +233,13 @@ describe("ApiKeyPool", () => { }); it("exportForReimport returns all keys in importable format", () => { - pool.add({ provider: "anthropic", model: "claude-opus-4-6", apiKey: "k1", label: "Prod" }); + pool.add({ + provider: "anthropic", + model: "claude-opus-4-6", + apiKey: "k1", + label: "Prod", + capabilities: ["chat", "embeddings"], + }); const exported = pool.exportForReimport(); expect(exported).toHaveLength(1); expect(exported[0]).toEqual({ @@ -189,6 +248,7 @@ describe("ApiKeyPool", () => { apiKey: "k1", baseUrl: "https://api.anthropic.com/v1", label: "Prod", + capabilities: ["chat", "embeddings"], }); }); diff --git a/tests/unit/auth/cf-path-block-tracker.test.ts b/tests/unit/auth/cf-path-block-tracker.test.ts new file mode 100644 index 00000000..ffde53e2 --- /dev/null +++ b/tests/unit/auth/cf-path-block-tracker.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + recordCfPathBlock, + resetCfPathBlock, + peekCfPathBlock, + _resetAllCfPathBlocks, +} from "@src/auth/cf-path-block-tracker.js"; + +describe("cf-path-block-tracker", () => { + beforeEach(() => { + _resetAllCfPathBlocks(); + }); + + it("increments per entry independently", () => { + expect(recordCfPathBlock("a")).toBe(1); + expect(recordCfPathBlock("a")).toBe(2); + expect(recordCfPathBlock("b")).toBe(1); + expect(recordCfPathBlock("a")).toBe(3); + }); + + it("resets after sliding window expires", () => { + const t0 = 1_000_000; + const t1 = t0 + 1000; + expect(recordCfPathBlock("a", t0)).toBe(1); + expect(recordCfPathBlock("a", t1)).toBe(2); + // The window is measured from the most recent increment (t1). + expect(recordCfPathBlock("a", t1 + 60 * 60 * 1000 + 1)).toBe(1); + }); + + it("resetCfPathBlock clears the counter", () => { + recordCfPathBlock("a"); + recordCfPathBlock("a"); + resetCfPathBlock("a"); + expect(peekCfPathBlock("a")).toBe(0); + expect(recordCfPathBlock("a")).toBe(1); + }); + + it("peek returns 0 for unknown entry and stale entry", () => { + expect(peekCfPathBlock("ghost")).toBe(0); + const t0 = 1_000_000; + recordCfPathBlock("a", t0); + expect(peekCfPathBlock("a", t0)).toBe(1); + expect(peekCfPathBlock("a", t0 + 60 * 60 * 1000 + 1)).toBe(0); + }); +}); diff --git a/tests/unit/auth/quota-utils.test.ts b/tests/unit/auth/quota-utils.test.ts index 44617608..3b14b5f0 100644 --- a/tests/unit/auth/quota-utils.test.ts +++ b/tests/unit/auth/quota-utils.test.ts @@ -30,6 +30,7 @@ describe("toQuota", () => { expect(quota.rate_limit.used_percent).toBe(42); expect(quota.rate_limit.reset_at).toBe(1700000000); expect(quota.rate_limit.limit_window_seconds).toBe(3600); + expect(quota.rate_limit.remaining_percent).toBe(58); expect(quota.rate_limit.limit_reached).toBe(false); expect(quota.rate_limit.allowed).toBe(true); expect(quota.secondary_rate_limit).toBeNull(); @@ -58,6 +59,7 @@ describe("toQuota", () => { expect(quota.secondary_rate_limit).not.toBeNull(); expect(quota.secondary_rate_limit!.used_percent).toBe(75); + expect(quota.secondary_rate_limit!.remaining_percent).toBe(25); expect(quota.secondary_rate_limit!.reset_at).toBe(1700500000); expect(quota.secondary_rate_limit!.limit_window_seconds).toBe(604800); }); @@ -81,6 +83,7 @@ describe("toQuota", () => { expect(quota.code_review_rate_limit!.allowed).toBe(true); expect(quota.code_review_rate_limit!.limit_reached).toBe(true); expect(quota.code_review_rate_limit!.used_percent).toBe(100); + expect(quota.code_review_rate_limit!.remaining_percent).toBe(0); expect(quota.code_review_rate_limit!.limit_window_seconds).toBe(3600); }); @@ -129,9 +132,11 @@ describe("toQuota", () => { limit_id: "codex_other", limit_name: "Codex Other", used_percent: 12, + remaining_percent: 88, limit_window_seconds: 1800, secondary_rate_limit: { used_percent: 34, + remaining_percent: 66, reset_at: 1700100000, limit_window_seconds: 604800, }, @@ -140,6 +145,7 @@ describe("toQuota", () => { allowed: true, limit_reached: false, used_percent: 7, + remaining_percent: 93, reset_at: 1700003000, limit_window_seconds: 1800, }); @@ -225,7 +231,66 @@ describe("toQuota", () => { })); expect(quota.rate_limit.used_percent).toBeNull(); + expect(quota.rate_limit.remaining_percent).toBeNull(); expect(quota.rate_limit.reset_at).toBeNull(); expect(quota.rate_limit.limit_window_seconds).toBeNull(); }); + + describe("credits", () => { + it("carries credits block through when present (Plus shape: has_credits=false, balance=0)", () => { + const quota = toQuota(makeUsageResponse({ + credits: { + has_credits: false, + unlimited: false, + overage_limit_reached: false, + balance: "0", + }, + })); + expect(quota.credits).toEqual({ + has_credits: false, + unlimited: false, + overage_limit_reached: false, + balance: 0, + }); + }); + + it("parses decimal-string balance into number for Pro / PAYG accounts", () => { + const quota = toQuota(makeUsageResponse({ + credits: { + has_credits: true, + unlimited: false, + overage_limit_reached: false, + balance: "247.50", + }, + })); + expect(quota.credits?.has_credits).toBe(true); + expect(quota.credits?.balance).toBe(247.5); + }); + + it("emits null credits when upstream omits the block", () => { + const quota = toQuota(makeUsageResponse({ credits: null })); + expect(quota.credits).toBeNull(); + }); + + it("emits null credits when upstream sends a malformed block (defensive)", () => { + const quota = toQuota(makeUsageResponse({ + // Simulate a future upstream schema where balance is missing entirely + credits: { has_credits: true } as unknown as NonNullable, + })); + expect(quota.credits).toBeNull(); + }); + + it("passes unlimited and overage_limit_reached flags through", () => { + const quota = toQuota(makeUsageResponse({ + credits: { + has_credits: true, + unlimited: true, + overage_limit_reached: true, + balance: "0", + }, + })); + expect(quota.credits?.unlimited).toBe(true); + expect(quota.credits?.overage_limit_reached).toBe(true); + }); + }); }); diff --git a/tests/unit/cache-reporting.test.ts b/tests/unit/cache-reporting.test.ts index 41442568..3ace5977 100644 --- a/tests/unit/cache-reporting.test.ts +++ b/tests/unit/cache-reporting.test.ts @@ -1,6 +1,6 @@ /** - * 验证缓存字段修复:cache_creation_input_tokens 和 cache_read_input_tokens - * 正确从 Codex cached_tokens 映射到 Anthropic usage 格式。 + * 验证缓存字段映射:Codex input_tokens 是总输入,cached_tokens 是其中缓存命中部分; + * Anthropic input_tokens 只报告未缓存输入,cache_read_input_tokens 报告缓存命中。 * * 不需要启动服务器,不需要真实账号,直接测翻译层。 */ @@ -142,19 +142,19 @@ function makeCodexApiMock(): CodexApi { // ── 非流式路径测试 ──────────────────────────────────────────────── describe("非流式响应 collectCodexToAnthropicResponse", () => { - it("无缓存时:cache_creation = input_tokens,cache_read 不出现", async () => { + it("无缓存时:input_tokens = Codex total,cache_read 不出现", async () => { const api = makeCodexApiMock(); const res = makeCodexResponse({ inputTokens: 10000, outputTokens: 500 }); const { response } = await collectCodexToAnthropicResponse(api, res, "gpt-5.4"); - expect(response.usage.cache_creation_input_tokens).toBe(10000); + expect(response.usage).not.toHaveProperty("cache_creation_input_tokens"); expect(response.usage.cache_read_input_tokens).toBeUndefined(); expect(response.usage.input_tokens).toBe(10000); expect(response.usage.output_tokens).toBe(500); }); - it("缓存全部命中时:cache_read = cached_tokens,cache_creation = input - cached", async () => { + it("缓存命中时:cache_read = cached_tokens,input_tokens = total - cached", async () => { const api = makeCodexApiMock(); const res = makeCodexResponse({ inputTokens: 10000, @@ -165,8 +165,8 @@ describe("非流式响应 collectCodexToAnthropicResponse", () => { const { response } = await collectCodexToAnthropicResponse(api, res, "gpt-5.4"); expect(response.usage.cache_read_input_tokens).toBe(7000); - expect(response.usage.cache_creation_input_tokens).toBe(3000); // 10000 - 7000 - expect(response.usage.input_tokens).toBe(10000); + expect(response.usage).not.toHaveProperty("cache_creation_input_tokens"); + expect(response.usage.input_tokens).toBe(3000); // 10000 - 7000 }); it("cachedTokens = 0 时:与无缓存情况一致,cache_read 不出现", async () => { @@ -179,7 +179,8 @@ describe("非流式响应 collectCodexToAnthropicResponse", () => { const { response } = await collectCodexToAnthropicResponse(api, res, "gpt-5.4"); - expect(response.usage.cache_creation_input_tokens).toBe(5000); + expect(response.usage).not.toHaveProperty("cache_creation_input_tokens"); + expect(response.usage.input_tokens).toBe(5000); expect(response.usage.cache_read_input_tokens).toBeUndefined(); }); @@ -194,7 +195,8 @@ describe("非流式响应 collectCodexToAnthropicResponse", () => { const { response } = await collectCodexToAnthropicResponse(api, res, "gpt-5.4"); expect(response.usage.cache_read_input_tokens).toBe(1_183_600); - expect(response.usage.cache_creation_input_tokens).toBe(70_000_000 - 1_183_600); + expect(response.usage).not.toHaveProperty("cache_creation_input_tokens"); + expect(response.usage.input_tokens).toBe(70_000_000 - 1_183_600); // cache_read / input 命中率 const hitRate = 1_183_600 / 70_000_000; expect(hitRate).toBeCloseTo(0.017, 2); // 约 1.7%,对应实际数据 @@ -217,7 +219,8 @@ describe("非流式响应 collectCodexToAnthropicResponse", () => { ); expect(response.usage.cache_read_input_tokens).toBe(15_240); - expect(response.usage.cache_creation_input_tokens).toBe(3); + expect(response.usage).not.toHaveProperty("cache_creation_input_tokens"); + expect(response.usage.input_tokens).toBe(3); }); it("工具调用响应会回传 call_id 元数据,供隐式续链接力校验", async () => { @@ -257,20 +260,35 @@ describe("流式响应 streamCodexToAnthropic", () => { return null; } - it("无缓存时:message_delta.usage 包含 cache_creation,无 cache_read", async () => { + interface MessageDeltaUsage { + input_tokens: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + } + + function getMessageDeltaUsage(sseText: string): MessageDeltaUsage { + const delta = parseMessageDelta(sseText); + expect(delta).not.toBeNull(); + const usage = delta?.usage; + expect(typeof usage).toBe("object"); + expect(usage).not.toBeNull(); + return usage as MessageDeltaUsage; + } + + it("无缓存时:message_delta.usage input_tokens = Codex total,无 cache_read", async () => { const api = makeCodexApiMock(); const res = makeCodexResponse({ inputTokens: 8000, outputTokens: 400 }); const sseText = await collectSSE(streamCodexToAnthropic(api, res, "gpt-5.4")); - const delta = parseMessageDelta(sseText); + const usage = getMessageDeltaUsage(sseText); - expect(delta).not.toBeNull(); - const usage = (delta as any).usage; - expect(usage.cache_creation_input_tokens).toBe(8000); + expect(usage).not.toHaveProperty("cache_creation_input_tokens"); + expect(usage.input_tokens).toBe(8000); expect(usage.cache_read_input_tokens).toBeUndefined(); }); - it("缓存命中时:message_delta.usage 同时包含 cache_creation 和 cache_read", async () => { + it("缓存命中时:message_delta.usage 包含 cache_read,input_tokens = total - cached", async () => { const api = makeCodexApiMock(); const res = makeCodexResponse({ inputTokens: 8000, @@ -279,11 +297,11 @@ describe("流式响应 streamCodexToAnthropic", () => { }); const sseText = await collectSSE(streamCodexToAnthropic(api, res, "gpt-5.4")); - const delta = parseMessageDelta(sseText); + const usage = getMessageDeltaUsage(sseText); - const usage = (delta as any).usage; expect(usage.cache_read_input_tokens).toBe(6000); - expect(usage.cache_creation_input_tokens).toBe(2000); // 8000 - 6000 + expect(usage).not.toHaveProperty("cache_creation_input_tokens"); + expect(usage.input_tokens).toBe(2000); // 8000 - 6000 }); it("多轮对话场景:第二轮缓存命中率应显著高于第一轮", async () => { @@ -292,9 +310,10 @@ describe("流式响应 streamCodexToAnthropic", () => { // 第一轮:全新对话,无缓存 const res1 = makeCodexResponse({ inputTokens: 5000, outputTokens: 300 }); const sse1 = await collectSSE(streamCodexToAnthropic(api, res1, "gpt-5.4")); - const delta1 = parseMessageDelta(sse1) as any; - expect(delta1.usage.cache_creation_input_tokens).toBe(5000); - expect(delta1.usage.cache_read_input_tokens).toBeUndefined(); + const usage1 = getMessageDeltaUsage(sse1); + expect(usage1).not.toHaveProperty("cache_creation_input_tokens"); + expect(usage1.input_tokens).toBe(5000); + expect(usage1.cache_read_input_tokens).toBeUndefined(); // 第二轮:同一对话,大量缓存命中 const res2 = makeCodexResponse({ @@ -303,12 +322,13 @@ describe("流式响应 streamCodexToAnthropic", () => { cachedTokens: 4800, // 前 4800 命中缓存 }); const sse2 = await collectSSE(streamCodexToAnthropic(api, res2, "gpt-5.4")); - const delta2 = parseMessageDelta(sse2) as any; - expect(delta2.usage.cache_read_input_tokens).toBe(4800); - expect(delta2.usage.cache_creation_input_tokens).toBe(700); // 5500 - 4800 + const usage2 = getMessageDeltaUsage(sse2); + expect(usage2.cache_read_input_tokens).toBe(4800); + expect(usage2).not.toHaveProperty("cache_creation_input_tokens"); + expect(usage2.input_tokens).toBe(700); // 5500 - 4800 // 第二轮缓存命中率 >> 0 - const hitRate2 = delta2.usage.cache_read_input_tokens / 5500; + const hitRate2 = usage2.cache_read_input_tokens / 5500; expect(hitRate2).toBeGreaterThan(0.8); // 87% 命中率 }); @@ -325,10 +345,11 @@ describe("流式响应 streamCodexToAnthropic", () => { reusedInputTokensUpperBound: 15_240, }), ); - const delta = parseMessageDelta(sseText) as any; + const usage = getMessageDeltaUsage(sseText); - expect(delta.usage.cache_read_input_tokens).toBe(15_240); - expect(delta.usage.cache_creation_input_tokens).toBe(3); + expect(usage.cache_read_input_tokens).toBe(15_240); + expect(usage).not.toHaveProperty("cache_creation_input_tokens"); + expect(usage.input_tokens).toBe(3); }); it("流式工具调用响应会回传 call_id 元数据", async () => { diff --git a/tests/unit/ci/electron-smoke-script.test.ts b/tests/unit/ci/electron-smoke-script.test.ts index 30ad62e2..963199f8 100644 --- a/tests/unit/ci/electron-smoke-script.test.ts +++ b/tests/unit/ci/electron-smoke-script.test.ts @@ -15,7 +15,11 @@ import { execFileSync } from "child_process"; import { existsSync, readFileSync, statSync } from "fs"; import { resolve } from "path"; -const SCRIPT = resolve(__dirname, "..", "..", "..", ".github", "scripts", "electron-smoke.sh"); +const ROOT = resolve(__dirname, "..", "..", ".."); +const SCRIPT = resolve(ROOT, ".github", "scripts", "electron-smoke.sh"); +const WINDOWS_SCRIPT = resolve(ROOT, ".github", "scripts", "electron-smoke.ps1"); +const RELEASE_WORKFLOW = resolve(ROOT, ".github", "workflows", "release.yml"); +const PROMOTE_WORKFLOW = resolve(ROOT, ".github", "workflows", "promote-dev-to-master.yml"); interface RunResult { status: number; @@ -96,10 +100,9 @@ describe("electron-smoke.sh script", () => { it("fails loudly when no AppImage is present in RELEASE_DIR", () => { // Use the repo root as a "release dir" — no AppImage inside it. - const root = resolve(__dirname, "..", "..", ".."); const result = run({ RUNNER_OS: "Linux", - RELEASE_DIR: root, + RELEASE_DIR: ROOT, }); expect(result.status).not.toBe(0); expect(result.stderr + result.stdout).toContain("AppImage not found"); @@ -114,11 +117,51 @@ describe("electron-smoke.sh script", () => { expect(result.stderr + result.stdout).toContain("Unsupported RUNNER_OS"); }); + it("has a dedicated Windows PowerShell smoke script", () => { + expect(existsSync(WINDOWS_SCRIPT), `script missing: ${WINDOWS_SCRIPT}`).toBe(true); + const source = readFileSync(WINDOWS_SCRIPT, "utf-8"); + expect(source).toContain("Start-Process"); + expect(source).toContain("Invoke-WebRequest"); + expect(source).toContain("Stop-Process"); + }); +}); + +function stepBlock(source: string, name: string): string { + const lines = source.split("\n"); + const start = lines.findIndex((line) => line.includes(`- name: ${name}`)); + if (start < 0) return ""; + const currentIndent = lines[start].match(/^\s*/)?.[0] ?? ""; + const next = lines.findIndex((line, index) => + index > start && + line.startsWith(`${currentIndent}- name:`) + ); + return lines.slice(start, next < 0 ? lines.length : next).join("\n"); +} + +describe("release workflow smoke wiring", () => { + it("runs stale release asset cleanup through bash on Windows-compatible matrix jobs", () => { + const workflow = readFileSync(RELEASE_WORKFLOW, "utf-8"); + const block = stepBlock(workflow, "Clean stale release assets"); + + expect(block).toContain("shell: bash"); + expect(block).toContain("[ -z \"$ASSETS\" ] && exit 0"); + }); + + it("uses PowerShell smoke on Windows and bash smoke on non-Windows platforms", () => { + const workflow = readFileSync(RELEASE_WORKFLOW, "utf-8"); + const windowsBlock = stepBlock(workflow, "Smoke test packaged binary (win)"); + const unixBlock = stepBlock(workflow, "Smoke test packaged binary (${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }})"); + + expect(windowsBlock).toContain("if: matrix.platform == 'win'"); + expect(windowsBlock).toContain("shell: pwsh"); + expect(windowsBlock).toContain("run: ./.github/scripts/electron-smoke.ps1"); + expect(unixBlock).toContain("if: matrix.platform != 'win'"); + expect(unixBlock).toContain("shell: bash"); + expect(unixBlock).toContain("run: bash .github/scripts/electron-smoke.sh"); + }); + it("gives mac x64 packaged smoke extra startup time", () => { - const workflow = readFileSync( - resolve(__dirname, "..", "..", "..", ".github", "workflows", "release.yml"), - "utf-8", - ); + const workflow = readFileSync(RELEASE_WORKFLOW, "utf-8"); const block = workflow.match( /- name: Smoke test packaged binary \(mac-x64\)[\s\S]*?run: bash \.github\/scripts\/electron-smoke\.sh/, )?.[0] ?? ""; @@ -127,3 +170,13 @@ describe("electron-smoke.sh script", () => { expect(block).toContain("SMOKE_TIMEOUT: 180"); }); }); + +describe("promote workflow fast-forward gate", () => { + it("fails the run instead of reporting success when master cannot fast-forward to dev", () => { + const workflow = readFileSync(PROMOTE_WORKFLOW, "utf-8"); + const block = stepBlock(workflow, "Check fast-forward possible"); + + expect(block).toContain("master has commits not in dev"); + expect(block).toContain("exit 1"); + }); +}); diff --git a/tests/unit/config-loader.test.ts b/tests/unit/config-loader.test.ts index a97cc70b..9bbffe2f 100644 --- a/tests/unit/config-loader.test.ts +++ b/tests/unit/config-loader.test.ts @@ -320,6 +320,7 @@ describe("applyEnvOverrides", () => { savedEnv.CODEX_JWT_TOKEN = process.env.CODEX_JWT_TOKEN; savedEnv.CODEX_PLATFORM = process.env.CODEX_PLATFORM; savedEnv.CODEX_ARCH = process.env.CODEX_ARCH; + savedEnv.CODEX_PROXY_HOST = process.env.CODEX_PROXY_HOST; savedEnv.PORT = process.env.PORT; savedEnv.HTTPS_PROXY = process.env.HTTPS_PROXY; savedEnv.https_proxy = process.env.https_proxy; @@ -332,6 +333,7 @@ describe("applyEnvOverrides", () => { delete process.env.CODEX_JWT_TOKEN; delete process.env.CODEX_PLATFORM; delete process.env.CODEX_ARCH; + delete process.env.CODEX_PROXY_HOST; delete process.env.PORT; delete process.env.HTTPS_PROXY; delete process.env.https_proxy; @@ -371,6 +373,20 @@ describe("applyEnvOverrides", () => { expect((raw.server as Record).port).toBe(3000); }); + it("applies CODEX_PROXY_HOST when local.yaml has no server.host", () => { + process.env.CODEX_PROXY_HOST = "0.0.0.0"; + const raw = { server: { host: "127.0.0.1" }, auth: {} } as Record; + applyEnvOverrides(raw, null); + expect((raw.server as Record).host).toBe("0.0.0.0"); + }); + + it("does not override explicit local.yaml server.host with CODEX_PROXY_HOST", () => { + process.env.CODEX_PROXY_HOST = "0.0.0.0"; + const raw = { server: { host: "127.0.0.1" }, auth: {} } as Record; + applyEnvOverrides(raw, { server: { host: "127.0.0.1" } }); + expect((raw.server as Record).host).toBe("127.0.0.1"); + }); + it("ignores non-numeric PORT", () => { process.env.PORT = "abc"; const raw = { server: { port: 8080 }, auth: {} } as Record; diff --git a/tests/unit/config-schema.test.ts b/tests/unit/config-schema.test.ts index 7e80dae1..6566384f 100644 --- a/tests/unit/config-schema.test.ts +++ b/tests/unit/config-schema.test.ts @@ -30,7 +30,7 @@ describe("ConfigSchema", () => { expect(result.api.base_url).toBe("https://chatgpt.com/backend-api"); expect(result.api.timeout_seconds).toBe(60); expect(result.server.port).toBe(8080); - expect(result.server.host).toBe("0.0.0.0"); + expect(result.server.host).toBe("127.0.0.1"); expect(result.server.proxy_api_key).toBeNull(); expect(result.auth.rotation_strategy).toBe("least_used"); expect(result.auth.refresh_concurrency).toBe(2); @@ -43,6 +43,7 @@ describe("ConfigSchema", () => { expect(result.tls.force_http11).toBe(false); expect(result.usage_stats.snapshot_interval_minutes).toBe(5); expect(result.usage_stats.history_retention_days).toBeNull(); + expect(result.usage_stats.credits_per_usd).toBe(25); expect(result.quota.refresh_interval_minutes).toBe(5); expect(result.quota.warning_thresholds.primary).toEqual([80, 90]); expect(result.quota.skip_exhausted).toBe(true); diff --git a/tests/unit/middleware/cors.test.ts b/tests/unit/middleware/cors.test.ts index f8b8bb19..d6672921 100644 --- a/tests/unit/middleware/cors.test.ts +++ b/tests/unit/middleware/cors.test.ts @@ -23,7 +23,7 @@ describe("cors middleware", () => { expect(res.status).toBe(204); expect(res.headers.get("Access-Control-Allow-Origin")).toBe("http://127.0.0.1:5173"); - expect(res.headers.get("Vary")).toBe("Origin"); + expect(res.headers.get("Vary")).toBe("Origin, Access-Control-Request-Method, Access-Control-Request-Headers"); }); it("does not expose admin routes to loopback web origins", async () => { diff --git a/tests/unit/proxy/cookie-jar.test.ts b/tests/unit/proxy/cookie-jar.test.ts index 3a3bff45..6bdd7e57 100644 --- a/tests/unit/proxy/cookie-jar.test.ts +++ b/tests/unit/proxy/cookie-jar.test.ts @@ -1,107 +1,128 @@ -/** - * Tests for CookieJar — per-account cookie storage. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -vi.mock("fs", () => ({ - readFileSync: vi.fn(() => { throw new Error("ENOENT"); }), - writeFileSync: vi.fn(), - renameSync: vi.fn(), - existsSync: vi.fn(() => false), - mkdirSync: vi.fn(), -})); - -vi.mock("@src/paths.js", () => ({ - getDataDir: vi.fn(() => "/tmp/test-data"), -})); - -import { CookieJar } from "@src/proxy/cookie-jar.js"; - -describe("CookieJar", () => { - let jar: CookieJar; - - beforeEach(() => { - jar = new CookieJar(); - }); - - afterEach(() => { - jar.destroy(); - }); - - describe("set + getCookieHeader", () => { - it("sets cookies from string and gets header", () => { - jar.set("acct1", "a=1; b=2"); - const header = jar.getCookieHeader("acct1"); - expect(header).toContain("a=1"); - expect(header).toContain("b=2"); - }); - - it("sets cookies from Record", () => { - jar.set("acct1", { foo: "bar", baz: "qux" }); - const header = jar.getCookieHeader("acct1"); - expect(header).toContain("foo=bar"); - expect(header).toContain("baz=qux"); - }); - - it("returns null for unknown account", () => { - expect(jar.getCookieHeader("unknown")).toBeNull(); - }); - - it("merges with existing cookies", () => { - jar.set("acct1", { a: "1" }); - jar.set("acct1", { b: "2" }); - const header = jar.getCookieHeader("acct1"); - expect(header).toContain("a=1"); - expect(header).toContain("b=2"); - }); - }); - - describe("captureRaw", () => { - it("parses Set-Cookie headers", () => { - jar.captureRaw("acct1", [ - "session_id=abc123; Path=/; HttpOnly", - "cf_clearance=xyz; Max-Age=3600", - ]); - const header = jar.getCookieHeader("acct1"); - expect(header).toContain("session_id=abc123"); - expect(header).toContain("cf_clearance=xyz"); - }); - - it("parses Max-Age for expiry", () => { - // Set a cookie with Max-Age=0 (immediately expired) - jar.captureRaw("acct1", [ - "expired=val; Max-Age=0", - "valid=val; Max-Age=3600", - ]); - const header = jar.getCookieHeader("acct1"); - expect(header).not.toContain("expired="); - expect(header).toContain("valid=val"); - }); - - it("does nothing with empty array", () => { - jar.captureRaw("acct1", []); - expect(jar.getCookieHeader("acct1")).toBeNull(); - }); - }); - - describe("get", () => { - it("returns raw cookie values", () => { - jar.set("acct1", { a: "1", b: "2" }); - const raw = jar.get("acct1"); - expect(raw).toEqual({ a: "1", b: "2" }); - }); - - it("returns null for unknown account", () => { - expect(jar.get("unknown")).toBeNull(); - }); - }); - - describe("clear", () => { - it("clears all cookies for an account", () => { - jar.set("acct1", { a: "1" }); - jar.clear("acct1"); - expect(jar.getCookieHeader("acct1")).toBeNull(); - }); - }); -}); +/** + * Tests for CookieJar — per-account cookie storage. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("fs", () => ({ + readFileSync: vi.fn(() => { throw new Error("ENOENT"); }), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + existsSync: vi.fn(() => false), + mkdirSync: vi.fn(), +})); + +vi.mock("@src/paths.js", () => ({ + getDataDir: vi.fn(() => "/tmp/test-data"), +})); + +import { CookieJar } from "@src/proxy/cookie-jar.js"; + +describe("CookieJar", () => { + let jar: CookieJar; + + beforeEach(() => { + jar = new CookieJar(); + }); + + afterEach(() => { + jar.destroy(); + }); + + describe("set + getCookieHeader", () => { + it("sets cookies from string and gets header", () => { + jar.set("acct1", "a=1; b=2"); + const header = jar.getCookieHeader("acct1"); + expect(header).toContain("a=1"); + expect(header).toContain("b=2"); + }); + + it("sets cookies from Record", () => { + jar.set("acct1", { foo: "bar", baz: "qux" }); + const header = jar.getCookieHeader("acct1"); + expect(header).toContain("foo=bar"); + expect(header).toContain("baz=qux"); + }); + + it("returns null for unknown account", () => { + expect(jar.getCookieHeader("unknown")).toBeNull(); + }); + + it("merges with existing cookies", () => { + jar.set("acct1", { a: "1" }); + jar.set("acct1", { b: "2" }); + const header = jar.getCookieHeader("acct1"); + expect(header).toContain("a=1"); + expect(header).toContain("b=2"); + }); + }); + + describe("captureRaw", () => { + it("parses Set-Cookie headers (whitelisted cookies only)", () => { + jar.captureRaw("acct1", [ + "cf_clearance=xyz; Max-Age=3600; Path=/; HttpOnly", + ]); + const header = jar.getCookieHeader("acct1"); + expect(header).toContain("cf_clearance=xyz"); + }); + + it("parses Max-Age for expiry", () => { + // First write a valid cf_clearance, then a Max-Age=0 update should + // immediately expire it. Both names must be whitelisted to exercise the + // attribute parser. + jar.captureRaw("acct1", ["cf_clearance=v1; Max-Age=3600"]); + expect(jar.getCookieHeader("acct1")).toContain("cf_clearance=v1"); + jar.captureRaw("acct1", ["cf_clearance=v2; Max-Age=0"]); + expect(jar.getCookieHeader("acct1")).toBeNull(); + }); + + it("does nothing with empty array", () => { + jar.captureRaw("acct1", []); + expect(jar.getCookieHeader("acct1")).toBeNull(); + }); + + it("filters out non-whitelisted cookies (e.g. __cf_bm)", () => { + // __cf_bm is Cloudflare Bot Management — when captured and replayed it + // becomes a "suspicious session" tag (bound to IP+UA+TLS at issue time) + // and triggers path-level 404s on /codex/responses. Only cf_clearance + // (the positive challenge-pass token) should be captured automatically. + jar.captureRaw("acct1", [ + "__cf_bm=poison; Path=/; Max-Age=1800; HttpOnly", + "cf_clearance=ok; Path=/; Max-Age=3600", + "session_id=abc; Path=/; HttpOnly", + ]); + const raw = jar.get("acct1"); + expect(raw).not.toBeNull(); + expect(raw).not.toHaveProperty("__cf_bm"); + expect(raw).not.toHaveProperty("session_id"); + expect(raw).toEqual({ cf_clearance: "ok" }); + }); + + it("manual set() still accepts arbitrary cookies (debugging / overrides)", () => { + // The whitelist only applies to auto-capture from Set-Cookie headers. + // Operators may still inject any cookie manually via the admin API. + jar.set("acct1", { __cf_bm: "manual" }); + expect(jar.get("acct1")).toEqual({ __cf_bm: "manual" }); + }); + }); + + describe("get", () => { + it("returns raw cookie values", () => { + jar.set("acct1", { a: "1", b: "2" }); + const raw = jar.get("acct1"); + expect(raw).toEqual({ a: "1", b: "2" }); + }); + + it("returns null for unknown account", () => { + expect(jar.get("unknown")).toBeNull(); + }); + }); + + describe("clear", () => { + it("clears all cookies for an account", () => { + jar.set("acct1", { a: "1" }); + jar.clear("acct1"); + expect(jar.getCookieHeader("acct1")).toBeNull(); + }); + }); +}); diff --git a/tests/unit/proxy/error-classification.test.ts b/tests/unit/proxy/error-classification.test.ts index b84c51e1..c7998500 100644 --- a/tests/unit/proxy/error-classification.test.ts +++ b/tests/unit/proxy/error-classification.test.ts @@ -3,6 +3,7 @@ import { CodexApiError } from "@src/proxy/codex-types.js"; import { extractRetryAfterSec, isBanError, + isCfPathBlockError, isQuotaExhaustedError, isTokenInvalidError, isModelNotSupportedError, @@ -159,3 +160,26 @@ describe("isUnansweredFunctionCallError", () => { expect(isUnansweredFunctionCallError(null)).toBe(false); }); }); + +describe("isCfPathBlockError", () => { + it("matches empty-body 404 (Cloudflare stealth deny)", () => { + expect(isCfPathBlockError(new CodexApiError(404, ""))).toBe(true); + expect(isCfPathBlockError(new CodexApiError(404, " "))).toBe(true); + expect(isCfPathBlockError(new CodexApiError(404, "\n"))).toBe(true); + }); + + it("does not match 404 with a real error body", () => { + const body = JSON.stringify({ error: { message: "Not found" } }); + expect(isCfPathBlockError(new CodexApiError(404, body))).toBe(false); + }); + + it("does not match other empty-body statuses", () => { + expect(isCfPathBlockError(new CodexApiError(403, ""))).toBe(false); + expect(isCfPathBlockError(new CodexApiError(502, ""))).toBe(false); + }); + + it("returns false for non-CodexApiError", () => { + expect(isCfPathBlockError(new Error("404"))).toBe(false); + expect(isCfPathBlockError(null)).toBe(false); + }); +}); diff --git a/tests/unit/proxy/upstream-router-bootstrap.test.ts b/tests/unit/proxy/upstream-router-bootstrap.test.ts new file mode 100644 index 00000000..35745eef --- /dev/null +++ b/tests/unit/proxy/upstream-router-bootstrap.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { ApiKeyPool } from "@src/auth/api-key-pool.js"; +import type { ApiKeyEntry, ApiKeyPersistence } from "@src/auth/api-key-pool.js"; +import type { UpstreamAdapter } from "@src/proxy/upstream-adapter.js"; +import type { CodexResponsesRequest, CodexSSEEvent } from "@src/proxy/codex-types.js"; +import { createRuntimeUpstreamRouter } from "@src/proxy/upstream-router-bootstrap.js"; + +function createMemoryPersistence(): ApiKeyPersistence { + let stored: ApiKeyEntry[] = []; + return { + load: () => [...stored], + save: (keys) => { + stored = [...keys]; + }, + }; +} + +function mockAdapter(tag: string): UpstreamAdapter { + return { + tag, + createResponse: (_req: CodexResponsesRequest) => Promise.resolve(new Response()), + async *parseStream(): AsyncGenerator { /* no events */ }, + }; +} + +describe("createRuntimeUpstreamRouter", () => { + it("creates a router even when startup has no configured adapters or persisted API keys", () => { + const pool = new ApiKeyPool(createMemoryPersistence()); + const router = createRuntimeUpstreamRouter(new Map(), {}, pool, (entry) => mockAdapter(`dynamic-${entry.model}`)); + + expect(router.resolveMatch("late-runtime-model").kind).toBe("not-found"); + + pool.add({ + provider: "custom", + model: "late-runtime-model", + apiKey: "secret", + baseUrl: "https://example.com/v1", + }); + + const match = router.resolveMatch("late-runtime-model"); + expect(match.kind).toBe("api-key"); + if (match.kind === "api-key") { + expect(match.adapter.tag).toBe("dynamic-late-runtime-model"); + } + }); +}); diff --git a/tests/unit/routes/accounts-import-export.test.ts b/tests/unit/routes/accounts-import-export.test.ts index 7ae5426c..2058f7ea 100644 --- a/tests/unit/routes/accounts-import-export.test.ts +++ b/tests/unit/routes/accounts-import-export.test.ts @@ -143,6 +143,32 @@ describe("account import/export", () => { expect(data.accounts[0]).not.toHaveProperty("email"); }); + it("GET /auth/accounts/export?format=sub2api returns Sub2API-compatible payload", async () => { + const id = pool.addAccount("tokenSUB21234567890", "rt_sub2api"); + pool.setLabel(id, "Sub2API Label"); + + const res = await app.request("/auth/accounts/export?format=sub2api"); + expect(res.status).toBe(200); + const data = await res.json() as { + type: string; + version: number; + accounts: Array<{ + name: string; + platform: string; + type: string; + credentials: { access_token: string; refresh_token?: string }; + }>; + }; + expect(data.type).toBe("sub2api-data"); + expect(data.version).toBe(1); + expect(data.accounts).toHaveLength(1); + expect(data.accounts[0].name).toBe("Sub2API Label"); + expect(data.accounts[0].platform).toBe("openai"); + expect(data.accounts[0].type).toBe("oauth"); + expect(data.accounts[0].credentials.access_token).toBe("tokenSUB21234567890"); + expect(data.accounts[0].credentials.refresh_token).toBe("rt_sub2api"); + }); + it("GET /auth/accounts/export?ids= filters server-side", async () => { const id1 = pool.addAccount("tokenAAAA1234567890"); pool.addAccount("tokenBBBB1234567890"); @@ -154,6 +180,36 @@ describe("account import/export", () => { expect(data.accounts[0].id).toBe(id1); }); + it("GET /auth/accounts/:id/quota caches successful active quota responses", async () => { + const id = pool.addAccount("tokenQUOT1234567890"); + const { CodexApi } = await import("@src/proxy/codex-api.js"); + const getUsageSpy = vi.spyOn(CodexApi.prototype, "getUsage").mockResolvedValueOnce({ + plan_type: "plus", + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { + used_percent: 64, + reset_at: 1700000000, + limit_window_seconds: 18_000, + reset_after_seconds: 100, + }, + secondary_window: null, + }, + code_review_rate_limit: null, + credits: null, + promo: null, + }); + + const res = await app.request(`/auth/accounts/${id}/quota`); + + expect(res.status).toBe(200); + const body = await res.json() as { quota: { rate_limit: { remaining_percent: number } } }; + expect(body.quota.rate_limit.remaining_percent).toBe(36); + expect(pool.getEntry(id)?.cachedQuota?.rate_limit.remaining_percent).toBe(36); + getUsageSpy.mockRestore(); + }); + // ── Import ───────────────────────────────────────────── it("POST /auth/accounts/import adds new accounts", async () => { @@ -252,6 +308,57 @@ describe("account import/export", () => { expect(entries[0].refreshToken).toBe("refresh_abc"); }); + it("POST /auth/accounts/import accepts Sub2API export JSON", async () => { + const res = await app.request("/auth/accounts/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "sub2api-data", + version: 1, + accounts: [ + { + name: "Imported Sub2API", + platform: "openai", + type: "oauth", + credentials: { + access_token: "tokenS2AI1234567890", + refresh_token: "rt_s2ai", + }, + }, + ], + }), + }); + + expect(res.status).toBe(200); + const data = await res.json() as { added: number; failed: number }; + expect(data.added).toBe(1); + expect(data.failed).toBe(0); + const entry = pool.getAllEntries()[0]; + expect(entry.token).toBe("tokenS2AI1234567890"); + expect(entry.refreshToken).toBe("rt_s2ai"); + expect(entry.label).toBe("Imported Sub2API"); + }); + + it("POST /auth/accounts/import accepts text/plain token lines", async () => { + const res = await app.request("/auth/accounts/import", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: [ + "tokenTEXT1234567890", + "{\"accessToken\":\"tokenJSON1234567890\",\"refreshToken\":\"rt_json\"}", + ].join("\n"), + }); + + expect(res.status).toBe(200); + const data = await res.json() as { added: number; failed: number }; + expect(data.added).toBe(2); + expect(data.failed).toBe(0); + expect(pool.getAllEntries().map((entry) => entry.token)).toEqual([ + "tokenTEXT1234567890", + "tokenJSON1234567890", + ]); + }); + it("POST /auth/accounts/import rejects empty accounts array", async () => { const res = await app.request("/auth/accounts/import", { method: "POST", diff --git a/tests/unit/routes/admin/error-logs.test.ts b/tests/unit/routes/admin/error-logs.test.ts index 0fdf07eb..7f131254 100644 --- a/tests/unit/routes/admin/error-logs.test.ts +++ b/tests/unit/routes/admin/error-logs.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, existsSync, rmSync, readFileSync } from "fs"; +import { mkdtempSync, existsSync, rmSync, readFileSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { resolve } from "path"; import { Hono } from "hono"; @@ -183,6 +183,52 @@ describe("POST /admin/error-logs/seen", () => { }); }); +describe("DELETE /admin/error-logs", () => { + it("clears current, rotated backup, and cursor files so grouped logs and counts become empty", async () => { + await appendFew(); + writeFileSync( + resolve(tmpDataDir, "error-log.1.jsonl"), + JSON.stringify({ + ts: "2026-05-01T00:00:00.000Z", + version: "0.0.0-test", + platform: "darwin", + source: "server", + error: { name: "StreamUpstreamPrematureClose", message: "closed early" }, + }) + "\n", + "utf-8", + ); + const { setReadCursor } = await import("@src/logs/error-log.js"); + setReadCursor("2025-01-01T00:00:00.000Z"); + const app = await buildApp(); + + expect(existsSync(resolve(tmpDataDir, "error-log.jsonl"))).toBe(true); + expect(existsSync(resolve(tmpDataDir, "error-log.1.jsonl"))).toBe(true); + expect(existsSync(resolve(tmpDataDir, "error-log.cursor"))).toBe(true); + + const before = await app.request("/admin/error-logs/count"); + expect(((await before.json()) as { total: number; unread: number })).toEqual({ + total: 4, + unread: 4, + }); + + const clearRes = await app.request("/admin/error-logs", { method: "DELETE" }); + expect(clearRes.status).toBe(200); + expect(await clearRes.json()).toEqual({ ok: true }); + expect(existsSync(resolve(tmpDataDir, "error-log.jsonl"))).toBe(false); + expect(existsSync(resolve(tmpDataDir, "error-log.1.jsonl"))).toBe(false); + expect(existsSync(resolve(tmpDataDir, "error-log.cursor"))).toBe(false); + + const grouped = await app.request("/admin/error-logs"); + expect((await grouped.json()) as { groups: unknown[] }).toEqual({ groups: [] }); + + const after = await app.request("/admin/error-logs/count"); + expect(((await after.json()) as { total: number; unread: number })).toEqual({ + total: 0, + unread: 0, + }); + }); +}); + describe("POST /admin/error-logs/report", () => { it("appends a renderer-reported error to the log with sanitized context", async () => { const app = await buildApp(); diff --git a/tests/unit/routes/api-keys.test.ts b/tests/unit/routes/api-keys.test.ts index d63246ec..de761291 100644 --- a/tests/unit/routes/api-keys.test.ts +++ b/tests/unit/routes/api-keys.test.ts @@ -26,6 +26,23 @@ describe("api key routes", () => { app = createApiKeyRoutes(pool); }); + it("returns current built-in Anthropic catalog defaults", async () => { + const res = await app.request("/auth/api-keys/catalog"); + expect(res.status).toBe(200); + + const body = await res.json() as { + catalog: { + anthropic: { + models: Array<{ id: string; displayName: string }>; + }; + }; + }; + expect(body.catalog.anthropic.models.slice(0, 2)).toEqual([ + { id: "claude-opus-4-7", displayName: "Claude Opus 4.7" }, + { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, + ]); + }); + it("adds one stored entry per selected model and masks returned keys", async () => { const res = await app.request("/auth/api-keys", { method: "POST", @@ -44,6 +61,23 @@ describe("api key routes", () => { expect(body.keys).toHaveLength(2); expect(body.keys[0].apiKey).toBe("sk-1****cdef"); expect(pool.getAll().map((entry) => entry.model)).toEqual(["gpt-5.4", "gpt-5.4-mini"]); + expect(pool.getAll().map((entry) => entry.capabilities)).toEqual([["chat"], ["chat"]]); + }); + + it("stores explicit capabilities for selected models", async () => { + const res = await app.request("/auth/api-keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "openai", + models: ["text-embedding-3-small"], + apiKey: "sk-embedding", + capabilities: ["embeddings"], + }), + }); + + expect(res.status).toBe(200); + expect(pool.getAll()[0].capabilities).toEqual(["embeddings"]); }); it("requires baseUrl for custom provider keys", async () => { @@ -79,6 +113,7 @@ describe("api key routes", () => { models: ["custom-a"], apiKey: "custom-key", baseUrl: "https://example.com/v1", + capabilities: ["chat", "embeddings"], }, ], }), @@ -92,10 +127,17 @@ describe("api key routes", () => { "claude-sonnet-4-6", "custom-a", ]); + expect(pool.getAll()[2].capabilities).toEqual(["chat", "embeddings"]); }); it("exports stored single-model entries as importable multi-model entries", async () => { - pool.add({ provider: "openai", model: "gpt-5.4", apiKey: "sk-openai", label: "A" }); + pool.add({ + provider: "openai", + model: "gpt-5.4", + apiKey: "sk-openai", + label: "A", + capabilities: ["chat", "embeddings"], + }); const res = await app.request("/auth/api-keys/export"); @@ -108,6 +150,7 @@ describe("api key routes", () => { apiKey: "sk-openai", baseUrl: "https://api.openai.com/v1", label: "A", + capabilities: ["chat", "embeddings"], }, ]); }); diff --git a/tests/unit/routes/embeddings.test.ts b/tests/unit/routes/embeddings.test.ts new file mode 100644 index 00000000..99981fbd --- /dev/null +++ b/tests/unit/routes/embeddings.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for OpenAI-compatible embeddings proxying through runtime API keys. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiKeyPool } from "@src/auth/api-key-pool.js"; +import type { ApiKeyEntry, ApiKeyPersistence } from "@src/auth/api-key-pool.js"; +import type { AccountPool } from "@src/auth/account-pool.js"; +import { createEmbeddingsRoutes } from "@src/routes/embeddings.js"; + +const mockConfig = { + server: { proxy_api_key: null as string | null }, + tls: { proxy_url: null as string | null }, +}; + +vi.mock("@src/config.js", () => ({ + getConfig: vi.fn(() => mockConfig), +})); + +function createMemoryPersistence(): ApiKeyPersistence { + let stored: ApiKeyEntry[] = []; + return { + load: () => [...stored], + save: (keys) => { + stored = [...keys]; + }, + }; +} + +function createAccountPool(validateProxyApiKey = true): AccountPool { + return { + validateProxyApiKey: vi.fn(() => validateProxyApiKey), + } as unknown as AccountPool; +} + +describe("embeddings routes", () => { + let pool: ApiKeyPool; + let fetchMock: ReturnType Promise>>; + + beforeEach(() => { + mockConfig.server.proxy_api_key = null; + pool = new ApiKeyPool(createMemoryPersistence()); + fetchMock = vi.fn(async () => new Response(JSON.stringify({ + object: "list", + data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2] }], + model: "text-embedding-3-small", + usage: { prompt_tokens: 1, total_tokens: 1 }, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("proxies embeddings to OpenAI-compatible runtime API keys", async () => { + pool.add({ + provider: "custom", + model: "text-embedding-3-small", + apiKey: "upstream-secret", + baseUrl: "https://embeddings.example.com/v1/", + capabilities: ["embeddings"], + }); + + const app = createEmbeddingsRoutes(createAccountPool(), pool); + const res = await app.request("/v1/embeddings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "text-embedding-3-small", + input: "hello", + encoding_format: "float", + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data[0].embedding).toEqual([0.1, 0.2]); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe("https://embeddings.example.com/v1/embeddings"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer upstream-secret"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + model: "text-embedding-3-small", + input: "hello", + encoding_format: "float", + }); + expect(pool.getAll()[0].lastUsedAt).toBeTruthy(); + }); + + it("requires the proxy API key when configured", async () => { + mockConfig.server.proxy_api_key = "proxy-secret"; + pool.add({ + provider: "openai", + model: "text-embedding-3-small", + apiKey: "upstream-secret", + capabilities: ["embeddings"], + }); + + const app = createEmbeddingsRoutes(createAccountPool(false), pool); + const res = await app.request("/v1/embeddings", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer wrong-key", + }, + body: JSON.stringify({ model: "text-embedding-3-small", input: "hello" }), + }); + + expect(res.status).toBe(401); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not use chat-only keys for embeddings", async () => { + pool.add({ + provider: "openai", + model: "text-embedding-3-small", + apiKey: "chat-only-secret", + }); + + const app = createEmbeddingsRoutes(createAccountPool(), pool); + const res = await app.request("/v1/embeddings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "text-embedding-3-small", input: "hello" }), + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error.code).toBe("model_not_found"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects embeddings-capable keys on providers without OpenAI-compatible embeddings", async () => { + pool.add({ + provider: "anthropic", + model: "claude-opus-4-6", + apiKey: "sk-ant", + capabilities: ["embeddings"], + }); + + const app = createEmbeddingsRoutes(createAccountPool(), pool); + const res = await app.request("/v1/embeddings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "claude-opus-4-6", input: "hello" }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe("unsupported_provider"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects malformed embedding requests", async () => { + const app = createEmbeddingsRoutes(createAccountPool(), pool); + const res = await app.request("/v1/embeddings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "text-embedding-3-small" }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe("invalid_request"); + }); +}); diff --git a/tests/unit/routes/general-settings.test.ts b/tests/unit/routes/general-settings.test.ts index d261f4a9..3328bfe7 100644 --- a/tests/unit/routes/general-settings.test.ts +++ b/tests/unit/routes/general-settings.test.ts @@ -29,7 +29,10 @@ const mockConfig = { }, update: { auto_update: true, auto_download: false, show_update_dialog: false }, logs: { enabled: false, capacity: 2000, capture_body: false, llm_only: true }, - usage_stats: { history_retention_days: null as number | null }, + usage_stats: { + history_retention_days: null as number | null, + credits_per_usd: 25, + }, }; vi.mock("@src/config.js", () => ({ @@ -113,9 +116,12 @@ describe("GET /admin/general-settings", () => { beforeEach(() => { vi.clearAllMocks(); mockConfig.logs.llm_only = true; + mockConfig.usage_stats.history_retention_days = null; + mockConfig.usage_stats.credits_per_usd = 25; }); - it("returns current values including logs_llm_only", async () => { + it("returns current values including logs_llm_only and credits_per_usd", async () => { + mockConfig.usage_stats.credits_per_usd = 40; const app = makeApp(); const res = await app.request("/admin/general-settings"); expect(res.status).toBe(200); @@ -135,6 +141,7 @@ describe("GET /admin/general-settings", () => { logs_capture_body: false, logs_llm_only: true, usage_history_retention_days: null, + credits_per_usd: 40, }); }); }); @@ -143,6 +150,8 @@ describe("POST /admin/general-settings", () => { beforeEach(() => { vi.clearAllMocks(); mockConfig.logs.llm_only = true; + mockConfig.usage_stats.history_retention_days = null; + mockConfig.usage_stats.credits_per_usd = 25; }); it("persists logs_llm_only without requiring restart", async () => { @@ -267,6 +276,40 @@ describe("POST /admin/general-settings", () => { }); }); + it("persists dashboard credit USD conversion rate without requiring restart", async () => { + const app = makeApp(); + const res = await app.request("/admin/general-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ credits_per_usd: 40 }), + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.restart_required).toBe(false); + expect(mutateYaml).toHaveBeenCalledOnce(); + const mutate = vi.mocked(mutateYaml).mock.calls[0]?.[1]; + const localConfig: Record = {}; + mutate?.(localConfig); + expect(localConfig).toEqual({ + usage_stats: { credits_per_usd: 40 }, + }); + }); + + it("rejects invalid dashboard credit USD conversion rate", async () => { + const app = makeApp(); + const res = await app.request("/admin/general-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ credits_per_usd: -1 }), + }); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("credits_per_usd"); + }); + it("rejects invalid usage history retention", async () => { const app = makeApp(); const res = await app.request("/admin/general-settings", { diff --git a/tests/unit/routes/responses-passthrough-metadata.test.ts b/tests/unit/routes/responses-passthrough-metadata.test.ts new file mode 100644 index 00000000..38148fbd --- /dev/null +++ b/tests/unit/routes/responses-passthrough-metadata.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Context } from "hono"; +import type { CodexSSEEvent } from "@src/proxy/codex-types.js"; +import type { UpstreamAdapter } from "@src/proxy/upstream-adapter.js"; +import type { + FormatAdapter, + HandleDirectRequestOptions, + ResponseMetadata, +} from "@src/routes/shared/proxy-handler-types.js"; +import type { UpstreamRouter } from "@src/proxy/upstream-router.js"; + +const mockConfig = { + server: { proxy_api_key: null as string | null }, + model: { + default: "gpt-5.3-codex", + default_reasoning_effort: null, + default_service_tier: null, + suppress_desktop_directives: false, + }, + auth: { + jwt_token: undefined as string | undefined, + rotation_strategy: "least_used" as const, + rate_limit_backoff_seconds: 60, + }, +}; + +vi.mock("@src/config.js", () => ({ + getConfig: vi.fn(() => mockConfig), +})); + +vi.mock("@src/paths.js", () => ({ + getDataDir: vi.fn(() => "/tmp/test-responses-metadata"), + getConfigDir: vi.fn(() => "/tmp/test-responses-metadata-config"), +})); + +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: vi.fn(() => "models: []"), + writeFileSync: vi.fn(), + writeFile: vi.fn( + (_path: string, _data: string, _encoding: string, cb: (err: Error | null) => void) => cb(null), + ), + existsSync: vi.fn(() => false), + mkdirSync: vi.fn(), + renameSync: vi.fn(), + }; +}); + +vi.mock("js-yaml", () => ({ + default: { + load: vi.fn(() => ({ models: [], aliases: {} })), + dump: vi.fn(() => ""), + }, +})); + +vi.mock("@src/auth/jwt-utils.js", () => ({ + decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })), + extractChatGptAccountId: vi.fn((token: string) => `acct-${token}`), + extractUserProfile: vi.fn(() => null), + isTokenExpired: vi.fn(() => false), +})); + +vi.mock("@src/models/model-fetcher.js", () => ({ + triggerImmediateRefresh: vi.fn(), + startModelRefresh: vi.fn(), + stopModelRefresh: vi.fn(), +})); + +const mockHandleDirectRequest = vi.fn(async (options: HandleDirectRequestOptions) => options.c.json({ ok: true })); +vi.mock("@src/routes/shared/proxy-handler.js", () => ({ + handleProxyRequest: vi.fn(async (options: { c: Context }) => options.c.json({ proxied: true })), +})); +vi.mock("@src/routes/shared/direct-request-handler.js", () => ({ + handleDirectRequest: (options: HandleDirectRequestOptions) => mockHandleDirectRequest(options), +})); + +import { AccountPool } from "@src/auth/account-pool.js"; +import { loadStaticModels } from "@src/models/model-store.js"; +import { createResponsesRoutes } from "@src/routes/responses.js"; + +const functionCallItem = { + type: "function_call", + id: "fc_issue_571", + call_id: "call_issue_571", + name: "read_file", + arguments: "{}", +}; + +function createFunctionCallEvents(): CodexSSEEvent[] { + return [ + { + event: "response.created", + data: { type: "response.created", response: { id: "resp_issue_571" } }, + }, + { + event: "response.output_item.done", + data: { type: "response.output_item.done", output_index: 0, item: functionCallItem }, + }, + { + event: "response.completed", + data: { + type: "response.completed", + response: { + id: "resp_issue_571", + output: [functionCallItem], + usage: { input_tokens: 10, output_tokens: 2 }, + }, + }, + }, + ]; +} + +function createMockAdapter(events: CodexSSEEvent[]): UpstreamAdapter { + return { + tag: "test-upstream", + createResponse: vi.fn(async () => new Response()), + async *parseStream(_response: Response): AsyncGenerator { + for (const event of events) { + yield event; + } + }, + }; +} + +async function captureResponsesFormat(): Promise { + const pool = new AccountPool(); + const adapter = createMockAdapter([]); + const upstreamRouter = { + resolveMatch: vi.fn(() => ({ kind: "adapter" as const, adapter })), + } as unknown as UpstreamRouter; + const app = createResponsesRoutes(pool, undefined, undefined, upstreamRouter); + + const res = await app.request("/v1/responses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "test-upstream-model", + input: [{ role: "user", content: "hello" }], + }), + }); + + expect(res.status).toBe(200); + expect(mockHandleDirectRequest).toHaveBeenCalledTimes(1); + const [options] = mockHandleDirectRequest.mock.calls[0] as [HandleDirectRequestOptions]; + pool.destroy(); + return options.fmt; +} + +describe("Responses passthrough metadata", () => { + beforeEach(() => { + vi.clearAllMocks(); + loadStaticModels(); + }); + + it("streams function_call call_id metadata through the Responses format adapter", async () => { + const format = await captureResponsesFormat(); + const metadata: ResponseMetadata[] = []; + + const chunks: string[] = []; + for await (const chunk of format.streamTranslator({ + api: createMockAdapter(createFunctionCallEvents()), + response: new Response(), + model: "test-upstream-model", + onUsage: () => {}, + onResponseId: () => {}, + onResponseMetadata: (value) => metadata.push(value), + })) { + chunks.push(chunk); + } + + expect(chunks.join("")).toContain("response.output_item.done"); + expect(metadata).toEqual([{ functionCallIds: ["call_issue_571"] }]); + }); + + it("collects function_call call_id metadata through the Responses format adapter", async () => { + const format = await captureResponsesFormat(); + const metadata: ResponseMetadata[] = []; + + const result = await format.collectTranslator({ + api: createMockAdapter(createFunctionCallEvents()), + response: new Response(), + model: "test-upstream-model", + onResponseMetadata: (value) => metadata.push(value), + }); + + expect(result.responseId).toBe("resp_issue_571"); + expect(metadata).toEqual([{ functionCallIds: ["call_issue_571"] }]); + }); +}); diff --git a/tests/unit/routes/shared/proxy-egress-log.test.ts b/tests/unit/routes/shared/proxy-egress-log.test.ts index 45784b16..86e46d51 100644 --- a/tests/unit/routes/shared/proxy-egress-log.test.ts +++ b/tests/unit/routes/shared/proxy-egress-log.test.ts @@ -58,6 +58,29 @@ describe("recordProxyEgressLog", () => { }); }); + it("records reasoning and service tier in the upstream request summary", () => { + recordProxyEgressLog({ + requestId: "rid-reasoning", + request: createRequest({ + reasoning: { effort: "xhigh", summary: "auto" }, + service_tier: "fast", + }), + status: 200, + startMs: 1_000, + nowMs: () => 1_515, + }); + + expect(enqueueLogEntryMock).toHaveBeenCalledWith(expect.objectContaining({ + request: { + model: "codex-model", + stream: true, + useWebSocket: true, + reasoning: { effort: "xhigh", summary: "auto" }, + service_tier: "fast", + }, + })); + }); + it("preserves nullable status and undefined websocket metadata", () => { recordProxyEgressLog({ requestId: "rid-456", diff --git a/tests/unit/routes/shared/proxy-error-handler.test.ts b/tests/unit/routes/shared/proxy-error-handler.test.ts index 26a8ee0c..0acd2095 100644 --- a/tests/unit/routes/shared/proxy-error-handler.test.ts +++ b/tests/unit/routes/shared/proxy-error-handler.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handleCodexApiError, type ErrorAction } from "@src/routes/shared/proxy-error-handler.js"; import { CodexApiError } from "@src/proxy/codex-types.js"; +import { _resetAllCfPathBlocks } from "@src/auth/cf-path-block-tracker.js"; /* ── Minimal mock matching AccountPool subset used by error handler ── */ interface MockPool { @@ -21,6 +22,14 @@ function createMockPool(): MockPool { }; } +interface MockJar { + clear: ReturnType; +} + +function createMockJar(): MockJar { + return { clear: vi.fn() }; +} + describe("handleCodexApiError", () => { let pool: MockPool; const tag = "Test"; @@ -233,6 +242,44 @@ describe("handleCodexApiError", () => { expect(result.useFormat429).toBe(true); }); + it("Cloudflare path-block (empty-body 404): clears cookies, retries, disables after threshold", () => { + _resetAllCfPathBlocks(); + const jar = createMockJar(); + const err = new CodexApiError(404, ""); + + // 1st & 2nd: clear cookies, retry on different account, no disable + let result = handleCodexApiError(err, pool as never, entryId, model, tag, false, jar as never); + expect(result.action).toBe("retry"); + expect(result.releaseBeforeRetry).toBe(true); + expect(jar.clear).toHaveBeenCalledWith(entryId); + expect(pool.markStatus).not.toHaveBeenCalled(); + + result = handleCodexApiError(err, pool as never, entryId, model, tag, false, jar as never); + expect(result.action).toBe("retry"); + expect(pool.markStatus).not.toHaveBeenCalled(); + + // 3rd: threshold reached — disable account + result = handleCodexApiError(err, pool as never, entryId, model, tag, false, jar as never); + expect(pool.markStatus).toHaveBeenCalledWith(entryId, "disabled"); + // Still a retry so the request can fail over to another account on the + // same orchestration loop. + expect(result.action).toBe("retry"); + }); + + it("Cloudflare path-block branch ignores non-empty 404 bodies", () => { + _resetAllCfPathBlocks(); + const jar = createMockJar(); + const err = new CodexApiError(404, JSON.stringify({ error: { message: "real not found" } })); + + const result = handleCodexApiError(err, pool as never, entryId, model, tag, false, jar as never); + + // Falls through to generic respond path; no cookie clear, no disable. + expect(result.action).toBe("respond"); + expect(result.status).toBe(404); + expect(jar.clear).not.toHaveBeenCalled(); + expect(pool.markStatus).not.toHaveBeenCalled(); + }); + it("retry actions do NOT include errorBody", () => { const cases = [ new CodexApiError(429, JSON.stringify({ error: { resets_in_seconds: 30 } })), diff --git a/tests/unit/routes/shared/proxy-handler-implicit-resume.test.ts b/tests/unit/routes/shared/proxy-handler-implicit-resume.test.ts index 9c6160a5..4c24a537 100644 --- a/tests/unit/routes/shared/proxy-handler-implicit-resume.test.ts +++ b/tests/unit/routes/shared/proxy-handler-implicit-resume.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { PreviousResponseWebSocketError } from "@src/proxy/codex-api.js"; import type { CodexResponsesRequest } from "@src/proxy/codex-api.js"; import { + evaluateImplicitResume, resolvePromptCacheIdentity, shouldActivateImplicitResume, shouldReplayFullInputAfterImplicitResumeError, @@ -150,4 +151,96 @@ describe("shouldActivateImplicitResume", () => { expect(shouldReplayFullInputAfterImplicitResumeError(err, true)).toBe(true); expect(shouldReplayFullInputAfterImplicitResumeError(err, false)).toBe(false); }); + + it("client 主动发自包含 full replay(function_call 与 function_call_output 都在 input 内)时返回 self_contained_replay,不报 missing_tool_calls", () => { + const result = evaluateImplicitResume({ + implicitPrevRespId: "resp_prev_stale", + continuationInputStart: 2, + inputLength: 100, + preferredEntryId: "entry_1", + acquiredEntryId: "entry_1", + currentInstructions: "system-a", + storedInstructions: "system-a", + // tool_outputs in input reference call_ids that don't exist in storage + // (proxy was restarted / session-affinity lost them), but they ARE + // present inline in the same input → self-contained replay. + requiredFunctionCallOutputIds: ["call_inlined_a", "call_inlined_b"], + storedFunctionCallIds: [], + inlineFunctionCallIds: ["call_inlined_a", "call_inlined_b"], + }); + expect(result.active).toBe(false); + expect(result.reason).toBe("self_contained_replay"); + }); + + it("混合场景:部分 call_id 在 input 内 inline、部分既不在 input 也不在 storage → 仍判 missing_tool_calls", () => { + const result = evaluateImplicitResume({ + implicitPrevRespId: "resp_prev", + continuationInputStart: 2, + inputLength: 50, + preferredEntryId: "entry_1", + acquiredEntryId: "entry_1", + currentInstructions: "system-a", + storedInstructions: "system-a", + requiredFunctionCallOutputIds: ["call_inlined", "call_truly_missing"], + storedFunctionCallIds: [], + inlineFunctionCallIds: ["call_inlined"], + }); + expect(result.active).toBe(false); + expect(result.reason).toBe("missing_tool_calls"); + }); + + it("self_contained_replay 优先于 missing_tool_calls:所有 tool_output 都能在 input 找到对应 function_call 时不应误报 missing", () => { + const result = evaluateImplicitResume({ + implicitPrevRespId: "resp_prev", + continuationInputStart: 50, + inputLength: 102, + preferredEntryId: "entry_1", + acquiredEntryId: "entry_1", + currentInstructions: "system-a", + storedInstructions: "system-a", + requiredFunctionCallOutputIds: ["call_x"], + storedFunctionCallIds: ["call_unrelated_stored"], + inlineFunctionCallIds: ["call_x", "call_y"], + }); + expect(result.reason).toBe("self_contained_replay"); + }); +}); + +describe("getInlineFunctionCallIds / isSelfContainedReplay", () => { + it("getInlineFunctionCallIds 只挑 function_call 项的 call_id,跳过 user/assistant/function_call_output", async () => { + const { getInlineFunctionCallIds } = await import("@src/routes/shared/proxy-session-helpers.js"); + const ids = getInlineFunctionCallIds([ + { role: "user", content: "hi" }, + { type: "function_call", call_id: "call_a", name: "read", arguments: "{}" }, + { type: "function_call_output", call_id: "call_a", output: "{}" }, + { role: "assistant", content: "ok" }, + { type: "function_call", call_id: "call_b", name: "write", arguments: "{}" }, + ]); + expect(ids).toEqual(["call_a", "call_b"]); + }); + + it("isSelfContainedReplay 在所有 function_call_output 都能在 input 找到 function_call 配对时返回 true", async () => { + const { isSelfContainedReplay } = await import("@src/routes/shared/proxy-session-helpers.js"); + expect(isSelfContainedReplay([ + { type: "function_call", call_id: "c1", name: "x", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output: "{}" }, + { type: "function_call", call_id: "c2", name: "y", arguments: "{}" }, + { type: "function_call_output", call_id: "c2", output: "{}" }, + ])).toBe(true); + }); + + it("isSelfContainedReplay 在 function_call_output 找不到 inline function_call 时返回 false", async () => { + const { isSelfContainedReplay } = await import("@src/routes/shared/proxy-session-helpers.js"); + expect(isSelfContainedReplay([ + { type: "function_call_output", call_id: "c_orphan", output: "{}" }, + ])).toBe(false); + }); + + it("isSelfContainedReplay 在没有 function_call_output 时返回 false(incremental turn 不是 replay)", async () => { + const { isSelfContainedReplay } = await import("@src/routes/shared/proxy-session-helpers.js"); + expect(isSelfContainedReplay([ + { role: "user", content: "hi" }, + { type: "function_call", call_id: "c1", name: "x", arguments: "{}" }, + ])).toBe(false); + }); }); diff --git a/tests/unit/routes/shared/proxy-rate-limit.test.ts b/tests/unit/routes/shared/proxy-rate-limit.test.ts index 005cfa84..bb5fea39 100644 --- a/tests/unit/routes/shared/proxy-rate-limit.test.ts +++ b/tests/unit/routes/shared/proxy-rate-limit.test.ts @@ -46,6 +46,7 @@ describe("applyParsedRateLimits", () => { plan_type: "team", rate_limit: { used_percent: 42, + remaining_percent: 58, reset_at: 1_700_000_000, limit_window_seconds: 18_000, allowed: true, @@ -53,6 +54,7 @@ describe("applyParsedRateLimits", () => { }, secondary_rate_limit: { used_percent: 18, + remaining_percent: 82, reset_at: 1_700_500_000, limit_window_seconds: 604_800, limit_reached: false, diff --git a/tests/unit/routes/shared/proxy-session-context.test.ts b/tests/unit/routes/shared/proxy-session-context.test.ts index 3bd6e552..53349a44 100644 --- a/tests/unit/routes/shared/proxy-session-context.test.ts +++ b/tests/unit/routes/shared/proxy-session-context.test.ts @@ -161,6 +161,7 @@ describe("buildProxySessionContext", () => { storedInstructions: "system", requiredFunctionCallOutputIds: ["call_a"], storedFunctionCallIds: ["call_a"], + inlineFunctionCallIds: ["call_a"], }); }); }); diff --git a/tests/unit/routes/upstream-auth-bypass.test.ts b/tests/unit/routes/upstream-auth-bypass.test.ts index b80aa5ce..bc6f5a5c 100644 --- a/tests/unit/routes/upstream-auth-bypass.test.ts +++ b/tests/unit/routes/upstream-auth-bypass.test.ts @@ -423,7 +423,7 @@ describe("upstream direct routing without Codex auth", () => { pool.destroy(); }); - it("bypasses proxy api key validation for configured direct upstream models", async () => { + it("requires proxy api key validation for chat direct upstream models", async () => { mockConfig.server.proxy_api_key = "proxy-secret"; const pool = new AccountPool(); const app = createChatRoutes(pool, undefined, undefined, { @@ -444,8 +444,35 @@ describe("upstream direct routing without Codex auth", () => { }), }); + expect(res.status).toBe(401); + expect(mockHandleDirectRequest).toHaveBeenCalledTimes(0); + pool.destroy(); + }); + + it("allows chat direct upstream models when proxy api key is valid", async () => { + mockConfig.server.proxy_api_key = "proxy-secret"; + const pool = new AccountPool(); + const adapter = createSentinelAdapter("custom-upstream"); + const app = createChatRoutes(pool, undefined, undefined, { + resolveMatch: vi.fn(() => ({ kind: "api-key", adapter, entry: { model: "deepseek-chat" } })), + hasApiKeyModel: vi.fn(() => true), + resolve: vi.fn(() => adapter), + } as never); + + const res = await app.request("/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer proxy-secret", + }, + body: JSON.stringify({ + model: "deepseek-chat", + messages: [{ role: "user", content: "hello" }], + }), + }); + expect(res.status).toBe(200); - expect(mockHandleDirectRequest).toHaveBeenCalledTimes(1); + expectDirectOptions({ adapter, model: "deepseek-chat", formatTag: "Chat" }); pool.destroy(); }); diff --git a/tests/unit/services/account-transfer-formats.test.ts b/tests/unit/services/account-transfer-formats.test.ts new file mode 100644 index 00000000..c60193de --- /dev/null +++ b/tests/unit/services/account-transfer-formats.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryPersistence } from "@helpers/account-pool-factory.js"; +import { createValidJwt } from "@helpers/jwt.js"; +import { AccountPool } from "@src/auth/account-pool.js"; +import { + buildAccountExportPayload, + parseAccountImportPayload, + parseAccountImportText, +} from "@src/services/account-transfer-formats.js"; + +function makePool(): AccountPool { + return new AccountPool({ + persistence: createMemoryPersistence(), + rotationStrategy: "least_used", + initialToken: null, + rateLimitBackoffSeconds: 300, + }); +} + +describe("account transfer formats", () => { + it("parses Cockpit Tools portable token objects", () => { + const entries = parseAccountImportPayload([ + { + access_token: "access.jwt.token", + refresh_token: "rt_portable", + email: "user@example.com", + }, + { + tokens: { + accessToken: "nested.jwt.token", + refreshToken: "rt_nested", + }, + label: "Nested", + }, + ]); + + expect(entries).toEqual([ + { token: "access.jwt.token", refreshToken: "rt_portable" }, + { token: "nested.jwt.token", refreshToken: "rt_nested", label: "Nested" }, + ]); + }); + + it("parses Sub2API OpenAI OAuth exports", () => { + const entries = parseAccountImportPayload({ + type: "sub2api-data", + version: 1, + proxies: [], + accounts: [ + { + name: "Team Alpha", + platform: "openai", + type: "oauth", + credentials: { + access_token: "sub2api.jwt.token", + refresh_token: "rt_sub2api", + }, + concurrency: 0, + priority: 0, + }, + { + name: "Ignored Anthropic", + platform: "anthropic", + type: "oauth", + credentials: { access_token: "anthropic-token" }, + }, + ], + }); + + expect(entries).toEqual([ + { + token: "sub2api.jwt.token", + refreshToken: "rt_sub2api", + label: "Team Alpha", + }, + ]); + }); + + it("parses text token lines and one-json-object-per-line input", () => { + const entries = parseAccountImportText([ + "{\"accessToken\":\"json.jwt.token\",\"refreshToken\":\"rt_json\",\"label\":\"JSON\"}", + "plain.access.token", + "rt_text_only", + ].join("\n")); + + expect(entries).toEqual([ + { token: "json.jwt.token", refreshToken: "rt_json", label: "JSON" }, + { token: "plain.access.token" }, + { refreshToken: "rt_text_only" }, + ]); + }); + + it("exports Cockpit Tools, Sub2API, and CPA payloads", () => { + const pool = makePool(); + const token = createValidJwt({ + accountId: "acct-1", + userId: "user-1", + email: "alpha@example.com", + planType: "plus", + }); + const entryId = pool.addAccount(token, "rt_alpha"); + pool.setLabel(entryId, "Alpha"); + const entries = pool.getAllEntries(); + + const cockpit = buildAccountExportPayload(entries, "cockpit_tools"); + expect(cockpit).toEqual([ + expect.objectContaining({ + access_token: token, + refresh_token: "rt_alpha", + account_id: "acct-1", + email: "alpha@example.com", + type: "codex", + }), + ]); + + const sub2api = buildAccountExportPayload(entries, "sub2api"); + expect(sub2api).toEqual(expect.objectContaining({ + type: "sub2api-data", + version: 1, + accounts: [ + expect.objectContaining({ + name: "Alpha", + platform: "openai", + type: "oauth", + credentials: expect.objectContaining({ + access_token: token, + refresh_token: "rt_alpha", + chatgpt_account_id: "acct-1", + }), + }), + ], + })); + + const cpa = buildAccountExportPayload(entries, "cpa"); + expect(cpa).toEqual(expect.objectContaining({ + access_token: token, + refresh_token: "rt_alpha", + account_id: "acct-1", + type: "codex", + })); + }); +}); diff --git a/tests/unit/translation/anthropic-to-codex.test.ts b/tests/unit/translation/anthropic-to-codex.test.ts index bf94d380..1aeb1a1e 100644 --- a/tests/unit/translation/anthropic-to-codex.test.ts +++ b/tests/unit/translation/anthropic-to-codex.test.ts @@ -106,6 +106,66 @@ describe("translateAnthropicToCodexRequest", () => { expect(result.instructions).toBe("Keep answers short."); }); + // Real Claude Code 2.1.84 emits the billing header as a standalone block[0] + // with per-request rotating cc_version + cch. Tests must prove the strip is + // invariant across that rotation, otherwise the cache-buster leaks into + // `instructions` and tanks upstream prompt cache. + it.each([ + "x-anthropic-billing-header: cc_version=2.1.84.c8e; cc_entrypoint=cli; cch=da09b;", + "x-anthropic-billing-header: cc_version=2.1.84.76b; cc_entrypoint=cli; cch=46d1d;", + "x-anthropic-billing-header: cc_version=2.1.84.f51; cc_entrypoint=cli; cch=3c1ed;", + "x-anthropic-billing-header: cc_version=2.1.84.5b4; cc_entrypoint=cli; cch=8f29c;", + "x-anthropic-billing-header: cc_version=2.1.84.4f3; cc_entrypoint=cli; cch=d1658;", + ])("strips Claude Code billing header variant: %s", (billingText) => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + system: [ + { type: "text" as const, text: billingText }, + { + type: "text" as const, + text: "You are Claude Code, Anthropic's official CLI for Claude.", + cache_control: { type: "ephemeral" }, + }, + { + type: "text" as const, + text: "\nYou are an interactive agent that helps users with software engineering tasks.", + cache_control: { type: "ephemeral" }, + }, + ], + }), + ); + expect(result.instructions).toBe( + "You are Claude Code, Anthropic's official CLI for Claude.\n\nYou are an interactive agent that helps users with software engineering tasks.", + ); + expect(result.instructions).not.toMatch(/cch=|cc_version=|x-anthropic-billing/); + }); + + it("produces identical instructions across rotating cc_version + cch values", () => { + const baseSystem = (billingText: string) => [ + { type: "text" as const, text: billingText }, + { + type: "text" as const, + text: "You are Claude Code, Anthropic's official CLI for Claude.", + cache_control: { type: "ephemeral" as const }, + }, + ]; + const a = translateAnthropicToCodexRequest( + makeRequest({ + system: baseSystem( + "x-anthropic-billing-header: cc_version=2.1.84.c8e; cc_entrypoint=cli; cch=da09b;", + ), + }), + ); + const b = translateAnthropicToCodexRequest( + makeRequest({ + system: baseSystem( + "x-anthropic-billing-header: cc_version=2.1.84.4f3; cc_entrypoint=cli; cch=d1658;", + ), + }), + ); + expect(a.instructions).toBe(b.instructions); + }); + it("falls back to default instructions when no system provided", () => { const result = translateAnthropicToCodexRequest(makeRequest()); expect(result.instructions).toBe("You are a helpful assistant."); diff --git a/tests/unit/translation/codex-to-anthropic-read-pages.test.ts b/tests/unit/translation/codex-to-anthropic-read-pages.test.ts new file mode 100644 index 00000000..5e507f9c --- /dev/null +++ b/tests/unit/translation/codex-to-anthropic-read-pages.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ExtractedEvent } from "@src/translation/codex-event-extractor.js"; +import { + createCompleted, + createCreated, + createFunctionCallDelta, + createFunctionCallDone, + createFunctionCallStart, + createInProgress, +} from "@helpers/events.js"; + +let mockEvents: ExtractedEvent[] = []; + +vi.mock("@src/translation/codex-event-extractor.js", async (importOriginal) => { + const original = await importOriginal() as Record; + return { + ...original, + iterateCodexEvents: vi.fn(async function* () { + for (const evt of mockEvents) { + yield evt; + } + }), + }; +}); + +import { collectCodexToAnthropicResponse, streamCodexToAnthropic } from "@src/translation/codex-to-anthropic.js"; +import type { CodexApi } from "@src/proxy/codex-api.js"; + +const fakeCodexApi = {} as CodexApi; +const fakeResponse = new Response(null); + +function readToolStream(args: string, deltas: string[] = []): ExtractedEvent[] { + return [ + createCreated("resp_read"), + createInProgress("resp_read"), + createFunctionCallStart("call_read", "Read"), + ...deltas.map((delta) => createFunctionCallDelta("call_read", delta)), + createFunctionCallDone("call_read", "Read", args), + createCompleted("resp_read", { input_tokens: 10, output_tokens: 5 }), + ]; +} + +async function collectStreamInput(events: ExtractedEvent[]): Promise> { + mockEvents = events; + const chunks: string[] = []; + for await (const chunk of streamCodexToAnthropic(fakeCodexApi, fakeResponse, "gpt-5.5")) { + chunks.push(chunk); + } + + const partialJson = chunks + .map((chunk) => { + const dataLine = chunk.trim().split("\n").find((line) => line.startsWith("data: ")); + if (!dataLine) return ""; + const data = JSON.parse(dataLine.slice(6)) as Record; + const delta = data.delta; + if (typeof delta !== "object" || delta === null || Array.isArray(delta)) return ""; + const partial = (delta as Record).partial_json; + return typeof partial === "string" ? partial : ""; + }) + .join(""); + + return JSON.parse(partialJson) as Record; +} + +describe("Codex to Anthropic Read.pages sanitization", () => { + it("omits empty Read.pages from streamed tool arguments", async () => { + const input = await collectStreamInput(readToolStream( + '{"file_path":"package.json","pages":""}', + )); + + expect(input).toEqual({ file_path: "package.json" }); + }); + + it("omits empty Read.pages when arguments arrive as streaming deltas", async () => { + const input = await collectStreamInput(readToolStream( + '{"file_path":"package.json","pages":""}', + ['{"file_path":"package.json"', ',"pages":""}'], + )); + + expect(input).toEqual({ file_path: "package.json" }); + }); + + it("omits empty Read.pages from collected tool input", async () => { + mockEvents = readToolStream('{"file_path":"package.json","pages":" "}'); + + const { response } = await collectCodexToAnthropicResponse(fakeCodexApi, fakeResponse, "gpt-5.5"); + const toolBlock = response.content.find((block) => block.type === "tool_use"); + + expect(toolBlock?.input).toEqual({ file_path: "package.json" }); + }); + + it("keeps non-empty Read.pages ranges", async () => { + mockEvents = readToolStream('{"file_path":"manual.pdf","pages":"1-2"}'); + + const { response } = await collectCodexToAnthropicResponse(fakeCodexApi, fakeResponse, "gpt-5.5"); + const toolBlock = response.content.find((block) => block.type === "tool_use"); + + expect(toolBlock?.input).toEqual({ file_path: "manual.pdf", pages: "1-2" }); + }); +}); diff --git a/tests/unit/translation/codex-to-anthropic.test.ts b/tests/unit/translation/codex-to-anthropic.test.ts index 5315aa9d..c1aa85e2 100644 --- a/tests/unit/translation/codex-to-anthropic.test.ts +++ b/tests/unit/translation/codex-to-anthropic.test.ts @@ -168,13 +168,16 @@ describe("collectCodexToAnthropicResponse", () => { // ── Usage details (streaming) ───────────────────────────────────────── describe("streamCodexToAnthropic — usage details", () => { - it("includes cache_read_input_tokens in message_delta when cached_tokens present", async () => { - const chunks = await collectStreamOutput(usageStream()); - const events = parseSSEEvents(chunks); - const msgDelta = events.find((e) => e.event === "message_delta"); - expect(msgDelta).toBeDefined(); - expect((msgDelta!.data.usage as Record)?.cache_read_input_tokens).toBe(30); - }); + it("includes cache_read_input_tokens in message_delta when cached_tokens present", async () => { + const chunks = await collectStreamOutput(usageStream()); + const events = parseSSEEvents(chunks); + const msgDelta = events.find((e) => e.event === "message_delta"); + expect(msgDelta).toBeDefined(); + const usage = msgDelta!.data.usage as Record; + expect(usage.input_tokens).toBe(20); + expect(usage.cache_read_input_tokens).toBe(30); + expect(usage).not.toHaveProperty("cache_creation_input_tokens"); + }); it("omits cache_read_input_tokens when not present", async () => { const chunks = await collectStreamOutput(simpleTextStream()); @@ -239,13 +242,15 @@ describe("streamCodexToAnthropic — tool_use block structure", () => { // ── Collect response details ────────────────────────────────────────── describe("collectCodexToAnthropicResponse — additional details", () => { - it("includes cache_read_input_tokens in usage", async () => { - mockEvents = usageStream(); - const { response } = await collectCodexToAnthropicResponse( - fakeCodexApi, fakeResponse, "gpt-5.4", - ); - expect(response.usage.cache_read_input_tokens).toBe(30); - }); + it("includes cache_read_input_tokens in usage", async () => { + mockEvents = usageStream(); + const { response } = await collectCodexToAnthropicResponse( + fakeCodexApi, fakeResponse, "gpt-5.4", + ); + expect(response.usage.input_tokens).toBe(20); + expect(response.usage.cache_read_input_tokens).toBe(30); + expect(response.usage).not.toHaveProperty("cache_creation_input_tokens"); + }); it("collects multiple tool_use blocks", async () => { mockEvents = multiToolCallStream(); diff --git a/tests/unit/web/account-list-quota-refresh.test.ts b/tests/unit/web/account-list-quota-refresh.test.ts new file mode 100644 index 00000000..1a13d170 --- /dev/null +++ b/tests/unit/web/account-list-quota-refresh.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; + +describe("AccountList quota refresh", () => { + it("uses the explicit quota endpoint instead of token refresh", () => { + const source = readFileSync( + resolve(__dirname, "../../../web/src/components/AccountList.tsx"), + "utf-8", + ); + + expect(source).toContain("`/auth/accounts/${encoded}/quota`"); + expect(source).toContain("console.warn"); + expect(source).not.toContain("`/auth/accounts/${encoded}/refresh`"); + }); + + it("does not request bulk fresh quota from the account list hook", () => { + const source = readFileSync( + resolve(__dirname, "../../../shared/hooks/use-accounts.ts"), + "utf-8", + ); + + expect(source).toContain("\"/auth/accounts?quota=true\""); + expect(source).not.toContain("quota=fresh"); + }); +}); diff --git a/tests/unit/web/anthropic-setup.test.ts b/tests/unit/web/anthropic-setup.test.ts new file mode 100644 index 00000000..a262107b --- /dev/null +++ b/tests/unit/web/anthropic-setup.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { + ANTHROPIC_MODEL_PRESETS, + DEFAULT_ANTHROPIC_MODELS, +} from "../../../web/src/components/AnthropicSetup"; + +describe("AnthropicSetup defaults", () => { + it("maps current Claude families to the desired Codex defaults", () => { + expect(DEFAULT_ANTHROPIC_MODELS).toEqual({ + opus: "gpt-5.5", + sonnet: "gpt-5.4", + haiku: "gpt-5.4-mini", + }); + + expect(ANTHROPIC_MODEL_PRESETS.slice(0, 2)).toEqual([ + { label: "gpt-5.5 (Opus 4.7)", value: "gpt-5.5" }, + { label: "gpt-5.4 (Sonnet 4.6)", value: "gpt-5.4" }, + ]); + }); +}); diff --git a/tests/unit/web/errors-page.test.ts b/tests/unit/web/errors-page.test.ts index c62a1e17..babb11db 100644 --- a/tests/unit/web/errors-page.test.ts +++ b/tests/unit/web/errors-page.test.ts @@ -12,4 +12,16 @@ describe("ErrorsPage", () => { expect(source).toContain("group.sample_context"); expect(source).toContain("JSON.stringify(group.sample_context, null, 2)"); }); + + it("wires a clear-all control for persisted error log entries", () => { + const source = readFileSync( + resolve(__dirname, "../../../web/src/pages/ErrorsPage.tsx"), + "utf-8", + ); + + expect(source).toContain("clearAll"); + expect(source).toContain("errorsClear"); + expect(source).toContain("aria-label={t(\"errorsClear\")}"); + expect(source).toContain("onClick={() => void clearAll()}"); + }); }); diff --git a/tests/unit/web/pool-overview-stats.test.ts b/tests/unit/web/pool-overview-stats.test.ts new file mode 100644 index 00000000..64c6855d --- /dev/null +++ b/tests/unit/web/pool-overview-stats.test.ts @@ -0,0 +1,149 @@ +// Pure-logic tests for the PoolOverview aggregation function. +// +// Imports the component module by string path because vitest's include +// pattern currently doesn't cover web .tsx files (jsdom devDep missing). +// Once the renderer environment is set up in a follow-up PR, a renderer +// test can live next to the component instead. +import { describe, it, expect } from "vitest"; +import { computePoolStats } from "../../../web/src/components/PoolOverview"; +import type { Account } from "../../../shared/types"; + +function plus(overrides: Partial = {}): Account { + return { + id: "p" + (overrides.id ?? Math.random().toString(36).slice(2, 6)), + email: overrides.email ?? "p@example.com", + status: "active", + planType: "plus", + ...overrides, + } as Account; +} + +function pro(balance: number, overrides: Partial = {}): Account { + return { + id: "pro-" + (overrides.id ?? Math.random().toString(36).slice(2, 6)), + email: overrides.email ?? "pro@example.com", + status: "active", + planType: "pro", + quota: { + credits: { + has_credits: true, + unlimited: false, + overage_limit_reached: false, + balance, + }, + }, + ...overrides, + } as Account; +} + +describe("computePoolStats", () => { + it("returns zero counts and no top-usage for an empty pool", () => { + const stats = computePoolStats([]); + expect(stats.active).toBe(0); + expect(stats.exhausted).toBe(0); + expect(stats.totalCredits).toBe(0); + expect(stats.totalUsd).toBeNull(); + expect(stats.hasAnyCredits).toBe(false); + expect(stats.topUsage).toBeNull(); + }); + + it("counts active vs quota-exhausted via derivedStatus", () => { + const stats = computePoolStats([ + plus({ id: "a", status: "active" }), + plus({ + id: "b", + status: "active", + quota: { + secondary_rate_limit: { + used_percent: 100, + limit_reached: true, + reset_at: Math.floor(Date.now() / 1000) + 3600, + }, + }, + }), + plus({ id: "c", status: "disabled" }), + ]); + expect(stats.active).toBe(1); + expect(stats.exhausted).toBe(1); + }); + + it("sums credit balance across accounts with has_credits=true (skips Plus)", () => { + const stats = computePoolStats([ + plus(), // Plus — ignored + pro(247.5, { id: "p1" }), + pro(100, { id: "p2" }), + ]); + expect(stats.hasAnyCredits).toBe(true); + expect(stats.totalCredits).toBe(347.5); + // 347.5 credits / 25 USD = $13.9 + expect(stats.totalUsd).toBeCloseTo(13.9); + }); + + it("uses the configured credits-per-USD conversion rate", () => { + const stats = computePoolStats([pro(120)], 40); + expect(stats.totalUsd).toBeCloseTo(3); + }); + + it("suppresses USD totals when the configured conversion rate is 0", () => { + const stats = computePoolStats([pro(120)], 0); + expect(stats.hasAnyCredits).toBe(true); + expect(stats.totalCredits).toBe(120); + expect(stats.totalUsd).toBeNull(); + }); + + it("treats unlimited accounts as 'has credits' but excludes their balance from the sum", () => { + const stats = computePoolStats([ + pro(50, { id: "p1" }), + pro(0, { + id: "p2", + quota: { + credits: { has_credits: true, unlimited: true, overage_limit_reached: false, balance: 999 }, + }, + }), + ]); + expect(stats.hasAnyCredits).toBe(true); + expect(stats.totalCredits).toBe(50); + }); + + it("hasAnyCredits stays false when only Plus accounts (has_credits=false) are present", () => { + const stats = computePoolStats([plus({ id: "a" }), plus({ id: "b" })]); + expect(stats.hasAnyCredits).toBe(false); + expect(stats.totalUsd).toBeNull(); + }); + + it("picks the account with highest secondary used_percent for topUsage", () => { + const accounts = [ + plus({ + id: "low", + email: "low@x.com", + quota: { secondary_rate_limit: { used_percent: 30, limit_reached: false, reset_at: 1700000000 } }, + }), + plus({ + id: "high", + email: "high@x.com", + quota: { secondary_rate_limit: { used_percent: 92, limit_reached: false, reset_at: 1700050000 } }, + }), + plus({ + id: "mid", + email: "mid@x.com", + quota: { secondary_rate_limit: { used_percent: 65, limit_reached: false, reset_at: 1700020000 } }, + }), + ]; + const stats = computePoolStats(accounts); + expect(stats.topUsage).not.toBeNull(); + expect(stats.topUsage!.account.email).toBe("high@x.com"); + expect(stats.topUsage!.pct).toBe(92); + expect(stats.topUsage!.resetAt).toBe(1700050000); + }); + + it("treats limit_reached as 100% for topUsage even when used_percent is null", () => { + const stats = computePoolStats([ + plus({ + id: "capped", + email: "capped@x.com", + quota: { secondary_rate_limit: { limit_reached: true, used_percent: null, reset_at: 1700000000 } }, + }), + ]); + expect(stats.topUsage?.pct).toBe(100); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index e416eedd..2c3582b0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { Header } from "./components/Header"; import { UpdateModal } from "./components/UpdateModal"; import { AddAccount } from "./components/AddAccount"; import { AccountList } from "./components/AccountList"; +import { PoolOverview } from "./components/PoolOverview"; import { SettingsTab } from "./components/SettingsTab"; import { ProxyPool } from "./components/ProxyPool"; import { Footer } from "./components/Footer"; @@ -23,6 +24,7 @@ import { useStatus } from "../../shared/hooks/use-status"; import { useUpdateStatus } from "../../shared/hooks/use-update-status"; import { useI18n, useT } from "../../shared/i18n/context"; import { useDashboardAuth } from "../../shared/hooks/use-dashboard-auth"; +import { useGeneralSettings } from "../../shared/hooks/use-general-settings"; import type { TranslationKey } from "../../shared/i18n/translations"; import { getShowUpdateDialogPreference, shouldAutoOpenUpdateModal } from "./update-modal-policy"; @@ -101,6 +103,7 @@ function Dashboard() { const accounts = useAccounts(); const proxies = useProxies(); const status = useStatus(accounts.list.length); + const generalSettings = useGeneralSettings(null); const update = useUpdateMessage(); const { onLogout } = useDashboardAuthCtx(); const [showModal, setShowModal] = useState(false); @@ -162,6 +165,10 @@ function Dashboard() { {activeTab === "" && (
+ limitLabel(a).localeCompare(limitLabel(b))); + // Credits — only render for accounts that actually carry a credit pool + // (Pro / PAYG / Team with explicit balance). Plus accounts have + // has_credits=false and a "0" balance that conveys no useful info. + const creditsInfo = q?.credits; + const showCredits = !!creditsInfo && (creditsInfo.has_credits || creditsInfo.unlimited); + const creditUsd = showCredits && !creditsInfo!.unlimited + ? creditsToUsd(creditsInfo!.balance, DEFAULT_CREDITS_PER_USD) + : null; + const [quotaRefreshing, setQuotaRefreshing] = useState(false); const handleRefreshQuota = useCallback(async () => { @@ -465,6 +485,30 @@ export function AccountCard({ account, index, onDelete, proxies, onProxyChange,
)} + {/* Credit balance (Pro / PAYG / Team — accounts with has_credits=true). */} + {showCredits && ( +
+ + {t("creditsBalance")} + + {creditsInfo!.unlimited ? ( + {t("creditsUnlimited")} + ) : ( + + {formatCredits(creditsInfo!.balance)} + {creditUsd != null && ( + + ({formatUsd(creditUsd)}) + + )} + {creditsInfo!.overage_limit_reached && ( + · {t("creditsOverageReached")} + )} + + )} +
+ )} + {/* Review quota window */} {rrl && (
diff --git a/web/src/components/AccountImportExport.test.tsx b/web/src/components/AccountImportExport.test.tsx new file mode 100644 index 00000000..93b94cab --- /dev/null +++ b/web/src/components/AccountImportExport.test.tsx @@ -0,0 +1,45 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/preact"; + +const mockI18n = vi.hoisted(() => ({ + useT: vi.fn(), +})); + +vi.mock("../../../shared/i18n/context", () => ({ + useT: () => mockI18n.useT(), +})); + +import { AccountImportExport } from "./AccountImportExport"; + +describe("AccountImportExport", () => { + beforeEach(() => { + mockI18n.useT.mockReturnValue((key: string) => key); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("exports with the selected compatibility format", async () => { + const onExport = vi.fn(async () => undefined); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("exportFormat"), { + target: { value: "sub2api" }, + }); + fireEvent.click(screen.getByTitle("exportBtn (1)")); + + await waitFor(() => { + expect(onExport).toHaveBeenCalledWith(["acct-1"], "sub2api"); + }); + }); +}); diff --git a/web/src/components/AccountImportExport.tsx b/web/src/components/AccountImportExport.tsx index e54edd7c..ecd5f686 100644 --- a/web/src/components/AccountImportExport.tsx +++ b/web/src/components/AccountImportExport.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useRef } from "preact/hooks"; import { useT } from "../../../shared/i18n/context"; +import type { AccountExportFormat } from "../../../shared/account-transfer-client"; interface ImportResult { success: boolean; @@ -10,7 +11,7 @@ interface ImportResult { } interface AccountImportExportProps { - onExport: (selectedIds?: string[], format?: "full" | "minimal") => Promise; + onExport: (selectedIds?: string[], format?: AccountExportFormat) => Promise; onImport: (file: File) => Promise; selectedIds: Set; } @@ -20,15 +21,16 @@ export function AccountImportExport({ onExport, onImport, selectedIds }: Account const fileRef = useRef(null); const [importing, setImporting] = useState(false); const [result, setResult] = useState(null); + const [exportFormat, setExportFormat] = useState("full"); - const handleExport = useCallback(async (format?: "full" | "minimal") => { + const handleExport = useCallback(async () => { try { const ids = selectedIds.size > 0 ? [...selectedIds] : undefined; - await onExport(ids, format); + await onExport(ids, exportFormat); } catch (err) { console.error("[AccountExport] failed:", err); } - }, [onExport, selectedIds]); + }, [exportFormat, onExport, selectedIds]); const handleFileChange = useCallback(async () => { const files = fileRef.current?.files; @@ -89,7 +91,7 @@ export function AccountImportExport({ onExport, onImport, selectedIds }: Account + - {selectedIds.size > 0 && (
) : ( displayAccounts.slice(0, visibleCount).map((acct, i) => ( - { await fetch(`/auth/accounts/${encodeURIComponent(id)}/refresh`, { method: "POST" }); onRefresh(); }} onToggleStatus={onToggleStatus} onUpdateLabel={onUpdateLabel} /> + { + const encoded = encodeURIComponent(id); + const resp = await fetch(`/auth/accounts/${encoded}/quota`); + if (!resp.ok) { + console.warn(`[AccountList] Failed to refresh quota for account ${id}: ${resp.status}`); + } + onRefresh(); + }} onToggleStatus={onToggleStatus} onUpdateLabel={onUpdateLabel} /> )) )} diff --git a/web/src/components/AnthropicSetup.tsx b/web/src/components/AnthropicSetup.tsx index c1acbdee..1d44e644 100644 --- a/web/src/components/AnthropicSetup.tsx +++ b/web/src/components/AnthropicSetup.tsx @@ -9,9 +9,15 @@ interface AnthropicSetupProps { serviceTier: string | null; } -const PRESETS: Array<{ label: string; value: string }> = [ - { label: "gpt-5.4 (Opus)", value: "gpt-5.4" }, - { label: "gpt-5.3-codex (Sonnet)", value: "gpt-5.3-codex" }, +export const DEFAULT_ANTHROPIC_MODELS = { + opus: "gpt-5.5", + sonnet: "gpt-5.4", + haiku: "gpt-5.4-mini", +}; + +export const ANTHROPIC_MODEL_PRESETS: Array<{ label: string; value: string }> = [ + { label: "gpt-5.5 (Opus 4.7)", value: DEFAULT_ANTHROPIC_MODELS.opus }, + { label: "gpt-5.4 (Sonnet 4.6)", value: DEFAULT_ANTHROPIC_MODELS.sonnet }, { label: "gpt-5.4-mini (Haiku)", value: "gpt-5.4-mini" }, ]; @@ -19,9 +25,9 @@ export function AnthropicSetup({ apiKey, selectedModel, reasoningEffort, service const t = useT(); const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:8080"; - const [opusModel, setOpusModel] = useState("gpt-5.4"); - const [sonnetModel, setSonnetModel] = useState("gpt-5.3-codex"); - const [haikuModel, setHaikuModel] = useState("gpt-5.4-mini"); + const [opusModel, setOpusModel] = useState(DEFAULT_ANTHROPIC_MODELS.opus); + const [sonnetModel, setSonnetModel] = useState(DEFAULT_ANTHROPIC_MODELS.sonnet); + const [haikuModel, setHaikuModel] = useState(DEFAULT_ANTHROPIC_MODELS.haiku); // Custom model from ApiConfig const customModel = useMemo(() => { @@ -40,7 +46,7 @@ export function AnthropicSetup({ apiKey, selectedModel, reasoningEffort, service .catch(() => {}); }, []); - const presetValues = new Set(PRESETS.map((p) => p.value)); + const presetValues = new Set(ANTHROPIC_MODEL_PRESETS.map((p) => p.value)); const extraModels = allModels.filter((id) => !presetValues.has(id)); const envText = useMemo(() => [ @@ -59,7 +65,7 @@ export function AnthropicSetup({ apiKey, selectedModel, reasoningEffort, service const modelDropdown = (value: string, onChange: (v: string) => void) => ( setManualModelsInput((e.target as HTMLInputElement).value)} + placeholder="manual-model-1, manual-model-2" + class="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-border-dark bg-slate-50 dark:bg-bg-dark text-slate-800 dark:text-text-main" + /> + )} + + +
+ +
+ {CAPABILITY_OPTIONS.map((option) => ( + + ))} +
{isCustom && ( @@ -314,6 +366,10 @@ function KeyRow({ entry, onDelete, onToggle }: { )} + + {entry.capabilities.join(", ")} + + diff --git a/web/src/components/PoolOverview.tsx b/web/src/components/PoolOverview.tsx new file mode 100644 index 00000000..4512562d --- /dev/null +++ b/web/src/components/PoolOverview.tsx @@ -0,0 +1,116 @@ +import { useMemo } from "preact/hooks"; +import { useT, useI18n } from "../../../shared/i18n/context"; +import { creditsToUsd, formatCredits, formatResetTime, formatUsd } from "../../../shared/utils/format"; +import type { Account } from "../../../shared/types"; +import { derivedStatus } from "../lib/accountStatus"; + +/** Default credit→USD rate matching the config schema default. */ +const DEFAULT_CREDITS_PER_USD = 25; + +interface PoolOverviewProps { + accounts: Account[]; + creditsPerUsd?: number; +} + +export interface PoolStats { + active: number; + exhausted: number; + totalCredits: number; + totalUsd: number | null; + hasAnyCredits: boolean; + topUsage: { account: Account; pct: number; resetAt: number | null } | null; +} + +export function computePoolStats(accounts: Account[], creditsPerUsd = DEFAULT_CREDITS_PER_USD): PoolStats { + let active = 0; + let exhausted = 0; + let totalCredits = 0; + let hasAnyCredits = false; + let topUsage: PoolStats["topUsage"] = null; + + for (const account of accounts) { + const status = derivedStatus(account); + if (status === "active") active += 1; + if (status === "quota_exhausted" || status === "rate_limited") exhausted += 1; + + const credits = account.quota?.credits; + if (credits?.has_credits || credits?.unlimited) { + hasAnyCredits = true; + if (!credits.unlimited && Number.isFinite(credits.balance)) { + totalCredits += credits.balance; + } + } + + const srl = account.quota?.secondary_rate_limit; + const pct = srl?.limit_reached + ? 100 + : srl?.used_percent != null + ? Math.round(srl.used_percent) + : null; + if (pct != null && (topUsage == null || pct > topUsage.pct)) { + topUsage = { account, pct, resetAt: srl?.reset_at ?? null }; + } + } + + const totalUsd = hasAnyCredits ? creditsToUsd(totalCredits, creditsPerUsd) : null; + return { active, exhausted, totalCredits, totalUsd, hasAnyCredits, topUsage }; +} + +export function PoolOverview({ accounts, creditsPerUsd = DEFAULT_CREDITS_PER_USD }: PoolOverviewProps) { + const t = useT(); + const { lang } = useI18n(); + const stats = useMemo(() => computePoolStats(accounts, creditsPerUsd), [accounts, creditsPerUsd]); + + if (accounts.length === 0) return null; + + return ( +
+
+

{t("poolOverview")}

+ + {accounts.length} + +
+ +
+
+
{t("poolActiveAccounts")}
+
{stats.active}
+
+
+
{t("poolExhaustedAccounts")}
+
0 ? "text-amber-600 dark:text-amber-500" : "text-slate-400 dark:text-text-dim"}`}> + {stats.exhausted} +
+
+ {stats.hasAnyCredits && ( +
+
{t("poolTotalCredits")}
+
+ {formatCredits(stats.totalCredits)} + {stats.totalUsd != null && ( + + ({formatUsd(stats.totalUsd)}) + + )} +
+
+ )} + {stats.topUsage && ( +
+
{t("poolTopUsage")}
+
+ {stats.topUsage.account.email || stats.topUsage.account.id} +
+
+ {stats.topUsage.pct}%{stats.topUsage.resetAt ? ` · ${formatResetTime(stats.topUsage.resetAt, lang === "zh")}` : ""} +
+
+ )} +
+
+ ); +} diff --git a/web/src/pages/ErrorsPage.tsx b/web/src/pages/ErrorsPage.tsx index 25d7da28..b06b2d51 100644 --- a/web/src/pages/ErrorsPage.tsx +++ b/web/src/pages/ErrorsPage.tsx @@ -86,7 +86,7 @@ function ErrorRow({ group }: { group: ErrorGroup }) { export function ErrorsPage() { const t = useT(); - const { groups, count, loading, error, refresh, markAllSeen } = useErrorLogs(); + const { groups, count, loading, error, refresh, markAllSeen, clearAll } = useErrorLogs(); return (
@@ -114,6 +114,22 @@ export function ErrorsPage() { {t("errorsMarkSeen")} ({count.unread}) )} + {groups.length > 0 && ( + + )}